Files
buzz/mobile/lib/shared/hosted/hosted_flow.dart
Wes b6aa5539e3 feat(mobile): account onboarding, key recovery and hosted reconnect (#8375)
## Behavior

Adds optional mobile Builderlab sign-in, identity backup/recovery,
community discovery/creation, and account-aware reconnect. **Builderlab
features are disabled by default:** when `BUZZ_AUTH_DOMAINS` is unset or
empty, account login, creation, backup/recovery, reconnect prompts, and
background account restoration are hidden/disabled. QR/code pairing,
links, and invites retain Nostr-only entry. Saved account state is
retained for later opt-in.


`--dart-define=BUZZ_AUTH_DOMAINS=communities.buzz.xyz,communities.public.builderlabstaging.xyz`
opts those DNS domains and their subdomains into required account setup.
The same policy gates pairing, deep links, durable community admission,
and reconnect. A nonempty list enables Builderlab features;
`BUZZ_BUILDERLAB_URL` alone does not. Enabled flows still require their
backend services. Existing Nostr relay authentication remains mandatory.

`BUZZ_HOSTED_RELAY_AUTH=true` enables scoped relay assertions and
reconnect prompts only for configured authentication domains. Configure
assertion issuance and each community's issuer binding before enabling
assertions; enable relay enforcement last. Account credentials stay at
Builderlab. Wrong-account recovery preserves keys; failed writes are not
replayed; authentication is bounded by request deadlines.

Hosted flows preserve keys across cancellation/restart, reconcile
interrupted creation/invitation acceptance, and support manual nsec file
import/export. Account identities are scoped to backend and
authenticated subject; reusing an existing local key for an unbound
account requires an explicit selection. Recovery reconciles the
advertised and recovered inventories before automatic selection.
Successful self-hosted admission retires its pending key, and iOS
startup removes interrupted export files. Adds authenticated relay `GET
/operator/communities/joined` for the companion Builderlab membership
endpoint.

## Validation

- Follow-up review repair at `66a12ac92`: self-hosted entry presents
readable choices and import despite incomplete enumeration, while
generation still requires a complete empty inventory; explicit selection
uses exact-key lookup. Consuming a value-equal duplicate now advances
dispatch. Reconnect requires an existing account binding or a
subject-scoped backup receipt before signing an assertion, and pins the
subject inside the serialized account request. An empty different
account is refused without sending key proof. Focused checks: configured
domains 58 passed; default 49 passed, 9 hosted-only skips. Separate
isolated mutations fail the incomplete-inventory sheet, exact-key reuse,
duplicate dispatch and reconnect journeys (including an
empty-account-only bypass); restored controls pass. Evidence uses
synthetic storage/network boundaries, not physical Android or live
backend association. All mandatory commit/push hooks passed; fresh
GitHub CI is pending.

- Bounded review repair at `d3cc0003f`: incomplete secure-storage
enumeration now blocks automatic identity generation while explicit
known-key reads retain their fallback; declined/failed onboarding links
release the queue; HTTP 429/5xx keeps Retry regardless of response
encoding. Default app-gate coverage exercises a bind link followed by
ordinary community entry. Focused checks: configured domains 47 passed;
default 38 passed, 9 hosted-only skips. Removing the three production
fixes in an isolated source/Git/build copy fails the
identity-resolution, declined/failed queue, and Retry journey
assertions; clean and restored controls pass. Storage and HTTP failures
are synthetic; physical Android and live outage behavior were not
exercised. Published after all mandatory commit/push hooks passed
(mobile analysis/tests, branch-skew and file-size gates); fresh GitHub
CI remains pending.

- Existing whole-PR source review cleared the reconnect defects at
`fb15d5b82`.
- Domain-policy increment: focused environment, entry, app-gate, pairing
and onboarding journeys pass with empty and configured domains. Existing
pairing provider/page tests pass. Changed Dart files analyze cleanly.
- Isolated source/Git/build mutation: forcing paired credentials through
hosted login fails the default-build journey; clean and restored
controls pass. CI now runs configured-domain journeys in addition to the
default-build suite.
- Integrated main through `5b433d929` at `0067c069c`, including its
pinned Flutter upgrade. Preserved the PR welcome UI with the updated
transition API and added Material surfaces around onboarding
selection/consent rows. Focused post-merge runs pass in both
configurations; all mandatory local push gates passed (mobile
analysis/tests, Rust tests, desktop-native checks, branch-skew and
file-size checks).

- Merge-review repair at `a315bce11`: focused identity/storage/admission
and widget journeys pass, including account switching with an empty or
already-bound second account, explicit reuse, and self-hosted
removal/rejoin with another key. Default-domain journeys pass too
(hosted-only cases intentionally skip there).
- Isolated source/Git/build mutation checks: bypassing account scoping,
reuse consent, pending-key retirement, or recovery inventory
reconciliation fails the relevant journey/invariant assertions; clean
and restored controls pass.
- Native `RunnerTests/IdentityFilePluginTests`: both tests pass on the
iPhone 18 Pro iOS 27 simulator with an isolated app identifier. At
`24a0dcaac`, the picker journey seeds residue in Library/Caches,
matching path_provider_foundation, and verifies plugin startup removes
it while preserving an unrelated file. The earlier tmp-only fixture did
not exercise the actual export location. This is synthetic native
cleanup/picker evidence, not a real terminated-share or live account
round trip.

- Follow-up: Caches and tmp cleanup; fallback to individually known
identity slots when Android bulk enumeration fails; explicit replacement
of a refused self-hosted key preserves its original before swapping the
retry slot. Configured-domain journeys/invariants: 34 passed; default:
31 passed, 3 hosted-only skips. Isolated enumeration/retry mutations
fail with passing controls. Bulk-read fallback preserves but cannot
enumerate arbitrary other-account slots until bulk reads recover.

- Configured-domain CI exposed a stale pairing-status assertion after
account scoping. The repaired test checks the admission mismatch,
preserved account/transfer keys, and no admitted community. The
configured-domain CI selection passes locally (56 tests); removing the
production mismatch guard fails the updated test, with clean/restored
controls passing. This is synthetic coordinator evidence, not physical
QR acceptance.

- Final review repair (`40e1e6101`): native iOS export creation uses
atomic complete file protection; normal and restart cleanup remain.
Account/policy/claim HTTP uses one 30-second total deadline with request
abort and body cancellation. Removed the unrelated NIP-FA spec edit.
- Focused account/admission/export tests: 25 pass; configured-domain
account/admission/onboarding journeys: 33 pass. Isolated deadline and
native-dispatch mutations fail, with passing clean/restored controls.
Native export/picker tests: 2 pass on iOS 27 simulator. The real
file-protection attribute assertion is physical-device-only because
simulator provides no Data Protection attribute; locked-device
protection and interrupted real sharing have not been observed.

- Opt-in UI: configured-domain CI selection passes (57 tests); default
selection passes (7 tests, 10 hosted-only skips); URL-only plus
whitespace/empty domains passes (6 tests, 9 hosted-only skips). Changed
Dart files analyze cleanly. Independent mutations that unconditionally
initialize the coordinator in the app gate, welcome entry, or link form
fail the default journey; clean/restored controls pass.
- Native welcome screens exercised on iPhone 18 Pro / iOS 27 simulator
in default and configured builds: default exposes community link / QR /
pairing code only; configured build additionally exposes Builderlab
connection and community creation. No live hosted login was exercised in
this check.

- Delivery at `e48fe73a9`: all mandatory push hooks passed (full mobile
analysis/tests, Rust tests, desktop-native checks, branch and file-size
checks). GitHub checks had not yet populated at the immediate post-push
snapshot.

## Review disposition

| Feedback | Disposition |
| --- | --- |
| Explicit self-hosted entry under unreadable inventory, duplicate
invite dispatch, reconnect cross-account binding
([entry](https://github.com/block/buzz/pull/8375#discussion_r4318421622),
[queue](https://github.com/block/buzz/pull/8375#discussion_r4318421640),
[reconnect](https://github.com/block/buzz/pull/8375#discussion_r4318455512))
| Repaired at `66a12ac92`; evidence above. Reconnect refuses unproven
ownership rather than silently establishing a binding. |
| Incomplete Android identity inventory, stranded deep-link queue,
transient HTTP retry classification
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5576864143))
| All three repaired in `d3cc0003f`; focused journeys and isolated
mutation evidence above. |
| Account cross-link, incomplete recovery inventory, committed invite
checkpoint, pending local key retirement/replacement | Repaired in the
preceding commits; journey and mutation evidence above. |
| iOS interrupted key export
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5575253252))
| Native cleanup plus complete protection at creation implemented.
Physical-device lock and real interrupted sharing acceptance remains
pending. |
| Trickle responses bypass idle timeout; unrelated NIP-FA edit
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5575764556))
| Total request deadline/abort implemented; spec edit removed. |
| Production domains rejected | Ordinary entry now needs no Builderlab
configuration. Opt-in account-required domains must still use supported
community mappings. |
| Lost Android Keystore recovery | Backup exclusions prevent the
reviewed stale-restore scenario; genuine key loss still has no in-app
recovery/reset and remains deferred. |
| Native secp256k1 dependency question | Obsolete against the current
base: upstream C integration landed in #8398; no package diff remains
here. |
| Operator membership endpoint contract | Documentation now states the
operator chooses the pubkey; no end-user session binding is implied.
Additional handler-level coverage is optional follow-up. |
| Optional Android partial document cleanup/activity retention; local
work after backup cancellation | Follow-up hardening, not claimed
implemented. Cancellation prevents remote backup upload. |
| Semgrep crypto inventory notices | Informational inventory, not
demonstrated defects. |

