mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
fix(push): accept canonical channel UUIDs across versions (#8609)
Starter channels use UUIDv5 IDs, but mobile push accepted only UUIDv4 channel IDs, so building a subscription that included a starter channel threw locally. After #8608, no non-v4 ID reaches mobile push today: positive `#h` filters cover joined DMs only, and mutes are limited to subscribed channels. This PR is therefore a safety net for future starter-channel subscriptions, and it prepares mobile for a relay that accepts them. Mobile now accepts canonical lowercase, hyphenated UUIDs of any version in subscriptions. It recognizes both the current `uuid-v4-lowercase` relay descriptor and a future `uuid-lowercase` descriptor. Against a `uuid-v4-lowercase` relay, mobile checks positive and mute channel filters before enrollment or publication, keeps the desired filters, and rechecks relay capabilities every five minutes instead of sending a lease the relay would reject. The relay is unchanged. It keeps validating and advertising `uuid-v4-lowercase`. Installed mobile builds reject any other `h_grammar`, and they parse the descriptor before renewal, opt-out and revocation, so changing the advertisement now would leave their leases active until expiry. The relay validation and advertisement change will follow in a separate PR once those builds have drained. `docs/buzz-push.md` records this sequencing. ### Validation - Regression tests reproduced the UUIDv5 rejection in Dart before the fix. Added coverage checks positive and mute filters, persisted subscription round trips, encrypted lease content, malformed IDs, retained bounds, parsing of both descriptor grammars, and pre-publication rejection of non-v4 IDs against a `uuid-v4-lowercase` relay. - Mobile push tests on the current head: 104 passed, 4 skipped. `flutter analyze` on the push code and tests is clean. - Before the split, exercised actual mobile lease construction, NIP44 encryption and NIP42 publication against an isolated local relay. The relay half of that exercise is no longer part of this PR. - Relay source is identical to `main`, so the relay serves the same descriptor that installed builds parse today. The standing mobile test exception applies. Agent review and changed-behavior exercise are complete. buzz-review-completed --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<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,
|
||||
@@ -260,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<String, dynamic>)['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<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'];
|
||||
@@ -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<String, dynamic> _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'],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
() {
|
||||
|
||||
Reference in New Issue
Block a user