Files
buzz/mobile/lib/shared/community/community_provider.dart
Tom Brow 97ede701c0 fix(mobile): send hosted relay assertion from native media and tombstone paths (#9069)
Follow-up to https://github.com/block/buzz/pull/9056, from an audit for
other places that miss the hosted relay assertion. Hosted and
organization relays require `Nostr-Federated-Identity` on every
non-exempt HTTP request and WebSocket upgrade. The Dart
`HostedRelayHttpClient` adds it, but anything that downloads or connects
outside that client has to add it itself. The notification service
extension and the iOS emoji picker both missed it, and these places do
too:

- **Voice notes** (`just_audio`, mainly Android) and **video preview
first frames** fetch media natively with only the Blossom
`Authorization` header. Both now use `nativeMediaHeadersProvider` from
https://github.com/block/buzz/pull/9056.
- **Push-lease tombstones**: removing a community, and the durable
revocation outbox, open a one-off relay socket with no headers. A hosted
relay refuses the upgrade, so the lease is never revoked and the outbox
keeps retrying. Both now ask `HostedRelayAuth` for that community's own
assertion, because the community being removed may not be the active
session.
- **Notification avatars** (iOS notification service extension):
relay-hosted sender avatars were fetched with Blossom auth only. The
loader now takes the same `BuzzRelayAssertionIssuer` the resolver uses
for `/query`. Signed requests already refuse redirects.

The iOS Me-chat agent picker also fetched avatars with no headers, but
https://github.com/block/buzz/pull/9079 removed that native screen, so
this PR no longer touches it.

### Validation

- Added tests, each checked to fail when the code it guards is reverted:
- A queued push-lease revocation and a community-removal tombstone both
send `Nostr-Federated-Identity` on the WebSocket upgrade, as background
requests. Runs against a local relay socket that handles NIP-42 auth.
- A notification avatar on an organization relay is downloaded with the
issued assertion (BuzzPushKit).
- Issuing for a second community keeps the first one's cached assertion.
A background request records no failure, never backs up the key, and its
failure never reaches the active community's reconnect waiting on it.
- Background issuance never binds an unbound account to its key,
bypasses the shared assertion cache (each attempt gets a fresh
assertion, and foreground use never inherits one that skipped the key
backup), and its failure is never handed to a foreground caller waiting
on it. When it can't get an assertion it sends none, so a non-enforcing
relay still accepts the tombstone.
- A notification avatar whose organization session is replaced
mid-download is refused and dropped from the cache (BuzzPushKit).
- Voice notes and posterless video previews renew an expired assertion
before the native fetch, and fall back to cached headers if renewal
fails. A voice-note attachment journey binds the production provider.
- Cached reads never present a signed-out or replaced account's
assertion.
- `flutter analyze` is clean. `swift test` passes for the BuzzPushKit
avatar loader tests, and `flutter build ios --simulator --debug`
succeeds.
- Full mobile suite (`just mobile-check` and `just mobile-test`) passes
at 2c84940832.
- Automated full-app run on an iOS simulator (debug build of this PR
before it was rebuilt on main, signed in to the internal Builderlab
community with two more communities saved). Passed: launch and channel
list, image attachments and avatars in a channel, New chat sheet kept
open for about 80 seconds before the agent list was opened (all agent
avatars loaded, so headers are requested per download), dismiss and
reopen, background and resume, switching to a second community (its icon
and avatars loaded), and removing a community. The native New chat sheet
is unreachable in the normal flow on this branch (a new chat opens the
Flutter draft page; the native sheet only opens when resuming an
unfinished chat), so that check ran with a temporary local bypass that
was reverted. Limits: none of these communities requires the hosted
relay assertion in this build, so the run shows the Swift-to-Dart header
bridge, the per-relay cache and the tombstone path cause no regression,
not that the assertion is sent or accepted. No voice note, video preview
or deleted message was reachable. Screenshots are omitted because they
show internal channel content.
- Limitations: no end-to-end run against a hosted relay. Simulator
sign-in to a hosted relay needs SSO, so a hosted-relay run needs a
device build.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 12:32:18 -08:00

662 lines
24 KiB
Dart

import '../hosted/hosted_relay_auth.dart';
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:math';
import 'package:buzz/shared/crypto/event_signing.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../auth/auth_provider.dart';
import '../channels/channel_list_snapshot.dart';
import '../hosted/community_entry_requirements.dart';
import '../push/dev_push_lease.dart';
import '../push/push_bridge.dart';
import '../push/push_lease_revocation_outbox.dart';
import '../push/push_subscription.dart';
import '../relay/signed_event_relay.dart';
import '../relay/relay_provider.dart' show RelayConfig, pubkeyFromNsec;
import 'community.dart';
import 'community_storage.dart';
final class CommunityTransitionCoordinator {
final Map<Object, Future<void> Function()> _callbacks = {};
Future<void>? _inFlight;
Future<void> _operationTail = Future.value();
void Function() register(Future<void> Function() callback) {
final owner = Object();
_callbacks[owner] = callback;
return () => _callbacks.remove(owner);
}
/// Runs a complete community mutation after every earlier mutation finishes.
///
/// Cleanup alone is intentionally memoized by [run], but callers that also
/// mutate credentials or the active community must queue the whole operation
/// here so they cannot race after sharing the same cleanup future.
Future<void> runExclusive(Future<void> Function() operation) {
final result = _operationTail.then((_) => operation());
_operationTail = result.then<void>(
(_) {},
onError: (Object _, StackTrace _) {},
);
return result;
}
Future<void> run() {
final inFlight = _inFlight;
if (inFlight != null) return inFlight;
final run = _runCallbacks();
_inFlight = run;
run.whenComplete(() {
if (identical(_inFlight, run)) _inFlight = null;
});
return run;
}
Future<void> _runCallbacks() async {
await Future.wait(
_callbacks.values.map((callback) async {
try {
await callback();
} catch (error, stackTrace) {
developer.log(
'Community transition cleanup failed',
name: 'buzz.community',
error: error,
stackTrace: stackTrace,
);
}
}),
);
}
}
final communityTransitionProvider = Provider<CommunityTransitionCoordinator>(
(ref) => CommunityTransitionCoordinator(),
);
final communityStorageProvider = Provider<CommunityStorage>((ref) {
return CommunityStorage();
});
typedef CommunitySnapshotWriter =
Future<void> Function(List<Community> communities);
/// Exports persisted communities to the notification service extension.
final communitySnapshotWriterProvider = Provider<CommunitySnapshotWriter>((
ref,
) {
return registerBuzzPushCommunitySnapshot;
});
final _communitySnapshotSyncProvider = Provider<_CommunitySnapshotSync>((ref) {
return _CommunitySnapshotSync(ref.read(communitySnapshotWriterProvider));
});
typedef CommunityPushLeaseDeactivator =
Future<void> Function(Community community, {int? generation});
final communityPushLeaseDeactivatorProvider =
Provider<CommunityPushLeaseDeactivator>((ref) {
return (community, {generation}) => _deactivateCommunityPushLease(
community,
generation: generation,
relayAuth: ref.read(hostedRelayAuthProvider),
);
});
typedef CommunityPushLeaseRevocationEnqueuer =
Future<bool> Function(Community community);
final communityPushLeaseRevocationEnqueuerProvider =
Provider<CommunityPushLeaseRevocationEnqueuer>((ref) {
return ref.watch(buzzPushLeaseRevocationOutboxProvider).enqueueCommunity;
});
typedef CommunityPushLeaseRevocationTrigger = Future<void> Function();
final communityPushLeaseRevocationTriggerProvider =
Provider<CommunityPushLeaseRevocationTrigger>((ref) {
return ref.watch(buzzPushLeaseRevocationOutboxProvider).trigger;
});
Future<void> _deactivateCommunityPushLease(
Community community, {
int? generation,
required HostedRelayAuth relayAuth,
}) async {
final state = community.pushSubscriptionState;
final acceptedGeneration = state.acceptedGeneration;
final nsec = community.nsec;
if (acceptedGeneration == null && generation == null) {
return;
}
if (nsec == null || nsec.isEmpty) {
throw StateError('Push lease tombstone requires community signing key.');
}
final decoded = nostr.Nip19.decode(payload: nsec);
final memberPubkey = community.pubkey ?? derivePublicKeyHex(decoded.data);
final descriptor = await fetchBuzzPushLeaseDescriptor(
canonicalBuzzPushRelayHttpUrl(community.relayUrl),
);
final matchingGrant = (await readBuzzPushEndpointGrants())
.where((grant) => grant.relayOrigin == descriptor.origin)
.firstOrNull;
if (matchingGrant == null) {
throw StateError('No endpoint grant exists for push lease tombstone.');
}
final installationId =
community.pushLeaseInstallationId ?? matchingGrant.installationId;
final uri = Uri.parse(community.relayUrl);
final httpScheme = switch (uri.scheme) {
'wss' => 'https',
'ws' => 'http',
_ => uri.scheme,
};
final wsScheme = httpScheme == 'https' ? 'wss' : 'ws';
final wsUrl = uri.replace(scheme: wsScheme).toString();
// The community being removed may not be the active session, so ask for its
// own assertion: a hosted relay refuses the socket upgrade without one.
final relayConfig = RelayConfig(
baseUrl: uri.replace(scheme: httpScheme).toString(),
nsec: nsec,
);
// Skip over the one renewal generation that could already be in flight
// when removal begins. Strict relay monotonicity then makes any stale
// active publication lose to this tombstone.
final tombstoneGeneration =
generation ?? (state.generationCursor ?? acceptedGeneration!) + 2;
await publishBuzzPushLeaseTombstone(
descriptor: descriptor,
installationId: installationId,
generation: tombstoneGeneration,
nsec: nsec,
memberPubkey: memberPubkey,
submit:
({required kind, required content, required tags, createdAt}) async =>
submitSignedEventOnce(
wsUrl: wsUrl,
headers: await relayAuth.headers(relayConfig, background: true),
nsec: nsec,
kind: kind,
content: content,
tags: tags,
createdAt: createdAt,
),
);
pushLeaseCleanupError.value = null;
}
class _CommunitySnapshotSync {
_CommunitySnapshotSync(this._writer);
final CommunitySnapshotWriter _writer;
String? _lastSuccessfulSnapshot;
Future<void> _mutationTail = Future.value();
Future<void> _serializeMutation(Future<void> Function() operation) {
final result = _mutationTail.then((_) => operation());
_mutationTail = result.then<void>(
(_) {},
onError: (Object _, StackTrace _) {},
);
return result;
}
Future<void> write(List<Community> communities) =>
_serializeMutation(() => _write(communities));
Future<void> _write(List<Community> communities) async {
final effectiveCommunities = communities;
final contentFingerprint = effectiveCommunities
.map(
(community) => [
community.id,
community.name,
community.relayUrl,
community.pubkey,
community.nsec,
community.pushNotificationsEnabled,
buzzPushSubscriptionStateFingerprint(
community.pushSubscriptionState,
),
].join('\u0000'),
)
.join('\u0001');
if (contentFingerprint == _lastSuccessfulSnapshot) return;
await _writer(effectiveCommunities);
_lastSuccessfulSnapshot = contentFingerprint;
}
}
Future<void> syncCommunitySnapshot(Ref ref, List<Community> communities) async {
try {
await ref.read(_communitySnapshotSyncProvider).write(communities);
pushCommunitySnapshotError.value = null;
} catch (error, stackTrace) {
reportPushCommunitySnapshotError(error, stackTrace);
}
}
Future<void> syncStoredCommunitySnapshot(Ref ref) async {
final communities = await ref.read(communityStorageProvider).loadAll();
await syncCommunitySnapshot(ref, communities);
}
class CommunityListNotifier extends AsyncNotifier<List<Community>> {
Future<void> _pushMutationTail = Future.value();
final Map<String, Future<void>> _tombstoneAttempts = {};
Future<T> _serializePushMutation<T>(Future<T> Function() operation) {
final result = _pushMutationTail.then((_) => operation());
_pushMutationTail = result.then<void>(
(_) {},
onError: (Object _, StackTrace _) {},
);
return result;
}
@override
Future<List<Community>> build() async {
final storage = ref.read(communityStorageProvider);
final communities = await storage.loadAll();
unawaited(_removeOrphanChannelListSnapshots(communities));
await syncCommunitySnapshot(ref, communities);
return communities;
}
/// Add a community. If one with the same relay URL already exists, update
/// its credentials instead. Returns the effective community ID.
Future<String> addCommunity(Community community) async {
await CommunityEntryRequirements.verify(
community,
ref.read(builderlabAccountProvider),
);
final storage = ref.read(communityStorageProvider);
final current = state.value ?? [];
// If a community with the same relay URL exists, update its credentials
// instead of creating a duplicate entry.
final existingIndex = current.indexWhere(
(w) => w.relayUrl == community.relayUrl,
);
if (existingIndex >= 0) {
final existing = current[existingIndex];
final updated = existing.copyWith(
pubkey: community.pubkey,
nsec: community.nsec,
);
if (_channelSnapshotPubkey(existing)?.toLowerCase() !=
_channelSnapshotPubkey(updated)?.toLowerCase()) {
await _removeChannelListSnapshot(existing);
}
await storage.save(updated);
final updatedList = [...current];
updatedList[existingIndex] = updated;
state = AsyncData(updatedList);
await _removeOrphanChannelListSnapshots(updatedList);
await syncCommunitySnapshot(ref, updatedList);
return existing.id;
}
await storage.save(community);
final updatedList = [...current, community];
state = AsyncData(updatedList);
await _removeOrphanChannelListSnapshots(updatedList);
await syncCommunitySnapshot(ref, updatedList);
return community.id;
}
Future<void> removeCommunity(String id) {
return ref.read(communityTransitionProvider).runExclusive(() async {
var revocationJournaled = false;
final storage = ref.read(communityStorageProvider);
final activeId = await storage.loadActiveId();
if (activeId == id) {
await ref.read(communityTransitionProvider).run();
}
final current = state.value ?? await storage.loadAll();
final removedIndex = current.indexWhere(
(community) => community.id == id,
);
if (removedIndex >= 0) {
await _removeChannelListSnapshot(current[removedIndex]);
// Persist every remote-cleanup dependency before erasing credentials.
// This local transaction is the only removal prerequisite. Relay I/O
// starts after the community and NSE snapshot have been removed.
revocationJournaled = await ref.read(
communityPushLeaseRevocationEnqueuerProvider,
)(current[removedIndex]);
}
await storage.remove(id);
final updatedList = current.where((w) => w.id != id).toList();
await _removeOrphanChannelListSnapshots(updatedList);
state = AsyncData(updatedList);
await syncCommunitySnapshot(ref, updatedList);
// If we removed the active community, switch to another or sign out.
if (activeId == id) {
final remaining = state.value ?? [];
if (remaining.isNotEmpty) {
await storage.saveActiveId(remaining.first.id);
// Reassign list state so activeCommunityProvider picks up the new ID.
state = AsyncData([...remaining]);
ref.invalidate(authProvider);
} else {
await storage.clearActiveId();
// Invalidate auth so it re-evaluates against the now-empty storage
// and transitions to unauthenticated.
ref.invalidate(authProvider);
}
}
if (revocationJournaled) {
unawaited(
ref.read(communityPushLeaseRevocationTriggerProvider)().catchError((
Object error,
StackTrace stackTrace,
) {
reportPushLeaseCleanupError(error, stackTrace);
}),
);
}
});
}
String? _channelSnapshotPubkey(Community community) =>
pubkeyFromNsec(community.nsec?.trim()) ?? community.pubkey;
Future<void> _removeChannelListSnapshot(Community community) async {
final snapshots = ref.read(channelListSnapshotStorageProvider);
final pubkey = _channelSnapshotPubkey(community);
if (snapshots == null || pubkey == null) return;
try {
// Retire writers before the revocation journal or credential changes.
// A disposable cache must never prevent local sign-out.
await snapshots.remove(pubkey, community.relayUrl);
} catch (error, stackTrace) {
developer.log(
'Channel snapshot deletion failed; orphan cleanup will retry',
name: 'buzz.community',
error: error,
stackTrace: stackTrace,
);
}
}
Future<void> _removeOrphanChannelListSnapshots(
List<Community> communities,
) async {
final snapshots = ref.read(channelListSnapshotStorageProvider);
if (snapshots == null) return;
await snapshots.removeOrphans({
for (final community in communities)
if (_channelSnapshotPubkey(community) case final pubkey?)
ChannelListSnapshotStorage.key(pubkey, community.relayUrl),
});
}
Future<void> switchCommunity(String id) {
return ref.read(communityTransitionProvider).runExclusive(() async {
final storage = ref.read(communityStorageProvider);
final activeId = await storage.loadActiveId();
if (activeId == id) return;
await ref.read(communityTransitionProvider).run();
await storage.saveActiveId(id);
// Reassign list state to trigger activeCommunityProvider (which watches
// communityListProvider.future) to rebuild and pick up the new active ID.
// We can't use ref.invalidate(activeCommunityProvider) here because that
// creates a circular dependency — activeCommunityProvider watches us.
state = AsyncData([...state.value ?? []]);
// Invalidate auth so AuthState.community reflects the new active community.
ref.invalidate(authProvider);
});
}
Future<void> updateDesiredPushSubscriptions(
String id,
List<BuzzPushSubscription> desired,
) => _serializePushMutation(() async {
final storage = ref.read(communityStorageProvider);
final current = state.value ?? await storage.loadAll();
final index = current.indexWhere((community) => community.id == id);
if (index < 0) return;
final community = current[index];
if (buzzPushSubscriptionsFingerprint(
community.pushSubscriptionState.desired,
) ==
buzzPushSubscriptionsFingerprint(desired)) {
return;
}
final updated = community.copyWith(
pushSubscriptionState: community.pushSubscriptionState.withDesired(
desired,
),
);
await storage.save(updated);
final updatedList = [...current]..[index] = updated;
state = AsyncData(updatedList);
await syncCommunitySnapshot(ref, updatedList);
});
Future<int> reservePushLeaseGeneration(String id) {
return _serializePushMutation(() async {
final storage = ref.read(communityStorageProvider);
final current = state.value ?? await storage.loadAll();
final index = current.indexWhere((community) => community.id == id);
if (index < 0) throw StateError('Push community is unavailable.');
final community = current[index];
if (!community.pushNotificationsEnabled) {
throw StateError('Push notifications are disabled.');
}
final cursor =
community.pushSubscriptionState.generationCursor ??
community.pushSubscriptionState.acceptedGeneration ??
0;
final generation = cursor + 1;
final updated = community.copyWith(
pushSubscriptionState: community.pushSubscriptionState
.withReservedGeneration(generation),
);
await storage.save(updated);
final updatedList = [...current]..[index] = updated;
state = AsyncData(updatedList);
await syncCommunitySnapshot(ref, updatedList);
return generation;
});
}
Future<bool> markPushLeaseAccepted(
String id, {
required List<BuzzPushSubscription> subscriptions,
required int generation,
}) => _serializePushMutation(() async {
final storage = ref.read(communityStorageProvider);
final current = state.value ?? await storage.loadAll();
final index = current.indexWhere((community) => community.id == id);
if (index < 0) return false;
final community = current[index];
final acceptedGeneration =
community.pushSubscriptionState.acceptedGeneration ?? 0;
final generationCursor =
community.pushSubscriptionState.generationCursor ?? 0;
if (generation < max(acceptedGeneration, generationCursor)) return false;
if (buzzPushSubscriptionsFingerprint(
community.pushSubscriptionState.desired,
) !=
buzzPushSubscriptionsFingerprint(subscriptions)) {
return false;
}
final updated = community.copyWith(
pushSubscriptionState: community.pushSubscriptionState.withAccepted(
subscriptions: subscriptions,
generation: generation,
),
);
await storage.save(updated);
final updatedList = [...current]..[index] = updated;
state = AsyncData(updatedList);
await syncCommunitySnapshot(ref, updatedList);
return true;
});
Future<void> setPushNotificationsEnabled(String id, bool enabled) async {
var shouldDeactivate = false;
await _serializePushMutation(() async {
final storage = ref.read(communityStorageProvider);
final current = state.value ?? await storage.loadAll();
final index = current.indexWhere((community) => community.id == id);
if (index < 0) return;
final community = current[index];
if (community.pushNotificationsEnabled == enabled) return;
var pushState = community.pushSubscriptionState;
if (!enabled &&
(pushState.acceptedGeneration != null ||
pushState.generationCursor != null)) {
final cursor =
pushState.generationCursor ?? pushState.acceptedGeneration ?? 0;
pushState = pushState.withPendingTombstone(cursor + 1);
}
final updated = community.copyWith(
pushNotificationsEnabled: enabled,
pushSubscriptionState: pushState,
);
await storage.save(updated);
final updatedList = [...current]..[index] = updated;
state = AsyncData(updatedList);
await syncCommunitySnapshot(ref, updatedList);
shouldDeactivate =
!enabled && pushState.pendingTombstoneGeneration != null;
});
if (shouldDeactivate) {
try {
await retryPendingPushLeaseTombstone(id);
} catch (_) {
// The durable journal remains pending. BuzzPushBootstrap retries it
// after reconnect while all registration/enrollment paths stay off.
}
}
}
/// Publishes a durably journaled opt-out tombstone.
///
/// A retry advances the generation before network I/O. That makes an
/// ambiguous relay-commit/local-save failure idempotent in effect: the next
/// inactive replacement wins even if the prior tombstone already committed.
Future<void> retryPendingPushLeaseTombstone(
String id, {
bool advanceGeneration = false,
}) {
final existing = _tombstoneAttempts[id];
if (existing != null) return existing;
final attempt = _retryPendingPushLeaseTombstone(
id,
advanceGeneration: advanceGeneration,
);
_tombstoneAttempts[id] = attempt;
void clearAttempt() {
if (identical(_tombstoneAttempts[id], attempt)) {
_tombstoneAttempts.remove(id);
}
}
attempt.then<void>(
(_) => clearAttempt(),
onError: (_, _) => clearAttempt(),
);
return attempt;
}
Future<void> _retryPendingPushLeaseTombstone(
String id, {
required bool advanceGeneration,
}) async {
Community? pendingCommunity;
int? pendingGeneration;
await _serializePushMutation(() async {
final storage = ref.read(communityStorageProvider);
final current = state.value ?? await storage.loadAll();
final index = current.indexWhere((community) => community.id == id);
if (index < 0) return;
final community = current[index];
var pushState = community.pushSubscriptionState;
final pending = pushState.pendingTombstoneGeneration;
if (community.pushNotificationsEnabled || pending == null) return;
if (advanceGeneration) {
final cursor = pushState.generationCursor ?? pending;
pushState = pushState.withPendingTombstone(cursor + 1);
}
final updated = community.copyWith(pushSubscriptionState: pushState);
if (!identical(pushState, community.pushSubscriptionState)) {
await storage.save(updated);
final updatedList = [...current]..[index] = updated;
state = AsyncData(updatedList);
await syncCommunitySnapshot(ref, updatedList);
}
pendingCommunity = updated;
pendingGeneration = pushState.pendingTombstoneGeneration;
});
final community = pendingCommunity;
final generation = pendingGeneration;
if (community == null || generation == null) return;
try {
await ref.read(communityPushLeaseDeactivatorProvider)(
community,
generation: generation,
);
await _markPushLeaseTombstoneAccepted(id, generation);
pushLeaseCleanupError.value = null;
} catch (error, stackTrace) {
reportPushLeaseCleanupError(error, stackTrace);
rethrow;
}
}
Future<void> _markPushLeaseTombstoneAccepted(String id, int generation) =>
_serializePushMutation(() async {
final storage = ref.read(communityStorageProvider);
final current = state.value ?? await storage.loadAll();
final index = current.indexWhere((community) => community.id == id);
if (index < 0) return;
final community = current[index];
final updatedState = community.pushSubscriptionState
.withAcceptedTombstone(generation);
if (identical(updatedState, community.pushSubscriptionState)) return;
final updated = community.copyWith(pushSubscriptionState: updatedState);
await storage.save(updated);
final updatedList = [...current]..[index] = updated;
state = AsyncData(updatedList);
await syncCommunitySnapshot(ref, updatedList);
});
}
final communityListProvider =
AsyncNotifierProvider<CommunityListNotifier, List<Community>>(
CommunityListNotifier.new,
);
/// The currently active community, derived from the stored active ID and
/// the community list.
final activeCommunityProvider = FutureProvider<Community?>((ref) async {
final communities = await ref.watch(communityListProvider.future);
final storage = ref.read(communityStorageProvider);
final activeId = await storage.loadActiveId();
if (communities.isEmpty) return null;
// This projection can outlive the list it captured above. Never persist a
// fallback here: a newer authentication may already have selected a community
// absent from that list. Authentication owns durable active-ID repair.
return communities.firstWhere(
(community) => community.id == activeId,
orElse: () => communities.first,
);
});