Files
buzz/mobile/lib/shared/hosted/hosted_flow.dart
Wes af54258f19 fix(mobile): streamline identity setup and community selection (#9070)
Selecting a saved community now connects only that destination, and
**Find my communities** remains available through **Switcher → Add
Community**. Failed destinations are named; completing nested Add/Find
opens the selected destination without resuming the outer batch.

Fresh hosted account setup now creates and backs up an identity
automatically through email sign-in, invites, community links and
community creation when the resolver confirms there are no
account-bound, backed-up, saved or device identities. Welcome has one
email sign-in entry. Authenticated import, existing-key recovery and
selection retain their explicit paths; pending import survives restart
and attaching a destination. Backup retry retains the same identity.

Web binding retains explicit identity creation and its browser-return
behavior. Self-hosted setup is separate and explicit. New-key creation
still requires platform sealing support; passive account restoration
never creates, binds or backs up an identity.

**Account tradeoff:** setup cannot discover a key stored only elsewhere
and never associated with this account. Such an account receives a new
backed-up, bound identity automatically. Importing a different old key
afterward fails with a binding mismatch; this flow does not undo that
account binding. There is no separate pre-authentication import button.

Pending import now survives retained community/web-bind link decoding:
internal import metadata is stripped before strict public-link parsing.
Once an identity is saved and resolved, the import request is retired
before binding or backup, so restart resumes the existing key. Passive
restoration also repairs stale import intents written by earlier builds
without creating, binding or backing up a key.

Validation (`f114c0e228`):
- All 30 hosted onboarding journeys pass. Import coverage includes
invite-to-community-link retention, restart before import, failed-backup
restart, successful-backup restart, and an older stale import intent.
- Three isolated production mutations (decode filtering, import
completion persistence, passive stale-intent handling) each fail the
relevant journey; restored control passes. Earlier auto-creation and
nested-selection mutations also failed their relevant journeys.
- Carl reviewed both P2 fixes and the final commit with no blockers.
Carl and Pinky previously cleared the shared setup behavior and Android
parity.
- Wes confirmed Android sign-in and fresh account creation on the
preceding build `be5690c684`. Android/iOS builds and earlier manual
acceptance do not establish native acceptance of the new import/restart
fixes. Installed devices remain on that preceding build.

**Remaining acceptance:** current-head CI and native validation of the
import/restart edge cases. Required publication hooks passed, including
mobile analysis/tests, branch-skew and file-size checks. The existing
iOS 16 sealing limitation remains: it cannot create a fresh hosted
identity. No membership/ownership repair, denial-classification change
or floating sign-in-control fix is included.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-11-04 14:42:24 -08:00

1173 lines
39 KiB
Dart

import '../storage/serial_queue.dart';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:url_launcher/url_launcher.dart';
import '../deeplink/deep_link.dart';
import '../community/community.dart';
import 'account_identity_store.dart';
import 'device_identity_source.dart';
import 'browser_auth.dart';
import 'builderlab_account.dart';
import 'hosted_identity_service.dart';
import 'hosted_backup_service.dart';
import 'hpke_backup.dart';
import 'hosted_environment.dart';
import 'hosted_communities.dart';
import 'hosted_community_creation.dart';
import 'hosted_community_connection.dart';
import 'web_connect_binding.dart';
import 'hosted_join_policy.dart';
enum HostedFlowStage {
idle,
working,
signIn,
chooseIdentity,
setupIdentity,
chooseCommunities,
communityConflict,
acceptPolicy,
profile,
blocked,
browserReturned,
complete,
}
enum HostedFlowProblem {
retry,
denied,
accountSetupRequired,
differentAccount,
identityUnreadable,
backupUnavailable,
unsupportedPlatform,
invalidResponse,
requestRejected,
identityMismatch,
identityUnavailable,
backupRequired,
bindingRequired,
unsupportedEnvironment,
invitePolicyRequired,
inviteExpired,
inviteExhausted,
inviteInvalid,
inviteRestricted,
inviteOwnerMismatch,
communityCreationRejected,
}
/// Resolves only after relay admission is proven and local persistence completes.
/// Implementations must check [isCurrent] immediately before durable mutations.
typedef HostedCommunityAdmission =
Future<void> Function(
BuzzDeepLink target,
String subject,
String pubkey,
bool Function() isCurrent,
);
/// Reopens the completed Add destination after restoring older memberships.
typedef HostedCommunityReturn =
Future<void> Function(bool Function() isCurrent);
/// Account-scoped picker state retained while the Add route is open.
class HostedCommunityChoices {
HostedCommunityChoices._(
this._subject,
this._communities,
this._selectedRelays,
this._landingRelay,
this._addedRelays,
);
final String _subject;
final List<AddCommunityDeepLink> _communities;
final Set<String> _selectedRelays;
final String? _landingRelay;
final Set<String> _addedRelays;
}
/// App-scoped owner: callback subscriptions must outlive any individual page.
/// Only public progress reaches widgets; pending auth secrets remain in BrowserAuth.
class HostedFlow extends ChangeNotifier {
HostedFlow({
required this.account,
required this.browser,
required this.store,
required this.identity,
required this.enterCommunity,
this.openLandingCommunity,
this.captureAddedCommunity,
Future<bool> Function(Uri)? openWeb,
}) : _openWeb =
openWeb ??
((uri) => launchUrl(uri, mode: LaunchMode.externalApplication));
final BuilderlabAccountClient account;
final BrowserAuth browser;
final AccountIdentityStore store;
final HostedIdentityService identity;
final HostedCommunityAdmission enterCommunity;
final HostedCommunityAdmission? openLandingCommunity;
/// Captures the persisted Add destination before restoring older memberships.
final Future<HostedCommunityReturn> Function()? captureAddedCommunity;
bool _returnToAddedCommunity = false;
HostedCommunityReturn? _openAddedCommunity;
/// Restoring older memberships must not replace the newly added destination.
bool get restoringAfterAdd => _returnToAddedCommunity;
bool get canRetry =>
problem == null ||
problem == HostedFlowProblem.retry ||
problem == HostedFlowProblem.invitePolicyRequired ||
problem == HostedFlowProblem.inviteRestricted;
final Future<bool> Function(Uri) _openWeb;
HostedFlowStage stage = HostedFlowStage.idle;
HostedFlowProblem? problem;
List<DeviceIdentityChoice> identityChoices = const [];
BuzzDeepLink? _target;
bool accountOnly = false;
bool _importRequested = false;
bool canCreateIdentity = false;
String? requiredIdentityPubkey;
String? _pairedPubkey;
SensitiveActionPolicy? pairedSensitiveActionPolicy;
List<AddCommunityDeepLink> communities = const [];
Set<String> selectedRelays = const {};
String? landingRelay;
final Set<String> _addedRelays = {};
bool _addingCommunities = false;
String? _batchSubject;
String? _conflictingRelay;
String? _failedRelay;
String? get failedCommunityName {
final relay = _failedRelay;
if (relay == null) return null;
final community = communities
.where((item) => item.relayUrl == relay)
.firstOrNull;
return HostedEnvironment.communityDisplayName(
community?.name ?? Uri.parse(relay).host,
relay,
);
}
bool get canSkipFailedCommunity =>
canChangeCommunities &&
stage == HostedFlowStage.blocked &&
_failedRelay != null;
bool get canChangeCommunities =>
accountOnly &&
account.account?.subject == _batchSubject &&
(stage == HostedFlowStage.blocked ||
stage == HostedFlowStage.communityConflict ||
(stage == HostedFlowStage.working && _addingCommunities)) &&
communities.isNotEmpty;
String? addingCommunityName;
int get addedCommunityCount => _addedRelays.length;
final _queue = SerialQueue();
StreamSubscription<BrowserAuthCode>? _codes;
StreamSubscription<BrowserAuthFailure>? _failures;
bool _disposed = false;
int _generation = 0;
int _nextGeneration() {
_failedRelay = null;
_cancelPolicy();
_cancelProfile();
return ++_generation;
}
Completer<void>? _profileCompletion;
Future<void> Function(String)? saveProfile;
Future<void> Function(String, Future<String> Function()?)? saveProfileDraft;
Future<void> requestProfile(
Future<void> Function(String) save, {
Future<void> Function(String, Future<String> Function()?)? saveDraft,
}) async {
if (_disposed) return;
final completion = Completer<void>();
_profileCompletion = completion;
saveProfileDraft = (name, avatar) async {
if (!identical(_profileCompletion, completion) ||
completion.isCompleted) {
throw AccountFailure.unavailable;
}
if (saveDraft != null) {
await saveDraft(name, avatar);
} else {
await save(name);
}
if (identical(_profileCompletion, completion)) finishProfile();
};
final saveCurrentDraft = saveProfileDraft!;
saveProfile = (name) => saveCurrentDraft(name, null);
_set(HostedFlowStage.profile);
await completion.future;
if (identical(_profileCompletion, completion)) {
_profileCompletion = null;
saveProfile = null;
saveProfileDraft = null;
_set(HostedFlowStage.working);
}
}
void finishProfile() {
final completion = _profileCompletion;
if (completion != null && !completion.isCompleted) completion.complete();
}
void _cancelProfile() {
final completion = _profileCompletion;
_profileCompletion = null;
saveProfile = null;
saveProfileDraft = null;
if (completion != null && !completion.isCompleted) completion.complete();
}
HostedJoinPolicy? joinPolicy;
Completer<bool>? _policyConsent;
Future<bool> confirmJoinPolicy(HostedJoinPolicy policy) async {
if (_disposed) return false;
joinPolicy = policy;
final consent = Completer<bool>();
_policyConsent = consent;
_set(HostedFlowStage.acceptPolicy);
final accepted = await consent.future;
if (identical(_policyConsent, consent)) {
_policyConsent = null;
joinPolicy = null;
if (accepted) _set(HostedFlowStage.working);
}
return accepted;
}
void acceptJoinPolicy() {
final consent = _policyConsent;
if (consent != null && !consent.isCompleted) consent.complete(true);
}
void _cancelPolicy() {
final consent = _policyConsent;
_policyConsent = null;
joinPolicy = null;
if (consent != null && !consent.isCompleted) consent.complete(false);
}
bool reconnecting = false;
Future<void>? _initialization;
Future<void> initialize() => _initialization ??= _initialize();
Future<void> _initialize() async {
if (_codes != null) return;
_codes = browser.codes.listen((code) {
final generation = _generation;
_enqueue(() async {
_importRequested = code.intent['import_identity'] == 'true';
reconnecting = code.intent['type'] == 'reconnect';
accountOnly = reconnecting || code.intent['type'] == 'account';
if (accountOnly) {
_target = null;
} else {
_restorePairing(code.intent);
_target = _decode(code.intent);
if (_target == null) throw BrowserAuthFailure.restartLogin;
}
_set(HostedFlowStage.working);
await account.exchange(code: code.code, verifier: code.verifier);
if (_disposed || generation != _generation) return;
if (reconnecting) {
await browser.clearIntent();
if (_disposed || generation != _generation) return;
_set(HostedFlowStage.complete);
reconnecting = false;
} else {
await _continue(generation);
}
});
});
_failures = browser.failures.listen((failure) {
if (_disposed) return;
if (!accountOnly && _target == null) {
if (_generation == 0) {
_set(HostedFlowStage.blocked, HostedFlowProblem.retry);
}
return;
}
if (stage != HostedFlowStage.signIn) return;
if (failure == BrowserAuthFailure.cancelled) {
unawaited(leave());
} else {
_set(HostedFlowStage.blocked, HostedFlowProblem.retry);
}
});
await _enqueue(() async {
final generation = _generation;
await browser.initialize();
if (_disposed || generation != _generation) return;
if (_target == null) {
final retained = await browser.retainedIntent();
if (_disposed || generation != _generation) return;
if (retained != null && _target == null) {
_importRequested = retained['import_identity'] == 'true';
if (retained['type'] == 'reconnect') {
reconnecting = true;
accountOnly = true;
_set(HostedFlowStage.idle);
} else if (retained['type'] == 'account') {
accountOnly = true;
_set(HostedFlowStage.idle);
} else {
_restorePairing(retained);
_target = _decode(retained);
}
if (_target != null && stage == HostedFlowStage.idle) {
_set(HostedFlowStage.signIn);
}
}
}
});
}
/// Reauthenticate an existing community without enrollment, backup or discovery.
Future<void> reconnectAccount({bool changeAccount = false}) async {
await initialize();
final generation = _nextGeneration();
reconnecting = true;
accountOnly = true;
_target = null;
_set(HostedFlowStage.signIn);
await _enqueue(() async {
if (_disposed || generation != _generation) return;
if (changeAccount) await account.clearLocalSession();
if (_disposed || generation != _generation) return;
await browser.start({'type': 'reconnect'});
});
}
Map<String, String> get _accountIntent => {
'type': 'account',
if (_importRequested) 'import_identity': 'true',
};
/// Explicit Connect completes identity setup and discovers joined communities.
/// Passive startup restore only checks the saved session.
Future<void> signInAccount({
bool openBrowser = true,
bool importIdentity = false,
}) {
if (_target != null ||
stage == HostedFlowStage.working ||
stage == HostedFlowStage.signIn) {
return Future.value();
}
final generation = openBrowser ? _nextGeneration() : _generation;
if (openBrowser) {
_importRequested = _importRequested || importIdentity;
reconnecting = false;
accountOnly = true;
_clearCommunityBatch();
_set(HostedFlowStage.working);
}
return _enqueue(() async {
if (!openBrowser) {
if (_target != null || stage != HostedFlowStage.idle) return;
}
if (openBrowser) {
await browser.retainIntent(_accountIntent);
if (_disposed || generation != _generation) return;
}
final restored = await account.restore();
if (_disposed || generation != _generation) return;
if (!openBrowser) {
// Mounting the welcome page must not create/bind/back up a key.
if (_importRequested && restored != null) {
final saved = await store.forSubject(restored.subject).read();
if (_disposed || generation != _generation) return;
if (saved != null) {
// Also repair intents saved before import completion was recorded.
_importRequested = false;
await browser.retainIntent(_accountIntent);
if (_disposed || generation != _generation) return;
}
}
if (_importRequested && restored != null) {
canCreateIdentity = false;
requiredIdentityPubkey = null;
_set(HostedFlowStage.setupIdentity);
} else {
_set(HostedFlowStage.idle);
}
} else if (restored != null) {
await _continue(generation);
} else {
_set(HostedFlowStage.signIn);
await browser.start(_accountIntent);
}
});
}
/// Securely retain the transferred identity before NIP-AB completion. Hosted
/// admission still waits for account, binding and backup in the normal flow.
Future<void> stagePairedCommunity(
Community community, {
required bool Function() isCurrent,
}) async {
final uri = Uri.parse(community.relayUrl);
if (!HostedEnvironment.supportsRelay(uri)) throw AccountFailure.denied;
final nsec = community.nsec;
if (nsec == null) throw AccountIdentityFailure.absent;
final pubkey = AccountIdentityStore.publicKeyForNsec(nsec);
if (community.pubkey != null && community.pubkey != pubkey) {
throw AccountIdentityFailure.mismatch;
}
final device = await DeviceIdentitySource().choices();
if (device.isNotEmpty && !device.any((item) => item.pubkey == pubkey)) {
throw AccountIdentityFailure.mismatch;
}
final generation = _nextGeneration();
void check() {
if (!isCurrent() || generation != _generation || _disposed) {
throw AccountFailure.unavailable;
}
}
check();
await store.forPairing(pubkey).create(nsec, checkCurrent: check);
check();
final relay = uri.replace(scheme: 'wss').toString();
final target = AddCommunityDeepLink(relayUrl: relay, name: community.name);
_pairedPubkey = pubkey;
pairedSensitiveActionPolicy = community.sensitiveActionPolicy;
await browser.retainIntent(_intent(target));
check();
_target = target;
accountOnly = false;
_set(HostedFlowStage.signIn);
}
void _restorePairing(Map<String, String> intent) {
_pairedPubkey = intent['paired_pubkey'];
pairedSensitiveActionPolicy = intent['paired_protection'] == 'enabled'
? SensitiveActionPolicy.enabled
: null;
}
Map<String, String> _intent(BuzzDeepLink target) => {
..._encode(target),
if (_importRequested) 'import_identity': 'true',
'paired_pubkey': ?_pairedPubkey,
if (pairedSensitiveActionPolicy == SensitiveActionPolicy.enabled)
'paired_protection': 'enabled',
};
Future<void> start(BuzzDeepLink target, {bool resumePairing = false}) {
if (!resumePairing) {
_pairedPubkey = null;
pairedSensitiveActionPolicy = null;
}
if (target is! NostrBindDeepLink &&
target is! AddCommunityDeepLink &&
target is! InviteDeepLink) {
throw ArgumentError('Unsupported hosted target');
}
final generation = _nextGeneration();
reconnecting = false;
accountOnly = false;
_clearCommunityBatch();
_target = target;
_set(HostedFlowStage.working);
return _enqueue(() async {
if (generation != _generation) return;
if (!_acceptTarget(target)) return;
// Login may already be complete. Retain the destination before any
// remote work so restart can reconcile using the same saved identity.
await browser.retainIntent(_intent(target));
if (_disposed || generation != _generation) return;
await _resume(generation, _intent(target));
});
}
Future<void> _resume(int generation, Map<String, String> intent) async {
final restored = await account.restore();
if (_disposed || generation != _generation) return;
if (restored == null) {
_set(HostedFlowStage.signIn);
await browser.start(intent);
} else {
await _continue(generation);
}
}
Future<void> retry() async {
if (reconnecting) return reconnectAccount();
final target = _target;
if (target == null && !accountOnly) return;
final generation = _nextGeneration();
try {
await browser.cancel();
if (_disposed || generation != _generation) return;
if (target != null) {
await start(target, resumePairing: _pairedPubkey != null);
} else {
_set(HostedFlowStage.working);
await _enqueue(() => _resume(generation, _accountIntent));
}
} catch (_) {
if (!_disposed && generation == _generation) {
_set(HostedFlowStage.blocked, HostedFlowProblem.retry);
}
}
}
/// Resume only an offered public identity; reread local and cloud facts first.
Future<void> chooseIdentity(String pubkey) {
if ((_target == null && !accountOnly) ||
stage != HostedFlowStage.chooseIdentity ||
!identityChoices.any((item) => item.pubkey == pubkey)) {
return Future.value();
}
final generation = _nextGeneration();
_set(HostedFlowStage.working);
return _enqueue(() => _continue(generation, selectedPubkey: pubkey));
}
/// Web-binding links retain an explicit choice before creating an identity.
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 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) {
throw AccountFailure.signedOut;
}
// Older native sessions may predate the web setup gate. Preserve their
// subject and destination while completing the same browser handoff.
if (session.needsAccountSetup) {
_set(HostedFlowStage.signIn);
await browser.start(target == null ? _accountIntent : _intent(target));
return;
}
final store = this.store.forSubject(session.subject);
if (target != null && !_acceptTarget(target)) return;
if (target == null && _batchSubject != session.subject) {
_clearCommunityBatch();
_batchSubject = session.subject;
}
final canSealBackup = await HpkeBackup.supportsSealing();
if (_disposed || generation != _generation) return;
var resolved = await identity.resolve(
isCurrent: _isCurrent(generation),
selectedPubkey: selectedPubkey ?? _pairedPubkey,
);
if (_disposed || generation != _generation) return;
if (_importRequested &&
{
HostedIdentityState.ready,
HostedIdentityState.needsBinding,
HostedIdentityState.needsBackup,
}.contains(resolved.state)) {
// Identity selection is durable before binding/backup can fail. Restart
// must resume that work rather than ask for the same key again.
_importRequested = false;
await browser.retainIntent(
target == null ? _accountIntent : _intent(target),
);
if (_disposed || generation != _generation) return;
}
if (resolved.state == HostedIdentityState.needsSetup &&
resolved.canCreate &&
createNew &&
!canSealBackup) {
_set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedPlatform);
return;
}
if (resolved.state == HostedIdentityState.needsSetup &&
resolved.canCreate &&
canSealBackup &&
(target is! NostrBindDeepLink || createNew) &&
!_importRequested) {
// Recheck authoritative absence in the service before generating. Never
// turn failed recovery or a pending backup into a replacement identity.
resolved = await identity.resolve(
isCurrent: _isCurrent(generation),
createNew: true,
);
}
if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) {
_set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch);
return;
}
if (target is! NostrBindDeepLink) {
for (var step = 0; step < 2; step++) {
if (_disposed || generation != _generation) return;
if (!canSealBackup) {
final saved = await store.read();
if (_disposed || generation != _generation) return;
final needsNewBackup =
resolved.state == HostedIdentityState.needsSetup ||
resolved.state == HostedIdentityState.needsBackup ||
(resolved.state == HostedIdentityState.needsBinding &&
!(saved?.hasBackupFor(
backend: HostedEnvironment.origin.origin,
subject: session.subject,
) ??
false));
if (needsNewBackup) {
_set(
HostedFlowStage.blocked,
HostedFlowProblem.unsupportedPlatform,
);
return;
}
}
if (resolved.state == HostedIdentityState.needsBinding &&
resolved.pubkey != null) {
final response = await account.post(
'/v1/buzz/nostr-identities/challenge',
{'origin': HostedEnvironment.origin.toString()},
);
if (_disposed || generation != _generation) return;
final fields = <String, String>{
'audience': 'buzz:nostr-identity',
'action': 'bind_nostr_identity',
'protocol': 'buzz-nostr-identity',
'version': '1',
'return': 'browser_fragment_v1',
'callback_url': HostedEnvironment.origin
.resolve('/buzz')
.toString(),
};
for (final key in [
'challenge_id',
'nonce',
'verification_code',
'origin',
'expires_at',
]) {
final value = response[key];
if (value is! String) throw AccountFailure.invalidResponse;
fields[key] = value;
}
final request = parseNostrBindDeepLink(
Uri(scheme: 'buzz', host: 'nostr-bind', queryParameters: fields),
);
if (response['error'] != null || request == null) {
throw AccountFailure.invalidResponse;
}
await WebConnectBindingService().complete(
request: request,
subject: session.subject,
expectedPubkey: resolved.pubkey!,
withKeys: store.withKeys,
verify: _verifyBinding,
isCurrent: _isCurrent(generation),
);
} else if (resolved.state == HostedIdentityState.needsBackup) {
await HostedBackupService(
account,
store,
).backup(isCurrent: _isCurrent(generation));
} else {
break;
}
if (_disposed || generation != _generation) return;
resolved = await identity.resolve(isCurrent: _isCurrent(generation));
}
}
if (generation != _generation || _disposed) return;
if (resolved.state == HostedIdentityState.needsSetup) {
canCreateIdentity =
resolved.canCreate && target != null && !_importRequested;
requiredIdentityPubkey = resolved.pubkey;
_set(HostedFlowStage.setupIdentity);
return;
}
if (resolved.state == HostedIdentityState.needsSelection) {
identityChoices = List.unmodifiable(resolved.choices);
_set(HostedFlowStage.chooseIdentity);
return;
}
final pubkey = resolved.pubkey;
if (target is NostrBindDeepLink &&
pubkey != null &&
{
HostedIdentityState.ready,
HostedIdentityState.needsBinding,
HostedIdentityState.needsBackup,
}.contains(resolved.state)) {
final returnTo = await WebConnectBindingService().complete(
request: target,
subject: session.subject,
expectedPubkey: pubkey,
withKeys: store.withKeys,
verify: _verifyBinding,
isCurrent: _isCurrent(generation),
);
if (generation != _generation || _disposed) return;
if (!await _openWeb(returnTo)) throw BrowserAuthFailure.unavailable;
await browser.clearIntent();
_set(HostedFlowStage.browserReturned);
return;
}
if (resolved.state != HostedIdentityState.ready || pubkey == null) {
final failure = switch (resolved.state) {
HostedIdentityState.needsBackup => HostedFlowProblem.backupRequired,
HostedIdentityState.needsBinding => HostedFlowProblem.bindingRequired,
HostedIdentityState.mismatch => HostedFlowProblem.identityMismatch,
_ => HostedFlowProblem.identityUnavailable,
};
_set(HostedFlowStage.blocked, failure);
return;
}
if (target == null) {
await _continueCommunities(generation, session.subject, pubkey);
return;
}
final destination = target is HostedCreateCommunity
? await target.provision(account, pubkey, _isCurrent(generation))
: target;
if (_disposed || generation != _generation) return;
await enterCommunity(
destination,
session.subject,
pubkey,
_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);
}
void chooseLandingCommunity(String relay) {
if (stage != HostedFlowStage.chooseCommunities ||
!communities.any((item) => item.relayUrl == relay)) {
return;
}
_returnToAddedCommunity = false;
_openAddedCommunity = null;
selectedRelays = Set.unmodifiable({relay});
landingRelay = relay;
notifyListeners();
}
/// Capture only a current account's discovered community choices.
HostedCommunityChoices? captureCommunityChoices() {
if (stage != HostedFlowStage.chooseCommunities ||
_batchSubject == null ||
account.account?.subject != _batchSubject) {
return null;
}
return HostedCommunityChoices._(
_batchSubject!,
List.unmodifiable(communities),
Set.unmodifiable(selectedRelays),
landingRelay,
Set.unmodifiable(_addedRelays),
);
}
/// Return from Add without repeating account discovery or relay admission.
/// A changed or expired account must go through sign-in again.
bool restoreCommunityChoices(HostedCommunityChoices choices) {
if (_disposed || account.account?.subject != choices._subject) return false;
_nextGeneration();
_target = null;
_pairedPubkey = null;
pairedSensitiveActionPolicy = null;
reconnecting = false;
accountOnly = true;
_clearCommunityBatch();
_batchSubject = choices._subject;
communities = choices._communities;
selectedRelays = choices._selectedRelays;
landingRelay = choices._landingRelay;
_addedRelays.addAll(choices._addedRelays);
_set(HostedFlowStage.chooseCommunities);
return true;
}
Future<void> addSelectedCommunities({bool returnToAddedCommunity = false}) {
if (!accountOnly ||
stage != HostedFlowStage.chooseCommunities ||
selectedRelays.isEmpty) {
return Future.value();
}
_returnToAddedCommunity = returnToAddedCommunity || _returnToAddedCommunity;
_addingCommunities = true;
final generation = _nextGeneration();
_set(HostedFlowStage.working);
return _enqueue(() => _continue(generation));
}
/// Keep completed additions while allowing a different landing choice.
void backToCommunityChoices() {
if (!canChangeCommunities) return;
_nextGeneration();
_conflictingRelay = null;
addingCommunityName = null;
_set(HostedFlowStage.chooseCommunities);
}
/// Omit only the failed membership for this restore attempt.
Future<void> skipFailedCommunity() {
if (!canSkipFailedCommunity) return Future.value();
final relay = _failedRelay!;
selectedRelays = Set.unmodifiable(
selectedRelays.where((item) => item != relay),
);
_failedRelay = null;
final generation = _nextGeneration();
_set(HostedFlowStage.working);
return _enqueue(() => _continue(generation));
}
Future<void> skipConflictingCommunity() {
final relay = _conflictingRelay;
if (stage != HostedFlowStage.communityConflict || relay == null) {
return Future.value();
}
selectedRelays = Set.unmodifiable(
selectedRelays.where((item) => item != relay),
);
_conflictingRelay = null;
final generation = _nextGeneration();
_set(HostedFlowStage.working);
return _enqueue(() => _continue(generation));
}
Future<void> _continueCommunities(
int generation,
String subject,
String pubkey,
) async {
bool current() =>
!_disposed &&
generation == _generation &&
account.account?.subject == subject;
if (!_addingCommunities) {
final listed = await loadKnownHostedCommunities(account);
if (!current()) return;
communities = listed;
selectedRelays = Set.unmodifiable(listed.map((item) => item.relayUrl));
if (listed.isEmpty) {
await leave(noCommunities: true);
return;
}
landingRelay = listed.first.relayUrl;
// A picker tap narrows admission to the chosen community.
_set(HostedFlowStage.chooseCommunities);
return;
}
if (_returnToAddedCommunity && _openAddedCommunity == null) {
final capture = captureAddedCommunity;
if (capture == null) throw AccountFailure.unavailable;
final open = await capture();
if (!current()) return;
_openAddedCommunity = open;
}
_failedRelay = null;
final ordered = [
...communities.where((item) => item.relayUrl == landingRelay),
...communities.where((item) => item.relayUrl != landingRelay),
];
for (final community in ordered) {
if (!current()) return;
if (!selectedRelays.contains(community.relayUrl) ||
_addedRelays.contains(community.relayUrl)) {
continue;
}
addingCommunityName = community.name;
notifyListeners();
try {
await enterCommunity(community, subject, pubkey, current);
} on SavedCommunityIdentityConflict {
if (!current()) return;
_conflictingRelay = community.relayUrl;
_set(HostedFlowStage.communityConflict);
return;
} catch (_) {
if (current()) _failedRelay = community.relayUrl;
rethrow;
}
if (!current()) return;
_addedRelays.add(community.relayUrl);
}
if (!current()) return;
addingCommunityName = null;
if (_openAddedCommunity != null) {
await _openAddedCommunity!(current);
if (!current()) return;
await browser.clearIntent();
if (current()) _set(HostedFlowStage.complete);
return;
}
if (_addedRelays.isEmpty) {
communities = List.unmodifiable(
communities.where((item) => selectedRelays.contains(item.relayUrl)),
);
await leave(noCommunities: true);
return;
}
final destination = _addedRelays.contains(landingRelay)
? landingRelay!
: _addedRelays.first;
await openLandingCommunity?.call(
AddCommunityDeepLink(
relayUrl: destination,
name: Uri.parse(destination).host,
),
subject,
pubkey,
current,
);
if (!current()) return;
await browser.clearIntent();
if (current()) _set(HostedFlowStage.complete);
}
bool _acceptTarget(BuzzDeepLink target) {
final relay = target.relayUrl;
if (relay != null && !HostedEnvironment.supportsRelay(Uri.parse(relay))) {
_set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedEnvironment);
return false;
}
return true;
}
Future<String> _verifyBinding({
required String subject,
required String challengeId,
required String nonce,
required String signedPayload,
}) async {
if (account.account?.subject != subject) {
throw AccountFailure.differentAccount;
}
final response = await account.post('/v1/buzz/nostr-identities/verify', {
'challenge_id': challengeId,
'nonce': nonce,
'signed_payload': signedPayload,
});
if (account.account?.subject != subject) {
throw AccountFailure.differentAccount;
}
final bound = response['identity'];
if (response['error'] != null ||
bound is! Map ||
bound['pubkey_hex'] is! String) {
throw AccountFailure.invalidResponse;
}
return bound['pubkey_hex'] as String;
}
bool Function() _isCurrent(int generation) =>
() => !_disposed && generation == _generation;
Future<void> _enqueue(Future<void> Function() operation) {
final generation = _generation;
final result = _queue.run(() async {
if (_disposed || generation != _generation) return;
try {
await operation();
} catch (error) {
if (!_disposed && generation == _generation) {
_set(HostedFlowStage.blocked, _problemFor(error));
}
}
});
return result;
}
static HostedFlowProblem _problemFor(Object error) => switch (error) {
HostedCreationFailure.rejected =>
HostedFlowProblem.communityCreationRejected,
HostedInviteFailure.policyRequired =>
HostedFlowProblem.invitePolicyRequired,
HostedInviteFailure.expired => HostedFlowProblem.inviteExpired,
HostedInviteFailure.exhausted => HostedFlowProblem.inviteExhausted,
HostedInviteFailure.invalid => HostedFlowProblem.inviteInvalid,
HostedInviteFailure.restricted => HostedFlowProblem.inviteRestricted,
HostedInviteFailure.ownerMismatch => HostedFlowProblem.inviteOwnerMismatch,
AccountFailure.denied => HostedFlowProblem.denied,
AccountFailure.accountSetupRequired =>
HostedFlowProblem.accountSetupRequired,
AccountFailure.rejected ||
HostedBackupFailure.rejected => HostedFlowProblem.requestRejected,
HostedBackupFailure.unavailable => HostedFlowProblem.backupUnavailable,
AccountFailure.differentAccount => HostedFlowProblem.differentAccount,
AccountIdentityFailure.unreadable ||
AccountIdentityFailure.invalidKey ||
AccountFailure.storage ||
HostedBackupFailure.storage => HostedFlowProblem.identityUnreadable,
SavedCommunityIdentityConflict() ||
AccountIdentityFailure.mismatch ||
AccountIdentityFailure.occupied ||
HostedBackupFailure.identityMismatch ||
HpkeBackupFailure.identityMismatch => HostedFlowProblem.identityMismatch,
HpkeBackupFailure.unavailable => HostedFlowProblem.backupUnavailable,
AccountFailure.invalidResponse ||
HostedBackupFailure.invalidResponse ||
HpkeBackupFailure.invalidEnrollment => HostedFlowProblem.invalidResponse,
_ => HostedFlowProblem.retry,
};
void _set(HostedFlowStage next, [HostedFlowProblem? failure]) {
if (_disposed) return;
if (next != HostedFlowStage.chooseIdentity) identityChoices = const [];
stage = next;
problem = failure;
notifyListeners();
}
bool noCommunitiesToAdd = false;
void _clearCommunityBatch() {
_returnToAddedCommunity = false;
_openAddedCommunity = null;
noCommunitiesToAdd = false;
communities = const [];
selectedRelays = const {};
landingRelay = null;
_failedRelay = null;
_addedRelays.clear();
_batchSubject = null;
_conflictingRelay = null;
_addingCommunities = false;
addingCommunityName = null;
}
/// Ends the account session without removing saved identities or communities.
Future<void> signOutAccount() async {
await leave();
final generation = _generation;
if (_disposed || stage != HostedFlowStage.idle) return;
accountOnly = true;
_set(HostedFlowStage.working);
await _enqueue(() async {
await account.clearLocalSession();
if (_disposed || generation != _generation) return;
accountOnly = false;
_set(HostedFlowStage.idle);
});
}
Future<void> leave({bool noCommunities = false}) async {
_nextGeneration();
final generation = _generation;
_target = null;
_importRequested = false;
_pairedPubkey = null;
pairedSensitiveActionPolicy = null;
reconnecting = false;
accountOnly = false;
_clearCommunityBatch();
noCommunitiesToAdd = noCommunities;
try {
await browser.cancel();
if (_disposed || generation != _generation) return;
await browser.clearIntent();
if (generation == _generation) _set(HostedFlowStage.idle);
} catch (_) {
if (generation == _generation) {
_set(HostedFlowStage.blocked, HostedFlowProblem.retry);
}
}
}
static Map<String, String> _encode(BuzzDeepLink target) {
if (target is HostedCreateCommunity) {
return {'type': 'create', 'name': target.name};
}
if (target is NostrBindDeepLink) return {'type': 'bind', ...target.fields};
if (target is AddCommunityDeepLink) {
return {'type': 'add', 'relay': target.relayUrl, 'name': target.name};
}
final invite = target as InviteDeepLink;
return {
'type': 'invite',
'relay': invite.relayUrl,
'code': invite.code,
if (invite.policyReceipt != null) 'policy_receipt': invite.policyReceipt!,
};
}
static BuzzDeepLink? _decode(Map<String, String> intent) {
if (intent['type'] == 'create') {
final name = intent['name'];
return name != null && HostedCreateCommunity.validName(name)
? HostedCreateCommunity(name)
: null;
}
final fields = Map<String, String>.of(intent)
..remove('type')
..remove('import_identity')
..remove('paired_pubkey')
..remove('paired_protection');
final host = switch (intent['type']) {
'bind' => 'nostr-bind',
'add' => 'add-community',
'invite' => 'join',
_ => '',
};
return parseBuzzDeepLink(
Uri(scheme: 'buzz', host: host, queryParameters: fields),
);
}
@override
void dispose() {
_disposed = true;
_cancelPolicy();
_cancelProfile();
_nextGeneration();
unawaited(_codes?.cancel());
unawaited(_failures?.cancel());
super.dispose();
}
}