mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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 <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> 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 &&
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -214,6 +214,18 @@ class BuilderlabAccountClient {
|
||||
Future<Map<String, dynamic>> 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<void> 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].
|
||||
|
||||
@@ -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<List<AddCommunityDeepLink>> 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'] ?? <dynamic>[];
|
||||
if (response['error'] != null || rows is! List) {
|
||||
throw AccountFailure.invalidResponse;
|
||||
}
|
||||
final communities = <String, AddCommunityDeepLink>{};
|
||||
for (final row in rows) {
|
||||
if (row is! Map<String, dynamic> || 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<List<AddCommunityDeepLink>> loadHostedCommunities(
|
||||
|
||||
@@ -25,6 +25,7 @@ enum HostedFlowStage {
|
||||
working,
|
||||
signIn,
|
||||
chooseIdentity,
|
||||
setupIdentity,
|
||||
chooseCommunities,
|
||||
communityConflict,
|
||||
acceptPolicy,
|
||||
@@ -93,6 +94,8 @@ class HostedFlow extends ChangeNotifier {
|
||||
List<DeviceIdentityChoice> identityChoices = const [];
|
||||
BuzzDeepLink? _target;
|
||||
bool accountOnly = false;
|
||||
bool canCreateIdentity = false;
|
||||
String? requiredIdentityPubkey;
|
||||
String? _pairedPubkey;
|
||||
SensitiveActionPolicy? pairedSensitiveActionPolicy;
|
||||
List<AddCommunityDeepLink> communities = const [];
|
||||
@@ -447,7 +450,39 @@ class HostedFlow extends ChangeNotifier {
|
||||
return _enqueue(() => _continue(generation, selectedPubkey: pubkey));
|
||||
}
|
||||
|
||||
Future<void> _continue(int generation, {String? selectedPubkey}) async {
|
||||
/// A new identity is offered only after authoritative absence was established.
|
||||
Future<void> 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<void> 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<void> _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));
|
||||
|
||||
@@ -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<DeviceIdentityChoice> choices;
|
||||
}
|
||||
|
||||
@@ -44,11 +47,15 @@ class HostedIdentityService {
|
||||
Future<HostedIdentityResult> resolve({
|
||||
bool Function()? isCurrent,
|
||||
String? selectedPubkey,
|
||||
}) => _resolve(isCurrent, selectedPubkey);
|
||||
bool createNew = false,
|
||||
String? importNsec,
|
||||
}) => _resolve(isCurrent, selectedPubkey, createNew, importNsec);
|
||||
|
||||
Future<HostedIdentityResult> _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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> Function()? beforeList;
|
||||
Uri? opened;
|
||||
|
||||
Map<String, dynamic> 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<String, dynamic> row(String name) => {
|
||||
'community_url': 'wss://$name.communities.buzz.xyz',
|
||||
};
|
||||
|
||||
Future<void> 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<String, dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'community': {'community_url': body['community_url']},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 503);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -71,6 +71,8 @@ class HostedJourneyFixture {
|
||||
bool offerProfile = true;
|
||||
bool profileUnavailable = false;
|
||||
bool rejectList = false;
|
||||
int saveFailures = 0;
|
||||
final knownSaved = <String>{};
|
||||
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);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@ class _Account extends BuilderlabAccountClient {
|
||||
_Account(this.pubkey);
|
||||
final String pubkey;
|
||||
var reads = 0;
|
||||
final remembered = <String>[];
|
||||
@override
|
||||
Future<void> 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), [
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user