From 70d2ca7760f5f6d5217e73d4921555bb3ce0c0e8 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 7 Oct 2026 17:37:02 -0700 Subject: [PATCH] fix(mobile): tolerate unrelated NIP-11 relay metadata (#8160) Relay capability additions such as `read_state_snapshot` currently make mobile push discovery reject otherwise valid NIP-11 documents. This blocks descriptor-dependent enrollment, lease updates, and remote revocation. Read unrelated top-level metadata permissively while preserving validation of the advertised `nip-pl` extension and the complete push descriptor. Each returned descriptor value is covered by compatibility tests; missing or malformed required push fields still fail. ### Validation - [Codex code review](https://github.com/block/buzz/pull/8160#issuecomment-6049355700) and [security review](https://github.com/block/buzz/pull/8160#issuecomment-6049486587) completed without findings on the final code. - Added 24 regression cases covering read-state metadata, arbitrary future metadata, HTTP discovery, missing/malformed push fields, and invalid extension advertisements. The six compatibility/discovery cases fail on unchanged main and pass with the fix. - All 55 focused descriptor, bootstrap, capability, and revocation tests pass. Mobile formatting and analysis pass. - Full mobile gate passes: 3,063 tests passed and four skipped with a configured gateway, plus all three tests for an unconfigured gateway build and the gateway recipe checks. - [Current-head CI](https://github.com/block/buzz/actions/runs/37706738403) passes, including Flutter tests, Android debug APK compilation, the complete iOS simulator app, and the unsigned iOS release build. - Production HTTP descriptor discovery against live personal-relay metadata passes without enrollment or relay changes. - Device enrollment, delivery, notification tap, and opt-out remain to be exercised in the updated iOS candidate. No relay or device state was changed. buzz-review-completed --------- Signed-off-by: Tom Brow --- mobile/lib/shared/push/dev_push_lease.dart | 27 ----- .../test/shared/push/dev_push_lease_test.dart | 107 ++++++++++++++++++ 2 files changed, 107 insertions(+), 27 deletions(-) diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index e7af8dc738..a66bd64029 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -44,33 +44,6 @@ class BuzzPushLeaseDescriptor { factory BuzzPushLeaseDescriptor.fromRelayInformation( Map information, ) { - _requireExactKeys( - information, - required: const {}, - allowed: const { - 'name', - 'description', - 'pubkey', - 'contact', - 'supported_nips', - 'supported_extensions', - 'software', - 'version', - 'limitation', - 'retention', - 'relay_countries', - 'language_tags', - 'tags', - 'posting_policy', - 'payments_url', - 'fees', - 'icon', - 'self', - 'pairing_relay_url', - 'push', - }, - name: 'NIP-11 document', - ); final extensions = _stringList( information['supported_extensions'], name: 'supported_extensions', diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart index 713504c7da..e1fe5c3c37 100644 --- a/mobile/test/shared/push/dev_push_lease_test.dart +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -11,6 +11,8 @@ import 'package:buzz/shared/relay/relay_socket.dart'; import 'package:buzz/shared/relay/signed_event_relay.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:nostr/nostr.dart' as nostr; void main() { @@ -298,6 +300,111 @@ void main() { expect(jsonDecode(plaintext), jsonDecode(publication.plaintext)); }); + group('NIP-11 forward compatibility', () { + final metadata = { + 'read_state_snapshot': { + 'version': 1, + 'community_id': '00000000-0000-4000-8000-000000000001', + 'max_events': 4096, + 'max_event_array_bytes': 8388608, + }, + 'future_object': { + 'nested': [1, true, null], + }, + 'future_array': [1, 'two'], + 'future_scalar': false, + 'future_null': null, + }; + + for (final entry in metadata.entries) { + test('ignores unrelated top-level ${entry.key}', () { + final information = _descriptorJson(relay.public) + ..[entry.key] = entry.value; + final parsed = BuzzPushLeaseDescriptor.fromRelayInformation( + information, + ); + expect(parsed.origin, descriptor.origin); + expect(parsed.executorPubkey, descriptor.executorPubkey); + expect(parsed.executorKeyId, descriptor.executorKeyId); + expect(parsed.transport, descriptor.transport); + expect(parsed.maxLeaseTtlSeconds, descriptor.maxLeaseTtlSeconds); + expect(parsed.maxContentLength, descriptor.maxContentLength); + expect(parsed.maxPlaintextLength, descriptor.maxPlaintextLength); + expect(parsed.maxEndpointLength, descriptor.maxEndpointLength); + expect(parsed.maxStringLength, descriptor.maxStringLength); + }); + } + + test('HTTP discovery accepts unrelated metadata', () async { + final information = _descriptorJson(relay.public)..addAll(metadata); + final client = MockClient((request) async { + expect(request.url, Uri.parse('https://tenant.example:8443/')); + expect(request.headers['Accept'], 'application/nostr+json'); + return http.Response(jsonEncode(information), 200); + }); + addTearDown(client.close); + + final parsed = await fetchBuzzPushLeaseDescriptor( + 'https://tenant.example:8443', + client: client, + ); + expect(parsed.executorPubkey, relay.public); + expect(parsed.origin, descriptor.origin); + }); + + for (final field in [ + 'origin', + 'keys', + 'app_profiles', + 'push_kinds', + 'h_grammar', + 'class_support', + 'limitation', + ]) { + for (final missing in [true, false]) { + test( + '${missing ? 'missing' : 'malformed'} push.$field fails with future metadata', + () { + final information = _descriptorJson(relay.public)..addAll(metadata); + final push = information['push'] as Map; + if (missing) { + push.remove(field); + } else { + push[field] = false; + } + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA( + isA().having( + (error) => error.message, + 'rejected field', + contains(field), + ), + ), + ); + }, + ); + } + } + + for (final extensions in [ + null, + false, + [], + ['nip-er'], + ]) { + test('invalid extension advertisement $extensions fails', () { + final information = _descriptorJson(relay.public) + ..addAll(metadata) + ..['supported_extensions'] = extensions; + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + } + }); + test('descriptor rejects canonical origin with a trailing slash', () { final information = _descriptorJson(relay.public); (information['push'] as Map)['origin'] =