## Remaining draft gates

Native QR/browser-return acceptance remains unverified for this
revision. Optional Builderlab onboarding still needs backend
backup-status and joined-community routes, recovery
policy/configuration, and real backup/recovery/reconnect acceptance. The
empty-domain build removes these services as prerequisites for ordinary
pairing; it does not make the optional hosted flows backend-ready. Human
acceptance and current CI are still required before merge.



Review dispositions retained: production auth-domain configuration must
use supported community mappings; enabling `builderlab.xyz` does not add
support for `buzz.block.builderlab.xyz`. Recovery after genuinely losing
an Android Keystore key remains a follow-up, not a newly implemented
capability.


Human acceptance steps: build once without `BUZZ_AUTH_DOMAINS`
(including with a previously saved account) and confirm no Builderlab
controls/restoration while ordinary link and QR/code entry work. Build
with supported domains and confirm connection/creation appear; exercise
browser return and live backup/recovery/reconnect. On physical iOS,
exercise export and interrupted sharing/locked-device protection. Record
results before marking review complete.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@wes.communities.public.builderlabstaging.xyz>
2026-10-19 15:46:58 -06:00

853 lines
28 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,
chooseCommunities,
communityConflict,
acceptPolicy,
profile,
blocked,
browserReturned,
complete,
}
enum HostedFlowProblem {
retry,
denied,
accountSetupRequired,
differentAccount,
identityUnreadable,
backupUnavailable,
invalidResponse,
requestRejected,
identityMismatch,
identityUnavailable,
backupRequired,
bindingRequired,
unsupportedEnvironment,
invitePolicyRequired,
inviteExpired,
inviteExhausted,
inviteInvalid,
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,
);
/// 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,
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;
bool get canRetry =>
problem == null ||
problem == HostedFlowProblem.retry ||
problem == HostedFlowProblem.invitePolicyRequired;
final Future<bool> Function(Uri) _openWeb;
HostedFlowStage stage = HostedFlowStage.idle;
HostedFlowProblem? problem;
List<DeviceIdentityChoice> identityChoices = const [];
BuzzDeepLink? _target;
bool accountOnly = false;
String? _pairedPubkey;
SensitiveActionPolicy? pairedSensitiveActionPolicy;
List<AddCommunityDeepLink> communities = const [];
Set<String> selectedRelays = const {};
final Set<String> _addedRelays = {};
bool _addingCommunities = false;
String? _batchSubject;
String? _conflictingRelay;
bool get canChangeCommunities =>
accountOnly &&
account.account?.subject == _batchSubject &&
(stage == HostedFlowStage.blocked ||
stage == HostedFlowStage.communityConflict) &&
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() {
_cancelPolicy();
_cancelProfile();
return ++_generation;
}
Completer<void>? _profileCompletion;
Future<void> Function(String)? saveProfile;
Future<void> requestProfile(Future<void> Function(String) save) async {
if (_disposed) return;
final completion = Completer<void>();
_profileCompletion = completion;
saveProfile = (name) async {
if (!identical(_profileCompletion, completion) ||
completion.isCompleted) {
throw AccountFailure.unavailable;
}
await save(name);
if (identical(_profileCompletion, completion)) finishProfile();
};
_set(HostedFlowStage.profile);
await completion.future;
if (identical(_profileCompletion, completion)) {
_profileCompletion = null;
saveProfile = 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;
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 {
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) {
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'});
});
}
/// Explicit Connect completes identity setup and discovers joined communities.
/// Passive startup restore only checks the saved session.
Future<void> signInAccount({bool openBrowser = true}) {
if (_target != null ||
stage == HostedFlowStage.working ||
stage == HostedFlowStage.signIn) {
return Future.value();
}
final generation = openBrowser ? _nextGeneration() : _generation;
if (openBrowser) {
reconnecting = false;
accountOnly = true;
_clearCommunityBatch();
_set(HostedFlowStage.working);
}
return _enqueue(() async {
if (!openBrowser) {
if (_target != null || stage != HostedFlowStage.idle) 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.
_set(HostedFlowStage.idle);
} else if (restored != null) {
await _continue(generation);
} else {
_set(HostedFlowStage.signIn);
await browser.start({'type': 'account'});
}
});
}
/// 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),
'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, {'type': 'account'}));
}
} 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}) async {
final target = _target;
final session = account.account;
if ((target == null && !accountOnly) || session == null) {
throw AccountFailure.signedOut;
}
final store = this.store.forSubject(session.subject);
if (target != null && !_acceptTarget(target)) return;
if (target == null && _batchSubject != session.subject) {
_clearCommunityBatch();
_batchSubject = session.subject;
}
var resolved = await identity.resolve(
isCurrent: _isCurrent(generation),
selectedPubkey: selectedPubkey ?? _pairedPubkey,
);
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 (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.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;
await browser.clearIntent();
_set(HostedFlowStage.complete);
}
void selectCommunity(String relay, bool selected) {
if (stage != HostedFlowStage.chooseCommunities ||
!communities.any((item) => item.relayUrl == relay)) {
return;
}
selectedRelays = Set.unmodifiable({
...selectedRelays.where((item) => item != relay),
if (selected) relay,
});
notifyListeners();
}
Future<void> addSelectedCommunities() {
if (!accountOnly ||
stage != HostedFlowStage.chooseCommunities ||
selectedRelays.isEmpty) {
return Future.value();
}
_addingCommunities = true;
final generation = _nextGeneration();
_set(HostedFlowStage.working);
return _enqueue(() => _continue(generation));
}
/// Keep completed additions and let the user omit a failing target.
void backToCommunityChoices() {
if (!canChangeCommunities) return;
_nextGeneration();
communities = List.unmodifiable(
communities.where((item) => !_addedRelays.contains(item.relayUrl)),
);
selectedRelays = Set.unmodifiable(selectedRelays.difference(_addedRelays));
_conflictingRelay = null;
addingCommunityName = null;
_set(HostedFlowStage.chooseCommunities);
}
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 loadHostedCommunities(account);
if (!current()) return;
communities = listed;
selectedRelays = Set.unmodifiable(listed.map((item) => item.relayUrl));
if (listed.isEmpty) {
await leave(noCommunities: true);
return;
}
// Direct account entry imports every joined community. The existing
// switcher handles choosing which community to enter after admission.
_addingCommunities = true;
}
for (final community in communities) {
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;
}
if (!current()) return;
_addedRelays.add(community.relayUrl);
}
if (!current()) return;
addingCommunityName = null;
if (_addedRelays.isEmpty) {
communities = List.unmodifiable(
communities.where((item) => selectedRelays.contains(item.relayUrl)),
);
await leave(noCommunities: true);
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,
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() {
noCommunitiesToAdd = false;
communities = const [];
selectedRelays = const {};
_addedRelays.clear();
_batchSubject = null;
_conflictingRelay = null;
_addingCommunities = false;
addingCommunityName = null;
}
Future<void> leave({bool noCommunities = false}) async {
_nextGeneration();
final generation = _generation;
_target = null;
_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('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();
}
}