mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
fix(mobile): route staging communities through hosted setup
Keep community domains bound to their trusted account environment and reject mismatched realms before setup or admission. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import 'shared/hosted/builderlab_account.dart';
|
||||
import 'shared/hosted/hosted_admission.dart';
|
||||
import 'shared/hosted/hosted_flow.dart';
|
||||
import 'shared/hosted/hosted_identity_service.dart';
|
||||
import 'shared/hosted/hosted_environment.dart';
|
||||
|
||||
// Set by a community-entry surface while it owns setup presentation.
|
||||
final hostedInlineEntryProvider = NotifierProvider<HostedInlineEntry, bool>(
|
||||
@@ -134,11 +135,7 @@ class HostedAppGate extends HookConsumerWidget {
|
||||
return true;
|
||||
}
|
||||
final host = Uri.parse(relay).host.toLowerCase();
|
||||
final reserved =
|
||||
host == 'buzz.xyz' ||
|
||||
host.endsWith('.buzz.xyz') ||
|
||||
host == 'builderlab.xyz' ||
|
||||
host.endsWith('.builderlab.xyz');
|
||||
final reserved = HostedEnvironment.isReservedHost(host);
|
||||
if (!reserved && link is AddCommunityDeepLink) {
|
||||
if (routeContext.mounted) {
|
||||
await showDialog<void>(
|
||||
@@ -161,14 +158,14 @@ class HostedAppGate extends HookConsumerWidget {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (reserved && !host.endsWith('.communities.buzz.xyz')) {
|
||||
if (reserved && !HostedEnvironment.supportsRelay(Uri.parse(relay))) {
|
||||
if (routeContext.mounted) {
|
||||
await showDialog<void>(
|
||||
context: routeContext,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Community setup unavailable'),
|
||||
content: const Text(
|
||||
'This community does not support account setup in this build yet.',
|
||||
'This community needs a different or unsupported Builderlab environment. Use an app build configured for this community.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -185,11 +182,7 @@ class HostedAppGate extends HookConsumerWidget {
|
||||
if (link is InviteDeepLink) {
|
||||
final host = Uri.parse(link.relayUrl).host;
|
||||
// Reserved hosted domains must never fall into legacy key generation.
|
||||
final reserved =
|
||||
host == 'buzz.xyz' ||
|
||||
host.endsWith('.buzz.xyz') ||
|
||||
host == 'builderlab.xyz' ||
|
||||
host.endsWith('.builderlab.xyz');
|
||||
final reserved = HostedEnvironment.isReservedHost(host);
|
||||
if (!reserved) return false;
|
||||
}
|
||||
await flow.start(link);
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../relay/relay_session.dart';
|
||||
import '../relay/relay_socket.dart';
|
||||
import 'account_identity_store.dart';
|
||||
import 'builderlab_account.dart';
|
||||
import 'hosted_environment.dart';
|
||||
|
||||
/// Builds a community only after scoped backup evidence and current binding.
|
||||
/// The caller persists it through the existing community transition owner.
|
||||
@@ -49,7 +50,7 @@ class HostedAdmission {
|
||||
uri.hasQuery ||
|
||||
uri.hasFragment ||
|
||||
(uri.path.isNotEmpty && uri.path != '/') ||
|
||||
!uri.host.endsWith('.communities.buzz.xyz') ||
|
||||
!HostedEnvironment.supportsRelay(uri) ||
|
||||
(uri.hasPort && uri.port != 443)) {
|
||||
throw AccountFailure.denied;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,40 @@
|
||||
abstract final class HostedEnvironment {
|
||||
static final Uri origin = _origin();
|
||||
|
||||
/// Reserved domains cannot fall back to Nostr-only onboarding, even when
|
||||
/// this build does not support their account environment.
|
||||
static bool isReservedHost(String host) {
|
||||
final normalized = host.toLowerCase();
|
||||
return [
|
||||
'buzz.xyz',
|
||||
'builderlab.xyz',
|
||||
'builderlabstaging.xyz',
|
||||
].any((domain) => normalized == domain || normalized.endsWith('.$domain'));
|
||||
}
|
||||
|
||||
/// Explicit community-to-account mapping. Never infer an account endpoint
|
||||
/// from arbitrary relay metadata or send this build's session across realms.
|
||||
static Uri? accountOriginFor(Uri relay) {
|
||||
if (!['wss', 'https'].contains(relay.scheme) ||
|
||||
relay.userInfo.isNotEmpty ||
|
||||
relay.hasQuery ||
|
||||
relay.hasFragment ||
|
||||
(relay.path.isNotEmpty && relay.path != '/') ||
|
||||
(relay.hasPort && relay.port != 443)) {
|
||||
return null;
|
||||
}
|
||||
final host = relay.host.toLowerCase();
|
||||
if (host.endsWith('.communities.buzz.xyz')) {
|
||||
return Uri.parse('https://app.builderlab.xyz');
|
||||
}
|
||||
if (host.endsWith('.communities.public.builderlabstaging.xyz')) {
|
||||
return Uri.parse('https://app.builderlabstaging.xyz');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool supportsRelay(Uri relay) => accountOriginFor(relay) == origin;
|
||||
|
||||
static Uri _origin() {
|
||||
const configured = String.fromEnvironment(
|
||||
'BUZZ_BUILDERLAB_URL',
|
||||
|
||||
@@ -34,6 +34,7 @@ enum HostedFlowProblem {
|
||||
identityUnavailable,
|
||||
backupRequired,
|
||||
bindingRequired,
|
||||
unsupportedEnvironment,
|
||||
}
|
||||
|
||||
/// Resolves only after relay admission is proven and local persistence completes.
|
||||
@@ -195,6 +196,7 @@ class HostedFlow extends ChangeNotifier {
|
||||
_set(HostedFlowStage.working);
|
||||
return _enqueue(() async {
|
||||
if (generation != _generation) return;
|
||||
if (!_acceptTarget(target)) return;
|
||||
final restored = await account.restore();
|
||||
if (generation != _generation) return;
|
||||
if (restored == null) {
|
||||
@@ -224,6 +226,7 @@ class HostedFlow extends ChangeNotifier {
|
||||
final target = _target;
|
||||
final session = account.account;
|
||||
if (target == null || session == null) throw AccountFailure.signedOut;
|
||||
if (!_acceptTarget(target)) return;
|
||||
var resolved = await identity.resolve();
|
||||
if (target is! NostrBindDeepLink) {
|
||||
for (var step = 0; step < 2; step++) {
|
||||
@@ -321,6 +324,19 @@ class HostedFlow extends ChangeNotifier {
|
||||
_set(HostedFlowStage.complete);
|
||||
}
|
||||
|
||||
bool _acceptTarget(BuzzDeepLink target) {
|
||||
final relay = switch (target) {
|
||||
InviteDeepLink(:final relayUrl) => relayUrl,
|
||||
AddCommunityDeepLink(:final relayUrl) => relayUrl,
|
||||
_ => null,
|
||||
};
|
||||
if (relay != null && !HostedEnvironment.supportsRelay(Uri.parse(relay))) {
|
||||
_set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedEnvironment);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<String> _verifyBinding({
|
||||
required String subject,
|
||||
required String challengeId,
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'hosted_flow.dart';
|
||||
String hostedFlowProblemMessage(
|
||||
HostedFlowProblem? problem,
|
||||
) => switch (problem) {
|
||||
HostedFlowProblem.unsupportedEnvironment =>
|
||||
'This community needs a different or unsupported Builderlab environment. Use an app build configured for this community.',
|
||||
HostedFlowProblem.denied => 'This account does not have access.',
|
||||
HostedFlowProblem.differentAccount =>
|
||||
'Your browser signed in to a different account. Sign in to your original account on the web, then reopen setup. Your saved account and keys have been kept.',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:buzz/features/channels/deep_link_dispatcher.dart';
|
||||
import 'package:buzz/hosted_app_gate.dart';
|
||||
import 'package:buzz/shared/deeplink/deep_link.dart';
|
||||
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
|
||||
import 'package:buzz/shared/hosted/account_identity_store.dart';
|
||||
import 'package:buzz/shared/hosted/browser_auth.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_flow.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_identity_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class _Flow extends HostedFlow {
|
||||
_Flow(BuilderlabAccountClient account, AccountIdentityStore store)
|
||||
: super(
|
||||
account: account,
|
||||
store: store,
|
||||
browser: BrowserAuth(),
|
||||
identity: HostedIdentityService(account: account, store: store),
|
||||
enterCommunity: (_, _, _, _) async {},
|
||||
);
|
||||
BuzzDeepLink? started;
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
@override
|
||||
Future<void> start(BuzzDeepLink target) async {
|
||||
started = target;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('app dispatch keeps staging invites out of legacy generation', (
|
||||
tester,
|
||||
) async {
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
PendingDeepLinkNotifier.debugUriStreamOverride = const Stream<Uri>.empty();
|
||||
final account = BuilderlabAccountClient();
|
||||
final flow = _Flow(account, AccountIdentityStore());
|
||||
addTearDown(() async {
|
||||
PendingDeepLinkNotifier.debugUriStreamOverride = null;
|
||||
flow.dispose();
|
||||
await flow.browser.dispose();
|
||||
account.dispose();
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [hostedFlowProvider.overrideWithValue(flow)],
|
||||
child: const MaterialApp(
|
||||
home: HostedAppGate(
|
||||
authenticated: false,
|
||||
child: Scaffold(body: Text('entry')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final dispatcher = tester.widget<DeepLinkDispatcher>(
|
||||
find.byType(DeepLinkDispatcher),
|
||||
);
|
||||
final context = tester.element(find.byType(DeepLinkDispatcher));
|
||||
const staging = InviteDeepLink(
|
||||
relayUrl: 'wss://team.communities.public.builderlabstaging.xyz',
|
||||
code: 'fixture',
|
||||
);
|
||||
final pending = dispatcher.onboardingHandler!(context, staging);
|
||||
await tester.pumpAndSettle();
|
||||
if (HostedEnvironment.origin.host == 'app.builderlabstaging.xyz') {
|
||||
expect(await pending, isTrue);
|
||||
expect(flow.started, staging);
|
||||
} else {
|
||||
expect(find.text('Community setup unavailable'), findsOneWidget);
|
||||
await tester.tap(find.text('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await pending, isTrue);
|
||||
expect(flow.started, isNull);
|
||||
}
|
||||
expect(
|
||||
await dispatcher.onboardingHandler!(
|
||||
context,
|
||||
const InviteDeepLink(
|
||||
relayUrl: 'wss://community.example.org',
|
||||
code: 'fixture',
|
||||
),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
117
mobile/test/shared/hosted/hosted_environment_flow_test.dart
Normal file
117
mobile/test/shared/hosted/hosted_environment_flow_test.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:buzz/shared/deeplink/deep_link.dart';
|
||||
import 'package:buzz/shared/hosted/account_identity_store.dart';
|
||||
import 'package:buzz/shared/hosted/browser_auth.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_admission.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_flow.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_identity_service.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
class _Account extends BuilderlabAccountClient {
|
||||
_Account(this.pubkey);
|
||||
final String pubkey;
|
||||
var reads = 0;
|
||||
@override
|
||||
BuilderlabAccount get account =>
|
||||
BuilderlabAccount(subject: 'owner', expiresAt: DateTime.utc(2100));
|
||||
@override
|
||||
Future<BuilderlabAccount?> restore() async {
|
||||
reads++;
|
||||
return account;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> currentIdentity() async {
|
||||
reads++;
|
||||
return {
|
||||
'identity': {'pubkey_hex': pubkey},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
|
||||
test(
|
||||
'real flow and admission use only the configured community realm',
|
||||
() async {
|
||||
final staging =
|
||||
HostedEnvironment.origin.host == 'app.builderlabstaging.xyz';
|
||||
final matching = staging
|
||||
? 'team.communities.public.builderlabstaging.xyz'
|
||||
: 'team.communities.buzz.xyz';
|
||||
final opposite = staging
|
||||
? 'team.communities.buzz.xyz'
|
||||
: 'team.communities.public.builderlabstaging.xyz';
|
||||
final keys = nostr.Keys('1'.padLeft(64, '0'));
|
||||
final account = _Account(keys.public);
|
||||
final store = AccountIdentityStore();
|
||||
await store.create(keys.nsec);
|
||||
await store.confirmBackup(
|
||||
BackupReceipt(
|
||||
backend: HostedEnvironment.origin.origin,
|
||||
subject: 'owner',
|
||||
pubkey: keys.public,
|
||||
backupId: 'receipt',
|
||||
),
|
||||
);
|
||||
final claims = <Uri>[];
|
||||
final admission = HostedAdmission(
|
||||
account: account,
|
||||
store: store,
|
||||
probe: (_) async {},
|
||||
client: MockClient((request) async {
|
||||
claims.add(request.url);
|
||||
return http.Response('{}', 200);
|
||||
}),
|
||||
);
|
||||
final browser = BrowserAuth(
|
||||
openBrowser: (_) async => fail('Saved account must not open browser'),
|
||||
);
|
||||
var entered = 0;
|
||||
final flow = HostedFlow(
|
||||
account: account,
|
||||
browser: browser,
|
||||
store: store,
|
||||
identity: HostedIdentityService(account: account, store: store),
|
||||
enterCommunity: (link, subject, pubkey, isCurrent) async {
|
||||
await admission.prepare(link, subject, pubkey, isCurrent: isCurrent);
|
||||
entered++;
|
||||
},
|
||||
);
|
||||
addTearDown(() async {
|
||||
flow.dispose();
|
||||
await browser.dispose();
|
||||
account.dispose();
|
||||
admission.dispose();
|
||||
});
|
||||
await flow.start(
|
||||
InviteDeepLink(relayUrl: 'wss://$opposite', code: 'test'),
|
||||
);
|
||||
expect(flow.problem, HostedFlowProblem.unsupportedEnvironment);
|
||||
expect(account.reads, 0);
|
||||
expect(claims, isEmpty);
|
||||
// Admission independently enforces the boundary, even without the UI/flow.
|
||||
await expectLater(
|
||||
admission.prepare(
|
||||
InviteDeepLink(relayUrl: 'wss://$opposite', code: 'test'),
|
||||
'owner',
|
||||
keys.public,
|
||||
),
|
||||
throwsA(AccountFailure.denied),
|
||||
);
|
||||
expect(account.reads, 0);
|
||||
await flow.start(
|
||||
InviteDeepLink(relayUrl: 'wss://$matching', code: 'test'),
|
||||
);
|
||||
expect(flow.stage, HostedFlowStage.complete);
|
||||
expect(entered, 1);
|
||||
expect(claims.single.host, matching);
|
||||
},
|
||||
);
|
||||
}
|
||||
51
mobile/test/shared/hosted/hosted_environment_test.dart
Normal file
51
mobile/test/shared/hosted/hosted_environment_test.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('community realms map only to their trusted account origin', () {
|
||||
expect(
|
||||
HostedEnvironment.accountOriginFor(
|
||||
Uri.parse('wss://team.communities.buzz.xyz'),
|
||||
),
|
||||
Uri.parse('https://app.builderlab.xyz'),
|
||||
);
|
||||
expect(
|
||||
HostedEnvironment.accountOriginFor(
|
||||
Uri.parse('wss://team.communities.public.builderlabstaging.xyz'),
|
||||
),
|
||||
Uri.parse('https://app.builderlabstaging.xyz'),
|
||||
);
|
||||
for (final value in [
|
||||
'wss://team.communities.public.builderlabstaging.xyz.attacker.test',
|
||||
'wss://communities.public.builderlabstaging.xyz',
|
||||
'wss://team.communities.public.builderlabstaging.xyz.',
|
||||
'wss://team.communities.public.builderlabstaging.xyz:8443',
|
||||
'ws://team.communities.public.builderlabstaging.xyz',
|
||||
'wss://user@team.communities.public.builderlabstaging.xyz',
|
||||
'wss://team.communities.public.builderlabstaging.xyz/path',
|
||||
'wss://team.communities.public.builderlabstaging.xyz?account=other',
|
||||
'wss://team.communities.public.builderlabstaging.xyz#fragment',
|
||||
'wss://team.builderlabstaging.xyz',
|
||||
]) {
|
||||
expect(
|
||||
HostedEnvironment.accountOriginFor(Uri.parse(value)),
|
||||
isNull,
|
||||
reason: value,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('reserved unsupported hosts never become self-hosted', () {
|
||||
for (final host in [
|
||||
'buzz.xyz',
|
||||
'app.builderlab.xyz',
|
||||
'TEAM.builderlabstaging.xyz',
|
||||
]) {
|
||||
expect(HostedEnvironment.isReservedHost(host), isTrue);
|
||||
}
|
||||
expect(
|
||||
HostedEnvironment.isReservedHost('builderlabstaging.xyz.attacker.test'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user