mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
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>
409 lines
12 KiB
Dart
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);
|
|
}),
|
|
);
|
|
}
|