diff --git a/docs/buzz-push.md b/docs/buzz-push.md index 34b76655e9..18e7d4cf96 100644 --- a/docs/buzz-push.md +++ b/docs/buzz-push.md @@ -19,3 +19,17 @@ stable specification after validating the design through launch and use. - Publish the matching gateway image and chart, update deployment configuration, then enable the new relay capability. Verify enrollment, delivery, renewal and revocation on a physical device before production activation. + +## Channel identifier grammar + +Relays advertise `h_grammar: "uuid-v4-lowercase"`. Mobile also accepts +`uuid-lowercase`, which drops the UUID version and variant restriction so +UUIDv5 starter channels can be subscribed. Against a `uuid-v4-lowercase` relay, +mobile checks positive and ignore channel filters before enrollment or +publication. It keeps the desired filters and rechecks relay capabilities every +five minutes rather than sending a lease the relay would reject. + +Do not change the relay advertisement until mobile builds that only parse +`uuid-v4-lowercase` have drained. Those builds parse the descriptor before +renewal, opt-out and revocation, so a new grammar would leave their existing +leases active until expiry. diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index 26d69538c5..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,7 +119,8 @@ class BuzzPushLeaseDescriptor { ); } final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar'); - if (hGrammar != 'uuid-v4-lowercase') { + // 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'); } @@ -185,6 +190,7 @@ class BuzzPushLeaseDescriptor { return BuzzPushLeaseDescriptor( origin: origin, + hGrammar: hGrammar, executorKeyId: currentKey['id'] as String, executorPubkey: currentKey['pubkey'] as String, transport: transport, @@ -197,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, @@ -260,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 28d690606b..8cb405ce62 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -84,6 +84,7 @@ String buzzPushPublicationAttemptKey({ token, descriptor.executorKeyId, descriptor.executorPubkey, + descriptor.hGrammar, buzzPushSubscriptionsFingerprint(subscriptions), ].join('|'); @@ -428,8 +429,13 @@ class _BuzzPushLifecycle 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; }, @@ -490,6 +496,7 @@ class _BuzzPushLifecycle 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/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart index 38d8cdeadb..788a1d3ab2 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -15,8 +15,10 @@ const buzzPushHellthreadParticipantLimit = 20; const _supportedNotificationClasses = {'default'}; const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'}; final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$'); +// Channel IDs use canonical UUID text; starter channels are UUIDv5. +// Match the relay's UUID parser without imposing a version or variant. final _channelIdPattern = RegExp( - r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', ); enum BuzzPushLeaseSubscriptionAuthority { desired, accepted } diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart index 1fd979a3b3..8e2e17bdcd 100644 --- a/mobile/test/shared/push/dev_push_lease_test.dart +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -22,6 +22,85 @@ void main() { final grant = _grant(relay.public); final now = DateTime.fromMillisecondsSinceEpoch(1752620000 * 1000); + for (final grammar in ['uuid-lowercase', 'uuid-v4-lowercase']) { + test('accepts the $grammar relay descriptor', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['h_grammar'] = grammar; + expect( + BuzzPushLeaseDescriptor.fromRelayInformation(information).origin, + descriptor.origin, + ); + }); + } + + 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']; @@ -41,7 +120,11 @@ void main() { memberPubkey: signer.public, subscriptions: [ BuzzPushSubscription( - filter: BuzzPushFilter(kinds: const [9], pTags: [signer.public]), + filter: BuzzPushFilter( + kinds: const [9], + pTags: [signer.public], + hTags: const ['fee88e92-da9f-5958-a910-48db3264ad38'], + ), notificationClass: 'default', ), ], @@ -92,6 +175,7 @@ void main() { 'filter': { 'kinds': [9], '#p': [signer.public], + '#h': ['fee88e92-da9f-5958-a910-48db3264ad38'], }, 'class': 'default', }, @@ -513,7 +597,7 @@ Map _descriptorJson(String relayPubkey) => { {'id': 'relay-v1', 'pubkey': relayPubkey, 'current': true}, ], 'push_kinds': [9, 40002, 45001, 45003], - 'h_grammar': 'uuid-v4-lowercase', + 'h_grammar': 'uuid-lowercase', 'class_support': { 'apns': ['default'], }, 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', diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart index 0c7f8f60d1..079f2739e0 100644 --- a/mobile/test/shared/push/push_subscription_test.dart +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -8,6 +8,56 @@ void main() { const channelA = '123e4567-e89b-42d3-a456-426614174000'; const channelB = '123e4567-e89b-42d3-a456-426614174001'; + test('subscribes to and mutes UUIDv5 starter channels', () { + const starter = 'fee88e92-da9f-5958-a910-48db3264ad38'; + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: [starter, channelA], + ); + expect(subscriptions.last.filter.hTags, [channelA, starter]); + final muted = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: [starter, channelA], + mutedChannelIds: [starter], + ); + expect(muted.last.filter.hTags, [channelA]); + expect(muted.first.ignore.last.hTags, [starter]); + expect( + BuzzPushSubscription.fromJson(muted.first.toJson()).toJson(), + muted.first.toJson(), + ); + }); + + test('channel filters preserve exact canonical UUID text', () { + for (final channel in [ + channelA, + 'fee88e92-da9f-5958-a910-48db3264ad38', + '01890abc-def0-7890-abcd-0123456789ab', + '00000000-0000-0000-0000-000000000000', + 'ffffffff-ffff-ffff-ffff-ffffffffffff', + ]) { + final filter = BuzzPushFilter(kinds: [9], hTags: [channel]); + expect(BuzzPushFilter.fromJson(filter.toJson()).hTags, [channel]); + } + for (final channel in [ + '', + channelA.toUpperCase(), + channelA.replaceAll('-', ''), + '{$channelA}', + 'urn:uuid:$channelA', + '$channelA ', + '$channelA\n', + 'g23e4567-e89b-42d3-a456-426614174000', + 'a' * 129, + ]) { + expect( + () => BuzzPushFilter(kinds: [9], hTags: [channel]), + throwsFormatException, + reason: channel, + ); + } + }); + test( 'desired subscription state round-trips with an accepted-state seam', () {