Files
buzz/mobile/lib/shared/hosted/hosted_flow.dart
John Matthew Tennant c848b6dcad fix(mobile): offer a new identity beside saved keys on unclaimed accounts (#9107)
## Summary

This fixes a dead end in mobile Builderlab sign-in. A new account
(unbound, with no backups) on a device that already had saved identities
could only pick one of those identities. Any saved key forced the
picker, and creating a key required the device to have none. If the
service then definitively rejected binding the chosen key, the app
showed "Let's try that again" with no way forward: the chosen key stayed
in the account's slot, so every retry and every launch re-sent the same
rejected bind.

For an unclaimed account, the picker now also offers **Create a new
identity**:

- Creation writes only this account's empty identity slot, so every
saved and device key is kept.
- Creation is offered only when every current device key was among the
choices shown, so a new identity never silently passes over a key the
user didn't see.
- Bound accounts, accounts with backups, pairing and pending import
never offer it.

When binding a chosen key is definitively rejected, the blocked screen
offers **Choose a different identity**:

- It appears only for a 400, 404, 409 or 422 from `/verify` in the
current attempt, for the current account and pubkey, and never during
pairing, whose transferred key is the only choice. Evidence from an
earlier attempt is cleared before each bind. A challenge rejection, a
403, a 503 or any other ambiguous failure offers nothing.
- Before releasing the slot, it rechecks the account: still unbound
(`/current` and status), no backups, the same subject, and a slot that
still holds that pubkey without a receipt.
- It releases the slot only when another durable copy of the same key
exists (the device inventory or another account slot), then verifies the
slot is empty. No key is deleted: only the account's pointer to the
rejected choice is cleared. A failed or refused release keeps the slot
and the choice, so it can be retried in place or after a restart.
- After release, the picker runs again and offers the saved keys plus
creation.

The recovery code lives in `hosted_flow_identity_recovery.dart`, a part
of `hosted_flow.dart`, to stay within the mobile file-size limit.

### Related issue

None found.

### Testing

**Physical iPhone, a signed release build of `1fb633d55` installed over
the existing app (keychain and saved identities kept):** a saved
identity was rejected, then Choose a different identity, the picker,
Create a new identity and Join your community. Joining then completed.
This head differs from `1fb633d55` only in the guards for leaving,
pairing and a failed or refused release listed below, which this journey
does not reach, and in no UI code. The status of that phone's original
rejection was not captured, so it is not confirmed to be one of the
rejections above. The 409 case is covered by tests.

**Tests (red on `main`, green here):**

- Identity service and store: creation beside offered keys; refusal for
unseen or partly offered device keys; release when another copy exists;
refusal for a sole copy, a binding, a backup, a receipt, another account
or an unusable recheck; failed delete and retry; a cancelled attempt.
- Onboarding journeys: create beside saved keys; select, rejected bind,
restart, choose a different identity, create and bind; ambiguous 503 and
403 failures; challenge rejection and stale evidence.
- Release boundaries: an account switch at `/current`, at status, or
between the recheck and the delete; cancelling between the recheck and
the delete; leaving the picker before a late creation; an unreadable
device inventory; leaving while a release is in flight; a failed delete
retried in place and after a restart; a release refused because the
inventory is unreadable, then retried in place; a rejected paired key.
Each keeps every key, leaves the new account's slot untouched and leaves
a cancelled flow idle, and a retry converges.
- Mutating the creation guard, the generation check, the binding
recheck, the store's receipt check, the account and cancellation checks
before the delete, or the device-inventory error handling fails a test.

#### Screenshots

Physical iPhone at `1fb633d55` (frames from a screen recording; saved
public keys redacted):

| Rejected bind | After Choose a different identity | After Create a new
identity |
| --- | --- | --- |
| ![Phone: rejected bind offers Choose a different
identity](https://github.com/user-attachments/assets/cfd81998-d860-4b2e-9963-e68ec322ef9b)
| ![Phone: picker with saved identities and Create a new
identity](https://github.com/user-attachments/assets/85235963-746c-489c-8134-a1c20f6c7cd1)
| ![Phone: Join your community after creating a new
identity](https://github.com/user-attachments/assets/bc7505c4-147d-4b03-8073-db4be0bd88f8)
|

Widget-test renders of the same screens (the real `HostedFlow` against a
mocked account service, with a fixture key; the square at the top left
is the test renderer's placeholder for an asset):

| Picker on an unclaimed account | Rejected bind |
| --- | --- |
| ![Widget render: picker with Create a new
identity](https://github.com/user-attachments/assets/3616eba2-f9dc-4e36-b6de-21cbad5fc8b6)
| ![Widget render: rejected bind with Choose a different
identity](https://github.com/user-attachments/assets/30bc6ef2-d63e-4f4b-8193-153ce5a3cafc)
|

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Xander <ff30c859b96bd5233ffaf5d071200458f1274bd0dab03589214cf1055198491d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:23:59 -05:00

1195 lines
40 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';
part 'hosted_flow_identity_recovery.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;
_RejectedBinding? _rejectedBinding;
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));
}
/// Create explicitly from setup, or beside the saved identities offered.
Future<void> createIdentity() {
if ((_target == null && !accountOnly) ||
!canCreateIdentity ||
(stage != HostedFlowStage.setupIdentity &&
stage != HostedFlowStage.chooseIdentity)) {
return Future.value();
}
final offered = {for (final item in identityChoices) item.pubkey};
final generation = _nextGeneration();
_set(HostedFlowStage.working);
return _enqueue(
() => _continue(generation, createNew: true, offered: offered),
);
}
/// 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,
Set<String> offered = const {},
}) 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;
}
// Selection offers creation beside saved identities only on request.
final creatable =
resolved.canCreate &&
(resolved.state == HostedIdentityState.needsSetup ||
(createNew &&
resolved.state == HostedIdentityState.needsSelection));
if (creatable && createNew && !canSealBackup) {
_set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedPlatform);
return;
}
if (creatable &&
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,
offered: offered,
);
}
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;
}
final pubkey = resolved.pubkey!;
await _observeBinding(
session.subject,
pubkey,
generation,
() => WebConnectBindingService().complete(
request: request,
subject: session.subject,
expectedPubkey: 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) {
canCreateIdentity =
resolved.canCreate && _pairedPubkey == null && !_importRequested;
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(_verifyPath, {
'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();
}
}