From fc972671f782fdaaa19f4a0b89f942470e96a512 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 26 Oct 2026 12:18:16 -0700 Subject: [PATCH] fix(push): honor legacy relay channel grammar before publication Signed-off-by: Tom Brow --- docs/buzz-push.md | 6 +- mobile/lib/shared/push/dev_push_lease.dart | 36 +++++++++- mobile/lib/shared/push/push_bootstrap.dart | 9 ++- .../test/shared/push/dev_push_lease_test.dart | 68 +++++++++++++++++++ .../test/shared/push/push_bootstrap_test.dart | 17 +++++ 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/docs/buzz-push.md b/docs/buzz-push.md index f2f3af6599..e826a514fc 100644 --- a/docs/buzz-push.md +++ b/docs/buzz-push.md @@ -30,7 +30,11 @@ filters match event tags exactly. Array and string limits, community scoping, and channel membership checks still apply. Mobile accepts both this descriptor and the older `uuid-v4-lowercase` -descriptor. Older relays continue to reject non-v4 channel filters. Deploy +descriptor. Mobile checks both positive and ignore channel filters against +the advertised grammar before enrollment or publication. A lease requiring +non-v4 IDs on an older relay remains unavailable until that relay upgrades; +mobile preserves all desired filters and rechecks capabilities every five +minutes instead of sending an invalid lease. Deploy mobile support for `uuid-lowercase` before changing the relay advertisement: older mobile clients reject that descriptor and cannot renew their push leases. The new grammar does not alter existing UUIDv4 subscriptions. diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index 2c5540d7bf..f8a7357f22 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -19,6 +19,9 @@ const _installationIdPattern = r'^[0-9a-f]{32}$'; class BuzzPushLeaseDescriptor { final String origin; + + /// Canonical channel-ID grammar advertised by this relay. + final String hGrammar; final String executorKeyId; final String executorPubkey; final String transport; @@ -30,6 +33,7 @@ class BuzzPushLeaseDescriptor { const BuzzPushLeaseDescriptor({ required this.origin, + this.hGrammar = 'uuid-v4-lowercase', required this.executorKeyId, required this.executorPubkey, required this.transport, @@ -115,8 +119,7 @@ class BuzzPushLeaseDescriptor { ); } final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar'); - // Older relays advertise the narrower grammar. Keep UUIDv4 leases working - // during rollout; those relays still reject non-v4 channels themselves. + // Retain the advertised grammar so publication can enforce legacy limits. if (hGrammar != 'uuid-lowercase' && hGrammar != 'uuid-v4-lowercase') { throw const FormatException('Unsupported push h_grammar'); } @@ -187,6 +190,7 @@ class BuzzPushLeaseDescriptor { return BuzzPushLeaseDescriptor( origin: origin, + hGrammar: hGrammar, executorKeyId: currentKey['id'] as String, executorPubkey: currentKey['pubkey'] as String, transport: transport, @@ -199,6 +203,33 @@ class BuzzPushLeaseDescriptor { } } +/// The desired lease requires channel IDs that this relay cannot accept yet. +class BuzzPushChannelGrammarException implements Exception { + const BuzzPushChannelGrammarException(); + + @override + String toString() => + 'Push lease requires uuid-lowercase support from the relay'; +} + +/// Validate both positive and ignore filters without weakening mute semantics. +void validateBuzzPushChannelGrammar( + BuzzPushLeaseDescriptor descriptor, + List subscriptions, +) { + if (descriptor.hGrammar == 'uuid-lowercase') return; + for (final subscription in subscriptions) { + for (final filter in [subscription.filter, ...subscription.ignore]) { + for (final channel in filter.hTags ?? const []) { + // The legacy Rust validator checked version 4, not the UUID variant. + if (channel[14] != '4') { + throw const BuzzPushChannelGrammarException(); + } + } + } + } +} + Future fetchBuzzPushLeaseDescriptor( String relayBaseUrl, { http.Client? client, @@ -262,6 +293,7 @@ Future publishBuzzDevPushLease({ required BuzzPushLeaseSubmit submit, DateTime Function() now = DateTime.now, }) async { + validateBuzzPushChannelGrammar(descriptor, subscriptions); _validateGrant(grant, descriptor); final effectiveLeaseInstallationId = leaseInstallationId ?? grant.installationId; diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 49b05859ec..243a71131c 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -82,6 +82,7 @@ String buzzPushPublicationAttemptKey({ token, descriptor.executorKeyId, descriptor.executorPubkey, + descriptor.hGrammar, buzzPushSubscriptionsFingerprint(subscriptions), ].join('|'); @@ -318,8 +319,13 @@ class BuzzPushBootstrap extends HookConsumerWidget { }, ); } catch (error, stack) { - publicationAttempt.failed( + publicationAttempt.retryAfter( attempt, + // Re-fetch capabilities after a relay upgrade without repeatedly + // enrolling or publishing a lease the old relay cannot accept. + delay: error is BuzzPushChannelGrammarException + ? const Duration(minutes: 5) + : _pushBootstrapRetryDelay, retry: () { if (context.mounted) publicationRetry.value += 1; }, @@ -380,6 +386,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { final descriptor = await ref.read(buzzPushDescriptorFetcherProvider)( config.baseUrl, ); + validateBuzzPushChannelGrammar(descriptor, desired); final grant = await enrollBuzzPush( config.wsUrl, Env.pushGatewayUrl, diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart index 0c7b41b458..8e2e17bdcd 100644 --- a/mobile/test/shared/push/dev_push_lease_test.dart +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -33,6 +33,74 @@ void main() { }); } + for (final ignored in [false, true]) { + test( + 'legacy relay rejects non-v4 ${ignored ? 'ignore' : 'positive'} filters before submission', + () async { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['h_grammar'] = + 'uuid-v4-lowercase'; + final legacy = BuzzPushLeaseDescriptor.fromRelayInformation( + information, + ); + final channelFilter = BuzzPushFilter( + kinds: const [9], + hTags: const ['fee88e92-da9f-5958-a910-48db3264ad38'], + ); + var submissions = 0; + await expectLater( + publishBuzzDevPushLease( + grant: grant, + descriptor: legacy, + nsec: signer.nsec, + memberPubkey: signer.public, + subscriptions: [ + BuzzPushSubscription( + filter: ignored + ? BuzzPushFilter(kinds: const [9], pTags: [signer.public]) + : channelFilter, + notificationClass: 'default', + ignore: ignored ? [channelFilter] : const [], + ), + ], + now: () => now, + submit: + ({ + required kind, + required content, + required tags, + createdAt, + }) async { + submissions++; + throw StateError('incompatible lease must not be sent'); + }, + ), + throwsA(isA()), + ); + expect(submissions, 0); + }, + ); + } + + test('legacy relay keeps compatible subscriptions and ignores intact', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['h_grammar'] = + 'uuid-v4-lowercase'; + final legacy = BuzzPushLeaseDescriptor.fromRelayInformation(information); + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: signer.public, + channelIds: const [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + ], + mutedChannelIds: const ['22222222-2222-4222-8222-222222222222'], + ); + final before = buzzPushSubscriptionsFingerprint(subscriptions); + validateBuzzPushChannelGrammar(legacy, subscriptions); + expect(buzzPushSubscriptionsFingerprint(subscriptions), before); + expect(legacy.hGrammar, 'uuid-v4-lowercase'); + }); + test('legacy capability does not enable Buzz push', () { final information = _descriptorJson(relay.public); information['supported_extensions'] = ['nip-pl']; diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 2fd37bfbbe..ee3b880a72 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -168,6 +168,21 @@ void main() { ); }); + test('relay grammar upgrade changes publication attempt identity', () { + String key(String grammar) => buzzPushPublicationAttemptKey( + communityId: 'community', + relayBaseUrl: 'https://relay.example', + token: 'token', + descriptor: _descriptor( + keyId: 'relay-v1', + pubkey: _hex('b'), + hGrammar: grammar, + ), + subscriptions: const [], + ); + expect(key('uuid-lowercase'), isNot(key('uuid-v4-lowercase'))); + }); + test('default push requires relay capability and respects opt-out', () { final enabled = Community.create( name: 'Team', @@ -269,8 +284,10 @@ void main() { BuzzPushLeaseDescriptor _descriptor({ required String keyId, required String pubkey, + String hGrammar = 'uuid-v4-lowercase', }) => BuzzPushLeaseDescriptor( origin: 'wss://relay.example', + hGrammar: hGrammar, executorKeyId: keyId, executorPubkey: pubkey, transport: 'apns',