From db8373ba6420b55a8f1b56261be3399b771fd1cf Mon Sep 17 00:00:00 2001 From: Krishna C Date: Mon, 27 Jul 2026 11:24:05 -0400 Subject: [PATCH 1/2] fix(mobile): keep TLS on relays joined by invite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RelayConfig.baseUrl is documented as an HTTP origin, but the two onboarding flows disagree on what they persist. Device pairing rejects anything that isn't https://, while an invite join stores the relay URL straight off the invite link, which deep_link.dart always emits as ws:// or wss://. wsUrl only special-cased https://, so a wss:// base fell through to the plaintext branch and yielded ws:// — dialing port 80. On a relay that only serves 443 that connection is refused outright and the app sits on "Reconnecting…" forever, never opening a socket. The same base also feeds /query, media upload and Blossom auth, so those were malformed too, and the downgrade drops TLS on deployments where port 80 does answer. Fold the websocket schemes back to their HTTP equivalents in baseUrl itself, so every consumer is correct without a second getter to remember at new call sites, and communities already persisted with wss:// are repaired without a migration. Derive this in the getter rather than the constructor so the constructor stays const. The compile-time fallback relies on const canonicalization for a stable identity across rebuilds, and Riverpod's default updateShouldNotify is `previous != next`, which falls back to identity for this class — a fresh instance per rebuild tears down and resubscribes every listener, which channels_provider_test catches as an unexpected unsubscribe during reconnect. Signed-off-by: Krishna C --- mobile/lib/shared/relay/relay_provider.dart | 31 ++++++++- .../test/shared/relay/relay_config_test.dart | 67 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 mobile/test/shared/relay/relay_config_test.dart diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 97b88dd3dfa..b602381bbf9 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -10,12 +10,39 @@ import 'relay_client.dart'; /// - `baseUrl` — where the relay lives (used for WS + media upload) /// - `nsec` — the user's signing key (drives NIP-42 AUTH and event sigs) class RelayConfig { - final String baseUrl; + const RelayConfig({required String baseUrl, this.nsec}) : _baseUrl = baseUrl; + + /// Relay origin exactly as the active community stored it. + final String _baseUrl; /// Nostr secret key (bech32 nsec) for signing events and NIP-42 AUTH. final String? nsec; - const RelayConfig({required this.baseUrl, this.nsec}); + /// Relay origin as an HTTP(S) URL. + /// + /// Communities are persisted with whichever scheme their onboarding flow + /// used: device pairing stores `https://` (it rejects anything else), while + /// an invite join stores the `wss://` relay URL carried by the invite link. + /// Every consumer treats this as an HTTP origin — [wsUrl], the `/query` + /// endpoint, media upload and Blossom auth — so a `wss://` base silently + /// degrades all of them. Folding the websocket schemes back here keeps both + /// onboarding paths equivalent, including for already-persisted communities. + /// + /// Derived rather than normalized in the constructor so that the constructor + /// stays `const`: the compile-time fallback below relies on canonicalization + /// to keep its identity stable across rebuilds, and Riverpod's default + /// `updateShouldNotify` is `previous != next`, which falls back to identity + /// here. A fresh instance per rebuild would resubscribe every listener. + String get baseUrl { + final uri = Uri.tryParse(_baseUrl); + if (uri == null) return _baseUrl; + final scheme = switch (uri.scheme) { + 'wss' => 'https', + 'ws' => 'http', + _ => null, + }; + return scheme == null ? _baseUrl : uri.replace(scheme: scheme).toString(); + } /// Derive the websocket URL from the HTTP base URL. String get wsUrl { diff --git a/mobile/test/shared/relay/relay_config_test.dart b/mobile/test/shared/relay/relay_config_test.dart new file mode 100644 index 00000000000..d0decb36855 --- /dev/null +++ b/mobile/test/shared/relay/relay_config_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:buzz/shared/relay/relay_provider.dart'; + +void main() { + group('RelayConfig.baseUrl normalization', () { + test('folds a wss:// community URL to https://', () { + // Invite joins persist the relay URL straight off the invite link, which + // deep_link.dart always emits as ws:// or wss://. + final config = RelayConfig(baseUrl: 'wss://relay.example.com'); + expect(config.baseUrl, 'https://relay.example.com'); + }); + + test('folds a ws:// community URL to http://', () { + final config = RelayConfig(baseUrl: 'ws://relay.example.com:3000'); + expect(config.baseUrl, 'http://relay.example.com:3000'); + }); + + test('leaves an https:// community URL untouched', () { + // Device pairing rejects anything but https://, so these already conform. + final config = RelayConfig(baseUrl: 'https://relay.example.com'); + expect(config.baseUrl, 'https://relay.example.com'); + }); + + test('leaves an http:// community URL untouched', () { + final config = RelayConfig(baseUrl: 'http://localhost:3000'); + expect(config.baseUrl, 'http://localhost:3000'); + }); + + test('preserves a non-default port', () { + final config = RelayConfig(baseUrl: 'wss://relay.example.com:8443'); + expect(config.baseUrl, 'https://relay.example.com:8443'); + }); + }); + + group('RelayConfig.wsUrl', () { + test('keeps TLS for a relay joined by invite', () { + // Regression: a wss:// base used to fall through to the non-https branch + // and downgrade to ws://, dialing port 80 — which never connects on a + // relay that only serves 443, and drops TLS everywhere else. + final config = RelayConfig(baseUrl: 'wss://relay.example.com'); + expect(config.wsUrl, 'wss://relay.example.com'); + }); + + test('keeps TLS for a relay added by pairing', () { + final config = RelayConfig(baseUrl: 'https://relay.example.com'); + expect(config.wsUrl, 'wss://relay.example.com'); + }); + + test('both onboarding paths agree on the same relay', () { + final invited = RelayConfig(baseUrl: 'wss://relay.example.com'); + final paired = RelayConfig(baseUrl: 'https://relay.example.com'); + expect(invited.wsUrl, paired.wsUrl); + expect(invited.baseUrl, paired.baseUrl); + }); + + test('stays plaintext for local development', () { + final config = RelayConfig(baseUrl: 'http://localhost:3000'); + expect(config.wsUrl, 'ws://localhost:3000'); + }); + + test('preserves a non-default port', () { + final config = RelayConfig(baseUrl: 'wss://relay.example.com:8443'); + expect(config.wsUrl, 'wss://relay.example.com:8443'); + }); + }); +} From d81c95f53f9de266767d12f8d5a67ef39e86d774 Mon Sep 17 00:00:00 2001 From: Krishna C Date: Wed, 29 Jul 2026 13:27:06 -0400 Subject: [PATCH 2/2] fix(mobile): keep identity-scoped prefs readable across origin canonicalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonicalizing RelayConfig.baseUrl changes more than the network address: compose_drafts_provider and recent_searches_provider embed that origin in their SharedPreferences keys. An install that joined by invite wrote its data under wss://..., so once the getter returns https://... the app computes a different key. The old entries stay on disk but become unreachable, which for compose drafts reads as losing unsent text on upgrade. Expose the persisted origin as RelayConfig.storedOrigin and read through readMigratedPref, which prefers the canonical key, otherwise returns the legacy value straight away and promotes it in the background. The legacy entry is removed only after the copy reports success, so an interrupted migration leaves the original readable and the next read retries. Where both keys coincide — pairing-created communities, which always stored an HTTP origin — it short-circuits without touching storage. The other two baseUrl identity consumers, observer_subscription and compose_bar, compare in-memory values recomputed each run and need no compatibility handling. Covered by an upgrade regression test per provider showing a wss:// community keeps its stored draft and its recent searches, plus unit tests for the promote-then-remove ordering and the coinciding-key case. Both provider tests fail with the migration removed. Signed-off-by: Krishna C --- .../activity/compose_drafts_provider.dart | 8 +- .../search/recent_searches_provider.dart | 10 +- .../shared/relay/identity_scoped_prefs.dart | 50 ++++++++++ mobile/lib/shared/relay/relay.dart | 1 + mobile/lib/shared/relay/relay_provider.dart | 7 ++ .../compose_drafts_provider_test.dart | 29 ++++++ .../search/recent_searches_provider_test.dart | 21 +++++ .../relay/identity_scoped_prefs_test.dart | 92 +++++++++++++++++++ 8 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 mobile/lib/shared/relay/identity_scoped_prefs.dart create mode 100644 mobile/test/shared/relay/identity_scoped_prefs_test.dart diff --git a/mobile/lib/features/activity/compose_drafts_provider.dart b/mobile/lib/features/activity/compose_drafts_provider.dart index b9a079e9a8e..b5755f19c8d 100644 --- a/mobile/lib/features/activity/compose_drafts_provider.dart +++ b/mobile/lib/features/activity/compose_drafts_provider.dart @@ -79,7 +79,13 @@ class ComposeDraftsNotifier extends Notifier> { _prefsKey = '$_draftsPrefsKey:${config.baseUrl}:$pubkey'; final prefs = ref.read(savedPrefsProvider); - final raw = prefs.getString(_prefsKey); + final raw = readMigratedPref( + prefs, + canonicalKey: _prefsKey, + legacyKey: '$_draftsPrefsKey:${config.storedOrigin}:$pubkey', + read: prefs.getString, + write: prefs.setString, + ); if (raw == null) return const []; try { final decoded = jsonDecode(raw); diff --git a/mobile/lib/features/search/recent_searches_provider.dart b/mobile/lib/features/search/recent_searches_provider.dart index 813672a9cc7..2fad17832ed 100644 --- a/mobile/lib/features/search/recent_searches_provider.dart +++ b/mobile/lib/features/search/recent_searches_provider.dart @@ -19,8 +19,16 @@ class RecentSearchesNotifier extends Notifier> { final pubkey = ref.watch(myPubkeyProvider) ?? 'anon'; _prefsKey = '$_recentSearchesPrefsKey:${config.baseUrl}:$pubkey'; + final prefs = ref.read(savedPrefsProvider); final stored = - ref.read(savedPrefsProvider).getStringList(_prefsKey) ?? const []; + readMigratedPref>( + prefs, + canonicalKey: _prefsKey, + legacyKey: '$_recentSearchesPrefsKey:${config.storedOrigin}:$pubkey', + read: prefs.getStringList, + write: prefs.setStringList, + ) ?? + const []; return List.unmodifiable( stored .map((query) => query.trim()) diff --git a/mobile/lib/shared/relay/identity_scoped_prefs.dart b/mobile/lib/shared/relay/identity_scoped_prefs.dart new file mode 100644 index 00000000000..e080b1ca8ca --- /dev/null +++ b/mobile/lib/shared/relay/identity_scoped_prefs.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Reads an identity-scoped preference, migrating values left under a +/// pre-canonicalization relay origin. +/// +/// Identity-scoped keys embed the relay origin, and that origin used to be +/// whatever scheme the onboarding flow happened to persist — `wss://` for an +/// invite join, `https://` for device pairing. Now that [RelayConfig.baseUrl] +/// canonicalizes the scheme, an install that joined by invite would compute a +/// different key than the one its data was written under, leaving that data on +/// disk but unreachable. +/// +/// The value under [canonicalKey] wins. Otherwise a value under [legacyKey] is +/// returned straight away and promoted onto the canonical key in the +/// background. The legacy entry is removed only once the copy reports success, +/// so a migration interrupted mid-flight leaves the original readable and the +/// next read simply retries. +/// +/// Pass matching accessors for the stored type — `getString`/`setString`, or +/// `getStringList`/`setStringList`. +T? readMigratedPref( + SharedPreferences prefs, { + required String canonicalKey, + required String legacyKey, + required T? Function(String key) read, + required Future Function(String key, T value) write, +}) { + final canonical = read(canonicalKey); + if (canonical != null) return canonical; + + // Pairing-created communities already store an HTTP origin, so the two keys + // coincide and there is nothing to migrate. + if (canonicalKey == legacyKey) return null; + + final legacy = read(legacyKey); + if (legacy == null) return null; + + unawaited(_promote(prefs, legacyKey, write(canonicalKey, legacy))); + return legacy; +} + +Future _promote( + SharedPreferences prefs, + String legacyKey, + Future copy, +) async { + if (await copy) await prefs.remove(legacyKey); +} diff --git a/mobile/lib/shared/relay/relay.dart b/mobile/lib/shared/relay/relay.dart index d168b9a0974..bc11325414b 100644 --- a/mobile/lib/shared/relay/relay.dart +++ b/mobile/lib/shared/relay/relay.dart @@ -1,4 +1,5 @@ export 'app_lifecycle_provider.dart'; +export 'identity_scoped_prefs.dart'; export 'media_auth.dart'; export 'media_image.dart'; export 'media_upload.dart'; diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index b602381bbf9..061dd6cb386 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -18,6 +18,13 @@ class RelayConfig { /// Nostr secret key (bech32 nsec) for signing events and NIP-42 AUTH. final String? nsec; + /// The origin as persisted, before scheme canonicalization. + /// + /// Exists solely so identity-scoped storage keys written before [baseUrl] + /// was canonicalized stay reachable — see [readMigratedPref]. Never use it + /// for network I/O; [baseUrl] and [wsUrl] are the addresses to connect to. + String get storedOrigin => _baseUrl; + /// Relay origin as an HTTP(S) URL. /// /// Communities are persisted with whichever scheme their onboarding flow diff --git a/mobile/test/features/activity/compose_drafts_provider_test.dart b/mobile/test/features/activity/compose_drafts_provider_test.dart index 1f93dcb674e..15899e6597e 100644 --- a/mobile/test/features/activity/compose_drafts_provider_test.dart +++ b/mobile/test/features/activity/compose_drafts_provider_test.dart @@ -34,6 +34,35 @@ void main() { return container; } + test( + 'an invite-joined community keeps its drafts after origin canonicalization', + () async { + // Written by a build that stored the invite link's wss:// origin + // verbatim; RelayConfig now canonicalizes that to https://, so the key + // the app computes no longer matches the key on disk. + const legacyKey = 'compose_drafts_v1:wss://relay-a.example:pk_a'; + const canonicalKey = 'compose_drafts_v1:https://relay-a.example:pk_a'; + SharedPreferences.setMockInitialValues({ + legacyKey: + '[{"key":"ch1","channel_id":"ch1","text":"unsent work",' + '"updated_at":1700000000}]', + }); + + final container = await containerWithPrefs( + relayUrl: 'wss://relay-a.example', + ); + + final drafts = container.read(composeDraftsProvider); + expect(drafts, hasLength(1), reason: 'draft survives the upgrade'); + expect(drafts.single.text, 'unsent work'); + + await Future.delayed(Duration.zero); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(canonicalKey), isNotNull); + expect(prefs.getString(legacyKey), isNull); + }, + ); + test('composeDraftKey separates channel and thread composers', () { expect(composeDraftKey('ch1'), 'ch1'); expect(composeDraftKey('ch1', threadHeadId: 't1'), 'ch1:t1'); diff --git a/mobile/test/features/search/recent_searches_provider_test.dart b/mobile/test/features/search/recent_searches_provider_test.dart index 30f13c329c3..df0a08a8ea1 100644 --- a/mobile/test/features/search/recent_searches_provider_test.dart +++ b/mobile/test/features/search/recent_searches_provider_test.dart @@ -35,6 +35,27 @@ void main() { return container; } + test('an invite-joined community keeps its recent searches after ' + 'origin canonicalization', () async { + const legacyKey = 'recent_searches_v1:wss://relay-a.example:pk-a'; + const canonicalKey = 'recent_searches_v1:https://relay-a.example:pk-a'; + SharedPreferences.setMockInitialValues({ + legacyKey: ['nostr', 'relays'], + }); + + final container = await containerWithPrefs( + relayUrl: 'wss://relay-a.example', + pubkey: 'pk-a', + ); + + expect(container.read(recentSearchesProvider), ['nostr', 'relays']); + + await Future.delayed(Duration.zero); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getStringList(canonicalKey), ['nostr', 'relays']); + expect(prefs.getStringList(legacyKey), isNull); + }); + test( 'normalizes, deduplicates, caps, and persists submitted queries', () async { diff --git a/mobile/test/shared/relay/identity_scoped_prefs_test.dart b/mobile/test/shared/relay/identity_scoped_prefs_test.dart new file mode 100644 index 00000000000..4fce3f254b7 --- /dev/null +++ b/mobile/test/shared/relay/identity_scoped_prefs_test.dart @@ -0,0 +1,92 @@ +import 'package:buzz/shared/relay/identity_scoped_prefs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const canonical = 'k_v1:https://relay.example:pk'; + const legacy = 'k_v1:wss://relay.example:pk'; + + Future prefsWith(Map values) async { + SharedPreferences.setMockInitialValues(values); + return SharedPreferences.getInstance(); + } + + String? readString(SharedPreferences prefs) => readMigratedPref( + prefs, + canonicalKey: canonical, + legacyKey: legacy, + read: prefs.getString, + write: prefs.setString, + ); + + test('returns the canonical value when present', () async { + final prefs = await prefsWith({canonical: 'new', legacy: 'old'}); + expect(readString(prefs), 'new'); + }); + + test('falls back to the legacy value and returns it immediately', () async { + final prefs = await prefsWith({legacy: 'carried over'}); + expect(readString(prefs), 'carried over'); + }); + + test('promotes the legacy value onto the canonical key', () async { + final prefs = await prefsWith({legacy: 'carried over'}); + readString(prefs); + await Future.delayed(Duration.zero); + + expect(prefs.getString(canonical), 'carried over'); + expect(prefs.getString(legacy), isNull, reason: 'legacy entry is cleared'); + }); + + test('is idempotent across repeated reads', () async { + final prefs = await prefsWith({legacy: 'carried over'}); + readString(prefs); + await Future.delayed(Duration.zero); + expect(readString(prefs), 'carried over'); + await Future.delayed(Duration.zero); + expect(prefs.getString(canonical), 'carried over'); + }); + + test('returns null when neither key holds a value', () async { + final prefs = await prefsWith({}); + expect(readString(prefs), isNull); + }); + + test('does not touch storage when the keys coincide', () async { + // A pairing-created community already stores an HTTP origin, so canonical + // and legacy are the same string and there is nothing to migrate. + SharedPreferences.setMockInitialValues({canonical: 'only'}); + final prefs = await SharedPreferences.getInstance(); + final value = readMigratedPref( + prefs, + canonicalKey: canonical, + legacyKey: canonical, + read: prefs.getString, + write: prefs.setString, + ); + await Future.delayed(Duration.zero); + + expect(value, 'only'); + expect(prefs.getString(canonical), 'only'); + }); + + test('migrates string lists as well as strings', () async { + final prefs = await prefsWith({ + legacy: ['alpha', 'beta'], + }); + final value = readMigratedPref>( + prefs, + canonicalKey: canonical, + legacyKey: legacy, + read: prefs.getStringList, + write: prefs.setStringList, + ); + await Future.delayed(Duration.zero); + + expect(value, ['alpha', 'beta']); + expect(prefs.getStringList(canonical), ['alpha', 'beta']); + expect(prefs.getStringList(legacy), isNull); + }); +}