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:
Wes
2026-10-26 20:50:17 -07:00
parent 8216db1c71
commit 625dfe0d08
15 changed files with 401 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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].

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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