Files
buzz/mobile/lib/shared/hosted/hosted_flow.dart
Wes 59ae028a36 fix(mobile): require Builderlab consent for existing identities (#8823)
Existing mobile identities entering the Builderlab hosted flow now
require confirmation before the app reserves the account’s identity
slot, binds the identity, or uploads its secret key. Cancelling leaves
saved community keys usable and allows another identity to be selected,
including after restart. Imported keys remain tentative until approval;
paired keys remain safely retained outside the account slot.

The confirmation shows the receiving account’s verified email and public
key, explains that Builderlab can decrypt the backup and signed-in
account users can recover it, and discloses that the app cannot change
the linked identity later. **Cancel setup** stops future work without
claiming to undo earlier binding or an upload whose acknowledgement was
lost. The user can sign out; missing account identification blocks
confirmation. Browser-initiated binding uses **Link and continue**, with
explicit bind-only wording and no upload.

Fresh keys generated in the current hosted attempt use ordinary
onboarding without an extra backup prompt. Interrupted attempts without
a confirmed backup ask again; approval is deliberately not persisted or
carried across retries, cancellation, restart, account changes, or a
replaced flow. Confirmed matching backup receipts skip the backup
prompt. Cloud recovery and organization-managed backup are separate
flows and are not changed here; this PR does not resolve server-side
consent or recovery-authorization policy.

Updated against main through `ce42853982` (#9418). Integration commit
`dfb29fe5e7` includes the landed Hickory security fix and the shared
workflow-test/runner repair. The consent implementation is unchanged
from Carl’s reviewed `e11025ebed`; the CI merge preserves its configured
public consent journeys.

Validation at `e11025ebed`: 96 focused tests across eight files passed
with public hosted domains configured; Flutter analysis passed. In an
isolated source/Git/build copy, removing pre-adoption consent, bypassing
the upload gate, and removing stale-confirmation checks each failed the
consent journeys; restoring the code passed. Carl’s independent source
review found no blockers. Mandatory hooks and hosted mobile CI passed,
including both consent journeys in the configured public lane.

Integration validation at `dfb29fe5e7`: 99 focused tests passed; one
additional fullscreen-onboarding test failed its background
community-admission assertion. An isolated clean-main control at
`ce42853982` reproduces the same failure with the same expected/actual
values. All consent journeys passed. Required commit and push hooks
passed, including mobile analysis and the broader default mobile suite.
Hosted CI is green at `dfb29fe5e7` ([run
43320753801](https://github.com/block/buzz/actions/runs/43320753801)).
Security and the previously failing Rust/workflow gates now pass. The
Mobile Checks log confirms both consent journeys executed successfully;
that run has no pending checks.

Review follow-up `ac9ac9be35`, integrated with main `279a31daac` at
current head `1acfd2dff6`, is test-only: the browser journey now
restores a saved account key without a receipt, cancels without binding,
restarts and requires explicit approval before `/verify`. Both consent
journeys passed again after integration using the updated CI settings.
Disabling only the production bind gate in an isolated source/Git/build
copy now fails the added step; restoring it passes. Mandatory
commit/push hooks passed. Hosted CI is green at `1acfd2dff6` ([run
43343333843](https://github.com/block/buzz/actions/runs/43343333843));
no failing or pending checks remain. The Mobile Checks log confirms both
consent journeys executed successfully, including the extended
browser-binding journey. Carl’s latest review on `dfb29fe5e7` found no
production blockers; its recommended test gap is addressed by this
follow-up.

Native-device and human acceptance have not been recorded here; the
author’s ready-for-review setting is preserved.

Try it: with two saved self-hosted keys, connect to Builderlab, select
A, cancel, restart, then select B. Check the receiving account and key
before approval. Repeat with an imported/paired key and a browser bind
link; cancel must not reserve or bind the new selection. After an
interrupted upload, cancellation must describe that earlier effects may
remain. Confirm that ordinary brand-new onboarding has no extra backup
step.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-11-13 11:55:32 -07:00

1190 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';
part 'hosted_flow_identity_recovery.dart';
part 'hosted_flow_backup_consent.dart';
enum HostedFlowStage {
idle,
working,
signIn,
chooseIdentity,
setupIdentity,
confirmBackup,
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;
({String subject, String pubkey})? _pendingBackup;
String? _pendingImport;
String? get backupPubkey => _pendingBackup?.pubkey;
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() {
_pendingBackup = null;
_pendingImport = null;
_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));
}
Future<void> _continue(
int generation, {
String? selectedPubkey,
bool createNew = false,
({String subject, String pubkey})? approvedBackup,
Set<String> offered = const {},
String? importNsec,
}) 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,
importNsec: importNsec,
canAdopt: (pubkey) =>
approvedBackup == (subject: session.subject, pubkey: pubkey),
);
if (_disposed || generation != _generation) return;
if (resolved.state == HostedIdentityState.needsConsent) {
_presentBackupConsent(
session.subject,
resolved.pubkey!,
importNsec: importNsec,
);
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,
canAdopt: (pubkey) =>
approvedBackup == (subject: session.subject, pubkey: pubkey),
);
}
if (_disposed || generation != _generation) return;
if (resolved.state == HostedIdentityState.needsConsent) {
_presentBackupConsent(session.subject, resolved.pubkey!);
return;
}
if (resolved.generated && resolved.pubkey != null) {
approvedBackup = (subject: session.subject, pubkey: resolved.pubkey!);
}
if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) {
_set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch);
return;
}
if (target is NostrBindDeepLink &&
!await _allowBackup(
resolved,
session.subject,
generation,
canSealBackup,
approvedBackup: approvedBackup,
)) {
return;
}
if (target is! NostrBindDeepLink) {
for (var step = 0; step < 2; step++) {
if (_disposed || generation != _generation) return;
if (!await _allowBackup(
resolved,
session.subject,
generation,
canSealBackup,
approvedBackup: approvedBackup,
)) {
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();
}
}