From 625dfe0d085a21ccada41f6e65939764f757a55c Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 26 Oct 2026 20:50:17 -0700 Subject: [PATCH] fix(mobile): preserve invite onboarding and offer identity setup Forward iOS cold-start invite URLs, offer explicit identity creation or matching-key import, and discover account-saved communities through the supported API. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes --- mobile/ios/Runner/SceneDelegate.swift | 17 ++++ .../onboarding/hosted_connect_sheet.dart | 89 +++++++++++++++++++ mobile/lib/hosted_app_gate.dart | 1 + mobile/lib/onboarding_app_entry.dart | 1 + .../lib/shared/hosted/builderlab_account.dart | 12 +++ .../lib/shared/hosted/hosted_communities.dart | 36 ++++++++ mobile/lib/shared/hosted/hosted_flow.dart | 53 ++++++++++- .../hosted/hosted_identity_service.dart | 42 ++++++++- .../onboarding/hosted_communities_test.dart | 21 +++-- .../onboarding/hosted_journeys_test.dart | 86 ++++++++++++++++-- .../helpers/hosted_communities_fixture.dart | 20 +++-- .../test/helpers/hosted_journey_fixture.dart | 19 ++++ .../hosted/hosted_communities_flow_test.dart | 15 ++-- .../hosted/hosted_environment_flow_test.dart | 11 +++ .../hosted_multi_backup_recovery_test.dart | 6 +- 15 files changed, 401 insertions(+), 28 deletions(-) diff --git a/mobile/ios/Runner/SceneDelegate.swift b/mobile/ios/Runner/SceneDelegate.swift index b9ce8ea2b2..642fbb59ba 100644 --- a/mobile/ios/Runner/SceneDelegate.swift +++ b/mobile/ios/Runner/SceneDelegate.swift @@ -1,6 +1,23 @@ import Flutter import UIKit +import app_links class SceneDelegate: FlutterSceneDelegate { + override func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + super.scene(scene, willConnectTo: session, options: connectionOptions) + // app_links 6 uses UIApplicationDelegate callbacks. With UIScene, launch + // URLs arrive here instead; warm links still use Flutter's forwarding. + // Save the launch URL in the plugin before Dart subscribes to its stream. + if let url = connectionOptions.urlContexts.first?.url { + AppLinks.shared.handleLink(url: url) + } else if let url = connectionOptions.userActivities + .first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb })?.webpageURL { + AppLinks.shared.handleLink(url: url) + } + } } diff --git a/mobile/lib/features/onboarding/hosted_connect_sheet.dart b/mobile/lib/features/onboarding/hosted_connect_sheet.dart index 9e0f1bb965..700e8e5a31 100644 --- a/mobile/lib/features/onboarding/hosted_connect_sheet.dart +++ b/mobile/lib/features/onboarding/hosted_connect_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/identity_transfer/nsec_import_sheet.dart'; import '../../shared/hosted/hosted_flow_problem_message.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/theme/grid.dart'; @@ -19,6 +20,19 @@ class HostedConnectSheet extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { useListenable(flow); + final showLinkedIdentity = useState(false); + Future importIdentity() async { + final imported = await showNsecImportSheet( + context, + onImport: flow.importIdentity, + ); + if (imported && context.mounted) await flow.retry(); + } + + final actionStyle = FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ); final working = flow.stage == HostedFlowStage.working; final message = switch (flow.stage) { HostedFlowStage.idle => 'Continue connecting your account.', @@ -26,6 +40,10 @@ class HostedConnectSheet extends HookConsumerWidget { flow.addingCommunityName == null ? 'Securing your account and finding communities…' : 'Adding ${flow.addingCommunityName}…', + HostedFlowStage.setupIdentity => + flow.canCreateIdentity + ? 'Create an identity, or bring yours from another device. We’ll back it up for future sign-ins.' + : 'Your identity hasn’t been backed up yet. Bring it from your other device to keep using the same Buzz account.', HostedFlowStage.chooseCommunities => 'Choose your communities', HostedFlowStage.communityConflict => '${flow.addingCommunityName} is already saved with a different identity. ' @@ -58,6 +76,17 @@ class HostedConnectSheet extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (flow.stage == HostedFlowStage.setupIdentity) ...[ + Text( + flow.canCreateIdentity + ? 'Make yourself at home' + : 'Bring your identity with you', + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Grid.twelve), + ], Text(message, style: context.textTheme.bodyLarge), const SizedBox(height: Grid.sm), for (final choice in flow.identityChoices) @@ -76,6 +105,65 @@ class HostedConnectSheet extends HookConsumerWidget { ), ), ), + if (flow.stage == HostedFlowStage.setupIdentity) ...[ + if (flow.canCreateIdentity) ...[ + FilledButton( + style: actionStyle.copyWith( + backgroundColor: WidgetStatePropertyAll( + context.colors.primary, + ), + foregroundColor: WidgetStatePropertyAll( + context.colors.onPrimary, + ), + ), + onPressed: flow.createIdentity, + child: const Text('Create my Buzz identity'), + ), + const SizedBox(height: Grid.xxs), + TextButton( + style: actionStyle, + onPressed: importIdentity, + child: const Text('Import existing identity'), + ), + ] else ...[ + FilledButton( + style: actionStyle.copyWith( + backgroundColor: WidgetStatePropertyAll( + context.colors.primary, + ), + foregroundColor: WidgetStatePropertyAll( + context.colors.onPrimary, + ), + ), + onPressed: importIdentity, + child: const Text('Import existing identity'), + ), + const SizedBox(height: Grid.twelve), + Text( + 'We’ll securely back it up so your next sign-in is easier.', + textAlign: TextAlign.center, + style: context.textTheme.bodySmall, + ), + ], + if (flow.requiredIdentityPubkey != null) ...[ + const SizedBox(height: Grid.xxs), + TextButton( + onPressed: () => + showLinkedIdentity.value = !showLinkedIdentity.value, + child: Text( + showLinkedIdentity.value + ? 'Hide linked identity' + : 'View linked identity', + ), + ), + if (showLinkedIdentity.value) + SelectableText( + fullNpub(flow.requiredIdentityPubkey!) ?? + flow.requiredIdentityPubkey!, + style: context.textTheme.bodySmall, + ), + ], + ], if (flow.stage == HostedFlowStage.profile && flow.saveProfile != null) HostedProfileSetup( @@ -114,6 +202,7 @@ class HostedConnectSheet extends HookConsumerWidget { flow.stage != HostedFlowStage.profile && flow.stage != HostedFlowStage.acceptPolicy && flow.stage != HostedFlowStage.chooseIdentity && + flow.stage != HostedFlowStage.setupIdentity && flow.stage != HostedFlowStage.chooseCommunities && flow.stage != HostedFlowStage.communityConflict && flow.stage != HostedFlowStage.complete && diff --git a/mobile/lib/hosted_app_gate.dart b/mobile/lib/hosted_app_gate.dart index 490943e18a..56aeb4ab10 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -175,6 +175,7 @@ class HostedAppGate extends HookConsumerWidget { !inlineAccountStatus && (!inlineEntry || flow.stage == HostedFlowStage.chooseIdentity || + flow.stage == HostedFlowStage.setupIdentity || flow.stage == HostedFlowStage.chooseCommunities || flow.stage == HostedFlowStage.communityConflict || flow.canChangeCommunities)) diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index ba4d01219d..5b1dc8c1af 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -80,6 +80,7 @@ class OnboardingAppEntry extends HookConsumerWidget { flow.stage != HostedFlowStage.complete && flow.stage != HostedFlowStage.profile && flow.stage != HostedFlowStage.chooseIdentity && + flow.stage != HostedFlowStage.setupIdentity && flow.stage != HostedFlowStage.chooseCommunities && flow.stage != HostedFlowStage.communityConflict && !flow.canChangeCommunities; diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index b97604fec5..781323f4c8 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -214,6 +214,18 @@ class BuilderlabAccountClient { Future> currentIdentity() => post('/v1/buzz/nostr-identities/current', {}); + /// Saves a successfully admitted destination to the same account, even if + /// another session operation was queued before this request runs. + Future rememberCommunity( + String relay, { + required String expectedSubject, + }) async { + final result = await _post('/v1/buzz/known-communities/add', { + 'community_url': relay, + }, expectedSubject: expectedSubject); + if (result['error'] != null) throw AccountFailure.rejected; + } + /// Authenticated JSON POST to a Buzz account route on the fixed origin. The /// credential never leaves this client. A 401 clears the stored session only /// when the session check also rejects it; otherwise it is [AccountFailure.denied]. diff --git a/mobile/lib/shared/hosted/hosted_communities.dart b/mobile/lib/shared/hosted/hosted_communities.dart index daa9c257a4..69374d9ca8 100644 --- a/mobile/lib/shared/hosted/hosted_communities.dart +++ b/mobile/lib/shared/hosted/hosted_communities.dart @@ -2,6 +2,42 @@ import '../deeplink/deep_link.dart'; import 'builderlab_account.dart'; import 'hosted_environment.dart'; +/// Account-saved destinations, not evidence that this identity is a member. +/// Keep the existing hosted environment boundary and normal relay admission. +Future> loadKnownHostedCommunities( + BuilderlabAccountClient account, +) async { + final response = await account.post('/v1/buzz/known-communities/list', {}); + // Protobuf JSON omits an empty repeated field. + final rows = response['communities'] ?? []; + if (response['error'] != null || rows is! List) { + throw AccountFailure.invalidResponse; + } + final communities = {}; + for (final row in rows) { + if (row is! Map || row['community_url'] is! String) { + throw AccountFailure.invalidResponse; + } + final relay = Uri.tryParse(row['community_url'] as String); + // The account can also save destinations this hosted build cannot enter. + if (relay == null || + relay.scheme != 'wss' || + !RegExp(r'^[a-z0-9]+(?:[.-][a-z0-9]+)*$').hasMatch(relay.host) || + !HostedEnvironment.supportsRelay(relay)) { + continue; + } + final canonical = Uri(scheme: 'wss', host: relay.host); + communities.putIfAbsent( + canonical.host, + () => AddCommunityDeepLink( + relayUrl: canonical.toString(), + name: canonical.host, + ), + ); + } + return List.unmodifiable(communities.values); +} + /// Validated, active communities joined by the signed-in Builderlab identity. /// Discovery never grants admission; each target still goes through HostedAdmission. Future> loadHostedCommunities( diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index a75f721cd0..f26c27e31b 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -25,6 +25,7 @@ enum HostedFlowStage { working, signIn, chooseIdentity, + setupIdentity, chooseCommunities, communityConflict, acceptPolicy, @@ -93,6 +94,8 @@ class HostedFlow extends ChangeNotifier { List identityChoices = const []; BuzzDeepLink? _target; bool accountOnly = false; + bool canCreateIdentity = false; + String? requiredIdentityPubkey; String? _pairedPubkey; SensitiveActionPolicy? pairedSensitiveActionPolicy; List communities = const []; @@ -447,7 +450,39 @@ class HostedFlow extends ChangeNotifier { return _enqueue(() => _continue(generation, selectedPubkey: pubkey)); } - Future _continue(int generation, {String? selectedPubkey}) async { + /// A new identity is offered only after authoritative absence was established. + Future createIdentity() { + if (stage != HostedFlowStage.setupIdentity || !canCreateIdentity) { + return Future.value(); + } + final generation = _nextGeneration(); + _set(HostedFlowStage.working); + return _enqueue(() => _continue(generation, createNew: true)); + } + + /// Import through the existing secure store, rechecking the account binding. + /// Errors propagate to the import sheet without discarding the setup choice. + Future importIdentity(String nsec) { + final generation = _generation; + final subject = account.account?.subject; + if (stage != HostedFlowStage.setupIdentity || subject == null) { + throw AccountFailure.unavailable; + } + return _queue.run(() async { + bool current() => + !_disposed && + generation == _generation && + account.account?.subject == subject; + if (!current()) throw AccountFailure.unavailable; + await identity.resolve(isCurrent: current, importNsec: nsec); + }); + } + + Future _continue( + int generation, { + String? selectedPubkey, + bool createNew = false, + }) async { final target = _target; final session = account.account; if ((target == null && !accountOnly) || session == null) { @@ -462,6 +497,7 @@ class HostedFlow extends ChangeNotifier { var resolved = await identity.resolve( isCurrent: _isCurrent(generation), selectedPubkey: selectedPubkey ?? _pairedPubkey, + createNew: createNew, ); if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) { _set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch); @@ -525,6 +561,12 @@ class HostedFlow extends ChangeNotifier { } } if (generation != _generation || _disposed) return; + if (resolved.state == HostedIdentityState.needsSetup) { + canCreateIdentity = resolved.canCreate; + requiredIdentityPubkey = resolved.pubkey; + _set(HostedFlowStage.setupIdentity); + return; + } if (resolved.state == HostedIdentityState.needsSelection) { identityChoices = List.unmodifiable(resolved.choices); _set(HostedFlowStage.chooseIdentity); @@ -577,6 +619,13 @@ class HostedFlow extends ChangeNotifier { _isCurrent(generation), ); if (generation != _generation || _disposed) return; + // Save only after admission/persistence succeeds. A failure keeps the + // existing intent for retry; add is idempotent after a lost response. + await account.rememberCommunity( + destination.relayUrl ?? (throw AccountFailure.invalidResponse), + expectedSubject: session.subject, + ); + if (generation != _generation || _disposed) return; await browser.clearIntent(); _set(HostedFlowStage.complete); } @@ -642,7 +691,7 @@ class HostedFlow extends ChangeNotifier { generation == _generation && account.account?.subject == subject; if (!_addingCommunities) { - final listed = await loadHostedCommunities(account); + final listed = await loadKnownHostedCommunities(account); if (!current()) return; communities = listed; selectedRelays = Set.unmodifiable(listed.map((item) => item.relayUrl)); diff --git a/mobile/lib/shared/hosted/hosted_identity_service.dart b/mobile/lib/shared/hosted/hosted_identity_service.dart index 3c7ae73cd4..d4b41d9d07 100644 --- a/mobile/lib/shared/hosted/hosted_identity_service.dart +++ b/mobile/lib/shared/hosted/hosted_identity_service.dart @@ -9,6 +9,7 @@ import 'device_identity_source.dart'; enum HostedIdentityState { ready, needsSelection, + needsSetup, needsBinding, needsBackup, mismatch, @@ -21,9 +22,11 @@ class HostedIdentityResult { this.state, { this.pubkey, this.choices = const [], + this.canCreate = false, }); final HostedIdentityState state; final String? pubkey; + final bool canCreate; final List choices; } @@ -44,11 +47,15 @@ class HostedIdentityService { Future resolve({ bool Function()? isCurrent, String? selectedPubkey, - }) => _resolve(isCurrent, selectedPubkey); + bool createNew = false, + String? importNsec, + }) => _resolve(isCurrent, selectedPubkey, createNew, importNsec); Future _resolve( bool Function()? isCurrent, String? selectedPubkey, + bool createNew, + String? importNsec, ) async { void checkCurrent() { if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable; @@ -71,7 +78,9 @@ class HostedIdentityService { } else { throw AccountFailure.invalidResponse; } - if (local != null && bound != null) return _result(local, bound, subject); + if (local != null && bound != null && importNsec == null) { + return _result(local, bound, subject); + } final status = await account.post('/v1/buzz/nsec-backups/status', {}); _sameAccount(subject); @@ -85,6 +94,23 @@ class HostedIdentityService { : _pubkey(status['current_nostr_pubkey']); final statusKeys = listed.map(_pubkey).toSet(); if (statusBound != bound) throw AccountFailure.invalidResponse; + if (importNsec != null) { + final pubkey = AccountIdentityStore.publicKeyForNsec(importNsec); + if ((bound != null && bound != pubkey) || + (bound == null && + statusKeys.isNotEmpty && + !statusKeys.contains(pubkey))) { + throw AccountIdentityFailure.mismatch; + } + final imported = await store.create( + importNsec, + checkCurrent: () { + _sameAccount(subject); + checkCurrent(); + }, + ); + return _result(imported, bound, subject); + } if (local != null) { // A previously chosen recovery key may still await binding after a // restart. Its account-scoped receipt and current backup listing preserve @@ -145,7 +171,19 @@ class HostedIdentityService { return _result(saved, bound, subject); } } + if (statusKeys.isEmpty && bound != null && !matchingDevice) { + return HostedIdentityResult( + HostedIdentityState.needsSetup, + pubkey: bound, + ); + } if (statusBound == null && statusKeys.isEmpty) { + if (!createNew) { + return const HostedIdentityResult( + HostedIdentityState.needsSetup, + canCreate: true, + ); + } final generated = await store.generateIfAbsent( authorize: () async { _sameAccount(subject); diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart index fdb32b4dde..a0563fce22 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -258,11 +258,16 @@ void main() { await tester.tap(find.text('Continue with Builderlab')); await tester.pumpAndSettle(); expect(find.byType(CheckboxListTile), findsNothing); - expect(f.admitted, ['one', 'two']); + expect(f.admitted, [ + 'one.communities.buzz.xyz', + 'two.communities.buzz.xyz', + ]); expect(find.byType(HostedConnectPage), findsNothing); f.admitted.clear(); f.beforeAdmission = (name) async { - if (name == 'two') throw const SavedCommunityIdentityConflict(); + if (name == 'two.communities.buzz.xyz') { + throw const SavedCommunityIdentityConflict(); + } }; await tester.tap(find.text('Continue with Builderlab')); await tester.pumpAndSettle(); @@ -270,11 +275,11 @@ void main() { expect(find.text('1 community added and kept.'), findsOneWidget); await tester.tap(find.text('Keep saved identity and skip')); await tester.pumpAndSettle(); - expect(f.admitted, ['one']); + expect(f.admitted, ['one.communities.buzz.xyz']); expect(find.byType(HostedConnectPage), findsNothing); f.admitted.clear(); f.beforeAdmission = (name) async { - if (name == 'two') throw AccountFailure.rejected; + if (name == 'two.communities.buzz.xyz') throw AccountFailure.rejected; }; await tester.tap(find.text('Continue with Builderlab')); await tester.pumpAndSettle(); @@ -283,7 +288,13 @@ void main() { await tester.tap(find.text('Back to choices')); await tester.pumpAndSettle(); expect(find.byType(CheckboxListTile), findsOneWidget); - expect(find.text('two'), findsOneWidget); + expect( + find.descendant( + of: find.byType(CheckboxListTile), + matching: find.text('two.communities.buzz.xyz'), + ), + findsWidgets, + ); } }, skip: HostedEnvironment.authDomains.isEmpty, diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 4c053b5896..95f4542089 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -57,6 +57,56 @@ void main() { ); } + journey('new account chooses creation and keeps its identity on retry', ( + tester, + f, + ) async { + await f.initialize(localIdentity: false); + f.bound = null; + f.offerProfile = false; + await f.mount(tester); + await f.tap(tester, 'Continue with Builderlab'); + expect(find.text('Create my Buzz identity'), findsOneWidget); + expect(find.text('Import existing identity'), findsOneWidget); + expect(await f.store.read(), isNull); + await f.tap(tester, 'Create my Buzz identity'); + final saved = await f.store.read(); + expect(saved, isNotNull); + expect(f.bound, saved!.pubkey); + expect(saved.backup, isNotNull); + await f.flow.retry(); + await f.settle(tester); + expect((await f.store.read())!.pubkey, saved.pubkey); + }); + + journey( + 'linked account without backup imports its key without replacing it', + (tester, f) async { + await f.initialize(localIdentity: false); + f.offerProfile = false; + await f.mount(tester); + await f.tap(tester, 'Continue with Builderlab'); + expect(find.text('Create my Buzz identity'), findsNothing); + expect(find.text('Import existing identity'), findsOneWidget); + await expectLater( + f.flow.importIdentity(f.secondKeys.nsec), + throwsA(AccountIdentityFailure.mismatch), + ); + expect(await f.store.read(), isNull); + expect(f.bound, f.keys.public); + await f.tap(tester, 'Import existing identity'); + await tester.enterText(find.byType(TextField).hitTestable(), f.keys.nsec); + await f.tap(tester, 'Preview identity'); + await f.tap(tester, 'Import this identity'); + expect((await f.store.read())!.pubkey, f.keys.public); + expect((await f.store.read())!.backup, isNotNull); + expect( + f.requests.where((r) => r.url.path.endsWith('/recovery')), + isEmpty, + ); + }, + ); + journey('system Back leaves community creation', (tester, f) async { await f.initialize(); await f.mount(tester); @@ -331,7 +381,11 @@ void main() { expect((await f.storage.loadAll()).single.nsec, community.nsec); expect(await f.storage.loadActiveId(), community.id); expect((await f.store.read())!.pubkey, f.keys.public); - for (final path in ['/status', '/enrollment', '/communities/joined']) { + for (final path in [ + '/status', + '/enrollment', + '/known-communities/list', + ]) { expect( f.sent(path), isEmpty, @@ -402,8 +456,11 @@ void main() { } finally { browserReturn.complete(); } + await f.settle(tester); + await tester.tap(find.text('Create my Buzz identity')); await enrollmentReached.future.timeout(const Duration(seconds: 5)); await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); try { await tester.tap(find.text('Leave setup').hitTestable()); await tester.pump(); @@ -569,7 +626,7 @@ void main() { findsOneWidget, ); final firstSaved = (await f.storage.loadAll()).firstWhere( - (c) => c.name == 'one', + (c) => c.name == 'one.communities.buzz.xyz', ); Navigator.of( tester.element(find.byType(ProfileSetupPage, skipOffstage: false)), @@ -592,10 +649,19 @@ void main() { .every((c) => c.nsec == f.secondKeys.nsec), isTrue, ); - expect(saved.singleWhere((c) => c.name == 'one').id, firstSaved.id); + expect( + saved.singleWhere((c) => c.name == 'one.communities.buzz.xyz').id, + firstSaved.id, + ); expect(await f.storage.loadActiveId(), firstSaved.id); - expect(f.probes.where((c) => c.name == 'one'), hasLength(1)); - expect(f.probes.where((c) => c.name == 'two'), hasLength(2)); + expect( + f.probes.where((c) => c.name == 'one.communities.buzz.xyz'), + hasLength(1), + ); + expect( + f.probes.where((c) => c.name == 'two.communities.buzz.xyz'), + hasLength(2), + ); expect(find.byType(OnboardingAppEntry), findsNothing); }, ); @@ -840,6 +906,7 @@ void main() { (tester, f) async { await f.initialize(); f.offerProfile = false; + f.saveFailures = 1; // The first dispatched attempt ends; retry owns the pending consent. // A new incoming link must now invalidate that consent, not wait on it. f.policyResponses.addAll([ @@ -871,6 +938,15 @@ void main() { f.sent('/accept-policy').single.url.host, 'two.communities.buzz.xyz', ); + // The remote add was accepted but its acknowledgement was lost. The + // saved community and pending intent survive; retry never reclaims it. + expect(find.text('Try again').hitTestable(), findsOneWidget); + expect(f.knownSaved, {'wss://two.communities.buzz.xyz'}); + await f.restart(tester); + await f.tap(tester, 'Start login again'); + expect((await f.storage.loadAll()).single.id, saved.id); + expect(f.sent('/claim'), hasLength(1)); + expect(f.sent('/known-communities/add'), hasLength(2)); expect(find.byType(OnboardingAppEntry), findsNothing); }, requiresAuthDomain: true, diff --git a/mobile/test/helpers/hosted_communities_fixture.dart b/mobile/test/helpers/hosted_communities_fixture.dart index fb7e4ae027..84a950a478 100644 --- a/mobile/test/helpers/hosted_communities_fixture.dart +++ b/mobile/test/helpers/hosted_communities_fixture.dart @@ -58,7 +58,7 @@ class WidgetDiscoveryAccount extends BuilderlabAccountClient { 'identity': {'pubkey_hex': fixture.bound}, }; } - if (path.endsWith('/communities/joined')) { + if (path.endsWith('/known-communities/list')) { return {'communities': fixture.rows}; } throw AccountFailure.unavailable; @@ -83,11 +83,8 @@ class HostedCommunitiesFixture { Future Function()? beforeList; Uri? opened; - Map row(String name, {bool archived = false}) => { - 'id': name, - 'name': name, - 'normalized_host': '$name.communities.buzz.xyz', - if (archived) 'archived_at': '2026-10-01T00:00:00Z', + Map row(String name) => { + 'community_url': 'wss://$name.communities.buzz.xyz', }; Future initialize({bool backup = true, bool widget = false}) async { @@ -121,11 +118,20 @@ class HostedCommunitiesFixture { 200, ); } - if (path.endsWith('/communities/joined')) { + if (path.endsWith('/known-communities/list')) { await beforeList?.call(); if (rejectList) return http.Response('{}', 503); return http.Response(jsonEncode({'communities': rows}), 200); } + if (path.endsWith('/known-communities/add')) { + final body = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'community': {'community_url': body['community_url']}, + }), + 200, + ); + } return http.Response('{}', 503); }), ); diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index db10fd426d..aa9ea0fcdb 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -71,6 +71,8 @@ class HostedJourneyFixture { bool offerProfile = true; bool profileUnavailable = false; bool rejectList = false; + int saveFailures = 0; + final knownSaved = {}; bool missingList = false; bool loseCreateResponse = false; bool loseClaimResponse = false; @@ -239,6 +241,23 @@ class HostedJourneyFixture { throw AccountFailure.unavailable; } result = {'community': created}; + } else if (path.endsWith('/known-communities/list')) { + if (rejectList) return http.Response('{}', 503); + result = { + if (rows.isNotEmpty) + 'communities': [ + for (final row in rows) + {'community_url': 'wss://${row['normalized_host']}'}, + ], + }; + } else if (path.endsWith('/known-communities/add')) { + expect(request.method, 'POST'); + final url = jsonDecode(request.body)['community_url'] as String; + knownSaved.add(url); + if (saveFailures-- > 0) return http.Response('{}', 503); + result = { + 'community': {'community_url': url}, + }; } else if (path.endsWith('/communities/joined')) { if (rejectList) return http.Response('{}', 503); if (missingList) return http.Response('{}', 404); diff --git a/mobile/test/shared/hosted/hosted_communities_flow_test.dart b/mobile/test/shared/hosted/hosted_communities_flow_test.dart index 1fa6d43125..0b5c97c17c 100644 --- a/mobile/test/shared/hosted/hosted_communities_flow_test.dart +++ b/mobile/test/shared/hosted/hosted_communities_flow_test.dart @@ -33,7 +33,7 @@ void main() { expect(f.flow.problem, HostedFlowProblem.identityMismatch); } expect( - f.requests.any((path) => path.endsWith('/communities/joined')), + f.requests.any((path) => path.endsWith('/known-communities/list')), isFalse, ); expect(await const FlutterSecureStorage().readAll(), before); @@ -45,7 +45,7 @@ void main() { test('malformed list targets cannot authorize admission', () async { final f = await fixture(); f.rows = [ - {...f.row('one'), 'normalized_host': 'wss://user@evil.example/path'}, + {...f.row('one'), 'community_url': 42}, ]; await f.flow.signInAccount(); expect(f.flow.problem, HostedFlowProblem.invalidResponse); @@ -56,10 +56,12 @@ void main() { await f.flow.initialize(); f.rows = [f.row('a-one'), f.row('a-two')]; f.beforeAdmission = (name) async { - if (name == 'a-two') throw AccountFailure.unavailable; + if (name == 'a-two.communities.buzz.xyz') { + throw AccountFailure.unavailable; + } }; await f.flow.signInAccount(); - expect(f.admitted, ['a-one']); + expect(f.admitted, ['a-one.communities.buzz.xyz']); f.expired = true; await f.flow.retry(); expect(f.flow.stage, HostedFlowStage.signIn); @@ -86,6 +88,9 @@ void main() { BrowserAuthCode('other-code', 'a' * 43, {'type': 'account'}), ); await completed.future.timeout(const Duration(seconds: 2)); - expect(f.admitted, ['a-one', 'b-one']); + expect(f.admitted, [ + 'a-one.communities.buzz.xyz', + 'b-one.communities.buzz.xyz', + ]); }); } diff --git a/mobile/test/shared/hosted/hosted_environment_flow_test.dart b/mobile/test/shared/hosted/hosted_environment_flow_test.dart index 1b40094eda..af56f431cb 100644 --- a/mobile/test/shared/hosted/hosted_environment_flow_test.dart +++ b/mobile/test/shared/hosted/hosted_environment_flow_test.dart @@ -17,6 +17,16 @@ class _Account extends BuilderlabAccountClient { _Account(this.pubkey); final String pubkey; var reads = 0; + final remembered = []; + @override + Future rememberCommunity( + String relay, { + required String expectedSubject, + }) async { + expect(expectedSubject, account.subject); + remembered.add(relay); + } + @override BuilderlabAccount get account => BuilderlabAccount(subject: 'owner', expiresAt: DateTime.utc(2100)); @@ -113,6 +123,7 @@ void main() { ); expect(flow.stage, HostedFlowStage.complete); expect(entered, 1); + expect(account.remembered, ['wss://$matching']); expect(requests.map((request) => request.url.host), [matching, matching]); expect(requests.map((request) => request.method), ['GET', 'POST']); expect(requests.map((request) => request.url.path), [ diff --git a/mobile/test/shared/hosted/hosted_multi_backup_recovery_test.dart b/mobile/test/shared/hosted/hosted_multi_backup_recovery_test.dart index 954ad03be1..3d62291ff1 100644 --- a/mobile/test/shared/hosted/hosted_multi_backup_recovery_test.dart +++ b/mobile/test/shared/hosted/hosted_multi_backup_recovery_test.dart @@ -378,12 +378,14 @@ void main() { : [DeviceIdentityChoice(keys[2].public, 'Newly saved')], ); expect( - (await blocked.resolve()).state, + (await blocked.resolve(createNew: true)).state, HostedIdentityState.needsAuthoritativeAbsence, ); expect(await store.read(), isNull); final resolver = await service(); - final first = await resolver.resolve(); + expect((await resolver.resolve()).state, HostedIdentityState.needsSetup); + expect(await store.read(), isNull); + final first = await resolver.resolve(createNew: true); expect(first.state, HostedIdentityState.needsBinding); expect((await resolver.resolve()).pubkey, first.pubkey); expect(recoveryCalls, 0);