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:
Wes
2026-10-15 15:07:32 -07:00
parent 782c5de598
commit 5c9122c6f1
8 changed files with 318 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.',

View File

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

View 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);
},
);
}

View 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,
);
});
}