From 5a8102ee5b50da94588368b8d2ccb3def64033ca Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 27 Oct 2026 15:31:31 -0700 Subject: [PATCH] fix(mobile): preserve invite onboarding and offer identity setup (#8617) ## Summary Cold-start iOS invite links could open the welcome screen without continuing sign-in, and accounts without a stored backup had no explicit identity setup choice. Forward scene launch URLs to `app_links`, offer creation for an unbound account with no backups, and offer matching-key import for an existing binding without a backup. Existing key storage, binding, backup, and admission checks remain in use. Discover saved hosted destinations using `known-communities/list` and remember successfully admitted destinations with `known-communities/add`. Saved destinations are not membership proof; normal admission and the existing failed-claim membership reconciliation remain separate. A failed save preserves the intent and local community for idempotent retry. ### Related issue Related to #8599 (QR-first mobile onboarding) and #8601 (invite policy consent; overlapping setup UI). No exact duplicate found in the open onboarding PR / identity-backup issue search. ### Validation before the latest main integration - 48 tests passed across five focused files: `hosted_journeys_test`, `hosted_communities_test`, `hosted_communities_flow_test`, `hosted_environment_flow_test`, and `hosted_multi_backup_recovery_test`. Test defines enable hosted domains and the push gateway. - Four additional pairing handoff tests passed with hosted domains enabled after updating their shared HTTP fixture for community saving. - Targeted Dart analysis: no issues. Required pre-commit and pre-push hooks passed, including the broader mobile analysis/test lane and file-size checks. - Native iOS build passed during iteration. On an isolated iOS simulator, a terminated app opened from an invite automatically entered browser login; human recording confirmed login continued into identity setup and new identity creation. - Human staging testing on October 27 passed invite entry with an existing email/password and new identity, community entry, messaging, cold restart, and recovery on two separate clean iOS simulators. These results apply to build `ff2138fc325790e0af4d9a64b2ed033fae3a8295`; the backend backup-upload blocker described previously is no longer blocking that tested flow. - Fresh-account signup through an invite is deferred. Physical-camera QR scanning, native regression/mutation coverage, independent review, and native retesting after the latest main integration remain outstanding. - Known limitation: failed-claim membership reconciliation still calls `communities/joined`; its endpoint/response contract needs alignment with the supported backend before that fallback is considered ready. The successful primary recovery path does not prove this fallback. - No account reset tooling, environment-specific credentials, generated dependency files, or recording artifacts are included. ### Current review fixes and validation Head `9902a98d42904a4a7d44a79541d81ceb94d8eac3` addresses the two review blockers and both small import races: - Hosted imports explain a linked-account key mismatch; the shared self-hosted/occupied-device message remains unchanged. The linked-account journey enters the wrong key through the actual sheet, checks the visible error and empty storage, then imports the correct key. - Closing the import sheet while its operation is pending now waits for that operation and resumes successful setup. The new-account journey covers creation and import, including dismissal while awaiting the status response, binding, backup receipt, and retry. - An unbound account with newly appeared backups refuses direct import, preserving recovery's receipt-before-adoption rule. The existing multiple-backup invariant covers matching and different imported keys. - The configured route journey uses host labels and its existing fake account now supports destination saving; the real-account journeys separately exercise persistence. 53 tests passed across six focused files with CI's `BUZZ_AUTH_DOMAINS=buzz.xyz,builderlab.xyz,builderlabstaging.xyz` and push-gateway define. In a separate clone with independent Git metadata/build output, restoring the old production import paths failed the three intended regressions; restoring the fixes passed all 43 tests in that control. Required pre-commit and pre-push hooks passed, including mobile analysis/tests and file-size checks. Hosted CI and native retesting of this head are still pending. The earlier independent review was at `10815e5d9`; these fixes still need reviewer confirmation. Optional accessibility and import-navigation refinements are deferred. Fresh signup, authenticated production add/list and the membership fallback remain open as described above. ### Staging testing and production configuration Staging selection is build configuration, not a changed production default. `HostedEnvironment` is unchanged by this PR: `BUZZ_BUILDERLAB_URL` defaults to `https://app.builderlab.xyz`; `BUZZ_AUTH_DOMAINS` is empty by default (hosted account features disabled). Builds that enable production hosted domains receive these onboarding behavior fixes after merge. This PR does not deploy anything or redirect production builds to staging. Check out this PR (`gh pr checkout 8617` in a dedicated `block/buzz` checkout/worktree), install dependencies per `mobile/README.md`, and boot a clean iOS simulator. With the repository-pinned Flutter toolchain: ```sh . ./bin/activate-hermit ./scripts/mobile-worktree-overrides.sh cd mobile flutter run -d \ --dart-define=BUZZ_BUILDERLAB_URL=https://app.builderlabstaging.xyz \ --dart-define=BUZZ_AUTH_DOMAINS=communities.public.builderlabstaging.xyz \ --dart-define=BUZZ_DEBUG_PRIVATE_LOGIN=true ``` Private login requests an ephemeral browser session only in debug builds. Hosted relay assertions remain off by default; this test does not validate assertion-enabled admission. Generated local Xcode settings, dependency metadata, credentials, and simulator state are not included in the PR. - Invite test: open a valid public-staging invite in the simulator browser, choose **Accept invite in Buzz**, sign in or sign up, complete identity setup, enter the community, and send a message. - Recovery test: preserve that simulator, start another clean simulator with the same build, choose **or sign in with email**, and sign into the same account. Verify the recovered public key matches, the saved community and earlier message return, and a new message sends. - Fresh signup: repeat invite entry using a new account. This still needs human verification. Keep this PR draft while the listed validation gaps remain. --------- Signed-off-by: Wes Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- mobile/ios/Runner/SceneDelegate.swift | 17 +++ .../onboarding/hosted_connect_sheet.dart | 102 +++++++++++++++ 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 | 44 ++++++- .../identity_transfer/nsec_import_sheet.dart | 11 +- .../onboarding/hosted_communities_test.dart | 21 ++- .../onboarding/hosted_journeys_test.dart | 121 +++++++++++++++++- .../onboarding_entry_routes_test.dart | 23 +++- .../helpers/hosted_communities_fixture.dart | 30 ++++- .../test/helpers/hosted_journey_fixture.dart | 21 +++ .../hosted/hosted_communities_flow_test.dart | 15 ++- .../hosted/hosted_environment_flow_test.dart | 11 ++ .../hosted_multi_backup_recovery_test.dart | 15 ++- 17 files changed, 499 insertions(+), 35 deletions(-) diff --git a/mobile/ios/Runner/SceneDelegate.swift b/mobile/ios/Runner/SceneDelegate.swift index 6464f1a2dd..c61a2153d6 100644 --- a/mobile/ios/Runner/SceneDelegate.swift +++ b/mobile/ios/Runner/SceneDelegate.swift @@ -1,8 +1,25 @@ 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) + } + } } /// Owns scanner visibility without replacing Flutter's normal status-bar policy. diff --git a/mobile/lib/features/onboarding/hosted_connect_sheet.dart b/mobile/lib/features/onboarding/hosted_connect_sheet.dart index 9e0f1bb965..a8104705cb 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,32 @@ class HostedConnectSheet extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { useListenable(flow); + final showLinkedIdentity = useState(false); + Future importIdentity() async { + Future? pendingImport; + await showNsecImportSheet( + context, + mismatchMessage: + 'This key doesn’t match the identity linked to your Builderlab account.', + onImport: (nsec) => pendingImport = flow.importIdentity(nsec), + ); + // Dismissing the sheet does not cancel a secure-store write in flight. + // Finish that operation before resuming, even when the modal returned null. + if (pendingImport == null) return; + try { + await pendingImport; + } catch (_) { + return; // The sheet already presented the import failure. + } + if (context.mounted && flow.stage == HostedFlowStage.setupIdentity) { + 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 +53,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 +89,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 +118,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 +215,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 71640e5c7e..0c8e8e0a61 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 d894707ab4..adf6b707d4 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -229,6 +229,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..72e33e85ff 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,25 @@ 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); + // A backup arriving while setup is open must use normal recovery, which + // records the selected backup receipt before adopting its identity. + if (bound == null && statusKeys.isNotEmpty) { + throw AccountFailure.unavailable; + } + if (bound != null && bound != 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 +173,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/lib/shared/identity_transfer/nsec_import_sheet.dart b/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart index a3a411ceaf..40ddb3317c 100644 --- a/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart +++ b/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart @@ -14,12 +14,14 @@ import 'nsec_transfer.dart'; Future showNsecImportSheet( BuildContext context, { required Future Function(String nsec) onImport, + String? mismatchMessage, }) async => await showBuzzModalBottomSheet( context: context, title: 'Import identity', isScrollControlled: true, - builder: (_) => NsecImportSheet(onImport: onImport), + builder: (_) => + NsecImportSheet(onImport: onImport, mismatchMessage: mismatchMessage), ) ?? false; @@ -29,11 +31,13 @@ class NsecImportSheet extends HookWidget { super.key, required this.onImport, this.pickFile, + this.mismatchMessage, this.autofocus = true, }); final Future Function(String nsec) onImport; final Future Function()? pickFile; + final String? mismatchMessage; /// Allows a containing sheet to finish expanding before opening the keyboard. final bool autofocus; @@ -89,7 +93,10 @@ class NsecImportSheet extends HookWidget { } catch (failure) { if (!context.mounted) return; error.value = switch (failure) { - AccountIdentityFailure.occupied || AccountIdentityFailure.mismatch => + AccountIdentityFailure.mismatch => + mismatchMessage ?? + 'This device already has a different identity. It has not been replaced.', + AccountIdentityFailure.occupied => 'This device already has a different identity. It has not been replaced.', AccountIdentityFailure.invalidKey => 'Enter a valid nsec private key.', diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart index 8ca1c279d6..a0cb332920 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -268,11 +268,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(); @@ -280,11 +285,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(); @@ -293,7 +298,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..e7c7f6418c 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/hosted/account_identity_store.dart'; import 'package:buzz/shared/hosted/hosted_environment.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; @@ -13,7 +14,6 @@ import 'package:buzz/features/pairing/pairing_page.dart'; import 'package:buzz/onboarding_app_entry.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/hosted/builderlab_account.dart'; -import 'package:buzz/shared/hosted/account_identity_store.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; import 'package:buzz/shared/relay/relay_session.dart'; @@ -57,6 +57,89 @@ void main() { ); } + for (final importing in [false, true]) { + journey( + 'new account ${importing ? 'imports' : 'creates'} 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); + if (importing) { + await f.tap(tester, 'Import existing identity'); + await tester.enterText( + find.byType(TextField).hitTestable(), + f.keys.nsec, + ); + await f.tap(tester, 'Preview identity'); + final started = Completer(); + final release = Completer(); + f.beforeStatusReturn = () async { + if (!started.isCompleted) started.complete(); + await release.future; + }; + await f.tap(tester, 'Import this identity'); + await started.future; + // Dismiss while the real import is awaiting its external status response. + Navigator.of( + tester.element(find.byType(TextField).hitTestable()), + ).pop(); + await f.settle(tester); + release.complete(); + f.beforeStatusReturn = null; + await f.settle(tester); + expect(f.bound, f.keys.public); + } else { + 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 f.tap(tester, 'Import existing identity'); + await tester.enterText( + find.byType(TextField).hitTestable(), + f.secondKeys.nsec, + ); + await f.tap(tester, 'Preview identity'); + await f.tap(tester, 'Import this identity'); + expect( + find.text( + 'This key doesn’t match the identity linked to your Builderlab account.', + ), + findsOneWidget, + ); + expect(await f.store.read(), isNull); + expect(f.bound, f.keys.public); + 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 +414,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 +489,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 +659,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 +682,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 +939,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 +971,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/features/onboarding/onboarding_entry_routes_test.dart b/mobile/test/features/onboarding/onboarding_entry_routes_test.dart index 701def7eb5..6ea6b927f7 100644 --- a/mobile/test/features/onboarding/onboarding_entry_routes_test.dart +++ b/mobile/test/features/onboarding/onboarding_entry_routes_test.dart @@ -123,12 +123,14 @@ void main() { // A root-navigator choice modal must remain actionable above pushed Add. f.rows = [f.row('one'), f.row('two')]; f.beforeAdmission = (name) async { - if (name == 'two') throw const SavedCommunityIdentityConflict(); + if (name == 'two.communities.buzz.xyz') { + throw const SavedCommunityIdentityConflict(); + } }; await openEntry(); await tester.tap(find.text('Continue with Builderlab').hitTestable()); await tester.pumpAndSettle(); - expect(f.admitted, ['one']); + expect(f.admitted, ['one.communities.buzz.xyz']); expect(f.flow.stage, HostedFlowStage.communityConflict); expect( find.text('Keep saved identity and skip').hitTestable(), @@ -138,11 +140,23 @@ void main() { await tester.tap(find.text('Back to choices').hitTestable()); await tester.pumpAndSettle(); expect(find.byType(CheckboxListTile).hitTestable(), findsOneWidget); - expect(find.text('two').hitTestable(), findsOneWidget); + expect( + (tester + .widget( + find.byType(CheckboxListTile).hitTestable(), + ) + .title + as Text) + .data, + 'two.communities.buzz.xyz', + ); f.beforeAdmission = null; await tester.tap(find.text('Continue').hitTestable()); await tester.pumpAndSettle(); - expect(f.admitted, ['one', 'two']); + expect(f.admitted, [ + 'one.communities.buzz.xyz', + 'two.communities.buzz.xyz', + ]); expect(find.byType(OnboardingAppEntry), findsNothing); expect(find.text('Add community').hitTestable(), findsOneWidget); f.admitted.clear(); @@ -179,6 +193,7 @@ void main() { await tester.tap(find.text('Try again').hitTestable()); await tester.pumpAndSettle(); expect(f.admitted, ['paired']); + expect(f.remembered, contains('wss://paired.communities.buzz.xyz')); expect(find.byType(OnboardingAppEntry), findsNothing); expect(find.text('Add community').hitTestable(), findsOneWidget); diff --git a/mobile/test/helpers/hosted_communities_fixture.dart b/mobile/test/helpers/hosted_communities_fixture.dart index fb7e4ae027..dcf3244d32 100644 --- a/mobile/test/helpers/hosted_communities_fixture.dart +++ b/mobile/test/helpers/hosted_communities_fixture.dart @@ -47,6 +47,15 @@ class WidgetDiscoveryAccount extends BuilderlabAccountClient { ); @override Future restore() async => account; + @override + Future rememberCommunity( + String relay, { + required String expectedSubject, + }) async { + if (account?.subject != expectedSubject) throw AccountFailure.denied; + fixture.remembered.add(relay); + } + @override Future> post( String path, @@ -58,7 +67,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; @@ -72,6 +81,7 @@ class HostedCommunitiesFixture { late BuilderlabAccountClient account; late final HostedFlow flow; final admitted = []; + final remembered = []; final requests = []; List> rows = []; String? bound; @@ -83,11 +93,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 +128,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..1263a8c3d5 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -43,6 +43,7 @@ class HostedJourneyFixture { final identityStorage = JourneyIdentityStorage(); final requests = []; Future Function()? beforeBrowserReturn; + Future Function()? beforeStatusReturn; Future Function()? beforeEnrollmentReturn; Future Function()? beforeUploadReturn; final probes = []; @@ -71,6 +72,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; @@ -167,6 +170,7 @@ class HostedJourneyFixture { 'identity': bound == null ? null : {'pubkey_hex': bound}, }; } else if (path.endsWith('/status')) { + await beforeStatusReturn?.call(); result = { 'current_nostr_pubkey': bound, 'backup_nostr_pubkeys': cloudKeys.map((k) => k.public).toList(), @@ -239,6 +243,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..99dc002f22 100644 --- a/mobile/test/shared/hosted/hosted_multi_backup_recovery_test.dart +++ b/mobile/test/shared/hosted/hosted_multi_backup_recovery_test.dart @@ -144,6 +144,15 @@ void main() { 'unbound distinct keys expose public choices and persist only selection', () async { final resolver = await service(includeDevice: true); + // Setup can become stale when another device uploads a backup. Even an + // imported key in that list must go through recovery to gain its receipt. + for (final key in keys) { + await expectLater( + resolver.resolve(importNsec: key.nsec), + throwsA(AccountFailure.unavailable), + ); + expect(await store.read(), isNull); + } final pending = await resolver.resolve(); expect(pending.state, HostedIdentityState.needsSelection); expect(pending.choices.map((choice) => choice.pubkey), [ @@ -378,12 +387,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);