Files
buzz/mobile/lib/shared/push/push_bootstrap.dart
Tom Brow 76f7e5ef0c feat(push): make gateway origin configurable (#7261)
Make the push gateway origin configurable across the relay, gateway,
mobile client, and deployment chart.

This is a fresh deployment. The gateway has only run on personal
development infrastructure, and push has no existing enabled users.
Legacy migration, configured-to-unconfigured mobile transitions, and
rolling upgrades from pre-launch gateway binaries are not supported.

Populated pre-launch databases are rejected with an informative error
before migrations run; their data is untouched. Mobile builds may omit
the gateway URL, leaving push unavailable. Once push is enabled,
subsequent builds must retain that configuration.

### Validation

- All applicable CI checks pass at
`5f546cfaf403f0386f146d213adfb4392f4ec29c`, including Rust,
mobile/Swift, desktop/integration, security, image builds, and Helm
checks. An unrelated agent cancellation test passed on retry.
- PostgreSQL CI passed 384 tests, including the production-entry-point
regression for refusing populated pre-launch databases before
migrations, fresh initialization, and subsequent initialized
deployments.
- Focused local validation passed all 10 gateway PostgreSQL tests under
the CI discovery filter and all 3 unconfigured-mobile tests.
Repository-wide PostgreSQL discovery validation and the complete
pre-push hooks passed.
- Physical-device APNs/App Attest behavior and deployment to a live
cluster were not verified in this work.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 14:56:45 -07:00

409 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../community/community.dart';
import '../community/community_provider.dart';
import '../relay/relay_provider.dart';
import '../relay/relay_session.dart';
import '../relay/signed_event_relay.dart';
import 'dev_push_lease.dart';
import 'push_bridge.dart';
import 'push_lease_revocation_outbox.dart';
import 'push_relay_capability_provider.dart';
import 'push_subscription.dart';
const _pushBootstrapRetryDelay = Duration(seconds: 5);
@visibleForTesting
class BuzzPushAttemptGate {
BuzzPushAttemptGate({this.retryDelay = _pushBootstrapRetryDelay});
final Duration retryDelay;
String? _attempt;
Timer? _retryTimer;
bool tryBegin(String attempt) {
if (_attempt == attempt) return false;
_retryTimer?.cancel();
_retryTimer = null;
_attempt = attempt;
return true;
}
void failed(String attempt, {required VoidCallback retry}) {
if (_attempt != attempt) return;
_attempt = null;
_retryTimer?.cancel();
_retryTimer = Timer(retryDelay, () {
_retryTimer = null;
if (_attempt == null) retry();
});
}
void retryAfter(
String attempt, {
required Duration delay,
required VoidCallback retry,
}) {
if (_attempt != attempt) return;
_retryTimer?.cancel();
_retryTimer = Timer(delay, () {
_retryTimer = null;
if (_attempt != attempt) return;
_attempt = null;
retry();
});
}
void complete(String attempt) {
if (_attempt != attempt) return;
_retryTimer?.cancel();
_retryTimer = null;
_attempt = null;
}
void dispose() => _retryTimer?.cancel();
}
@visibleForTesting
String buzzPushPublicationAttemptKey({
required String communityId,
required String relayBaseUrl,
required String token,
required BuzzPushLeaseDescriptor descriptor,
required List<BuzzPushSubscription> subscriptions,
}) => [
communityId,
relayBaseUrl,
token,
descriptor.executorKeyId,
descriptor.executorPubkey,
buzzPushSubscriptionsFingerprint(subscriptions),
].join('|');
@visibleForTesting
bool buzzPushLifecycleEnabled({
required Community? community,
required BuzzPushLeaseDescriptor? descriptor,
}) =>
Env.pushGatewayConfigured &&
community?.pushNotificationsEnabled == true &&
descriptor != null;
/// Starts APNs registration when the active community opts in.
@visibleForTesting
class BuzzPushRegistrationBootstrap extends HookWidget {
const BuzzPushRegistrationBootstrap({
required this.shouldRegister,
required this.attemptKey,
required this.child,
this.startRegistration = startBuzzPushRegistration,
super.key,
});
final bool shouldRegister;
final String attemptKey;
final Widget child;
final Future<void> Function() startRegistration;
@override
Widget build(BuildContext context) {
final attemptGate = useMemoized(BuzzPushAttemptGate.new);
final retry = useState(0);
useEffect(() => attemptGate.dispose, const []);
useEffect(() {
if (!shouldRegister || !attemptGate.tryBegin(attemptKey)) return null;
unawaited(() async {
try {
await startRegistration();
} catch (error, stack) {
attemptGate.failed(
attemptKey,
retry: () {
if (context.mounted) retry.value += 1;
},
);
debugPrint('Push registration bootstrap failed: $error');
debugPrintStack(stackTrace: stack);
}
}());
return null;
}, [shouldRegister, attemptKey, retry.value]);
return child;
}
}
@visibleForTesting
Future<int> publishBuzzPushLeaseRecoverably({
required Future<int> Function() reserveGeneration,
required Future<void> Function(int generation) publish,
required Future<bool> Function(int generation) markAccepted,
}) async {
final generation = await reserveGeneration();
await publish(generation);
if (!await markAccepted(generation)) {
throw StateError('A newer push lease superseded the published generation.');
}
return generation;
}
/// Starts the push lifecycle only after authenticated relay connectivity and a
/// push-capable NIP-11 descriptor are both present.
class BuzzPushBootstrap extends HookConsumerWidget {
const BuzzPushBootstrap({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
useListenable(apnsDeviceToken);
final publicationAttempt = useMemoized(BuzzPushAttemptGate.new);
final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new);
final publicationRetry = useState(0);
final tombstoneRetry = useState(0);
final revocationOutbox = ref.watch(buzzPushLeaseRevocationOutboxProvider);
final session = ref.watch(relaySessionProvider);
final communities = ref.watch(communityListProvider).value ?? const [];
final config = ref.watch(relayConfigProvider);
final community = ref.watch(activeCommunityProvider).value;
final memberPubkey = ref.watch(myPubkeyProvider);
final descriptor = ref.watch(currentRelayPushDescriptorProvider).value;
useEffect(() {
final listener = AppLifecycleListener(
onResume: () => _runRevocationOutbox(revocationOutbox.trigger),
);
_runRevocationOutbox(revocationOutbox.start);
return listener.dispose;
}, [revocationOutbox]);
useEffect(() {
if (session.status == SessionStatus.connected) {
_runRevocationOutbox(revocationOutbox.trigger);
}
return null;
}, [revocationOutbox, session.status]);
useEffect(
() => () {
publicationAttempt.dispose();
tombstoneAttempt.dispose();
},
const [],
);
useEffect(
() {
final pendingCommunities = communities
.where(
(candidate) =>
!candidate.pushNotificationsEnabled &&
candidate.pushSubscriptionState.pendingTombstoneGeneration !=
null,
)
.toList();
if (session.status != SessionStatus.connected ||
pendingCommunities.isEmpty) {
return null;
}
const attempt = 'pending-tombstones';
if (!tombstoneAttempt.tryBegin(attempt)) return null;
unawaited(() async {
try {
Object? firstError;
StackTrace? firstStack;
for (final pendingCommunity in pendingCommunities) {
try {
await ref
.read(communityListProvider.notifier)
.retryPendingPushLeaseTombstone(
pendingCommunity.id,
advanceGeneration: true,
);
} catch (error, stack) {
firstError ??= error;
firstStack ??= stack;
}
}
if (firstError != null) {
Error.throwWithStackTrace(firstError, firstStack!);
}
tombstoneAttempt.complete(attempt);
} catch (error, stack) {
tombstoneAttempt.failed(
attempt,
retry: () {
if (context.mounted) tombstoneRetry.value += 1;
},
);
debugPrint('Push lease tombstone retry failed: $error');
debugPrintStack(stackTrace: stack);
}
}());
return null;
},
[
session.status,
for (final candidate in communities)
'${candidate.id}|${candidate.pushNotificationsEnabled}|'
'${candidate.pushSubscriptionState.pendingTombstoneGeneration}',
tombstoneRetry.value,
],
);
final token = apnsDeviceToken.value;
useEffect(
() {
if (!_ready(session, config, community, memberPubkey) ||
!buzzPushLifecycleEnabled(
community: community,
descriptor: descriptor,
) ||
token == null) {
return null;
}
final activeCommunity = community!;
final activeDescriptor = descriptor!;
final state = activeCommunity.pushSubscriptionState;
if (state.desired.isEmpty) return null;
final attempt = buzzPushPublicationAttemptKey(
communityId: activeCommunity.id,
relayBaseUrl: config.baseUrl,
token: token,
descriptor: activeDescriptor,
subscriptions: state.desired,
);
if (!publicationAttempt.tryBegin(attempt)) return null;
final relay = SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: config.nsec!,
);
unawaited(() async {
try {
final grant = await _publish(
ref,
config,
activeCommunity,
memberPubkey!,
relay,
);
final renewInMilliseconds =
grant.expiresAt * 1000 -
DateTime.now().millisecondsSinceEpoch -
const Duration(minutes: 5).inMilliseconds;
publicationAttempt.retryAfter(
attempt,
delay: Duration(
milliseconds: renewInMilliseconds > 1000
? renewInMilliseconds
: 1000,
),
retry: () {
if (context.mounted) publicationRetry.value += 1;
},
);
} catch (error, stack) {
publicationAttempt.failed(
attempt,
retry: () {
if (context.mounted) publicationRetry.value += 1;
},
);
debugPrint('Push lease bootstrap failed: $error');
debugPrintStack(stackTrace: stack);
}
}());
return null;
},
[
session.status,
config.baseUrl,
community?.id,
community?.pushNotificationsEnabled,
community?.pushSubscriptionState,
memberPubkey,
descriptor,
token,
publicationRetry.value,
],
);
return BuzzPushRegistrationBootstrap(
shouldRegister:
_ready(session, config, community, memberPubkey) &&
buzzPushLifecycleEnabled(
community: community,
descriptor: descriptor,
),
attemptKey: '${community?.id}|${config.baseUrl}',
child: child,
);
}
static bool _ready(
SessionState session,
RelayConfig config,
Community? community,
String? memberPubkey,
) =>
session.status == SessionStatus.connected &&
community != null &&
config.nsec != null &&
config.nsec!.isNotEmpty &&
memberPubkey != null &&
memberPubkey.isNotEmpty;
static Future<BuzzPushEndpointGrant> _publish(
WidgetRef ref,
RelayConfig config,
Community community,
String memberPubkey,
SignedEventRelay relay,
) async {
final state = community.pushSubscriptionState;
final desired = state.desired;
final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl);
final grant = await enrollBuzzPush(
config.wsUrl,
Env.pushGatewayUrl,
communitiesForSnapshotRefresh:
ref.read(communityListProvider).value ?? [community],
);
// Relay lease replacement and gateway delegation are independent state
// machines. Subscription changes advance only the kind-30350 generation;
// the opaque grant remains reusable until its own authority changes.
final notifier = ref.read(communityListProvider.notifier);
await publishBuzzPushLeaseRecoverably(
reserveGeneration: () =>
notifier.reservePushLeaseGeneration(community.id),
publish: (leaseGeneration) => publishBuzzDevPushLeaseThroughRelay(
grant: grant,
leaseInstallationId: community.pushLeaseInstallationId,
leaseGeneration: leaseGeneration,
descriptor: descriptor,
nsec: config.nsec!,
memberPubkey: memberPubkey,
subscriptions: desired,
relay: relay,
),
markAccepted: (leaseGeneration) => notifier.markPushLeaseAccepted(
community.id,
subscriptions: desired,
generation: leaseGeneration,
),
);
return grant;
}
}
void _runRevocationOutbox(Future<void> Function() operation) {
unawaited(
operation().catchError((Object error, StackTrace stackTrace) {
reportPushLeaseCleanupError(error, stackTrace);
}),
);
}