fix(push): honor legacy relay channel grammar before publication

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-26 12:18:16 -07:00
parent 0f5acd42cb
commit fc972671f7
5 changed files with 132 additions and 4 deletions

View File

@@ -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.

View File

@@ -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<BuzzPushSubscription> 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 <String>[]) {
// The legacy Rust validator checked version 4, not the UUID variant.
if (channel[14] != '4') {
throw const BuzzPushChannelGrammarException();
}
}
}
}
}
Future<BuzzPushLeaseDescriptor> fetchBuzzPushLeaseDescriptor(
String relayBaseUrl, {
http.Client? client,
@@ -262,6 +293,7 @@ Future<BuzzPushLeasePublication> publishBuzzDevPushLease({
required BuzzPushLeaseSubmit submit,
DateTime Function() now = DateTime.now,
}) async {
validateBuzzPushChannelGrammar(descriptor, subscriptions);
_validateGrant(grant, descriptor);
final effectiveLeaseInstallationId =
leaseInstallationId ?? grant.installationId;

View File

@@ -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,

View File

@@ -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<String, dynamic>)['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<BuzzPushChannelGrammarException>()),
);
expect(submissions, 0);
},
);
}
test('legacy relay keeps compatible subscriptions and ignores intact', () {
final information = _descriptorJson(relay.public);
(information['push'] as Map<String, dynamic>)['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'];

View File

@@ -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',