From a96ace4e55baa9c943cfbbfc323502ff2eff05f3 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 17 Oct 2026 16:53:48 -0700 Subject: [PATCH 01/28] fix(mobile perf): avoid eager emoji palette scans for prose (#8416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ordinary mobile messages eagerly built a set of every community emoji shortcode and segmented their entire body just to decide whether to use enlarged emoji rendering. Stop at the first non-emoji grapheme and build the shortcode set only when a complete shortcode is encountered. Emoji-only sizing and shortcode matching stay unchanged. ### Validation - Full `just ci` passed. - 125 focused detector and message-rendering tests pass; targeted analysis is clean. The palette-access regression fails on the original implementation (10,320 unnecessary reads) and passes with the fix. It also checks that consecutive shortcodes share one palette scan. - **Full Buzz app, real community data, debug/JIT iOS simulator, not a rendering fixture:** two paired actual-input captures measured 125–127 µs per original detector call versus under 1 µs with the fix. All 5,200 executions per version agreed across 260 observed prose calls and a 2,580-entry palette. Instrumentation repeated each detector call, so these captures are excluded from frame comparisons. - Separate clean-code scrolling runs do **not** establish a reliable smoothness improvement: quiet original UI-callback p95 was 10.1–12.6 ms versus 10.1–12.9 ms fixed. A busy fixed run still reached 90.8 ms. Normal app services stayed enabled; only scrolling was exercised. Live background refresh, debug mode, host scheduling, and profiler overhead limit interpretation. No physical-device/release-AOT or end-to-end interaction-latency claim. Signed-off-by: Tom Brow --- mobile/lib/shared/emoji/emoji_only.dart | 39 +++++++------- mobile/test/shared/emoji/emoji_only_test.dart | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/mobile/lib/shared/emoji/emoji_only.dart b/mobile/lib/shared/emoji/emoji_only.dart index e6994f0b9b..97a945b78f 100644 --- a/mobile/lib/shared/emoji/emoji_only.dart +++ b/mobile/lib/shared/emoji/emoji_only.dart @@ -44,35 +44,35 @@ bool isEmojiOnlyMessage( final trimmed = content.trim(); if (trimmed.isEmpty) return false; - final shortcodes = { - for (final emoji in customEmoji) emoji.shortcode.toLowerCase(), - }; + Set? shortcodes; var sawEmoji = false; // Grapheme clusters, so a ZWJ family or a flag pair is one unit. Desktop // hand-rolls this scan; Dart's `characters` already segments correctly. final iterator = trimmed.characters.iterator; - final clusters = []; while (iterator.moveNext()) { - clusters.add(iterator.current); - } + final cluster = iterator.current; - var index = 0; - while (index < clusters.length) { - final cluster = clusters[index]; - - if (cluster.trim().isEmpty) { - index += 1; - continue; - } + if (cluster.trim().isEmpty) continue; if (cluster == ':') { - final end = clusters.indexOf(':', index + 1); - if (end <= index + 1) return false; - final shortcode = clusters.sublist(index + 1, end).join().toLowerCase(); - if (!shortcodes.contains(shortcode)) return false; + final shortcode = StringBuffer(); + var closed = false; + while (iterator.moveNext()) { + if (iterator.current == ':') { + closed = true; + break; + } + shortcode.write(iterator.current); + } + if (!closed || shortcode.isEmpty) return false; + shortcodes ??= { + for (final emoji in customEmoji) emoji.shortcode.toLowerCase(), + }; + if (!shortcodes.contains(shortcode.toString().toLowerCase())) { + return false; + } sawEmoji = true; - index = end + 1; continue; } @@ -80,7 +80,6 @@ bool isEmojiOnlyMessage( return false; } sawEmoji = true; - index += 1; } return sawEmoji; diff --git a/mobile/test/shared/emoji/emoji_only_test.dart b/mobile/test/shared/emoji/emoji_only_test.dart index 261b7d7cf1..10b7703db1 100644 --- a/mobile/test/shared/emoji/emoji_only_test.dart +++ b/mobile/test/shared/emoji/emoji_only_test.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/emoji/emoji_only.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -55,6 +57,34 @@ void main() { expect(emojiOnly('hi :buzz:', customEmoji: _customEmoji), isFalse); }); + test('scans consecutive shortcodes without skipping following text', () { + expect(emojiOnly(':BUZZ::buzz:', customEmoji: _customEmoji), isTrue); + expect(emojiOnly('😀:buzz:😀', customEmoji: _customEmoji), isTrue); + expect( + emojiOnly(':buzz:ordinary text', customEmoji: _customEmoji), + isFalse, + ); + expect( + emojiOnly(':buzz::unfinished', customEmoji: _customEmoji), + isFalse, + ); + expect(emojiOnly(':buzz:::', customEmoji: _customEmoji), isFalse); + }); + + test('does not inspect the community palette without a shortcode', () { + final palette = _CountingEmojiList(); + expect( + emojiOnly('Ordinary message text. ' * 500, customEmoji: palette), + isFalse, + ); + expect(emojiOnly('😀 👋', customEmoji: palette), isTrue); + expect(emojiOnly('text :buzz:', customEmoji: palette), isFalse); + expect(emojiOnly(':unfinished', customEmoji: palette), isFalse); + expect(palette.reads, 0); + expect(emojiOnly(':buzz::BUZZ:', customEmoji: palette), isTrue); + expect(palette.reads, palette.length); + }); + test('an unknown shortcode is just text', () { // Desktop behaves the same: without the palette it renders literally, so // it must not be scaled up as if it were a glyph. @@ -79,3 +109,26 @@ void main() { }); }); } + +class _CountingEmojiList extends ListBase { + int reads = 0; + + @override + int get length => 2580; + + @override + set length(int value) => throw UnsupportedError('read-only fixture'); + + @override + CustomEmoji operator [](int index) { + reads += 1; + return CustomEmoji( + shortcode: index == 0 ? 'buzz' : 'unused_$index', + url: 'https://example.test/emoji.png', + ); + } + + @override + void operator []=(int index, CustomEmoji value) => + throw UnsupportedError('read-only fixture'); +} From 3a45e87b255adb2b43ac0de94dba7a485290d27e Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 18 Oct 2026 10:31:21 -0400 Subject: [PATCH 02/28] fix(mobile): polish Inbox controls and system notices (#8423) Use Tabler `filter-2` in Inbox and keep the iOS filter button rounded when its menu closes. Remove shaded containers from chat system notices while preserving spacing, profile links, and reactions. Validation: mobile formatting, analysis, full test suite, and iOS Release build; independent review found no blockers. Kenny verified the icon and menu dismissal on his iPhone. --------- Signed-off-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- mobile/ios/Runner/IosNavigationBar.swift | 11 +++++++++++ mobile/lib/features/activity/activity_page.dart | 2 +- .../channels/channel_detail_page/system_rows.dart | 6 +----- mobile/lib/shared/theme/buzz_icons.dart | 3 +++ mobile/test/shared/theme/tabler_icon_render_test.dart | 4 ++++ 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index 24692e38a3..1b9b1f4675 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -623,6 +623,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { let glass = UIGlassEffect(style: .regular) glass.isInteractive = true surface.effect = glass + surface.cornerConfiguration = .capsule() } else { surface.backgroundColor = controlBackground } @@ -647,6 +648,16 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { if let action = item.primaryAction { button.addAction(action, for: .touchUpInside) } button.menu = item.menu button.showsMenuAsPrimaryAction = item.menu != nil + if item.menu != nil { + // UIKit lifts the button itself for its menu transition, not the + // enclosing glass. Give that return target the same rounded shape. + if #available(iOS 26.0, *) { + button.cornerConfiguration = .capsule() + } + button.layer.cornerRadius = 22 + button.layer.cornerCurve = .circular + button.clipsToBounds = true + } button.isEnabled = item.isEnabled button.accessibilityLabel = item.accessibilityLabel button.accessibilityValue = item.accessibilityValue diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 03aba66863..13382da620 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -437,7 +437,7 @@ class ActivityPage extends HookConsumerWidget { nativeActions: [ IosNavigationAction( label: 'Filter inbox', - symbol: 'line.3.horizontal.decrease', + icon: BuzzIcons.filter2, children: [ for (final entry in _filterLabels.entries) IosNavigationAction( diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index 1f5db4ee18..b4dff4aa91 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -196,16 +196,12 @@ class _SystemMessageRow extends HookConsumerWidget { alignment: isHuddle ? Alignment.centerLeft : Alignment.center, - child: Container( + child: Padding( key: ValueKey('activity-card-${message.id}'), padding: const EdgeInsets.symmetric( horizontal: Grid.twelve, vertical: Grid.xxs, ), - decoration: BoxDecoration( - color: context.colors.onSurface.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(Radii.container), - ), child: IntrinsicWidth( child: groupedMembership != null ? _MembershipSystemMessageContent( diff --git a/mobile/lib/shared/theme/buzz_icons.dart b/mobile/lib/shared/theme/buzz_icons.dart index c7b3140889..9ced63f285 100644 --- a/mobile/lib/shared/theme/buzz_icons.dart +++ b/mobile/lib/shared/theme/buzz_icons.dart @@ -5,6 +5,9 @@ import 'package:flutter/widgets.dart'; /// Semantic names preserve existing call sites; artwork matches buzz-app. /// Native iOS controls share Tabler artwork except Back and Close navigation. abstract final class BuzzIcons { + /// Tabler `filter-2`. Referenced from Dart so release icon pruning retains it. + static const filter2 = IconData(0x1014b, fontFamily: 'BuzzTabler'); + static const cornerUpLeft = IconData(0xeb77, fontFamily: 'BuzzTabler'); static const droplet = IconData(0xea97, fontFamily: 'BuzzTabler'); diff --git a/mobile/test/shared/theme/tabler_icon_render_test.dart b/mobile/test/shared/theme/tabler_icon_render_test.dart index ffb50cc3ef..8765269e9b 100644 --- a/mobile/test/shared/theme/tabler_icon_render_test.dart +++ b/mobile/test/shared/theme/tabler_icon_render_test.dart @@ -45,6 +45,10 @@ void main() { child: Column( mainAxisSize: MainAxisSize.min, children: [ + const ListTile( + leading: Icon(BuzzIcons.filter2), + title: Text('Filter inbox'), + ), const ListTile( leading: Icon(BuzzIcons.archive), title: Text('Archive'), From cf1bb8dd13cd9b722b96473870bae98c57609b87 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 18 Oct 2026 13:06:27 -0400 Subject: [PATCH 03/28] chore(deny): exclude buzz-mesh-smoke from cargo-deny graph (#8413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Excludes the test-only `buzz-mesh-smoke` crate from the `cargo-deny` graph via `[graph] exclude` in `deny.toml`. `buzz-mesh-smoke` holds hardware smoke-test examples; nothing from it ships. It is the only root-workspace crate that depends on `mesh-llm`, which brings in `zip` 8.6 (RUSTSEC-2026-0367/-0368/-0369), the `zbus` 5 unmaintained notices (RUSTSEC-2026-0370/-0371/-0372), and `negentropy` via `mesh-llm-client` → `nostr-sdk` (RUSTSEC-2026-0365). None has a usable upstream fix for the pinned `mesh-llm` v0.76.2. The `zip` bump has merged upstream in [mesh-llm#2471](https://github.com/Mesh-LLM/mesh-llm/pull/2471), but no `mesh-llm` release contains it yet (latest is v0.78.1). CI's Security job runs `cargo-deny check` from the repo root, which reads this `deny.toml`, so the exclusion applies there. These advisories remain fatal for any shipped crate that pulls in the same dependencies. No existing advisory ignores were changed. Signed-off-by: Will Pfleger Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- deny.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/deny.toml b/deny.toml index 00b4f66ae4..2362c3f7c0 100644 --- a/deny.toml +++ b/deny.toml @@ -1,3 +1,9 @@ +[graph] +# buzz-mesh-smoke is a test-only hardware smoke-test crate; nothing from it ships. +# It is the only root-workspace crate depending on mesh-llm, whose zip/zbus +# advisories have no usable upstream fix for our pinned version. +exclude = ["buzz-mesh-smoke"] + [advisories] ignore = [ # instant 0.1.13 — unmaintained crate. Transitive dep: nostr → instant. From bc995041cdd0a6c68c1b8a988f4e73596b3a92f9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 18 Oct 2026 13:30:50 -0400 Subject: [PATCH 04/28] fix(deps): bump negentropy to 0.5.2 for RUSTSEC-2026-0365 (#8409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Bumps `negentropy` (pulled in via `nostr-sdk`) to 0.5.2 to clear RUSTSEC-2026-0365. The `zip` advisories (RUSTSEC-2026-0367/0368/0369) are not covered here because `mesh-llm` still requires `zip` 8. Signed-off-by: Will Pfleger Co-authored-by: Duncan --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 94a5ebd139..57d4a9ff54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6227,9 +6227,9 @@ checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "negentropy" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" +checksum = "a3d66662cb5ca384d4991509755ed5a42dd8e31c4282bf881938e506fc3146ec" [[package]] name = "netdev" From 6f67dcdac4fdc948660d6b78baa673252c94284f Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 18 Oct 2026 13:41:21 -0400 Subject: [PATCH 05/28] fix(desktop): bump zip to 9 for RUSTSEC-2026-0367/0368/0369 (#8412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Bumps the desktop app's direct `zip` dependency from 8 to 9 to clear RUSTSEC-2026-0367, RUSTSEC-2026-0368 and RUSTSEC-2026-0369 on the managed Node.js archive extraction path. zip 9 makes `ZipArchive::name_for_index` and `ZipFile::name` fallible. Managed Node.js zip validation now rejects an entry whose name can't be decoded, and the "unsafe path" error prints the raw entry name lossily. The absolute-path, drive-prefix, traversal and `enclosed_name()` checks are unchanged. The lockfile update is scoped to `zip`; it pulls in newer `zstd`, `lzma-rust2` and `ppmd-rust` that zip 9 requires. **`zip` 8 is still in release builds.** With `--features mesh-llm`, `mesh-llm` v0.76.2 (`mesh-llm-system`, `mesh-llm-plugin-manager`, `mesh-llm-host-runtime`) still depends on `zip` 8.6.0. That goes away when the `mesh-llm` pin moves to a release that includes Mesh-LLM/mesh-llm#2471. Signed-off-by: Will Pfleger Co-authored-by: Duncan --- desktop/src-tauri/Cargo.lock | 50 ++++++++++++++----- desktop/src-tauri/Cargo.toml | 2 +- .../commands/agent_discovery/managed_node.rs | 5 +- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 2b622d7c6c..4a21a6fa58 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -916,7 +916,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if 1.0.4", - "constant_time_eq", + "constant_time_eq 0.4.2", "cpufeatures 0.3.0", ] @@ -1208,7 +1208,7 @@ dependencies = [ "window-vibrancy", "windows-sys 0.61.2", "zeroize", - "zip 8.6.0", + "zip 9.0.5", ] [[package]] @@ -1809,6 +1809,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "constant_time_eq" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -5095,9 +5104,9 @@ dependencies = [ [[package]] name = "lzma-rust2" -version = "0.16.5" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" +checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb" dependencies = [ "sha2 0.11.0", ] @@ -7869,9 +7878,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppmd-rust" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" +checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1" [[package]] name = "ppv-lite86" @@ -14015,10 +14024,24 @@ name = "zip" version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zip" +version = "9.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "aes 0.9.1", "bzip2 0.6.1", - "constant_time_eq", + "constant_time_eq 0.6.1", "crc32fast", "deflate64", "flate2", @@ -14027,6 +14050,7 @@ dependencies = [ "indexmap 2.14.0", "lzma-rust2", "memchr", + "nix 0.31.3", "pbkdf2 0.13.0", "ppmd-rust", "sha1 0.11.0", @@ -14063,27 +14087,27 @@ dependencies = [ [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.2.4" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index fdff759d0f..41f3d4d4ac 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -139,7 +139,7 @@ png = "0.18" # target-gated inside arboard; at runtime X11 sessions still fall back to X11. arboard = { version = "3", features = ["wayland-data-control"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] } -zip = "8" +zip = "9" flate2 = "1" sherpa-onnx = "1.12" regex = "1" diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index fbfb068c0e..fdb2999a48 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -472,7 +472,8 @@ fn validate_managed_node_zip_entries( for i in 0..archive.len() { let name = archive .name_for_index(i) - .ok_or_else(|| format!("Node.js zip entry {i}: missing name"))?; + .ok_or_else(|| format!("Node.js zip entry {i}: missing name"))? + .map_err(|e| format!("Node.js zip entry {i}: invalid name: {e}"))?; // Absolute-path checks (platform-neutral). if name.starts_with('/') || name.starts_with('\\') { @@ -509,7 +510,7 @@ fn extract_managed_node_zip( None => { return Err(format!( "Node.js zip contains unsafe path: {}", - entry.name() + String::from_utf8_lossy(entry.name_raw()) )) } }; From 3ffbfa693c6727b04b01ef727ce4848e4695ac40 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sun, 18 Oct 2026 11:32:09 -0700 Subject: [PATCH 06/28] feat(relay): start a channel with its members in one create (#8421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What A kind:9007 can now name the channel's first members with `p` tags. Those members are added in the same database transaction that creates the channel. Resending a create whose OK was lost is now accepted instead of refused. ## Why Starting a new Solo chat today takes about 6–8 sequential relay round trips before the first message goes out: create, read, add each agent (kind:9000), read the roster, then send. With members in the create, a client can start a chat with its agents as two writes: the create, then the message as soon as the create is OK. The resend fix makes the create safe for an outbox to retry. Today an identical resend after a lost OK is refused with `duplicate: channel already exists`, so the client can't tell success from a real UUID collision. ## How - **buzz-db:** `Db::create_channel_with_event` writes the channel, its owner, its members (role `member`, `invited_by` = creator) and the 9007 event itself in one transaction. A channel row therefore never exists without its create event. `create_channel_with_id` is unchanged for its other callers. - **Validation** (`side_effects::initial_members` and the `validate_admin_event` 9007 branch): - Each `p` must be a 32-byte hex pubkey. - Duplicates and the creator are dropped. - At most 32 members. - An `h` tag is required when there are members. - Each member goes through the same `decide_channel_add_policy` check a kind:9000 uses, so an agent that refuses third-party adds keeps the whole create out. - **Side effects:** each initial member gets the same `member_joined` system message and member-added notification a kind:9000 would post. The fallback in `handle_create_group` creates a bare channel when the pre-created row is missing; it announces no members, because it added none. - **Resend:** when the UUID is already taken, the same transaction checks whether this exact event is the stored create. - If it is, the create is OK'd as `duplicate:` (it is the same signed create). - Anything else, including a changed create from the same author, is refused and writes nothing, so it can never re-run side effects or announce members. - A resend that arrives while the first create is still uncommitted waits on its channel insert. It is confirmed once the first commits, or creates the channel itself if the first rolled back. Before this, a resend in that window was refused, and the app then withdrew a chat that existed. - **No compensation:** ingest no longer soft-deletes a pre-created channel when the event insert fails. The rollback covers it. - **Announcements:** a failed `member_joined` notice for one initial member is logged at `error` and the loop continues, so the members after it still get theirs. A resend writes nothing, so nothing would retry them. ## Compatibility A 9007 with malformed `p` tags, or more than 32 of them, used to be accepted with the tags ignored. It is now rejected with `invalid:`. The relay's own SDK and CLI builders send no `p` tags on 9007. ## Out of scope - The kind:9000 OK still doesn't prove the membership change committed: side-effect failures are logged and OK'd, and a duplicate replay skips side effects. The new path doesn't depend on kind:9000. - Events in separate HTTP requests are still unordered. Clients send the first message after the create's OK. ## Tests `store::channel::postgres_tests` (buzz-db): - `a_resend_beside_an_open_create_confirms_it_once_committed`: holds a create open, checks that the resend waits, commits, and expects `Exists { resent: true }` with one owner and one member. - `a_resend_beside_a_failed_create_creates_the_channel`: the same, but the first create rolls back. - `a_create_whose_event_fails_leaves_no_channel`: an event the insert refuses leaves no channel row, and a retry creates the channel. - `another_create_for_a_taken_channel_writes_nothing`. Two mutations, each caught: committing the channel before its event fails the "leaves no channel" test, and looking up the event before the channel insert waits fails the "confirmed once committed" test. `handlers/create_with_members_postgres_tests.rs`, all through the real ingest path: - `create_adds_its_p_tags_as_members` checks: - the roles and the 39002 roster - exactly one `member_joined` notice - that the agent can post with no separate add - `create_respects_the_members_add_policy`: an agent with policy `nobody` gets an `invalid:` rejection, and no channel is created. - `resending_a_create_confirms_it`: - an identical resend is accepted - a changed create (from the creator or from someone else) is refused - the roster is unchanged and nobody new is announced - Unit tests for `initial_members`: dedupe, creator excluded, bad hex, the 32-member cap, and the missing `h` tag. Each new test was mutation-checked against the change it covers: no members, no policy check, refuse every resend, and let a changed creator create through. Each mutation turns its test red. Agent exercise on a local relay, over `POST /events` the way the app sends: - The 9007 with `p` = agent was accepted in 54–97ms (debug build). - The kind 9 was accepted 12–15ms after the create's OK. - The agent was in the 39002 roster, and the operator listener delivered the mention. - An identical resend came back `duplicate:` (accepted), and a changed create was refused. Local suite runs at 10adcf6dd (the transaction fix), on PG14: - `postgres-test-run.sh -p buzz-relay -p buzz-db`: 28 failures. That is the same 27 as below, plus `personal_read_frontier_is_monotonic…`, which hit a statement timeout under load and passes when run alone. - `cargo test -p buzz-db -p buzz-relay`: the same 6 `api::media` failures and the flaky `mesh_demo` test. Earlier local suite runs: - New tests pass and clippy is clean. - `postgres-test-run.sh -p buzz-relay -p buzz-db`: 27 failures (partition maintenance, a huddle links test, a redis boot test), and the same 27 fail on a96ace4e5. - `cargo test -p buzz-relay`: 6 `api::media` failures (they need a DB at the default URL) also fail on base. - One `api::mesh_demo` test is flaky: it passed 2 of 3 branch runs and failed once on a timeout. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Bradley Axen Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 Co-authored-by: claude00 Co-authored-by: ART --- crates/buzz-db/src/store/channel.rs | 435 ++++++++++++++++-- .../create_with_members_postgres_tests.rs | 251 ++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 133 +++--- crates/buzz-relay/src/handlers/mod.rs | 2 + .../buzz-relay/src/handlers/side_effects.rs | 162 ++++++- 5 files changed, 867 insertions(+), 116 deletions(-) create mode 100644 crates/buzz-relay/src/handlers/create_with_members_postgres_tests.rs diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index cf8d15094e..e456f46dfb 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::error::{DbError, Result}; use crate::Db; -use buzz_core::CommunityId; +use buzz_core::{CommunityId, StoredEvent}; use buzz_datastore_tracing::datastore_span; // Re-export the canonical enum definitions from buzz-core. @@ -200,6 +200,143 @@ pub async fn create_channel_with_id( ttl_seconds: Option, labels: &[String], ) -> Result<(ChannelRecord, bool)> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + + let was_created = insert_channel_rows( + &mut tx, + channel_id, + NewChannel { + name, + channel_type, + visibility, + description, + created_by, + ttl_seconds, + labels, + members: &[], + }, + ) + .await?; + + let row = sqlx::query( + r#" + SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility, + description, canvas, + created_by, created_at, updated_at, archived_at, deleted_at, + nip29_group_id, topic_required, max_members, + topic, topic_set_by, topic_set_at, + purpose, purpose_set_by, purpose_set_at, + ttl_seconds, ttl_deadline, labels + FROM channels WHERE community_id = $1 AND id = $2 + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_one(tx.conn()) + .await?; + + let record = row_to_channel_record(row)?; + tx.commit().await?; + Ok((record, was_created)) +} + +/// A channel to create: the fields a kind:9007 sets. +pub struct NewChannel<'a> { + /// Display name, canonicalized before storage. + pub name: &'a str, + /// Stream, forum, DM, … + pub channel_type: ChannelType, + /// Open or private. + pub visibility: ChannelVisibility, + /// The `about` text. + pub description: Option<&'a str>, + /// The creator's 32-byte pubkey; becomes the owner. + pub created_by: &'a [u8], + /// Ephemeral channel lifetime. + pub ttl_seconds: Option, + /// Labels validated by `buzz_core::channel::parse_channel_labels`. + pub labels: &'a [String], + /// 32-byte pubkeys added as ordinary members invited by the creator. The + /// caller has authorized each addition. + pub members: &'a [Vec], +} + +/// What [`Db::create_channel_with_event`] did. +#[derive(Debug)] +pub enum ChannelCreate { + /// The channel, its members and the create event committed together. + Created(Box), + /// The UUID was already taken and nothing was written. `resent` is true + /// when the stored create that took it is this same event. + Exists { + /// This exact event is the channel's stored create. + resent: bool, + }, +} + +/// See [`Db::create_channel_with_event`]. +pub(crate) async fn create_channel_with_event( + pool: &PgPool, + push_enqueue: Option, + community_id: CommunityId, + channel_id: Uuid, + channel: NewChannel<'_>, + event: &nostr::Event, +) -> Result { + let mut tx = crate::begin_community_event_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + tx.set_push_enqueue(push_enqueue); + + if !insert_channel_rows(&mut tx, channel_id, channel).await? { + let resent = sqlx::query( + "SELECT 1 FROM events WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_optional(tx.conn()) + .await? + .is_some(); + return Ok(ChannelCreate::Exists { resent }); + } + + // The same writes as `Db::insert_event_with_thread_metadata`. + let (stored, inserted) = + crate::event::insert_event_with_thread_metadata_tx(&mut tx, event, Some(channel_id), None) + .await?; + if inserted { + crate::insert_mentions_in_transaction(&mut tx, event, Some(channel_id)).await?; + } + tx.commit().await?; + Ok(ChannelCreate::Created(Box::new(stored))) +} + +/// Inserts the channel row, its owner and its initial members on `tx`. +/// Returns `false`, having written nothing, when `channel_id` already exists. +async fn insert_channel_rows( + tx: &mut crate::AdmittedTx, + channel_id: Uuid, + channel: NewChannel<'_>, +) -> Result { + let NewChannel { + name, + channel_type, + visibility, + description, + created_by, + ttl_seconds, + labels, + members, + } = channel; + let community_id = tx.community(); if created_by.len() != 32 { return Err(DbError::InvalidData(format!( "pubkey must be 32 bytes, got {}", @@ -218,13 +355,6 @@ pub async fn create_channel_with_id( return Err(DbError::InvalidData("channel name is required".into())); } - let mut tx = crate::begin_community_write_transaction( - pool, - community_id, - crate::observability::WriterOperation::EventWrite, - ) - .await?; - let rows_affected = sqlx::query( r#" INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels) @@ -266,28 +396,34 @@ pub async fn create_channel_with_id( .bind(created_by) .execute(tx.conn()) .await?; + + for member in members + .iter() + .filter(|member| member.as_slice() != created_by) + { + if member.len() != 32 { + return Err(DbError::InvalidData(format!( + "pubkey must be 32 bytes, got {}", + member.len() + ))); + } + sqlx::query( + r#" + INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) + VALUES ($1, $2, $3, 'member', $4) + ON CONFLICT (community_id, channel_id, pubkey) DO NOTHING + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(member) + .bind(created_by) + .execute(tx.conn()) + .await?; + } } - let row = sqlx::query( - r#" - SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility, - description, canvas, - created_by, created_at, updated_at, archived_at, deleted_at, - nip29_group_id, topic_required, max_members, - topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels - FROM channels WHERE community_id = $1 AND id = $2 - "#, - ) - .bind(community_id.as_uuid()) - .bind(channel_id) - .fetch_one(tx.conn()) - .await?; - - let record = row_to_channel_record(row)?; - tx.commit().await?; - Ok((record, was_created)) + Ok(was_created) } /// Fetches a channel record by `(community_id, id)`. Returns `ChannelNotFound` if missing or deleted. @@ -955,35 +1091,28 @@ impl Db { .await } - /// Creates a channel with a client-supplied UUID and an initial label set. + /// Creates `channel_id` from its kind:9007 `event`: the channel, its + /// owner, its initial members and the event itself commit in one + /// transaction, so a channel row always has its create event beside it. /// - /// The same as [`Db::create_channel_with_id`], plus `labels`, which the - /// caller has validated with `buzz_core::channel::parse_channel_labels`. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "create_labeled_channel_with_id", system = "postgresql")] - pub async fn create_labeled_channel_with_id( + /// A concurrent copy of the same create waits on the first one's channel + /// insert, then sees either the committed event (`Exists { resent: true }`) + /// or, if the first rolled back, an untaken UUID. + #[datastore_span(name = "create_channel_with_event", system = "postgresql")] + pub async fn create_channel_with_event( &self, community_id: CommunityId, channel_id: Uuid, - name: &str, - channel_type: ChannelType, - visibility: ChannelVisibility, - description: Option<&str>, - created_by: &[u8], - ttl_seconds: Option, - labels: &[String], - ) -> Result<(ChannelRecord, bool)> { - create_channel_with_id( + channel: NewChannel<'_>, + event: &nostr::Event, + ) -> Result { + create_channel_with_event( &self.pool, + self.push_enqueue.clone(), community_id, channel_id, - name, - channel_type, - visibility, - description, - created_by, - ttl_seconds, - labels, + channel, + event, ) .await } @@ -1426,4 +1555,210 @@ mod postgres_tests { "reaper should carry the archived row's community id and host" ); } + + fn create_event(creator: &Keys, channel_id: Uuid) -> nostr::Event { + nostr::EventBuilder::new(nostr::Kind::Custom(9007), "") + .tags([ + nostr::Tag::parse(["h", &channel_id.to_string()]).expect("h tag"), + nostr::Tag::parse(["name", "start"]).expect("name tag"), + ]) + .sign_with_keys(creator) + .expect("sign 9007") + } + + fn new_channel<'a>(created_by: &'a [u8], members: &'a [Vec]) -> NewChannel<'a> { + NewChannel { + name: "start", + channel_type: ChannelType::Stream, + visibility: ChannelVisibility::Private, + description: None, + created_by, + ttl_seconds: None, + labels: &[], + members, + } + } + + /// Holds a create open on its own transaction (channel row and event both + /// written, not committed) while the same create runs again beside it. + async fn resend_beside_open_create(commit_first: bool) -> ChannelCreate { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let creator = Keys::generate(); + let creator_bytes = creator.public_key().to_bytes().to_vec(); + let members = vec![random_pubkey()]; + let channel_id = Uuid::new_v4(); + let event = create_event(&creator, channel_id); + + let mut first = crate::begin_community_write_transaction( + &pool, + community, + crate::observability::WriterOperation::EventWrite, + ) + .await + .expect("begin first create"); + assert!(insert_channel_rows( + &mut first, + channel_id, + new_channel(&creator_bytes, &members) + ) + .await + .expect("first create's rows")); + crate::event::insert_event_with_thread_metadata_tx( + &mut first, + &event, + Some(channel_id), + None, + ) + .await + .expect("first create's event"); + + let resend = tokio::spawn({ + let (pool, event, creator_bytes, members) = ( + pool.clone(), + event.clone(), + creator_bytes.clone(), + members.clone(), + ); + async move { + create_channel_with_event( + &pool, + None, + community, + channel_id, + new_channel(&creator_bytes, &members), + &event, + ) + .await + } + }); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!( + !resend.is_finished(), + "the resend waits for the open create instead of answering early" + ); + + if commit_first { + first.commit().await.expect("commit first create"); + } else { + drop(first); + } + let outcome = resend.await.expect("resend task").expect("resend outcome"); + let roster = sqlx::query_scalar::<_, Vec>( + "SELECT pubkey FROM channel_members WHERE community_id = $1 AND channel_id = $2 \ + AND removed_at IS NULL ORDER BY pubkey", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .fetch_all(&pool) + .await + .expect("roster"); + let mut expected = vec![creator_bytes, members[0].clone()]; + expected.sort(); + assert_eq!( + roster, expected, + "the channel has its owner and member once" + ); + outcome + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn a_resend_beside_an_open_create_confirms_it_once_committed() { + let outcome = resend_beside_open_create(true).await; + assert!( + matches!(outcome, ChannelCreate::Exists { resent: true }), + "{outcome:?}" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn a_resend_beside_a_failed_create_creates_the_channel() { + let outcome = resend_beside_open_create(false).await; + assert!(matches!(outcome, ChannelCreate::Created(_)), "{outcome:?}"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn a_create_whose_event_fails_leaves_no_channel() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let creator = Keys::generate(); + let creator_bytes = creator.public_key().to_bytes().to_vec(); + let channel_id = Uuid::new_v4(); + // An ephemeral kind is refused by the event insert, after the rows. + let unstorable = nostr::EventBuilder::new(nostr::Kind::Custom(20_000), "") + .sign_with_keys(&creator) + .expect("sign"); + let members = [random_pubkey()]; + create_channel_with_event( + &pool, + None, + community, + channel_id, + new_channel(&creator_bytes, &members), + &unstorable, + ) + .await + .expect_err("the event insert fails"); + assert!( + get_channel(&pool, community, channel_id).await.is_err(), + "the channel rolls back with its event" + ); + let retry = create_channel_with_event( + &pool, + None, + community, + channel_id, + new_channel(&creator_bytes, &members), + &create_event(&creator, channel_id), + ) + .await + .expect("retry"); + assert!(matches!(retry, ChannelCreate::Created(_)), "{retry:?}"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn another_create_for_a_taken_channel_writes_nothing() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let creator = Keys::generate(); + let creator_bytes = creator.public_key().to_bytes().to_vec(); + let channel_id = Uuid::new_v4(); + let first = create_channel_with_event( + &pool, + None, + community, + channel_id, + new_channel(&creator_bytes, &[]), + &create_event(&creator, channel_id), + ) + .await + .expect("first create"); + assert!(matches!(first, ChannelCreate::Created(_)), "{first:?}"); + + let other = Keys::generate(); + let other_bytes = other.public_key().to_bytes().to_vec(); + let intruder = random_pubkey(); + let second = create_channel_with_event( + &pool, + None, + community, + channel_id, + new_channel(&other_bytes, std::slice::from_ref(&intruder)), + &create_event(&other, channel_id), + ) + .await + .expect("second create"); + assert!( + matches!(second, ChannelCreate::Exists { resent: false }), + "{second:?}" + ); + let members = crate::channel_members::get_members(&pool, community, channel_id) + .await + .expect("members"); + assert_eq!(members.len(), 1, "only the first creator is a member"); + } } diff --git a/crates/buzz-relay/src/handlers/create_with_members_postgres_tests.rs b/crates/buzz-relay/src/handlers/create_with_members_postgres_tests.rs new file mode 100644 index 0000000000..de8327ddb4 --- /dev/null +++ b/crates/buzz-relay/src/handlers/create_with_members_postgres_tests.rs @@ -0,0 +1,251 @@ +//! Postgres-backed tests for starting a channel with its members: a kind:9007 +//! that adds its `p` tags as members, and a creator's retry of it. + +use std::sync::Arc; + +use buzz_core::TenantContext; +use buzz_db::event::EventQuery; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use uuid::Uuid; + +use crate::handlers::group_state_postgres_tests::{community, group_state_tags, http_auth, state}; +use crate::handlers::ingest::{ingest_event, IngestError, IngestResult}; +use crate::state::AppState; + +fn create_event(creator: &Keys, channel_id: Uuid, members: &[&Keys]) -> Event { + let mut tags = vec![ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["name", "start"]).unwrap(), + Tag::parse(["visibility", "private"]).unwrap(), + ]; + for member in members { + tags.push(Tag::parse(["p", &member.public_key().to_hex()]).unwrap()); + } + EventBuilder::new(Kind::Custom(9007), "") + .tags(tags) + .sign_with_keys(creator) + .expect("sign 9007") +} + +fn message_event(author: &Keys, channel_id: Uuid) -> Event { + EventBuilder::new(Kind::Custom(9), "hello") + .tags([Tag::parse(["h", &channel_id.to_string()]).unwrap()]) + .sign_with_keys(author) + .expect("sign message") +} + +async fn ingest( + state: &Arc, + tenant: &TenantContext, + event: Event, + keys: &Keys, +) -> Result { + ingest_event(state, tenant, event, http_auth(keys)).await +} + +async fn roles( + state: &AppState, + tenant: &TenantContext, + channel_id: Uuid, +) -> Vec<(Vec, String)> { + let mut roles: Vec<_> = state + .db + .get_members(tenant.community(), channel_id) + .await + .expect("members") + .into_iter() + .map(|member| (member.pubkey, member.role)) + .collect(); + roles.sort(); + roles +} + +/// The `p` tags join in the create's transaction, the roster lists them, and +/// each gets the "joined" notice a kind:9000 would post. +/// Mutation: drop `members` from the create → no agent membership → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn create_adds_its_p_tags_as_members() { + let state = state().await; + let tenant = community(&state, "create-members").await; + let (creator, agent) = (Keys::generate(), Keys::generate()); + let channel_id = Uuid::new_v4(); + + let result = ingest( + &state, + &tenant, + create_event(&creator, channel_id, &[&agent, &agent]), + &creator, + ) + .await + .expect("create"); + assert!(result.accepted, "{}", result.message); + + let mut expected = vec![ + ( + creator.public_key().to_bytes().to_vec(), + "owner".to_string(), + ), + (agent.public_key().to_bytes().to_vec(), "member".to_string()), + ]; + expected.sort(); + assert_eq!(roles(&state, &tenant, channel_id).await, expected); + + let roster = group_state_tags(&state, &tenant, channel_id, 39002).await; + assert!( + roster + .iter() + .any(|tag| tag[0] == "p" && tag[1] == agent.public_key().to_hex()), + "{roster:?}" + ); + + let notices = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_SYSTEM_MESSAGE as i32]), + channel_id: Some(channel_id), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("system messages"); + let joined = notices + .iter() + .filter(|stored| { + let content: serde_json::Value = + serde_json::from_str(&stored.event.content).unwrap_or_default(); + content["type"] == "member_joined" && content["target"] == agent.public_key().to_hex() + }) + .count(); + assert_eq!(joined, 1, "one joined notice for the agent"); + + // The agent can answer in the chat with no separate add. + let reply = ingest(&state, &tenant, message_event(&agent, channel_id), &agent) + .await + .expect("agent reply"); + assert!(reply.accepted, "{}", reply.message); +} + +/// An agent whose policy refuses third-party adds keeps the whole create out: +/// no channel is made. Mutation: skip the policy check → channel exists → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn create_respects_the_members_add_policy() { + let state = state().await; + let tenant = community(&state, "create-members-policy").await; + let (creator, agent) = (Keys::generate(), Keys::generate()); + let agent_bytes = agent.public_key().to_bytes().to_vec(); + state + .db + .ensure_user(tenant.community(), &agent_bytes) + .await + .expect("agent user"); + state + .db + .set_channel_add_policy(tenant.community(), &agent_bytes, "nobody") + .await + .expect("policy"); + let channel_id = Uuid::new_v4(); + + let result = ingest( + &state, + &tenant, + create_event(&creator, channel_id, &[&agent]), + &creator, + ) + .await; + match result { + Err(IngestError::Rejected(message)) => { + assert!(message.starts_with("invalid:"), "{message}") + } + Err(error) => panic!("expected a rejection, got {error:?}"), + Ok(result) => panic!("expected a rejection, got OK {}", result.message), + } + assert!(state + .db + .get_channel(tenant.community(), channel_id) + .await + .is_err()); +} + +/// Resending a create whose OK was lost is accepted; a different create for +/// the same UUID, even from its creator, is refused before its side effects, +/// so it can neither add nor announce anyone. +/// Mutations: refuse every existing UUID → resend not accepted → RED; check the +/// members' policies before recognizing a stored resend → RED; let the +/// creator's changed create through → stranger announced → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn resending_a_create_confirms_it() { + let state = state().await; + let tenant = community(&state, "create-retry").await; + let (creator, agent, stranger, other) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + let channel_id = Uuid::new_v4(); + let create = create_event(&creator, channel_id, &[&agent]); + + let first = ingest(&state, &tenant, create.clone(), &creator) + .await + .expect("create"); + assert!(first.accepted, "{}", first.message); + let before = roles(&state, &tenant, channel_id).await; + let resent = ingest(&state, &tenant, create.clone(), &creator) + .await + .expect("resend"); + assert!(resent.accepted, "{}", resent.message); + + for (author, members) in [(&creator, vec![&agent, &stranger]), (&other, vec![])] { + let changed = ingest( + &state, + &tenant, + create_event(author, channel_id, &members), + author, + ) + .await + .expect("changed create"); + assert!(!changed.accepted); + assert_eq!(changed.message, "duplicate: channel already exists"); + } + + // The agent closing its adds after the create doesn't refuse the resend. + let agent_bytes = agent.public_key().to_bytes().to_vec(); + state + .db + .ensure_user(tenant.community(), &agent_bytes) + .await + .expect("agent user"); + state + .db + .set_channel_add_policy(tenant.community(), &agent_bytes, "nobody") + .await + .expect("policy"); + let resent_after_policy = ingest(&state, &tenant, create, &creator) + .await + .expect("resend after policy change"); + assert!( + resent_after_policy.accepted, + "{}", + resent_after_policy.message + ); + + assert_eq!(roles(&state, &tenant, channel_id).await, before); + let notices = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_SYSTEM_MESSAGE as i32]), + channel_id: Some(channel_id), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("system messages"); + assert!( + !notices.iter().any(|stored| stored + .event + .content + .contains(&stranger.public_key().to_hex())), + "the refused create announced nobody" + ); +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index de4a19217d..0b2e8d8f09 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2381,6 +2381,18 @@ fn validate_canvas_future_timestamp(event_ts: i64, now: i64) -> Result<(), &'sta Ok(()) } +/// A validated kind:9007 whose channel is created when the event is stored. +struct ChannelCreateRequest { + channel_id: Uuid, + name: String, + channel_type: buzz_db::channel::ChannelType, + visibility: buzz_db::channel::ChannelVisibility, + description: Option, + ttl_seconds: Option, + labels: Vec, + members: Vec>, +} + async fn ingest_event_inner( state: &Arc, tracer: &Arc, @@ -3050,8 +3062,9 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } - // Track pre-created channel UUID for compensation on insert failure. - let mut pre_created_channel: Option = None; + // A kind:9007 with an `h` tag creates its channel when it is stored, in the + // same transaction (see the storage dispatch below). + let mut channel_create: Option = None; if kind_u32 == KIND_NIP29_CREATE_GROUP { // Validate name tag is present and non-empty before any DB work. @@ -3112,50 +3125,24 @@ async fn ingest_event_inner( .unwrap_or_default(); if let Some(client_uuid) = channel_id { - let name = create_name.unwrap_or_default(); - let name = buzz_core::channel::canonical_channel_name(&name); - - let description = event.tags.iter().find_map(|t| { - if t.kind().to_string() == "about" { - t.content().map(|s| s.to_string()) - } else { - None - } + let members = super::side_effects::initial_members(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + channel_create = Some(ChannelCreateRequest { + channel_id: client_uuid, + name: create_name.unwrap_or_default(), + channel_type, + visibility, + description: event.tags.iter().find_map(|t| { + if t.kind().to_string() == "about" { + t.content().map(|s| s.to_string()) + } else { + None + } + }), + ttl_seconds: super::resolve_ttl(&event, state.config.ephemeral_ttl_override), + labels, + members, }); - - let ttl_seconds = super::resolve_ttl(&event, state.config.ephemeral_ttl_override); - - let actor_bytes = event.pubkey.to_bytes().to_vec(); - let (_, was_created) = state - .db - .create_labeled_channel_with_id( - tenant.community(), - client_uuid, - name, - channel_type, - visibility, - description.as_deref(), - &actor_bytes, - ttl_seconds, - &labels, - ) - .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))?; - - if !was_created { - return Ok(IngestResult { - event_id: event_id_hex, - accepted: false, - message: "duplicate: channel already exists".into(), - }); - } - pre_created_channel = Some(client_uuid); - metrics::counter!( - "buzz_channels_created_total", - "community" => tenant.host().to_owned(), - "type" => channel_type.to_string() - ) - .increment(1); } } @@ -3406,7 +3393,49 @@ async fn ingest_event_inner( }; let workflow_deletion = crate::handlers::side_effects::is_workflow_deletion(&event); - let (stored_event, was_inserted) = if workflow_deletion { + let (stored_event, was_inserted) = if let Some(create) = channel_create.as_ref() { + let creator = event.pubkey.to_bytes(); + let channel = buzz_db::channel::NewChannel { + name: &create.name, + channel_type: create.channel_type, + visibility: create.visibility, + description: create.description.as_deref(), + created_by: &creator, + ttl_seconds: create.ttl_seconds, + labels: &create.labels, + members: &create.members, + }; + match state + .db + .create_channel_with_event(tenant.community(), create.channel_id, channel, &event) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))? + { + buzz_db::channel::ChannelCreate::Created(stored) => { + metrics::counter!( + "buzz_channels_created_total", + "community" => tenant.host().to_owned(), + "type" => create.channel_type.to_string() + ) + .increment(1); + (*stored, true) + } + // Resending a create whose OK was lost confirms it. Anything else + // for a taken UUID is refused. Neither writes anything, so the + // create's side effects never run again. + buzz_db::channel::ChannelCreate::Exists { resent } => { + return Ok(IngestResult { + event_id: event_id_hex, + accepted: resent, + message: if resent { + "duplicate:".into() + } else { + "duplicate: channel already exists".into() + }, + }); + } + } + } else if workflow_deletion { // A single commit owns public acceptance, domain mutation, and dispatch. // Failure rolls everything back; identical concurrent requests cannot // divide insertion and repair ownership between two relay workers. @@ -3486,18 +3515,6 @@ async fn ingest_event_inner( { Ok(result) => result, Err(e) => { - // Compensate: if we pre-created a channel for kind:9007, - // soft-delete it so no orphaned channel row remains. - if let Some(ch_id) = pre_created_channel { - if let Err(re) = state - .db - .soft_delete_channel(tenant.community(), ch_id) - .await - { - warn!(event_id = %event_id_hex, "channel compensation failed: {re}"); - } - state.invalidate_channel_deleted(tenant); - } return Err(match e { buzz_db::DbError::AuthEventRejected => { IngestError::Rejected("invalid: AUTH events cannot be stored".into()) diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index 24c368a564..dd4db5e986 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -12,6 +12,8 @@ pub mod command_executor; pub mod community_provisioning; /// NIP-45 COUNT handler. pub mod count; +#[cfg(test)] +mod create_with_members_postgres_tests; /// EVENT handler — WS dispatcher → ingest pipeline → fan-out. pub mod event; #[cfg(test)] diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index ca4a2304dd..38d035910b 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use nostr::{Event, EventBuilder, Kind, Tag}; -use tracing::{info, warn}; +use tracing::{error, info, warn}; use uuid::Uuid; use buzz_core::kind::{ @@ -540,8 +540,31 @@ pub async fn validate_admin_event( event: &Event, state: &Arc, ) -> anyhow::Result<()> { - // CREATE_GROUP doesn't need an existing channel — skip h-tag extraction + // CREATE_GROUP doesn't need an existing channel — skip h-tag extraction. + // Its initial members get the third-party add check a kind:9000 would. if kind == 9007 { + let members = initial_members(event)?; + // Ingest confirms a resend of a stored create without writing anything, + // so a policy changed since then must not refuse it. + if members.is_empty() + || state + .db + .get_event_by_id_for_event_write(tenant.community(), event.id.as_bytes()) + .await? + .is_some() + { + return Ok(()); + } + let actor_bytes = event.pubkey.to_bytes().to_vec(); + for member in members { + if let Some((policy, owner)) = state + .db + .get_agent_channel_policy(tenant.community(), &member) + .await? + { + channel_authz::decide_channel_add_policy(&policy, owner.as_deref(), &actor_bytes)?; + } + } return Ok(()); } @@ -2093,13 +2116,14 @@ async fn handle_create_group( // inaccessible, so the counter correctly records a new creation. For the // no-h-tag path, ingest never creates the channel, so this is the sole // increment. - let channel = if let Some(client_uuid) = extract_h_tag_channel(event) { + // `members_added`: ingest's create also added the event's `p` tags. + let (channel, members_added) = if let Some(client_uuid) = extract_h_tag_channel(event) { match state .db .get_channel_for_event_write(tenant.community(), client_uuid) .await { - Ok(ch) => ch, + Ok(ch) => (ch, true), Err(_) => { // Channel not found — shouldn't happen (ingest_event pre-created it), // but fall back to creation to stay resilient. @@ -2122,7 +2146,7 @@ async fn handle_create_group( "type" => channel_type.to_string() ) .increment(1); - ch + (ch, false) } } } else { @@ -2145,13 +2169,13 @@ async fn handle_create_group( "type" => channel_type.to_string() ) .increment(1); - ch + (ch, false) }; // Creator becomes owner — evict any stale negative membership lookup. state.invalidate_membership(tenant, channel.id, &actor_bytes); - // Open channels appear in everyone's accessible set; private channels only - // affect the creator (the sole initial member). + // Open channels appear in everyone's accessible set; a private channel only + // affects its initial members, whose entries the invalidations here drop. if visibility == buzz_db::channel::ChannelVisibility::Open { state.invalidate_all_accessible_channels(tenant); } @@ -2185,6 +2209,45 @@ async fn handle_create_group( warn!(channel = %channel.id, error = %e, "membership notification emission failed"); } + // Ingest added these in the create's transaction; announce them as a + // kind:9000 would. A failed announcement for one member must not cost the + // rest theirs: a resend writes nothing, so nothing would retry them. + let members = if members_added { + initial_members(event)? + } else { + Vec::new() + }; + for member in members { + state.invalidate_membership(tenant, channel.id, &member); + if let Err(e) = emit_system_message( + tenant, + state, + channel.id, + serde_json::json!({ + "type": "member_joined", + "actor": actor_hex, + "target": hex::encode(&member), + }), + chrono::Utc::now(), + ) + .await + { + error!(channel = %channel.id, error = %e, "member_joined system message failed"); + } + if let Err(e) = emit_membership_notification( + tenant, + state, + channel.id, + &member, + &actor_bytes, + KIND_MEMBER_ADDED_NOTIFICATION, + ) + .await + { + warn!(channel = %channel.id, error = %e, "membership notification emission failed"); + } + } + info!(channel_id = %channel.id, name = %name, "NIP-29 CREATE_GROUP processed"); Ok(()) } @@ -2653,6 +2716,38 @@ async fn handle_standard_deletion_event( Ok(()) } +/// Most members a kind:9007 may add besides its creator. +pub(crate) const MAX_INITIAL_MEMBERS: usize = 32; + +/// The members a kind:9007 adds besides its creator: its `p` tags, deduplicated. +/// They need the client-chosen channel UUID (`h`), so a retry of the same +/// create names the same channel. +pub(crate) fn initial_members(event: &Event) -> anyhow::Result>> { + let creator = event.pubkey.to_bytes(); + let mut members: Vec> = Vec::new(); + for tag in event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == "p") + { + let member = tag + .content() + .and_then(|value| hex::decode(value).ok()) + .filter(|bytes| bytes.len() == 32) + .ok_or_else(|| anyhow::anyhow!("p tag must be a 32-byte hex pubkey"))?; + if member != creator && !members.contains(&member) { + members.push(member); + } + } + if members.len() > MAX_INITIAL_MEMBERS { + anyhow::bail!("a channel may start with at most {MAX_INITIAL_MEMBERS} members"); + } + if !members.is_empty() && extract_h_tag_channel(event).is_none() { + anyhow::bail!("initial members need an h tag"); + } + Ok(members) +} + /// Extract channel UUID from `h` tag (NIP-29 group ID). fn extract_h_tag_channel(event: &Event) -> Option { for tag in event.tags.iter() { @@ -3996,6 +4091,57 @@ pub async fn publish_nipia_unarchived( mod tests { use super::*; + fn create_with(creator: &nostr::Keys, tags: Vec>) -> Event { + nostr::EventBuilder::new(nostr::Kind::Custom(9007), "") + .tags( + tags.into_iter() + .map(|tag| nostr::Tag::parse(tag).expect("tag")), + ) + .sign_with_keys(creator) + .expect("sign") + } + + fn tag(name: &str, value: &str) -> Vec { + vec![name.to_string(), value.to_string()] + } + + #[test] + fn initial_members_dedupes_and_leaves_out_the_creator() { + let (creator, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let h = tag("h", &Uuid::new_v4().to_string()); + let event = create_with( + &creator, + vec![ + h, + tag("p", &agent.public_key().to_hex()), + tag("p", &agent.public_key().to_hex()), + tag("p", &creator.public_key().to_hex()), + ], + ); + assert_eq!( + initial_members(&event).expect("members"), + vec![agent.public_key().to_bytes().to_vec()] + ); + } + + #[test] + fn initial_members_rejects_bad_pubkeys_too_many_members_and_no_h_tag() { + let creator = nostr::Keys::generate(); + let h = tag("h", &Uuid::new_v4().to_string()); + let bad = create_with(&creator, vec![h.clone(), tag("p", "not-hex")]); + assert!(initial_members(&bad).is_err()); + + let mut crowd = vec![h]; + for _ in 0..=MAX_INITIAL_MEMBERS { + crowd.push(tag("p", &nostr::Keys::generate().public_key().to_hex())); + } + assert!(initial_members(&create_with(&creator, crowd)).is_err()); + + let member = nostr::Keys::generate().public_key().to_hex(); + let headless = create_with(&creator, vec![tag("p", &member)]); + assert!(initial_members(&headless).is_err()); + } + #[test] fn nip43_progress_logs_every_interval_inside_the_startup_sweep_only() { use Nip43ReconciliationPurpose::{Bootstrap, Maintenance}; From 27b9c827fc082ae16b36217f40a82c6453081eef Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 18 Oct 2026 15:17:25 -0400 Subject: [PATCH 07/28] Polish mobile message layout and navigation controls (#8427) Align outgoing messages and galleries to the right, reclaim the outgoing avatar gutter, and match desktop calendar dividers with balanced spacing. Standardize navigation and sheet icons at 24 points, restore native Edit/Done styling, and align section menus with trailing channel indicators. Validation: clean analysis; 3,513 mobile tests and push-configuration checks passed; iPhone Release build installed and tested by the owner. Includes regression coverage for message placement, date spacing, gallery direction, and section alignment. --------- Signed-off-by: kenny lopez Co-authored-by: fin <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- mobile/ios/Runner/IosNavigationBar.swift | 11 +- .../ios/Runner/JumpToLatestGlassButton.swift | 2 +- mobile/ios/Runner/NativeReactionDetails.swift | 2 +- mobile/ios/Runner/NativeTabler.swift | 4 - .../channel_detail_page/message_bubble.dart | 120 +++-- .../channel_detail_page/message_list.dart | 14 + .../channel_detail_page/system_rows.dart | 105 +++-- .../channels/channels_page/badges.dart | 15 +- .../channels/channels_page/channel_tile.dart | 13 +- .../channels/channels_page/sections.dart | 49 +- .../channels/compose_bar/camera_preview.dart | 2 +- .../compose_bar/photo_gallery_picker.dart | 2 +- mobile/lib/features/channels/day_divider.dart | 97 +++- .../media_viewer_page/image_controls.dart | 2 +- .../features/channels/message_content.dart | 22 +- .../message_content/media_carousel.dart | 441 ++++++++++-------- .../channels/message_gallery_frame.dart | 11 +- .../features/channels/reply_summary_link.dart | 16 +- .../thread_detail_page/message_list.dart | 8 + .../nested_thread_summary_row.dart | 58 ++- .../thread_detail_page/thread_message.dart | 89 ++-- .../profile/profile_text_edit_sheet.dart | 2 +- .../theme_preview_sheet.dart | 2 +- .../lib/shared/widgets/buzz_sheet_header.dart | 2 +- .../widgets/ios_glass_navigation_button.dart | 2 +- .../shared/widgets/message_author_meta.dart | 68 +-- .../channels/channel_detail_page_test.dart | 67 ++- .../bubble_tests.dart | 216 +++++++++ .../features/channels/channels_page_test.dart | 117 +++++ .../features/channels/day_divider_test.dart | 69 +++ .../message_carousel_preview_cases.dart | 174 +++++++ .../channels/message_content_test.dart | 2 + 32 files changed, 1381 insertions(+), 423 deletions(-) create mode 100644 mobile/test/features/channels/message_carousel_preview_cases.dart diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index 1b9b1f4675..a425a1cae3 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -606,7 +606,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { private func makeAction(_ data: [String: Any]) -> UIAction { let id = data["id"] as? String ?? "" let enabled = data["enabled"] as? Bool == true - let symbol = (data["symbol"] as? String).flatMap { NativeTabler.image($0) } + let symbol = (data["symbol"] as? String).flatMap { NativeTabler.image($0, size: 24) } return UIAction(title: data["label"] as? String ?? "", image: symbol, attributes: enabled ? [] : [.disabled], state: data["selected"] as? Bool == true ? .on : .off) { [weak self] _ in @@ -615,8 +615,13 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { } private func makeControlGroup(_ actions: [[String: Any]]) -> UIBarButtonItem { - if actions.count == 1, actions[0]["plain"] as? Bool == true { - return makeItem(actions[0]) + if actions.count == 1 { + let item = makeItem(actions[0]) + // Let UIKit retain its navigation typography and sizing for text actions + // such as Edit/Done, rather than inheriting UIButton's default styling. + if actions[0]["plain"] as? Bool == true || (item.image == nil && item.customView == nil) { + return item + } } let surface = UIVisualEffectView() if #available(iOS 26.0, *) { diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index 44e7fc8b85..170763c184 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -452,7 +452,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { ) let pointSize: CGFloat = icon == "shutter" ? controlSize * Self.shutterIconRatio - : 17 + : 24 buttonImage = NativeTabler.image(buttonIconName, size: pointSize) } updateDisplayedContent() diff --git a/mobile/ios/Runner/NativeReactionDetails.swift b/mobile/ios/Runner/NativeReactionDetails.swift index 2580c77093..637da5fffe 100644 --- a/mobile/ios/Runner/NativeReactionDetails.swift +++ b/mobile/ios/Runner/NativeReactionDetails.swift @@ -72,7 +72,7 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa configuration.baseBackgroundColor = .secondarySystemBackground } configuration.cornerStyle = .capsule - configuration.image = NativeTabler.image("xmark", size: 17) + configuration.image = NativeTabler.image("xmark", size: 24) configuration.baseForegroundColor = foregroundColor close.configuration = configuration close.tintColor = foregroundColor diff --git a/mobile/ios/Runner/NativeTabler.swift b/mobile/ios/Runner/NativeTabler.swift index cdffe39a80..e4b9c51160 100644 --- a/mobile/ios/Runner/NativeTabler.swift +++ b/mobile/ios/Runner/NativeTabler.swift @@ -16,10 +16,6 @@ enum NativeTabler { }() static func image(_ symbol: String, size: CGFloat = 20) -> UIImage? { - // Back and Close deliberately retain native navigation artwork. - if ["chevron.backward", "chevron.left", "xmark", "xmark.circle.fill"].contains(symbol) { - return UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: size, weight: .semibold)) - } guard let code = glyphs[symbol], let scalar = UnicodeScalar(code), let name = fontName, let font = UIFont(name: name, size: size) else { assertionFailure("Missing Tabler icon: \(symbol)") diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index d846f455e5..6e4c812de4 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -5,6 +5,8 @@ class _MessageBubble extends HookConsumerWidget { final bool showAuthor; final bool typingHandoff; final bool showAvatar; + final bool startsDay; + final bool endsDay; final bool hasReplies; final Widget? replyFooter; final Map channelNames; @@ -21,6 +23,8 @@ class _MessageBubble extends HookConsumerWidget { required this.showAuthor, this.typingHandoff = false, this.showAvatar = true, + this.startsDay = false, + this.endsDay = false, required this.hasReplies, this.replyFooter, required this.channelNames, @@ -52,6 +56,7 @@ class _MessageBubble extends HookConsumerWidget { ); // Watch only this user's profile to avoid rebuilding on unrelated cache changes. final pk = message.pubkey.toLowerCase(); + final ownMessage = currentPubkey?.toLowerCase() == pk; final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); @@ -133,7 +138,7 @@ class _MessageBubble extends HookConsumerWidget { return true; }, child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + padding: EdgeInsets.only(top: showAuthor && !startsDay ? Grid.xs : 0), child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), @@ -165,46 +170,60 @@ class _MessageBubble extends HookConsumerWidget { ), child: Padding( padding: EdgeInsets.only( - bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, + bottom: endsDay + ? 0 + : (showAvatar ? Grid.quarter : Grid.quarter / 2), ), child: MessageFooterLayout( animateSize: true, overlap: message.reactions.isNotEmpty ? Grid.xs : 0, footer: message.reactions.isNotEmpty || replyFooter != null - ? Padding( - padding: const EdgeInsets.only( - left: - messageAvatarSize + - messageAvatarContentGap + - Grid.xxs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (message.reactions.isNotEmpty) - ReactionRow( - combined: true, - messageId: message.id, - channelId: currentChannelId, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, + ? Align( + alignment: ownMessage + ? Alignment.centerRight + : Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only( + right: ownMessage ? Grid.xxs : 0, + left: ownMessage + ? 0 + : messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (message.reactions.isNotEmpty) + ReactionRow( + combined: true, + messageId: message.id, + channelId: currentChannelId, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), ), - ), - if (replyFooter != null) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.xxs, + if (replyFooter != null) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.xxs, + ), + child: Directionality( + textDirection: Directionality.of(context), + child: replyFooter!, + ), ), - child: replyFooter, - ), - ], + ], + ), ), ) : const SizedBox.shrink(), @@ -214,29 +233,41 @@ class _MessageBubble extends HookConsumerWidget { clipBehavior: Clip.none, children: [ Row( + textDirection: ownMessage + ? TextDirection.rtl + : TextDirection.ltr, crossAxisAlignment: CrossAxisAlignment.end, children: [ - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), + if (!ownMessage) + const SizedBox(width: messageAvatarSize), + if (!ownMessage) + const SizedBox(width: messageAvatarContentGap), Expanded( child: Padding( padding: EdgeInsets.only( - top: showAuthor ? Grid.half : 0, + top: showAuthor && !startsDay ? Grid.half : 0, ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ if (showAuthor) Padding( - padding: const EdgeInsets.only( + padding: EdgeInsets.only( bottom: Grid.quarter, - left: Grid.xs, + left: ownMessage ? 0 : Grid.xs, + right: ownMessage ? Grid.xs : 0, ), child: Row( children: [ Expanded( child: MessageAuthorMeta( showTimestamp: true, + reversed: false, + alignment: ownMessage + ? MainAxisAlignment.end + : MainAxisAlignment.start, nameStyle: messageUsernameTextStyle .copyWith( @@ -318,8 +349,8 @@ class _MessageBubble extends HookConsumerWidget { replyFooter != null, bubbleEnd: showAvatar, bubbleStart: showAuthor, - ownMessage: - currentPubkey?.toLowerCase() == pk, + ownMessage: ownMessage, + trimTrailingSpacing: endsDay, mentionNames: resolvedMentionNames, mentionLabels: mentionLabels, agentMentionPubkeys: agentMentionPubkeys, @@ -392,9 +423,10 @@ class _MessageBubble extends HookConsumerWidget { ), ], ), - if (showAvatar) + if (showAvatar && !ownMessage) Positioned( - left: 0, + left: ownMessage ? null : 0, + right: ownMessage ? 0 : null, bottom: 0, child: MessageMediaAvatar( offset: mediaOffset, diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index adc60fd5d1..f3cfb1d10e 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -963,6 +963,13 @@ class _MessageList extends HookConsumerWidget { if (message.isSystem) _SystemMessageRow( message: message, + startsDay: showDayDivider, + endsDay: + nextMessage != null && + !isSameDay( + message.createdAt, + nextMessage.createdAt, + ), groupedMessages: entryGroup.length > 1 ? entryGroup .map((entry) => entry.message) @@ -977,6 +984,13 @@ class _MessageList extends HookConsumerWidget { else ...[ _MessageBubble( message: message, + startsDay: showDayDivider, + endsDay: + nextMessage != null && + !isSameDay( + message.createdAt, + nextMessage.createdAt, + ), showAuthor: showAuthor || isRecentAgentReplyHandoff( diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index b4dff4aa91..76d2321ac1 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -8,6 +8,8 @@ class _SystemMessageRow extends HookConsumerWidget { final List? allMessages; final bool isMember; final bool isArchived; + final bool startsDay; + final bool endsDay; const _SystemMessageRow({ required this.message, @@ -17,6 +19,8 @@ class _SystemMessageRow extends HookConsumerWidget { this.allMessages, this.isMember = false, this.isArchived = false, + this.startsDay = false, + this.endsDay = false, }); @override @@ -136,37 +140,43 @@ class _SystemMessageRow extends HookConsumerWidget { systemEvent.type == SystemEventType.huddleStarted && systemEvent.ephemeralChannelId != null; final actor = (systemEvent.actorPubkey ?? message.pubkey).toLowerCase(); + final ownActor = currentPubkey?.toLowerCase() == actor; + final ownHuddle = + ownActor && + (isHuddle || systemEvent.type == SystemEventType.huddleEnded); Widget withActor(Widget content) { if (!isHuddle) return content; return Row( key: ValueKey('huddle-activity-${message.id}'), + textDirection: ownHuddle ? TextDirection.rtl : TextDirection.ltr, crossAxisAlignment: CrossAxisAlignment.end, children: [ - Semantics( - container: true, - label: 'Open ${resolveLabel(actor)} profile', - button: true, - excludeSemantics: true, - onTap: () => showUserProfileSheet( - context, - actor, - names: channelIdentityNamesProvider(channelId), - ), - child: GestureDetector( - excludeFromSemantics: true, + if (!ownHuddle) + Semantics( + container: true, + label: 'Open ${resolveLabel(actor)} profile', + button: true, + excludeSemantics: true, onTap: () => showUserProfileSheet( context, actor, names: channelIdentityNamesProvider(channelId), ), - child: _UserAvatar( - profile: userCache[actor], - pubkey: actor, - isAgent: userCache[actor]?.ownerPubkey != null, + child: GestureDetector( + excludeFromSemantics: true, + onTap: () => showUserProfileSheet( + context, + actor, + names: channelIdentityNamesProvider(channelId), + ), + child: _UserAvatar( + profile: userCache[actor], + pubkey: actor, + isAgent: userCache[actor]?.ownerPubkey != null, + ), ), ), - ), - const SizedBox(width: messageAvatarContentGap), + if (!ownHuddle) const SizedBox(width: messageAvatarContentGap), Expanded(child: content), ], ); @@ -183,9 +193,14 @@ class _SystemMessageRow extends HookConsumerWidget { borderRadius: BorderRadius.circular(Radii.md), highlightColor: context.colors.primary.withValues(alpha: 0.1), child: Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + padding: EdgeInsets.only( + top: startsDay ? 0 : Grid.xxs, + bottom: endsDay ? 0 : Grid.xxs, + ), child: Column( - crossAxisAlignment: isHuddle + crossAxisAlignment: ownHuddle + ? CrossAxisAlignment.end + : isHuddle ? CrossAxisAlignment.start : CrossAxisAlignment.center, children: [ @@ -193,7 +208,9 @@ class _SystemMessageRow extends HookConsumerWidget { RepaintBoundary( key: spotlightKey, child: Align( - alignment: isHuddle + alignment: ownHuddle + ? Alignment.centerRight + : isHuddle ? Alignment.centerLeft : Alignment.center, child: Padding( @@ -244,25 +261,32 @@ class _SystemMessageRow extends HookConsumerWidget { overlap: reactions.isNotEmpty ? Grid.xs : 0, footer: reactions.isEmpty ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.only( - left: - messageAvatarSize + - messageAvatarContentGap + - Grid.xxs, - ), - child: ReactionRow( - combined: true, - messageId: message.id, - channelId: channelId, - reactions: reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, + : Align( + alignment: ownHuddle + ? Alignment.centerRight + : Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only( + left: ownHuddle + ? 0 + : messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + right: ownHuddle ? Grid.xxs : 0, + ), + child: ReactionRow( + combined: true, + messageId: message.id, + channelId: channelId, + reactions: reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), ), ), ), @@ -609,6 +633,7 @@ class _ThreadSummaryRow extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return ReplySummaryLink( key: ValueKey('thread-summary-${message.id}'), + mirrored: currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(), summary: summary, hasReactions: message.reactions.isNotEmpty, onTap: () { diff --git a/mobile/lib/features/channels/channels_page/badges.dart b/mobile/lib/features/channels/channels_page/badges.dart index 15c081045e..c2641a433d 100644 --- a/mobile/lib/features/channels/channels_page/badges.dart +++ b/mobile/lib/features/channels/channels_page/badges.dart @@ -12,11 +12,16 @@ class _EphemeralBadge extends StatelessWidget { return Tooltip( message: display.tooltipLabel, - child: Icon( - BuzzIcons.clockFading, - key: Key('channel-ephemeral-${channel.id}'), - size: 16, - color: context.colors.onSurfaceVariant, + child: SizedBox( + width: _kChannelIconSize, + child: Center( + child: Icon( + BuzzIcons.clockFading, + key: Key('channel-ephemeral-${channel.id}'), + size: 16, + color: context.colors.onSurfaceVariant, + ), + ), ), ); } diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 7903b07fb2..553df6abaf 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -86,10 +86,15 @@ class _ChannelTile extends ConsumerWidget { ], if (isMuted) ...[ const SizedBox(width: Grid.xxs), - Icon( - BuzzIcons.bellOff, - size: 12, - color: context.colors.onSurface.withValues(alpha: 0.4), + SizedBox( + width: _kChannelIconSize, + child: Center( + child: Icon( + BuzzIcons.bellOff, + size: 12, + color: context.colors.onSurface.withValues(alpha: 0.4), + ), + ), ), ], if (!channel.isMember && !channel.isDm) diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 2a1bd87e3d..0987bb27a2 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -138,10 +138,9 @@ class _CustomSectionHeader extends StatelessWidget { ), ), Builder( - builder: (buttonContext) => IconButton( + builder: (buttonContext) => _SectionOptionsButton( key: ValueKey('section-menu-${section.id}'), tooltip: '${section.name} options', - visualDensity: VisualDensity.compact, icon: Icon( BuzzIcons.ellipsisVertical, size: _kChannelIconSize, @@ -209,7 +208,6 @@ class _CustomSectionHeader extends StatelessWidget { }, ), ), - const SizedBox(width: Grid.quarter), ], ), ), @@ -495,10 +493,9 @@ class _SectionHeader extends StatelessWidget { ), if (sortMode case final mode?) ...[ Builder( - builder: (buttonContext) => IconButton( + builder: (buttonContext) => _SectionOptionsButton( key: ValueKey('sort-menu-$label'), tooltip: '$label options', - visualDensity: VisualDensity.compact, icon: Icon( BuzzIcons.ellipsisVertical, size: _kChannelIconSize, @@ -509,15 +506,6 @@ class _SectionHeader extends StatelessWidget { context: buttonContext, width: 216, alignment: AnchoredPopoverAlignment.end, - color: context.colors.surface, - elevation: 4, - shadowColor: context.colors.shadow.withValues( - alpha: 0.18, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.md), - side: BorderSide(color: context.colors.outline), - ), surfaceKey: ValueKey('sort-popover-$label'), items: _sortMenuItems(mode, showDivider: false), ); @@ -529,7 +517,6 @@ class _SectionHeader extends StatelessWidget { }, ), ), - const SizedBox(width: Grid.quarter), ], ], ), @@ -587,3 +574,35 @@ class _SectionChevron extends StatelessWidget { ); } } + +class _SectionOptionsButton extends StatelessWidget { + const _SectionOptionsButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final Widget icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: tooltip, + onTap: onPressed, + excludeSemantics: true, + child: Tooltip( + message: tooltip, + child: SizedBox.square( + dimension: 40, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(Radii.full), + child: Align(alignment: Alignment.centerRight, child: icon), + ), + ), + ), + ); +} diff --git a/mobile/lib/features/channels/compose_bar/camera_preview.dart b/mobile/lib/features/channels/compose_bar/camera_preview.dart index 12e80e53ee..85ed7ed943 100644 --- a/mobile/lib/features/channels/compose_bar/camera_preview.dart +++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart @@ -309,7 +309,7 @@ class _CameraCloseButton extends StatelessWidget { ), foregroundColor: Colors.white, ), - icon: Icon(BuzzIcons.arrowLeft, size: emphasized ? 24 : 18), + icon: Icon(BuzzIcons.arrowLeft, size: 24), ), ); } diff --git a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart index 60ddbf588f..65139a9d1a 100644 --- a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart +++ b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart @@ -179,7 +179,7 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { : () => _runComposerAction(onBack), tooltip: 'Back to attachment options', visualDensity: VisualDensity.compact, - icon: const Icon(BuzzIcons.arrowLeft, size: 20), + icon: const Icon(BuzzIcons.arrowLeft, size: 24), ), const SizedBox(width: Grid.quarter), Expanded( diff --git a/mobile/lib/features/channels/day_divider.dart b/mobile/lib/features/channels/day_divider.dart index 24f5bb798e..ab51482c66 100644 --- a/mobile/lib/features/channels/day_divider.dart +++ b/mobile/lib/features/channels/day_divider.dart @@ -28,11 +28,49 @@ class DayDivider extends StatelessWidget { : const Duration(milliseconds: 120), curve: Curves.easeOutCubic, opacity: isSticky ? 0 : 1, - child: Text( - label, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant.withValues(alpha: 0.72), - fontWeight: FontWeight.w500, + child: LayoutBuilder( + builder: (context, constraints) => Row( + children: [ + Expanded( + child: Divider( + color: context.colors.outlineVariant, + thickness: 0.5, + ), + ), + const SizedBox(width: Grid.xs), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: (constraints.maxWidth - Grid.xs * 2) * 0.6, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (dayTimestamp != null) ...[ + _CalendarDateTile(timestamp: dayTimestamp!), + const SizedBox(width: Grid.xxs), + ], + Flexible( + child: Text( + label, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant.withValues( + alpha: 0.72, + ), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + const SizedBox(width: Grid.xs), + Expanded( + child: Divider( + color: context.colors.outlineVariant, + thickness: 0.5, + ), + ), + ], ), ), ), @@ -44,7 +82,7 @@ class DayDivider extends StatelessWidget { final activeTimestamp = stickyDayTimestamp; final timestamp = dayTimestamp; return Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs + Grid.quarter), + padding: const EdgeInsets.symmetric(vertical: Grid.gutter), child: Center( child: activeTimestamp == null || timestamp == null ? _buildOpacity(context, isSticky: false) @@ -59,3 +97,50 @@ class DayDivider extends StatelessWidget { ); } } + +/// Decorative desktop calendar artwork; the adjacent label owns semantics. +class _CalendarDateTile extends StatelessWidget { + const _CalendarDateTile({required this.timestamp}); + + final int timestamp; + + @override + Widget build(BuildContext context) { + final day = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000).day; + return ExcludeSemantics( + child: Container( + key: ValueKey('day-calendar-$timestamp'), + width: Grid.gutter, + height: Grid.gutter, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all(color: context.colors.outlineVariant, width: 0.5), + ), + child: Column( + children: [ + const SizedBox( + height: Grid.gutter * 7 / 24, + width: double.infinity, + child: ColoredBox(color: Color(0xFFFF0000)), + ), + Expanded( + child: Center( + child: Text( + '$day', + textScaler: TextScaler.noScaling, + style: const TextStyle( + color: Color(0xFF111111), + fontSize: Grid.gutter / 2, + height: 1, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/media_viewer_page/image_controls.dart b/mobile/lib/features/channels/media_viewer_page/image_controls.dart index 571f6bbcd5..c4e8102efa 100644 --- a/mobile/lib/features/channels/media_viewer_page/image_controls.dart +++ b/mobile/lib/features/channels/media_viewer_page/image_controls.dart @@ -102,7 +102,7 @@ class _MediaViewerCircleButton extends StatelessWidget { child: IconButton( onPressed: onPressed, tooltip: tooltip, - icon: Icon(icon, color: Colors.white, size: 20), + icon: Icon(icon, color: Colors.white, size: 24), ), ), ); diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 6e7e0d019e..daf0b42566 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -101,6 +101,9 @@ class MessageContent extends HookConsumerWidget { /// Distinguishes messages authored by the signed-in identity. final bool ownMessage; + /// Lets an adjacent day divider own spacing after the final text segment. + final bool trimTrailingSpacing; + /// Marks the final message in a sender run for rounded corners and dots. final bool bubbleEnd; @@ -171,6 +174,7 @@ class MessageContent extends HookConsumerWidget { required this.content, this.bubble = false, this.ownMessage = false, + this.trimTrailingSpacing = false, this.bubbleEnd = true, this.bubbleStart = true, this.hasReactions = false, @@ -395,7 +399,9 @@ class MessageContent extends HookConsumerWidget { : const <({String text, bool attachment})>[]; final markdown = bubble ? Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ for (final (index, segment) in segments.indexed) @@ -413,7 +419,14 @@ class MessageContent extends HookConsumerWidget { else Padding( key: ValueKey('message-text-bubble-$index'), - padding: const EdgeInsets.only(bottom: Grid.quarter), + padding: EdgeInsets.only( + bottom: + trimTrailingSpacing && + trailingGallery == null && + index == segments.length - 1 + ? 0 + : Grid.quarter, + ), child: LocalSendBubbleSurface( color: ownMessage ? chatColors.outgoing @@ -479,7 +492,9 @@ class MessageContent extends HookConsumerWidget { if (trailingGallery == null) return markdown; return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (trailingGallery.content.trim().isNotEmpty) markdown, @@ -493,6 +508,7 @@ class MessageContent extends HookConsumerWidget { trailingGallery.items.map((item) => item.url).join('\u0000'), ), items: trailingGallery.items, + mirrored: ownMessage, leadingOverflow: mediaCarouselLeadingOverflow, trailingOverflow: mediaCarouselTrailingOverflow, onReply: onMediaReply, diff --git a/mobile/lib/features/channels/message_content/media_carousel.dart b/mobile/lib/features/channels/message_content/media_carousel.dart index 3225689484..2006e2c520 100644 --- a/mobile/lib/features/channels/message_content/media_carousel.dart +++ b/mobile/lib/features/channels/message_content/media_carousel.dart @@ -11,21 +11,24 @@ class _MessageGalleryPrecache extends HookWidget { @override Widget build(BuildContext context) { + final prefetch = useMemoized(_GalleryPrecache.new, const []); + useEffect(() => prefetch.dispose, [prefetch]); final providerSignature = Object.hashAll(providers); useEffect(() { var cancelled = false; WidgetsBinding.instance.addPostFrameCallback((_) { - if (cancelled || !context.mounted) { - return; - } - for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) { - if (index < 0 || index >= providers.length) { - continue; - } - unawaited( - precacheImage(providers[index], context, onError: (_, _) {}), - ); - } + if (cancelled || !context.mounted) return; + // Prepare a five-photo strip immediately, then keep four ahead and two + // behind the current page. Two workers bound speculative network work. + final indices = [ + for (var i = focusedIndex; i <= focusedIndex + 4; i++) i, + focusedIndex - 1, + focusedIndex - 2, + ]; + prefetch.update(context, [ + for (final i in indices) + if (i >= 0 && i < providers.length) providers[i], + ]); }); return () => cancelled = true; }, [focusedIndex, providerSignature]); @@ -33,8 +36,50 @@ class _MessageGalleryPrecache extends HookWidget { } } +class _GalleryPrecache { + BuildContext? _context; + List> _window = []; + final _ready = >{}; + final _loading = >{}; + + void update(BuildContext context, List> window) { + _context = context; + _window = window; + _ready.removeWhere((provider) => !window.contains(provider)); + _drain(); + } + + void _drain() { + final context = _context; + if (context == null || !context.mounted) return; + for (final provider in _window) { + if (_loading.length >= 2) break; + if (_ready.contains(provider) || _loading.contains(provider)) continue; + _loading.add(provider); + unawaited(_load(context, provider)); + } + } + + Future _load( + BuildContext context, + ImageProvider provider, + ) async { + await precacheImage(provider, context, onError: (_, _) {}); + _loading.remove(provider); + if (_window.contains(provider)) _ready.add(provider); + _drain(); + } + + void dispose() { + _context = null; + _window = []; + _ready.clear(); + } +} + class _MessageImageCarousel extends HookConsumerWidget { final List items; + final bool mirrored; final double leadingOverflow; final double trailingOverflow; final VoidCallback? onReply; @@ -43,6 +88,7 @@ class _MessageImageCarousel extends HookConsumerWidget { const _MessageImageCarousel({ super.key, required this.items, + required this.mirrored, required this.leadingOverflow, required this.trailingOverflow, required this.onReply, @@ -51,6 +97,7 @@ class _MessageImageCarousel extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final viewerContext = context; final itemSignature = items.map((item) => item.url).join('\u0000'); final heroTags = useMemoized( () => [for (var index = 0; index < items.length; index++) Object()], @@ -62,211 +109,223 @@ class _MessageImageCarousel extends HookConsumerWidget { return MessageGalleryFrame( count: items.length, - child: LayoutBuilder( - builder: (context, constraints) { - final contentWidth = constraints.hasBoundedWidth - ? constraints.maxWidth - : messageMediaMaxWidth(context); - final carouselWidth = - contentWidth + leadingOverflow + trailingOverflow; - final leadingExtent = leadingOverflow; - final isLeftToRight = Directionality.of(context) == TextDirection.ltr; - final itemTrailingPaddings = [ - for (var index = 0; index < items.length; index++) - index == items.length - 1 ? Grid.gutter : Grid.half, - ]; - final previewDecodeWidths = [ - for (var index = 0; index < items.length; index++) - math.max(1.0, carouselWidth * 0.9 - itemTrailingPaddings[index]), - ]; - final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); - final previewProviders = [ - for (var index = 0; index < items.length; index++) - ResizeImage.resizeIfNeeded( - (previewDecodeWidths[index] * devicePixelRatio).ceil(), - null, - MediaImageProvider( - url: items[index].url, - auth: mediaAuth, - client: mediaClient, + mirrored: mirrored, + child: Directionality( + textDirection: mirrored ? TextDirection.rtl : TextDirection.ltr, + child: LayoutBuilder( + builder: (context, constraints) { + final contentWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : messageMediaMaxWidth(context); + final carouselWidth = + contentWidth + leadingOverflow + trailingOverflow; + // Directionality mirrors both the starting edge and trailing overflow. + final leadingExtent = leadingOverflow; + final isLeftToRight = + Directionality.of(context) == TextDirection.ltr; + final itemTrailingPaddings = [ + for (var index = 0; index < items.length; index++) + index == items.length - 1 ? Grid.gutter : Grid.half, + ]; + final previewDecodeWidths = [ + for (var index = 0; index < items.length; index++) + math.max( + 1.0, + carouselWidth * 0.9 - itemTrailingPaddings[index], ), - ), - ]; - final viewerItems = [ - for (var index = 0; index < items.length; index++) - MediaViewerImage( - url: items[index].url, - heroTag: heroTags[index], - semanticLabel: items[index].semanticLabel, - previewDecodeWidth: previewDecodeWidths[index], - aspectRatio: items[index].aspectRatio, - preloadProvider: previewProviders[index], - ), - ]; - final nativeGutter = math.max( - 0.0, - MediaQuery.sizeOf(context).width - carouselWidth, - ); - final carousel = SizedBox( - key: const ValueKey('message-media-carousel'), - width: carouselWidth, - height: messageMediaCarouselHeight, - child: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS - ? OverflowBox( - minWidth: carouselWidth + 2 * nativeGutter, - maxWidth: carouselWidth + 2 * nativeGutter, - child: IosMediaCarousel( - key: ValueKey((itemSignature, mediaAuth)), - providers: previewProviders, - labels: [for (final item in items) item.semanticLabel], + ]; + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final previewProviders = [ + for (var index = 0; index < items.length; index++) + ResizeImage.resizeIfNeeded( + (previewDecodeWidths[index] * devicePixelRatio).ceil(), + null, + MediaImageProvider( + url: items[index].url, + auth: mediaAuth, + client: mediaClient, + ), + ), + ]; + final viewerItems = [ + for (var index = 0; index < items.length; index++) + MediaViewerImage( + url: items[index].url, + heroTag: heroTags[index], + semanticLabel: items[index].semanticLabel, + previewDecodeWidth: previewDecodeWidths[index], + aspectRatio: items[index].aspectRatio, + preloadProvider: previewProviders[index], + ), + ]; + final nativeGutter = math.max( + 0.0, + MediaQuery.sizeOf(context).width - carouselWidth, + ); + final carousel = SizedBox( + key: const ValueKey('message-media-carousel'), + width: carouselWidth, + height: messageMediaCarouselHeight, + child: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS + ? OverflowBox( + minWidth: carouselWidth + 2 * nativeGutter, + maxWidth: carouselWidth + 2 * nativeGutter, + child: IosMediaCarousel( + key: ValueKey((itemSignature, mediaAuth)), + providers: previewProviders, + labels: [for (final item in items) item.semanticLabel], + width: carouselWidth, + gutter: nativeGutter, + gap: Grid.half, + trailingPadding: Grid.gutter, + radius: + (MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md)) + .topLeft + .x, + onScroll: (offset) => MessageGalleryScrollNotification( + offset, + ).dispatch(context), + onPageChanged: (index) { + if (index != currentIndex.value) { + unawaited(HapticFeedback.selectionClick()); + } + currentIndex.value = index; + }, + onOpen: (index) => openImageViewer( + viewerContext, + imageUrl: items[index].url, + heroTag: heroTags[index], + semanticLabel: items[index].semanticLabel, + previewDecodeWidth: previewDecodeWidths[index], + aspectRatio: items[index].aspectRatio, + galleryItems: viewerItems, + initialIndex: index, + onReply: onReply, + onMore: onMore, + ), + ), + ) + : _MessageCarouselViewport( width: carouselWidth, - gutter: nativeGutter, - gap: Grid.half, - trailingPadding: Grid.gutter, - radius: - (MessageAttachmentCorners.of(context) ?? - BorderRadius.circular(Radii.md)) - .topLeft - .x, - onScroll: (offset) => MessageGalleryScrollNotification( - offset, - ).dispatch(context), + currentIndex: currentIndex.value, + itemCount: items.length, onPageChanged: (index) { if (index != currentIndex.value) { unawaited(HapticFeedback.selectionClick()); } currentIndex.value = index; }, - onOpen: (index) => openImageViewer( - context, - imageUrl: items[index].url, - heroTag: heroTags[index], - semanticLabel: items[index].semanticLabel, - previewDecodeWidth: previewDecodeWidths[index], - aspectRatio: items[index].aspectRatio, - galleryItems: viewerItems, - initialIndex: index, - onReply: onReply, - onMore: onMore, - ), - ), - ) - : _MessageCarouselViewport( - width: carouselWidth, - currentIndex: currentIndex.value, - itemCount: items.length, - onPageChanged: (index) { - if (index != currentIndex.value) { - unawaited(HapticFeedback.selectionClick()); - } - currentIndex.value = index; - }, - itemBuilder: (context, index) { - final item = items[index]; - return Padding( - key: ValueKey( - 'message-media-carousel-page:${item.url}', - ), - padding: EdgeInsetsDirectional.only( - end: itemTrailingPaddings[index], - ), - child: Semantics( - button: true, - excludeSemantics: true, - label: 'Open ${item.semanticLabel}', - child: GestureDetector( - key: ValueKey( - 'message-media-carousel-item:${item.url}', - ), - onTap: () => openImageViewer( - context, - imageUrl: item.url, - heroTag: heroTags[index], - semanticLabel: item.semanticLabel, - previewDecodeWidth: previewDecodeWidths[index], - aspectRatio: item.aspectRatio, - galleryItems: viewerItems, - initialIndex: index, - onReply: onReply, - onMore: onMore, - ), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: - MessageAttachmentCorners.of(context) ?? - BorderRadius.circular(Radii.md), - border: Border.all( - color: context.colors.outlineVariant, - ), + itemBuilder: (context, index) { + final item = items[index]; + return Padding( + key: ValueKey( + 'message-media-carousel-page:${item.url}', + ), + padding: EdgeInsetsDirectional.only( + end: itemTrailingPaddings[index], + ), + child: Semantics( + button: true, + excludeSemantics: true, + label: 'Open ${item.semanticLabel}', + child: GestureDetector( + key: ValueKey( + 'message-media-carousel-item:${item.url}', ), - child: MediaViewerHero( - tag: heroTags[index], - child: ClipRRect( + onTap: () => openImageViewer( + viewerContext, + imageUrl: item.url, + heroTag: heroTags[index], + semanticLabel: item.semanticLabel, + previewDecodeWidth: previewDecodeWidths[index], + aspectRatio: item.aspectRatio, + galleryItems: viewerItems, + initialIndex: index, + onReply: onReply, + onMore: onMore, + ), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, borderRadius: MessageAttachmentCorners.of(context) ?? BorderRadius.circular(Radii.md), - child: MediaImage( - url: item.url, - decodeWidth: previewDecodeWidths[index], - fit: BoxFit.cover, - semanticLabel: item.semanticLabel, - frameBuilder: - (context, child, frame, synchronous) => - frame != null || synchronous - ? child - : const MediaLoadingPlaceholder( - label: 'Loading image', - ), - errorBuilder: (_, _, _) => - const _MediaPreviewFallback( - icon: BuzzIcons.imageOff, - label: 'Image unavailable', - ), + border: Border.all( + color: context.colors.outlineVariant, + ), + ), + child: MediaViewerHero( + tag: heroTags[index], + child: ClipRRect( + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), + child: MediaImage( + url: item.url, + decodeWidth: previewDecodeWidths[index], + fit: BoxFit.cover, + semanticLabel: item.semanticLabel, + frameBuilder: + ( + context, + child, + frame, + synchronous, + ) => frame != null || synchronous + ? child + : const MediaLoadingPlaceholder( + label: 'Loading image', + ), + errorBuilder: (_, _, _) => + const _MediaPreviewFallback( + icon: BuzzIcons.imageOff, + label: 'Image unavailable', + ), + ), ), ), ), ), ), - ), - ); - }, - ), - ); + ); + }, + ), + ); - final carouselSurface = Stack( - clipBehavior: Clip.none, - children: [ - carousel, - if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) + final carouselSurface = Stack( + clipBehavior: Clip.none, + children: [ + carousel, _MessageGalleryPrecache( providers: previewProviders, focusedIndex: currentIndex.value, ), - ], - ); + ], + ); - if (leadingExtent <= 0 && trailingOverflow <= 0) { - return carouselSurface; - } - return SizedBox( - width: contentWidth, - height: messageMediaCarouselHeight, - child: OverflowBox( - alignment: AlignmentDirectional.centerStart, - minWidth: carouselWidth, - maxWidth: carouselWidth, - child: Transform.translate( - offset: Offset( - isLeftToRight ? -leadingExtent : leadingExtent, - 0, + if (leadingExtent <= 0 && trailingOverflow <= 0) { + return carouselSurface; + } + return SizedBox( + width: contentWidth, + height: messageMediaCarouselHeight, + child: OverflowBox( + alignment: AlignmentDirectional.centerStart, + minWidth: carouselWidth, + maxWidth: carouselWidth, + child: Transform.translate( + offset: Offset( + isLeftToRight ? -leadingExtent : leadingExtent, + 0, + ), + child: carouselSurface, ), - child: carouselSurface, ), - ), - ); - }, + ); + }, + ), ), ); } diff --git a/mobile/lib/features/channels/message_gallery_frame.dart b/mobile/lib/features/channels/message_gallery_frame.dart index 6c3b3627a8..e66e4664a5 100644 --- a/mobile/lib/features/channels/message_gallery_frame.dart +++ b/mobile/lib/features/channels/message_gallery_frame.dart @@ -11,11 +11,13 @@ class MessageGalleryFrame extends StatelessWidget { final int count; final Widget child; final bool loading; + final bool mirrored; const MessageGalleryFrame({ required this.count, required this.child, this.loading = false, + this.mirrored = false, super.key, }); @@ -36,11 +38,16 @@ class MessageGalleryFrame extends StatelessWidget { return Padding( padding: const EdgeInsets.only(top: Grid.half), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: mirrored + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Padding( - padding: const EdgeInsetsDirectional.only(start: Grid.xs), + padding: EdgeInsets.only( + left: mirrored ? 0 : Grid.xs, + right: mirrored ? Grid.xs : 0, + ), child: loading ? ExcludeSemantics( child: Stack( diff --git a/mobile/lib/features/channels/reply_summary_link.dart b/mobile/lib/features/channels/reply_summary_link.dart index c3cb376827..55c3d34498 100644 --- a/mobile/lib/features/channels/reply_summary_link.dart +++ b/mobile/lib/features/channels/reply_summary_link.dart @@ -13,12 +13,16 @@ class ReplySummaryLink extends ConsumerWidget { required this.summary, required this.onTap, this.hasReactions = false, + this.mirrored = false, }); final ThreadSummary summary; final VoidCallback onTap; final bool hasReactions; + /// Places the connector on the outgoing edge without reversing its contents. + final bool mirrored; + @override Widget build(BuildContext context, WidgetRef ref) { final participants = summary.participantPubkeys.take(3).toList(); @@ -28,7 +32,10 @@ class ReplySummaryLink extends ConsumerWidget { : '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}'; final connectorTop = hasReactions ? -Grid.half : Grid.half; return Padding( - padding: const EdgeInsetsDirectional.only(start: Grid.xxs), + padding: EdgeInsets.only( + left: mirrored ? 0 : Grid.xxs, + right: mirrored ? Grid.xxs : 0, + ), child: Stack( clipBehavior: Clip.none, children: [ @@ -38,14 +45,17 @@ class ReplySummaryLink extends ConsumerWidget { child: CustomPaint( painter: _ReplyConnector( context.colors.outlineVariant, - Directionality.of(context), + mirrored ? TextDirection.rtl : TextDirection.ltr, connectorTop, ), ), ), ), Padding( - padding: const EdgeInsetsDirectional.only(start: Grid.twelve), + padding: EdgeInsets.only( + left: mirrored ? 0 : Grid.twelve, + right: mirrored ? Grid.twelve : 0, + ), child: Semantics( button: true, label: 'View thread: $label', diff --git a/mobile/lib/features/channels/thread_detail_page/message_list.dart b/mobile/lib/features/channels/thread_detail_page/message_list.dart index d4894bfe29..cfd7572b5d 100644 --- a/mobile/lib/features/channels/thread_detail_page/message_list.dart +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -97,6 +97,7 @@ class _ThreadMessageList extends HookWidget { ), _ThreadMessage( message: head, + startsDay: true, channelNames: channelNames, channelId: channelId, currentPubkey: currentPubkey, @@ -300,6 +301,13 @@ class _ThreadMessageList extends HookWidget { ), _ThreadMessage( message: reply, + startsDay: showDayDivider, + endsDay: + nextReply != null && + !isSameDay( + reply.createdAt, + nextReply.createdAt, + ), channelNames: channelNames, channelId: channelId, currentPubkey: currentPubkey, diff --git a/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart b/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart index 6adf6e1ab6..fc79a822d0 100644 --- a/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart +++ b/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart @@ -23,29 +23,41 @@ class _NestedThreadSummaryRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return Padding( - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap + Grid.xxs, - bottom: Grid.xxs, - ), - child: ReplySummaryLink( - key: ValueKey('nested-thread-summary-${replyMessage.id}'), - summary: summary, - hasReactions: replyMessage.reactions.isNotEmpty, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: replyMessage, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, + final ownMessage = + currentPubkey?.toLowerCase() == replyMessage.pubkey.toLowerCase(); + return Align( + alignment: ownMessage ? Alignment.centerRight : Alignment.centerLeft, + child: Directionality( + textDirection: Directionality.of(context), + child: Padding( + padding: EdgeInsets.only( + left: ownMessage + ? 0 + : messageAvatarSize + messageAvatarContentGap + Grid.xxs, + right: ownMessage ? Grid.xxs : 0, + bottom: Grid.xxs, + ), + child: ReplySummaryLink( + key: ValueKey('nested-thread-summary-${replyMessage.id}'), + summary: summary, + mirrored: ownMessage, + hasReactions: replyMessage.reactions.isNotEmpty, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: replyMessage, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + ), + ), ), ); } diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index aa4f6e1d71..7bbec9def0 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -7,6 +7,8 @@ class _ThreadMessage extends HookConsumerWidget { final String? currentPubkey; final bool showAuthor; final bool showAvatar; + final bool startsDay; + final bool endsDay; final bool isHighlighted; final List? allMessages; final bool isMember; @@ -25,6 +27,8 @@ class _ThreadMessage extends HookConsumerWidget { required this.currentPubkey, required this.showAuthor, this.showAvatar = true, + this.startsDay = false, + this.endsDay = false, this.isHighlighted = false, this.allMessages, this.isMember = false, @@ -39,6 +43,7 @@ class _ThreadMessage extends HookConsumerWidget { final messageSnapshotKey = useMemoized(GlobalKey.new, const []); final mediaOffset = useMessageMediaOffset(message.content, message.tags); final pk = message.pubkey.toLowerCase(); + final ownMessage = currentPubkey?.toLowerCase() == pk; final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); @@ -134,7 +139,7 @@ class _ThreadMessage extends HookConsumerWidget { return true; }, child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + padding: EdgeInsets.only(top: showAuthor && !startsDay ? Grid.xs : 0), child: DecoratedBox( key: ValueKey('thread-message-${message.id}'), decoration: BoxDecoration( @@ -156,31 +161,40 @@ class _ThreadMessage extends HookConsumerWidget { snapshotKey: messageSnapshotKey, child: Padding( padding: EdgeInsets.only( - bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, + bottom: endsDay + ? 0 + : (showAvatar ? Grid.quarter : Grid.quarter / 2), ), child: MessageFooterLayout( animateSize: true, overlap: message.reactions.isNotEmpty ? Grid.xs : 0, footer: (isThreadHead || message.reactions.isNotEmpty) - ? Padding( - padding: const EdgeInsets.only( - left: - messageAvatarSize + - messageAvatarContentGap + - Grid.xxs, - ), - child: ReactionRow( - combined: true, - messageId: message.id, - channelId: channelId, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, + ? Align( + alignment: ownMessage + ? Alignment.centerRight + : Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only( + right: ownMessage ? Grid.xxs : 0, + left: ownMessage + ? 0 + : messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + ), + child: ReactionRow( + combined: true, + messageId: message.id, + channelId: channelId, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), ), ), ) @@ -191,29 +205,41 @@ class _ThreadMessage extends HookConsumerWidget { clipBehavior: Clip.none, children: [ Row( + textDirection: ownMessage + ? TextDirection.rtl + : TextDirection.ltr, crossAxisAlignment: CrossAxisAlignment.end, children: [ - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), + if (!ownMessage) + const SizedBox(width: messageAvatarSize), + if (!ownMessage) + const SizedBox(width: messageAvatarContentGap), Expanded( child: Padding( padding: EdgeInsets.only( - top: showAuthor ? Grid.half : 0, + top: showAuthor && !startsDay ? Grid.half : 0, ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ if (showAuthor) Padding( - padding: const EdgeInsets.only( + padding: EdgeInsets.only( bottom: Grid.quarter, - left: Grid.xs, + left: ownMessage ? 0 : Grid.xs, + right: ownMessage ? Grid.xs : 0, ), child: Row( children: [ Expanded( child: MessageAuthorMeta( showTimestamp: true, + reversed: false, + alignment: ownMessage + ? MainAxisAlignment.end + : MainAxisAlignment.start, nameStyle: messageUsernameTextStyle .copyWith( @@ -290,8 +316,8 @@ class _ThreadMessage extends HookConsumerWidget { message.reactions.isNotEmpty, bubbleEnd: showAvatar, bubbleStart: showAuthor, - ownMessage: - currentPubkey?.toLowerCase() == pk, + ownMessage: ownMessage, + trimTrailingSpacing: endsDay, mentionNames: resolvedMentionNames, mentionLabels: mentionLabels, agentMentionPubkeys: agentMentionPubkeys, @@ -362,9 +388,10 @@ class _ThreadMessage extends HookConsumerWidget { ), ], ), - if (showAvatar) + if (showAvatar && !ownMessage) Positioned( - left: 0, + left: ownMessage ? null : 0, + right: ownMessage ? 0 : null, bottom: 0, child: MessageMediaAvatar( offset: mediaOffset, diff --git a/mobile/lib/features/profile/profile_text_edit_sheet.dart b/mobile/lib/features/profile/profile_text_edit_sheet.dart index a0e9dce3c4..9cbb89d286 100644 --- a/mobile/lib/features/profile/profile_text_edit_sheet.dart +++ b/mobile/lib/features/profile/profile_text_edit_sheet.dart @@ -96,7 +96,7 @@ class ProfileTextEditSheet extends HookWidget { borderRadius: BorderRadius.circular(Radii.dialog), ), ), - icon: const Icon(BuzzIcons.x, size: 22), + icon: const Icon(BuzzIcons.x, size: 24), ), ); diff --git a/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart b/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart index cd7e6bd8ce..0dfe750849 100644 --- a/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart +++ b/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart @@ -305,7 +305,7 @@ class _ThemePreviewCloseButton extends StatelessWidget { borderRadius: BorderRadius.circular(Radii.dialog), ), ), - icon: const Icon(BuzzIcons.x, size: 22), + icon: const Icon(BuzzIcons.x, size: 24), ), ); } diff --git a/mobile/lib/shared/widgets/buzz_sheet_header.dart b/mobile/lib/shared/widgets/buzz_sheet_header.dart index e51f6973cd..14353bcd33 100644 --- a/mobile/lib/shared/widgets/buzz_sheet_header.dart +++ b/mobile/lib/shared/widgets/buzz_sheet_header.dart @@ -107,7 +107,7 @@ class _SheetCloseButton extends StatelessWidget { borderRadius: BorderRadius.circular(Radii.dialog), ), ), - icon: const Icon(BuzzIcons.x, size: 22), + icon: const Icon(BuzzIcons.x, size: 24), ), ); } diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index 0c64993478..2a5b93a333 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -258,7 +258,7 @@ class IosGlassNavigationButton extends HookWidget { }, size: icon == IosGlassNavigationIcon.shutter ? controlSize * 0.72 - : 22, + : 24, color: icon == IosGlassNavigationIcon.colorSwatch ? swatchColor : effectiveForeground, diff --git a/mobile/lib/shared/widgets/message_author_meta.dart b/mobile/lib/shared/widgets/message_author_meta.dart index 2de13ad5b5..402c0d1f40 100644 --- a/mobile/lib/shared/widgets/message_author_meta.dart +++ b/mobile/lib/shared/widgets/message_author_meta.dart @@ -43,6 +43,12 @@ class MessageAuthorMeta extends StatelessWidget { /// Base text style for [timestamp], with [metadataColor] applied. final TextStyle timestampStyle; + /// Mirrors metadata order without changing text reading direction. + final bool reversed; + + /// Positions the metadata within the available message width. + final MainAxisAlignment alignment; + /// Creates an inline author row with optional username and tap handling. const MessageAuthorMeta({ super.key, @@ -51,6 +57,8 @@ class MessageAuthorMeta extends StatelessWidget { required this.nameColor, required this.metadataColor, this.username, + this.alignment = MainAxisAlignment.start, + this.reversed = false, this.showTimestamp = true, this.onAuthorTap, this.displayNameKey, @@ -91,37 +99,39 @@ class MessageAuthorMeta extends StatelessWidget { ? constraints.maxWidth / (showUsername ? 3 : 2) : double.infinity; - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flexible(child: authorName), - if (showUsername) ...[ - const SizedBox(width: Grid.half), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: metadataMaxWidth), - child: Text( - normalizedUsername, - key: usernameKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: resolvedMetadataStyle, - ), + final children = [ + Flexible(child: authorName), + if (showUsername) ...[ + const SizedBox(width: Grid.half), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: metadataMaxWidth), + child: Text( + normalizedUsername, + key: usernameKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: resolvedMetadataStyle, ), - ], - if (showTimestamp) ...[ - const SizedBox(width: Grid.xxs), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: metadataMaxWidth), - child: Text( - timestamp, - key: timestampKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: resolvedTimestampStyle, - ), - ), - ], + ), ], + if (showTimestamp) ...[ + const SizedBox(width: Grid.xxs), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: metadataMaxWidth), + child: Text( + timestamp, + key: timestampKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: resolvedTimestampStyle, + ), + ), + ], + ]; + return Row( + mainAxisAlignment: alignment, + crossAxisAlignment: CrossAxisAlignment.center, + children: reversed ? children.reversed.toList() : children, ); }, ); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 4f212b3952..d9f7585e0c 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -5679,7 +5679,7 @@ void main() { for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { for (final multiline in [false, true]) { testWidgets( - 'group avatar stays fixed throughout a local send: $platform, multiline=$multiline', + 'outgoing group stays avatar-free throughout a local send: $platform, multiline=$multiline', (tester) async { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = platform; @@ -5710,7 +5710,7 @@ void main() { of: find.byKey(ValueKey('message-row-$id')), matching: find.byType(AvatarImage), ); - final before = tester.getRect(avatar('first')); + expect(avatar('first'), findsNothing); final container = ProviderScope.containerOf( tester.element(find.byType(ChannelDetailPage)), ); @@ -5730,19 +5730,19 @@ void main() { ]); for (var frame = 0; frame < 32; frame++) { await tester.pump(const Duration(milliseconds: 16)); - final visibleAvatar = avatar('second').evaluate().isNotEmpty - ? avatar('second') - : avatar('first'); - expect(visibleAvatar, findsOneWidget); - final current = tester.getRect(visibleAvatar); expect( - current.top, - closeTo(before.top, 1), - reason: 'avatar frame $frame', + avatar('first'), + findsNothing, + reason: 'first avatar frame $frame', + ); + expect( + avatar('second'), + findsNothing, + reason: 'second avatar frame $frame', ); } await tester.pumpAndSettle(); - expect(avatar('second'), findsOneWidget); + expect(avatar('second'), findsNothing); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } @@ -6508,6 +6508,51 @@ void main() { }, ); + testWidgets('own ended huddle reclaims avatar space and aligns reactions', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'alice', + messages: [ + _huddleMsg(id: 'own-huddle', kind: EventKind.huddleStarted), + _huddleMsg( + id: 'own-ending', + kind: EventKind.huddleEnded, + createdAt: 1500, + ), + _reaction(id: 'own-huddle-reaction', targetId: 'own-huddle'), + ], + ), + ); + await tester.pumpAndSettle(); + final card = tester.getRect( + find.byKey(const ValueKey('huddle-card-own-huddle')), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('huddle-activity-own-huddle')), + matching: find.byType(AvatarImage), + ), + findsNothing, + ); + expect(find.text('Huddle ended'), findsOneWidget); + expect( + card.right, + closeTo( + tester + .getRect(find.byKey(const ValueKey('huddle-activity-own-huddle'))) + .right, + 0.01, + ), + ); + final tray = tester.getRect( + find.byKey(const ValueKey('combined-reaction-tray')), + ); + expect(tray.right, lessThan(card.right)); + expect(tray.right, greaterThan(card.center.dx)); + }); + testWidgets('renders a compact huddle activity row', (tester) async { await tester.pumpWidget( _buildTestable( diff --git a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart index 454cb733f5..5af1a5d680 100644 --- a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart @@ -1,6 +1,222 @@ part of '../channel_detail_page_test.dart'; void bubbleLayoutTests() { + for (final author in ['self', 'alice']) { + testWidgets('date divider owns equal space around $author messages', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'self', + messages: [ + _textMsg( + id: 'before-day', + pubkey: author, + content: 'Before', + createdAt: 1000, + ), + _textMsg( + id: 'after-day', + pubkey: author, + content: 'After', + createdAt: 87400, + ), + ], + ), + ); + await tester.pumpAndSettle(); + final before = tester.getRect( + find + .descendant( + of: find.byKey(const ValueKey('message-row-before-day')), + matching: find.byType(LocalSendBubbleSurface), + ) + .first, + ); + final divider = tester.getRect( + find.byKey(const ValueKey('channel-day-divider-opacity-87400')), + ); + final after = tester.getRect( + find.byKey(const ValueKey('message-author-after-day')), + ); + expect( + divider.top - before.bottom, + closeTo(after.top - divider.bottom, 1), + ); + expect(divider.top - before.bottom, closeTo(Grid.gutter, 1)); + expect(tester.takeException(), isNull); + }); + } + + for (final personalChat in [false, true]) { + for (final scale in [1.0, 2.0]) { + testWidgets( + 'viewer bubbles align right: personal=$personalChat scale=$scale', + (tester) async { + tester.view.physicalSize = const Size(390, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'self', + personalChat: personalChat, + textScaler: TextScaler.linear(scale), + users: const { + 'self': UserProfile(pubkey: 'self', displayName: 'Me'), + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + messages: [ + _textMsg( + id: 'incoming', + pubkey: 'alice', + content: 'Hello', + createdAt: 1000, + ), + _textMsg( + id: 'outgoing', + pubkey: 'self', + content: 'Hi', + createdAt: 1001, + ), + _reaction( + id: 'reaction-in', + targetId: 'incoming', + pubkey: 'self', + ), + _reaction( + id: 'reaction-out', + targetId: 'outgoing', + pubkey: 'alice', + ), + ], + ), + ); + await tester.pumpAndSettle(); + Rect bounds(String id, Finder child) => tester.getRect( + find + .descendant( + of: find.byKey(ValueKey('message-row-$id')), + matching: child, + ) + .first, + ); + final incoming = bounds( + 'incoming', + find.byType(LocalSendBubbleSurface), + ); + final outgoing = bounds( + 'outgoing', + find.byType(LocalSendBubbleSurface), + ); + expect(outgoing.center.dx, greaterThan(incoming.center.dx)); + expect( + bounds( + 'outgoing', + find.byKey(const ValueKey('message-author-outgoing')), + ).right, + lessThan( + bounds( + 'outgoing', + find.byKey(const ValueKey('message-timestamp-outgoing')), + ).left, + ), + ); + expect( + bounds( + 'incoming', + find.byKey(const ValueKey('message-author-incoming')), + ).right, + lessThan( + bounds( + 'incoming', + find.byKey(const ValueKey('message-timestamp-incoming')), + ).left, + ), + ); + + expect(outgoing.right, greaterThan(300)); + expect(incoming.left, lessThan(90)); + final incomingAvatar = bounds( + 'incoming', + find.byType(MessageMediaAvatar), + ); + expect(incomingAvatar.right, lessThan(incoming.left)); + expect( + find.descendant( + of: find.byKey(const ValueKey('message-row-outgoing')), + matching: find.byType(MessageMediaAvatar), + ), + findsNothing, + ); + expect( + outgoing.right, + closeTo( + tester + .getRect(find.byKey(const ValueKey('message-row-outgoing'))) + .right, + 0.01, + ), + ); + final incomingReaction = bounds( + 'incoming', + find.byKey(const ValueKey('combined-reaction-tray')), + ); + final outgoingReaction = bounds( + 'outgoing', + find.byKey(const ValueKey('combined-reaction-tray')), + ); + expect( + outgoingReaction.center.dx, + greaterThan(incomingReaction.center.dx), + ); + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets('thread replies reclaim the outgoing avatar gutter', ( + tester, + ) async { + final root = _textMsg(id: threadIdRoot, pubkey: 'alice', content: 'Root'); + final reply = _textMsg( + id: threadIdR1, + pubkey: 'self', + content: 'My reply', + createdAt: 1100, + extraTags: const [ + ['e', threadIdRoot, '', 'reply'], + ], + ); + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'self', + messages: [root, reply], + threadReplies: { + threadIdRoot: [reply], + }, + initialThreadRootId: threadIdRoot, + ), + ); + await tester.pumpAndSettle(); + final row = find.byKey(const ValueKey('thread-message-row-$threadIdR1')); + final bubble = tester.getRect( + find + .descendant(of: row, matching: find.byType(LocalSendBubbleSurface)) + .first, + ); + expect( + find.descendant(of: row, matching: find.byType(MessageMediaAvatar)), + findsNothing, + ); + expect(bubble.right, closeTo(tester.getRect(row).right, 0.01)); + expect(tester.takeException(), isNull); + }); + testWidgets('large activity members build and fetch only visible rows', ( tester, ) async { diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 902ecad2e1..fcd67f123c 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -248,6 +248,98 @@ void main() { await tester.pumpWidget(const SizedBox()); }); + for (final label in ['Channels options', 'Design options']) { + testWidgets('$label exposes a labeled button and tap action', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + channelSectionsProvider.overrideWith( + () => _FakeChannelSectionsNotifier( + const ChannelSectionStore( + sections: [ + ChannelSection( + id: 'section-1', + name: 'Design', + icon: '🎨', + order: 0, + ), + ], + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + final control = find.bySemanticsLabel(label); + expect(control, findsOneWidget); + final node = tester.getSemantics(control); + expect(node.flagsCollection.isButton, isTrue); + expect( + node.getSemanticsData().hasAction(ui.SemanticsAction.tap), + isTrue, + ); + node.owner!.performAction(node.id, ui.SemanticsAction.tap); + await tester.pumpAndSettle(); + expect( + find.byKey( + ValueKey( + label == 'Channels options' + ? 'sort-popover-Channels' + : 'section-popover-section-1', + ), + ), + findsOneWidget, + ); + } finally { + semantics.dispose(); + } + }); + } + + testWidgets('section ellipsis aligns with temporary channel icon', ( + tester, + ) async { + final temporary = Channel( + id: 'temporary', + name: 'temporary', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 1, + isMember: true, + ttlSeconds: 3600, + ); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier([temporary])), + ], + ), + ); + await tester.pumpAndSettle(); + final ellipsis = find.descendant( + of: find.byTooltip('Channels options'), + matching: find.byIcon(BuzzIcons.ellipsisVertical), + ); + expect( + tester.getCenter(ellipsis).dx, + closeTo( + tester + .getCenter(find.byKey(const Key('channel-ephemeral-temporary'))) + .dx, + 0.01, + ), + ); + }); + testWidgets('shows grouped channel list when data loads', (tester) async { // Valid fixture keys whose npub encodings were verified against the // NIP-19 codec independently of the code under test. @@ -325,6 +417,17 @@ void main() { findsOneWidget, ); + expect( + tester + .getTopRight( + find.descendant( + of: find.byTooltip('Channels options'), + matching: find.byIcon(BuzzIcons.ellipsisVertical), + ), + ) + .dx, + closeTo(tester.getTopRight(find.byTooltip('Channels options')).dx, 0.01), + ); await tester.tap(find.byTooltip('Channels options')); await tester.pumpAndSettle(); expect(find.text('Sort: Recent'), findsOneWidget); @@ -986,6 +1089,20 @@ void main() { AppTheme.light().colorScheme.onSurface.withValues(alpha: 0.07), ); } + expect( + tester + .getTopRight( + find.descendant( + of: find.byKey(const Key('section-menu-section-1')), + matching: find.byIcon(BuzzIcons.ellipsisVertical), + ), + ) + .dx, + closeTo( + tester.getTopRight(find.byKey(const Key('section-menu-section-1'))).dx, + 0.01, + ), + ); await tester.tap(find.byKey(const Key('section-menu-section-1'))); await tester.pumpAndSettle(); diff --git a/mobile/test/features/channels/day_divider_test.dart b/mobile/test/features/channels/day_divider_test.dart index 23b3905268..240f88ee3e 100644 --- a/mobile/test/features/channels/day_divider_test.dart +++ b/mobile/test/features/channels/day_divider_test.dart @@ -4,6 +4,75 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + for (final dark in [false, true]) { + testWidgets('inline calendar shows local day before label, dark=$dark', ( + tester, + ) async { + final date = DateTime(2026, 10, 18, 12); + final timestamp = date.millisecondsSinceEpoch ~/ 1000; + await tester.pumpWidget( + MaterialApp( + theme: dark ? AppTheme.dark() : AppTheme.light(), + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: SizedBox( + width: 320, + child: DayDivider( + label: 'October 18, 2026', + dayTimestamp: timestamp, + ), + ), + ), + ), + ), + ); + final tile = find.byKey(ValueKey('day-calendar-$timestamp')); + expect( + find.descendant(of: tile, matching: find.text('18')), + findsOneWidget, + ); + expect( + tester.getRect(tile).right, + lessThan(tester.getRect(find.text('October 18, 2026')).left), + ); + expect(find.byType(Divider), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }); + } + + for (final label in ['Today', 'October 18, 2026']) { + testWidgets('date group centered with full-width rules: $label', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + width: 390, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: DayDivider(label: label, dayTimestamp: 1000), + ), + ), + ), + ), + ); + final left = tester.getRect(find.byType(Divider).first); + final right = tester.getRect(find.byType(Divider).last); + final icon = tester.getRect( + find.byKey(const ValueKey('day-calendar-1000')), + ); + final text = tester.getRect(find.text(label)); + expect(left.left, closeTo(Grid.gutter, 0.01)); + expect(right.right, closeTo(390 - Grid.gutter, 0.01)); + expect(left.width, closeTo(right.width, 0.01)); + expect((icon.left + text.right) / 2, closeTo(195, 0.01)); + expect(tester.takeException(), isNull); + }); + } + testWidgets('fades the in-flow date while that day is sticky', ( tester, ) async { diff --git a/mobile/test/features/channels/message_carousel_preview_cases.dart b/mobile/test/features/channels/message_carousel_preview_cases.dart new file mode 100644 index 0000000000..1dff030bf3 --- /dev/null +++ b/mobile/test/features/channels/message_carousel_preview_cases.dart @@ -0,0 +1,174 @@ +part of 'message_content_test.dart'; + +void carouselPreviewTests() { + testWidgets( + 'iOS prepares five thumbnails before scrolling with two preload requests', + (tester) async { + final started = []; + + final responses = >{}; + final urls = List.generate( + 10, + (i) => 'https://example.com/preload-$i.png', + ); + final messenger = tester.binding.defaultBinaryMessenger; + const connectivity = MethodChannel( + 'dev.fluttercommunity.plus/connectivity_status', + ); + messenger.setMockMethodCallHandler(connectivity, (_) async => null); + addTearDown(() => messenger.setMockMethodCallHandler(connectivity, null)); + final channels = []; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final id = (call.arguments as Map)['id']; + final channel = MethodChannel('buzz/ios_media_carousel/$id'); + channels.add(channel); + messenger.setMockMethodCallHandler(channel, (_) async => null); + } + return null; + }); + final client = MockClient((request) { + final url = request.url.toString(); + started.add(url); + final response = responses[url] = Completer(); + + return response.future; + }); + Future waitForCount(int count) async { + for ( + var attempt = 0; + attempt < 100 && started.length < count; + attempt++ + ) { + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + } + expect(started.length, count); + } + + final png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=', + ); + void release(String url) { + final response = responses[url]!; + if (!response.isCompleted) { + response.complete(http.Response.bytes(png, 200)); + } + } + + addTearDown(() async { + for (final response in responses.values) { + if (!response.isCompleted) response.complete(http.Response('', 404)); + } + messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); + for (final channel in channels) { + messenger.setMockMethodCallHandler(channel, null); + } + + client.close(); + }); + await tester.pumpWidget( + _testable( + MessageContent( + content: urls.map((url) => '![image]($url)').join('\n'), + ), + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue( + MediaGetAuthService(baseUrl: 'https://example.com', nsec: null), + ), + mediaHttpClientProvider.overrideWithValue(client), + ], + ), + ); + await tester.pump(); + { + await waitForCount(2); + expect(started, hasLength(2)); + for (var i = 0; i < 3; i++) { + release(started[i]); + await waitForCount(i + 3); + expect(started, hasLength(i + 3)); + } + release(started[3]); + release(started[4]); + } + await tester.pumpAndSettle(); + expect(started.toSet(), urls.take(5).toSet()); + // No native subscribeImage or page callback was sent by this harness. + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'outgoing gallery starts at the right and mirrors its spacing', + (tester) async { + final messenger = tester.binding.defaultBinaryMessenger; + final channels = []; + final configs = >[]; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final id = (call.arguments as Map)['id']; + final channel = MethodChannel('buzz/ios_media_carousel/$id'); + channels.add(channel); + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'configure') { + configs.add(Map.from(call.arguments as Map)); + } + return null; + }); + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); + for (final channel in channels) { + messenger.setMockMethodCallHandler(channel, null); + } + }); + final counts = []; + final widths = []; + for (final own in [false, true]) { + await tester.pumpWidget( + _testable( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 50), + child: MessageContent( + key: ValueKey(own), + ownMessage: own, + bubble: true, + mediaCarouselTrailingOverflow: Grid.gutter, + content: + '![one](https://example.com/mirror-one.png)\n![two](https://example.com/mirror-two.png)', + ), + ), + ), + ); + await tester.pumpAndSettle(); + counts.add( + tester.getRect( + find.byKey(const ValueKey('message-media-carousel-count')), + ), + ); + widths.add( + tester + .getSize(find.byKey(const ValueKey('message-media-carousel'))) + .width, + ); + expect(configs.last['rtl'], own); + } + expect(widths[0], widths[1]); + expect(counts[0].left, closeTo(800 - counts[1].right, 0.1)); + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 6f28932c16..3715f8cf97 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -37,6 +37,7 @@ import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; part 'message_content_bubble_cases.dart'; +part 'message_carousel_preview_cases.dart'; Widget _testable( Widget child, { @@ -379,6 +380,7 @@ class _TestChannelsNotifier extends ChannelsNotifier { } void main() { + carouselPreviewTests(); bubbleContentTests(); test('wide voice-note waveforms distribute bars across their full width', () { const width = 320.0; From e048f5ac788c65a447dcb83decef973da855779b Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 12:41:06 -0700 Subject: [PATCH 08/28] fix(mobile): bound search result rendering work (#8418) Global search eagerly mounted every result and parsed entire messages even for two-line previews. Large markdown hits could block the UI while changing categories, updating results, or reopening search. Build result rows lazily and bound long message previews to a literal prefix of at most 512 UTF-16 units without splitting surrogate pairs. Short messages retain rich formatting and mentions. Long snippets show markdown punctuation literally; opening a result still uses the original message. Truncated markdown is never passed to the rich renderer. ### Validation - Added regressions for lazy message/channel/people rows, scrolling to the final result, and a >100 KB markdown hit with a surrogate pair at the preview boundary. All 23 focused search tests pass; full mobile analysis is clean. The original large-hit test consumed a CPU core for over 30 seconds before being terminated. - Native iOS simulator build passed. Diagnostic Debug/JIT captures attributed a 1,033 ms search frame and a separate 12,972 ms result-update callback to widget/markdown work. A verified populated-results run of the final candidate completed category switching, scrolling, and closing, with a longest UI callback of 70 ms. Separate instrumented Debug/JIT runs have uncontrolled host load, warm-up, and live data; they are not release-performance benchmarks. - Full local gate passed through desktop builds/checks, then failed two unchanged native subprocess PID/timeout tests; both passed in isolation. The remaining web build and mobile suite passed. - [Codex review](https://github.com/block/buzz/pull/8418#issuecomment-6243734066) completed on the current head with no findings. [CI](https://github.com/block/buzz/actions/runs/39082758977) passed, including mobile checks, all three test shards, Android, the iOS release build, and native simulator build/tests. ### Preview examples Synthetic widget captures with app fonts, without real community data or native iOS glass controls. | Long message before | Bounded literal preview after | | --- | --- | | ![Before](https://raw.githubusercontent.com/block/buzz/5840caf6cc3e35ce0ecdba68cfbcb10506f3eb23/pr-8418--search-long-before.png) | ![After](https://raw.githubusercontent.com/block/buzz/5840caf6cc3e35ce0ecdba68cfbcb10506f3eb23/pr-8418--search-long-preview.png) | --------- Signed-off-by: Tom Brow --- mobile/lib/features/search/search_page.dart | 278 +++++++++--------- .../search/search_page/message_tile.dart | 103 ++++--- .../search/search_page_lazy_test.dart | 257 ++++++++++++++++ .../features/search/search_page_test.dart | 9 +- 4 files changed, 474 insertions(+), 173 deletions(-) create mode 100644 mobile/test/features/search/search_page_lazy_test.dart diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 69397c477f..fe5b5a74d9 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -328,41 +328,49 @@ class _SearchBody extends ConsumerWidget { ); } - return ListView( + return CustomScrollView( key: const Key('search-results-list'), - padding: EdgeInsets.only( - bottom: - Grid.xl + - MediaQuery.paddingOf(context).bottom + - MediaQuery.viewInsetsOf(context).bottom, - ), - children: [ - if (showChannels && state.channelResults.isNotEmpty) - _ChannelsSection( - channels: state.channelResults, - onResultSelected: recordResultSelection, + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + bottom: + Grid.xl + + MediaQuery.paddingOf(context).bottom + + MediaQuery.viewInsetsOf(context).bottom, ), - if (showPeople && state.userResults.isNotEmpty) - _PeopleSection( - users: state.userResults, - onResultSelected: recordResultSelection, - ), - if (showMessages && state.messageResults.isNotEmpty) - _MessagesSection( - hits: state.messageResults, - currentPubkey: currentPubkey, - onResultSelected: recordResultSelection, - ), - if (state.isLoading) - const Padding( - padding: EdgeInsets.all(Grid.sm), - child: Center( - child: BuzzLoadingIndicator( - size: 36, - semanticLabel: 'Loading more search results', - ), - ), + sliver: SliverMainAxisGroup( + slivers: [ + if (showChannels && state.channelResults.isNotEmpty) + _ChannelsSection( + channels: state.channelResults, + onResultSelected: recordResultSelection, + ), + if (showPeople && state.userResults.isNotEmpty) + _PeopleSection( + users: state.userResults, + onResultSelected: recordResultSelection, + ), + if (showMessages && state.messageResults.isNotEmpty) + _MessagesSection( + hits: state.messageResults, + currentPubkey: currentPubkey, + onResultSelected: recordResultSelection, + ), + if (state.isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(Grid.sm), + child: Center( + child: BuzzLoadingIndicator( + size: 36, + semanticLabel: 'Loading more search results', + ), + ), + ), + ), + ], ), + ), ], ); } @@ -472,57 +480,57 @@ class _ChannelsSection extends ConsumerWidget { }); @override Widget build(BuildContext context, WidgetRef ref) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel(label: 'Channels'), - for (final channel in channels) - ListTile( - key: ValueKey('search-channel-row-${channel.id}'), - contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - leading: Icon( - channelIcon(channel), - key: ValueKey('search-channel-leading-${channel.id}'), - size: 20, - ), - title: Text( - channel.name, - key: ValueKey('search-channel-title-${channel.id}'), - style: contentListTitleTextStyle, - ), - subtitle: channel.isMember - ? Text( - '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', - style: contentListBodyTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ) - : null, - trailing: !channel.isMember && !channel.isDm - ? Container( - padding: const EdgeInsets.symmetric( - horizontal: Grid.half + 2, - vertical: 3, - ), - decoration: BoxDecoration( - color: context.colors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(Radii.sm), - ), - child: Text( - 'Open', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w600, - ), - ), - ) - : null, - onTap: () { - onResultSelected(); - _openSearchChannel(context, channel); - }, + return SliverList.builder( + itemCount: channels.length + 1, + itemBuilder: (context, index) { + if (index == 0) return const _SectionLabel(label: 'Channels'); + final channel = channels[index - 1]; + return ListTile( + key: ValueKey('search-channel-row-${channel.id}'), + contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + leading: Icon( + channelIcon(channel), + key: ValueKey('search-channel-leading-${channel.id}'), + size: 20, ), - ], + title: Text( + channel.name, + key: ValueKey('search-channel-title-${channel.id}'), + style: contentListTitleTextStyle, + ), + subtitle: channel.isMember + ? Text( + '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', + style: contentListBodyTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + ) + : null, + trailing: !channel.isMember && !channel.isDm + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.half + 2, + vertical: 3, + ), + decoration: BoxDecoration( + color: context.colors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Text( + 'Open', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + ), + ), + ) + : null, + onTap: () { + onResultSelected(); + _openSearchChannel(context, channel); + }, + ); + }, ); } } @@ -545,46 +553,46 @@ class _PeopleSection extends ConsumerWidget { }, fallbackNames: {for (final user in users) user.pubkey: user.label}, ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel(label: 'People'), - for (final user in users) - ListTile( - key: ValueKey('search-person-row-${user.pubkey}'), - contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - leading: AvatarImage( - key: ValueKey('search-person-leading-${user.pubkey}'), - imageUrl: user.avatarUrl, - radius: 20, - fallback: Text(user.initial), - isAgent: user.isAgent, - ), - title: Text( - names.labelFor(user.pubkey), - key: ValueKey('search-person-title-${user.pubkey}'), - style: contentListTitleTextStyle, - ), - subtitle: Text( - user.secondaryLabel, - style: contentListBodyTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - onTap: () async { - onResultSelected(); - final channel = await ref - .read(channelActionsProvider) - .openDm(pubkeys: [user.pubkey]); - if (!context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), - ), - ); - }, + return SliverList.builder( + itemCount: users.length + 1, + itemBuilder: (context, index) { + if (index == 0) return const _SectionLabel(label: 'People'); + final user = users[index - 1]; + return ListTile( + key: ValueKey('search-person-row-${user.pubkey}'), + contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + leading: AvatarImage( + key: ValueKey('search-person-leading-${user.pubkey}'), + imageUrl: user.avatarUrl, + radius: 20, + fallback: Text(user.initial), + isAgent: user.isAgent, ), - ], + title: Text( + names.labelFor(user.pubkey), + key: ValueKey('search-person-title-${user.pubkey}'), + style: contentListTitleTextStyle, + ), + subtitle: Text( + user.secondaryLabel, + style: contentListBodyTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + onTap: () async { + onResultSelected(); + final channel = await ref + .read(channelActionsProvider) + .openDm(pubkeys: [user.pubkey]); + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + }, + ); + }, ); } } @@ -616,20 +624,20 @@ class _MessagesSection extends HookConsumerWidget { return null; }, [preloadPubkeysKey]); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel(label: 'Messages'), - for (final hit in hits) - _MessageTile( - hit: hit, - authorProfile: profiles[hit.pubkey.toLowerCase()], - userCache: profiles, - channel: channels.where((c) => c.id == hit.channelId).firstOrNull, - currentPubkey: currentPubkey, - onResultSelected: onResultSelected, - ), - ], + return SliverList.builder( + itemCount: hits.length + 1, + itemBuilder: (context, index) { + if (index == 0) return const _SectionLabel(label: 'Messages'); + final hit = hits[index - 1]; + return _MessageTile( + hit: hit, + authorProfile: profiles[hit.pubkey.toLowerCase()], + userCache: profiles, + channel: channels.where((c) => c.id == hit.channelId).firstOrNull, + currentPubkey: currentPubkey, + onResultSelected: onResultSelected, + ); + }, ); } } diff --git a/mobile/lib/features/search/search_page/message_tile.dart b/mobile/lib/features/search/search_page/message_tile.dart index ca2fcf5ba6..e902096b4d 100644 --- a/mobile/lib/features/search/search_page/message_tile.dart +++ b/mobile/lib/features/search/search_page/message_tile.dart @@ -1,5 +1,16 @@ part of '../search_page.dart'; +// Two-line search snippets must not parse an entire document. Long messages +// use literal text so cutting a code fence or link cannot create a new embed. +const _richSearchPreviewLimit = 512; + +String _literalSearchPreview(String content) { + var end = _richSearchPreviewLimit; + final last = content.codeUnitAt(end - 1); + if (last >= 0xD800 && last <= 0xDBFF) end--; + return '${content.substring(0, end)}…'; +} + class _MessageTile extends ConsumerWidget { final SearchHit hit; final UserProfile? authorProfile; @@ -19,7 +30,9 @@ class _MessageTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mentionedPubkeys = mentionedPubkeysFromTags(hit.tags); + final mentionedPubkeys = hit.content.length > _richSearchPreviewLimit + ? {} + : mentionedPubkeysFromTags(hit.tags); // A hit is labelled within its channel, as it would be there. final hitChannelId = channel?.id ?? hit.channelId; final labelPubkeys = {hit.pubkey.toLowerCase(), ...mentionedPubkeys}; @@ -40,28 +53,6 @@ class _MessageTile extends ConsumerWidget { final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), ''); final hasChannelName = channelName != null && channelName.isNotEmpty; final isDm = channel?.isDm ?? false; - final profileMentionNames = { - for (final pubkey in mentionedPubkeysFromTags(hit.tags)) - if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) - pubkey: userCache[pubkey]!.displayName!.trim(), - }; - final mentionPubkeys = mentionedPubkeysFromTags(hit.tags); - final knownAgentPubkeys = channel == null - ? ref.watch(knownAgentPubkeysProvider) - : ref.watch(agentMentionPubkeysProvider(channel!.id)); - final agentMentionPubkeys = agentPubkeysWithProfileOwners( - knownAgentPubkeys: knownAgentPubkeys, - profileOwnedAgentPubkeys: [ - for (final profile in userCache.values) - if (profile.ownerPubkey != null) profile.pubkey, - ], - ); - final mentionNames = mentionNamesWithDirectoryLabels( - mentionPubkeys: mentionPubkeys, - profileMentionNames: profileMentionNames, - directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), - agentMentionPubkeys: agentMentionPubkeys, - ); return ListTile( key: ValueKey('search-message-row-${hit.eventId}'), @@ -130,18 +121,7 @@ class _MessageTile extends ConsumerWidget { ], ), const SizedBox(height: Grid.half), - MessageContent( - key: ValueKey('search-message-body-${hit.eventId}'), - content: hit.content, - mentionNames: mentionNames, - mentionLabels: labels, - agentMentionPubkeys: agentMentionPubkeys, - tags: hit.tags, - maxLines: 2, - baseStyle: activityPreviewTextStyle.copyWith( - color: context.colors.onSurface, - ), - ), + _buildPreview(context, ref, labels), ], ), onTap: () { @@ -151,6 +131,59 @@ class _MessageTile extends ConsumerWidget { ); } + Widget _buildPreview( + BuildContext context, + WidgetRef ref, + Map labels, + ) { + if (hit.content.length > _richSearchPreviewLimit) { + return Text( + _literalSearchPreview(hit.content), + key: ValueKey('search-message-body-${hit.eventId}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: activityPreviewTextStyle.copyWith( + color: context.colors.onSurface, + ), + ); + } + final profileMentionNames = { + for (final pubkey in mentionedPubkeysFromTags(hit.tags)) + if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) + pubkey: userCache[pubkey]!.displayName!.trim(), + }; + final mentionPubkeys = mentionedPubkeysFromTags(hit.tags); + final knownAgentPubkeys = channel == null + ? ref.watch(knownAgentPubkeysProvider) + : ref.watch(agentMentionPubkeysProvider(channel!.id)); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: knownAgentPubkeys, + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: mentionPubkeys, + profileMentionNames: profileMentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); + + return MessageContent( + key: ValueKey('search-message-body-${hit.eventId}'), + content: hit.content, + mentionNames: mentionNames, + mentionLabels: labels, + agentMentionPubkeys: agentMentionPubkeys, + tags: hit.tags, + maxLines: 2, + baseStyle: activityPreviewTextStyle.copyWith( + color: context.colors.onSurface, + ), + ); + } + void _navigateToHit( BuildContext context, WidgetRef ref, diff --git a/mobile/test/features/search/search_page_lazy_test.dart b/mobile/test/features/search/search_page_lazy_test.dart new file mode 100644 index 0000000000..56f88b7466 --- /dev/null +++ b/mobile/test/features/search/search_page_lazy_test.dart @@ -0,0 +1,257 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/search/recent_searches_provider.dart'; +import 'package:buzz/features/search/search_page.dart'; +import 'package:buzz/features/search/search_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets( + 'message results build only near the viewport and remain scrollable', + (tester) async { + tester.view.physicalSize = const Size(390, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final state = SearchState( + query: 'result', + messageResults: [ + for (var index = 0; index < 20; index++) + SearchHit( + eventId: 'message-$index', + content: 'Result **$index** with a preview', + kind: 9, + pubkey: 'author-pubkey', + createdAt: 1, + score: 1, + ), + ], + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier()), + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier( + const UserProfile(pubkey: 'author-pubkey'), + ), + ), + knownAgentPubkeysProvider.overrideWith((ref) => const {}), + agentDirectoryDisplayNamesProvider.overrideWith((ref) => const {}), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(MessageContent).evaluate().length, lessThan(10)); + final last = find.byKey(const ValueKey('search-message-row-message-19')); + expect(last, findsNothing); + await tester.scrollUntilVisible( + last, + 400, + scrollable: find.descendant( + of: find.byKey(const Key('search-results-list')), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + expect(last.hitTestable(), findsOneWidget); + expect(find.byType(MessageContent).evaluate().length, lessThan(10)); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'long message previews bound text without parsing partial markdown', + (tester) async { + tester.view.physicalSize = const Size(390, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final state = SearchState( + query: 'result', + messageResults: [ + SearchHit( + eventId: 'long', + content: + '${'x' * 511}😀${'**[long](https://example.com)**' * 4000}', + kind: 9, + pubkey: 'author-pubkey', + createdAt: 1, + score: 1, + ), + ], + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier()), + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier( + const UserProfile(pubkey: 'author-pubkey'), + ), + ), + knownAgentPubkeysProvider.overrideWith((ref) => const {}), + agentDirectoryDisplayNamesProvider.overrideWith((ref) => const {}), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(MessageContent), findsNothing); + final preview = tester.widget( + find.byKey(const ValueKey('search-message-body-long')), + ); + expect(preview.data, '${'x' * 511}…'); + expect(preview.maxLines, 2); + expect(preview.overflow, TextOverflow.ellipsis); + expect(tester.takeException(), isNull); + }, + ); + + for (final category in ['Channels', 'People']) { + testWidgets('$category results are lazy and reachable after filtering', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final state = SearchState( + query: 'result', + channelResults: [ + for (var index = 0; index < 30; index++) + Channel( + id: 'channel-$index', + name: 'Result $index', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'test', + createdAt: DateTime(2025), + memberCount: 1, + ), + ], + userResults: [ + for (var index = 0; index < 30; index++) + DirectoryUser( + pubkey: 'person-$index', + displayName: 'Person $index', + ), + ], + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier()), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(category).first); + await tester.pumpAndSettle(); + final key = category == 'Channels' + ? 'search-channel-row-channel-29' + : 'search-person-row-person-29'; + final last = find.byKey(ValueKey(key)); + expect(last, findsNothing); + await tester.scrollUntilVisible( + last, + 400, + scrollable: find.descendant( + of: find.byKey(const Key('search-results-list')), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + expect(last.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } +} + +class _FakeSearchNotifier extends SearchNotifier { + _FakeSearchNotifier(this.initialState); + + final SearchState initialState; + + @override + SearchState build() => initialState; + + @override + void search(String query) { + state = SearchState(query: query.trim()); + } + + @override + void clear() { + state = const SearchState.initial(); + } +} + +class _FakeRecentSearchesNotifier extends RecentSearchesNotifier { + _FakeRecentSearchesNotifier(this.initialSearches); + + final List initialSearches; + List get searches => state; + + @override + List build() => initialSearches; + + @override + void record(String query) { + final trimmed = query.trim(); + if (trimmed.isEmpty) return; + state = [ + trimmed, + ...state.where((item) => item.toLowerCase() != trimmed.toLowerCase()), + ]; + } + + @override + void clear() { + state = const []; + } +} + +class _FakeProfileNotifier extends ProfileNotifier { + @override + Future build() async => + const UserProfile(pubkey: 'test', displayName: 'Test'); +} + +class _FakeChannelsNotifier extends ChannelsNotifier { + @override + Future> build() async => const []; +} + +class _FakeUserCacheNotifier extends UserCacheNotifier { + _FakeUserCacheNotifier(this.profile); + + final UserProfile profile; + + @override + Map build() => {profile.pubkey: profile}; +} diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 5c8de7380a..9810165914 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -444,10 +444,13 @@ void main() { ); await tester.pumpAndSettle(); - final results = tester.widget( - find.byKey(const Key('search-results-list')), + final resultsPadding = tester.widget( + find.descendant( + of: find.byKey(const Key('search-results-list')), + matching: find.byType(SliverPadding), + ), ); - final padding = results.padding! as EdgeInsets; + final padding = resultsPadding.padding as EdgeInsets; expect(padding.bottom, Grid.xl + footerClearance + keyboardInset); }); From 819dd7924f1740b72b37a736393ae20509864e9e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 12:44:43 -0700 Subject: [PATCH 09/28] fix(mobile): bump Flutter 3.47, resolving memory leak (#8419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With accessibility semantics active, opening and leaving channels retained disposed composer overlay states through Flutter’s semantics traversal map. Those states kept departed route and message trees alive. This upgrades the pinned Flutter SDK from 3.41.7 to 3.47.7, which includes [the upstream fix](https://github.com/flutter/flutter/commit/3c96406cc4d79a256c0d504d62754242bb810c1d). Includes SDK compatibility and formatter changes, preserves CocoaPods integration, and gives create-channel radio lists a Material ink surface required by the newer framework. Widget regressions verify both the framework portal and the production channel/composer lifecycle: departed routes, composer elements, and portal states are collectible while their application’s semantics owner remains alive. Related issue: no matching open issue or PR found. ### Validation - [Current-head CI passed](https://github.com/block/buzz/actions/runs/39085320351): all mobile test shards, checks, Android APK, iOS release build, and native simulator tests. - Framework regression fails on Flutter 3.41.7 with three retained states and passes on 3.47.7. - Signed native Debug iOS Simulator build and paired-community navigation replay: after 18 channel visits and forced GC, retained disposed portal keys fell from 18 to 0 and route instances from 19 to 1. - Review follow-up: all 365 focused channel/overlay tests, analyzer, and complete mobile pre-push checks passed. - Full local CI passed before the test-only review follow-up, including Rust and desktop validation, frontend builds, analyzer checks, 3,499 mobile tests (four skipped), and the separate unconfigured-push test. The replay used iOS 26.5 Simulator with accessibility semantics enabled, CPU profiling disabled, and Debug/JIT builds. The before app was a preinstalled emoji candidate; the after app uses this branch. Object retention and the focused regression establish the fix; absolute heap differences are not a controlled release-memory measurement. Android native execution was not tested. ### Create-channel compatibility The radio groups retain their layout after switching their container to Material. | Before (3.41.7) | After (3.47.7) | | --- | --- | | Original create-channel radio groups | Updated create-channel radio groups | --------- Signed-off-by: Tom Brow --- CONTRIBUTING.md | 2 +- ...flutter-3.41.7.pkg => .flutter-3.47.7.pkg} | 0 bin/dart | 2 +- bin/flutter | 2 +- mobile/analysis_options.yaml | 2 + mobile/ios/Podfile.lock | 2 +- .../channels/channel_detail_page.dart | 4 +- .../activity_members_sheet.dart | 2 +- .../channels/channel_detail_page/banners.dart | 4 +- .../lib/features/channels/channels_page.dart | 1 + .../channels/channels_page/sheets.dart | 8 +- .../channels/channels_page/skeleton.dart | 2 +- .../features/channels/compose_bar/layout.dart | 2 +- .../pairing_page/pairing_welcome_view.dart | 2 +- mobile/lib/shared/emoji/emoji_only.dart | 2 + mobile/lib/shared/relay/media_image.dart | 2 +- .../security/sensitive_action_authorizer.dart | 2 +- .../shared/theme/community_theme_sync.dart | 16 +- mobile/pubspec.lock | 28 +-- mobile/pubspec.yaml | 4 + .../channels/channel_detail_page_test.dart | 121 +++++----- .../bubble_tests.dart | 210 +++++++++--------- .../retention_tests.dart | 69 ++++++ .../settings/connection_section_test.dart | 210 +++++++++--------- .../widgets/ios_media_carousel_test.dart | 128 ++++++----- .../widgets/ios_scroll_to_top_test.dart | 196 ++++++++-------- .../overlay_portal_retention_test.dart | 53 +++++ .../shared/widgets/page_indicator_test.dart | 82 ++++--- 28 files changed, 633 insertions(+), 525 deletions(-) rename bin/{.flutter-3.41.7.pkg => .flutter-3.47.7.pkg} (100%) create mode 100644 mobile/test/features/channels/channel_detail_page_test/retention_tests.dart create mode 100644 mobile/test/shared/widgets/overlay_portal_retention_test.dart diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c761fab9c1..2beb40bd5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,7 @@ We review as capacity allows — focused PRs that follow this guide move fastest | Rust | 1.88+ | Install via [rustup](https://rustup.rs/) | | Node.js | 24+ | Required for desktop app commands and `just ci` | | pnpm | 10+ | Required for desktop app commands and `just ci` | -| Flutter | 3.41+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) | +| Flutter | 3.47.7+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) | | Xcode | 27.0 recommended; 26 supported | Required for iOS builds; see [iOS toolchain setup](mobile/README.md#ios-toolchain) | | Docker | 24+ | For Postgres, Redis, MinIO | | `just` | latest | Task runner — `cargo install just` | diff --git a/bin/.flutter-3.41.7.pkg b/bin/.flutter-3.47.7.pkg similarity index 100% rename from bin/.flutter-3.41.7.pkg rename to bin/.flutter-3.47.7.pkg diff --git a/bin/dart b/bin/dart index 781b79bc07..28f286c9c6 120000 --- a/bin/dart +++ b/bin/dart @@ -1 +1 @@ -.flutter-3.41.7.pkg \ No newline at end of file +.flutter-3.47.7.pkg \ No newline at end of file diff --git a/bin/flutter b/bin/flutter index 781b79bc07..28f286c9c6 120000 --- a/bin/flutter +++ b/bin/flutter @@ -1 +1 @@ -.flutter-3.41.7.pkg \ No newline at end of file +.flutter-3.47.7.pkg \ No newline at end of file diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 37b3985d79..dfe6827218 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -4,6 +4,8 @@ analyzer: plugins: - custom_lint errors: + # riverpod_lint 3.1 uses custom_lint, which still requires legacy plugins. + analysis_options_deprecated_plugins: ignore invalid_use_of_visible_for_overriding_member: ignore exclude: - build/** diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 0384f7355d..310628f0e6 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -197,7 +197,7 @@ SPEC CHECKSUMS: camera_avfoundation: 968a9a5323c79a99c166ad9d7866bfd2047b5a9b connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 google_mlkit_commons: b4dfe2f6ae4cbf24010dc6640a28984d01343c86 google_mlkit_selfie_segmentation: 7fc41b3ef8ba238abf8dc76724da50e44de8aaf0 diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index b7e4251c7f..67bd1e0623 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -10,7 +10,7 @@ import 'dart:ui' show ImageFilter, lerpDouble; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' show ScrollDirection; +import 'package:flutter/rendering.dart' show ScrollCacheExtent, ScrollDirection; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -138,7 +138,7 @@ Future _preloadMembers( final members = await ref.read(channelMembersProvider(channelId).future); await notifier.preload(members.map((member) => member.pubkey).toList()); if (refreshDmParticipants) { - return notifier.refresh(participantPubkeys); + return await notifier.refresh(participantPubkeys); } return true; } catch (_) { diff --git a/mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart b/mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart index 69a38f128b..05eb305ee8 100644 --- a/mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart @@ -28,7 +28,7 @@ class _ActivityMembersSheet extends ConsumerWidget { child: ListView.separated( key: const ValueKey('activity-members-list'), shrinkWrap: true, - cacheExtent: 0, + scrollCacheExtent: const ScrollCacheExtent.pixels(0), padding: const EdgeInsets.symmetric(horizontal: Grid.xs), itemCount: pubkeys.length, separatorBuilder: (context, index) => Divider( diff --git a/mobile/lib/features/channels/channel_detail_page/banners.dart b/mobile/lib/features/channels/channel_detail_page/banners.dart index d1fdf3f37f..ff0bb0e2df 100644 --- a/mobile/lib/features/channels/channel_detail_page/banners.dart +++ b/mobile/lib/features/channels/channel_detail_page/banners.dart @@ -66,7 +66,7 @@ class _MessageTimelineSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.builder( - cacheExtent: 0, + scrollCacheExtent: const ScrollCacheExtent.pixels(0), reverse: true, physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.fromLTRB( @@ -177,7 +177,7 @@ class _ForumConnectionSkeleton extends StatelessWidget { child: IgnorePointer( child: ExcludeFocus( child: ListView.builder( - cacheExtent: 0, + scrollCacheExtent: const ScrollCacheExtent.pixels(0), padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), itemBuilder: (_, _) => Padding( diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index ff26cc9d13..a3642c8e20 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -9,6 +9,7 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show ScrollCacheExtent; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter/physics.dart'; diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index dcfa15d2eb..0072958c5b 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -292,10 +292,10 @@ class _CreateChannelRadioGroup extends StatelessWidget { children: [ _CreateChannelFieldLabel(label: label), const SizedBox(height: Grid.xxs), - DecoratedBox( - decoration: BoxDecoration( - color: context.colors.surface, - border: Border.all(color: context.colors.outlineVariant), + Material( + color: context.colors.surface, + shape: RoundedRectangleBorder( + side: BorderSide(color: context.colors.outlineVariant), borderRadius: BorderRadius.circular(Radii.container), ), child: RadioGroup( diff --git a/mobile/lib/features/channels/channels_page/skeleton.dart b/mobile/lib/features/channels/channels_page/skeleton.dart index 132d57886a..0f8d305aa7 100644 --- a/mobile/lib/features/channels/channels_page/skeleton.dart +++ b/mobile/lib/features/channels/channels_page/skeleton.dart @@ -34,7 +34,7 @@ class _ChannelsSkeleton extends StatelessWidget { if (widths.isEmpty) widths.addAll(fallbackWidths); return ListView.builder( - cacheExtent: 0, + scrollCacheExtent: const ScrollCacheExtent.pixels(0), physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.fromLTRB( Grid.gutter, diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index b51dad01b9..9017bc4ce1 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -263,7 +263,7 @@ class _ComposeBarLayout extends HookWidget { opacity: animation, child: SizeTransition( sizeFactor: animation, - axisAlignment: 1, + alignment: Alignment.bottomCenter, child: child, ), ), diff --git a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart index c404e3582d..0485049ef1 100644 --- a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart +++ b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart @@ -116,7 +116,7 @@ class _PairingWelcomeView extends StatelessWidget { transitionBuilder: (child, animation) { return SizeTransition( sizeFactor: animation, - axisAlignment: -1, + alignment: Alignment.topCenter, child: FadeTransition( opacity: animation, child: child, diff --git a/mobile/lib/shared/emoji/emoji_only.dart b/mobile/lib/shared/emoji/emoji_only.dart index 97a945b78f..60637e0a36 100644 --- a/mobile/lib/shared/emoji/emoji_only.dart +++ b/mobile/lib/shared/emoji/emoji_only.dart @@ -25,6 +25,8 @@ const double kEmojiOnlyCustomEmojiSize = kEmojiOnlyFontSize * 1.45; /// digit-based sequences don't match — those are covered by the dataset's own /// native set instead, exactly as desktop does it. final RegExp _pictographic = RegExp( + // The Dart VM supports this Unicode property; the analyzer parser does not. + // ignore: valid_regexps r'\p{Extended_Pictographic}', unicode: true, ); diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index 4b5aa5ccb8..d6d43e3ff4 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -132,7 +132,7 @@ class MediaImageProvider extends ImageProvider { } onBytesLoaded?.call(bytes); final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); - return decode(buffer); + return await decode(buffer); } catch (_) { // Match NetworkImage: make sure an errored key is not retained in the // cache. The cooldown map (not the cache) throttles retries. diff --git a/mobile/lib/shared/security/sensitive_action_authorizer.dart b/mobile/lib/shared/security/sensitive_action_authorizer.dart index b229f63330..cc3b96e6ad 100644 --- a/mobile/lib/shared/security/sensitive_action_authorizer.dart +++ b/mobile/lib/shared/security/sensitive_action_authorizer.dart @@ -44,7 +44,7 @@ class LocalSensitiveActionAuthorizer implements SensitiveActionAuthorizer { final availableBiometrics = await _authentication .getAvailableBiometrics(); if (availableBiometrics.isEmpty) return DeviceAuthResult.unavailable; - return _authorize( + return await _authorize( localizedReason: 'Enable biometrics for secure actions', biometricOnly: true, ); diff --git a/mobile/lib/shared/theme/community_theme_sync.dart b/mobile/lib/shared/theme/community_theme_sync.dart index fdbe5ae3a2..03a109a059 100644 --- a/mobile/lib/shared/theme/community_theme_sync.dart +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -131,15 +131,13 @@ class CommunityThemeSyncManager { if (_disposed) return false; final epoch = ++_subscriptionEpoch; try { - final unsubscribe = await relaySession.subscribe( - _themeFilter(limit: 0), - (event) { - if (_disposed || epoch != _subscriptionEpoch) return; - final remote = _decode(event); - if (remote != null) _accept(remote); - }, - onClosed: (message) => _handleSubscriptionClosed(epoch, message), - ); + final unsubscribe = await relaySession.subscribe(_themeFilter(limit: 0), ( + event, + ) { + if (_disposed || epoch != _subscriptionEpoch) return; + final remote = _decode(event); + if (remote != null) _accept(remote); + }, onClosed: (message) => _handleSubscriptionClosed(epoch, message)); if (_disposed || epoch != _subscriptionEpoch) { unsubscribe(); return false; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 12f1c7966d..7333019aad 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -825,7 +825,7 @@ packages: source: hosted version: "0.4.16" leak_tracker: - dependency: transitive + dependency: "direct dev" description: name: leak_tracker sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" @@ -908,10 +908,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -924,10 +924,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.19.0" mime: dependency: transitive description: @@ -1481,26 +1481,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.18" tuple: dependency: transitive description: @@ -1617,10 +1617,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" video_player: dependency: "direct main" description: @@ -1751,4 +1751,4 @@ packages: version: "2.2.4" sdks: dart: ">=3.11.4 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.47.7" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f96ee6a2bd..cf4ac1e9aa 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -5,6 +5,7 @@ version: 0.0.0+1 environment: sdk: ^3.11.4 + flutter: ">=3.47.7" dependencies: flutter: @@ -49,6 +50,7 @@ dependencies: local_auth: ^3.0.2 dev_dependencies: + leak_tracker: ^11.0.2 shared_preferences_platform_interface: ^2.4.2 flutter_test: sdk: flutter @@ -65,6 +67,8 @@ dev_dependencies: video_player_platform_interface: ^6.6.0 flutter: + config: + enable-swift-package-manager: false uses-material-design: true shaders: - shaders/onboarding_wordmark.frag diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index d9f7585e0c..dcd6fb89c7 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -31,6 +31,7 @@ import 'package:flutter/rendering.dart' SemanticsNode; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker/leak_tracker.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; @@ -98,6 +99,7 @@ part 'channel_detail_page_test/presence_tests.dart'; part 'channel_detail_page_test/action_row_tests.dart'; part 'channel_detail_page_test/bubble_tests.dart'; part 'channel_detail_page_test/review_avatar_tests.dart'; +part 'channel_detail_page_test/retention_tests.dart'; const _channelId = '11111111-2222-4333-8444-555555555555'; const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c'; @@ -567,6 +569,7 @@ void main() { actionRowTests(); bubbleLayoutTests(); reviewAvatarTests(); + channelRetentionTests(); setUp(() async { SharedPreferences.setMockInitialValues({}); _testPrefs = await SharedPreferences.getInstance(); @@ -4548,12 +4551,22 @@ void main() { }); for (final gesture in ['status bar', 'scroll']) { - testWidgets( - 'Me history uses selected provider for $gesture', - (tester) async { - final notifier = _FakeMessagesNotifier( + testWidgets('Me history uses selected provider for $gesture', ( + tester, + ) async { + final notifier = _FakeMessagesNotifier( + [ + for (var i = 50; i < 250; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ], + olderPages: [ [ - for (var i = 50; i < 250; i++) + for (var i = 0; i < 50; i++) _textMsg( id: 'msg$i', pubkey: 'alice', @@ -4561,64 +4574,52 @@ void main() { createdAt: 1000 + i, ), ], - olderPages: [ - [ - for (var i = 0; i < 50; i++) - _textMsg( - id: 'msg$i', - pubkey: 'alice', - content: 'Message $i', - createdAt: 1000 + i, - ), - ], - ], - ); - await tester.pumpWidget( - _buildTestable( - messages: const [], - messagesNotifier: notifier, - personalChat: true, - disableAnimations: true, + ], + ); + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: notifier, + personalChat: true, + disableAnimations: true, + ), + ); + await tester.pumpAndSettle(); + if (gesture == 'status bar') { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), ), + (_) {}, ); await tester.pumpAndSettle(); - if (gesture == 'status bar') { - await tester.binding.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/status_bar', - const JSONMethodCodec().encodeMethodCall( - const MethodCall('handleScrollToTop'), - ), - (_) {}, - ); - await tester.pumpAndSettle(); - } else { - final list = tester.widget( - find.byType(ScrollablePositionedList), - ); - list.itemScrollController!.jumpTo(index: 195); - await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), - const Offset(0, 350), - ); - await tester.pumpAndSettle(); - } - expect(notifier.fetchOlderCalls, greaterThan(0)); - if (gesture == 'status bar') { - expect(findRichText('Message 0'), findsOneWidget); - } else { - // Scroll pagination extends the visible provider, even if the user - // has not scrolled through every newly loaded row yet. - expect( - ProviderScope.containerOf( - tester.element(find.byType(ChannelDetailPage)), - ).read(meMessagesProvider(_channelId)).requireValue.first.id, - 'msg0', - ); - } - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + } else { + final list = tester.widget( + find.byType(ScrollablePositionedList), + ); + list.itemScrollController!.jumpTo(index: 195); + await tester.pumpAndSettle(); + await tester.drag( + find.byType(ScrollablePositionedList), + const Offset(0, 350), + ); + await tester.pumpAndSettle(); + } + expect(notifier.fetchOlderCalls, greaterThan(0)); + if (gesture == 'status bar') { + expect(findRichText('Message 0'), findsOneWidget); + } else { + // Scroll pagination extends the visible provider, even if the user + // has not scrolled through every newly loaded row yet. + expect( + ProviderScope.containerOf( + tester.element(find.byType(ChannelDetailPage)), + ).read(meMessagesProvider(_channelId)).requireValue.first.id, + 'msg0', + ); + } + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); } for (final personal in [false, true]) { diff --git a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart index 5af1a5d680..6741a0ce95 100644 --- a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart @@ -276,58 +276,56 @@ void bubbleLayoutTests() { expect(tester.takeException(), isNull); }); - testWidgets( - 'status bar pagination is capped and later taps continue', - (tester) async { - final notifier = _FakeMessagesNotifier( - [ - for (var index = 0; index < 30; index++) + testWidgets('status bar pagination is capped and later taps continue', ( + tester, + ) async { + final notifier = _FakeMessagesNotifier( + [ + for (var index = 0; index < 30; index++) + _textMsg( + id: 'recent-$index', + pubkey: 'alice', + content: 'Recent $index', + createdAt: 3000 + index, + ), + ], + olderPages: [ + for (var index = 1; index <= 10; index++) + [ _textMsg( - id: 'recent-$index', + id: 'page-$index', pubkey: 'alice', - content: 'Recent $index', - createdAt: 3000 + index, + content: 'Older page $index', + createdAt: 3000 - index * 100, ), - ], - olderPages: [ - for (var index = 1; index <= 10; index++) - [ - _textMsg( - id: 'page-$index', - pubkey: 'alice', - content: 'Older page $index', - createdAt: 3000 - index * 100, - ), - ], - ], - ); - await tester.pumpWidget( - _buildTestable(messages: const [], messagesNotifier: notifier), + ], + ], + ); + await tester.pumpWidget( + _buildTestable(messages: const [], messagesNotifier: notifier), + ); + await tester.pumpAndSettle(); + expect(notifier.fetchOlderCalls, 0); + for (final expected in [3, 6, 9, 10]) { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ), + (_) {}, ); await tester.pumpAndSettle(); - expect(notifier.fetchOlderCalls, 0); - for (final expected in [3, 6, 9, 10]) { - await tester.binding.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/status_bar', - const JSONMethodCodec().encodeMethodCall( - const MethodCall('handleScrollToTop'), - ), - (_) {}, - ); - await tester.pumpAndSettle(); - expect(notifier.fetchOlderCalls, expected); - expect(find.text('Older page $expected').hitTestable(), findsOneWidget); - await tester.pumpAndSettle(); - expect( - notifier.fetchOlderCalls, - expected, - reason: 'landing must not bypass the gesture cap', - ); - } - expect(notifier.reachedOldest, isTrue); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + expect(notifier.fetchOlderCalls, expected); + expect(find.text('Older page $expected').hitTestable(), findsOneWidget); + await tester.pumpAndSettle(); + expect( + notifier.fetchOlderCalls, + expected, + reason: 'landing must not bypass the gesture cap', + ); + } + expect(notifier.reachedOldest, isTrue); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); testWidgets( 'grouped removal quick reactions remove reactions on later events', @@ -642,68 +640,66 @@ void bubbleLayoutTests() { ); for (final thread in [false, true]) { - testWidgets( - 'status bar tap reaches oldest content, thread=$thread', - (tester) async { - final events = [ + testWidgets('status bar tap reaches oldest content, thread=$thread', ( + tester, + ) async { + final events = [ + _textMsg( + id: threadIdTopDashRoot, + pubkey: 'bob', + content: 'Oldest content', + createdAt: 900, + ), + for (var index = 0; index < 60; index++) _textMsg( - id: threadIdTopDashRoot, - pubkey: 'bob', - content: 'Oldest content', - createdAt: 900, + id: 'top-$index', + pubkey: 'alice', + content: 'Message $index', + createdAt: 1000 + index, + extraTags: thread + ? const [ + ['e', threadIdTopDashRoot, '', 'reply'], + ] + : const [], ), - for (var index = 0; index < 60; index++) - _textMsg( - id: 'top-$index', - pubkey: 'alice', - content: 'Message $index', - createdAt: 1000 + index, - extraTags: thread - ? const [ - ['e', threadIdTopDashRoot, '', 'reply'], - ] - : const [], - ), - ]; - final messages = formatTimeline(events); - await tester.pumpWidget( - _buildTestable( - messages: events, - home: thread - ? ThreadDetailPage( - threadHead: messages.firstWhere( - (m) => m.id == threadIdTopDashRoot, - ), - allMessages: messages, - channelId: _channelId, - currentPubkey: 'me', - isMember: true, - isArchived: false, - ) - : null, - ), - ); - await tester.pumpAndSettle(); - final list = tester.widget( - find.byKey( - ValueKey(thread ? 'thread-message-list' : 'channel-message-list'), - ), - ); - list.itemScrollController!.jumpTo(index: thread ? 55 : 0); - await tester.pumpAndSettle(); - expect(find.text('Oldest content').hitTestable(), findsNothing); - await tester.binding.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/status_bar', - const JSONMethodCodec().encodeMethodCall( - const MethodCall('handleScrollToTop'), - ), - (_) {}, - ); - await tester.pumpAndSettle(); - expect(find.text('Oldest content').hitTestable(), findsOneWidget); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + home: thread + ? ThreadDetailPage( + threadHead: messages.firstWhere( + (m) => m.id == threadIdTopDashRoot, + ), + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ) + : null, + ), + ); + await tester.pumpAndSettle(); + final list = tester.widget( + find.byKey( + ValueKey(thread ? 'thread-message-list' : 'channel-message-list'), + ), + ); + list.itemScrollController!.jumpTo(index: thread ? 55 : 0); + await tester.pumpAndSettle(); + expect(find.text('Oldest content').hitTestable(), findsNothing); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('Oldest content').hitTestable(), findsOneWidget); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); } testWidgets('newest short message keeps the same group gap', (tester) async { diff --git a/mobile/test/features/channels/channel_detail_page_test/retention_tests.dart b/mobile/test/features/channels/channel_detail_page_test/retention_tests.dart new file mode 100644 index 0000000000..b89eadbff1 --- /dev/null +++ b/mobile/test/features/channels/channel_detail_page_test/retention_tests.dart @@ -0,0 +1,69 @@ +part of '../channel_detail_page_test.dart'; + +void channelRetentionTests() { + testWidgets('departed channel composer and route are collectible', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg(id: 'message', pubkey: 'alice', content: 'Hello'), + ], + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: _testChannel), + ), + ), + child: const Text('Open channel'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final retired = >[]; + for (var visit = 0; visit < 3; visit++) { + retired.addAll(await _visitAndLeaveChannel(tester)); + } + // Keep the same app, navigator and semantics owner alive during GC. + await tester.runAsync(() => forceGC(fullGcCycles: 3)); + expect( + retired.where((reference) => reference.target != null), + isEmpty, + reason: 'Departed routes, composers and their portals must be released', + ); + } finally { + semantics.dispose(); + } + }); +} + +Future>> _visitAndLeaveChannel( + WidgetTester tester, +) async { + await tester.tap(find.text('Open channel')); + await tester.pumpAndSettle(); + expect(find.text('Message #general'), findsOneWidget); + final composer = find.byType(ComposeBar); + final portal = find.descendant( + of: composer, + matching: find.byType(OverlayPortal), + ); + expect(portal, findsWidgets); + final retired = >[ + for (final state in tester.stateList(portal)) WeakReference(state), + WeakReference(tester.element(composer)), + WeakReference(ModalRoute.of(tester.element(composer))!), + ]; + Navigator.of(tester.element(composer)).pop(); + await tester.pumpAndSettle(); + expect(find.byType(ChannelDetailPage), findsNothing); + expect(find.text('Open channel'), findsOneWidget); + return retired; +} diff --git a/mobile/test/features/settings/connection_section_test.dart b/mobile/test/features/settings/connection_section_test.dart index b94bcfc465..4d0f518efd 100644 --- a/mobile/test/features/settings/connection_section_test.dart +++ b/mobile/test/features/settings/connection_section_test.dart @@ -16,122 +16,118 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/widget_helpers.dart'; void main() { - testWidgets( - 'shows a compact copyable identity row', - (tester) async { - var successFeedback = false; + testWidgets('shows a compact copyable identity row', (tester) async { + var successFeedback = false; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/haptics'), + (call) async { + expect(call.method, 'success'); + successFeedback = true; + return null; + }, + ); + addTearDown(() { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('buzz/haptics'), - (call) async { - expect(call.method, 'success'); - successFeedback = true; + null, + ); + }); + MethodCall? clipboardCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') clipboardCall = call; return null; - }, - ); - addTearDown(() { - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - const MethodChannel('buzz/haptics'), - null, - ); - }); - MethodCall? clipboardCall; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (call) async { - if (call.method == 'Clipboard.setData') clipboardCall = call; - return null; - }); - addTearDown( - () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null), - ); - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - relayConfigProvider.overrideWith(_RelayConfigNotifier.new), - authProvider.overrideWith(_AuthNotifier.new), - pairingProvider.overrideWith( - () => _PairingNotifier(Future.value(true)), - ), - savedPrefsProvider.overrideWithValue(prefs), - ], - child: SettingsPage( - profileHeader: const SizedBox.shrink(), - identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + relayConfigProvider.overrideWith(_RelayConfigNotifier.new), + authProvider.overrideWith(_AuthNotifier.new), + pairingProvider.overrideWith( + () => _PairingNotifier(Future.value(true)), ), + savedPrefsProvider.overrideWithValue(prefs), + ], + child: SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), - ); - await tester.pump(); - await tester.ensureVisible(find.text('Copy public key (npub)')); - await tester.pumpAndSettle(); + ), + ); + await tester.pump(); + await tester.ensureVisible(find.text('Copy public key (npub)')); + await tester.pumpAndSettle(); - final expectedPubkey = nostr.Keys( - '1111111111111111111111111111111111111111111111111111111111111111', - ).public; - // Keys('1'×64).public ↔ this npub — the NIP-19 canonical vector for the - // identity row, hardcoded so the codec itself stays under test. - const expectedNpub = - 'npub1fu64hh9hes90w2808n8tjc2ajp5yhddjef0ctx4s7zmsgp6cwx4qgy4eg9'; - final statusCard = find.byKey(const ValueKey('status-identity-options')); - expect( - find.descendant(of: statusCard, matching: find.text('Set status')), - findsOneWidget, - ); - expect( - find.descendant( - of: statusCard, - matching: find.text('Copy public key (npub)'), + final expectedPubkey = nostr.Keys( + '1111111111111111111111111111111111111111111111111111111111111111', + ).public; + // Keys('1'×64).public ↔ this npub — the NIP-19 canonical vector for the + // identity row, hardcoded so the codec itself stays under test. + const expectedNpub = + 'npub1fu64hh9hes90w2808n8tjc2ajp5yhddjef0ctx4s7zmsgp6cwx4qgy4eg9'; + final statusCard = find.byKey(const ValueKey('status-identity-options')); + expect( + find.descendant(of: statusCard, matching: find.text('Set status')), + findsOneWidget, + ); + expect( + find.descendant( + of: statusCard, + matching: find.text('Copy public key (npub)'), + ), + findsOneWidget, + ); + expect( + tester.getTopLeft(find.text('Copy public key (npub)')).dy, + lessThan(tester.getTopLeft(find.text('Display name')).dy), + ); + expect(find.text('Connection'), findsNothing); + final sendCard = find.ancestor( + of: find.text('Send identity to desktop'), + matching: find.byType(AppListCard), + ); + expect( + find.descendant( + of: sendCard, + matching: find.text('Copy public key (npub)'), + ), + findsNothing, + ); + expect(find.text('Connected to'), findsNothing); + expect(find.text('https://relay.test'), findsNothing); + // Neither the raw hex key nor the full npub is rendered visually — the + // full npub is exposed through a11y and the clipboard only. + expect(find.text(expectedPubkey), findsNothing); + expect(find.text(expectedNpub), findsNothing); + // The identity row's a11y value carries the full npub (not raw hex). + expect( + find.ancestor( + of: find.text('Copy public key (npub)'), + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.value == expectedNpub, ), - findsOneWidget, - ); - expect( - tester.getTopLeft(find.text('Copy public key (npub)')).dy, - lessThan(tester.getTopLeft(find.text('Display name')).dy), - ); - expect(find.text('Connection'), findsNothing); - final sendCard = find.ancestor( - of: find.text('Send identity to desktop'), - matching: find.byType(AppListCard), - ); - expect( - find.descendant( - of: sendCard, - matching: find.text('Copy public key (npub)'), - ), - findsNothing, - ); - expect(find.text('Connected to'), findsNothing); - expect(find.text('https://relay.test'), findsNothing); - // Neither the raw hex key nor the full npub is rendered visually — the - // full npub is exposed through a11y and the clipboard only. - expect(find.text(expectedPubkey), findsNothing); - expect(find.text(expectedNpub), findsNothing); - // The identity row's a11y value carries the full npub (not raw hex). - expect( - find.ancestor( - of: find.text('Copy public key (npub)'), - matching: find.byWidgetPredicate( - (widget) => - widget is Semantics && widget.properties.value == expectedNpub, - ), - ), - findsOneWidget, - ); - final copy = tester.getRect(find.byIcon(BuzzIcons.copy)); - final chevron = tester.getRect(find.byIcon(BuzzIcons.chevronRight).first); - expect(copy.center.dx, closeTo(chevron.center.dx, 0.5)); + ), + findsOneWidget, + ); + final copy = tester.getRect(find.byIcon(BuzzIcons.copy)); + final chevron = tester.getRect(find.byIcon(BuzzIcons.chevronRight).first); + expect(copy.center.dx, closeTo(chevron.center.dx, 0.5)); - await tester.tap(find.text('Copy public key (npub)')); - await tester.pump(); - expect(successFeedback, isTrue); - expect(clipboardCall?.method, 'Clipboard.setData'); - expect(clipboardCall?.arguments, {'text': expectedNpub}); - expect(find.text('Public key (npub) copied'), findsOneWidget); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + await tester.tap(find.text('Copy public key (npub)')); + await tester.pump(); + expect(successFeedback, isTrue); + expect(clipboardCall?.method, 'Clipboard.setData'); + expect(clipboardCall?.arguments, {'text': expectedNpub}); + expect(find.text('Public key (npub) copied'), findsOneWidget); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); testWidgets('waits for a resumed frame before navigating after auth', ( tester, diff --git a/mobile/test/shared/widgets/ios_media_carousel_test.dart b/mobile/test/shared/widgets/ios_media_carousel_test.dart index 6e5a2fdabb..9990c0c3b9 100644 --- a/mobile/test/shared/widgets/ios_media_carousel_test.dart +++ b/mobile/test/shared/widgets/ios_media_carousel_test.dart @@ -80,74 +80,70 @@ void main() { ); } - testWidgets( - 'native callbacks and disposal release subscribed images', - (tester) async { - final provider = _TestImage(); - final harness = await _mount(tester, [provider]); - await harness.send('open', 0); - await harness.send('open', 2); - await harness.send('page', 0); - await harness.send('scroll', 21.5); - expect(harness.opened, [0]); - expect(harness.pages, [0]); - expect(harness.offsets, [21.5]); - await harness.send('subscribeImage', {'id': 'a', 'index': 0}); - expect(provider.completer.listeners, hasLength(1)); - await tester.pumpWidget(const SizedBox()); - expect(provider.completer.listeners, isEmpty); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + testWidgets('native callbacks and disposal release subscribed images', ( + tester, + ) async { + final provider = _TestImage(); + final harness = await _mount(tester, [provider]); + await harness.send('open', 0); + await harness.send('open', 2); + await harness.send('page', 0); + await harness.send('scroll', 21.5); + expect(harness.opened, [0]); + expect(harness.pages, [0]); + expect(harness.offsets, [21.5]); + await harness.send('subscribeImage', {'id': 'a', 'index': 0}); + expect(provider.completer.listeners, hasLength(1)); + await tester.pumpWidget(const SizedBox()); + expect(provider.completer.listeners, isEmpty); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); - testWidgets( - 'multi-image native gallery forwards continuing animation frames', - (tester) async { - final animated = _TestImage(); - final still = _TestImage(); - final harness = await _mount(tester, [animated, still]); - await harness.send('subscribeImage', {'id': 'animated', 'index': 0}); - await harness.send('subscribeImage', {'id': 'still', 'index': 1}); - final decoded = await tester.runAsync(() async { - // Two 1x1 GIF frames with different palette indices: red, then blue. - final codec = await ui.instantiateImageCodec( - base64Decode( - 'R0lGODlhAQABAIAAAP8AAAAA/yH5BAAKAAAALAAAAAABAAEAAAICRAEAIfkEAAoAAAAsAAAAAAEAAQAAAgJMAQA7', - ), - ); - try { - expect(codec.frameCount, 2); - return [ - (await codec.getNextFrame()).image, - (await codec.getNextFrame()).image, - ]; - } finally { - codec.dispose(); - } - }); - await tester.runAsync(() async { - animated.completer.emit(decoded![0]); - await harness.waitForFrames(1); - animated.completer.emit(decoded[1]); - await harness.waitForFrames(2); - }); - expect(harness.frames.map((f) => f['id']), ['animated', 'animated']); - expect( - harness.frames[0]['bytes'], - isNot(orderedEquals(harness.frames[1]['bytes'] as Uint8List)), + testWidgets('multi-image native gallery forwards continuing animation frames', ( + tester, + ) async { + final animated = _TestImage(); + final still = _TestImage(); + final harness = await _mount(tester, [animated, still]); + await harness.send('subscribeImage', {'id': 'animated', 'index': 0}); + await harness.send('subscribeImage', {'id': 'still', 'index': 1}); + final decoded = await tester.runAsync(() async { + // Two 1x1 GIF frames with different palette indices: red, then blue. + final codec = await ui.instantiateImageCodec( + base64Decode( + 'R0lGODlhAQABAIAAAP8AAAAA/yH5BAAKAAAALAAAAAABAAEAAAICRAEAIfkEAAoAAAAsAAAAAAEAAQAAAgJMAQA7', + ), ); - await harness.send('cancelImage', 'animated'); - expect(animated.completer.listeners, isEmpty); - final count = harness.frames.length; - final green = await tester.runAsync(() => _frame(Colors.green)); - animated.completer.emit(green!); - await tester.pump(); - expect(harness.frames, hasLength(count)); - await tester.pumpWidget(const SizedBox()); - expect(still.completer.listeners, isEmpty); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + try { + expect(codec.frameCount, 2); + return [ + (await codec.getNextFrame()).image, + (await codec.getNextFrame()).image, + ]; + } finally { + codec.dispose(); + } + }); + await tester.runAsync(() async { + animated.completer.emit(decoded![0]); + await harness.waitForFrames(1); + animated.completer.emit(decoded[1]); + await harness.waitForFrames(2); + }); + expect(harness.frames.map((f) => f['id']), ['animated', 'animated']); + expect( + harness.frames[0]['bytes'], + isNot(orderedEquals(harness.frames[1]['bytes'] as Uint8List)), + ); + await harness.send('cancelImage', 'animated'); + expect(animated.completer.listeners, isEmpty); + final count = harness.frames.length; + final green = await tester.runAsync(() => _frame(Colors.green)); + animated.completer.emit(green!); + await tester.pump(); + expect(harness.frames, hasLength(count)); + await tester.pumpWidget(const SizedBox()); + expect(still.completer.listeners, isEmpty); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); testWidgets( 'rapid reuse bounds unresolved loads and drops cancelled queued requests', diff --git a/mobile/test/shared/widgets/ios_scroll_to_top_test.dart b/mobile/test/shared/widgets/ios_scroll_to_top_test.dart index 52c141deb2..413c5139af 100644 --- a/mobile/test/shared/widgets/ios_scroll_to_top_test.dart +++ b/mobile/test/shared/widgets/ios_scroll_to_top_test.dart @@ -50,115 +50,107 @@ void main() { ); } - testWidgets( - 'status tap does not scroll a page under a modal', - (tester) async { - final controller = ScrollController(); - addTearDown(controller.dispose); - late BuildContext pageContext; - await tester.pumpWidget( - _testApp( - home: FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Test')), - body: Builder( - builder: (context) { - pageContext = context; - return ListView.builder( - controller: controller, - itemCount: 100, - itemBuilder: (_, index) => - SizedBox(height: 60, child: Text('Row $index')), - ); - }, - ), + testWidgets('status tap does not scroll a page under a modal', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + late BuildContext pageContext; + await tester.pumpWidget( + _testApp( + home: FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Test')), + body: Builder( + builder: (context) { + pageContext = context; + return ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (_, index) => + SizedBox(height: 60, child: Text('Row $index')), + ); + }, ), ), - ); - await tester.pumpAndSettle(); - controller.jumpTo(900); - await tester.pumpAndSettle(); - showModalBottomSheet( - context: pageContext, - builder: (_) => const SizedBox(height: 200), - ); - await tester.pumpAndSettle(); - await tapStatusBar(tester); - expect(controller.offset, 900); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); - testWidgets( - 'status tap expands a pinned title and returns its list to top', - (tester) async { - await tester.pumpWidget( - _testApp( - home: FrostedScaffold( - nativePinnedBody: true, - appBar: const FrostedAppBar( - title: Text('Title'), - nativeLargeTitle: true, - ), - body: ListView.builder( - itemCount: 100, - itemBuilder: (_, index) => - SizedBox(height: 60, child: Text('Pinned $index')), - ), + ), + ); + await tester.pumpAndSettle(); + controller.jumpTo(900); + await tester.pumpAndSettle(); + showModalBottomSheet( + context: pageContext, + builder: (_) => const SizedBox(height: 200), + ); + await tester.pumpAndSettle(); + await tapStatusBar(tester); + expect(controller.offset, 900); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + testWidgets('status tap expands a pinned title and returns its list to top', ( + tester, + ) async { + await tester.pumpWidget( + _testApp( + home: FrostedScaffold( + nativePinnedBody: true, + appBar: const FrostedAppBar( + title: Text('Title'), + nativeLargeTitle: true, + ), + body: ListView.builder( + itemCount: 100, + itemBuilder: (_, index) => + SizedBox(height: 60, child: Text('Pinned $index')), ), ), - ); - await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -900)); - await tester.pumpAndSettle(); - final nested = tester.state( - find.byType(NestedScrollView), - ); - expect(nested.innerController.offset, greaterThan(0)); - await tapStatusBar(tester); - expect(nested.innerController.offset, 0); - expect(nested.outerController.offset, 0); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + ), + ); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -900)); + await tester.pumpAndSettle(); + final nested = tester.state( + find.byType(NestedScrollView), + ); + expect(nested.innerController.offset, greaterThan(0)); + await tapStatusBar(tester); + expect(nested.innerController.offset, 0); + expect(nested.outerController.offset, 0); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); - testWidgets( - 'status tap leaves an inactive tab alone', - (tester) async { - final controllers = [ScrollController(), ScrollController()]; - addTearDown(() { - for (final controller in controllers) { - controller.dispose(); - } - }); - await tester.pumpWidget( - _testApp( - home: IndexedStack( - index: 1, - children: [ - for (var index = 0; index < 2; index++) - FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Tab')), - body: ListView.builder( - controller: controllers[index], - itemCount: 100, - itemBuilder: (_, row) => - SizedBox(height: 60, child: Text('Row $row')), - ), - ), - ], - ), - ), - ); - await tester.pumpAndSettle(); + testWidgets('status tap leaves an inactive tab alone', (tester) async { + final controllers = [ScrollController(), ScrollController()]; + addTearDown(() { for (final controller in controllers) { - controller.jumpTo(900); + controller.dispose(); } - await tester.pumpAndSettle(); - await tapStatusBar(tester); - expect(controllers[0].offset, 900); - expect(controllers[1].offset, 0); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + }); + await tester.pumpWidget( + _testApp( + home: IndexedStack( + index: 1, + children: [ + for (var index = 0; index < 2; index++) + FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Tab')), + body: ListView.builder( + controller: controllers[index], + itemCount: 100, + itemBuilder: (_, row) => + SizedBox(height: 60, child: Text('Row $row')), + ), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + for (final controller in controllers) { + controller.jumpTo(900); + } + await tester.pumpAndSettle(); + await tapStatusBar(tester); + expect(controllers[0].offset, 900); + expect(controllers[1].offset, 0); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); } Widget _testApp({Widget? home, TransitionBuilder? builder}) => ProviderScope( diff --git a/mobile/test/shared/widgets/overlay_portal_retention_test.dart b/mobile/test/shared/widgets/overlay_portal_retention_test.dart new file mode 100644 index 0000000000..990121ec23 --- /dev/null +++ b/mobile/test/shared/widgets/overlay_portal_retention_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker/leak_tracker.dart'; + +void main() { + testWidgets('disposed composer-style overlay portals are collectible', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + // Keep the application's semantics owner alive while each route leaves. + // Disposing the entire app would hide retention in its traversal maps. + final retired = >>[]; + for (var visit = 0; visit < 3; visit++) { + retired.add(await _visitOverlay(tester)); + } + + await tester.runAsync(() => forceGC(fullGcCycles: 3)); + expect( + retired.where((reference) => reference.target != null), + isEmpty, + reason: + 'A closed route must not survive through semantics traversal keys', + ); + } finally { + semantics.dispose(); + } + }); +} + +Future>> _visitOverlay( + WidgetTester tester, +) async { + final controller = OverlayPortalController()..show(); + await tester.pumpWidget( + MaterialApp( + home: OverlayPortal.overlayChildLayoutBuilder( + controller: controller, + overlayChildBuilder: (_, _) => const Positioned( + left: 0, + top: 0, + child: Text('Composer suggestions'), + ), + child: const Text('Composer'), + ), + ), + ); + await tester.pumpAndSettle(); + final retired = WeakReference(tester.state(find.byType(OverlayPortal))); + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + await tester.pumpAndSettle(); + return retired; +} diff --git a/mobile/test/shared/widgets/page_indicator_test.dart b/mobile/test/shared/widgets/page_indicator_test.dart index f0e42f3c20..254ff8af9a 100644 --- a/mobile/test/shared/widgets/page_indicator_test.dart +++ b/mobile/test/shared/widgets/page_indicator_test.dart @@ -8,50 +8,48 @@ import 'package:flutter_test/flutter_test.dart'; import '../../helpers/widget_helpers.dart'; void main() { - testWidgets( - 'iOS receives initial and updated layout direction', - (tester) async { - final updates = >[]; - const channel = MethodChannel('buzz/theme_pagination_glass/54321'); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, - ) async { - if (call.method == 'setState') { - updates.add(call.arguments as Map); - } - return null; - }); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ), - ); - Future pump(TextDirection direction) => tester.pumpWidget( - WidgetHelpers.testable( - child: Directionality( - textDirection: direction, - child: PageIndicator( - semanticLabel: 'Photo', - count: 20, - selected: 10, - animateChanges: false, - onSelected: (_) {}, - ), + testWidgets('iOS receives initial and updated layout direction', ( + tester, + ) async { + final updates = >[]; + const channel = MethodChannel('buzz/theme_pagination_glass/54321'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method == 'setState') { + updates.add(call.arguments as Map); + } + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + Future pump(TextDirection direction) => tester.pumpWidget( + WidgetHelpers.testable( + child: Directionality( + textDirection: direction, + child: PageIndicator( + semanticLabel: 'Photo', + count: 20, + selected: 10, + animateChanges: false, + onSelected: (_) {}, ), ), - ); - await pump(TextDirection.rtl); - final native = tester.widget(find.byType(UiKitView)); - expect((native.creationParams as Map)['isRTL'], isTrue); - native.onPlatformViewCreated!(54321); - await tester.pump(); - expect(updates.last['isRTL'], isTrue); - await pump(TextDirection.ltr); - expect(updates.last['isRTL'], isFalse); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + ), + ); + await pump(TextDirection.rtl); + final native = tester.widget(find.byType(UiKitView)); + expect((native.creationParams as Map)['isRTL'], isTrue); + native.onPlatformViewCreated!(54321); + await tester.pump(); + expect(updates.last['isRTL'], isTrue); + await pump(TextDirection.ltr); + expect(updates.last['isRTL'], isFalse); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); testWidgets( 'pagination has a single platform semantics owner', From 7c1a8967e0d2e12fb8b2ec3ae9783869b4a8be9f Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 18 Oct 2026 16:16:52 -0400 Subject: [PATCH 10/28] chore(deny): check buzz-mesh-smoke in the root graph again (#8424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Removes the whole-crate `[graph] exclude = ["buzz-mesh-smoke"]` from `deny.toml`, so the root `cargo-deny` check covers `buzz-mesh-smoke` and its `mesh-llm` dependency again. The advisories that crate brings in are ignored one ID at a time instead, each naming its dependency path and when to remove it: - `RUSTSEC-2026-0367` / `0368` / `0369` — `zip` 8.6.0 extraction bugs via `buzz-mesh-smoke` → `mesh-llm` v0.76.2. `mesh-llm` copies entries manually and never calls `ZipArchive::extract`. Remove on the `mesh-llm` release that ships Mesh-LLM/mesh-llm#2471. - `RUSTSEC-2026-0370` / `0371` / `0372` — unmaintained (not vulnerable) `zvariant`, `zbus_names`, `zvariant_derive` via `mesh-llm` → `keyring` 4 → `secret-service` 5 → `zbus` 5. Remove when that stack leaves `zbus` 5. Any new advisory under `mesh-llm` now fails CI instead of being silently excluded. No workflow or lockfile changes; the old beta desktop crate in `desktop/` is not checked. Signed-off-by: Will Pfleger Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- deny.toml | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/deny.toml b/deny.toml index 2362c3f7c0..6ef1126096 100644 --- a/deny.toml +++ b/deny.toml @@ -1,9 +1,3 @@ -[graph] -# buzz-mesh-smoke is a test-only hardware smoke-test crate; nothing from it ships. -# It is the only root-workspace crate depending on mesh-llm, whose zip/zbus -# advisories have no usable upstream fix for our pinned version. -exclude = ["buzz-mesh-smoke"] - [advisories] ignore = [ # instant 0.1.13 — unmaintained crate. Transitive dep: nostr → instant. @@ -32,6 +26,23 @@ ignore = [ # Keep the exact NetEQ pin and the wrapper ban below together. Re-audit or # remove this exception when NetEQ removes/updates the unused dependency. { id = "RUSTSEC-2026-0293", reason = "unused dependency of audited, exactly pinned neteq 0.8.3; no Rust references to ringbuf; other consumers forbidden by bans.deny" }, + # zip 8.6.0 < 9: extraction keeps setuid/setgid bits (0367), creates + # symlinks that escape the destination (0368) and applies permissions through + # symlinks (0369). Only path: buzz-mesh-smoke → mesh-llm v0.76.2 → + # mesh-llm-host-runtime / mesh-llm-plugin-manager / mesh-llm-system → zip 8. + # mesh-llm's zip readers copy entries manually and never call + # ZipArchive::extract; recheck if that changes. Remove on the mesh-llm bump + # that ships Mesh-LLM/mesh-llm#2471. + { id = "RUSTSEC-2026-0367", reason = "zip 8.6.0 via buzz-mesh-smoke → mesh-llm v0.76.2; manual entry copy, never extracts; fix in unreleased mesh-llm#2471" }, + { id = "RUSTSEC-2026-0368", reason = "zip 8.6.0 via buzz-mesh-smoke → mesh-llm v0.76.2; manual entry copy, never extracts; fix in unreleased mesh-llm#2471" }, + { id = "RUSTSEC-2026-0369", reason = "zip 8.6.0 via buzz-mesh-smoke → mesh-llm v0.76.2; manual entry copy, never extracts; fix in unreleased mesh-llm#2471" }, + # zbus 5 D-Bus crates (zvariant, zbus_names, zvariant_derive) — unmaintained, + # not vulnerable. Only path: buzz-mesh-smoke → mesh-llm → keyring 4 → + # zbus-secret-service-keyring-store / secret-service 5 → zbus 5. + # Remove when mesh-llm's keyring stack moves off zbus 5. + { id = "RUSTSEC-2026-0370", reason = "zvariant via buzz-mesh-smoke → mesh-llm → keyring 4 → secret-service 5 → zbus 5; unmaintained, not vulnerable" }, + { id = "RUSTSEC-2026-0371", reason = "zbus_names via buzz-mesh-smoke → mesh-llm → keyring 4 → secret-service 5 → zbus 5; unmaintained, not vulnerable" }, + { id = "RUSTSEC-2026-0372", reason = "zvariant_derive via buzz-mesh-smoke → mesh-llm → keyring 4 → secret-service 5 → zbus 5; unmaintained, not vulnerable" }, ] [licenses] From bbb5183f33df3c6a5f7c587cc41233bc555cd15c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 18 Oct 2026 18:19:21 -0400 Subject: [PATCH 11/28] feat(mobile): add swipe-to-reply message gesture (#8436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Swipe right on channel and thread messages to open the existing reply destination. Adds a centred accent progress ring, Reply label, threshold haptic/pulse, drag resistance, cancellation, and reduced-motion support. Retreating below readiness re-arms feedback. Includes a separate formatting-only fix required by main’s updated Dart formatter. ### Related issue Related message styling: #8281. No matching swipe-to-reply issue found. ### Testing - Full pre-push checks passed, including mobile analysis/tests and file-size checks. - Widget coverage: routing, repeated threshold crossings, cancellation, resistance, alignment, and centred scaling. - Release preview installed on iPhone with the existing profile/community; Kenny tested and confirmed the interaction. Draft: native preview verification predates the rebase onto main’s Flutter upgrade. No native gesture recording attached. --------- Signed-off-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- .../channels/channel_detail_page.dart | 1 + .../channel_detail_page/message_bubble.dart | 14 +- .../channels/message_swipe_reply.dart | 234 ++++++++++++++++++ .../features/channels/thread_detail_page.dart | 1 + .../thread_detail_page/thread_message.dart | 14 +- .../channels/channel_detail_page_test.dart | 2 + .../swipe_reply_tests.dart | 226 +++++++++++++++++ .../message_carousel_preview_cases.dart | 126 +++++----- 8 files changed, 552 insertions(+), 66 deletions(-) create mode 100644 mobile/lib/features/channels/message_swipe_reply.dart create mode 100644 mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 67bd1e0623..6d3a2487d7 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,3 +1,4 @@ +import 'message_swipe_reply.dart'; import 'agent_reply_handoff.dart'; import 'huddle_timeline.dart'; import '../../shared/widgets/modal_presentation.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 6e4c812de4..13d525ef2d 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -40,6 +40,7 @@ class _MessageBubble extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final messageSnapshotKey = useMemoized(GlobalKey.new, const []); + final swipeContentKey = useMemoized(GlobalKey.new, const []); final mediaOffset = useMessageMediaOffset(message.content, message.tags); final hasLocalReplies = ref.watch( threadLocalRepliesProvider( @@ -132,7 +133,7 @@ class _MessageBubble extends HookConsumerWidget { ); } - return NotificationListener( + final content = NotificationListener( onNotification: (notification) { mediaOffset.value = notification.offset; return true; @@ -342,6 +343,7 @@ class _MessageBubble extends HookConsumerWidget { ), animate: typingHandoff, child: MessageContent( + key: swipeContentKey, content: message.content, bubble: true, hasReactions: @@ -468,6 +470,16 @@ class _MessageBubble extends HookConsumerWidget { ), ), ); + return MessageSwipeReply( + contentKey: swipeContentKey, + message: message, + allMessages: allMessages, + channelId: currentChannelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + child: content, + ); } } diff --git a/mobile/lib/features/channels/message_swipe_reply.dart b/mobile/lib/features/channels/message_swipe_reply.dart new file mode 100644 index 0000000000..e4448dfbe1 --- /dev/null +++ b/mobile/lib/features/channels/message_swipe_reply.dart @@ -0,0 +1,234 @@ +import 'dart:async'; +import 'dart:math' show sin, pi; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/theme/buzz_icons.dart'; +import '../../shared/theme/theme.dart'; +import 'thread_detail_page.dart'; +import 'timeline_message.dart'; + +/// Reveals the existing reply action as a message is dragged to the right. +class MessageSwipeReply extends HookConsumerWidget { + final TimelineMessage message; + final List? allMessages; + final String channelId; + final String? currentPubkey; + final bool isMember; + final bool isArchived; + final Widget child; + + /// The visible content, excluding author metadata and reply/reaction footers. + final GlobalKey contentKey; + + const MessageSwipeReply({ + super.key, + required this.message, + required this.allMessages, + required this.channelId, + required this.currentPubkey, + required this.isMember, + required this.isArchived, + required this.child, + required this.contentKey, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final offset = useAnimationController(upperBound: double.infinity); + final pulse = useAnimationController( + duration: const Duration(milliseconds: 180), + ); + final dragDistance = useRef(0.0); + final layoutKey = useMemoized(GlobalKey.new); + final contentCenter = useState(null); + final didHaptic = useRef(false); + final cancelled = useRef(false); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final messages = allMessages; + if (messages == null || message.isSystem) return child; + + void reset() { + pulse.stop(); + pulse.value = 0; + if (reducedMotion) { + offset.value = 0; + } else { + unawaited( + offset.animateWith( + ScrollSpringSimulation( + const SpringDescription(mass: 1, stiffness: 500, damping: 36), + offset.value, + 0, + 0, + ), + ), + ); + } + } + + // Flutter ends an accepted drag on PointerCancel too. Preserve the raw + // cancellation signal so an interrupted gesture cannot open a reply. + return Listener( + onPointerCancel: (_) => cancelled.value = true, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + excludeFromSemantics: true, + onHorizontalDragStart: (_) { + final contentBox = contentKey.currentContext?.findRenderObject(); + final layoutBox = layoutKey.currentContext?.findRenderObject(); + if (contentBox is RenderBox && layoutBox is RenderBox) { + contentCenter.value = layoutBox + .globalToLocal( + contentBox.localToGlobal(contentBox.size.center(Offset.zero)), + ) + .dy; + } + offset.stop(); + pulse.stop(); + pulse.value = 0; + offset.value = 0; + dragDistance.value = 0; + didHaptic.value = false; + cancelled.value = false; + }, + onHorizontalDragUpdate: (details) { + dragDistance.value = (dragDistance.value + details.delta.dx).clamp( + 0.0, + double.infinity, + ); + // Keep pulling beyond readiness, with increasing rubber-band resistance. + final excess = dragDistance.value - 72; + offset.value = excess <= 0 + ? dragDistance.value + : 72 + 40 * excess / (excess + 80); + if (offset.value >= 72 && !didHaptic.value) { + didHaptic.value = true; + unawaited(HapticFeedback.mediumImpact()); + if (!reducedMotion) unawaited(pulse.forward(from: 0)); + } + if (offset.value < 72) { + didHaptic.value = false; + pulse.stop(); + pulse.value = 0; + } + }, + onHorizontalDragCancel: reset, + onHorizontalDragEnd: (_) { + final reply = !cancelled.value && offset.value >= 72; + reset(); + if (!reply) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: messages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + child: AnimatedBuilder( + animation: Listenable.merge([offset, pulse]), + child: child, + builder: (context, child) { + final progress = (offset.value / 72).clamp(0.0, 1.0); + final accent = context.appColors.accent; + final filledForeground = + ThemeData.estimateBrightnessForColor(accent) == Brightness.dark + ? Colors.white + : Colors.black; + return Stack( + key: layoutKey, + clipBehavior: Clip.none, + alignment: Alignment.centerLeft, + children: [ + if (progress > 0) + Positioned( + left: 0, + top: contentCenter.value ?? 0, + width: Grid.lg, + child: FractionalTranslation( + translation: const Offset(0, -0.5), + child: ExcludeSemantics( + child: Opacity( + opacity: progress, + child: Transform.scale( + scale: reducedMotion + ? 1 + : (0.5 + 0.5 * progress) * + (1 + 0.12 * sin(pi * pulse.value)), + alignment: Alignment.center, + child: SizedBox( + key: const ValueKey('swipe-reply-feedback'), + width: Grid.lg, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + dimension: Grid.lg, + child: DecoratedBox( + key: const ValueKey('swipe-reply-circle'), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: progress == 1 + ? accent + : Colors.transparent, + ), + child: Stack( + fit: StackFit.expand, + children: [ + CircularProgressIndicator( + value: progress, + strokeWidth: 2, + strokeAlign: -1, + color: accent, + backgroundColor: Colors.transparent, + ), + Icon( + BuzzIcons.messageSquareReply, + size: 22, + color: progress == 1 + ? filledForeground + : context.colors.primary, + ), + ], + ), + ), + ), + const SizedBox(height: Grid.quarter), + Text( + 'Reply', + textAlign: TextAlign.center, + style: context.textTheme.labelSmall + ?.copyWith( + color: + context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + Transform.translate( + offset: Offset(offset.value, 0), + child: child, + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 0541f97229..3bb517008f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,3 +1,4 @@ +import 'message_swipe_reply.dart'; import '../../shared/widgets/ios_scroll_to_top.dart'; import 'message_footer_layout.dart'; import 'reply_summary_link.dart'; diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 7bbec9def0..89bf0ef415 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -41,6 +41,7 @@ class _ThreadMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final messageSnapshotKey = useMemoized(GlobalKey.new, const []); + final swipeContentKey = useMemoized(GlobalKey.new, const []); final mediaOffset = useMessageMediaOffset(message.content, message.tags); final pk = message.pubkey.toLowerCase(); final ownMessage = currentPubkey?.toLowerCase() == pk; @@ -133,7 +134,7 @@ class _ThreadMessage extends HookConsumerWidget { alpha: _landingHighlightOpacity * highlightProgress, ); - return NotificationListener( + final content = NotificationListener( onNotification: (notification) { mediaOffset.value = notification.offset; return true; @@ -310,6 +311,7 @@ class _ThreadMessage extends HookConsumerWidget { ), ), MessageContent( + key: swipeContentKey, content: message.content, bubble: true, hasReactions: @@ -434,5 +436,15 @@ class _ThreadMessage extends HookConsumerWidget { ), ), ); + return MessageSwipeReply( + contentKey: swipeContentKey, + message: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + child: content, + ); } } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index dcd6fb89c7..5b375c84bd 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -98,6 +98,7 @@ part 'channel_detail_page_test/loading_review_tests.dart'; part 'channel_detail_page_test/presence_tests.dart'; part 'channel_detail_page_test/action_row_tests.dart'; part 'channel_detail_page_test/bubble_tests.dart'; +part 'channel_detail_page_test/swipe_reply_tests.dart'; part 'channel_detail_page_test/review_avatar_tests.dart'; part 'channel_detail_page_test/retention_tests.dart'; @@ -568,6 +569,7 @@ void main() { presenceTests(); actionRowTests(); bubbleLayoutTests(); + swipeReplyTests(); reviewAvatarTests(); channelRetentionTests(); setUp(() async { diff --git a/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart b/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart new file mode 100644 index 0000000000..c5d9e08d70 --- /dev/null +++ b/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart @@ -0,0 +1,226 @@ +part of '../channel_detail_page_test.dart'; + +void swipeReplyTests() { + for (final thread in [false, true]) { + testWidgets('right drag opens existing reply destination, thread=$thread', ( + tester, + ) async { + final events = [ + _textMsg( + id: threadIdRoot, + pubkey: 'alice', + content: 'Swipe this message\nA second line\nAnd a third line', + ), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + home: thread + ? ThreadDetailPage( + threadHead: messages.first, + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ) + : null, + ), + ); + await tester.pumpAndSettle(); + final row = find.byKey( + ValueKey( + '${thread ? 'thread-message-row' : 'message-row'}-$threadIdRoot', + ), + ); + final initial = tester.getTopLeft(row); + final haptics = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'HapticFeedback.vibrate') haptics.add(call); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + final gesture = await tester.startGesture(tester.getCenter(row)); + await gesture.moveBy(const Offset(25, 0)); + await gesture.moveBy(const Offset(40, 0)); + await tester.pump(); + expect(tester.getTopLeft(row).dx, greaterThan(initial.dx)); + expect(haptics, isEmpty); + final partialCircle = tester.widget( + find.byKey(const ValueKey('swipe-reply-circle')), + ); + expect( + (partialCircle.decoration as BoxDecoration).color, + Colors.transparent, + ); + final ring = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('swipe-reply-circle')), + matching: find.byType(CircularProgressIndicator), + ), + ); + expect(ring.strokeWidth, 2); + expect(ring.value, inExclusiveRange(0, 1)); + await gesture.moveBy(const Offset(45, 0)); + await tester.pump(); + expect(haptics, hasLength(1)); + await gesture.moveBy(const Offset(10, 0)); + await tester.pump(); + expect(haptics, hasLength(1)); + final armedPosition = tester.getTopLeft(row); + await gesture.moveBy(const Offset(160, 0)); + await tester.pump(); + final resistedPosition = tester.getTopLeft(row); + expect(resistedPosition.dx, greaterThan(armedPosition.dx)); + expect(resistedPosition.dx, lessThan(armedPosition.dx + 40)); + final circle = find.byKey(const ValueKey('swipe-reply-circle')); + expect(tester.getRect(circle).left, initial.dx); + final fill = + tester.widget(circle).decoration as BoxDecoration; + expect(fill.color, tester.element(circle).appColors.accent); + expect(find.text('Reply'), findsOneWidget); + final centerBeforePulse = tester.getCenter( + find.byKey(const ValueKey('swipe-reply-feedback')), + ); + await tester.pump(const Duration(milliseconds: 60)); + expect( + tester.getCenter(find.byKey(const ValueKey('swipe-reply-feedback'))), + centerBeforePulse, + ); + expect(tester.getRect(circle).width, greaterThan(Grid.lg)); + await tester.pump(const Duration(milliseconds: 180)); + expect(tester.getRect(circle).width, Grid.lg); + expect(haptics, hasLength(1)); + final feedback = find.byKey(const ValueKey('swipe-reply-feedback')); + final content = find.descendant( + of: row, + matching: find.byType(MessageContent), + ); + expect(tester.getCenter(feedback).dy, tester.getCenter(content).dy); + expect( + tester.getCenter(find.text('Reply')).dx, + tester.getCenter(circle).dx, + ); + expect( + tester.widget(find.text('Reply')).style?.color, + tester.element(circle).colors.onSurfaceVariant, + ); + // Retreat below readiness without returning to rest, then cross again. + await gesture.moveBy(const Offset(-210, 0)); + await tester.pump(); + expect(tester.getTopLeft(row).dx, greaterThan(initial.dx)); + await gesture.moveBy(const Offset(45, 0)); + await tester.pump(); + expect(haptics, hasLength(2)); + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getRect(circle).width, greaterThan(Grid.lg)); + await tester.pump(const Duration(milliseconds: 180)); + expect(tester.getRect(circle).width, Grid.lg); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsOneWidget); + expect( + tester + .widget(find.byType(ThreadDetailPage)) + .threadHead + .id, + threadIdRoot, + ); + // A thread message must push a reply route too, rather than doing nothing. + expect( + Navigator.of(tester.element(find.byType(ThreadDetailPage))).canPop(), + isTrue, + ); + }); + } + + for (final movement in [ + const Offset(45, 0), + const Offset(-100, 0), + const Offset(0, -100), + ]) { + testWidgets('non-reply drag $movement does not navigate', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'swipe-cancel', + pubkey: 'alice', + content: 'Keep reading', + ), + ], + ), + ); + await tester.pumpAndSettle(); + final row = find.byKey(const ValueKey('message-row-swipe-cancel')); + await tester.drag(row, movement); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsNothing); + }); + } + + for (final reducedMotion in [false, true]) { + testWidgets('cancelled drag resets feedback, reduced=$reducedMotion', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + disableAnimations: reducedMotion, + messages: [ + _textMsg(id: 'cancel', pubkey: 'alice', content: 'Cancel drag'), + ], + ), + ); + await tester.pumpAndSettle(); + final row = find.byKey(const ValueKey('message-row-cancel')); + final origin = tester.getTopLeft(row); + final gesture = await tester.startGesture(tester.getCenter(row)); + await gesture.moveBy(const Offset(25, 0)); + await gesture.moveBy(const Offset(90, 0)); + await tester.pump(); + final icon = find.byIcon(BuzzIcons.messageSquareReply); + expect( + tester + .widget( + find.ancestor(of: icon, matching: find.byType(Opacity)).first, + ) + .opacity, + 1, + ); + await gesture.cancel(); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(row), origin); + expect(find.byType(ThreadDetailPage), findsNothing); + }); + } + + testWidgets('crossing threshold then retreating cancels reply', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg(id: 'retreat', pubkey: 'alice', content: 'Keep reading'), + ], + ), + ); + await tester.pumpAndSettle(); + final row = find.byKey(const ValueKey('message-row-retreat')); + final gesture = await tester.startGesture(tester.getCenter(row)); + await gesture.moveBy(const Offset(25, 0)); + await gesture.moveBy(const Offset(90, 0)); + await gesture.moveBy(const Offset(-70, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsNothing); + }); +} diff --git a/mobile/test/features/channels/message_carousel_preview_cases.dart b/mobile/test/features/channels/message_carousel_preview_cases.dart index 1dff030bf3..9ab476de19 100644 --- a/mobile/test/features/channels/message_carousel_preview_cases.dart +++ b/mobile/test/features/channels/message_carousel_preview_cases.dart @@ -105,70 +105,68 @@ void carouselPreviewTests() { variant: TargetPlatformVariant.only(TargetPlatform.iOS), ); - testWidgets( - 'outgoing gallery starts at the right and mirrors its spacing', - (tester) async { - final messenger = tester.binding.defaultBinaryMessenger; - final channels = []; - final configs = >[]; - messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( - call, - ) async { - if (call.method == 'create') { - final id = (call.arguments as Map)['id']; - final channel = MethodChannel('buzz/ios_media_carousel/$id'); - channels.add(channel); - messenger.setMockMethodCallHandler(channel, (call) async { - if (call.method == 'configure') { - configs.add(Map.from(call.arguments as Map)); - } - return null; - }); - } - return null; - }); - addTearDown(() { - messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); - for (final channel in channels) { - messenger.setMockMethodCallHandler(channel, null); - } - }); - final counts = []; - final widths = []; - for (final own in [false, true]) { - await tester.pumpWidget( - _testable( - Padding( - padding: const EdgeInsets.symmetric(horizontal: 50), - child: MessageContent( - key: ValueKey(own), - ownMessage: own, - bubble: true, - mediaCarouselTrailingOverflow: Grid.gutter, - content: - '![one](https://example.com/mirror-one.png)\n![two](https://example.com/mirror-two.png)', - ), + testWidgets('outgoing gallery starts at the right and mirrors its spacing', ( + tester, + ) async { + final messenger = tester.binding.defaultBinaryMessenger; + final channels = []; + final configs = >[]; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final id = (call.arguments as Map)['id']; + final channel = MethodChannel('buzz/ios_media_carousel/$id'); + channels.add(channel); + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'configure') { + configs.add(Map.from(call.arguments as Map)); + } + return null; + }); + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); + for (final channel in channels) { + messenger.setMockMethodCallHandler(channel, null); + } + }); + final counts = []; + final widths = []; + for (final own in [false, true]) { + await tester.pumpWidget( + _testable( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 50), + child: MessageContent( + key: ValueKey(own), + ownMessage: own, + bubble: true, + mediaCarouselTrailingOverflow: Grid.gutter, + content: + '![one](https://example.com/mirror-one.png)\n![two](https://example.com/mirror-two.png)', ), ), - ); - await tester.pumpAndSettle(); - counts.add( - tester.getRect( - find.byKey(const ValueKey('message-media-carousel-count')), - ), - ); - widths.add( - tester - .getSize(find.byKey(const ValueKey('message-media-carousel'))) - .width, - ); - expect(configs.last['rtl'], own); - } - expect(widths[0], widths[1]); - expect(counts[0].left, closeTo(800 - counts[1].right, 0.1)); - await tester.pumpWidget(const SizedBox()); - expect(tester.takeException(), isNull); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + ), + ); + await tester.pumpAndSettle(); + counts.add( + tester.getRect( + find.byKey(const ValueKey('message-media-carousel-count')), + ), + ); + widths.add( + tester + .getSize(find.byKey(const ValueKey('message-media-carousel'))) + .width, + ); + expect(configs.last['rtl'], own); + } + expect(widths[0], widths[1]); + expect(counts[0].left, closeTo(800 - counts[1].right, 0.1)); + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); } From 305fb7b5cd913379c246a66ce04c38411bba612b Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 15:59:19 -0700 Subject: [PATCH 12/28] fix(push-gateway): request default notification sound (#8431) Push banners arrive silently because the gateway's fixed APNs body never requests a sound, even though the iOS app requests sound permission. Add `aps.sound: "default"` to that constant so ordinary alerts request the system sound even if the notification extension is not invoked. Preserve the existing alert text, mutable-content flag, and class-blind, content-free delivery contract; synchronize its normative specification and executable models. Companion client workaround: [#8429](https://github.com/block/buzz/pull/8429). This gateway change also covers the original-payload fallback and requires no mobile source change. Sound remains subject to iOS settings and authorization. It does not bypass Focus or Silent Mode, select critical alerts, or guarantee vibration. Every public-profile wake requests the same sound, including generic/restricted fallback banners. ### Validation - Extended the real HTTP transport test: reproduced missing sound (`null` versus `default`) before the fix, then passed with the fix. All 79 runnable gateway library tests pass; 17 infrastructure/manual tests remain ignored. - Added a guard against drift between the emitted bytes, NIP-PL, and both formal models. Fixed-payload and stateful-delivery models and mutation checks pass. - Inspected the 0.24.0 (7) iOS source: sound permission is requested; ordinary enrichment and restricted fallback copy incoming content. A macOS UserNotifications probe confirms copy retention, but does not test iOS Intents or audible device delivery. - Focused existing native resolver/handoff tests pass (35 tests on macOS). These cover resolution and fallback behavior, not iOS sound playback. - Deployed exact head [b8a0eb6](https://github.com/block/buzz/commit/b8a0eb6b7885fd87a77052df0a0e71e3ee05589c) to the personal dev push gateway (Railway deployment `b3e52fc8-d1d3-48d7-ae21-1a83c5db2e99`). Verified the dev relay already routes to that gateway. On an iPhone 12 mini running the existing Buzz Dev 0.22 (10140), without the client workaround, automation captured the test banner and Tom confirmed audible sound on October 18. - Broad local validation reached an unrelated ACP startup-deadline failure in `signals_cancel_startup_turn_and_pending_stdin_without_hanging`. No ACP source or dependency changed, and ACP does not depend on the gateway. The full local gate is not reported as passing. - Local desktop pre-push tests passed 3,420 cases but failed two unchanged agent-discovery checks (a 300 ms subprocess deadline and a login-shell PATH comparison). Desktop does not depend on the gateway. Retried publication with only the Rust-suite and desktop-Tauri lanes excluded under the standing unrelated-failure exception; other hooks remain enabled. ### Before merge - [x] Verify a real push banner and audible sound through the dev relay and candidate dev gateway on an iOS device without the client workaround. Signed-off-by: Tom Brow --- crates/buzz-push-gateway/src/apns.rs | 17 +++++++++++++++++ crates/buzz-push-gateway/src/model.rs | 2 +- docs/formal/nip-pl/delivery.py | 2 +- docs/formal/nip-pl/fixed_payload.py | 2 +- docs/nips/NIP-PL.md | 2 +- 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/buzz-push-gateway/src/apns.rs b/crates/buzz-push-gateway/src/apns.rs index 8fe125b576..6b65ffcb1d 100644 --- a/crates/buzz-push-gateway/src/apns.rs +++ b/crates/buzz-push-gateway/src/apns.rs @@ -239,6 +239,18 @@ mod tests { StatusCode::OK } + #[test] + fn apns_payload_matches_normative_contract_and_models() { + let body = std::str::from_utf8(APNS_RECONNECT_PAYLOAD).unwrap(); + for contract in [ + include_str!("../../../docs/nips/NIP-PL.md"), + include_str!("../../../docs/formal/nip-pl/delivery.py"), + include_str!("../../../docs/formal/nip-pl/fixed_payload.py"), + ] { + assert!(contract.contains(body), "APNs fixed-body contract drift"); + } + } + #[tokio::test] async fn certificate_transport_sends_no_bearer_and_exact_body_for_every_attempt() { let requests = Arc::new(Mutex::new(Vec::new())); @@ -274,6 +286,11 @@ mod tests { } let captured = requests.lock().unwrap(); assert_eq!(captured.len(), 2); + for request in captured.iter() { + let payload: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + assert_eq!(payload["aps"]["sound"], "default"); + assert_eq!(payload["aps"]["mutable-content"], 1); + } assert!(captured .iter() .all(|request| request.body.as_slice() == APNS_RECONNECT_PAYLOAD)); diff --git a/crates/buzz-push-gateway/src/model.rs b/crates/buzz-push-gateway/src/model.rs index 441966bd20..05f97ef3d5 100644 --- a/crates/buzz-push-gateway/src/model.rs +++ b/crates/buzz-push-gateway/src/model.rs @@ -13,7 +13,7 @@ pub const MAX_ENROLL_REQUEST_BYTES: usize = pub const MAX_GRANT_BYTES: usize = 4096; pub const MAX_ENDPOINT_HEX_BYTES: usize = 512; pub const APNS_RECONNECT_PAYLOAD: &[u8] = - br#"{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}"#; + br#"{"aps":{"alert":{"body":"Reconnect to your relay now"},"sound":"default","mutable-content":1}}"#; pub const WIRE_VERSION: u8 = 1; /// Relay request. It deliberately has no application-payload field: diff --git a/docs/formal/nip-pl/delivery.py b/docs/formal/nip-pl/delivery.py index 2c8c5ac350..39a1ca9d34 100644 --- a/docs/formal/nip-pl/delivery.py +++ b/docs/formal/nip-pl/delivery.py @@ -7,7 +7,7 @@ transient release, revocation ordering, custody, and the constant APNs body. """ from itertools import permutations, product -FIXED_BODY = b'{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}' +FIXED_BODY = b'{"aps":{"alert":{"body":"Reconnect to your relay now"},"sound":"default","mutable-content":1}}' class Gateway: def __init__(self): diff --git a/docs/formal/nip-pl/fixed_payload.py b/docs/formal/nip-pl/fixed_payload.py index e69af9c434..5b424cd21f 100644 --- a/docs/formal/nip-pl/fixed_payload.py +++ b/docs/formal/nip-pl/fixed_payload.py @@ -5,7 +5,7 @@ caller-controlled or capability-derived category; none is an argument to body(). """ from itertools import product -C = b'{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}' +C = b'{"aps":{"alert":{"body":"Reconnect to your relay now"},"sound":"default","mutable-content":1}}' DOMAINS = [ (b"request-a", b"request-b"), # exact signed body (b"auth-a", b"auth-b"), # NIP-98 event/header diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 1ee9ed2f16..38ee0eca4c 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -224,7 +224,7 @@ Common invariant, all transports: the application payload is a transport-owned r ### APNs -The APNs application body is the exact UTF-8 byte constant `{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}`. It has no custom member, event identifier, unread count, or relay-supplied byte. The constant mutable-content flag lets the Buzz Notification Service Extension compute a local badge and, when separately authorized data is available, replace the generic text; the gateway does not carry that data. The gateway MUST send that exact body for every accepted APNs attempt; it MUST NOT serialize any relay request, endpoint grant, provider response, or generic JSON value into the body. `apns-topic`, environment, credentials, push type `alert`, and priority `10` come only from gateway configuration. `apns-id` is a canonical UUID and `apns-expiration` is bounded by the endpoint capability and a gateway-local ceiling. +The APNs application body is the exact UTF-8 byte constant `{"aps":{"alert":{"body":"Reconnect to your relay now"},"sound":"default","mutable-content":1}}`. It has no custom member, event identifier, unread count, or relay-supplied byte. The constant sound requests the system default alert sound, subject to notification authorization and user sound, Focus, and haptics settings. It does not select critical alerts or guarantee vibration. Sound is present even when the Notification Service Extension does not run. The constant mutable-content flag lets the Buzz Notification Service Extension compute a local badge and, when separately authorized data is available, replace the generic text; the gateway does not carry that data. The gateway MUST send that exact body for every accepted APNs attempt; it MUST NOT serialize any relay request, endpoint grant, provider response, or generic JSON value into the body. `apns-topic`, environment, credentials, push type `alert`, and priority `10` come only from gateway configuration. `apns-id` is a canonical UUID and `apns-expiration` is bounded by the endpoint capability and a gateway-local ceiling. ### FCM From 8f2c7710e958952b53e8e280cde52eb0d51b7312 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 16:10:01 -0700 Subject: [PATCH 13/28] fix(mobile): default community push notifications to on (#8425) New mobile communities currently require a manual Settings opt-in before push can start. Default that local preference to on for new communities and stored records without a preference. Preserve every saved `true` or `false`, including values written by older clients. Older default-off values are indistinguishable from explicit opt-outs, so this does not migrate them to on. Push still requires a configured gateway, authenticated connectivity, and a valid relay capability. iOS continues to control display authorization through its existing permission prompt. Notification eligibility and subscriptions are unchanged. Settings retains its existing opt-out control for enabled communities even when capability is unavailable. Drafted from the 0.24 baseline; release-branch inclusion is a separate decision. ### Related issue Closest related issue: #3238 (synced defaults). This change is limited to the mobile device-local community preference; no duplicate found. ### Validation - Added or updated coverage for new and missing preferences, saved true/false persistence, opt-out after restart, capability-gated registration, and Settings without a configured gateway. - Full configured mobile suite: 3,499 passed, 4 skipped. Separate no-gateway suite: 4 passed. Flutter analysis passed. - Existing permission-denied and unsupported-relay tests passed as part of the mobile suite. No physical-device or end-to-end APNs validation performed. - Full local repository gate passed (formatting, lint, unit tests, desktop/web builds, Tauri checks/tests, and mobile tests). - Follow-up review edits: restored the original VISION.md wording and shared the default in one private Community constant. All 43 focused community/bootstrap tests passed. --------- Signed-off-by: Tom Brow --- mobile/lib/shared/community/community.dart | 7 +- .../community/community_provider_test.dart | 8 +- .../test/shared/community/community_test.dart | 29 +++++++ .../test/shared/push/push_bootstrap_test.dart | 33 +++++++- .../push/push_unconfigured_build_test.dart | 83 +++++++++++-------- 5 files changed, 116 insertions(+), 44 deletions(-) diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 86db62bb62..426a34198b 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -19,6 +19,8 @@ String _newPushLeaseInstallationId() { enum SensitiveActionPolicy { enabled, disabledByUser } class Community { + static const _defaultPushNotificationsEnabled = true; + final String id; final String name; final String relayUrl; @@ -45,7 +47,7 @@ class Community { this.pubkey, this.nsec, this.sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser, - this.pushNotificationsEnabled = false, + this.pushNotificationsEnabled = _defaultPushNotificationsEnabled, this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(), this.pushLeaseInstallationId, this.starterSetupIncomplete = false, @@ -130,7 +132,8 @@ class Community { ); } final pushNotificationsEnabled = - json['pushNotificationsEnabled'] as bool? ?? false; + json['pushNotificationsEnabled'] as bool? ?? + _defaultPushNotificationsEnabled; var pushSubscriptionState = json['pushSubscriptionState'] == null ? const BuzzPushLeaseSubscriptionState.desired() : BuzzPushLeaseSubscriptionState.fromJson( diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index fa977786f2..d79d1620f6 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -122,7 +122,7 @@ void main() { }); test( - 'push notifications default off and opt-in survives restart', + 'push notifications default on and opt-out survives restart', () async { container = createContainer(); await container.read(communityListProvider.future); @@ -137,12 +137,12 @@ void main() { (await container.read( communityListProvider.future, )).single.pushNotificationsEnabled, - isFalse, + isTrue, ); await container .read(communityListProvider.notifier) - .setPushNotificationsEnabled(community.id, true); + .setPushNotificationsEnabled(community.id, false); container.dispose(); container = createContainer(); @@ -150,7 +150,7 @@ void main() { (await container.read( communityListProvider.future, )).single.pushNotificationsEnabled, - isTrue, + isFalse, ); }, ); diff --git a/mobile/test/shared/community/community_test.dart b/mobile/test/shared/community/community_test.dart index 0d229d2366..a5304f643e 100644 --- a/mobile/test/shared/community/community_test.dart +++ b/mobile/test/shared/community/community_test.dart @@ -16,6 +16,8 @@ void main() { ); expect(community.starterSetupIncomplete, isFalse); expect(community.pushLeaseInstallationId, isNull); + expect(community.pushNotificationsEnabled, isTrue); + expect(community.pushSubscriptionState.pendingTombstoneGeneration, isNull); }); test('new community gets a unique canonical push lease address id', () { @@ -25,6 +27,8 @@ void main() { relayUrl: 'https://relay.test', ); + expect(first.pushNotificationsEnabled, isTrue); + expect(Community.fromJson(first.toJson()).pushNotificationsEnabled, isTrue); expect(first.pushLeaseInstallationId, matches(RegExp(r'^[0-9a-f]{32}$'))); expect( second.pushLeaseInstallationId, @@ -49,6 +53,31 @@ void main() { expect(roundTrip.pushLeaseInstallationId, 'a' * 32); }); + for (final enabled in [true, false]) { + test('saved push preference $enabled survives loading and copying', () { + final community = Community.fromJson({ + 'id': 'one', + 'name': 'Buzz', + 'relayUrl': 'https://relay.test', + 'pushNotificationsEnabled': enabled, + 'addedAt': '2026-08-05T00:00:00.000Z', + }); + expect(community.pushNotificationsEnabled, enabled); + expect( + community.copyWith(name: 'Renamed').pushNotificationsEnabled, + enabled, + ); + expect( + Community.fromJson(community.toJson()).pushNotificationsEnabled, + enabled, + ); + expect( + community.pushSubscriptionState.pendingTombstoneGeneration, + isNull, + ); + }); + } + test('malformed stored push lease address id is rejected', () { expect( () => Community.fromJson({ diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 6770ad546e..2fd37bfbbe 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -26,6 +26,33 @@ void main() { expect(registrations, 1); }); + testWidgets('new community starts registration once capability arrives', ( + tester, + ) async { + final community = Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ); + var registrations = 0; + Widget bootstrap(BuzzPushLeaseDescriptor? descriptor) => + BuzzPushRegistrationBootstrap( + shouldRegister: buzzPushLifecycleEnabled( + community: community, + descriptor: descriptor, + ), + attemptKey: '${community.id}|${community.relayUrl}', + startRegistration: () async => registrations += 1, + child: const SizedBox(), + ); + await tester.pumpWidget(bootstrap(null)); + expect(registrations, 0); + final descriptor = _descriptor(keyId: 'relay-v1', pubkey: _hex('b')); + await tester.pumpWidget(bootstrap(descriptor)); + expect(registrations, 1); + await tester.pumpWidget(bootstrap(descriptor)); + expect(registrations, 1); + }); + test( 'superseded lease acceptance is a retryable publication failure', () async { @@ -141,12 +168,12 @@ void main() { ); }); - test('relay capability alone does not activate push without opt-in', () { - final disabled = Community.create( + test('default push requires relay capability and respects opt-out', () { + final enabled = Community.create( name: 'Team', relayUrl: 'wss://relay.example', ); - final enabled = disabled.copyWith(pushNotificationsEnabled: true); + final disabled = enabled.copyWith(pushNotificationsEnabled: false); expect( buzzPushLifecycleEnabled( diff --git a/mobile/test/shared/push/push_unconfigured_build_test.dart b/mobile/test/shared/push/push_unconfigured_build_test.dart index c5cf8161ad..3c54d3fcb4 100644 --- a/mobile/test/shared/push/push_unconfigured_build_test.dart +++ b/mobile/test/shared/push/push_unconfigured_build_test.dart @@ -73,43 +73,56 @@ void main() { expect(calls, isEmpty); }); - testWidgets('Settings hides notifications without push capability', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget( - ProviderScope( - overrides: [ - savedPrefsProvider.overrideWithValue(prefs), - activeCommunityProvider.overrideWith( - (ref) async => Community.create( - name: 'Team', - relayUrl: 'wss://relay.example', + for (final enabled in [true, false]) { + testWidgets( + 'Settings preserves preference $enabled without push capability', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + activeCommunityProvider.overrideWith( + (ref) async => Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ).copyWith(pushNotificationsEnabled: enabled), + ), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => throw StateError( + 'must not query notification permission', + ), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), ), ), - buzzPushAuthorizationStatusReaderProvider.overrideWithValue( - () async => - throw StateError('must not query notification permission'), - ), - ], - child: MaterialApp( - theme: AppTheme.light(), - home: SettingsPage( - profileHeader: const SizedBox.shrink(), - identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), - ), - ), - ), + ); + await tester.pumpAndSettle(); + expect( + find.text('Notifications'), + enabled ? findsOneWidget : findsNothing, + ); + expect(find.text('Push notifications'), findsNothing); + if (enabled) { + final toggle = tester.widget(find.byType(Switch)); + expect(toggle.value, isTrue); + expect(toggle.onChanged, isNotNull); + } else { + expect(find.byType(Switch), findsNothing); + } + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }, ); - await tester.pumpAndSettle(); - expect(find.text('Notifications'), findsNothing); - expect(find.text('Push notifications'), findsNothing); - expect(find.byType(Switch), findsNothing); - expect(tester.takeException(), isNull); - debugDefaultTargetPlatformOverride = null; - }); + } }, skip: Env.pushGatewayConfigured); } From 30ca980b49a7479dd89cb1ced0871af05ff1e12e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 18:04:04 -0700 Subject: [PATCH 14/28] feat(release): support version-matched mobile stable branches (#8430) Mobile candidates currently require the current `main` commit, so a stable release branch cannot publish through the existing protected tag workflow. Add optional `--source-branch mobile-vMAJOR.MINOR` selection while preserving `main` as the default. Both the command and App-backed publisher reject a stable branch whose major/minor differs from the requested version. Candidates retain exact-tip checks and immutable annotated tags; the private build pipeline is unchanged. Document the temporary stable-only distribution policy and explicit per-fix backport decisions. No candidate, build, or upload is triggered by this change. No duplicate implementation was found among open release PRs. Stable publication now checks active review and status-check protection in both the operator and publisher. The process permits approved backport PRs and stable-only corrections for regressions caused by earlier backports. Administrator activation and verification of the no-bypass ruleset remain outstanding; non-admin API responses hide the bypass actor list. ### Validation - Full local `just ci` passed during preparation; the final changes affect shell release tooling, its workflow inputs, CI path detection, and documentation. - Publisher contract passed, including version-matched stable sources and mismatched branch rejection. - Operator contract covers default main, a divergent stable commit, missing or mismatched sources, immutable sequencing, and publication races. The final operator contract run passed. - Updated publisher and operator contracts pass, including 17 weakened-policy cases and rejection of unavailable protection. The live, currently unprotected stable branch is rejected. --------- Signed-off-by: Tom Brow --- .github/workflows/ci.yml | 1 + .../workflows/mobile-release-candidate.yml | 10 +- RELEASING.md | 75 +++++++++--- scripts/ci-selection.test.mjs | 5 + scripts/mobile-release-source.sh | 44 +++++++ scripts/mobile-release.sh | 46 +++++--- scripts/publish-mobile-release-candidate.sh | 15 ++- ...test-mobile-release-candidate-publisher.sh | 110 +++++++++++++++--- scripts/test-mobile-release-contract.sh | 44 ++++++- 9 files changed, 291 insertions(+), 59 deletions(-) create mode 100644 scripts/mobile-release-source.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 351c7bef6a..be87fa4be4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,7 @@ jobs: - 'Justfile' - 'scripts/test-mobile-gateway-recipes.sh' - 'scripts/mobile-release.sh' + - 'scripts/mobile-release-source.sh' - 'scripts/mobile-worktree-overrides.sh' - 'scripts/mobile-worktree-clean.sh' - 'scripts/publish-mobile-release-candidate.sh' diff --git a/.github/workflows/mobile-release-candidate.yml b/.github/workflows/mobile-release-candidate.yml index e1f1c1c918..5f2ecf305a 100644 --- a/.github/workflows/mobile-release-candidate.yml +++ b/.github/workflows/mobile-release-candidate.yml @@ -12,8 +12,13 @@ on: description: Expected next release-candidate number required: true type: string + source_branch: + description: Source branch (main or mobile-vMAJOR.MINOR matching version) + required: true + default: main + type: string target_sha: - description: Exact current block/buzz main commit + description: Exact current source branch commit required: true type: string @@ -65,5 +70,6 @@ jobs: GH_TOKEN: ${{ steps.release-tagger.outputs.token }} MOBILE_VERSION: ${{ inputs.version }} CANDIDATE_NUMBER: ${{ inputs.candidate_number }} + SOURCE_BRANCH: ${{ inputs.source_branch }} TARGET_SHA: ${{ inputs.target_sha }} - run: scripts/publish-mobile-release-candidate.sh "$MOBILE_VERSION" "$CANDIDATE_NUMBER" "$TARGET_SHA" + run: scripts/publish-mobile-release-candidate.sh "$MOBILE_VERSION" "$CANDIDATE_NUMBER" "$TARGET_SHA" "$SOURCE_BRANCH" diff --git a/RELEASING.md b/RELEASING.md index 34fc9b49da..0385dd9e4a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,7 +1,8 @@ # Releasing Buzz Buzz has three independent release lanes. Desktop and relay use release PRs. -Mobile uses immutable release-candidate tags cut directly from remote `main`: +Mobile uses immutable release-candidate tags cut from remote `main` by default, +with an optional stable source branch: | Lane | Entry point | Artifact | |------|-------------|----------| @@ -38,7 +39,8 @@ scripts/mobile-release.sh candidate 0.5.0 Desktop uses an immutable generated candidate PR; relay continues using its metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record. -There is no mobile release branch, stable mobile tag alias, finalization step, +Optional stable branches use `mobile-vMAJOR.MINOR`. There is no +stable mobile tag alias, finalization step, or mobile GitHub Release. --- @@ -153,10 +155,47 @@ RC tags. For example, iOS can ship `mobile-v0.5.0-rc.2` while Android ships There is intentionally no single selected or final candidate for the marketing version. -The simplification trades away a separate stabilization line. Unrelated commits -that reach `main` become part of every later candidate, and there is no retained -hotfix branch or branch-ancestry history. Add a dedicated hotfix flow later if a -release actually needs isolation from `main`. +### Optional stable release branches + +For a release that needs isolation from ongoing development, create a branch +such as `mobile-v0.25` from an explicitly selected, verified source commit. +Publish candidates from its current remote tip: + +```bash +scripts/mobile-release.sh candidate 0.25.0 --source-branch mobile-v0.25 +``` + +The branch must match the marketing version's major and minor components: +`mobile-v0.25` accepts 0.25.x, but rejects 0.26.x and 1.25.x. Without the option, +the source remains `main`. Both the operator and publisher validate the source; +a missing branch or changed tip fails rather than falling back to `main`. +The GitHub workflow itself continues to run from reviewed `main`. The private +pipeline still builds only the resulting immutable candidate tag. + +For the October 2026 stabilization period, all distributed mobile builds, +including Company Portal dogfood builds, must come from `mobile-v0.25` until +after the conference. Do not upload builds from `main` or a later minor version +that would displace the stable dogfood build. The branch starts from +`mobile-v0.24.0-rc.2`, the source of uploaded iOS 0.24.0 (7). + +General fix PRs must target `main` and land there first. At each fix landing, +ask the release owner whether to +backport the merged commit to the stable branch. For each explicitly approved +fix, cherry-pick with `-x --signoff` onto a backport branch and open a backport-only +PR targeting the stable branch. Require review and passing checks before merging +that PR. Preserve the original commit provenance and validate the resulting +stable source. A correction for a regression introduced by an earlier backport +may target the stable branch directly; identify the faulty backport and keep the +correction scoped to that regression. The same review and checks apply. +Never merge `main` wholesale. +Remote agents and App Clip are post-conference work. There is no preselected +fix list and no requirement to create a stable branch for every minor release. + +The initial 0.25.0 candidate uses an empty commit above that baseline to verify +that the existing pipeline builds a commit outside `main`. Verify identical +source trees, absence of that commit from remote `main`, and the exact tag, +commit, marketing version and build number in the uploaded artifacts. Upload +and external TestFlight review do not establish production App Store approval. `mobile/pubspec.yaml` keeps `0.0.0+1` only as a valid, visibly non-release fallback for local development and validation builds. Release jobs always @@ -317,8 +356,15 @@ Mobile candidate publication requires workflow-dispatch access and the existing release App because strict tag protection denies direct human creation. The App must be installed on `block/buzz`, have Contents write and Metadata read, and retain an `always` bypass on the immutable `mobile-v*` tag rules. It does not -require GitHub Releases permissions, repository Administration permission, or a -mobile release-branch ruleset. The publisher validates the App token's effective +require GitHub Releases permissions or repository Administration permission. +Stable sources additionally require active branch ruleset `19321162` covering +`refs/heads/mobile-v*`, with no exclusions or bypass actors, deletion and +force-push prevention, at least one approval, stale-approval dismissal, last-push +approval, resolved review threads, and strict required `Mobile` and `DCO Check` +checks. An administrator must configure and audit the empty bypass list; GitHub +hides that list from non-admin tokens. Both operator and publisher verify the +public policy and their effective lack of branch bypass on every publication. +The publisher validates the App token's effective `current_user_can_bypass` value rather than reading the ruleset's hidden bypass actor list. @@ -343,18 +389,19 @@ Commit or stash your changes before running the release recipe. ### New commits land after publishing a mobile candidate Run `scripts/mobile-release.sh candidate ` again after the intended -fix reaches remote `main`. It publishes a new immutable RC tag at the new exact +fix reaches the intended remote source branch. Use the same `--source-branch` +option for a stable release. The command publishes a new immutable RC tag at the new exact remote commit. Continue referring to each tested or shipped platform artifact by its own exact tag. -### `scripts/mobile-release.sh candidate` fails because `main` moved during publication +### `scripts/mobile-release.sh candidate` fails because the source branch moved during publication The App-backed workflow may already have published the requested immutable RC -at the prior `main` tip before the operator command detects the race. Do not -move or delete that tag, and do not treat it as the candidate for current -`main`. Inspect the run URL from the command output, then rerun +at the prior source branch tip before the operator command detects the race. Do not +move or delete that tag, and do not treat it as the candidate for the current +source branch. Inspect the run URL from the command output, then rerun `scripts/mobile-release.sh candidate ` to publish the next RC from the -new current `main` tip. +new current source branch tip, preserving the source-branch option. ### A mobile candidate command selects the wrong RC number diff --git a/scripts/ci-selection.test.mjs b/scripts/ci-selection.test.mjs index ccad2f604c..4fb7508293 100644 --- a/scripts/ci-selection.test.mjs +++ b/scripts/ci-selection.test.mjs @@ -217,6 +217,11 @@ const scenarios = [ ["mobile"], ], ["mobile lockfile", ["mobile/pubspec.lock"], ["mobile"]], + [ + "mobile release source helper", + ["scripts/mobile-release-source.sh"], + ["mobile"], + ], ["mobile release script", ["scripts/mobile-release.sh"], ["mobile"]], [ "mobile gateway recipe tests", diff --git a/scripts/mobile-release-source.sh b/scripts/mobile-release-source.sh new file mode 100644 index 0000000000..6420ae0d03 --- /dev/null +++ b/scripts/mobile-release-source.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# Validate an explicitly selected stable branch against the marketing version. +# main remains the default for releases without a stabilization branch. +require_mobile_release_source() { + local version="$1" branch="$2" + if [[ "$branch" == main ]]; then + return 0 + fi + if [[ "$branch" != "mobile-v${version%.*}" ]]; then + echo "Source branch must be main or mobile-v${version%.*} for version $version" >&2 + return 1 + fi +} + +# The repository administrator must configure this ruleset without bypass actors. +# GitHub hides that list from non-admin tokens; check the caller's effective +# bypass plus every public policy field on each publication. +require_mobile_release_branch_ruleset() { + local branch="$1" valid + [[ "$branch" != main ]] || return 0 + valid="$(gh api repos/block/buzz/rulesets/19321162 --jq ' + .target == "branch" and .enforcement == "active" + and .current_user_can_bypass == "never" + and (.conditions.ref_name.include | index("refs/heads/mobile-v*") != null) + and (.conditions.ref_name.exclude == []) + and ((has("bypass_actors") | not) or .bypass_actors == []) + and any(.rules[]; .type == "deletion") + and any(.rules[]; .type == "non_fast_forward") + and any(.rules[]; .type == "pull_request" + and .parameters.required_approving_review_count >= 1 + and .parameters.dismiss_stale_reviews_on_push == true + and .parameters.require_last_push_approval == true + and .parameters.required_review_thread_resolution == true) + and any(.rules[]; .type == "required_status_checks" + and .parameters.strict_required_status_checks_policy == true + and ([.parameters.required_status_checks[].context] | index("Mobile") != null) + and ([.parameters.required_status_checks[].context] | index("DCO Check") != null)) + ')" || { echo "Could not verify mobile stable-branch protection" >&2; return 1; } + [[ "$valid" == true ]] || { + echo "Mobile stable-branch ruleset 19321162 must enforce review and checks on mobile-v* without bypass" >&2 + return 1 + } +} diff --git a/scripts/mobile-release.sh b/scripts/mobile-release.sh index 5702281700..cd81c50fd1 100755 --- a/scripts/mobile-release.sh +++ b/scripts/mobile-release.sh @@ -4,10 +4,10 @@ set -euo pipefail usage() { cat >&2 <<'USAGE' usage: - scripts/mobile-release.sh candidate X.Y.Z + scripts/mobile-release.sh candidate X.Y.Z [--source-branch mobile-vX.Y] candidate Publish the next immutable mobile-vX.Y.Z-rc.N candidate tag at the - exact current commit of block/buzz's remote main branch. + exact current remote source commit (main unless --source-branch is set). USAGE exit 2 } @@ -19,6 +19,8 @@ fail() { # shellcheck source=scripts/release-rulesets.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-rulesets.sh" +# shellcheck source=scripts/mobile-release-source.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/mobile-release-source.sh" require_gh_minimum_version() { local minimum="2.87.0" version @@ -91,33 +93,40 @@ remote_tag_commit_sha() { printf '%s' "$commit" } -remote_main_commit_sha() { - local ref="refs/heads/main" line advertised_oid fetched_oid commit +remote_source_commit_sha() { + local ref="refs/heads/$MOBILE_RELEASE_SOURCE_BRANCH" line advertised_oid fetched_oid commit line="$(git ls-remote --refs origin "$ref")" || return 1 [[ -n "$line" && "$line" != *$'\n'* ]] || return 1 advertised_oid="${line%%$'\t'*}" git fetch -q --no-tags origin "$ref" fetched_oid="$(git rev-parse --verify FETCH_HEAD)" [[ "$fetched_oid" == "$advertised_oid" ]] || \ - fail "origin/main moved while it was being resolved" + fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH moved while it was being resolved" commit="$(git rev-parse --verify 'FETCH_HEAD^{commit}')" || return 1 [[ "$commit" == "$advertised_oid" ]] || \ - fail "origin/main did not resolve directly to a commit" + fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH did not resolve directly to a commit" printf '%s' "$commit" } command="${1:-}" case "$command" in candidate) - [[ "$#" -eq 2 ]] || usage + [[ "$#" -eq 2 || "$#" -eq 4 ]] || usage version="$2" + MOBILE_RELEASE_SOURCE_BRANCH=main + if [[ "$#" -eq 4 ]]; then + [[ "$3" == --source-branch ]] || usage + MOBILE_RELEASE_SOURCE_BRANCH="$4" + fi require_clean_semver "$version" + require_mobile_release_source "$version" "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1 require_clean_tree require_canonical_repository || exit 1 require_gh_minimum_version + require_mobile_release_branch_ruleset "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1 local_head_sha="$(git rev-parse --verify 'HEAD^{commit}')" || fail "HEAD is not a commit" - main_sha="$(remote_main_commit_sha)" || fail "origin/main does not exist" + source_sha="$(remote_source_commit_sha)" || fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH does not exist" next=1 if ! remote_tags="$(git ls-remote --refs --tags origin "refs/tags/mobile-v${version}-rc.*")"; then fail "could not list existing candidates for $version" @@ -135,7 +144,8 @@ case "$command" in --ref main \ -f "version=$version" \ -f "candidate_number=$next" \ - -f "target_sha=$main_sha" 2>&1)"; then + -f "source_branch=$MOBILE_RELEASE_SOURCE_BRANCH" \ + -f "target_sha=$source_sha" 2>&1)"; then : else if [[ "$dispatch_output" == *"does not have 'workflow_dispatch' trigger"* ]]; then @@ -151,18 +161,18 @@ case "$command" in gh run watch "$run_id" --repo block/buzz --exit-status --compact || \ fail "App-backed publication failed: $run_url" - current_main_sha="$(remote_main_commit_sha)" || fail "origin/main does not exist after publication" - [[ "$current_main_sha" == "$main_sha" ]] || \ - fail "origin/main moved from requested commit $main_sha to $current_main_sha during publication" + current_source_sha="$(remote_source_commit_sha)" || fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH does not exist after publication" + [[ "$current_source_sha" == "$source_sha" ]] || \ + fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH moved from requested commit $source_sha to $current_source_sha during publication" published_sha="$(remote_tag_commit_sha "refs/tags/$tag")" || \ fail "publication completed without exact annotated candidate tag $tag" - [[ "$published_sha" == "$main_sha" ]] || \ - fail "$tag resolved to $published_sha instead of requested commit $main_sha" - if [[ "$local_head_sha" != "$main_sha" ]]; then - echo "Note: local HEAD is $local_head_sha; candidate source is current origin/main $main_sha." >&2 + [[ "$published_sha" == "$source_sha" ]] || \ + fail "$tag resolved to $published_sha instead of requested commit $source_sha" + if [[ "$local_head_sha" != "$source_sha" ]]; then + echo "Note: local HEAD is $local_head_sha; candidate source is current origin/$MOBILE_RELEASE_SOURCE_BRANCH $source_sha." >&2 fi - printf 'Published %s at origin/main commit %s through buzz-release-bot. Use this exact tag in Release Mobile.\n' \ - "$tag" "$main_sha" + printf 'Published %s at origin/%s commit %s through buzz-release-bot. Use this exact tag in Release Mobile.\n' \ + "$tag" "$MOBILE_RELEASE_SOURCE_BRANCH" "$source_sha" ;; *) usage ;; diff --git a/scripts/publish-mobile-release-candidate.sh b/scripts/publish-mobile-release-candidate.sh index 6b0ee65b21..edeb9ad59b 100755 --- a/scripts/publish-mobile-release-candidate.sh +++ b/scripts/publish-mobile-release-candidate.sh @@ -8,11 +8,14 @@ fail() { # shellcheck source=scripts/release-rulesets.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-rulesets.sh" +# shellcheck source=scripts/mobile-release-source.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/mobile-release-source.sh" -[[ "$#" -eq 3 ]] || fail "usage: $0 X.Y.Z N COMMIT_SHA" +[[ "$#" -eq 4 ]] || fail "usage: $0 X.Y.Z N COMMIT_SHA SOURCE_BRANCH" version="$1" candidate_number="$2" target_sha="$3" +MOBILE_RELEASE_SOURCE_BRANCH="$4" repo="${GITHUB_REPOSITORY:-}" [[ "$repo" == "block/buzz" ]] || fail "candidate publishing is restricted to block/buzz" @@ -21,13 +24,15 @@ repo="${GITHUB_REPOSITORY:-}" [[ "$candidate_number" =~ ^[1-9][0-9]*$ ]] || \ fail "'$candidate_number' is not a candidate number (expected N >= 1)" [[ "$target_sha" =~ ^[0-9a-f]{40}$ ]] || fail "'$target_sha' is not a full commit SHA" +require_mobile_release_source "$version" "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1 command -v gh >/dev/null 2>&1 || fail "gh is required" require_release_tag_ruleset || exit 1 +require_mobile_release_branch_ruleset "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1 -main_sha="$(gh api "repos/$repo/git/ref/heads/main" --jq .object.sha)" || \ - fail "could not resolve $repo main" -[[ "$main_sha" == "$target_sha" ]] || \ - fail "$repo main moved from requested commit $target_sha to $main_sha" +source_sha="$(gh api "repos/$repo/git/ref/heads/$MOBILE_RELEASE_SOURCE_BRANCH" --jq .object.sha)" || \ + fail "could not resolve $repo $MOBILE_RELEASE_SOURCE_BRANCH" +[[ "$source_sha" == "$target_sha" ]] || \ + fail "$repo $MOBILE_RELEASE_SOURCE_BRANCH moved from requested commit $target_sha to $source_sha" commit_sha="$(gh api "repos/$repo/commits/$target_sha" --jq .sha)" || \ fail "$target_sha is not a commit in $repo" [[ "$commit_sha" == "$target_sha" ]] || \ diff --git a/scripts/test-mobile-release-candidate-publisher.sh b/scripts/test-mobile-release-candidate-publisher.sh index 92cfc69d84..87156a451a 100755 --- a/scripts/test-mobile-release-candidate-publisher.sh +++ b/scripts/test-mobile-release-candidate-publisher.sh @@ -17,6 +17,11 @@ record() { } case "${1:-}:${2:-}" in + api:repos/block/buzz/rulesets/19321162) + [[ "${GH_BRANCH_POLICY_FAIL:-}" != 1 ]] || exit 1 + policy='{"target":"branch","enforcement":"active","current_user_can_bypass":"never","conditions":{"ref_name":{"include":["refs/heads/mobile-v*"],"exclude":[]}},"bypass_actors":[],"rules":[{"type":"deletion"},{"type":"non_fast_forward"},{"type":"pull_request","parameters":{"required_approving_review_count":1,"dismiss_stale_reviews_on_push":true,"require_last_push_approval":true,"required_review_thread_resolution":true}},{"type":"required_status_checks","parameters":{"strict_required_status_checks_policy":true,"required_status_checks":[{"context":"Mobile"},{"context":"DCO Check"}]}}]}' + printf '%s\n' "${GH_BRANCH_POLICY:-$policy}" | jq -r "$4" + ;; api:repos/block/buzz/rulesets/14378754) case "$*" in *'.enforcement'*) printf '%s\n' "${GH_TAG_RULESET_STATE:-active}" ;; @@ -28,7 +33,9 @@ case "${1:-}:${2:-}" in esac ;; api:repos/block/buzz/git/ref/heads/main) printf '%s\n' "$GH_TARGET_SHA" ;; - api:repos/block/buzz/commits/*) printf '%s\n' "$GH_TARGET_SHA" ;; + api:repos/block/buzz/git/ref/heads/mobile-v1.2) printf '%s\n' "$GH_STABLE_SHA" ;; + api:repos/block/buzz/git/ref/heads/*) printf '%s\n' "$GH_MISMATCH_SHA" ;; + api:repos/block/buzz/commits/*) printf '%s\n' "${2##*/}" ;; api:--paginate) [[ "$3" == "repos/block/buzz/git/matching-refs/tags/mobile-v1.2.3-rc." ]] printf '%s' "${GH_EXISTING_REFS:-}" @@ -72,9 +79,11 @@ export PATH="$bin:$PATH" export GH_CALLS="$tmp/calls" export GITHUB_REPOSITORY=block/buzz export GH_TARGET_SHA=1111111111111111111111111111111111111111 +export GH_STABLE_SHA=4444444444444444444444444444444444444444 +export GH_MISMATCH_SHA=5555555555555555555555555555555555555555 export GH_TAG_OBJECT_SHA=2222222222222222222222222222222222222222 -"$publisher" 1.2.3 1 "$GH_TARGET_SHA" +"$publisher" 1.2.3 1 "$GH_TARGET_SHA" main grep -Fq -- '-f tag=mobile-v1.2.3-rc.1' "$GH_CALLS" grep -Fq -- '-f message=Buzz Mobile 1.2.3 release candidate 1' "$GH_CALLS" grep -Fq -- "-f object=$GH_TARGET_SHA" "$GH_CALLS" @@ -82,76 +91,141 @@ grep -Fq -- '-f type=commit' "$GH_CALLS" grep -Fq -- '-f ref=refs/tags/mobile-v1.2.3-rc.1' "$GH_CALLS" grep -Fq -- "-f sha=$GH_TAG_OBJECT_SHA" "$GH_CALLS" -if GH_CURRENT_USER_CAN_BYPASS=never "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then +if GH_CURRENT_USER_CAN_BYPASS=never "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted an App token without an always bypass" >&2 exit 1 fi -if GH_CURRENT_USER_CAN_BYPASS='' "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then +if GH_CURRENT_USER_CAN_BYPASS='' "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted a ruleset response without an effective bypass" >&2 exit 1 fi -if GH_TAG_RULESET_STATE=disabled "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then +if GH_TAG_RULESET_STATE=disabled "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted disabled tag protection" >&2 exit 1 fi -if GH_TAG_RULE_TYPES=creation "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then +if GH_TAG_RULE_TYPES=creation "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted incomplete tag protection" >&2 exit 1 fi -if GH_TAG_INCLUDES=refs/tags/v\* "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then +if GH_TAG_INCLUDES=refs/tags/v\* "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted a tag ruleset that excludes mobile candidates" >&2 exit 1 fi -if GH_TAG_EXCLUDES=refs/tags/mobile-v0.0.0 "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then +if GH_TAG_EXCLUDES=refs/tags/mobile-v0.0.0 "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted tag ruleset exclusions" >&2 exit 1 fi if GH_TARGET_SHA=3333333333333333333333333333333333333333 \ - "$publisher" 1.2.3 1 1111111111111111111111111111111111111111 >/dev/null 2>&1; then + "$publisher" 1.2.3 1 1111111111111111111111111111111111111111 main >/dev/null 2>&1; then echo "publisher accepted a moved main branch" >&2 exit 1 fi if GH_EXISTING_REFS=$'refs/tags/mobile-v1.2.3-rc.1\n' \ - "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then + "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted a stale candidate number" >&2 exit 1 fi -if GH_PUBLISHED_REF_TYPE=commit "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then +if GH_PUBLISHED_REF_TYPE=commit "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted a lightweight published tag" >&2 exit 1 fi if GH_PUBLISHED_REF_SHA=3333333333333333333333333333333333333333 \ - "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then + "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted the wrong annotated tag object" >&2 exit 1 fi -if GH_ANNOTATED_TARGET_TYPE=tag "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then +if GH_ANNOTATED_TARGET_TYPE=tag "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted a nested annotated tag" >&2 exit 1 fi if GH_ANNOTATED_TARGET_SHA=3333333333333333333333333333333333333333 \ - "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then + "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted an annotated tag on the wrong commit" >&2 exit 1 fi -if "$publisher" 01.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then +if "$publisher" 01.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted a marketing version with a leading zero" >&2 exit 1 fi -if "$publisher" 1.2.3 01 "$GH_TARGET_SHA" >/dev/null 2>&1; then +if "$publisher" 1.2.3 01 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted a candidate number with a leading zero" >&2 exit 1 fi if GH_EXISTING_REFS=$'refs/tags/mobile-v1.2.3-rc.1\nrefs/tags/mobile-v1.2.3-rc.7\nrefs/tags/mobile-v1.2.3-rc.08\nrefs/tags/mobile-v1.2.4-rc.99\n' \ - "$publisher" 1.2.3 8 "$GH_TARGET_SHA" >/dev/null; then + "$publisher" 1.2.3 8 "$GH_TARGET_SHA" main >/dev/null; then : else echo "publisher did not sequence from the highest exact candidate" >&2 exit 1 fi -if GITHUB_REPOSITORY=attacker/buzz "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then +if GITHUB_REPOSITORY=attacker/buzz "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then echo "publisher accepted the wrong repository" >&2 exit 1 fi +GH_ANNOTATED_TARGET_SHA="$GH_STABLE_SHA" "$publisher" 1.2.3 1 "$GH_STABLE_SHA" mobile-v1.2 +for source in mobile-v1.3 mobile-v2.2 mobile-v01.2 arbitrary ''; do + if GH_ANNOTATED_TARGET_SHA="$GH_MISMATCH_SHA" "$publisher" 1.2.3 1 "$GH_MISMATCH_SHA" "$source" >/dev/null 2>&1; then + echo "publisher accepted mismatched source '$source'" >&2 + exit 1 + fi +done +# Mutate actual policy JSON so these tests exercise the production jq predicate. +python3 - "$publisher" <<'PYPOLICY' +import copy, json, os, subprocess, sys +policy = {'target': 'branch', 'enforcement': 'active', 'current_user_can_bypass': 'never', 'conditions': {'ref_name': {'include': ['refs/heads/mobile-v*'], 'exclude': []}}, 'bypass_actors': [], 'rules': [{'type': 'deletion'}, {'type': 'non_fast_forward'}, {'type': 'pull_request', 'parameters': {'required_approving_review_count': 1, 'dismiss_stale_reviews_on_push': True, 'require_last_push_approval': True, 'required_review_thread_resolution': True}}, {'type': 'required_status_checks', 'parameters': {'strict_required_status_checks_policy': True, 'required_status_checks': [{'context': 'Mobile'}, {'context': 'DCO Check'}]}}]} +mutations = { + "disabled": lambda p: p.update(enforcement="disabled"), + "wrong target": lambda p: p.update(target="tag"), + "wrong scope": lambda p: p["conditions"]["ref_name"].update(include=["refs/heads/mobile-release/*"]), + "excluded branch": lambda p: p["conditions"]["ref_name"].update(exclude=["refs/heads/mobile-v1.2"]), + "caller bypass": lambda p: p.update(current_user_can_bypass="always"), + "other bypass": lambda p: p.update(bypass_actors=[{"actor_id":1}]), + "no deletion guard": lambda p: p["rules"].pop(0), + "no force guard": lambda p: p["rules"].pop(1), + "no review": lambda p: p["rules"].pop(2), + "no checks": lambda p: p["rules"].pop(3), + "no approvals": lambda p: p["rules"][2]["parameters"].update(required_approving_review_count=0), + "stale approvals": lambda p: p["rules"][2]["parameters"].update(dismiss_stale_reviews_on_push=False), + "self approval": lambda p: p["rules"][2]["parameters"].update(require_last_push_approval=False), + "open threads": lambda p: p["rules"][2]["parameters"].update(required_review_thread_resolution=False), + "non-strict checks": lambda p: p["rules"][3]["parameters"].update(strict_required_status_checks_policy=False), + "missing mobile": lambda p: p["rules"][3]["parameters"]["required_status_checks"].pop(0), + "missing DCO": lambda p: p["rules"][3]["parameters"]["required_status_checks"].pop(1), +} +for name, mutate in mutations.items(): + value = copy.deepcopy(policy) + mutate(value) + env = dict(os.environ, GH_BRANCH_POLICY=json.dumps(value), GH_ANNOTATED_TARGET_SHA=os.environ["GH_STABLE_SHA"]) + before = open(env["GH_CALLS"]).read() + result = subprocess.run([sys.argv[1], "1.2.3", "1", env["GH_STABLE_SHA"], "mobile-v1.2"], env=env, capture_output=True, text=True) + assert result.returncode != 0 and "must enforce review" in result.stderr, (name, result.stderr) + assert open(env["GH_CALLS"]).read() == before, name +PYPOLICY +if GH_BRANCH_POLICY_FAIL=1 "$publisher" 1.2.3 1 "$GH_STABLE_SHA" mobile-v1.2 >/dev/null 2>&1; then + echo "publisher accepted unavailable branch policy" >&2 + exit 1 +fi + +# Exercise the checked-in workflow command with its actual source input binding. +python3 - "$repo_root/.github/workflows/mobile-release-candidate.yml" "$tmp" <<'PYWORKFLOW' +import os +from pathlib import Path +import re +import subprocess +import sys +workflow = Path(sys.argv[1]).read_text() +step = workflow.split(" - name: Publish annotated candidate tag\n", 1)[1] +assert re.search(r"^ SOURCE_BRANCH: \$\{\{ inputs\.source_branch \}\}$", step, re.M), "source input is not bound to SOURCE_BRANCH" +command = re.search(r"^ run: (.+)$", step, re.M).group(1) +root = Path(sys.argv[2]) / "workflow" +(root / "scripts").mkdir(parents=True) +stub = root / "scripts/publish-mobile-release-candidate.sh" +stub.write_text('#!/usr/bin/env bash\nprintf "%s\\n" "$@"\n') +stub.chmod(0o755) +env = dict(os.environ, MOBILE_VERSION="1.2.3", CANDIDATE_NUMBER="1", TARGET_SHA="4" * 40, SOURCE_BRANCH="mobile-v1.2") +actual = subprocess.check_output(["bash", "-eu", "-c", command], cwd=root, env=env, text=True).splitlines() +assert actual == ["1.2.3", "1", "4" * 40, "mobile-v1.2"], actual +PYWORKFLOW + echo "mobile release candidate publisher contract passed" diff --git a/scripts/test-mobile-release-contract.sh b/scripts/test-mobile-release-contract.sh index 531590a585..ca6b79b9e1 100755 --- a/scripts/test-mobile-release-contract.sh +++ b/scripts/test-mobile-release-contract.sh @@ -17,6 +17,11 @@ cat > "$bin/gh" <<'GH' set -euo pipefail case "${1:-}:${2:-}" in --version:*) printf 'gh version %s (test)\n' "${GH_VERSION:-2.94.0}" ;; + api:repos/block/buzz/rulesets/19321162) + [[ "${GH_BRANCH_POLICY_FAIL:-}" != 1 ]] || exit 1 + policy='{"target":"branch","enforcement":"active","current_user_can_bypass":"never","conditions":{"ref_name":{"include":["refs/heads/mobile-v*"],"exclude":[]}},"bypass_actors":[],"rules":[{"type":"deletion"},{"type":"non_fast_forward"},{"type":"pull_request","parameters":{"required_approving_review_count":1,"dismiss_stale_reviews_on_push":true,"require_last_push_approval":true,"required_review_thread_resolution":true}},{"type":"required_status_checks","parameters":{"strict_required_status_checks_policy":true,"required_status_checks":[{"context":"Mobile"},{"context":"DCO Check"}]}}]}' + printf '%s\n' "${GH_BRANCH_POLICY:-$policy}" | jq -r "$4" + ;; api:repos/block/buzz/rulesets/14378754) case "$*" in *'.enforcement'*) printf '%s\n' "${GH_TAG_RULESET_STATE:-active}" ;; @@ -42,15 +47,18 @@ case "${1:-}:${2:-}" in version="" number="" sha="" + source_branch="" while [[ "$#" -gt 0 ]]; do case "$1" in version=*) version="${1#version=}" ;; candidate_number=*) number="${1#candidate_number=}" ;; + source_branch=*) source_branch="${1#source_branch=}" ;; target_sha=*) sha="${1#target_sha=}" ;; esac shift done - [[ -n "$version" && -n "$number" && -n "$sha" ]] + [[ -n "$version" && -n "$number" && -n "$sha" && -n "$source_branch" ]] || exit 1 + [[ "$(git --git-dir="$GH_REMOTE" rev-parse "refs/heads/$source_branch")" == "$sha" ]] || exit 1 printf '%s\t%s\t%s\n' "$version" "$number" "$sha" >> "$GH_WORKFLOW_CAPTURE" if [[ "${GH_WORKFLOW_NO_URL:-}" == "1" ]]; then exit 0 @@ -86,6 +94,7 @@ chmod +x "$bin/gh" export PATH="$bin:$PATH" export GH_WORKFLOW_CAPTURE="$tmp/workflow-dispatches" export GH_WORKTREE="$work" +export GH_REMOTE="$remote" run_release() { local repo="$1" @@ -106,7 +115,7 @@ fail() { assert_no_removed_mobile_release_behavior() { local status - grep -Eq 'gh[[:space:]]+release|mobile-release/|finalize' "$@" && \ + grep -Eq 'gh[[:space:]]+release|finalize' "$@" && \ fail "removed branch/finalization/GitHub Release behavior remains" status="$?" [[ "$status" -eq 1 ]] || \ @@ -149,6 +158,37 @@ cat "$tmp/stale-output" [[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v1.2.3-rc.1)" == tag ]] grep -Fq $'1.2.3\t1\t' "$GH_WORKFLOW_CAPTURE" +# A stable branch may diverge from main, including an empty release-only commit. +git -C "$work" switch -q -c mobile-v1.2 +git -C "$work" commit -q --allow-empty -m stable-only +git -C "$work" push -q origin mobile-v1.2 +stable_sha="$(git -C "$work" rev-parse HEAD)" +git -C "$work" switch -q main +if GH_BRANCH_POLICY_FAIL=1 run_release "$operator" candidate 1.2.4 --source-branch mobile-v1.2 > "$tmp/policy-error" 2>&1; then + fail "operator accepted unavailable stable-branch policy" +fi +grep -Fq 'Could not verify mobile stable-branch protection' "$tmp/policy-error" +if GH_BRANCH_POLICY='{}' run_release "$operator" candidate 1.2.4 --source-branch mobile-v1.2 > "$tmp/policy-error" 2>&1; then + fail "operator accepted missing stable-branch policy" +fi +run_release "$operator" candidate 1.2.4 --source-branch mobile-v1.2 +[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.4-rc.1^{commit}')" == "$stable_sha" ]] +# Existing mismatched refs must be rejected by the guard, not missing-ref errors. +for source in mobile-v1.3 mobile-v2.2 mobile-v01.2 arbitrary; do + git -C "$work" switch -q -c "$source" + git -C "$work" commit -q --allow-empty -m "$source" + git -C "$work" push -q origin "$source" +done +git -C "$work" switch -q main +for source in mobile-v1.3 mobile-v2.2 mobile-v01.2 arbitrary ''; do + if run_release "$operator" candidate 1.2.4 --source-branch "$source" >/dev/null 2>&1; then + fail "accepted mismatched source '$source'" + fi +done +if run_release "$operator" candidate 8.8.0 --source-branch mobile-v8.8 >/dev/null 2>&1; then + fail "missing stable branch fell back to main" +fi + # Existing remote identities remain unchanged. Later candidates sequence # monotonically and target the then-current remote main commit. rc1_tag_oid="$(git --git-dir="$remote" rev-parse refs/tags/mobile-v1.2.3-rc.1)" From 3debf18f1448334aa7bc202d328d93bb9651e168 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 18:27:45 -0700 Subject: [PATCH 15/28] fix(ios): supply missing push notification sound at handoff (#8443) Temporarily default missing notification sound until the [gateway fix](https://github.com/block/buzz/pull/8431) is deployed. Preserve any sound already provided by APNs. ### Validation Final build 10151: automation verified the push banner on a real iPhone 12 mini against the unchanged staging gateway, and Tom confirmed hearing the sound. iOS build, two existing handoff tests, and normal pre-push passed. ### Before merge - [x] Verify notification delivery and sound on the final iPhone build. Replaces [#8429](https://github.com/block/buzz/pull/8429) with the identical commit to obtain a fresh owner review. The prior review requested tests; this temporary workaround intentionally adds no tests or helper at the author's request. [Prior review response](https://github.com/block/buzz/pull/8429#issuecomment-6256498498). --------- Signed-off-by: Tom Brow --- mobile/ios/NotificationService/NotificationService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index 0fffa19540..c10bb0f3fd 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -101,11 +101,13 @@ final class NotificationService: UNNotificationServiceExtension { ) { resolvedScope = nil handoff = BuzzNotificationHandoff(handler: contentHandler) - restrictedFallbackContent = Self.restrictedFallback(from: request.content) guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { finish(request.content) return } + // Workaround until https://github.com/block/buzz/pull/8431 lands. + if content.sound == nil { content.sound = .default } + restrictedFallbackContent = Self.restrictedFallback(from: content) bestAttemptContent = content var cleanUserInfo = content.userInfo cleanUserInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey) From 4f1f1b5300f08c9f01a8bc09d263f332c5f69f30 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 19 Oct 2026 07:12:44 -0700 Subject: [PATCH 16/28] test(mobile): remove flaky channel collection assertion (#8440) Remove the intermittent `departed channel composer and route are collectible` test introduced in [#8419](https://github.com/block/buzz/pull/8419), as requested after its CI failure could not be reproduced in focused macOS and Linux investigation. Keep the Flutter upgrade containing the upstream leak fix and all other tests. No replacement GC-based test is added. ### Validation All 379 remaining channel tests pass, along with formatting and analysis. The broad local gate was stopped during unrelated Rust compilation; no full-gate pass is claimed. Signed-off-by: Tom Brow --- .../channels/channel_detail_page_test.dart | 3 - .../retention_tests.dart | 69 ------------------- 2 files changed, 72 deletions(-) delete mode 100644 mobile/test/features/channels/channel_detail_page_test/retention_tests.dart diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 5b375c84bd..50bacdb440 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -31,7 +31,6 @@ import 'package:flutter/rendering.dart' SemanticsNode; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:leak_tracker/leak_tracker.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; @@ -100,7 +99,6 @@ part 'channel_detail_page_test/action_row_tests.dart'; part 'channel_detail_page_test/bubble_tests.dart'; part 'channel_detail_page_test/swipe_reply_tests.dart'; part 'channel_detail_page_test/review_avatar_tests.dart'; -part 'channel_detail_page_test/retention_tests.dart'; const _channelId = '11111111-2222-4333-8444-555555555555'; const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c'; @@ -571,7 +569,6 @@ void main() { bubbleLayoutTests(); swipeReplyTests(); reviewAvatarTests(); - channelRetentionTests(); setUp(() async { SharedPreferences.setMockInitialValues({}); _testPrefs = await SharedPreferences.getInstance(); diff --git a/mobile/test/features/channels/channel_detail_page_test/retention_tests.dart b/mobile/test/features/channels/channel_detail_page_test/retention_tests.dart deleted file mode 100644 index b89eadbff1..0000000000 --- a/mobile/test/features/channels/channel_detail_page_test/retention_tests.dart +++ /dev/null @@ -1,69 +0,0 @@ -part of '../channel_detail_page_test.dart'; - -void channelRetentionTests() { - testWidgets('departed channel composer and route are collectible', ( - tester, - ) async { - final semantics = tester.ensureSemantics(); - try { - await tester.pumpWidget( - _buildTestable( - messages: [ - _textMsg(id: 'message', pubkey: 'alice', content: 'Hello'), - ], - home: Builder( - builder: (context) => Scaffold( - body: TextButton( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: _testChannel), - ), - ), - child: const Text('Open channel'), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - final retired = >[]; - for (var visit = 0; visit < 3; visit++) { - retired.addAll(await _visitAndLeaveChannel(tester)); - } - // Keep the same app, navigator and semantics owner alive during GC. - await tester.runAsync(() => forceGC(fullGcCycles: 3)); - expect( - retired.where((reference) => reference.target != null), - isEmpty, - reason: 'Departed routes, composers and their portals must be released', - ); - } finally { - semantics.dispose(); - } - }); -} - -Future>> _visitAndLeaveChannel( - WidgetTester tester, -) async { - await tester.tap(find.text('Open channel')); - await tester.pumpAndSettle(); - expect(find.text('Message #general'), findsOneWidget); - final composer = find.byType(ComposeBar); - final portal = find.descendant( - of: composer, - matching: find.byType(OverlayPortal), - ); - expect(portal, findsWidgets); - final retired = >[ - for (final state in tester.stateList(portal)) WeakReference(state), - WeakReference(tester.element(composer)), - WeakReference(ModalRoute.of(tester.element(composer))!), - ]; - Navigator.of(tester.element(composer)).pop(); - await tester.pumpAndSettle(); - expect(find.byType(ChannelDetailPage), findsNothing); - expect(find.text('Open channel'), findsOneWidget); - return retired; -} From 5b433d92926504370c6e12f651d5d010b5af33c2 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 19 Oct 2026 10:32:18 -0400 Subject: [PATCH 17/28] feat(relay): resolve NIP-FI issuer bindings from the database (#8377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stack: #8372 (merged) → this PR → #8399 → #8401 Makes relay login checks read the per-community NIP-FI issuer bindings that #8372 stores. Without this, the relay can't keep up with bb-public, where new communities are created at every signup and a static `BUZZ_NIP_FI_COMMUNITIES` list won't do. **Source switch.** `BUZZ_NIP_FI_COMMUNITY_SOURCE=database` resolves each community's trusted issuers from `community_nip_fi_issuers`, filtered by the issuer registry, instead of the static list. Each community gets its own policy ID: the base ID no longer hashes every community audience, and the final ID hashes the base plus this community's audience, so policy IDs change once (see `docs/operator-nip-fi-bindings.md`). `Config::from_env` refuses to start, in every mode, on an unknown source, on `database` together with `BUZZ_NIP_FI_COMMUNITIES`, and on `database` without `BUZZ_NIP_FI_PROVISION_BINDING`. `BUZZ_NIP_FI_RESOLVER_*` and `BUZZ_NIP_FI_SNAPSHOT_*` are validated in every mode, so a bad value fails the deploy that sets it. In `off` mode no resolver is built and no snapshot is loaded. **Resolver (`nip_fi_resolver.rs`).** - **Snapshot:** each pod loads every binding into memory, capped at `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` (going over is a failed load). It refreshes every 30 s, start to start, with jitter that only shortens the delay and backoff after failures. Each attempt is limited by `BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS`, which must be shorter than the interval. A refresh whose deadline passes before it publishes publishes nothing. The old snapshot is dropped off the runtime. - **Freshness:** a host in a snapshot loaded under two minutes ago is answered from memory. Any other host, and every host once the snapshot is stale, goes to a live lookup. Every answer carries a deadline two minutes after the read behind it started, and WS, HTTP and audio recheck it just before admitting. - **Live lookups:** bounded and single-flight. In-flight lookups are capped by `BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT` (default 8, clamped below the database pool size), and the pending hosts and their waiters have fixed caps. Positive answers are used for that admission only. A "not found" is cached for 5 s, up to `BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX` hosts, and only when no snapshot was published and no binding was created while it ran. A lookup that panics still leaves the pending map. - **Removal:** a removed binding stops admitting new connections within 30 s plus the refresh timeout (40 s by default) while refreshes succeed, and within two minutes of the last good load while they fail. Open WebSocket sessions are not rechecked when a binding is removed; they end at their own session deadline. **Shadow mode** resolves through the same path as enforce, including the database lookup and the freshness rules. It keeps the resolved binding's deadline and checks it at the same point as enforce; a binding that goes stale during the tenant lookup is recorded as `stage=deadline, outcome=unavailable`, without changing the legacy response or closing the socket. With `source=database` its `community` metric label is only `bound` or `unmapped`; with `source=env` it keeps the existing per-community audience label, so current shadow dashboards are unchanged. Each request resolves its Host once: the router guard's resolution, failures included, is reused by shadow recording and admission. **Provisioning.** Both operator provisioning paths drop this pod's cached "not found" for the new host after a commit that bound issuers, so the creator's first login looks it up. **Startup and readiness.** With `source=database` and a mode other than `off`, the first snapshot loads before any listener binds, under its own startup phase `nip_fi_snapshot_load`. A failed load exits non-zero, so a pod is never ready without a snapshot. **Operational consequence:** boot already fails when the database is unreachable; what is new is that a snapshot over `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` or a load past its timeout also crash-loops new pods. After two minutes the old pods' snapshots go stale and most logins fall through to the 8-slot live lookup, which sheds the rest with 503. The way out is raising the cap or timeout, or `mode=off`. **Metrics.** `buzz_nip_fi_binding_snapshot_*` covers loads, failures, rows and freshness. `buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds` is written on every refresh attempt, so alert on `time() - x > 120` together with `absent(x)`. `buzz_nip_fi_binding_snapshot_rows` against `buzz_nip_fi_binding_snapshot_max_rows` gives the headroom to the row cap. `buzz_nip_fi_resolver_*` covers live lookups and the negative cache. The env vars and alerts are documented in `.env.example`, the chart README and `docs/operator-nip-fi-bindings.md`, which also has the staged off → shadow → enforce rollout for `source=database` with go/no-go signals at each step, and the over-cap and load-timeout failure modes with their ways out. **Regression coverage.** Router-level tests run the database resolver through the real `build_router` for root WS, audio and protected HTTP, in enforce and shadow mode. Over PostgreSQL and Redis, two pods using the env community source (two exact bindings) replay assertions retained from before a disconnect through a real root NIP-42 session, the audio deny-set check and signed NIP-98 requests: they are denied on both pods, and a root session upgraded before the disconnect is refused by the post-registration deny-set check. Postgres tests in `buzz-db` check that archived and quiescing communities, and issuers missing from the registry, never reach the snapshot or the live lookup. Process-level `boot_lifecycle` tests spawn the real relay with `source=database`: an over-cap or timed-out startup load exits non-zero with no health listener bound, and a loadable snapshot lets readiness turn ready. --------- Signed-off-by: Will Pfleger Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Duncan --- .env.example | 15 + Cargo.lock | 5 + Cargo.toml | 2 + Justfile | 5 +- crates/buzz-auth/src/nip_fi/command.rs | 25 + crates/buzz-auth/src/nip_fi/config.rs | 40 +- crates/buzz-auth/src/nip_fi/verifier.rs | 2 +- crates/buzz-auth/src/nip_fi/verifier/tests.rs | 56 +- crates/buzz-db/src/lib.rs | 2 +- crates/buzz-db/src/store/nip_fi_binding.rs | 205 +++ crates/buzz-relay/Cargo.toml | 2 + crates/buzz-relay/src/api/bridge.rs | 27 +- crates/buzz-relay/src/api/buzz_v1/auth.rs | 1 + crates/buzz-relay/src/api/git/settings.rs | 3 +- crates/buzz-relay/src/api/git/transport.rs | 7 +- crates/buzz-relay/src/api/invites.rs | 4 +- crates/buzz-relay/src/api/media.rs | 21 +- crates/buzz-relay/src/api/nip_fi.rs | 513 +++++++- crates/buzz-relay/src/api/workflows.rs | 3 +- crates/buzz-relay/src/audio/handler.rs | 108 +- crates/buzz-relay/src/config.rs | 97 +- crates/buzz-relay/src/handlers/auth.rs | 5 +- .../src/handlers/community_provisioning.rs | 24 + crates/buzz-relay/src/lib.rs | 1 + crates/buzz-relay/src/main.rs | 34 + crates/buzz-relay/src/nip_fi_config.rs | 190 ++- crates/buzz-relay/src/nip_fi_core.rs | 115 +- crates/buzz-relay/src/nip_fi_http.rs | 223 +++- crates/buzz-relay/src/nip_fi_resolver.rs | 875 +++++++++++++ .../buzz-relay/src/nip_fi_resolver/tests.rs | 1097 +++++++++++++++++ .../src/nip_fi_resolver/tests/router_tests.rs | 817 ++++++++++++ crates/buzz-relay/src/nip_fi_shadow.rs | 121 +- .../buzz-relay/src/nip_fi_shadow_session.rs | 33 +- crates/buzz-relay/src/nip_fi_upgrade.rs | 184 ++- crates/buzz-relay/src/router.rs | 74 +- crates/buzz-relay/src/startup_steps.rs | 6 +- crates/buzz-relay/src/state.rs | 16 + crates/buzz-relay/tests/boot_lifecycle.rs | 188 ++- deploy/charts/buzz/README.md | 9 + docs/operator-nip-fi-bindings.md | 118 ++ scripts/run-tests.sh | 3 + 41 files changed, 4927 insertions(+), 349 deletions(-) create mode 100644 crates/buzz-relay/src/nip_fi_resolver.rs create mode 100644 crates/buzz-relay/src/nip_fi_resolver/tests.rs create mode 100644 crates/buzz-relay/src/nip_fi_resolver/tests/router_tests.rs diff --git a/.env.example b/.env.example index 6674deec40..2af5dc36bb 100644 --- a/.env.example +++ b/.env.example @@ -372,3 +372,18 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here." # BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here." # BUZZ_AGE_ATTESTATION_REQUIRED=true + +# ── NIP-FI database community source ───────────────────────────────────────── +# Where NIP-FI communities come from: "env" (BUZZ_NIP_FI_COMMUNITIES, default) +# or "database" (bindings created by provisioning; needs +# BUZZ_NIP_FI_PROVISION_BINDING and must not be combined with +# BUZZ_NIP_FI_COMMUNITIES). See docs/operator-nip-fi-bindings.md. +# BUZZ_NIP_FI_COMMUNITY_SOURCE=env +# Most community-issuer rows one snapshot may hold (1..=10000000). +# BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS=1000000 +# Snapshot load timeout in ms; must be under the 30s refresh interval. +# BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS=10000 +# Hosts whose "not found" is cached for 5s (1..=1000000). +# BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX=100000 +# Concurrent live lookups; clamped below the database pool size. +# BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT=8 diff --git a/Cargo.lock b/Cargo.lock index 57d4a9ff54..d266b8580a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1424,6 +1424,7 @@ name = "buzz-relay" version = "0.2.1" dependencies = [ "anyhow", + "arc-swap", "async-compression", "async-trait", "axum", @@ -1454,6 +1455,7 @@ dependencies = [ "http-body-util", "infer", "jsonwebtoken", + "lru 0.16.4", "metrics", "metrics-exporter-prometheus", "metrics-util", @@ -5182,6 +5184,9 @@ name = "lru" version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] [[package]] name = "lru" diff --git a/Cargo.toml b/Cargo.toml index df77ab7cb3..6207d5a8c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,6 +124,8 @@ zeroize = "1.8" # Concurrent data structures dashmap = "6" moka = { version = "0.12", features = ["sync"] } +arc-swap = "1" +lru = "0.16" # Async stream utilities futures-util = "0.3" diff --git a/Justfile b/Justfile index ca89c5f35b..e479c467f4 100644 --- a/Justfile +++ b/Justfile @@ -522,7 +522,8 @@ test-unit: # unit job either. The REQ subscription-lifecycle tests are picked by # exact name for the same reason: the rest of handlers::req needs a DB. # The third clause adds the NIP-FI HTTP ingress and its router/config - # neighbours: nip_fi_http, nip_fi_config, router, api::parse_query_tests, + # neighbours: nip_fi_http, nip_fi_config, nip_fi_resolver (snapshot + # resolver and its WS/HTTP/audio router tests), router, api::parse_query_tests, # the profile search request bounds (api::profiles), and the Git # transport off_mode_precedence_tests. All are infra-free # and finish in well under a second with no DATABASE_URL, so none wait @@ -536,7 +537,7 @@ test-unit: # are infra-free; config parses env into a struct and never connects. # `^config::` is anchored so it does not also select nip_fi_config. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^nip_fi_resolver::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' # Note on audio::join::tests scope: the full suite is infra-free (no DB, # no Redis). The infra-free audio/FI regression witnesses — bootstrap # ordering barrier, CommitConfirmed arm, pending-close invisibility, diff --git a/crates/buzz-auth/src/nip_fi/command.rs b/crates/buzz-auth/src/nip_fi/command.rs index 83b864498c..deeae99e75 100644 --- a/crates/buzz-auth/src/nip_fi/command.rs +++ b/crates/buzz-auth/src/nip_fi/command.rs @@ -1214,6 +1214,31 @@ mod tests { ); } + #[test] + fn until_at_maximum_assertion_lifetime_is_accepted_and_one_second_more_is_not() { + // An issuer's `until` is the expiry of the last assertion it issued, so + // a maximum-lifetime assertion minted now (exp = now + max_age) must + // fit the ceiling now + skew + max_age, both at issue time and on a + // re-send just before that deadline. The test policy is skew 30 s, + // maximum assertion age 3600 s. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + for until_offset in [3600, 30 + 3600, 1] { + let token = mint_cmd_jwt(&target, until_offset, serde_json::json!({})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now).map(|_| ()), + Ok(()), + "until = now + {until_offset}s must fit the ceiling" + ); + } + let token = mint_cmd_jwt(&target, 30 + 3600 + 1, serde_json::json!({})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now).map(|_| ()), + Err(CommandError::UntilExceedsCeiling), + ); + } + // ── jti is the LAST step ───────────────────────────────────────────────── #[test] diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 26aad88115..99231d82d5 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -368,10 +368,6 @@ pub struct IssuerPolicy { /// Accepted `aud` values for S4 command JWTs only. Assertion `aud` comes /// from the per-community [`super::CommunityBinding`], never from here. command_audiences: Vec, - /// Expected `aud` of every community authorizing this issuer — the - /// issuer's side of the community allowlist (NIP-FI.md:145). Empty until - /// [`IssuerPolicy::authorized_for_communities`] sets it. - community_audiences: Vec, token_class: TokenClass, freshness: FreshnessClass, algorithms: Vec, @@ -488,7 +484,6 @@ impl IssuerPolicy { let mut policy = Self { issuer, command_audiences: canonical_set(command_audiences), - community_audiences: Vec::new(), token_class, freshness, algorithms: canonical_algorithm_set(algorithms), @@ -503,15 +498,18 @@ impl IssuerPolicy { Ok(policy) } - /// Record the expected `aud` of every community that authorizes this - /// issuer and re-derive the policy ID over it, so changing the community - /// allowlist moves the ID (NIP-FI.md:145). Set-valued: order and - /// duplicates do not affect the ID. - #[must_use] - pub fn authorized_for_communities(mut self, community_audiences: Vec) -> Self { - self.community_audiences = canonical_set(community_audiences); - self.id = derive_assertion_policy_id(&self); - self + /// The policy ID an assertion verified for the community expecting + /// `community_aud` carries: `H(base policy contract, community aud)`. The + /// base contract already commits to the issuer. The verifier mints it + /// only after the community's allowlist admitted this issuer, so it moves + /// exactly when this community's association or the base policy changes, + /// never when an unrelated community is added (NIP-FI.md:145). + pub fn id_for_community(&self, community_aud: &str) -> AssertionPolicyId { + let mut hasher = Sha256::new(); + hasher.update(b"buzz:nip-fi:community-assertion-policy:v1\0"); + hasher.update(self.id.0); + hash_field(&mut hasher, community_aud.as_bytes()); + AssertionPolicyId(hasher.finalize().into()) } /// The exact `iss` value this policy is selected by. @@ -524,11 +522,6 @@ impl IssuerPolicy { &self.command_audiences } - /// The expected `aud` of each community authorizing this issuer. - pub fn community_audiences(&self) -> &[String] { - &self.community_audiences - } - /// The single accepted token class. pub fn token_class(&self) -> &TokenClass { &self.token_class @@ -668,7 +661,6 @@ fn derive_assertion_policy_id(policy: &IssuerPolicy) -> AssertionPolicyId { let IssuerPolicy { issuer, command_audiences, - community_audiences, token_class, freshness, algorithms, @@ -699,10 +691,6 @@ fn derive_assertion_policy_id(policy: &IssuerPolicy) -> AssertionPolicyId { } hash_field(&mut hasher, issuer.as_bytes()); hash_seq(&mut hasher, command_audiences.iter().map(String::as_bytes)); - hash_seq( - &mut hasher, - community_audiences.iter().map(String::as_bytes), - ); hash_field(&mut hasher, token_class.discriminant().as_bytes()); match token_class { TokenClass::AccessTokenAtJwt { subject_class } => { @@ -804,7 +792,7 @@ mod jwks_transport_identity_tests { assert_eq!( hex::encode(policy(contract).id().as_bytes()), - "1f904aaaad1c1db3a22ae50bd2d24cdf7181d5d0b8c5006cc1ae9ac1107f9832", + "fae0dd479d79306c5eef135594f678a5cdf3a2ee45770e20a7e83395fc76857f", "public_https mode omits the mesh transport tag" ); } @@ -820,7 +808,7 @@ mod jwks_transport_identity_tests { assert_eq!( hex::encode(policy(contract).id().as_bytes()), - "33e4cf02148ca494c12ffc49cd0bb223e0e007de4084ac5b406f2742bbb91c0a", + "dca3e998388ae75bdea6bb2070c8f28b8d389a8b874de96e7852dd1f71b14bcd", "mesh transport is part of policy identity" ); } diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index 34bd711c28..750c617324 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -463,7 +463,7 @@ impl FederatedAssertionVerifier { asserted_key, capabilities, deadlines, - policy.id(), + policy.id_for_community(community.expected_aud()), self.transport_contract_id, RevalidationDependencies::new( header.kid, diff --git a/crates/buzz-auth/src/nip_fi/verifier/tests.rs b/crates/buzz-auth/src/nip_fi/verifier/tests.rs index bdab25370c..77ce87a154 100644 --- a/crates/buzz-auth/src/nip_fi/verifier/tests.rs +++ b/crates/buzz-auth/src/nip_fi/verifier/tests.rs @@ -268,7 +268,10 @@ fn valid_access_token_verifies() { // Spec v2: nostr_pubkey is injected by mint() and unconditionally required. assert!(assertion.asserted_key().is_some()); assert!(!assertion.authority_deadlines().is_empty()); - assert_eq!(assertion.assertion_policy_id(), access_token_policy().id()); + assert_eq!( + assertion.assertion_policy_id(), + access_token_policy().id_for_community(AUDIENCE) + ); } // ---- Token class / typ enforcement, ID-token denial ---------------------- @@ -1943,22 +1946,41 @@ fn aud_array_containing_expected_passes() { } #[test] -fn assertion_policy_id_covers_community_allowlist() { - let base = dedicated_policy(ISSUER); - let a = base - .clone() - .authorized_for_communities(vec![AUDIENCE.to_owned(), COMMUNITY_B_AUD.to_owned()]); - let permuted = base.clone().authorized_for_communities(vec![ - COMMUNITY_B_AUD.to_owned(), - AUDIENCE.to_owned(), - AUDIENCE.to_owned(), - ]); - let fewer = base - .clone() - .authorized_for_communities(vec![AUDIENCE.to_owned()]); - assert_ne!(base.id(), a.id()); - assert_eq!(a.id(), permuted.id()); - assert_ne!(a.id(), fewer.id()); +fn assertion_policy_id_covers_community_audience_and_issuer() { + let policy = dedicated_policy(ISSUER); + let other_issuer = dedicated_policy("https://other-issuer.example"); + let here = policy.id_for_community(AUDIENCE); + // Stable for one (base policy, community, issuer) association. + assert_eq!(here, policy.clone().id_for_community(AUDIENCE)); + // A different community `aud` or issuer moves the ID. + assert_ne!(here, policy.id_for_community(COMMUNITY_B_AUD)); + assert_ne!(here, other_issuer.id_for_community(AUDIENCE)); + // The per-community ID is not the bare base-policy ID. + assert_ne!(here, policy.id()); +} + +#[test] +fn verified_assertion_carries_the_per_community_policy_id() { + let verifier = verifier_with(dedicated_policy(ISSUER)); + let token = mint(Some("nip-fi+jwt"), TEST_KID, json!({ "sub": "user-123" })); + let assertion = verifier + .verify(&token, &community(AUDIENCE, &[ISSUER])) + .expect("verifies"); + // An unrelated community authorizing the same issuer leaves it unchanged. + let unchanged = verifier + .verify( + &token, + &community(AUDIENCE, &[ISSUER, "https://unrelated.example"]), + ) + .expect("verifies"); + assert_eq!( + assertion.assertion_policy_id(), + dedicated_policy(ISSUER).id_for_community(AUDIENCE) + ); + assert_eq!( + assertion.assertion_policy_id(), + unchanged.assertion_policy_id() + ); } #[test] diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 459ddd48ed..e13b06c81d 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -82,7 +82,7 @@ pub use community::{ pub use error::{DbError, Result}; pub use event::{ChannelHeadPrecondition, ChannelHeadWriteStatus}; pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; -pub use nip_fi_binding::{NipFiBindingRecord, NipFiUnbind}; +pub use nip_fi_binding::{NipFiBindingRecord, NipFiUnbind, RowCapExceeded}; pub use reaction::ReactionEventInsertOutcome; pub use reminder::DueReminder; pub use usage::UsageMetricsLeader; diff --git a/crates/buzz-db/src/store/nip_fi_binding.rs b/crates/buzz-db/src/store/nip_fi_binding.rs index 3f49f5c875..1e193e2b4c 100644 --- a/crates/buzz-db/src/store/nip_fi_binding.rs +++ b/crates/buzz-db/src/store/nip_fi_binding.rs @@ -311,6 +311,86 @@ impl Db { } } +/// A snapshot load produced more rows than its cap allows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("row cap of {0} exceeded")] +pub struct RowCapExceeded(pub usize); + +impl Db { + /// Read every binding of an active community to an issuer in `registry`, + /// on the primary, as `(host, issuer)` rows, one per community-issuer + /// pair. It is one `SELECT`, so every row comes from the same statement + /// snapshot. The read stops at the `max_rows + 1`st row and fails. + #[datastore_span(name = "load_nip_fi_binding_snapshot", system = "postgresql")] + pub async fn load_nip_fi_binding_snapshot( + &self, + registry: &[String], + max_rows: usize, + ) -> Result, RowCapExceeded>> { + use futures_util::StreamExt; + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut stream = sqlx::query_as::<_, (String, String)>( + r#" + SELECT c.host, b.issuer + FROM community_nip_fi_issuers b + JOIN communities c ON c.id = b.community_id + WHERE b.issuer = ANY($1) + AND c.archived_at IS NULL + AND c.deletion_state = 'active' + AND c.deleted_at IS NULL + "#, + ) + .bind(registry) + .fetch(&mut *connection); + let mut rows = Vec::new(); + while let Some(row) = stream.next().await { + if rows.len() == max_rows { + return Ok(Err(RowCapExceeded(max_rows))); + } + rows.push(row?); + } + Ok(Ok(rows)) + } + + /// The registry-filtered issuers bound to the active community at + /// `normalized_host`, on the primary. Empty when the host is unknown, + /// inactive, or bound only to unregistered issuers. The primary key + /// `(community_id, issuer)` bounds the result at `registry.len()` rows. + #[datastore_span(name = "lookup_nip_fi_admission_binding", system = "postgresql")] + pub async fn lookup_nip_fi_admission_binding( + &self, + normalized_host: &str, + registry: &[String], + ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let issuers = sqlx::query_scalar::<_, String>( + r#" + SELECT b.issuer + FROM community_nip_fi_issuers b + JOIN communities c ON c.id = b.community_id + WHERE lower(c.host) = lower($1) + AND b.issuer = ANY($2) + AND c.archived_at IS NULL + AND c.deletion_state = 'active' + AND c.deleted_at IS NULL + "#, + ) + .bind(normalized_host) + .bind(registry) + .fetch_all(&mut *connection) + .await?; + Ok(issuers) + } +} + #[cfg(test)] mod postgres_tests { use super::*; @@ -643,4 +723,129 @@ mod postgres_tests { ); assert_eq!(bound(&db, &unique_host("missing")).await, None); } + + /// Move `host`'s community out of `active` the way the deletion executor + /// does: the tombstone trigger admits the state change only under the + /// executor's community and fence-generation settings. + async fn start_deleting(db: &Db, host: &str) { + let mut tx = db.pool.begin().await.expect("begin"); + let id: Uuid = sqlx::query_scalar("SELECT id FROM communities WHERE host = $1") + .bind(host) + .fetch_one(&mut *tx) + .await + .expect("community id"); + sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true), set_config('buzz.deletion_fence_generation', '1', true)") + .bind(id.to_string()) + .execute(&mut *tx) + .await + .expect("executor settings"); + sqlx::query("UPDATE communities SET deletion_state = 'quiescing', deletion_fence_generation = 1 WHERE id = $1") + .bind(id) + .execute(&mut *tx) + .await + .expect("start deletion"); + tx.commit().await.expect("commit"); + } + + async fn snapshot_hosts(db: &Db, registry: &[String]) -> Vec { + db.load_nip_fi_binding_snapshot(registry, 100_000) + .await + .expect("snapshot") + .expect("under cap") + .into_iter() + .map(|(host, _)| host) + .collect() + } + + async fn admission(db: &Db, host: &str, registry: &[String]) -> Vec { + db.lookup_nip_fi_admission_binding(host, registry) + .await + .expect("admission lookup") + } + + // Criterion 14(a): archived and deleting communities drop out of both the + // snapshot load and the admission miss lookup; an active control stays. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn snapshot_and_admission_lookup_skip_archived_and_deleting_communities() { + let db = setup_db().await; + let registry = issuers(); + let [archived, deleting, control] = ["archived", "deleting", "control"].map(unique_host); + for host in [&archived, &deleting, &control] { + db.ensure_configured_community_with_nip_fi_issuers(host, ®istry) + .await + .expect("seed"); + assert!(snapshot_hosts(&db, ®istry).await.contains(host)); + assert_eq!(admission(&db, host, ®istry).await, registry); + } + + sqlx::query("UPDATE communities SET archived_at = now() WHERE host = $1") + .bind(&archived) + .execute(&db.pool) + .await + .expect("archive"); + start_deleting(&db, &deleting).await; + + let hosts = snapshot_hosts(&db, ®istry).await; + for gone in [&archived, &deleting] { + assert!(!hosts.contains(gone), "{gone} must leave the snapshot"); + assert!(admission(&db, gone, ®istry).await.is_empty(), "{gone}"); + } + assert!(hosts.contains(&control)); + assert_eq!(admission(&db, &control, ®istry).await, registry); + } + + // Criterion 17(a)(1): only registered issuers come back, and a host bound + // only to unregistered issuers yields zero rows from the real SQL. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn admission_lookup_returns_only_registered_issuers() { + let db = setup_db().await; + let other = "https://unregistered.example".to_string(); + let mixed = unique_host("mixed"); + let foreign = unique_host("foreign"); + db.ensure_configured_community_with_nip_fi_issuers( + &mixed, + &[ISS.to_string(), other.clone()], + ) + .await + .expect("seed mixed"); + db.ensure_configured_community_with_nip_fi_issuers(&foreign, std::slice::from_ref(&other)) + .await + .expect("seed foreign"); + + assert_eq!( + admission(&db, &mixed.to_uppercase(), &issuers()).await, + issuers() + ); + assert!(admission(&db, &foreign, &issuers()).await.is_empty()); + assert!(!snapshot_hosts(&db, &issuers()).await.contains(&foreign)); + } + + // The load fails at the `max_rows + 1`st row instead of truncating. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn snapshot_load_over_its_row_cap_fails() { + let db = setup_db().await; + let registry = vec![format!("https://issuer-{}.example", uuid::Uuid::new_v4())]; + for prefix in ["cap-a", "cap-b"] { + db.ensure_configured_community_with_nip_fi_issuers(&unique_host(prefix), ®istry) + .await + .expect("seed"); + } + assert_eq!( + db.load_nip_fi_binding_snapshot(®istry, 1) + .await + .expect("load"), + Err(RowCapExceeded(1)) + ); + assert_eq!( + db.load_nip_fi_binding_snapshot(®istry, 2) + .await + .expect("load") + .expect("at cap") + .len(), + 2 + ); + } } diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index d83c5e4f73..6a4ab379fd 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -78,6 +78,8 @@ rand = { workspace = true } hex = { workspace = true } url = { workspace = true } moka = { workspace = true } +arc-swap = { workspace = true } +lru = { workspace = true } metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } metrics-util = { workspace = true } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 9bc2940a81..de54b8232c 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -63,7 +63,8 @@ pub(crate) async fn authenticate_member_post( true, true, ), - )?; + ) + .await?; let pubkey = *admission.proven_pubkey(); let (event_id_bytes, signed_created_at) = admission.into_extra(); @@ -1004,7 +1005,8 @@ pub async fn submit_event( // only available through the returned NipFiAdmission. [FI-TRACE-AUTHORITY-UNIFORM] crate::nip_fi_shadow::observe_strict_proof(&state, &headers, "bridge", || { verify_bridge_auth_with_options(&headers, "POST", &url, Some(&body), true, true).map(drop) - }); + }) + .await; let admission = admit_nip_fi_http_on_state(&state, &headers, || { verify_bridge_auth_with_options( &headers, @@ -1016,7 +1018,8 @@ pub async fn submit_event( ) .map(|auth| auth.proof((auth.event_id_bytes, auth.signed_created_at))) .map_err(|e| e.into_response()) - })?; + }) + .await?; let pubkey = *admission.proven_pubkey(); let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); @@ -1356,7 +1359,8 @@ pub async fn query_events( // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] crate::nip_fi_shadow::observe_strict_proof(&state, &headers, "bridge", || { verify_bridge_auth_with_options(&headers, "POST", &url, Some(&body), true, true).map(drop) - }); + }) + .await; let admission = admit_nip_fi_http_on_state(&state, &headers, || { verify_bridge_auth_with_options( &headers, @@ -1368,7 +1372,8 @@ pub async fn query_events( ) .map(|auth| auth.proof((auth.event_id_bytes, auth.signed_created_at))) .map_err(|e| e.into_response()) - })?; + }) + .await?; let pubkey = *admission.proven_pubkey(); let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); @@ -2031,7 +2036,8 @@ pub async fn count_events( // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] crate::nip_fi_shadow::observe_strict_proof(&state, &headers, "bridge", || { verify_bridge_auth_with_options(&headers, "POST", &url, Some(&body), true, true).map(drop) - }); + }) + .await; let admission = admit_nip_fi_http_on_state(&state, &headers, || { verify_bridge_auth_with_options( &headers, @@ -2043,7 +2049,8 @@ pub async fn count_events( ) .map(|auth| auth.proof((auth.event_id_bytes, auth.signed_created_at))) .map_err(|e| e.into_response()) - })?; + }) + .await?; let pubkey = *admission.proven_pubkey(); let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); @@ -2873,7 +2880,8 @@ async fn authorize_moderation_read( // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] crate::nip_fi_shadow::observe_strict_proof(state, headers, "bridge", || { verify_bridge_auth(headers, "GET", &url, None, true).map(drop) - }); + }) + .await; let admission = admit_nip_fi_http_on_state(state, headers, || { verify_bridge_auth( headers, @@ -2884,7 +2892,8 @@ async fn authorize_moderation_read( ) .map(|auth| auth.proof(auth.event_id_bytes)) .map_err(|e| e.into_response()) - })?; + }) + .await?; let pubkey = *admission.proven_pubkey(); let event_id_bytes = admission.into_extra(); diff --git a/crates/buzz-relay/src/api/buzz_v1/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs index dd65d3dad6..6a3b731f03 100644 --- a/crates/buzz-relay/src/api/buzz_v1/auth.rs +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -110,6 +110,7 @@ pub(super) async fn authorize( body.is_some(), ), ) + .await .map_err(|response| { if state.config.nip_fi.mode.restricts() { // Preserve the shared NIP-FI wire contract, not just its status. diff --git a/crates/buzz-relay/src/api/git/settings.rs b/crates/buzz-relay/src/api/git/settings.rs index a4f4edeaf8..3602c8a560 100644 --- a/crates/buzz-relay/src/api/git/settings.rs +++ b/crates/buzz-relay/src/api/git/settings.rs @@ -193,7 +193,8 @@ async fn authenticate( true, // require_auth_token: always required for control-plane require_payload, ), - )?; + ) + .await?; let pubkey = *admission.proven_pubkey(); let (event_id_bytes, signed_created_at) = admission.into_extra(); diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index c3b292d8b4..d1ace7b62d 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -105,8 +105,8 @@ impl axum::extract::FromRequestParts> for GitAuth { if !mode.restricts() { if let Err(rejection) = parse_git_auth_header(&parts.headers, method) { // Off returns `rejection` as is; shadow also records the - // enforce verdict for the failed proof, still with no DB work. - crate::nip_fi_http::observe_failed_proof(state, &parts.headers); + // enforce verdict for the failed proof. + crate::nip_fi_http::observe_failed_proof(state, &parts.headers).await; return Err(rejection); } } @@ -195,7 +195,8 @@ impl axum::extract::FromRequestParts> for GitAuth { (event, signed_auth_created_at), )) }, - )?; + ) + .await?; let pubkey = *admission.proven_pubkey(); let (event, signed_auth_created_at) = admission.into_extra(); diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index bff1df0fcc..45fa15e1df 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -317,7 +317,9 @@ async fn mint_invite_checked( true, // invites always require NIP-98; no X-Pubkey dev fallback true, // POST bodies must be covered by a payload tag ), - ) { + ) + .await + { Ok(a) => a, Err(resp) => return resp, }; diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 6864cb15ae..470768e308 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -338,7 +338,8 @@ pub(crate) async fn upload_blob( let strict = BlossomStrictness::Strict; buzz_media::auth::verify_blossom_auth_event(&event, Some(&tenant_host), strict) .map_err(drop) - }); + }) + .await; let headers_clone = headers.clone(); let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { let auth_event = extract_blossom_auth(&headers_clone).map_err(|e| e.into_response())?; @@ -348,7 +349,9 @@ pub(crate) async fn upload_blob( .map_err(|e| e.into_response())?; let pubkey = auth_event.pubkey; Ok(Nip98Proof::new(pubkey, auth_event)) - }) { + }) + .await + { Ok(a) => a, Err(resp) => return resp, }; @@ -818,11 +821,14 @@ pub(crate) async fn get_blob( BlossomStrictness::Strict, ) .map(drop) - }); + }) + .await; let admission = match admit_nip_fi_http_on_state(&state, &req_headers, move || { extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host, strictness) .map_err(|e| e.into_response()) - }) { + }) + .await + { Ok(a) => a, Err(resp) => return Ok(resp), }; @@ -1112,11 +1118,14 @@ pub(crate) async fn head_blob( crate::nip_fi_shadow::observe_strict_proof(&state, &headers, "blossom", || { extract_blossom_read_proof(&headers, &sha256, &tenant_host, BlossomStrictness::Strict) .map(drop) - }); + }) + .await; let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host, strictness) .map_err(|e| e.into_response()) - }) { + }) + .await + { Ok(a) => a, Err(resp) => return Ok(resp), }; diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index b3e4267cf6..39a24bbdc3 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -1930,6 +1930,7 @@ mod route_integration_tests { }, )]; config.nip_fi = NipFiRelayConfig { + source: Default::default(), mode: NipFiMode::Enforce, registry: registry.clone(), jwks_configs: jwks_configs.clone(), @@ -2511,7 +2512,7 @@ mod route_integration_tests { /// for `key`. `Ok(())` = admitted; `Err` = the denial response. // Response is intentionally large (axum's design); see admit_nip_fi_http. #[allow(clippy::result_large_err)] - fn http_admit( + async fn http_admit( state: &crate::state::AppState, iss: &str, key: &nostr::PublicKey, @@ -2526,6 +2527,7 @@ mod route_integration_tests { crate::nip_fi_http::admit_nip_fi_http_on_state(state, &headers, || { Ok(crate::nip_fi_http::Nip98Proof::new(*key, ())) }) + .await .map(|_| ()) } @@ -2551,7 +2553,7 @@ mod route_integration_tests { let key = nostr::Keys::generate().public_key(); let mut state = (*http_enforce_state().await).clone(); assert!( - http_admit(&state, TEST_ISS, &key).is_ok(), + http_admit(&state, TEST_ISS, &key).await.is_ok(), "control: the assertion is admitted on its own community" ); Arc::make_mut(&mut state.config).nip_fi.communities = @@ -2559,7 +2561,9 @@ mod route_integration_tests { "https://relay.example", &[TEST_ISS, OTHER_ISS], ); - let resp = http_admit(&state, TEST_ISS, &key).expect_err("A's aud on B's Host"); + let resp = http_admit(&state, TEST_ISS, &key) + .await + .expect_err("A's aud on B's Host"); assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await @@ -2572,13 +2576,14 @@ mod route_integration_tests { let state = http_enforce_state().await; let key = nostr::Keys::generate().public_key(); assert!( - http_admit(&state, TEST_ISS, &key).is_ok(), + http_admit(&state, TEST_ISS, &key).await.is_ok(), "control: (iss-A, k) is admitted before any deny" ); deny_via_route(&state, &key, 300).await; let resp = http_admit(&state, TEST_ISS, &key) + .await .expect_err("(iss-A, k) must be denied at HTTP ingress after the route denies it"); assert_fixed_authorization_denied(resp, "(iss-A, k) HTTP denial").await; } @@ -2590,11 +2595,11 @@ mod route_integration_tests { deny_via_route(&state, &key, 300).await; assert!( - http_admit(&state, TEST_ISS, &key).is_err(), + http_admit(&state, TEST_ISS, &key).await.is_err(), "control: (iss-A, k) is denied" ); assert!( - http_admit(&state, OTHER_ISS, &key).is_ok(), + http_admit(&state, OTHER_ISS, &key).await.is_ok(), "a deny for (iss-A, k) must not block (iss-B, k)" ); } @@ -2624,8 +2629,9 @@ mod route_integration_tests { .expect("router response"); assert_fixed_authorization_denied(ws_resp, "WS admission sees the route's deny").await; - let http_resp = - http_admit(&state, TEST_ISS, &key).expect_err("HTTP admission sees the same deny"); + let http_resp = http_admit(&state, TEST_ISS, &key) + .await + .expect_err("HTTP admission sees the same deny"); assert_fixed_authorization_denied(http_resp, "HTTP admission sees the route's deny").await; } @@ -2656,6 +2662,7 @@ mod route_integration_tests { crate::nip_fi_http::admit_nip_fi_http_on_state(&state, &HeaderMap::new(), || { Ok(crate::nip_fi_http::Nip98Proof::new(key, 7u8)) }) + .await .expect("Off mode admits on NIP-98 success regardless of deny entries"); assert_eq!(admitted.proven_pubkey(), &key); assert!(admitted.assertion().is_none()); @@ -2673,6 +2680,7 @@ mod route_integration_tests { &HeaderMap::new(), || Err(legacy()), ) + .await .expect_err("Off mode propagates the NIP-98 failure"); let expected = legacy(); assert_eq!(resp.status(), expected.status()); @@ -3548,6 +3556,7 @@ mod route_integration_tests { }, )]; config.nip_fi = NipFiRelayConfig { + source: Default::default(), mode: NipFiMode::Shadow, registry: registry.clone(), jwks_configs: vec![test_jwks_config()], @@ -3623,7 +3632,9 @@ mod route_integration_tests { /// Run `state`'s production disconnect subscriber and apply what it /// hears through the production consumer, like `main.rs`. Yields /// the target of each applied message. - fn listen(state: &Arc) -> tokio::sync::mpsc::UnboundedReceiver> { + pub(super) fn listen( + state: &Arc, + ) -> tokio::sync::mpsc::UnboundedReceiver> { let mut rx = state.pubsub.subscribe_nip_fi_disconnect(); let channels = disconnect_subscribe_channels(state.config.nip_fi.mode); tokio::spawn(Arc::clone(&state.pubsub).run_nip_fi_disconnect_subscriber(channels)); @@ -3641,7 +3652,7 @@ mod route_integration_tests { /// Return once a probe that `prober` publishes has been applied via /// `seen`: the subscription is live, and anything published earlier /// on the probe's channel has been applied. - async fn await_probe( + pub(super) async fn await_probe( seen: &mut tokio::sync::mpsc::UnboundedReceiver>, prober: &Arc, ) { @@ -3661,7 +3672,7 @@ mod route_integration_tests { } /// Return once `seen` has applied a message for `key`. - async fn await_target( + pub(super) async fn await_target( seen: &mut tokio::sync::mpsc::UnboundedReceiver>, key: &nostr::PublicKey, ) { @@ -3781,4 +3792,484 @@ mod route_integration_tests { deny_set_full_leaves_command_retryable(redis_guard()).await; } } + + /// Plan criterion 10 over real infrastructure: PostgreSQL holds the two + /// communities, Redis carries the disconnect between pods, and every + /// admission runs the production path end to end. + mod postgres_tests { + use super::external_infra_redis::{await_probe, await_target, listen}; + use super::*; + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::{self, client::IntoClientRequest, Message}; + + const DENY_BODY: &[u8] = b"authorization denied\n"; + + fn redis_url() -> String { + std::env::var("BUZZ_TEST_REDIS_URL") + .or_else(|_| std::env::var("REDIS_URL")) + .unwrap_or_else(|_| "redis://127.0.0.1:6379".into()) + } + + /// An enforce pod serving `hosts` as two exact communities over the + /// shared PostgreSQL and Redis, listening on a real socket and + /// running the production disconnect subscriber. + struct Pod { + state: Arc, + addr: std::net::SocketAddr, + seen: tokio::sync::mpsc::UnboundedReceiver>, + server: tokio::task::JoinHandle<()>, + } + + impl Drop for Pod { + fn drop(&mut self) { + self.server.abort(); + } + } + + async fn pod(hosts: &[String; 2]) -> Pod { + let mut config = crate::config::Config::for_test(); + config.database_url = crate::test_support::database_url(); + config.redis_url = redis_url(); + config.require_relay_membership = false; + config.nip_fi.mode = NipFiMode::Enforce; + config.nip_fi.registry.insert(test_issuer_policy()); + let uris = hosts.clone().map(|host| format!("https://{host}")); + config.nip_fi.communities = crate::nip_fi_config::NipFiCommunities::for_test_many( + &[&uris[0], &uris[1]], + &[TEST_ISS], + ); + let mut state = build_test_app_state(1000, config).await; + state.nip_fi_command_replay = + command_replay_guard(state.redis_pool.clone(), state.config.nip_fi.mode); + let key_source = Arc::new( + ProductionJwksSource::new( + vec![test_jwks_config()], + buzz_auth::HttpJwksFetcher::new(), + ) + .expect("key source"), + ); + key_source + .seed_snapshot_for_test(TEST_ISS, test_jwks()) + .await; + state.nip_fi_verifier = Some(Arc::new(buzz_auth::FederatedAssertionVerifier::new( + state.config.nip_fi.registry.clone(), + key_source, + ))); + let state = Arc::new(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("addr"); + let app = crate::router::build_router(Arc::clone(&state)) + .into_make_service_with_connect_info::(); + let server = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let seen = listen(&state); + Pod { + state, + addr, + seen, + server, + } + } + + /// Both pods, with each subscription confirmed live. + async fn pair(hosts: &[String; 2]) -> [Pod; 2] { + let mut pods = [pod(hosts).await, pod(hosts).await]; + for p in &mut pods { + await_probe(&mut p.seen, &p.state).await; + } + pods + } + + /// A login assertion for `key` whose audience is `host`'s community. + /// Minted once and replayed as the same bytes in every phase. + fn retained_assertion(key: &nostr::Keys, host: &str) -> String { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": TEST_ISS, "aud": format!("https://{host}"), "sub": "restart-test", + "iat": now, "exp": now + 600, "nostr_pubkey": key.public_key().to_hex(), + }); + let mut header = Header::new(Algorithm::ES256); + header.typ = Some("nip-fi+jwt".to_owned()); + header.kid = Some(TEST_KID.to_owned()); + let signing = EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes()).unwrap(); + encode(&header, &claims, &signing).unwrap() + } + + /// A key and its retained assertion for each community. + struct Retained { + keys: nostr::Keys, + assertions: [(String, String); 2], + } + + impl Retained { + fn mint(hosts: &[String; 2]) -> Self { + let keys = nostr::Keys::generate(); + let assertions = hosts.clone().map(|host| { + let token = retained_assertion(&keys, &host); + (host, token) + }); + Self { keys, assertions } + } + } + + /// How one admission ended; allowed cases must reach real admission. + #[derive(Debug, PartialEq, Eq, Clone, Copy)] + enum Outcome { + Admitted, + Denied, + } + use Outcome::{Admitted, Denied}; + + /// A root socket past the upgrade, holding its NIP-42 challenge. + struct RootSession { + socket: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + challenge: String, + relay: String, + } + + /// Upgrade the root socket at `host` with `token`. A deny at upgrade + /// is the 403 deny body; anything else that is not an upgrade fails. + async fn open_root(pod: &Pod, host: &str, token: &str) -> Result { + let mut req = format!("ws://{}/", pod.addr) + .into_client_request() + .expect("request"); + req.headers_mut().insert("host", host.parse().unwrap()); + req.headers_mut().insert( + "nostr-federated-identity", + format!("Bearer {token}").parse().unwrap(), + ); + let mut socket = match tokio_tungstenite::connect_async(req).await { + Ok((socket, _)) => socket, + Err(tungstenite::Error::Http(resp)) => { + let body = resp.body().clone().unwrap_or_default(); + assert_eq!( + (resp.status().as_u16(), &body[..]), + (403, DENY_BODY), + "root upgrade refused other than by the deny body" + ); + return Err(Denied); + } + Err(e) => panic!("root upgrade failed: {e}"), + }; + let challenge = loop { + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), socket.next()) + .await + .expect("AUTH challenge in time"); + if let Some(Ok(Message::Text(text))) = frame { + let json: serde_json::Value = serde_json::from_str(&text).unwrap(); + if json[0] == "AUTH" { + break json[1].as_str().unwrap().to_owned(); + } + } else { + panic!("expected the AUTH challenge, got {frame:?}"); + } + }; + let relay = format!( + "{}://{host}", + if pod.state.config.relay_url.starts_with("wss://") { + "wss" + } else { + "ws" + } + ); + Ok(RootSession { + socket, + challenge, + relay, + }) + } + + impl RootSession { + /// Complete NIP-42 as `keys`: `Admitted` on OK true, `Denied` on + /// the authorization-denied frame followed by the close. + async fn authenticate(mut self, keys: &nostr::Keys) -> Outcome { + let auth = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &self.relay]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &self.challenge]).unwrap()) + .sign_with_keys(keys) + .unwrap(); + let id = auth.id.to_hex(); + let frame = serde_json::json!(["AUTH", auth]).to_string(); + self.socket.send(Message::Text(frame.into())).await.unwrap(); + let denial = buzz_auth::DenialClass::AuthorizationDenied.nostr_text(); + let mut denied = false; + loop { + let frame = + tokio::time::timeout(std::time::Duration::from_secs(5), self.socket.next()) + .await + .expect("AUTH outcome in time"); + match frame { + Some(Ok(Message::Text(text))) => { + let json: serde_json::Value = serde_json::from_str(&text).unwrap(); + if json[0] == "OK" && json[1] == id.as_str() && json[2] == true { + assert!(!denied, "admitted after a denial frame"); + return Admitted; + } + denied |= text.contains(denial); + } + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => { + assert!(denied, "root closed without the denial frame"); + return Denied; + } + Some(Ok(_)) => {} + } + } + } + } + + /// A full root session: upgrade, then NIP-42. + async fn root(pod: &Pod, keys: &nostr::Keys, host: &str, token: &str) -> Outcome { + match open_root(pod, host, token).await { + Ok(session) => session.authenticate(keys).await, + Err(denied) => denied, + } + } + + /// A signed NIP-98 `POST /query` through the real handler: 200 when + /// admitted, the deny body when denied. + async fn http(pod: &Pod, keys: &nostr::Keys, host: &str, token: &str) -> Outcome { + use base64::Engine as _; + use sha2::Digest as _; + let body = br#"[{"kinds":[1],"limit":1}]"#; + let scheme = if pod.state.config.relay_url.starts_with("wss://") { + "https" + } else { + "http" + }; + let proof = nostr::EventBuilder::new(nostr::Kind::HttpAuth, "") + .tags([ + nostr::Tag::parse(["u", &format!("{scheme}://{host}/query")]).unwrap(), + nostr::Tag::parse(["method", "POST"]).unwrap(), + // A fresh proof per request: the replay guard is shared. + nostr::Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(), + nostr::Tag::parse(["payload", &hex::encode(sha2::Sha256::digest(body))]) + .unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + let proof = base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&proof).unwrap()); + let request = Request::post("/query") + .header(axum::http::header::HOST, host) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .header(axum::http::header::AUTHORIZATION, format!("Nostr {proof}")) + .header(CLIENT_ATTACHED_HEADER, format!("Bearer {token}")) + .body(Body::from(&body[..])) + .unwrap(); + let resp = crate::router::build_router(Arc::clone(&pod.state)) + .oneshot(request) + .await + .unwrap(); + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), 64 * 1024) + .await + .unwrap(); + match status { + StatusCode::OK => Admitted, + StatusCode::FORBIDDEN if &body[..] == DENY_BODY => Denied, + other => panic!( + "unexpected HTTP outcome {other}: {}", + String::from_utf8_lossy(&body) + ), + } + } + + /// The audio deny-set check after a real NIP-42 session. + async fn audio(pod: &Pod, keys: &nostr::Keys, host: &str, token: &str) -> Outcome { + let binding = pod + .state + .config + .nip_fi + .communities + .resolve(host) + .expect("served"); + let assertion = pod + .state + .nip_fi_verifier + .as_deref() + .expect("verifier") + .verify_assertion(token, binding) + .expect("the retained assertion is still valid"); + if crate::audio::handler::test_support::deny_set_refuses( + Arc::clone(&pod.state), + assertion, + keys, + ) + .await + { + Denied + } else { + Admitted + } + } + + /// Asserts every retained assertion of K (expected `key`) and the + /// control key (always admitted), on both pods and in both + /// communities, at root, audio and HTTP. + async fn check(pods: &[Pod; 2], [k, control]: [&Retained; 2], key: Outcome, phase: &str) { + for (n, pod) in pods.iter().enumerate() { + for (who, retained, expected) in [("K", k, key), ("control", control, Admitted)] { + for (host, token) in &retained.assertions { + let keys = &retained.keys; + assert_eq!( + [ + root(pod, keys, host, token).await, + audio(pod, keys, host, token).await, + http(pod, keys, host, token).await, + ], + [expected; 3], + "{phase}: {who} at {host} on P{} [root, audio, HTTP]", + n + 1 + ); + } + } + } + } + + /// Root sessions for every retained assertion on both pods, upgraded + /// but not yet authenticated. + async fn open_all(pods: &[Pod; 2], retained: &Retained) -> Vec<(String, RootSession)> { + let mut sessions = Vec::new(); + for (n, pod) in pods.iter().enumerate() { + for (host, token) in &retained.assertions { + let session = open_root(pod, host, token) + .await + .unwrap_or_else(|_| panic!("{host} on P{} must upgrade", n + 1)); + sessions.push((format!("{host} on P{}", n + 1), session)); + } + } + sessions + } + + /// Sessions K upgraded before a disconnect landed, now finishing + /// NIP-42: only the post-registration deny-set check refuses them. + /// The control key's open sessions are admitted. + async fn authenticate_all( + phase: &str, + k: (&nostr::Keys, Vec<(String, RootSession)>), + control: (&nostr::Keys, Vec<(String, RootSession)>), + ) { + for (who, (keys, sessions), expected) in + [("K", k, Denied), ("control", control, Admitted)] + { + for (at, session) in sessions { + assert_eq!( + session.authenticate(keys).await, + expected, + "{phase}: {who}'s session upgraded before it, at {at}" + ); + } + } + } + + // Plan criterion 10: restart recovery across pods. K and a control + // key each hold one assertion per community (two exact host bindings + // in PostgreSQL and config), minted before the revocation and replayed + // as the same bytes in every phase, on both pods, through a real root + // NIP-42 session, the audio deny-set check and a signed NIP-98 request + // to a real handler. A disconnect sent to one pod denies K on both. A + // root session K upgraded before the disconnect is refused at NIP-42 + // by the post-registration deny-set check. Restarting both pods + // empties their deny maps, so the retained assertions are admitted + // again until the issuer re-sends; that is the exposure window, + // recorded from restart to restored denial. The control key is + // admitted throughout. Mutations: dropping the post-registration + // check admits K's pre-upgraded sessions; dropping cross-pod + // application leaves K admitted on the other pod. + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn re_sent_disconnect_restores_the_deny_on_every_pod_after_restart() { + let tag = uuid::Uuid::new_v4().simple().to_string(); + let hosts = [ + format!("restart-a-{tag}.example"), + format!("restart-b-{tag}.example"), + ]; + let db = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("PostgreSQL must be available"); + for host in &hosts { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(uuid::Uuid::new_v4()) + .bind(host) + .execute(&db) + .await + .expect("seed community"); + } + let k = Retained::mint(&hosts); + let control = Retained::mint(&hosts); + let minted: Vec = [&k, &control] + .iter() + .flat_map(|r| r.assertions.iter().map(|(_, t)| t.clone())) + .collect(); + let both = [&k, &control]; + let key = k.keys.public_key(); + let until = chrono::Utc::now().timestamp() + 300; + let send = |pod: Arc| async move { + let token = mint_token(&key.to_hex(), 0, serde_json::json!({ "until": until })); + let (status, body) = post_command(&pod, &token, &key.to_hex()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(&body[..], br#"{"disconnected": true}"#); + drain_publishes(&pod).await; + }; + + let mut pods = pair(&hosts).await; + check(&pods, both, Admitted, "before the disconnect").await; + let (k_open, control_open) = + (open_all(&pods, &k).await, open_all(&pods, &control).await); + send(Arc::clone(&pods[0].state)).await; + await_target(&mut pods[1].seen, &key).await; + authenticate_all( + "after the disconnect", + (&k.keys, k_open), + (&control.keys, control_open), + ) + .await; + check(&pods, both, Denied, "after the disconnect").await; + + // Restart: fresh pods over the same database, empty deny maps. + drop(pods); + let mut pods = pair(&hosts).await; + let restarted = std::time::Instant::now(); + let (first_host, first_token) = &k.assertions[0]; + assert_eq!( + root(&pods[0], &k.keys, first_host, first_token).await, + Admitted, + "restarted: K's retained assertion completes a root session on P1" + ); + let first_admitted = restarted.elapsed(); + check(&pods, both, Admitted, "restarted, before the re-send").await; + + let (k_open, control_open) = + (open_all(&pods, &k).await, open_all(&pods, &control).await); + send(Arc::clone(&pods[1].state)).await; + let [p1, p2] = &mut pods; + await_target(&mut p1.seen, &key).await; + await_target(&mut p2.seen, &key).await; + authenticate_all( + "after the re-send", + (&k.keys, k_open), + (&control.keys, control_open), + ) + .await; + check(&pods, both, Denied, "after the re-send").await; + let restored = restarted.elapsed(); + + let replayed: Vec = [&k, &control] + .iter() + .flat_map(|r| r.assertions.iter().map(|(_, t)| t.clone())) + .collect(); + assert_eq!(replayed, minted, "assertion bytes unchanged"); + eprintln!( + "criterion 10 exposure: first retained root admission {first_admitted:?} after \ + restart; denial restored on every pod {restored:?} after restart" + ); + assert!(first_admitted < restored); + } + } } diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 16685f3041..813fe434fc 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -79,7 +79,8 @@ async fn authorize_workflow_read( require_auth, false, ), - )?; + ) + .await?; let pubkey = *admission.proven_pubkey(); let (event_id_bytes, signed_created_at) = admission.into_extra(); diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 8115ed9bbd..1a8b251ea2 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -92,14 +92,14 @@ pub async fn ws_audio_handler( req: axum::extract::Request, ) -> impl IntoResponse { // NIP-FI assertion check at upgrade — before tenant lookup and before the - // WebSocket handshake. Running pre-lookup means a denied request pays zero - // DB cost and the gate is reachable in tests without a live community. + // WebSocket handshake. Running pre-lookup means a denied request skips the tenant + // lookup (a resolver miss costs one bounded binding lookup) and the gate is reachable in tests without a live community. // [FI-TRACE-TRANSPORT-CLOSED] [NIP-FI.md §Admission pairing sequence] - let (nip_fi_assertion, shadow_assertion) = { + let (nip_fi_assertion, shadow_assertion, nip_fi_deadline) = { use crate::nip_fi_upgrade::check_nip_fi_at_upgrade; let mode = state.config.nip_fi.mode; let verifier = state.nip_fi_verifier.as_deref(); - let communities = &state.config.nip_fi.communities; + let communities = state.nip_fi_communities(); match check_nip_fi_at_upgrade( crate::nip_fi_session::NipFiWsRoute::Audio, &headers, @@ -107,6 +107,7 @@ pub async fn ws_audio_handler( verifier, mode, ) + .await .into_assertions() { Ok(assertions) => assertions, @@ -134,6 +135,16 @@ pub async fn ws_audio_handler( } }; + // The tenant lookup awaited since the community resolved; a snapshot + // that went stale meanwhile accepts no socket. + if let Err(class) = nip_fi_deadline.check() { + return crate::nip_fi_core::http_denial(class).into_response(); + } + let shadow_assertion = crate::nip_fi_upgrade::observe_deadline( + crate::nip_fi_session::NipFiWsRoute::Audio, + shadow_assertion, + ); + let ws = match WebSocketUpgrade::from_request(req, &state).await { Ok(ws) => ws, Err(e) => return e.into_response(), @@ -158,11 +169,11 @@ pub async fn ws_audio_handler( // so the NIP-FI session partition is rooted at the HTTP handshake, not the // post-community-active-check instant. [FI-TRACE-LEASE-BOUND] let connection_time = chrono::Utc::now(); - let shadow = shadow_assertion.map(|a| { + let shadow = shadow_assertion.map(|(a, community)| { crate::nip_fi_shadow_session::ShadowSession::start( &state, "audio", - &headers, + community, a, connection_time, ) @@ -3533,6 +3544,89 @@ async fn emit_participant_event( } } +/// Drives one audio socket through the production NIP-42 and deny-set path. +#[cfg(test)] +pub(crate) mod test_support { + use tokio_tungstenite::{connect_async, tungstenite::Message}; + + use super::*; + + /// Whether the audio deny-set check refuses a session admitted on + /// `assertion` and authenticated by `key`: `true` on the denial frame, + /// `false` once the handler passes the check. + pub(crate) async fn deny_set_refuses( + state: Arc, + assertion: VerifiedAssertion, + key: &nostr::Keys, + ) -> bool { + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + let tenant = TenantContext::resolved(community, "audio-deny.test".to_owned()); + let relay_url = + crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); + let (passed, release) = + crate::nip_fi_test_hooks::audio_after_deny_check_passed_hook::arm(community); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = axum::Router::new().route( + "/", + axum::routing::get(move |ws: WebSocketUpgrade| async move { + ws.on_upgrade(move |socket| { + handle_active_audio_connection( + socket, + state, + tenant, + Uuid::new_v4(), + CommunityConnectionControl::new(CancellationToken::new()), + Some(assertion), + chrono::Utc::now(), + None, + ) + }) + }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + + let (mut client, _) = connect_async(format!("ws://{addr}/")).await.unwrap(); + let challenge = match client.next().await { + Some(Ok(Message::Text(text))) => { + let json: serde_json::Value = serde_json::from_str(&text).unwrap(); + json["challenge"].as_str().unwrap().to_owned() + } + other => panic!("expected the AUTH challenge, got {other:?}"), + }; + let auth = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(key) + .unwrap(); + let auth = serde_json::json!({"type": "auth", "event": auth}).to_string(); + client.send(Message::Text(auth.into())).await.unwrap(); + + let denial = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text(), + }) + .to_string(); + let refused = tokio::time::timeout(std::time::Duration::from_secs(5), async { + tokio::select! { + frame = client.next() => match frame { + Some(Ok(Message::Text(text))) if text.as_str() == denial => true, + other => panic!("expected the denial frame, got {other:?}"), + }, + arrived = passed => { + arrived.expect("hook"); + false + } + } + }) + .await + .expect("audio session reached neither outcome"); + release.notify_one(); + server.abort(); + refused + } +} + #[cfg(test)] mod tests { use std::sync::Mutex; @@ -5994,7 +6088,7 @@ mod tests { crate::nip_fi_shadow_session::ShadowSession::start( state, "audio", - &axum::http::HeaderMap::new(), + crate::nip_fi_shadow::BOUND.into(), assertion, Utc::now(), ) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 129d2792ed..4c9e257318 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -403,6 +403,11 @@ pub struct Config { /// (`BUZZ_NIP_FI_PROVISION_BINDING`). `None` binds nothing at creation. /// Parsed whatever the NIP-FI mode, since creation happens in every mode. pub nip_fi_provision_binding: Option, + + /// Database resolver bounds (`BUZZ_NIP_FI_RESOLVER_*`, + /// `BUZZ_NIP_FI_SNAPSHOT_*`). Validated in every mode, so a bad value + /// fails the deploy that sets it, not the later switch to shadow. + pub nip_fi_resolver_bounds: crate::nip_fi_resolver::ResolverBounds, } fn parse_bind_addr(raw: &str) -> Result { @@ -1377,6 +1382,18 @@ impl Config { }; let nip_fi = crate::nip_fi_config::NipFiRelayConfig::from_env()?; + let nip_fi_provision_binding = crate::nip_fi_config::parse_provision_binding()?; + let nip_fi_resolver_bounds = + crate::nip_fi_resolver::ResolverBounds::from_env(db_pool_size)?; + if nip_fi.source == crate::nip_fi_config::CommunitySource::Database + && nip_fi_provision_binding.is_none() + { + return Err(ConfigError::InvalidValue( + "BUZZ_NIP_FI_COMMUNITY_SOURCE=database requires BUZZ_NIP_FI_PROVISION_BINDING \ + (the suffix and reserved host)" + .into(), + )); + } Ok(Self { bind_addr, database_url, @@ -1442,7 +1459,8 @@ impl Config { admin, web_dir, serve_git_web_gui, - nip_fi_provision_binding: crate::nip_fi_config::parse_provision_binding()?, + nip_fi_provision_binding, + nip_fi_resolver_bounds, nip_fi, }) } @@ -1514,6 +1532,83 @@ mod tests { (fi, cfg) } + // The three `source=database` startup guards. Mutation: dropping any one + // lets that configuration start. + #[test] + fn database_source_config_guards_refuse_startup() { + const VARS: [&str; 6] = [ + "BUZZ_NIP_FI_COMMUNITY_SOURCE", + "BUZZ_NIP_FI_COMMUNITIES", + "BUZZ_NIP_FI_ISSUERS", + "BUZZ_NIP_FI_PROVISION_BINDING", + "BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", + "BUZZ_NIP_FI_MODE", + ]; + struct Clear; + impl Drop for Clear { + fn drop(&mut self) { + for v in VARS { + std::env::remove_var(v); + } + } + } + let _guards = env_guards(); + let _clear = Clear; + let refusal = || match Config::from_env() { + Err(ConfigError::InvalidValue(message)) => message, + other => panic!("expected a refusal, got {:?}", other.map(|_| ())), + }; + + std::env::set_var("BUZZ_NIP_FI_COMMUNITY_SOURCE", "ldap"); + assert!(refusal().contains("must be \"env\" or \"database\"")); + + std::env::set_var("BUZZ_NIP_FI_COMMUNITY_SOURCE", "database"); + std::env::set_var("BUZZ_NIP_FI_COMMUNITIES", "[]"); + assert!(refusal().contains("BUZZ_NIP_FI_COMMUNITIES is set")); + + std::env::remove_var("BUZZ_NIP_FI_COMMUNITIES"); + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + serde_json::json!([{ + "issuer": "https://idp.example.com", + "command_audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400, + "maximum_command_age_seconds": 30, + "authorized_principals": ["https://idp.example.com/operator"] + }]) + .to_string(), + ); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + assert!(refusal().contains("requires BUZZ_NIP_FI_PROVISION_BINDING")); + + std::env::set_var( + "BUZZ_NIP_FI_PROVISION_BINDING", + r#"{"host_suffix":"communities.example.com","authorized_issuers":["https://idp.example.com"]}"#, + ); + let config = Config::from_env().expect("complete database-source config"); + assert!(config.nip_fi_provision_binding.is_some()); + } + + // Mutation: parsing the resolver bounds only when the resolver is built + // lets an off-mode deploy carry a value the switch to shadow refuses. + #[test] + fn resolver_bounds_are_validated_in_off_mode() { + let _guards = env_guards(); + std::env::set_var("BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT", "0"); + let result = Config::from_env(); + std::env::remove_var("BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT"); + assert!( + matches!(result, Err(ref e) if e.to_string().contains("BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT")), + "{:?}", + result.map(|_| ()) + ); + } + /// Look up against a fixed set, standing in for process env. fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + use<'a> { move |name| { diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 8a94a5827d..f0160ed275 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -376,7 +376,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } // NIP-FI key pairing [FI-INV-05]: immediately after successful // verify_auth_event, before community-ban/allowlist/membership gates. - // Pre-DB positioning means a denied caller pays zero DB cost and the + // Running before those gates means a denied caller skips their DB reads and the // production call site is falsifiable without live tenant policy. // [FI-TRACE-DENIAL-ORACLE post-establishment] if crate::nip_fi_session::enforce_nip_fi_key_pairing( @@ -1465,11 +1465,10 @@ mod tests { let t = crate::connection::tests::test_conn(pending, None); let assertion = buzz_auth::VerifiedAssertion::for_test(Some(asserted), vec![Utc::now() + lifetime]); - let headers = axum::http::HeaderMap::new(); let session = crate::nip_fi_shadow_session::ShadowSession::start( state, "ws", - &headers, + crate::nip_fi_shadow::BOUND.into(), assertion, Utc::now(), ); diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index b187b223ad..6b258cd084 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -229,6 +229,18 @@ async fn publish_membership_snapshot_if_required( } } +/// After a committed creation that bound issuers, drop this pod's cached +/// "not found" for the host so the creator's first login looks it up. +pub(crate) fn record_binding_creation( + resolver: Option<&crate::nip_fi_resolver::DatabaseResolver>, + host: &str, + issuers: &[String], +) { + if let (Some(resolver), false) = (resolver, issuers.is_empty()) { + resolver.record_creation(host); + } +} + /// Validate and execute a relay-operator community provisioning request. /// /// The caller is an HTTP operator endpoint, not the Nostr event ingest path. @@ -309,6 +321,11 @@ pub async fn provision_community( owner = %owner_hex, "community created via operator endpoint" ); + record_binding_creation( + state.nip_fi_resolver.as_deref(), + &record.host, + nip_fi_issuers, + ); publish_membership_snapshot_if_required(state, record.id, &record.host).await; return Ok(ProvisionCommunityResponse { community_id: record.id.to_string(), @@ -326,6 +343,13 @@ pub async fn provision_community( .ensure_configured_community_with_nip_fi_issuers(&request.host, nip_fi_issuers) .await .map_err(|e| format!("failed to create community: {e}"))?; + if record.created { + record_binding_creation( + state.nip_fi_resolver.as_deref(), + &record.host, + nip_fi_issuers, + ); + } if let Some(owner_hex) = &initial_owner { match state diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index e0fd61d1f6..17a7cb49e4 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -52,6 +52,7 @@ pub mod nip_fi_config; /// NIP-FI HTTP ingress enforcement: assertion extraction, verification, /// key-pairing check, and deny-map gate for every protected HTTP surface. pub(crate) mod nip_fi_http; +pub mod nip_fi_resolver; /// Deployment-global operator-listener mention delivery worker. pub mod operator_listener; /// NIP-01 client/relay message parsing. diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index d6a082affa..effda6cc0a 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -856,6 +856,36 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { )); } } + // NIP-FI database community source. The startup load runs before any + // listener binds, so the pod can never report ready without a snapshot; + // a failed load fails startup and the orchestrator retries. + if config.nip_fi.builds_resolver() { + let step = StepTimer::start(StartupStep::NipFiSnapshotLoad); + // `Config::from_env` refuses the database source without it. + let policy = config + .nip_fi_provision_binding + .clone() + .ok_or_else(|| anyhow::anyhow!("NIP-FI: no provisioning binding policy"))?; + let registry = config + .nip_fi + .registry + .all_policies() + .map(|p| p.issuer().to_owned()) + .collect(); + let resolver = Arc::new(buzz_relay::nip_fi_resolver::DatabaseResolver::new( + Arc::new(app_state.db.clone()), + policy, + registry, + config.nip_fi_resolver_bounds.clone(), + )); + resolver + .refresh() + .await + .map_err(|e| anyhow::anyhow!("NIP-FI: startup binding snapshot load failed: {e:?}"))?; + step.finish(); + info!("NIP-FI: binding snapshot loaded"); + app_state.nip_fi_resolver = Some(resolver); + } let state = Arc::new(app_state); let has_startup_partition_audit = startup_partition_audit.is_some(); if let Some(audit) = startup_partition_audit { @@ -912,6 +942,10 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { // The refresh task is owned: a `CancellationToken` + `JoinHandle` let the // process cancel it cleanly on shutdown instead of leaking the task. let jwks_refresh_cancel = CancellationToken::new(); + // The binding refresh loop stops with the JWKS loop on shutdown. + if let Some(resolver) = state.nip_fi_resolver.clone() { + tokio::spawn(resolver.run_refresh_loop(jwks_refresh_cancel.child_token())); + } let jwks_refresh_handle = if let Some(jwks_source) = state.nip_fi_jwks_source.clone() { let jwks_configs = state.config.nip_fi.jwks_configs.clone(); info!( diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs index 6dd846533f..f78f52f225 100644 --- a/crates/buzz-relay/src/nip_fi_config.rs +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -32,6 +32,7 @@ //! backward-compatible until an operator explicitly enables enforcement. use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; use std::time::Duration; use buzz_auth::nip_fi::provision::canonical_authority; @@ -179,17 +180,17 @@ pub(super) struct CommunityEnvConfig { /// no database access. Empty unless the mode is `enforce`. #[derive(Debug, Clone, Default)] pub struct NipFiCommunities { - by_host: BTreeMap, + by_host: BTreeMap>, /// Test-only: one community served at every Host, for fixtures that /// exercise enforce-mode handlers on per-test unique Hosts. #[cfg(test)] - any_host: Option, + any_host: Option>, } impl NipFiCommunities { /// The community served at `raw_host`, matched after the same /// normalization the tenant binder applies. - pub fn resolve(&self, raw_host: &str) -> Option<&CommunityBinding> { + pub fn resolve(&self, raw_host: &str) -> Option<&Arc> { #[cfg(test)] if let Some(binding) = &self.any_host { return Some(binding); @@ -231,7 +232,7 @@ impl NipFiCommunities { .map_err(|e| format!("community at index {idx}: {e}"))?; // One Host per canonical URI, so a unique Host also means a // unique `aud`. - if by_host.insert(host, binding).is_some() { + if by_host.insert(host, Arc::new(binding)).is_some() { return Err(format!( "community at index {idx}: canonical_uri maps to the same \ Host as an earlier community" @@ -245,13 +246,9 @@ impl NipFiCommunities { }) } - /// Every community `aud` that authorizes `issuer`. - fn audiences_authorizing(&self, issuer: &str) -> Vec { - self.by_host - .values() - .filter(|c| c.authorizes(issuer)) - .map(|c| c.expected_aud().to_owned()) - .collect() + /// Whether any community authorizes `issuer`. + fn authorizes_anywhere(&self, issuer: &str) -> bool { + self.by_host.values().any(|c| c.authorizes(issuer)) } } @@ -274,6 +271,8 @@ pub fn parse_provision_binding() -> Result Result { let mode = parse_mode()?; + let source = parse_community_source()?; + if source == CommunitySource::Database + && !std::env::var("BUZZ_NIP_FI_COMMUNITIES") + .unwrap_or_default() + .trim() + .is_empty() + { + return Err(ConfigError::InvalidValue( + "BUZZ_NIP_FI_COMMUNITY_SOURCE=database but BUZZ_NIP_FI_COMMUNITIES is set; \ + database-sourced communities are read from community_nip_fi_issuers" + .to_string(), + )); + } - if !mode.evaluates() { + // The issuer registry is required whenever admission evaluates, and + // with the database source in every mode: provisioning, bootstrap and + // the resolver's registry filter all read it. + if !mode.evaluates() && source == CommunitySource::Env { return Ok(Self { mode, + source, registry: IssuerRegistry::new(), communities: NipFiCommunities::default(), jwks_configs: Vec::new(), @@ -346,18 +362,21 @@ impl NipFiRelayConfig { // duplicate that could disagree with the per-issuer value is a config-drift // trap — removed in this PR. - let max_connection_lifetime_secs = parse_u64_bounded( + let max_connection_lifetime_secs = match parse_u64_bounded( "BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", 1, MAX_CONNECTION_LIFETIME_SECS, - )? - .ok_or_else(|| { - mode_requires( - mode, - "BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS is not set; \ - every enforce deployment must configure a positive finite value", - ) - })?; + )? { + Some(secs) => secs, + None if !mode.evaluates() => 0, + None => { + return Err(mode_requires( + mode, + "BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS is not set; \ + every enforce deployment must configure a positive finite value", + )) + } + }; let mut policies = Vec::with_capacity(issuer_entries.len()); let mut jwks_configs = Vec::with_capacity(issuer_entries.len()); @@ -452,30 +471,45 @@ impl NipFiRelayConfig { } } - let configured: BTreeSet<&str> = policies.iter().map(IssuerPolicy::issuer).collect(); - let communities = parse_communities(mode, &configured)?; - // Fold each issuer's community allowlist into its policy ID - // (NIP-FI.md:145), and reject an issuer no community authorizes - // (NIP-FI.md:183). let mut registry = IssuerRegistry::new(); - for (issuer_idx, policy) in policies.into_iter().enumerate() { - let audiences = communities.audiences_authorizing(policy.issuer()); - if audiences.is_empty() { - return Err(ConfigError::InvalidValue(format!( - "BUZZ_NIP_FI_ISSUERS: issuer at index {issuer_idx} is not \ - authorized by any community in BUZZ_NIP_FI_COMMUNITIES" - ))); + let communities = match source { + CommunitySource::Env => { + let configured: BTreeSet<&str> = + policies.iter().map(IssuerPolicy::issuer).collect(); + let communities = parse_communities(mode, &configured)?; + // Reject an issuer no community authorizes (NIP-FI.md:183). + for (issuer_idx, policy) in policies.iter().enumerate() { + if !communities.authorizes_anywhere(policy.issuer()) { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer at index {issuer_idx} is not \ + authorized by any community in BUZZ_NIP_FI_COMMUNITIES" + ))); + } + } + communities } - registry.insert(policy.authorized_for_communities(audiences)); + // An issuer no community row references grants nothing: + // authorization still needs a per-community binding. + CommunitySource::Database => NipFiCommunities::default(), + }; + for policy in policies { + registry.insert(policy); } // Delegate final validation to buzz-auth startup gate. - validate_nip_fi_config(mode, ®istry, &jwks_configs).map_err( + // The database source validates the registry in every mode, since + // creation paths bind against it even while admission is off. + let validation_mode = match source { + CommunitySource::Database if !mode.evaluates() => NipFiMode::Enforce, + _ => mode, + }; + validate_nip_fi_config(validation_mode, ®istry, &jwks_configs).map_err( |e: NipFiStartupError| ConfigError::InvalidValue(format!("NIP-FI config invalid: {e}")), )?; Ok(Self { mode, + source, registry, communities, jwks_configs, @@ -512,6 +546,30 @@ fn parse_communities( // ── Helpers ─────────────────────────────────────────────────────────────────── +/// Where the Host → community bindings come from. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum CommunitySource { + /// The static `BUZZ_NIP_FI_COMMUNITIES` map (the default). + #[default] + Env, + /// The `community_nip_fi_issuers` table, via the snapshot resolver. + Database, +} + +fn parse_community_source() -> Result { + match std::env::var("BUZZ_NIP_FI_COMMUNITY_SOURCE") + .ok() + .as_deref() + .map(str::trim) + { + None | Some("") | Some("env") => Ok(CommunitySource::Env), + Some("database") => Ok(CommunitySource::Database), + Some(other) => Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_COMMUNITY_SOURCE must be \"env\" or \"database\"; got {other:?}" + ))), + } +} + fn parse_mode() -> Result { match std::env::var("BUZZ_NIP_FI_MODE") .ok() @@ -542,7 +600,11 @@ fn mode_requires(mode: NipFiMode, missing: &str) -> ConfigError { /// Parse an optional positive `u64` env var bounded to `[min_val, max_val]`. /// Returns `None` when the variable is absent or empty. -fn parse_u64_bounded(name: &str, min_val: u64, max_val: u64) -> Result, ConfigError> { +pub(crate) fn parse_u64_bounded( + name: &str, + min_val: u64, + max_val: u64, +) -> Result, ConfigError> { match std::env::var(name) { Err(_) => Ok(None), Ok(raw) if raw.trim().is_empty() => Ok(None), @@ -654,6 +716,13 @@ fn build_issuer(entry: &IssuerEnvConfig) -> Result<(IssuerPolicy, IssuerJwksConf // ── Duration helpers ────────────────────────────────────────────────────────── impl NipFiRelayConfig { + /// Whether startup builds the database resolver: only for the `database` + /// source in a mode that evaluates evidence. Off and deny-protected never + /// resolve a community, so they load no snapshot. + pub fn builds_resolver(&self) -> bool { + self.source == CommunitySource::Database && self.mode.evaluates() + } + /// Returns the configured `max_connection_lifetime` as a `Duration`. /// Returns `None` in `Off`/`DenyProtected` mode (sentinel value 0). pub fn max_connection_lifetime(&self) -> Option { @@ -674,12 +743,20 @@ impl NipFiRelayConfig { impl NipFiCommunities { /// One community at `canonical_uri` authorizing `issuers`. pub(crate) fn for_test(canonical_uri: &str, issuers: &[&str]) -> Self { - let entries = vec![CommunityEnvConfig { - canonical_uri: canonical_uri.to_owned(), - authorized_issuers: issuers.iter().map(|i| (*i).to_owned()).collect(), - }]; + Self::for_test_many(&[canonical_uri], issuers) + } + + /// One community per entry of `canonical_uris`, each authorizing `issuers`. + pub(crate) fn for_test_many(canonical_uris: &[&str], issuers: &[&str]) -> Self { + let entries = canonical_uris + .iter() + .map(|uri| CommunityEnvConfig { + canonical_uri: (*uri).to_owned(), + authorized_issuers: issuers.iter().map(|i| (*i).to_owned()).collect(), + }) + .collect(); Self::from_entries(entries, &issuers.iter().copied().collect()) - .expect("valid test community") + .expect("valid test communities") } /// One community, expecting `aud` and authorizing `issuers`, served at @@ -689,7 +766,7 @@ impl NipFiCommunities { CommunityBinding::new(aud.to_owned(), issuers.iter().map(|i| (*i).to_owned())) .expect("valid test community"); Self { - any_host: Some(binding), + any_host: Some(Arc::new(binding)), ..Self::default() } } @@ -916,8 +993,6 @@ mod tests { for policy in [public_policy, mesh_policy] { assert_eq!(policy.command_audiences().len(), 1); assert_eq!(policy.command_audiences()[0], "https://relay.test"); - assert_eq!(policy.community_audiences().len(), 1); - assert_eq!(policy.community_audiences()[0], "https://relay.test"); } assert_eq!(cfg.jwks_configs.len(), 2); assert_eq!(cfg.jwks_configs[0].contract, public_contract); @@ -1894,4 +1969,31 @@ mod tests { ); } } + + // Mutation: building the resolver for the database source in every mode + // loads a snapshot that off and deny-protected never read. + #[test] + fn only_evaluating_modes_with_the_database_source_build_a_resolver() { + let base = { + let _guard = super::NIP_FI_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _env = EnvGuard::new(NIP_FI_VARS); + NipFiRelayConfig::from_env().expect("off config") + }; + for (mode, source, expected) in [ + (NipFiMode::Off, CommunitySource::Database, false), + (NipFiMode::DenyProtected, CommunitySource::Database, false), + (NipFiMode::Shadow, CommunitySource::Database, true), + (NipFiMode::Enforce, CommunitySource::Database, true), + (NipFiMode::Enforce, CommunitySource::Env, false), + ] { + let config = NipFiRelayConfig { + mode, + source, + ..base.clone() + }; + assert_eq!(config.builds_resolver(), expected, "{mode:?} {source:?}"); + } + } } diff --git a/crates/buzz-relay/src/nip_fi_core.rs b/crates/buzz-relay/src/nip_fi_core.rs index 86d4e776e0..1b1d887df7 100644 --- a/crates/buzz-relay/src/nip_fi_core.rs +++ b/crates/buzz-relay/src/nip_fi_core.rs @@ -47,8 +47,14 @@ impl AssertionRejection { } } -/// Resolve the community served at the request `Host` — enforce-mode -/// admission step 1, before any evidence is examined (NIP-FI.md:266-268). +/// The request `Host`, if present and readable. +fn request_host(headers: &HeaderMap) -> Option<&str> { + headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) +} + +/// Resolve the community served at the request `Host` from a static map. /// /// An absent, unreadable, or unmapped Host is `authorization_unavailable`. /// This makes mapped and unmapped Hosts distinguishable (401/403 vs 503) in @@ -57,14 +63,100 @@ impl AssertionRejection { pub(crate) fn resolve_community<'a>( headers: &HeaderMap, communities: &'a NipFiCommunities, -) -> Result<&'a CommunityBinding, DenialClass> { - headers - .get(axum::http::header::HOST) - .and_then(|v| v.to_str().ok()) +) -> Result<&'a std::sync::Arc, DenialClass> { + request_host(headers) .and_then(|host| communities.resolve(host)) .ok_or(DenialClass::AuthorizationUnavailable) } +/// A community resolved for one enforce admission. +#[derive(Clone)] +pub(crate) struct ResolvedCommunity { + binding: std::sync::Arc, + deadline: AdmissionDeadline, +} + +impl ResolvedCommunity { + /// The deadline admission must re-check after its own awaits. + pub(crate) fn deadline(&self) -> AdmissionDeadline { + self.deadline.clone() + } + + /// The shadow `community` label: the configured URI for an `env` + /// binding, whose set is bounded by config, else `bound`, since the + /// database's host set is unbounded. + pub(crate) fn shadow_label(&self) -> metrics::SharedString { + if self.deadline.0.is_none() { + self.binding.expected_aud().to_owned().into() + } else { + metrics::SharedString::const_str(crate::nip_fi_shadow::BOUND) + } + } +} + +impl std::ops::Deref for ResolvedCommunity { + type Target = CommunityBinding; + fn deref(&self) -> &CommunityBinding { + &self.binding + } +} + +/// The point past which admitting on a resolved community is +/// `authorization_unavailable`: the database snapshot's staleness deadline. +/// A static `env` binding never goes stale. +#[derive(Clone, Default)] +pub(crate) struct AdmissionDeadline(Option); + +impl AdmissionDeadline { + /// Run immediately before admitting. + pub(crate) fn check(&self) -> Result<(), DenialClass> { + self.0.as_ref().map_or(Ok(()), |fresh| fresh.check()) + } +} + +/// The relay's community source (`BUZZ_NIP_FI_COMMUNITY_SOURCE`): the +/// static `env` map, or the database resolver. +#[derive(Clone, Copy)] +pub(crate) enum Communities<'a> { + Env(&'a NipFiCommunities), + Database(&'a std::sync::Arc), + /// The `database` source with no resolver built: every host is + /// `authorization_unavailable`. + Unavailable, +} + +impl Communities<'_> { + /// Resolve the community at the request `Host` — enforce-mode admission + /// step 1, before any evidence is examined (NIP-FI.md:266-268). Called + /// once per admission; the returned binding is what verification uses. + pub(crate) async fn resolve( + self, + headers: &HeaderMap, + ) -> Result { + match self { + Self::Env(map) => resolve_community(headers, map).map(|b| ResolvedCommunity { + binding: std::sync::Arc::clone(b), + deadline: AdmissionDeadline::default(), + }), + Self::Database(resolver) => { + let host = request_host(headers).ok_or(DenialClass::AuthorizationUnavailable)?; + let (binding, fresh) = resolver.resolve(host).await?; + Ok(ResolvedCommunity { + binding, + deadline: AdmissionDeadline(Some(fresh)), + }) + } + Self::Unavailable => Err(DenialClass::AuthorizationUnavailable), + } + } +} + +impl<'a> From<&'a NipFiCommunities> for Communities<'a> { + fn from(map: &'a NipFiCommunities) -> Self { + Self::Env(map) + } +} + /// Extract and verify the attached assertion for `community`. /// /// Transport extraction runs before the verifier-presence check, so a @@ -198,7 +290,7 @@ pub(crate) mod test_support { /// The binding a fixture community expecting `aud` presents. pub(crate) fn binding(aud: &str) -> CommunityBinding { - any_host(aud).resolve(TEST_HOST).expect("served").clone() + (**any_host(aud).resolve(TEST_HOST).expect("served")).clone() } /// Request headers addressed to [`TEST_HOST`]. @@ -220,10 +312,11 @@ pub(crate) mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; fn community() -> CommunityBinding { - communities() - .resolve(super::test_support::TEST_HOST) - .expect("mapped") - .clone() + CommunityBinding::clone( + communities() + .resolve(super::test_support::TEST_HOST) + .expect("mapped"), + ) } /// Verifier returning a fixed result and counting calls. diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index eafb68862b..42993bbea3 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -61,11 +61,40 @@ use chrono::{DateTime, Utc}; use nostr::PublicKey; use std::fmt; -use crate::nip_fi_config::NipFiCommunities; +use crate::nip_fi_core::ResolvedCommunity; use crate::nip_fi_core::{ - asserted_key_matches, evaluate_attached_assertion, http_denial, resolve_community, - AssertionRejection, + asserted_key_matches, evaluate_attached_assertion, http_denial, AssertionRejection, Communities, }; +use buzz_auth::CommunityBinding; + +/// One resolution of the request `Host`, shared by every step of a request. +pub(crate) type Resolution = Result; + +tokio::task_local! { + /// The community the router guard resolved for this request. + static GUARD_COMMUNITY: Resolution; +} + +/// Run `next` with the guard's resolution, so the handler's admission and +/// every shadow record reuse it instead of resolving the Host again. +pub(crate) async fn with_resolved_community( + community: Resolution, + next: F, +) -> F::Output { + GUARD_COMMUNITY.scope(community, next).await +} + +/// Enforce step 1 for one HTTP admission: the guard's resolution when the +/// request passed through the guard, else one resolution (guard-exempt routes). +pub(crate) async fn resolve_for_admission( + communities: Communities<'_>, + headers: &HeaderMap, +) -> Resolution { + match GUARD_COMMUNITY.try_with(Resolution::clone) { + Ok(community) => community, + Err(_) => communities.resolve(headers).await, + } +} use crate::nip_fi_shadow::Stage; // ── Deny-map seam ───────────────────────────────────────────────────────────── @@ -291,10 +320,10 @@ impl NipFiAdmission { // would add allocation without architectural benefit. The large Err variant // is load-bearing: it IS the HTTP response, returned directly by handlers. #[allow(clippy::result_large_err)] -pub(crate) fn admit_nip_fi_http( +pub(crate) async fn admit_nip_fi_http<'a, D, X, F>( headers: &HeaderMap, extract_nip98: F, - communities: &NipFiCommunities, + communities: impl Into>, verifier: Option<&dyn VerifyAssertion>, mode: NipFiMode, deny_map: &D, @@ -309,6 +338,7 @@ where if mode.denies_unconditionally() { return Err(http_denial(DenialClass::AuthorizationUnavailable)); } + let communities = communities.into(); // Off and Shadow: the NIP-98 result passes through unchanged (legacy // behavior); shadow only records the enforce verdict. [FI-INV-15 exemption] @@ -316,7 +346,7 @@ where let proof = extract_nip98(); if mode.observes_only() { let signed = proof.as_ref().ok().filter(|p| p.signed).map(|p| p.pubkey); - observe_shadow_verdict(headers, communities, verifier, deny_map, signed); + observe_shadow_verdict(headers, communities, verifier, deny_map, signed).await; } let proof = proof?; return Ok(NipFiAdmission { @@ -326,20 +356,30 @@ where }); } - let (proof, assertion) = - evaluate_enforce_steps(headers, communities, verifier, deny_map, extract_nip98).map_err( - |(stage, class)| { - let reason = match stage { - Stage::Pairing => Some("nip_fi_http_key_mismatch"), - Stage::DenySet => Some("nip_fi_http_denied_pubkey"), - _ => None, - }; - if let Some(reason) = reason { - metrics::counter!("buzz_auth_failures_total", "reason" => reason).increment(1); - } - http_denial(class) - }, - )?; + let community = resolve_for_admission(communities, headers).await; + let (proof, assertion) = evaluate_enforce_steps( + headers, + community.as_deref(), + verifier, + deny_map, + extract_nip98, + ) + .map_err(|(stage, class)| { + let reason = match stage { + Stage::Pairing => Some("nip_fi_http_key_mismatch"), + Stage::DenySet => Some("nip_fi_http_denied_pubkey"), + _ => None, + }; + if let Some(reason) = reason { + metrics::counter!("buzz_auth_failures_total", "reason" => reason).increment(1); + } + http_denial(class) + })?; + // The handler may have awaited (tenant lookup) since the guard resolved + // the community; a snapshot that went stale meanwhile admits nothing. + if let Ok(community) = &community { + community.deadline().check().map_err(http_denial)?; + } // Step 8: admit. Ok(NipFiAdmission { @@ -351,32 +391,42 @@ where /// Record the verdict enforce would reach for a request whose NIP-98 step /// proved `signed` (`None`: missing, rejected or unsigned). -fn observe_shadow_verdict( +async fn observe_shadow_verdict( headers: &HeaderMap, - communities: &NipFiCommunities, + communities: Communities<'_>, verifier: Option<&dyn VerifyAssertion>, deny_map: &D, signed: Option, ) { - let verdict = evaluate_enforce_steps(headers, communities, verifier, deny_map, || { + let community = resolve_for_admission(communities, headers).await; + let verdict = evaluate_enforce_steps(headers, community.as_deref(), verifier, deny_map, || { signed.map(|key| Nip98Proof::new(key, ())).ok_or(()) + }) + .and_then(|_| match &community { + // Enforce's final re-check, after the handler's awaits. + Ok(community) => community + .deadline() + .check() + .map_err(|class| (Stage::Deadline, class)), + Err(_) => Ok(()), }); - crate::nip_fi_shadow::record("http", headers, communities, verdict.map(drop)); + crate::nip_fi_shadow::record("http", &community, verdict); } /// Shadow only: record the enforce verdict for a request whose NIP-98 proof -/// already failed, without any DB work. Off and enforce are untouched. -pub(crate) fn observe_failed_proof(state: &crate::state::AppState, headers: &HeaderMap) { +/// already failed. Off and enforce are untouched. +pub(crate) async fn observe_failed_proof(state: &crate::state::AppState, headers: &HeaderMap) { let nip_fi = &state.config.nip_fi; if nip_fi.mode.observes_only() { let verifier = state.nip_fi_verifier.as_deref(); observe_shadow_verdict( headers, - &nip_fi.communities, + state.nip_fi_communities(), verifier, &NoDenyMapConfigured, None, - ); + ) + .await; } } @@ -384,7 +434,7 @@ pub(crate) fn observe_failed_proof(state: &crate::state::AppState, headers: &Hea /// (the shadow `stage` label) and its class. fn evaluate_enforce_steps( headers: &HeaderMap, - communities: &NipFiCommunities, + community: Result<&CommunityBinding, &DenialClass>, verifier: Option<&dyn VerifyAssertion>, deny_map: &D, extract_nip98: F, @@ -397,7 +447,7 @@ where // evidence (`Authorization`, NIP-98, assertion) is examined. Explicit // here because some admitted routes are exempt from the router guard. // [NIP-FI.md:266-268] - let community = resolve_community(headers, communities).map_err(|c| (Stage::Community, c))?; + let community = community.map_err(|c| (Stage::Community, *c))?; // Step 2 — cardinality gate: exactly one Authorization field per // NIP-FI.md:695-700. @@ -464,7 +514,7 @@ where /// [FI-TRACE-AUTHORITY-UNIFORM] // Response is intentionally large (axum's design); see admit_nip_fi_http. #[allow(clippy::result_large_err)] -pub(crate) fn admit_nip_fi_http_on_state( +pub(crate) async fn admit_nip_fi_http_on_state( state: &crate::state::AppState, headers: &HeaderMap, extract_nip98: F, @@ -474,24 +524,30 @@ where { let mode = state.config.nip_fi.mode; let verifier = state.nip_fi_verifier.as_deref(); - let communities = &state.config.nip_fi.communities; + let communities = state.nip_fi_communities(); match state.nip_fi_deny_map.as_deref() { - Some(deny_map) => admit_nip_fi_http( - headers, - extract_nip98, - communities, - verifier, - mode, - deny_map, - ), - None => admit_nip_fi_http( - headers, - extract_nip98, - communities, - verifier, - mode, - &NoDenyMapConfigured, - ), + Some(deny_map) => { + admit_nip_fi_http( + headers, + extract_nip98, + communities, + verifier, + mode, + deny_map, + ) + .await + } + None => { + admit_nip_fi_http( + headers, + extract_nip98, + communities, + verifier, + mode, + &NoDenyMapConfigured, + ) + .await + } } } @@ -508,6 +564,7 @@ mod tests { use crate::nip_fi_core::tests::ScriptedVerifier; use axum::http::StatusCode; use buzz_auth::CLIENT_ATTACHED_HEADER; + use futures_util::FutureExt as _; /// Test fixture: a deny map with no entries. struct AlwaysAdmitStubDenyMap; @@ -733,6 +790,8 @@ mod tests { NipFiMode::Enforce, deny_map, ) + .now_or_never() + .expect("env resolution never waits") }; let snapshot = |resp: Response| { let status = resp.status(); @@ -779,7 +838,9 @@ mod tests { None::<&dyn VerifyAssertion>, NipFiMode::Off, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let admission = outcome.expect("Off mode MUST not require NIP-FI assertion — OSS default regression"); assert_eq!(*admission.proven_pubkey(), expected_pubkey); @@ -802,7 +863,9 @@ mod tests { None::<&dyn VerifyAssertion>, NipFiMode::Off, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let resp = outcome.unwrap_err(); assert_eq!(resp.status(), deny_status); } @@ -834,7 +897,9 @@ mod tests { None::<&dyn VerifyAssertion>, NipFiMode::Enforce, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let resp = outcome.unwrap_err(); assert_eq!( resp.status(), @@ -860,7 +925,9 @@ mod tests { None::<&dyn VerifyAssertion>, NipFiMode::Enforce, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let resp = outcome.unwrap_err(); assert_eq!( resp.status(), @@ -884,7 +951,9 @@ mod tests { None::<&dyn VerifyAssertion>, NipFiMode::Off, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let resp = outcome.unwrap_err(); assert_eq!( resp.status(), @@ -922,7 +991,9 @@ mod tests { None::<&dyn VerifyAssertion>, NipFiMode::Off, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let resp = outcome.unwrap_err(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert_eq!( @@ -1014,7 +1085,9 @@ mod tests { Some(&verifier as &dyn VerifyAssertion), NipFiMode::DenyProtected, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let Err(resp) = outcome else { panic!("{case}: DenyProtected must deny with 503"); }; @@ -1058,7 +1131,9 @@ mod tests { None::<&dyn VerifyAssertion>, NipFiMode::Enforce, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); // Missing header → MissingEvidence before verifier check. match outcome { Err(resp) => { @@ -1090,7 +1165,9 @@ mod tests { None::<&dyn VerifyAssertion>, NipFiMode::Enforce, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); match outcome { Err(resp) => { assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); @@ -1150,7 +1227,9 @@ mod tests { Some(&verifier as &dyn VerifyAssertion), NipFiMode::Enforce, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); match outcome { Err(resp) => { assert_eq!( @@ -1226,7 +1305,9 @@ mod tests { None::<&dyn VerifyAssertion>, NipFiMode::Enforce, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let resp = outcome.unwrap_err(); assert_eq!( resp.status(), @@ -1276,7 +1357,9 @@ mod tests { None::<&dyn VerifyAssertion>, NipFiMode::Off, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let admission = match outcome { Ok(a) => a, Err(resp) => panic!( @@ -1321,6 +1404,8 @@ mod tests { NipFiMode::Enforce, &AlwaysAdmitStubDenyMap, ) + .now_or_never() + .expect("env resolution never waits") } fn denial_parts(outcome: Result, Response>) -> (StatusCode, Vec) { @@ -1434,7 +1519,9 @@ mod tests { Some(&verifier as &dyn VerifyAssertion), NipFiMode::Enforce, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let (status, body) = denial_parts(outcome); assert_eq!(status, StatusCode::UNAUTHORIZED); assert_eq!(body, b"authentication required\n"); @@ -1466,7 +1553,9 @@ mod tests { Some(&verifier as &dyn VerifyAssertion), NipFiMode::Enforce, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let (status, body) = denial_parts(outcome); assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(body, b"authorization unavailable\n"); @@ -1500,6 +1589,8 @@ mod tests { NipFiMode::Shadow, &AlwaysAdmitStubDenyMap, ) + .now_or_never() + .expect("env resolution never waits") .expect("shadow admits an unmapped Host"); assert_eq!(admission.proven_pubkey, key); assert!(admission.assertion.is_none()); @@ -1537,6 +1628,8 @@ mod tests { NipFiMode::Shadow, &AlwaysAdmitStubDenyMap, ) + .now_or_never() + .expect("env resolution never waits") }) .expect("shadow admits the dev fallback like Off"); assert_eq!(admission.proven_pubkey, key); @@ -1567,6 +1660,8 @@ mod tests { NipFiMode::Shadow, &AlwaysAdmitStubDenyMap, ) + .now_or_never() + .expect("env resolution never waits") .expect("shadow admits"); assert!(admission.assertion.is_none()); assert_eq!(verifier.calls(), 1); @@ -1578,7 +1673,9 @@ mod tests { Some(&verifier as &dyn VerifyAssertion), NipFiMode::Shadow, &AlwaysAdmitStubDenyMap, - ); + ) + .now_or_never() + .expect("env resolution never waits"); let Err(resp) = legacy else { panic!("NIP-98 failure must propagate") }; diff --git a/crates/buzz-relay/src/nip_fi_resolver.rs b/crates/buzz-relay/src/nip_fi_resolver.rs new file mode 100644 index 0000000000..3c1ffb2b9f --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_resolver.rs @@ -0,0 +1,875 @@ +//! Database-sourced NIP-FI community resolution (`BUZZ_NIP_FI_COMMUNITY_SOURCE=database`). +//! +//! Each pod holds a snapshot of every registry-filtered binding of an active +//! community, loaded at startup and refreshed every 30s, start to start. A +//! host found in a fresh snapshot (loaded under two minutes ago) is answered +//! from memory. Any other host, and every host once the snapshot is stale, +//! goes to a bounded, single-flight database lookup whose positive answer is +//! used for that admission only and whose "not found" is cached for 5s. +//! +//! Every answer carries a [`FreshUntil`] two minutes after the read behind +//! it started (the snapshot load, or the lookup), re-checked after every +//! await, so no admission rests on a read older than that. +//! +//! A removed binding stops admitting new connections once a later refresh +//! publishes: within [`REFRESH_INTERVAL`] plus the refresh timeout (40s by +//! default) while refreshes succeed. While they fail, the last snapshot keeps +//! answering until it goes stale, so the bound is [`STALE_AFTER`] (two +//! minutes) from that snapshot's read; after that every host is looked up +//! live. Open WebSocket sessions are not rechecked when a binding is removed; +//! they end at their own session deadline. +//! +//! `buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds` is written on +//! every refresh attempt with the start of the last good load (zero before +//! the first). Alert on `time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds > 120` +//! together with `absent(buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds)` +//! for a database-source pod, since a pod that never exports it cannot age. +//! +//! The snapshot pointer and the negative cache change under one publication +//! lock. Publishing a snapshot and recording a creation bump a sequence +//! number; a "not found" read while the sequence moved is not cached. +//! Snapshot reads are lock-free. + +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use arc_swap::ArcSwapOption; +use buzz_auth::{CommunityBinding, DenialClass, ProvisionBindingPolicy}; +use buzz_core::tenant::normalize_host; +use buzz_db::RowCapExceeded; +use futures_util::future::{BoxFuture, FutureExt, Shared}; +use tokio::sync::Semaphore; + +use crate::config::ConfigError; + +#[cfg(test)] +mod tests; + +/// Refreshes start this often, measured start to start. +pub const REFRESH_INTERVAL: Duration = Duration::from_secs(30); +/// The last good snapshot serves for at most this long after its load. +pub const STALE_AFTER: Duration = Duration::from_secs(120); +/// The first retry delay after a failed refresh; it doubles per failure up +/// to [`REFRESH_INTERVAL`]. +pub const RETRY_FIRST: Duration = Duration::from_secs(2); +/// How long a "not found" miss is cached. +pub const NEGATIVE_TTL: Duration = Duration::from_secs(5); + +/// Resolver bounds. Every bound fails closed. Only the first four are read +/// from the environment; the lookup queue limits are fixed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolverBounds { + /// `BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX`, at most 1,000,000. + pub negative_cache_max: usize, + /// `BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT`, kept below the writer pool size + /// so lookups never take every database connection. + pub max_inflight: usize, + /// `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS`, at most 10,000,000: + /// community-issuer pairs, not communities. + pub snapshot_max_rows: usize, + /// `BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS`, shorter than + /// [`REFRESH_INTERVAL`]. + pub refresh_timeout: Duration, + /// Hosts with a lookup in flight. + pub max_pending_hosts: usize, + /// Callers waiting on those lookups. + pub max_waiters: usize, + /// How long a lookup waits for an inflight permit. + pub permit_wait: Duration, + /// How long a lookup's database read may take. + pub lookup_timeout: Duration, +} + +impl Default for ResolverBounds { + fn default() -> Self { + Self { + negative_cache_max: 100_000, + max_inflight: 8, + snapshot_max_rows: 1_000_000, + refresh_timeout: Duration::from_secs(10), + max_pending_hosts: 1_024, + max_waiters: 4_096, + permit_wait: Duration::from_millis(50), + lookup_timeout: Duration::from_millis(500), + } + } +} + +impl ResolverBounds { + /// Read the tunable bounds from the environment, defaulting unset ones, + /// and keep `max_inflight` below `db_pool_size` (at least 1). + pub fn from_env(db_pool_size: u32) -> Result { + let d = Self::default(); + let bounded = |name: &str, max: u64, default: usize| -> Result { + Ok(crate::nip_fi_config::parse_u64_bounded(name, 1, max)? + .map_or(default, |v| v as usize)) + }; + let below_interval = REFRESH_INTERVAL.as_millis() as u64 - 1; + let refresh_timeout = crate::nip_fi_config::parse_u64_bounded( + "BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS", + 1, + below_interval, + )? + .map_or(d.refresh_timeout, Duration::from_millis); + let max_inflight = bounded( + "BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT", + u32::MAX.into(), + d.max_inflight, + )? + .min(db_pool_size.saturating_sub(1).max(1) as usize); + Ok(Self { + negative_cache_max: bounded( + "BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX", + 1_000_000, + d.negative_cache_max, + )?, + max_inflight, + snapshot_max_rows: bounded( + "BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS", + 10_000_000, + d.snapshot_max_rows, + )?, + refresh_timeout, + ..d + }) + } +} + +/// A snapshot load's `(host, issuer)` rows, or the cap it exceeded; the +/// outer error is a failed read. +pub type SnapshotRows = Result, RowCapExceeded>, String>; + +/// Where bindings are read from. Implemented by [`buzz_db::Db`]; tests +/// substitute counting and failing sources. +pub trait BindingSource: Send + Sync + 'static { + /// One consistent registry-filtered read of every active binding, as + /// `(host, issuer)` rows; more than `max_rows` fails. + fn load<'a>(&'a self, registry: &'a [String], max_rows: usize) -> BoxFuture<'a, SnapshotRows>; + + /// The registry-filtered issuers of the active community at `host`. + fn lookup<'a>( + &'a self, + host: &'a str, + registry: &'a [String], + ) -> BoxFuture<'a, Result, String>>; +} + +impl BindingSource for buzz_db::Db { + fn load<'a>(&'a self, registry: &'a [String], max_rows: usize) -> BoxFuture<'a, SnapshotRows> { + async move { + self.load_nip_fi_binding_snapshot(registry, max_rows) + .await + .map_err(|e| e.to_string()) + } + .boxed() + } + + fn lookup<'a>( + &'a self, + host: &'a str, + registry: &'a [String], + ) -> BoxFuture<'a, Result, String>> { + async move { + self.lookup_nip_fi_admission_binding(host, registry) + .await + .map_err(|e| e.to_string()) + } + .boxed() + } +} + +/// Monotonic time since the resolver started; replaceable in tests. +pub trait Clock: Send + Sync + 'static { + /// Elapsed time on this clock. + fn now(&self) -> Duration; +} + +struct TokioClock(tokio::time::Instant); + +impl Clock for TokioClock { + fn now(&self) -> Duration { + self.0.elapsed() + } +} + +/// The staleness deadline of the read an admission resolved against: two +/// minutes after that snapshot load or lookup started. Admission re-checks +/// it after its own awaits, so a binding resolved just inside the limit +/// cannot be admitted after it. Holds no map. +#[derive(Clone)] +pub struct FreshUntil { + clock: Arc, + deadline: Duration, +} + +impl FreshUntil { + /// `authorization_unavailable` once the deadline has passed. + pub fn check(&self) -> Result<(), DenialClass> { + (self.clock.now() < self.deadline) + .then_some(()) + .ok_or(DenialClass::AuthorizationUnavailable) + } +} + +/// One published full load. +struct Published { + map: HashMap>, + /// Clock time the load started; the staleness deadline counts from here. + loaded_at: Duration, +} + +/// State changed only under the publication lock. +struct Writable { + negative: lru::LruCache, +} + +/// A lookup's binding, if any, with the clock time its read started. +type MissOutcome = Result, Duration)>, ()>; +type PendingLookup = Shared>; + +/// Why a refresh published nothing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoadFailure { + /// The store failed. + Store(String), + /// More rows than `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS`. + OverCap, + /// The whole attempt exceeded `BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS`. + Timeout, + /// Its read started before the published one's (defensive). + Superseded, + /// An earlier attempt's map build is still running; no new load starts. + BuildOutstanding, +} + +impl LoadFailure { + fn label(&self) -> &'static str { + match self { + Self::Store(_) => "store", + Self::OverCap => "over_cap", + Self::Timeout => "timeout", + Self::Superseded => "superseded", + Self::BuildOutstanding => "build_outstanding", + } + } +} + +/// The database-sourced community resolver. +pub struct DatabaseResolver { + source: Arc, + policy: ProvisionBindingPolicy, + registry: Vec, + bounds: ResolverBounds, + clock: Arc, + published: ArcSwapOption, + publication: Mutex, + sequence: AtomicU64, + pending: Mutex>, + waiters: AtomicUsize, + permits: Arc, + /// Set while an attempt (its load or its map build) is alive. A build + /// abandoned by a timeout keeps it set until the builder thread returns. + building: Arc, + /// Wall-clock seconds of the last successful load's start, as `f64` + /// bits; zero before the first. + loaded_wall_secs: AtomicU64, + /// A uniform draw in `[0, 1)` for refresh jitter; fixed in tests. + pub(crate) jitter: fn() -> f64, + #[cfg(test)] + pub(crate) hooks: Arc, +} + +impl DatabaseResolver { + /// A resolver with no snapshot yet: every admission is unavailable until + /// the first [`DatabaseResolver::refresh`] succeeds. + pub fn new( + source: Arc, + policy: ProvisionBindingPolicy, + registry: Vec, + bounds: ResolverBounds, + ) -> Self { + Self::with_clock( + source, + policy, + registry, + bounds, + Arc::new(TokioClock(tokio::time::Instant::now())), + ) + } + + pub(crate) fn with_clock( + source: Arc, + policy: ProvisionBindingPolicy, + registry: Vec, + bounds: ResolverBounds, + clock: Arc, + ) -> Self { + let negative_cap = + NonZeroUsize::new(bounds.negative_cache_max).unwrap_or(NonZeroUsize::MIN); + Self { + source, + policy, + registry, + permits: Arc::new(Semaphore::new(bounds.max_inflight)), + bounds, + clock, + published: ArcSwapOption::empty(), + publication: Mutex::new(Writable { + negative: lru::LruCache::new(negative_cap), + }), + sequence: AtomicU64::new(0), + pending: Mutex::new(HashMap::new()), + waiters: AtomicUsize::new(0), + building: Arc::default(), + loaded_wall_secs: AtomicU64::new(0), + jitter: rand::random::, + #[cfg(test)] + hooks: Arc::default(), + } + } + + /// Resolve the community at `raw_host` for one admission: from a fresh + /// snapshot when it lists the host, otherwise from a database lookup, + /// each answer judged by its own read's age. + pub async fn resolve( + self: &Arc, + raw_host: &str, + ) -> Result<(Arc, FreshUntil), DenialClass> { + const UNAVAILABLE: DenialClass = DenialClass::AuthorizationUnavailable; + let host = normalize_host(raw_host); + if !self.policy.is_bindable(&host) { + return Err(UNAVAILABLE); + } + if let Some(hit) = self.fresh_snapshot_hit(&host) { + return Ok(hit); + } + if self.negatively_cached(&host) { + return Err(UNAVAILABLE); + } + match self.miss(host).await { + Ok(Some((binding, read_started))) => Ok((binding, self.fresh_until(read_started))), + Ok(None) | Err(()) => Err(UNAVAILABLE), + } + } + + /// Record that this pod just committed a binding for `host`: drop its + /// cached "not found" and bump the sequence, so a lookup that read + /// before the commit cannot cache one, and the creator's first login + /// looks it up. + pub fn record_creation(&self, host: &str) { + let host = normalize_host(host); + let mut writable = self.lock(); + writable.negative.pop(&host); + self.bump(&writable); + } + + /// Run one full load and publish it. A failure publishes nothing and + /// leaves the staleness deadline where it was. + pub async fn refresh(&self) -> Result<(), LoadFailure> { + let started = self.clock.now(); + // The attempt's absolute deadline. The timeout below only fires at an + // await; the synchronous map build and publication after the read + // check this instead, so expired work is never published. + let deadline = started + self.bounds.refresh_timeout; + let started_wall = std::time::SystemTime::now(); + let timer = std::time::Instant::now(); + // Held by the attempt, then by its builder thread: dropping the + // attempt on timeout cannot release it while the build still runs. + let Some(slot) = BuildSlot::claim(&self.building) else { + metrics::counter!("buzz_nip_fi_binding_snapshot_load_failures_total", "reason" => LoadFailure::BuildOutstanding.label()) + .increment(1); + return Err(LoadFailure::BuildOutstanding); + }; + let attempt = async { + let load = self + .source + .load(&self.registry, self.bounds.snapshot_max_rows) + .await + .map_err(LoadFailure::Store)? + .map_err(|_| LoadFailure::OverCap)?; + let rows = load.len(); + // Up to the row cap of grouping and binding construction: run it + // off the runtime worker. + // Set when this attempt is dropped (timeout or cancel); the + // builder checks it, and the deadline, as it goes. + let abandoned = Abandoned::default(); + let stop = { + let flag = Arc::clone(&abandoned.0); + let clock = Arc::clone(&self.clock); + move || flag.load(Ordering::Acquire) || clock.now() >= deadline + }; + #[cfg(test)] + let test_hooks = Arc::clone(&self.hooks); + let map = tokio::task::spawn_blocking(move || { + let _slot = slot; + #[cfg(test)] + test_hooks.hit_sync(hooks::BUILD_START); + build_map(load, stop) + }) + .await + .map_err(|e| LoadFailure::Store(format!("map build failed: {e}")))? + .ok_or(LoadFailure::Timeout)?; + #[cfg(test)] + self.hooks.hit(hooks::REFRESH_BEFORE_PUBLISH).await; + let replaced = self.publish(map, started, deadline)?; + // Freeing up to the row cap of bindings is real work; keep it + // off the runtime worker and outside the publication lock. + if let Some(old) = replaced { + tokio::task::spawn_blocking(move || drop(old)); + } + Ok(rows) + }; + let result = match tokio::time::timeout(self.bounds.refresh_timeout, attempt).await { + Ok(result) => result, + Err(_) => Err(LoadFailure::Timeout), + }; + metrics::histogram!("buzz_nip_fi_binding_snapshot_load_duration_seconds") + .record(timer.elapsed().as_secs_f64()); + if result.is_ok() { + if let Ok(since_epoch) = started_wall.duration_since(std::time::UNIX_EPOCH) { + self.loaded_wall_secs + .store(since_epoch.as_secs_f64().to_bits(), Ordering::Relaxed); + } + } + // Rewritten on every attempt, failed ones included, so the series + // exists from the first attempt and keeps reporting the last good + // load. A timestamp, not an age: `time() - ...` keeps aging at scrape + // time even if the refresh task stops; `absent()` catches a pod that + // never exports it. + let loaded = f64::from_bits(self.loaded_wall_secs.load(Ordering::Relaxed)); + metrics::gauge!("buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds").set(loaded); + metrics::gauge!("buzz_nip_fi_binding_snapshot_max_rows") + .set(self.bounds.snapshot_max_rows as f64); + match result { + Ok(rows) => { + metrics::gauge!("buzz_nip_fi_binding_snapshot_rows").set(rows as f64); + Ok(()) + } + Err(failure) => { + tracing::warn!( + reason = failure.label(), + "NIP-FI binding snapshot load failed" + ); + metrics::counter!("buzz_nip_fi_binding_snapshot_load_failures_total", "reason" => failure.label()) + .increment(1); + Err(failure) + } + } + } + + /// Refresh every [`REFRESH_INTERVAL`], start to start, never + /// overlapping. After a failure the next attempt comes sooner, backing + /// off from [`RETRY_FIRST`] and doubling up to the interval; every delay + /// is jittered down by up to a tenth so pods do not refresh in lockstep. + /// The caller has already done the startup load. + pub async fn run_refresh_loop(self: Arc, cancel: tokio_util::sync::CancellationToken) { + let mut failures = 0u32; + let mut started = tokio::time::Instant::now(); + loop { + let delay = jittered(next_refresh_delay(failures), (self.jitter)()); + tokio::select! { + () = cancel.cancelled() => return, + () = tokio::time::sleep_until(started + delay) => {} + } + started = tokio::time::Instant::now(); + failures = match self.refresh().await { + Ok(()) => 0, + Err(_) => failures.saturating_add(1), + }; + } + } + + // ── Internals ─────────────────────────────────────────────────────────── + + fn lock(&self) -> std::sync::MutexGuard<'_, Writable> { + self.publication + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + + /// Bump the publication sequence; the caller holds the lock. + fn bump(&self, _held: &Writable) { + self.sequence.fetch_add(1, Ordering::AcqRel); + } + + /// The deadline for an answer whose read started at `read_started`. + fn fresh_until(&self, read_started: Duration) -> FreshUntil { + FreshUntil { + clock: Arc::clone(&self.clock), + deadline: read_started + STALE_AFTER, + } + } + + /// `host` in the published snapshot, unless that snapshot is stale (or + /// none is loaded): a stale one may still list a removed binding. Clones + /// only the selected binding. + fn fresh_snapshot_hit(&self, host: &str) -> Option<(Arc, FreshUntil)> { + let published = self.published.load(); + let published = published.as_ref()?; + let fresh = self.fresh_until(published.loaded_at); + fresh.check().ok()?; + Some((Arc::clone(published.map.get(host)?), fresh)) + } + + fn negatively_cached(&self, host: &str) -> bool { + let now = self.clock.now(); + let mut writable = self.lock(); + match writable.negative.get(host) { + Some(expiry) if *expiry > now => true, + Some(_) => { + writable.negative.pop(host); + false + } + None => false, + } + } + + fn publish( + &self, + map: HashMap>, + started: Duration, + deadline: Duration, + ) -> Result>, LoadFailure> { + let mut writable = self.lock(); + if self + .published + .load() + .as_ref() + .is_some_and(|published| started < published.loaded_at) + { + return Err(LoadFailure::Superseded); + } + #[cfg(test)] + self.hooks.hit_sync(hooks::PUBLISH_BEFORE_WRITE); + // Checked under the lock, immediately before the first write: + // waiting for the lock cannot let an expired attempt publish. + if self.clock.now() >= deadline { + return Err(LoadFailure::Timeout); + } + let replaced = self.published.swap(Some(Arc::new(Published { + map, + loaded_at: started, + }))); + writable.negative.clear(); + metrics::gauge!("buzz_nip_fi_resolver_negative_cache_size").set(0.0); + self.bump(&writable); + Ok(replaced) + } + + /// The bounded, single-flight lookup. A positive answer is returned to + /// its waiters only; a "not found" is cached only if no creation or + /// publication happened while it ran. + async fn miss(self: &Arc, host: String) -> MissOutcome { + let lookup = { + let mut pending = self.pending.lock().unwrap_or_else(PoisonError::into_inner); + match pending.get(&host) { + Some(lookup) => lookup.clone(), + None => { + if pending.len() >= self.bounds.max_pending_hosts { + reject("pending_hosts"); + return Err(()); + } + let lookup = self.spawn_lookup(host.clone()); + pending.insert(host, lookup.clone()); + lookup + } + } + }; + let Some(_waiter) = WaiterSlot::take(&self.waiters, self.bounds.max_waiters) else { + reject("waiters"); + return Err(()); + }; + lookup.await + } + + /// Run one lookup on its own task, so it completes (and leaves the + /// pending map) even if every waiter goes away. + fn spawn_lookup(self: &Arc, host: String) -> PendingLookup { + let this = Arc::clone(self); + let task = tokio::spawn(async move { + // Leaves the pending map even if the lookup panics, so the host + // is not stuck behind a dead entry. + struct Leave<'a>(&'a DatabaseResolver, &'a str); + impl Drop for Leave<'_> { + fn drop(&mut self) { + self.0 + .pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(self.1); + } + } + let _leave = Leave(&this, &host); + let sequence = this.sequence.load(Ordering::Acquire); + let outcome = this.lookup_once(&host).await; + if matches!(outcome, Ok(None)) { + this.store_negative(&host, sequence); + } + outcome + }); + async move { task.await.unwrap_or(Err(())) } + .boxed() + .shared() + } + + async fn lookup_once(&self, host: &str) -> MissOutcome { + let permit = match tokio::time::timeout( + self.bounds.permit_wait, + Arc::clone(&self.permits).acquire_owned(), + ) + .await + { + Ok(Ok(permit)) => permit, + _ => { + reject("permit"); + return Err(()); + } + }; + // Every waiter on this lookup shares this deadline origin. + let read_started = self.clock.now(); + let timer = std::time::Instant::now(); + let read = tokio::time::timeout( + self.bounds.lookup_timeout, + self.source.lookup(host, &self.registry), + ) + .await; + drop(permit); + metrics::histogram!("buzz_nip_fi_resolver_lookup_duration_seconds") + .record(timer.elapsed().as_secs_f64()); + #[cfg(test)] + self.hooks.hit(hooks::MISS_AFTER_READ).await; + match read { + Err(_) => { + metrics::counter!("buzz_nip_fi_resolver_lookup_timeouts_total").increment(1); + Err(()) + } + Ok(Err(_)) => Err(()), + Ok(Ok(issuers)) if issuers.is_empty() => Ok(None), + Ok(Ok(issuers)) => Ok(binding_for(host, issuers).map(|b| (b, read_started))), + } + } + + fn store_negative(&self, host: &str, sequence: u64) { + let mut writable = self.lock(); + if self.sequence.load(Ordering::Acquire) != sequence { + return; + } + let expiry = self.clock.now() + NEGATIVE_TTL; + if let Some((evicted, _)) = writable.negative.push(host.to_owned(), expiry) { + if evicted != host { + metrics::counter!("buzz_nip_fi_resolver_negative_evictions_total").increment(1); + } + } + metrics::gauge!("buzz_nip_fi_resolver_negative_cache_size") + .set(writable.negative.len() as f64); + } + + #[cfg(test)] + pub(crate) fn negative_len(&self) -> usize { + self.lock().negative.len() + } + + #[cfg(test)] + pub(crate) fn pending_len(&self) -> usize { + self.pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .len() + } + + #[cfg(test)] + pub(crate) fn waiters_now(&self) -> usize { + self.waiters.load(Ordering::Acquire) + } + + #[cfg(test)] + pub(crate) fn inflight_now(&self) -> usize { + self.bounds.max_inflight - self.permits.available_permits() + } +} + +fn reject(reason: &'static str) { + metrics::counter!("buzz_nip_fi_resolver_miss_rejections_total", "reason" => reason) + .increment(1); +} + +/// One counted caller waiting on a pending lookup. +struct WaiterSlot<'a>(&'a AtomicUsize); + +impl<'a> WaiterSlot<'a> { + fn take(waiters: &'a AtomicUsize, max: usize) -> Option { + waiters + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| { + (n < max).then_some(n + 1) + }) + .ok() + .map(|_| Self(waiters)) + } +} + +impl Drop for WaiterSlot<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +/// The delay before the next refresh after `failures` consecutive failures. +fn next_refresh_delay(failures: u32) -> Duration { + match failures { + 0 => REFRESH_INTERVAL, + n => RETRY_FIRST + .saturating_mul(1 << (n - 1).min(16)) + .min(REFRESH_INTERVAL), + } +} + +/// `delay` shortened by up to a tenth, by `unit` in `[0, 1)`. +fn jittered(delay: Duration, unit: f64) -> Duration { + delay.mul_f64(1.0 - unit.clamp(0.0, 1.0) / 10.0) +} + +fn binding_for(host: &str, issuers: Vec) -> Option> { + CommunityBinding::new(format!("https://{host}"), issuers) + .ok() + .map(Arc::new) +} + +/// Group rows into bindings, or `None` as soon as `stop` reports the +/// attempt abandoned or expired; checked in both passes. +fn build_map( + rows: Vec<(String, String)>, + stop: impl Fn() -> bool, +) -> Option>> { + let mut grouped: HashMap> = HashMap::new(); + for (host, issuer) in rows { + if stop() { + return None; + } + grouped + .entry(normalize_host(&host)) + .or_default() + .push(issuer); + } + let mut map = HashMap::with_capacity(grouped.len()); + for (host, issuers) in grouped { + if stop() { + return None; + } + if let Some(binding) = binding_for(&host, issuers) { + map.insert(host, binding); + } + } + Some(map) +} + +/// The resolver's single in-flight attempt; released on drop, wherever the +/// owner (the attempt or its builder thread) ends, including a panic. +struct BuildSlot(Arc); + +impl BuildSlot { + fn claim(building: &Arc) -> Option { + building + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .ok() + .map(|_| Self(Arc::clone(building))) + } +} + +impl Drop for BuildSlot { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +/// Raised when the awaiting attempt is dropped, telling its builder to stop. +#[derive(Default)] +struct Abandoned(Arc); + +impl Drop for Abandoned { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } +} + +#[cfg(test)] +pub(crate) mod hooks { + //! Per-resolver pause points for ordering tests. + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + use tokio::sync::Semaphore; + + pub(crate) const REFRESH_BEFORE_PUBLISH: &str = "refresh.before_publish"; + pub(crate) const MISS_AFTER_READ: &str = "miss.after_read"; + /// Synchronous, under the publication lock: see [`Hooks::on_sync`]. + pub(crate) const PUBLISH_BEFORE_WRITE: &str = "publish.before_write"; + /// Synchronous, on the builder thread before the map build. + pub(crate) const BUILD_START: &str = "build.start"; + + /// An armed pause: production signals `reached`, then waits for `release`. + pub(crate) struct Gate { + reached: Semaphore, + release: Semaphore, + } + + impl Gate { + pub(crate) async fn reached(&self) { + self.reached.acquire().await.expect("open").forget(); + } + + pub(crate) fn release(&self) { + self.release.add_permits(1); + } + } + + #[derive(Default)] + pub(crate) struct Hooks( + Mutex>>, + Mutex>, + ); + + type SyncAction = Arc; + + impl Hooks { + /// Pause every later hit of `point` until released, once per release. + pub(crate) fn arm(&self, point: &'static str) -> Arc { + let gate = Arc::new(Gate { + reached: Semaphore::new(0), + release: Semaphore::new(0), + }); + self.0.lock().unwrap().insert(point, Arc::clone(&gate)); + gate + } + + pub(crate) fn disarm(&self, point: &'static str) { + self.0.lock().unwrap().remove(point); + } + + /// Run `action` at every later hit of the synchronous `point`. + pub(crate) fn on_sync( + &self, + point: &'static str, + action: impl Fn() + Send + Sync + 'static, + ) { + self.1.lock().unwrap().insert(point, Arc::new(action)); + } + + pub(crate) fn hit_sync(&self, point: &'static str) { + let action = self.1.lock().unwrap().get(point).cloned(); + if let Some(action) = action { + action(); + } + } + + pub(crate) async fn hit(&self, point: &'static str) { + let gate = self.0.lock().unwrap().get(point).cloned(); + if let Some(gate) = gate { + gate.reached.add_permits(1); + gate.release.acquire().await.expect("open").forget(); + } + } + } +} diff --git a/crates/buzz-relay/src/nip_fi_resolver/tests.rs b/crates/buzz-relay/src/nip_fi_resolver/tests.rs new file mode 100644 index 0000000000..309ca1c310 --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_resolver/tests.rs @@ -0,0 +1,1097 @@ +//! Resolver ordering, staleness, and bounds tests. The store is an in-memory +//! fake that counts every call; the clock is manual. + +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use buzz_auth::{DenialClass, ProvisionBindingPolicy}; +use buzz_db::RowCapExceeded; +use futures_util::future::{BoxFuture, FutureExt}; + +use super::hooks::{BUILD_START, MISS_AFTER_READ, PUBLISH_BEFORE_WRITE, REFRESH_BEFORE_PUBLISH}; +use super::*; + +const ISS: &str = "https://app.builderlab.xyz/api/goose"; +const UNAVAILABLE: Result<(), DenialClass> = Err(DenialClass::AuthorizationUnavailable); + +fn policy() -> ProvisionBindingPolicy { + ProvisionBindingPolicy::parse( + &serde_json::json!({"host_suffix": "communities.buzz.xyz", "authorized_issuers": [ISS]}) + .to_string(), + &serde_json::json!([ + {"issuer": ISS, "command_audiences": ["https://communities.buzz.xyz"], "token_class": "nip-fi+jwt"} + ]) + .to_string(), + ) + .expect("valid policy") +} + +fn host(label: &str) -> String { + format!("{label}.communities.buzz.xyz") +} + +#[derive(Default)] +struct ManualClock(AtomicU64); + +impl ManualClock { + fn set(&self, at: Duration) { + self.0.store(at.as_millis() as u64, Ordering::SeqCst); + } +} + +impl Clock for ManualClock { + fn now(&self) -> Duration { + Duration::from_millis(self.0.load(Ordering::SeqCst)) + } +} + +/// In-memory bindings. +struct FakeStore { + hosts: Mutex>, + fail_loads: std::sync::atomic::AtomicBool, + lookup_fails: std::sync::atomic::AtomicBool, + lookup_panics: std::sync::atomic::AtomicBool, + loads: AtomicUsize, + /// Rows the last load pulled from its stream. + rows_pulled: AtomicUsize, + lookups: Mutex>, + lookup_delay: Mutex, + load_delay: Mutex, +} + +impl FakeStore { + fn new(hosts: &[&str]) -> Arc { + Arc::new(Self { + hosts: Mutex::new(hosts.iter().map(|l| host(l)).collect()), + fail_loads: false.into(), + lookup_fails: false.into(), + lookup_panics: false.into(), + loads: AtomicUsize::new(0), + rows_pulled: AtomicUsize::new(0), + lookups: Mutex::new(Vec::new()), + lookup_delay: Mutex::new(Duration::ZERO), + load_delay: Mutex::new(Duration::ZERO), + }) + } + fn add(&self, label: &str) { + self.hosts.lock().unwrap().push(host(label)); + } + fn remove(&self, label: &str) { + self.hosts.lock().unwrap().retain(|h| h != &host(label)); + } + fn lookups_for(&self, label: &str) -> usize { + let h = host(label); + self.lookups + .lock() + .unwrap() + .iter() + .filter(|l| **l == h) + .count() + } + fn total_lookups(&self) -> usize { + self.lookups.lock().unwrap().len() + } +} + +impl BindingSource for FakeStore { + fn load<'a>(&'a self, _registry: &'a [String], max_rows: usize) -> BoxFuture<'a, SnapshotRows> { + async move { + self.loads.fetch_add(1, Ordering::SeqCst); + // A consistent read: rows as of the read's start, however long + // it then takes. + let hosts = self.hosts.lock().unwrap().clone(); + let delay = *self.load_delay.lock().unwrap(); + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + if self.fail_loads.load(Ordering::SeqCst) { + return Err("load failed".into()); + } + // Pulled one row at a time and capped as the real store does. + self.rows_pulled.store(0, Ordering::SeqCst); + let mut rows = Vec::new(); + for host in hosts { + self.rows_pulled.fetch_add(1, Ordering::SeqCst); + if rows.len() == max_rows { + return Ok(Err(RowCapExceeded(max_rows))); + } + rows.push((host, ISS.to_owned())); + } + Ok(Ok(rows)) + } + .boxed() + } + + fn lookup<'a>( + &'a self, + host: &'a str, + _registry: &'a [String], + ) -> BoxFuture<'a, Result, String>> { + async move { + self.lookups.lock().unwrap().push(host.to_owned()); + let delay = *self.lookup_delay.lock().unwrap(); + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + assert!( + !self.lookup_panics.load(Ordering::SeqCst), + "lookup panicked" + ); + if self.lookup_fails.load(Ordering::SeqCst) { + return Err("pool closed".into()); + } + let found = self.hosts.lock().unwrap().iter().any(|h| h == host); + Ok(if found { + vec![ISS.to_owned()] + } else { + Vec::new() + }) + } + .boxed() + } +} + +struct Fixture { + store: Arc, + clock: Arc, + resolver: Arc, +} + +fn fixture_with(hosts: &[&str], bounds: ResolverBounds) -> Fixture { + let store = FakeStore::new(hosts); + let clock = Arc::new(ManualClock::default()); + let resolver = Arc::new(DatabaseResolver::with_clock( + Arc::clone(&store) as Arc, + policy(), + vec![ISS.to_owned()], + bounds, + Arc::clone(&clock) as Arc, + )); + Fixture { + store, + clock, + resolver, + } +} + +fn fixture(hosts: &[&str]) -> Fixture { + fixture_with(hosts, ResolverBounds::default()) +} + +impl Fixture { + async fn resolve(&self, label: &str) -> Result<(), DenialClass> { + self.resolver.resolve(&host(label)).await.map(|_| ()) + } +} + +fn secs(s: f64) -> Duration { + Duration::from_secs_f64(s) +} + +// A lookup that panics still leaves the pending map, so the next admission +// for that host runs a fresh lookup. Mutation: removing the entry only on the +// normal path leaves the host denied by the dead lookup forever. +#[tokio::test] +async fn panicked_lookup_does_not_strand_the_host() { + let f = fixture(&["a"]); + f.store.lookup_panics.store(true, Ordering::SeqCst); + assert!(f.resolve("a").await.is_err()); + f.store.lookup_panics.store(false, Ordering::SeqCst); + assert_eq!(f.resolve("a").await, Ok(())); + assert_eq!(f.store.lookups_for("a"), 2); +} + +// ── Before the first load, and once the snapshot is stale ────────────────── + +// With no snapshot loaded, admissions go to the live lookup; a failed or +// over-cap load publishes nothing. +#[tokio::test] +async fn unloaded_resolver_uses_live_lookups_and_failed_or_over_cap_load_keeps_it_unloaded() { + let f = fixture_with( + &["a", "b"], + ResolverBounds { + snapshot_max_rows: 1, + ..ResolverBounds::default() + }, + ); + assert_eq!(f.resolve("a").await, Ok(())); + assert_eq!(f.resolve("unknown").await, UNAVAILABLE); + assert_eq!(f.resolver.refresh().await, Err(LoadFailure::OverCap)); + f.store.fail_loads.store(true, Ordering::SeqCst); + assert!(matches!( + f.resolver.refresh().await, + Err(LoadFailure::Store(_)) + )); + assert!(f.resolver.published.load().is_none()); + assert_eq!(f.resolve("b").await, Ok(())); + assert_eq!(f.store.lookups_for("a"), 1); + assert_eq!(f.store.lookups_for("b"), 1); +} + +// While refreshes fail, the last snapshot answers for two minutes after its +// load started; after that every host goes to the live lookup, which sees a +// removal at once and fails closed when the database does. Mutation: serving +// a stale snapshot admits "gone"; denying once stale refuses "a". +#[tokio::test] +async fn stale_snapshot_is_skipped_for_live_lookups() { + let f = fixture(&["a", "gone"]); + f.resolver.refresh().await.unwrap(); + f.store.fail_loads.store(true, Ordering::SeqCst); + f.store.remove("gone"); + + f.clock.set(secs(60.0)); + assert!(f.resolver.refresh().await.is_err()); + f.clock.set(secs(119.0)); + assert_eq!(f.resolve("a").await, Ok(())); + assert_eq!( + f.resolve("gone").await, + Ok(()), + "fresh snapshot still lists it" + ); + assert_eq!( + f.store.total_lookups(), + 0, + "fresh snapshot hosts never look up" + ); + + f.clock.set(secs(121.0)); + assert_eq!(f.resolve("a").await, Ok(())); + assert_eq!(f.resolve("gone").await, UNAVAILABLE); + assert_eq!(f.store.lookups_for("a"), 1); + assert_eq!(f.store.lookups_for("gone"), 1); + assert_eq!(f.resolver.negative_len(), 1, "the not-found is cached"); + f.store.lookup_fails.store(true, Ordering::SeqCst); + assert_eq!(f.resolve("a").await, UNAVAILABLE, "database down"); + + f.store.fail_loads.store(false, Ordering::SeqCst); + f.resolver.refresh().await.unwrap(); + assert_eq!(f.resolve("a").await, Ok(())); + assert_eq!(f.store.lookups_for("a"), 2, "fresh snapshot again"); +} + +// A lookup answer is judged by its own read, not the snapshot's: the +// snapshot going stale while the lookup runs still admits, and the answer +// expires two minutes after its read started. Mutation: re-checking the +// snapshot's deadline after the await denies; dating the answer from the +// snapshot load expires it at once. +#[tokio::test] +async fn lookup_answer_carries_its_own_deadline_past_a_stale_snapshot() { + let f = fixture(&["a"]); + f.resolver.refresh().await.unwrap(); + f.store.add("late"); + f.clock.set(secs(119.9)); + let gate = f.resolver.hooks.arm(MISS_AFTER_READ); + let pending = tokio::spawn({ + let resolver = Arc::clone(&f.resolver); + async move { resolver.resolve(&host("late")).await } + }); + gate.reached().await; + f.clock.set(secs(120.1)); + gate.release(); + let (_, fresh) = pending.await.unwrap().expect("admitted"); + assert_eq!(fresh.check(), Ok(())); + f.clock.set(secs(239.8)); + assert_eq!(fresh.check(), Ok(())); + f.clock.set(secs(239.9)); + assert_eq!(fresh.check(), UNAVAILABLE, "read started at 119.9s"); +} + +#[tokio::test] +async fn timed_out_refresh_publishes_nothing_and_keeps_the_deadline() { + let f = fixture_with( + &["a"], + ResolverBounds { + refresh_timeout: Duration::from_millis(20), + ..ResolverBounds::default() + }, + ); + f.resolver.refresh().await.unwrap(); + f.store.remove("a"); + f.store.add("b"); + *f.store.load_delay.lock().unwrap() = Duration::from_millis(200); + f.clock.set(secs(100.0)); + assert_eq!(f.resolver.refresh().await, Err(LoadFailure::Timeout)); + // Retained snapshot unchanged: "a" still resolves without a lookup. + assert_eq!(f.resolve("a").await, Ok(())); + assert_eq!(f.store.lookups_for("a"), 0); + f.clock.set(secs(121.0)); + assert_eq!( + f.resolve("a").await, + UNAVAILABLE, + "the live lookup sees the removal" + ); +} + +// Criterion 3 (publication bound): a deadline that passes during the +// synchronous build/publish tail fails the attempt. The manual clock moves +// while tokio time does not, so only the attempt deadline can catch it. +#[tokio::test] +async fn refresh_whose_deadline_passes_before_publication_publishes_nothing() { + let f = fixture(&["a"]); + f.resolver.refresh().await.unwrap(); + f.store.remove("a"); + f.clock.set(secs(30.0)); + let gate = f.resolver.hooks.arm(REFRESH_BEFORE_PUBLISH); + let refresh = tokio::spawn({ + let resolver = Arc::clone(&f.resolver); + async move { resolver.refresh().await } + }); + gate.reached().await; + f.clock.set(secs(40.0)); + gate.release(); + assert_eq!(refresh.await.unwrap(), Err(LoadFailure::Timeout)); + assert_eq!(f.resolve("a").await, Ok(()), "old map retained"); + f.clock.set(secs(120.0)); + assert_eq!(f.resolve("a").await, UNAVAILABLE, "old deadline retained"); + assert_eq!(f.store.lookups_for("a"), 1); +} + +// The deadline is rechecked under the publication lock, immediately before +// the first write: an attempt that crosses it while waiting publishes +// nothing. Mutation: checking before taking the lock (or not at all) +// publishes the late snapshot. +#[tokio::test] +async fn refresh_crossing_its_deadline_while_preparing_publishes_nothing() { + let f = fixture(&["a"]); + f.resolver.refresh().await.unwrap(); + f.store.remove("a"); + f.clock.set(secs(30.0)); + let clock = Arc::clone(&f.clock); + f.resolver + .hooks + .on_sync(PUBLISH_BEFORE_WRITE, move || clock.set(secs(40.0))); + assert_eq!(f.resolver.refresh().await, Err(LoadFailure::Timeout)); + assert_eq!(f.resolve("a").await, Ok(()), "old map retained"); + assert_eq!(f.store.lookups_for("a"), 0); + f.clock.set(secs(120.0)); + assert_eq!(f.resolve("a").await, UNAVAILABLE, "old deadline retained"); + assert_eq!(f.store.lookups_for("a"), 1); +} + +// A map build abandoned by a timeout keeps running on its blocking thread; +// until it returns, no new load starts, so at most one cap-sized build is +// ever outstanding. Once released it stops cooperatively, publishes nothing, +// and the next tick loads normally. Mutation: releasing the slot with the +// attempt (or not holding one) starts a second build while the first lives. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn timed_out_build_blocks_new_loads_until_it_returns_and_publishes_nothing() { + let f = fixture_with( + &["a"], + ResolverBounds { + refresh_timeout: Duration::from_millis(50), + ..ResolverBounds::default() + }, + ); + f.resolver.refresh().await.unwrap(); + f.store.remove("a"); + f.store.add("b"); + + let outstanding = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let started = Arc::new(AtomicUsize::new(0)); + let release = Arc::new((Mutex::new(false), std::sync::Condvar::new())); + f.resolver.hooks.on_sync(BUILD_START, { + let (outstanding, peak, started, release) = ( + Arc::clone(&outstanding), + Arc::clone(&peak), + Arc::clone(&started), + Arc::clone(&release), + ); + move || { + started.fetch_add(1, Ordering::SeqCst); + let now = outstanding.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(now, Ordering::SeqCst); + let (open, signal) = &*release; + // Bounded, so a regression fails the test instead of hanging it. + let _ = signal + .wait_timeout_while(open.lock().unwrap(), Duration::from_secs(5), |open| !*open) + .unwrap(); + outstanding.fetch_sub(1, Ordering::SeqCst); + } + }); + + assert_eq!(f.resolver.refresh().await, Err(LoadFailure::Timeout)); + let loads = f.store.loads.load(Ordering::SeqCst); + for _ in 0..3 { + assert_eq!( + f.resolver.refresh().await, + Err(LoadFailure::BuildOutstanding) + ); + } + assert_eq!(f.store.loads.load(Ordering::SeqCst), loads, "no new load"); + assert_eq!(started.load(Ordering::SeqCst), 1); + + let (open, signal) = &*release; + *open.lock().unwrap() = true; + signal.notify_all(); + for _ in 0..200 { + if !f.resolver.building.load(Ordering::SeqCst) { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!( + !f.resolver.building.load(Ordering::SeqCst), + "build released" + ); + assert_eq!(f.resolve("a").await, Ok(()), "nothing published late"); + assert_eq!(f.store.lookups_for("a"), 0); + + f.resolver.refresh().await.unwrap(); + assert_eq!(peak.load(Ordering::SeqCst), 1, "one build at a time"); + assert_eq!(started.load(Ordering::SeqCst), 2); + assert_eq!(f.resolve("b").await, Ok(())); + assert_eq!(f.store.lookups_for("b"), 0, "new snapshot published"); +} + +#[test] +fn build_map_stops_in_either_pass_once_told_to() { + let rows = vec![(host("a"), ISS.to_owned()), (host("b"), ISS.to_owned())]; + assert!(build_map(rows.clone(), || true).is_none(), "grouping pass"); + let checks = AtomicUsize::new(0); + let stop_in_second_pass = || checks.fetch_add(1, Ordering::SeqCst) >= rows.len(); + assert!( + build_map(rows.clone(), stop_in_second_pass).is_none(), + "binding pass" + ); + assert_eq!(build_map(rows, || false).map(|m| m.len()), Some(2)); +} + +// The load stops at cap + 1 rows; a loaded snapshot survives an over-cap +// refresh unchanged. +#[tokio::test] +async fn over_cap_refresh_stops_streaming_and_keeps_the_loaded_snapshot() { + let f = fixture_with( + &["a", "b"], + ResolverBounds { + snapshot_max_rows: 2, + ..ResolverBounds::default() + }, + ); + f.resolver.refresh().await.unwrap(); + for label in ["c", "d", "e", "f"] { + f.store.add(label); + } + f.store.remove("b"); + f.clock.set(secs(60.0)); + assert_eq!(f.resolver.refresh().await, Err(LoadFailure::OverCap)); + assert_eq!(f.store.rows_pulled.load(Ordering::SeqCst), 3, "cap + 1"); + assert_eq!(f.resolve("b").await, Ok(()), "old map retained"); + assert_eq!(f.store.lookups_for("b"), 0); + f.clock.set(secs(120.0)); + assert_eq!(f.resolve("b").await, UNAVAILABLE, "old deadline retained"); +} + +#[tokio::test] +async fn failed_miss_lookup_denies_and_stores_no_negative_entry() { + let f = fixture(&["a"]); + f.resolver.refresh().await.unwrap(); + f.store.lookup_fails.store(true, Ordering::SeqCst); + assert_eq!(f.resolve("x").await, UNAVAILABLE); + assert_eq!(f.resolver.negative_len(), 0); +} + +// ── Criterion 14: refresh removes bindings; 40s removal bound ─────────────── + +#[tokio::test] +async fn refresh_removes_unbound_communities_and_held_positive_miss_is_not_stored() { + let f = fixture(&["a", "b", "control"]); + f.resolver.refresh().await.unwrap(); + f.store.remove("a"); + f.store.remove("b"); + assert_eq!(f.resolve("a").await, Ok(()), "before refresh"); + f.resolver.refresh().await.unwrap(); + assert_eq!(f.resolve("a").await, UNAVAILABLE); + assert_eq!(f.resolve("b").await, UNAVAILABLE); + assert_eq!(f.resolve("control").await, Ok(())); + + // (c) A miss reads "h" active and is held; "h" is removed and a refresh + // publishes; the held answer is returned once and never stored. + f.store.add("h"); + let gate = f.resolver.hooks.arm(MISS_AFTER_READ); + let pending = tokio::spawn({ + let resolver = Arc::clone(&f.resolver); + async move { resolver.resolve(&host("h")).await.map(|_| ()) } + }); + gate.reached().await; + f.resolver.hooks.disarm(MISS_AFTER_READ); + f.store.remove("h"); + f.resolver.refresh().await.unwrap(); + gate.release(); + assert_eq!(pending.await.unwrap(), Ok(())); + assert_eq!(f.resolve("h").await, UNAVAILABLE); +} + +#[tokio::test(start_paused = true)] +async fn unbinding_after_a_snapshot_is_enforced_within_forty_seconds() { + // The production scheduler on the tokio clock: refresh at +30s, its + // read held for just under the 10s limit, published before +40s. + let store = FakeStore::new(&["a", "gone"]); + let mut resolver = DatabaseResolver::new( + Arc::clone(&store) as Arc, + policy(), + vec![ISS.to_owned()], + ResolverBounds::default(), + ); + // No jitter: the worst case, a tick a full interval after the last. + resolver.jitter = || 0.0; + let resolver = Arc::new(resolver); + let resolve = |label: &str| { + let resolver = Arc::clone(&resolver); + let host = host(label); + async move { resolver.resolve(&host).await.map(drop) } + }; + resolver.refresh().await.unwrap(); + store.remove("gone"); + *store.load_delay.lock().unwrap() = Duration::from_millis(9_900); + let cancel = tokio_util::sync::CancellationToken::new(); + let refresh_loop = tokio::spawn(Arc::clone(&resolver).run_refresh_loop(cancel.clone())); + tokio::task::yield_now().await; // the loop arms its schedule at t=0 + + // Step the paused clock so the loop wakes on its +30s tick, and let its + // load start and sleep on the 9.9s read. + tokio::time::advance(REFRESH_INTERVAL).await; + while store.loads.load(Ordering::SeqCst) < 2 { + tokio::task::yield_now().await; + } + tokio::time::advance(Duration::from_millis(9_800)).await; + assert_eq!( + resolve("gone").await, + Ok(()), + "before the refresh publishes" + ); + // +39.9s: the held read returns. The map build runs on a real blocking + // thread the paused clock cannot see, so wait for the publication itself + // (bounded in real time); the paused clock stays at +39.9s meanwhile. + tokio::time::advance(Duration::from_millis(100)).await; + let waited = std::time::Instant::now(); + while resolver + .published + .load() + .as_ref() + .is_none_or(|published| published.loaded_at < REFRESH_INTERVAL) + { + assert!( + waited.elapsed() < Duration::from_secs(30), + "never published" + ); + tokio::task::yield_now().await; + } + assert!(resolver.clock.now() < Duration::from_secs(40)); + assert_eq!(resolve("gone").await, UNAVAILABLE, "removed by +40s"); + assert_eq!(resolve("a").await, Ok(())); + cancel.cancel(); + refresh_loop.await.unwrap(); +} + +// Mutation: retrying a failure only on the next full interval, never +// backing off, or jittering a delay up instead of down. +#[test] +fn refresh_delay_backs_off_after_failures_and_jitters_down() { + let delays: Vec = (0..7).map(|n| next_refresh_delay(n).as_secs()).collect(); + assert_eq!(delays, [30, 2, 4, 8, 16, 30, 30]); + assert_eq!(next_refresh_delay(u32::MAX), REFRESH_INTERVAL); + assert_eq!(jittered(REFRESH_INTERVAL, 0.0), REFRESH_INTERVAL); + assert_eq!(jittered(REFRESH_INTERVAL, 0.5), secs(28.5)); + assert!(jittered(REFRESH_INTERVAL, 0.999_999) > secs(27.0)); +} + +// The loop refreshes every REFRESH_INTERVAL and retries a failure after +// RETRY_FIRST. Mutation: a different interval, or no retry before the next +// interval, changes the load count at each step. +#[tokio::test(start_paused = true)] +async fn refresh_loop_runs_every_interval_and_retries_failures_sooner() { + let store = FakeStore::new(&["a"]); + let mut resolver = DatabaseResolver::new( + Arc::clone(&store) as Arc, + policy(), + vec![ISS.to_owned()], + ResolverBounds::default(), + ); + resolver.jitter = || 0.0; + let resolver = Arc::new(resolver); + let cancel = tokio_util::sync::CancellationToken::new(); + let refresh_loop = tokio::spawn(Arc::clone(&resolver).run_refresh_loop(cancel.clone())); + let loads_after = |step: Duration| { + let store = Arc::clone(&store); + async move { + tokio::time::advance(step).await; + for _ in 0..100 { + tokio::task::yield_now().await; + } + store.loads.load(Ordering::SeqCst) + } + }; + tokio::task::yield_now().await; + assert_eq!(loads_after(REFRESH_INTERVAL - secs(0.1)).await, 0); + store.fail_loads.store(true, Ordering::SeqCst); + assert_eq!( + loads_after(secs(0.1)).await, + 1, + "first tick at the interval" + ); + assert_eq!(loads_after(RETRY_FIRST).await, 2, "failure retried sooner"); + store.fail_loads.store(false, Ordering::SeqCst); + assert_eq!(loads_after(RETRY_FIRST * 2).await, 3, "backed off, then ok"); + // The successful load's map build runs on a real blocking thread the + // paused clock cannot see. Wait for its publication (bounded in real + // time) before moving the clock, or the next advance passes the + // refresh's timeout and fails it. + let succeeded_at = REFRESH_INTERVAL + RETRY_FIRST * 3; + let waited = std::time::Instant::now(); + while resolver + .published + .load() + .as_ref() + .is_none_or(|published| published.loaded_at < succeeded_at) + { + assert!( + waited.elapsed() < Duration::from_secs(30), + "never published" + ); + tokio::task::yield_now().await; + } + assert_eq!(loads_after(REFRESH_INTERVAL - secs(0.1)).await, 3); + assert_eq!(loads_after(secs(0.1)).await, 4, "back on the interval"); + cancel.cancel(); + refresh_loop.await.unwrap(); +} + +// Two slow successful refreshes in a row, the binding removed just after the +// first one's read began: the second refresh starts one interval after the +// first start, so the removal is published within the documented 40s. +// Mutation: timing the interval from when a refresh finishes starts the +// second one at +69.9s and publishes the removal at +79.8s. +#[tokio::test(start_paused = true)] +async fn removal_during_a_slow_refresh_is_published_within_forty_seconds() { + let store = FakeStore::new(&["a", "gone"]); + *store.load_delay.lock().unwrap() = Duration::from_millis(9_900); + let mut resolver = DatabaseResolver::new( + Arc::clone(&store) as Arc, + policy(), + vec![ISS.to_owned()], + ResolverBounds::default(), + ); + resolver.jitter = || 0.0; + let resolver = Arc::new(resolver); + let cancel = tokio_util::sync::CancellationToken::new(); + let refresh_loop = tokio::spawn(Arc::clone(&resolver).run_refresh_loop(cancel.clone())); + tokio::task::yield_now().await; // the loop arms its schedule at t=0 + let started = tokio::time::Instant::now(); + let published_after = |at: Duration| { + let resolver = Arc::clone(&resolver); + async move { + // The map build runs on a blocking thread the paused clock + // cannot see; wait for it in bounded real time. + let waited = std::time::Instant::now(); + while resolver + .published + .load() + .as_ref() + .is_none_or(|published| published.loaded_at < at) + { + assert!( + waited.elapsed() < Duration::from_secs(30), + "never published" + ); + tokio::task::yield_now().await; + } + } + }; + let resolve = |label: &str| { + let resolver = Arc::clone(&resolver); + let host = host(label); + async move { resolver.resolve(&host).await.map(drop) } + }; + + // +30s: the first refresh reads, still seeing "gone"; it is removed now. + tokio::time::advance(REFRESH_INTERVAL).await; + while store.loads.load(Ordering::SeqCst) < 1 { + tokio::task::yield_now().await; + } + store.remove("gone"); + let removed = started.elapsed(); + tokio::time::advance(Duration::from_millis(9_900)).await; + published_after(REFRESH_INTERVAL).await; + assert_eq!( + resolve("gone").await, + Ok(()), + "first snapshot predates removal" + ); + + // The second refresh starts at +60s and publishes at +69.9s. + tokio::time::sleep_until(started + REFRESH_INTERVAL * 2 + Duration::from_millis(9_900)).await; + published_after(REFRESH_INTERVAL * 2).await; + assert!(resolver.clock.now() - removed < Duration::from_secs(40)); + assert_eq!(resolve("gone").await, UNAVAILABLE, "removed within 40s"); + assert_eq!(resolve("a").await, Ok(())); + cancel.cancel(); + refresh_loop.await.unwrap(); +} + +// The freshness timestamp is written on every attempt and keeps the last +// good load through failures. Mutation: setting it only on success leaves +// the series absent after a failed first attempt. +#[tokio::test] +async fn freshness_gauge_is_written_on_every_attempt() { + const GAUGE: &str = "buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds"; + let gauge = |recorder: &metrics_util::debugging::DebuggingRecorder| { + recorder + .snapshotter() + .snapshot() + .into_vec() + .into_iter() + .find(|(key, ..)| key.key().name() == GAUGE) + .map(|(.., value)| match value { + metrics_util::debugging::DebugValue::Gauge(v) => v.into_inner(), + _ => unreachable!(), + }) + }; + let f = fixture(&["a"]); + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let _guard = metrics::set_default_local_recorder(&recorder); + f.store.fail_loads.store(true, Ordering::SeqCst); + assert!(f.resolver.refresh().await.is_err()); + assert_eq!( + gauge(&recorder), + Some(0.0), + "written by a failed first attempt" + ); + f.store.fail_loads.store(false, Ordering::SeqCst); + f.resolver.refresh().await.unwrap(); + let loaded = gauge(&recorder).unwrap(); + assert!(loaded > 0.0); + f.store.fail_loads.store(true, Ordering::SeqCst); + assert!(f.resolver.refresh().await.is_err()); + assert_eq!(gauge(&recorder), Some(loaded), "kept through a failure"); +} + +// ── Creation and the negative cache ──────────────────────────────────────── + +// A creation on this pod drops the host's cached "not found" (whatever case +// the host arrives in), so the creator's first login looks it up. Mutation: +// not popping the entry denies until the 5s TTL lapses. +#[tokio::test] +async fn record_creation_clears_the_hosts_cached_not_found() { + let f = fixture(&["a"]); + f.resolver.refresh().await.unwrap(); + assert_eq!(f.resolve("h").await, UNAVAILABLE); + f.store.add("h"); + assert_eq!(f.resolve("h").await, UNAVAILABLE, "cached not-found"); + assert_eq!(f.store.lookups_for("h"), 1); + f.resolver.record_creation(&host("H")); + assert_eq!(f.resolver.negative_len(), 0); + assert_eq!(f.resolve("h").await, Ok(())); + assert_eq!(f.store.lookups_for("h"), 2); +} + +// Only a creation or a publication moves the sequence: two unknown hosts +// looked up at once are both cached. Mutation: bumping on a negative store +// leaves the second one uncached. +#[tokio::test] +async fn concurrent_not_found_lookups_are_all_cached() { + let f = fixture(&["a"]); + f.resolver.refresh().await.unwrap(); + let gate = f.resolver.hooks.arm(MISS_AFTER_READ); + let pending: Vec<_> = ["x", "y"] + .into_iter() + .map(|label| { + let resolver = Arc::clone(&f.resolver); + tokio::spawn(async move { resolver.resolve(&host(label)).await.map(drop) }) + }) + .collect(); + gate.reached().await; + gate.reached().await; + gate.release(); + gate.release(); + for task in pending { + assert_eq!(task.await.unwrap(), UNAVAILABLE); + } + assert_eq!(f.resolver.negative_len(), 2); +} + +#[tokio::test] +async fn negative_lookup_racing_a_creation_or_swap_stores_nothing() { + // (d), and criterion 7(f). + for swap in [false, true] { + let f = fixture(&["a"]); + f.resolver.refresh().await.unwrap(); + let gate = f.resolver.hooks.arm(MISS_AFTER_READ); + let pending = tokio::spawn({ + let resolver = Arc::clone(&f.resolver); + async move { resolver.resolve(&host("h")).await.map(|_| ()) } + }); + gate.reached().await; + f.resolver.hooks.disarm(MISS_AFTER_READ); + f.store.add("h"); + if swap { + f.resolver.refresh().await.unwrap(); + } else { + f.resolver.record_creation(&host("h")); + } + gate.release(); + assert_eq!(pending.await.unwrap(), UNAVAILABLE, "answered as read"); + assert_eq!(f.resolver.negative_len(), 0, "swap={swap}"); + assert_eq!(f.resolve("h").await, Ok(()), "swap={swap}"); + } +} + +// The provisioning handler drops the cached "not found" only for a creation +// that bound issuers. Mutation: skipping the resolver call in the handler, or +// calling it for an unbound creation, fails one of the two halves. +#[tokio::test] +async fn provisioning_a_bound_community_clears_its_cached_not_found() { + use crate::handlers::community_provisioning::record_binding_creation; + let f = fixture(&["a"]); + f.resolver.refresh().await.unwrap(); + assert_eq!(f.resolve("h").await, UNAVAILABLE); + f.store.add("h"); + record_binding_creation(Some(&f.resolver), &host("h"), &[]); + assert_eq!( + f.resolve("h").await, + UNAVAILABLE, + "unbound creation keeps it" + ); + record_binding_creation(Some(&f.resolver), &host("h"), &[ISS.to_owned()]); + assert_eq!(f.resolve("h").await, Ok(())); + assert_eq!(f.store.lookups_for("h"), 2); +} + +// One lookup admits at most `max_waiters` admissions; the next is rejected +// rather than queued. Mutation: ignoring the cap lets the third wait. +#[tokio::test(start_paused = true)] +async fn waiters_beyond_the_cap_are_rejected() { + let f = fixture_with( + &["a"], + ResolverBounds { + max_waiters: 2, + ..ResolverBounds::default() + }, + ); + *f.store.lookup_delay.lock().unwrap() = secs(0.5); + let waiters: Vec<_> = (0..2) + .map(|_| { + let resolver = Arc::clone(&f.resolver); + tokio::spawn(async move { resolver.resolve(&host("a")).await.map(drop) }) + }) + .collect(); + tokio::time::sleep(secs(0.1)).await; + assert_eq!(f.resolver.waiters_now(), 2); + assert_eq!(f.resolve("a").await, UNAVAILABLE); + for w in waiters { + assert_eq!(w.await.unwrap(), Ok(())); + } + assert_eq!(f.store.lookups_for("a"), 1); +} + +// ── Criterion 9: miss-path bounds under a flood ───────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn flood_of_unknown_hosts_respects_every_cap_and_spares_snapshot_hosts() { + let bounds = ResolverBounds { + max_inflight: 4, + max_pending_hosts: 8, + max_waiters: 16, + negative_cache_max: 32, + permit_wait: Duration::from_millis(5), + lookup_timeout: Duration::from_millis(500), + ..ResolverBounds::default() + }; + let f = fixture_with(&["a", "b"], bounds.clone()); + f.resolver.refresh().await.unwrap(); + *f.store.lookup_delay.lock().unwrap() = Duration::from_millis(20); + + let peak = Arc::new(Mutex::new((0usize, 0usize, 0usize, 0usize))); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let monitor = tokio::spawn({ + let (resolver, peak, stop) = ( + Arc::clone(&f.resolver), + Arc::clone(&peak), + Arc::clone(&stop), + ); + async move { + while !stop.load(Ordering::SeqCst) { + { + let mut p = peak.lock().unwrap(); + p.0 = p.0.max(resolver.inflight_now()); + p.1 = p.1.max(resolver.pending_len()); + p.2 = p.2.max(resolver.waiters_now()); + p.3 = p.3.max(resolver.negative_len()); + } + tokio::task::yield_now().await; + } + } + }); + let flood: Vec<_> = (0..400) + .map(|i| { + let resolver = Arc::clone(&f.resolver); + tokio::spawn(async move { resolver.resolve(&host(&format!("rand{i}"))).await.is_err() }) + }) + .collect(); + for _ in 0..50 { + assert_eq!(f.resolve("a").await, Ok(())); + assert_eq!(f.resolve("b").await, Ok(())); + } + for task in flood { + assert!(task.await.unwrap(), "every made-up host is denied"); + } + stop.store(true, Ordering::SeqCst); + monitor.await.unwrap(); + let p = *peak.lock().unwrap(); + assert!(p.0 <= bounds.max_inflight, "inflight {}", p.0); + assert!(p.1 <= bounds.max_pending_hosts, "pending {}", p.1); + assert!(p.2 <= bounds.max_waiters, "waiters {}", p.2); + assert!(p.3 <= bounds.negative_cache_max, "negative {}", p.3); + assert_eq!(f.store.lookups_for("a") + f.store.lookups_for("b"), 0); +} + +// ── Criterion 17: map lifetime ────────────────────────────────────────────── + +#[tokio::test] +async fn old_snapshot_maps_are_dropped_while_admissions_keep_their_bindings() { + let f = fixture(&["a"]); + f.resolver.refresh().await.unwrap(); + let mut held = Vec::new(); + let mut weak_maps = Vec::new(); + for _ in 0..10 { + held.push(f.resolver.resolve(&host("a")).await.unwrap()); + weak_maps.push(Arc::downgrade( + f.resolver.published.load_full().as_ref().unwrap(), + )); + f.resolver.refresh().await.unwrap(); + } + // Replaced maps are freed on a blocking thread; wait for it (bounded). + let waited = std::time::Instant::now(); + while weak_maps.iter().any(|w| w.strong_count() > 0) { + assert!( + waited.elapsed() < Duration::from_secs(30), + "only the published map is alive" + ); + tokio::task::yield_now().await; + } + assert!(held + .iter() + .all(|b| b.0.expected_aud() == "https://a.communities.buzz.xyz")); +} + +// ── Pre-filter ────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn non_suffix_reserved_and_multi_label_hosts_never_reach_the_store() { + let f = fixture(&["a"]); + f.resolver.refresh().await.unwrap(); + for raw in [ + "communities.buzz.xyz", + "a.b.communities.buzz.xyz", + "evil.example", + "a.communities.buzz.xyz:8443", + ] { + assert!(f.resolver.resolve(raw).await.is_err(), "{raw}"); + } + assert_eq!(f.store.total_lookups(), 0); +} + +/// Unset or blank variables take the defaults, a set value overrides only +/// its own bound, out-of-range or non-numeric values fail startup, and +/// `max_inflight` stays below the writer pool. Mutation: dropping the clamp +/// lets pool 4 run 8 lookups at once. +#[test] +fn resolver_bounds_from_env_defaults_overrides_clamps_and_rejects() { + const VARS: [&str; 4] = [ + "BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX", + "BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT", + "BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS", + "BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS", + ]; + struct Clear; + impl Drop for Clear { + fn drop(&mut self) { + for v in VARS { + std::env::remove_var(v); + } + } + } + let _lock = crate::nip_fi_config::NIP_FI_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _clear = Clear; + for v in VARS { + std::env::remove_var(v); + } + + assert_eq!( + ResolverBounds::from_env(50).unwrap(), + ResolverBounds::default() + ); + assert_eq!(ResolverBounds::from_env(4).unwrap().max_inflight, 3); + assert_eq!(ResolverBounds::from_env(1).unwrap().max_inflight, 1); + + std::env::set_var("BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS", "250"); + std::env::set_var("BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS", "29999"); + std::env::set_var("BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT", " "); + let bounds = ResolverBounds::from_env(50).unwrap(); + assert_eq!(bounds.snapshot_max_rows, 250); + assert_eq!(bounds.refresh_timeout, Duration::from_millis(29_999)); + assert_eq!(bounds.max_inflight, ResolverBounds::default().max_inflight); + std::env::set_var("BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT", "40"); + assert_eq!(ResolverBounds::from_env(50).unwrap().max_inflight, 40); + assert_eq!(ResolverBounds::from_env(10).unwrap().max_inflight, 9); + + for (var, bad) in [ + ("BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS", "30000"), + ("BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT", "0"), + ("BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS", "lots"), + ("BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS", "10000001"), + ("BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX", "1000001"), + ] { + let prior = std::env::var(var).ok(); + std::env::set_var(var, bad); + assert!( + ResolverBounds::from_env(50).is_err(), + "{var}={bad} must fail startup" + ); + match prior { + Some(p) => std::env::set_var(var, p), + None => std::env::remove_var(var), + } + } +} + +mod router_tests; + +// A database-source community is labelled `bound` in shadow, never by its +// host, whose set is unbounded. Mutation: labelling every resolved community +// by its URI, as the `env` source is, names the host. +#[tokio::test] +async fn database_source_shadow_label_never_names_the_host() { + let f = fixture(&["a"]); + let mut headers = axum::http::HeaderMap::new(); + headers.insert(axum::http::header::HOST, host("a").parse().unwrap()); + let community = crate::nip_fi_core::Communities::Database(&f.resolver) + .resolve(&headers) + .await + .expect("bound"); + assert_eq!( + community.shadow_label(), + metrics::SharedString::from("bound") + ); +} + +// Every step of one request reuses the router guard's resolution, a failed +// one included, so shadow resolves the Host once. Mutation: reusing only a +// successful resolution sends the denied host back to the live lookup. +#[tokio::test] +async fn admission_reuses_the_guards_resolution_without_a_lookup() { + let f = fixture(&["a"]); + let mut headers = axum::http::HeaderMap::new(); + headers.insert(axum::http::header::HOST, host("a").parse().unwrap()); + let communities = crate::nip_fi_core::Communities::Database(&f.resolver); + let denied = Err(DenialClass::AuthorizationUnavailable); + let reused = crate::nip_fi_http::with_resolved_community(denied, async { + crate::nip_fi_http::resolve_for_admission(communities, &headers).await + }) + .await; + assert_eq!(reused.map(|_| ()), UNAVAILABLE); + assert_eq!(f.store.lookups_for("a"), 0); +} diff --git a/crates/buzz-relay/src/nip_fi_resolver/tests/router_tests.rs b/crates/buzz-relay/src/nip_fi_resolver/tests/router_tests.rs new file mode 100644 index 0000000000..ab00467cf4 --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_resolver/tests/router_tests.rs @@ -0,0 +1,817 @@ +//! Database-resolver admission through the real router (plan criteria 1, 2, +//! 3, 4, 5, 6f, 7e, 8). Root and audio upgrades and the protected-HTTP guard all +//! run against a resolver over an in-memory store that counts lookups; the +//! assertion verifier is the production one with a seeded key snapshot. +//! The relay database is unreachable, so a request that clears NIP-FI ends +//! at the next step: the upgrades' tenant lookup or the route's extractor. + +use std::collections::HashMap; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use buzz_auth::{ + FederatedAssertionVerifier, FreshnessClass, HttpJwksFetcher, IssuerJwksConfig, IssuerPolicy, + IssuerRegistry, JwksSourceContract, NipFiMode, ProductionJwksSource, TokenClass, +}; +use tower::ServiceExt; + +use super::*; +use crate::state::AppState; + +/// A second registered issuer. Never seeded with keys, so any verification +/// that reaches its key source is `authorization unavailable`. +const ISS_B: &str = "https://issuer-b.example"; +const KID: &str = "resolver-router-test-key"; +// Same public ES256 fixture as the router and command tests. +const PRIVATE_KEY_PEM: &str = + "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; // sadscan:disable np.pem.1 kingfisher.privkey.2 -- public ES256 test fixture + +const UNAVAILABLE_BODY: &[u8] = b"authorization unavailable\n"; +const REJECTED_BODY: &[u8] = b"evidence rejected\n"; + +/// Host → bound issuers, counting every per-host lookup. +#[derive(Default)] +struct Bindings { + rows: Mutex>>, + lookups: Mutex>, + lookup_fails: std::sync::atomic::AtomicBool, +} + +impl Bindings { + fn bind(&self, label: &str, issuers: &[&str]) { + let issuers = issuers.iter().map(|i| (*i).to_owned()).collect(); + self.rows.lock().unwrap().insert(host(label), issuers); + } + fn unbind(&self, label: &str) { + self.rows.lock().unwrap().remove(&host(label)); + } + fn lookups_for(&self, raw_host: &str) -> usize { + let lookups = self.lookups.lock().unwrap(); + lookups.iter().filter(|h| *h == raw_host).count() + } +} + +impl BindingSource for Bindings { + fn load<'a>( + &'a self, + _registry: &'a [String], + _max_rows: usize, + ) -> BoxFuture<'a, Result, RowCapExceeded>, String>> { + async move { + let rows = + self.rows + .lock() + .unwrap() + .iter() + .fold(Vec::new(), |mut acc, (h, issuers)| { + acc.extend(issuers.iter().map(|i| (h.clone(), i.clone()))); + acc + }); + Ok(Ok(rows)) + } + .boxed() + } + + fn lookup<'a>( + &'a self, + host: &'a str, + _registry: &'a [String], + ) -> BoxFuture<'a, Result, String>> { + async move { + self.lookups.lock().unwrap().push(host.to_owned()); + if self.lookup_fails.load(Ordering::SeqCst) { + return Err("pool closed".into()); + } + Ok(self + .rows + .lock() + .unwrap() + .get(host) + .cloned() + .unwrap_or_default()) + } + .boxed() + } +} + +fn jwks_contract(issuer: &str) -> JwksSourceContract { + JwksSourceContract::new(format!("{issuer}/.well-known/jwks.json"), 300, 86_400) + .expect("valid JWKS contract") +} + +fn issuer_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec!["https://communities.buzz.xyz".to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![jsonwebtoken::Algorithm::ES256], + 30, + 3600, + None, + jwks_contract(issuer), + ) + .expect("valid issuer policy") +} + +/// A valid assertion from `iss` for `https://`, signed by the +/// fixture key. +fn mint(iss: &str, raw_host: &str) -> String { + mint_for(iss, raw_host, &nostr::Keys::generate().public_key()) +} + +/// [`mint`] asserting `key`. +fn mint_for(iss: &str, raw_host: &str, key: &nostr::PublicKey) -> String { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": iss, + "aud": format!("https://{raw_host}"), + "sub": "resolver-router-test", + "iat": now, + "exp": now + 600, + "nostr_pubkey": key.to_hex(), + }); + let mut header = Header::new(Algorithm::ES256); + header.typ = Some("nip-fi+jwt".to_owned()); + header.kid = Some(KID.to_owned()); + let key = EncodingKey::from_ec_pem(PRIVATE_KEY_PEM.as_bytes()).expect("test EC key"); + encode(&header, &claims, &key).expect("sign assertion") +} + +/// One relay pod: router state with the database resolver installed. +struct Pod { + resolver: Arc, + state: Arc, +} + +async fn pod(store: &Arc, clock: &Arc, mode: NipFiMode) -> Pod { + let base = + crate::state::tests::test_state_with_database_url("postgres://127.0.0.1:1/none").await; + pod_on(base, store, clock, mode).await +} + +/// [`pod`] over a caller-built base state (its tenant store). +async fn pod_on( + base: Arc, + store: &Arc, + clock: &Arc, + mode: NipFiMode, +) -> Pod { + let resolver = Arc::new(DatabaseResolver::with_clock( + Arc::clone(store) as Arc, + policy(), + vec![ISS.to_owned(), ISS_B.to_owned()], + ResolverBounds::default(), + Arc::clone(clock) as Arc, + )); + resolver.refresh().await.expect("startup load"); + + let mut state = (*base).clone(); + let config = Arc::make_mut(&mut state.config); + config.nip_fi.mode = mode; + config.nip_fi.source = crate::nip_fi_config::CommunitySource::Database; + config.nip_fi.registry = { + let mut registry = IssuerRegistry::new(); + registry.insert(issuer_policy(ISS)); + registry.insert(issuer_policy(ISS_B)); + registry + }; + let key_source = Arc::new( + ProductionJwksSource::new( + [ISS, ISS_B] + .map(|issuer| IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: jwks_contract(issuer), + }) + .into(), + HttpJwksFetcher::new(), + ) + .expect("key source"), + ); + let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(serde_json::json!({ + "kty": "EC", "crv": "P-256", "alg": "ES256", "use": "sig", "kid": KID, + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA", + })) + .expect("test JWK"); + key_source + .seed_snapshot_for_test(ISS, jsonwebtoken::jwk::JwkSet { keys: vec![jwk] }) + .await; + state.nip_fi_verifier = Some(Arc::new(FederatedAssertionVerifier::new( + state.config.nip_fi.registry.clone(), + Arc::clone(&key_source), + ))); + state.nip_fi_jwks_source = Some(key_source); + state.nip_fi_resolver = Some(Arc::clone(&resolver)); + Pod { + resolver, + state: Arc::new(state), + } +} + +/// The three NIP-FI admission sites: root upgrade, audio upgrade, protected +/// HTTP (the router guard). +fn sites() -> [String; 3] { + [ + "/".to_owned(), + format!("/huddle/{}/audio", uuid::Uuid::new_v4()), + "/workflows/wf/runs".to_owned(), + ] +} + +impl Pod { + async fn send(&self, path: &str, raw_host: &str, token: Option<&str>) -> (StatusCode, Vec) { + self.send_signed(path, raw_host, token, None).await + } + + /// [`Pod::send`] with an optional `Authorization` (NIP-98) header. + async fn send_signed( + &self, + path: &str, + raw_host: &str, + token: Option<&str>, + authorization: Option, + ) -> (StatusCode, Vec) { + let mut builder = Request::get(path) + .header(axum::http::header::HOST, raw_host) + .header("Upgrade", "websocket") + .header("Connection", "Upgrade") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="); + if let Some(token) = token { + builder = builder.header("Nostr-Federated-Identity", format!("Bearer {token}")); + } + if let Some(authorization) = authorization { + builder = builder.header(axum::http::header::AUTHORIZATION, authorization); + } + let response = crate::router::build_router(Arc::clone(&self.state)) + .oneshot(builder.body(Body::empty()).expect("request")) + .await + .expect("router response"); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("body"); + (status, body.to_vec()) + } + + /// Every site's outcome for a token from `iss` presented at `label`. + async fn every_site(&self, iss: &str, label: &str) -> Vec<(StatusCode, Vec)> { + let token = mint(iss, &host(label)); + let mut outcomes = Vec::new(); + for path in sites() { + outcomes.push(self.send(&path, &host(label), Some(&token)).await); + } + outcomes + } +} + +fn unavailable() -> (StatusCode, Vec) { + (StatusCode::SERVICE_UNAVAILABLE, UNAVAILABLE_BODY.to_vec()) +} + +fn rejected() -> (StatusCode, Vec) { + (StatusCode::FORBIDDEN, REJECTED_BODY.to_vec()) +} + +/// The request cleared NIP-FI admission and was answered by the next step: +/// the upgrades' tenant lookup (no reachable database) or the protected +/// route's path extractor (`wf` is not a UUID). +fn cleared_nip_fi(outcomes: &[(StatusCode, Vec)]) -> bool { + outcomes.iter().all(|(status, body)| match *status { + StatusCode::NOT_FOUND => body == b"relay: no community is configured for this host", + StatusCode::BAD_REQUEST => body.starts_with(b"Invalid URL: Cannot parse `workflow_id`"), + _ => false, + }) +} + +fn setup() -> (Arc, Arc) { + ( + Arc::new(Bindings::default()), + Arc::new(ManualClock::default()), + ) +} + +// ── Criterion 1: one resolution per admission, no suffix fallback ─────────── + +#[tokio::test] +async fn each_admission_resolves_its_host_exactly_once() { + let (store, clock) = setup(); + let p = pod(&store, &clock, NipFiMode::Enforce).await; + // Bound after the startup load: every admission takes the miss lookup, + // and a found binding is never cached, so lookups count resolutions. + store.bind("fresh", &[ISS]); + let token = mint(ISS, &host("fresh")); + for (n, path) in sites().iter().enumerate() { + let outcome = p.send(path, &host("fresh"), Some(&token)).await; + assert!( + cleared_nip_fi(std::slice::from_ref(&outcome)), + "{path}: {outcome:?}" + ); + assert_eq!(store.lookups_for(&host("fresh")), n + 1, "{path}"); + } +} + +#[tokio::test] +async fn one_label_suffix_host_with_no_rows_is_unavailable_at_every_site() { + let (store, clock) = setup(); + let p = pod(&store, &clock, NipFiMode::Enforce).await; + assert_eq!(p.every_site(ISS, "nobody").await, vec![unavailable(); 3]); +} + +// ── Criterion 2: an issuer the community does not list never reaches keys ─── + +#[tokio::test] +async fn unlisted_issuer_is_rejected_before_its_key_source() { + let (store, clock) = setup(); + store.bind("only-a", &[ISS]); + store.bind("lists-b", &[ISS_B]); + let p = pod(&store, &clock, NipFiMode::Enforce).await; + // Issuer B has no key snapshot. Where B is listed, verification reaches + // that key source and is unavailable; where it is not, the allowlist + // rejects the same kind of token first, so the missing keys never show. + assert_eq!(p.every_site(ISS_B, "lists-b").await, vec![unavailable(); 3]); + assert_eq!(p.every_site(ISS_B, "only-a").await, vec![rejected(); 3]); + assert!(cleared_nip_fi(&p.every_site(ISS, "only-a").await)); +} + +// ── Criterion 3: removing the community's issuer revokes its tokens ───────── + +#[tokio::test] +async fn removing_the_issuer_denies_a_previously_valid_token() { + let (store, clock) = setup(); + store.bind("shared", &[ISS, ISS_B]); + let p = pod(&store, &clock, NipFiMode::Enforce).await; + assert!(cleared_nip_fi(&p.every_site(ISS, "shared").await)); + + store.bind("shared", &[ISS_B]); + p.resolver.refresh().await.expect("refresh"); + assert_eq!(p.every_site(ISS, "shared").await, vec![rejected(); 3]); +} + +// ── Criterion 4: full admission per request; failed miss stores nothing ───── + +#[tokio::test] +async fn second_request_after_unbind_and_refresh_is_denied() { + let (store, clock) = setup(); + store.bind("gone", &[ISS]); + let p = pod(&store, &clock, NipFiMode::Enforce).await; + let token = mint(ISS, &host("gone")); + let path = "/workflows/wf/runs"; + assert!(cleared_nip_fi(&[p + .send(path, &host("gone"), Some(&token)) + .await])); + + store.unbind("gone"); + p.resolver.refresh().await.expect("refresh"); + assert_eq!( + p.send(path, &host("gone"), Some(&token)).await, + unavailable() + ); +} + +#[tokio::test] +async fn closed_pool_miss_is_unavailable_everywhere_and_caches_nothing() { + let (store, clock) = setup(); + let p = pod(&store, &clock, NipFiMode::Enforce).await; + store.bind("late", &[ISS]); + store.lookup_fails.store(true, Ordering::SeqCst); + assert_eq!(p.every_site(ISS, "late").await, vec![unavailable(); 3]); + assert_eq!(store.lookups_for(&host("late")), 3, "no negative entry"); + assert!(!p.resolver.negatively_cached(&host("late"))); + + store.lookup_fails.store(false, Ordering::SeqCst); + assert!(cleared_nip_fi(&p.every_site(ISS, "late").await)); +} + +// ── Criterion 6(f): the reserved command-audience host never admits ───────── + +#[tokio::test] +async fn reserved_apex_is_unavailable_without_a_lookup() { + let (store, clock) = setup(); + store + .rows + .lock() + .unwrap() + .insert("communities.buzz.xyz".to_owned(), vec![ISS.to_owned()]); + let p = pod(&store, &clock, NipFiMode::Enforce).await; + let token = mint(ISS, "communities.buzz.xyz"); + for path in sites() { + let outcome = p.send(&path, "communities.buzz.xyz", Some(&token)).await; + assert_eq!(outcome, unavailable(), "{path}"); + } + assert_eq!(store.lookups_for("communities.buzz.xyz"), 0); +} + +// ── Criterion 7(e): negative cache per pod; creating pod admits at once ───── + +#[tokio::test] +async fn creation_admits_on_its_pod_now_and_elsewhere_after_negative_ttl() { + let (store, clock) = setup(); + let p1 = pod(&store, &clock, NipFiMode::Enforce).await; + let p2 = pod(&store, &clock, NipFiMode::Enforce).await; + let path = "/workflows/wf/runs"; + let token = mint(ISS, &host("newco")); + let h = host("newco"); + for p in [&p1, &p2] { + assert_eq!(p.send(path, &h, Some(&token)).await, unavailable()); + } + assert_eq!(store.lookups_for(&h), 2); + + // Created through P1, after both published their snapshots. + store.bind("newco", &[ISS]); + p1.resolver.record_creation(&h); + assert!(cleared_nip_fi(&[p1.send(path, &h, Some(&token)).await])); + assert_eq!(p2.send(path, &h, Some(&token)).await, unavailable()); + assert_eq!(store.lookups_for(&h), 3, "P1 looks up, P2 from cache"); + + clock.set(secs(5.1)); + assert!(cleared_nip_fi(&[p2.send(path, &h, Some(&token)).await])); + assert_eq!(store.lookups_for(&h), 4); +} + +// ── Criterion 8: mode off never resolves or gates ─────────────────────────── + +#[tokio::test] +async fn off_mode_with_database_source_neither_gates_nor_resolves() { + let (store, clock) = setup(); + store.bind("quiet", &[ISS]); + let p = pod(&store, &clock, NipFiMode::Off).await; + for path in sites() { + let outcome = p.send(&path, &host("quiet"), None).await; + assert!( + cleared_nip_fi(std::slice::from_ref(&outcome)), + "{path}: {outcome:?}" + ); + } + assert!(store.lookups.lock().unwrap().is_empty()); +} + +// Shadow resolves as enforce does: a community bound after the startup load +// is found by the live lookup, so shadow records no `community` would-deny. +// Mutation: resolving shadow from memory only takes no lookup and records a +// `community`-stage denial at every site. +#[tokio::test] +async fn shadow_resolves_through_the_database_lookup_like_enforce() { + let (store, clock) = setup(); + let p = pod(&store, &clock, NipFiMode::Shadow).await; + store.bind("late", &[ISS]); + let token = mint(ISS, &host("late")); + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + { + let _guard = metrics::set_default_local_recorder(&recorder); + for path in sites() { + let outcome = p.send(&path, &host("late"), Some(&token)).await; + assert!( + cleared_nip_fi(std::slice::from_ref(&outcome)), + "{path}: {outcome:?}" + ); + } + } + assert_eq!(store.lookups_for(&host("late")), sites().len()); + let community_denials = recorder + .snapshotter() + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| { + let key = key.key(); + key.name() == "buzz_nip_fi_shadow_total" + && key + .labels() + .any(|l| l.key() == "stage" && l.value() == "community") + }) + .count(); + assert_eq!(community_denials, 0); +} + +// ── Criterion 5: a disconnect covers communities created after it ────────── + +#[tokio::test] +#[allow(clippy::result_large_err)] // the NIP-98 closure returns the production `Response` error type +async fn key_disconnected_before_its_community_exists_is_denied_there() { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _metrics = metrics::set_default_local_recorder(&recorder); + + let (store, clock) = setup(); + let p = pod(&store, &clock, NipFiMode::Enforce).await; + let mut state = (*p.state).clone(); + let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( + 16, + vec![buzz_auth::IssuerCapacity { + issuer: ISS.to_owned(), + capacity: 16, + }], + )); + state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); + let p = Pod { + resolver: p.resolver, + state: Arc::new(state), + }; + + // Disconnect K while H does not exist anywhere. + let (k_keys, control_keys) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (k, control) = (k_keys.public_key(), control_keys.public_key()); + let now = chrono::Utc::now(); + let merged = deny_map.merge_cross_pod_deny(ISS, &k, now + chrono::Duration::hours(1), now); + assert!(matches!(merged, buzz_auth::CrossPodMergeResult::Merged)); + let h = host("after"); + assert_eq!( + p.send("/", &h, Some(&mint_for(ISS, &h, &k))).await, + unavailable() + ); + + // Provision H through this pod. + store.bind("after", &[ISS]); + p.resolver.record_creation(&h); + + let denied = (StatusCode::FORBIDDEN, b"authorization denied\n".to_vec()); + assert_eq!(p.send("/", &h, Some(&mint_for(ISS, &h, &k))).await, denied); + assert!(cleared_nip_fi(&[p + .send("/", &h, Some(&mint_for(ISS, &h, &control))) + .await])); + + // Audio resolves H at upgrade and denies after NIP-42, at its deny-set + // check, on an assertion verified against H's resolved binding. + let audio = format!("/huddle/{}/audio", uuid::Uuid::new_v4()); + let (binding, _) = p.resolver.resolve(&h).await.expect("H resolves"); + let verifier = p.state.nip_fi_verifier.as_deref().expect("verifier"); + for (keys, refused) in [(&k_keys, true), (&control_keys, false)] { + let token = mint_for(ISS, &h, &keys.public_key()); + assert!(cleared_nip_fi(&[p.send(&audio, &h, Some(&token)).await])); + let assertion = verifier.verify_assertion(&token, &binding).expect("valid"); + let outcome = crate::audio::handler::test_support::deny_set_refuses( + Arc::clone(&p.state), + assertion, + keys, + ) + .await; + assert_eq!(outcome, refused, "audio deny-set check"); + } + + let http_admit = |key: nostr::PublicKey| { + let state = Arc::clone(&p.state); + let h = h.clone(); + async move { + let mut headers = axum::http::HeaderMap::new(); + headers.insert(axum::http::header::HOST, h.parse().unwrap()); + headers.insert( + "Nostr-Federated-Identity", + format!("Bearer {}", mint_for(ISS, &h, &key)) + .parse() + .unwrap(), + ); + crate::nip_fi_http::admit_nip_fi_http_on_state(&state, &headers, || { + Ok(crate::nip_fi_http::Nip98Proof::new(key, ())) + }) + .await + .map(drop) + } + }; + let resp = http_admit(k) + .await + .expect_err("K is denied at HTTP admission"); + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); + assert_eq!((status, body.to_vec()), denied); + assert!(http_admit(control).await.is_ok(), "control key is admitted"); + + let http_denials: u64 = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| { + key.key().name() == "buzz_auth_failures_total" + && key + .key() + .labels() + .any(|l| l.value() == "nip_fi_http_denied_pubkey") + }) + .map(|(.., value)| match value { + metrics_util::debugging::DebugValue::Counter(n) => n, + _ => 0, + }) + .sum(); + assert_eq!(http_denials, 1); +} + +// ── Reachable handlers over a real tenant store ───────────────────────────── + +/// Requests reach their handlers: the tenant store is real PostgreSQL with a +/// row for each test host, and protected HTTP uses a valid workflow path with +/// a NIP-98 proof, so the handler itself calls `admit_nip_fi_http_on_state`. +/// The first tenant-store connection a request takes can move the resolver +/// clock, which places a deadline crossing inside the tenant-lookup await. +mod postgres_tests { + use super::*; + + /// Pod over a one-connection pool whose next acquire, once armed, moves + /// the clock to the armed time. + struct TenantPod { + pod: Pod, + clock: Arc, + advance_on_acquire: Arc>>, + label: String, + } + + async fn tenant_pod( + store: &Arc, + clock: &Arc, + mode: NipFiMode, + ) -> TenantPod { + let label = format!("t{}", uuid::Uuid::new_v4().simple()); + let advance_on_acquire = Arc::new(Mutex::new(None::)); + let (hook, hook_clock) = (Arc::clone(&advance_on_acquire), Arc::clone(clock)); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .before_acquire(move |_, _| { + if let Some(at) = hook.lock().unwrap().take() { + hook_clock.set(at); + } + Box::pin(async { Ok(true) }) + }) + .connect(&crate::test_support::database_url()) + .await + .expect("PostgreSQL must be available"); + // Seeding leaves the pool's only connection idle, so every request + // acquire reuses it and runs the hook. + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(uuid::Uuid::new_v4()) + .bind(host(&label)) + .execute(&pool) + .await + .expect("seed community"); + store.bind(&label, &[ISS]); + let base = crate::state::tests::test_state_with_database_pool(pool).await; + let pod = pod_on(base, store, clock, mode).await; + TenantPod { + pod, + clock: Arc::clone(clock), + advance_on_acquire, + label, + } + } + + fn nip98(keys: &nostr::Keys, url: &str) -> String { + use base64::Engine as _; + let event = nostr::EventBuilder::new(nostr::Kind::HttpAuth, "") + .tags([ + nostr::Tag::parse(["u", url]).unwrap(), + nostr::Tag::parse(["method", "GET"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ) + } + + impl TenantPod { + /// Each admission site's outcome for one fresh key: root and audio + /// upgrades, then the workflow-runs handler with a matching proof. + /// With `crossing = Some((start, at))` every request resolves at + /// `start` and its tenant lookup moves the clock to `at`. + async fn every_site( + &self, + crossing: Option<(Duration, Duration)>, + ) -> Vec<(StatusCode, Vec)> { + let raw_host = host(&self.label); + let keys = nostr::Keys::generate(); + let token = mint_for(ISS, &raw_host, &keys.public_key()); + let scheme = if self.pod.state.config.relay_url.starts_with("wss://") { + "https" + } else { + "http" + }; + let workflow = format!("/workflows/{}/runs", uuid::Uuid::new_v4()); + let requests = [ + ("/".to_owned(), None), + (format!("/huddle/{}/audio", uuid::Uuid::new_v4()), None), + ( + workflow.clone(), + Some(nip98(&keys, &format!("{scheme}://{raw_host}{workflow}"))), + ), + ]; + let mut outcomes = Vec::new(); + for (path, proof) in requests { + if let Some((start, at)) = crossing { + self.clock.set(start); + *self.advance_on_acquire.lock().unwrap() = Some(at); + } + outcomes.push( + self.pod + .send_signed(&path, &raw_host, Some(&token), proof) + .await, + ); + } + outcomes + } + } + + /// Every site cleared NIP-FI and its handler went on past tenant + /// binding: a oneshot request cannot upgrade, so root falls back to its + /// NIP-11 document and audio refuses the upgrade, and the workflow handler + /// passed admission to its rate limiter, whose Redis is unreachable. + fn reached_handlers(outcomes: &[(StatusCode, Vec)]) -> bool { + let body = |o: &(StatusCode, Vec)| String::from_utf8_lossy(&o.1).into_owned(); + match outcomes { + [root, audio, http] => { + root.0 == StatusCode::OK + && body(root).contains("\"federated_identity\"") + && audio.0 == StatusCode::UPGRADE_REQUIRED + && http.0 == StatusCode::SERVICE_UNAVAILABLE + && body(http).contains("shared admission unavailable") + } + _ => false, + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn every_reachable_handler_resolves_its_host_exactly_once() { + let (store, clock) = setup(); + let p = tenant_pod(&store, &clock, NipFiMode::Enforce).await; + // Absent from the published map, so every resolution is a counted miss. + let raw_host = host(&p.label); + store.unbind(&p.label); + p.pod.resolver.refresh().await.expect("refresh"); + store.bind(&p.label, &[ISS]); + + let outcomes = p.every_site(None).await; + assert!(reached_handlers(&outcomes), "{outcomes:?}"); + assert_eq!(store.lookups_for(&raw_host), 3, "one lookup per admission"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn snapshot_going_stale_during_the_tenant_lookup_admits_nowhere() { + let (store, clock) = setup(); + let p = tenant_pod(&store, &clock, NipFiMode::Enforce).await; + let inside = (secs(119.0), secs(119.9)); + assert!( + reached_handlers(&p.every_site(Some(inside)).await), + "control" + ); + + // Startup loaded at 0 s: fresh at resolution, stale at admission. + let crossing = (secs(119.9), secs(120.1)); + assert_eq!(p.every_site(Some(crossing)).await, vec![unavailable(); 3]); + } + + /// Each `buzz_nip_fi_shadow_total` series recorded while `run` ran, as + /// sorted `route/stage/outcome` labels. + async fn shadow_records(run: F) -> (F::Output, Vec) { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let output = { + let _guard = metrics::set_default_local_recorder(&recorder); + run.await + }; + let mut records: Vec = recorder + .snapshotter() + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.key().name() == "buzz_nip_fi_shadow_total") + .map(|(key, ..)| { + let label = |name: &str| { + let label = key.key().labels().find(|l| l.key() == name); + label.map(|l| l.value().to_owned()).unwrap_or_default() + }; + format!("{}/{}/{}", label("route"), label("stage"), label("outcome")) + }) + .collect(); + records.sort(); + (output, records) + } + + // Shadow re-checks the binding's deadline where enforce does, after the + // tenant lookup, and records enforce's refusal without changing any + // response. Mutation: dropping the upgrade's observed deadline, or + // resolving HTTP afresh instead of reusing the guard's binding, records + // no `deadline` would-deny at that site. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn shadow_records_a_snapshot_going_stale_during_the_tenant_lookup() { + let (store, clock) = setup(); + let p = tenant_pod(&store, &clock, NipFiMode::Shadow).await; + let inside = (secs(119.0), secs(119.9)); + let (outcomes, records) = shadow_records(p.every_site(Some(inside))).await; + assert!(reached_handlers(&outcomes), "control: {outcomes:?}"); + assert_eq!(records, ["http/admit/admit"], "control"); + + let crossing = (secs(119.9), secs(120.1)); + let (outcomes, records) = shadow_records(p.every_site(Some(crossing))).await; + assert!(reached_handlers(&outcomes), "{outcomes:?}"); + assert_eq!( + records, + [ + "audio/deadline/unavailable", + "http/deadline/unavailable", + "ws/deadline/unavailable" + ] + ); + } +} diff --git a/crates/buzz-relay/src/nip_fi_shadow.rs b/crates/buzz-relay/src/nip_fi_shadow.rs index 5f78fb5d7f..7a924f1161 100644 --- a/crates/buzz-relay/src/nip_fi_shadow.rs +++ b/crates/buzz-relay/src/nip_fi_shadow.rs @@ -1,15 +1,18 @@ //! Shadow-mode recording of what enforce would have decided. The verdict -//! never reaches admission, a connection, or a response. Labels are bounded: -//! the community is its configured URI or `unmapped`, never the raw `Host`, -//! and no token, claim, or issuer is logged. The one key logged is the NIP-98 -//! proof key, in the debug line of the key-pairing step shadow shares with +//! never reaches admission, a connection, or a response. Shadow resolves the +//! community exactly as enforce does, database lookup and freshness included, +//! and once per HTTP request. Labels are bounded and never the raw `Host`: the +//! community label is the configured URI for the `env` source (a set bounded +//! by config), `bound` for the `database` source, and `unmapped` when the +//! community did not resolve. No token, claim, or issuer is logged. The one +//! key logged is the NIP-98 proof key, in the debug line of the key-pairing step shadow shares with //! enforce. [FI-TRACE-PRIVACY-NONPUBLIC] //! //! # Metrics //! //! | Series | Labels | One increment per | //! |---|---|---| -//! | `buzz_nip_fi_shadow_total` | `route` (`http`, `ws`, `audio`), `stage`, `outcome`, `community` | NIP-FI decision enforce would make: one per HTTP request that reaches admission, one per WebSocket upgrade enforce would refuse, and one per observed session: at the AUTH where enforce would admit or deny it, or `deadline` when its deadline passes first | +//! | `buzz_nip_fi_shadow_total` | `route` (`http`, `ws`, `audio`), `stage`, `outcome`, `community` | NIP-FI decision enforce would make: one per HTTP request that reaches admission, one per WebSocket upgrade enforce would refuse, and one per observed session: at the AUTH where enforce would admit or deny it, or `deadline` when its deadline passes first. A request or upgrade whose community binding goes stale before enforce's final re-check also records `deadline` | //! | `buzz_nip_fi_shadow_strict_proof_total` | `route` (`bridge`, `blossom`), `outcome` (`pass`, `rejected`), `community` | strict NIP-98 side check on a route whose proof is stricter in enforce (payload tag, Blossom proof); not every request runs one | //! | `buzz_nip_fi_shadow_session_end_total` | `route` (`ws`, `audio`), `reason` (`expired`, `revoked`), `community` | admitted shadow session enforce would have ended; a session never admitted records none | //! | `buzz_nip_fi_shadow_disconnect_total` | `route` (`admin`, `cross_pod`), `outcome` | disconnect-path event that moves a real disconnect counter in enforce: an accepted admin command, a capacity rejection, a subscriber lag, or a cross-pod capacity or poison failsafe. An admin command refused for its header, body, proof, replay, or a dependency error records nothing | @@ -24,7 +27,9 @@ use axum::http::HeaderMap; use buzz_auth::DenialClass; -use crate::nip_fi_config::NipFiCommunities; +use metrics::SharedString; + +use crate::nip_fi_http::Resolution; /// Which enforce step would have denied, and with which class. pub(crate) type WouldDeny = (Stage, DenialClass); @@ -55,18 +60,30 @@ impl Stage { } } -/// Record one would-be enforce verdict for a request at `route`. -pub(crate) fn record( - route: &'static str, - headers: &HeaderMap, - communities: &NipFiCommunities, - verdict: Result<(), WouldDeny>, -) { - record_for(route, community_label(headers, communities), verdict); +/// The `community` label of a `database`-source community that resolved. +pub(crate) const BOUND: &str = "bound"; +/// The `community` label of a verdict whose community did not resolve. +const UNMAPPED: &str = "unmapped"; + +/// The `community` label of `community`. +pub(crate) fn label(community: &Resolution) -> SharedString { + community + .as_ref() + .map_or(SharedString::const_str(UNMAPPED), |c| c.shadow_label()) +} + +/// Record one would-be enforce verdict for a request at `route` whose Host +/// resolved to `community`. +pub(crate) fn record(route: &'static str, community: &Resolution, verdict: Result<(), WouldDeny>) { + record_for(route, label(community), verdict); } /// [`record`] for a caller that resolved its community label earlier. -pub(crate) fn record_for(route: &'static str, community: String, verdict: Result<(), WouldDeny>) { +pub(crate) fn record_for( + route: &'static str, + community: SharedString, + verdict: Result<(), WouldDeny>, +) { let (stage, outcome) = match verdict { Ok(()) => ("admit", "admit"), Err((stage, class)) => (stage.label(), class_label(class)), @@ -74,11 +91,10 @@ pub(crate) fn record_for(route: &'static str, community: String, verdict: Result emit(route, community, stage, outcome); } -/// The bounded community label for `headers`: its configured URI or `unmapped`. -pub(crate) fn community_label(headers: &HeaderMap, communities: &NipFiCommunities) -> String { - crate::nip_fi_core::resolve_community(headers, communities) - .map_or("unmapped", |binding| binding.expected_aud()) - .to_owned() +/// The request's community, from the router guard's resolution when there is +/// one. +async fn resolution(state: &crate::state::AppState, headers: &HeaderMap) -> Resolution { + crate::nip_fi_http::resolve_for_admission(state.nip_fi_communities(), headers).await } tokio::task_local! { @@ -90,14 +106,13 @@ tokio::task_local! { /// verdict. Enforce never lets a guard-denied request reach a handler, so /// none of that request's handler-side records are emitted. pub(crate) async fn observe_guard( - state: &crate::state::AppState, - headers: &HeaderMap, + community: SharedString, verdict: Result<(), WouldDeny>, run: F, ) -> F::Output { let denied = verdict.is_err(); if denied { - record("http", headers, &state.config.nip_fi.communities, verdict); + record_for("http", community, verdict); } GUARD_DENIED.scope(denied, run).await } @@ -107,7 +122,7 @@ fn guard_denied() -> Option { GUARD_DENIED.try_with(|denied| *denied).ok() } -fn emit(route: &'static str, community: String, stage: &'static str, outcome: &'static str) { +fn emit(route: &'static str, community: SharedString, stage: &'static str, outcome: &'static str) { if guard_denied() == Some(true) { return; } @@ -121,7 +136,7 @@ fn emit(route: &'static str, community: String, stage: &'static str, outcome: &' .increment(1); // The counter is the record; only a would-deny earns a (debug) line. if stage != "admit" { - tracing::debug!(route, stage, outcome, community, "nip-fi shadow would-deny"); + tracing::debug!(route, stage, outcome, %community, "nip-fi shadow would-deny"); } } @@ -136,7 +151,7 @@ const fn class_label(class: DenialClass) -> &'static str { /// In shadow mode only, record whether `strict` (a pure check) would reject, /// on its own counter: a pass is one proof, not an admission verdict. -pub(crate) fn observe_strict_proof( +pub(crate) async fn observe_strict_proof( state: &crate::state::AppState, headers: &HeaderMap, route: &'static str, @@ -145,11 +160,12 @@ pub(crate) fn observe_strict_proof( let nip_fi = &state.config.nip_fi; if nip_fi.mode.observes_only() && guard_denied() != Some(true) { let outcome = strict().map_or(class_label(DenialClass::EvidenceRejected), |()| "pass"); + let community = label(&resolution(state, headers).await); metrics::counter!( "buzz_nip_fi_shadow_strict_proof_total", "route" => route, "outcome" => outcome, - "community" => community_label(headers, &nip_fi.communities) + "community" => community ) .increment(1); } @@ -170,7 +186,7 @@ pub(crate) async fn bind_tenant( .await .ok(); if tenant.is_none() { - observe_unbound(state, headers); + observe_unbound(state, headers).await; } tenant } @@ -179,15 +195,16 @@ pub(crate) async fn bind_tenant( /// Host bound no tenant: `community` when the Host maps to no configured /// community; an admit when enforce's guard passed it, since the handler's /// own rejection is no NIP-FI denial; nothing outside a guarded route. -fn observe_unbound(state: &crate::state::AppState, headers: &HeaderMap) { +async fn observe_unbound(state: &crate::state::AppState, headers: &HeaderMap) { let nip_fi = &state.config.nip_fi; if nip_fi.mode.observes_only() { - let verdict = match crate::nip_fi_core::resolve_community(headers, &nip_fi.communities) { - Err(class) => Err((Stage::Community, class)), + let community = resolution(state, headers).await; + let verdict = match &community { + Err(class) => Err((Stage::Community, *class)), Ok(_) if guard_denied() == Some(false) => Ok(()), Ok(_) => return, }; - record("http", headers, &nip_fi.communities, verdict); + record("http", &community, verdict); } } @@ -241,15 +258,19 @@ mod tests { headers } - // Mutation: labelling by the raw Host leaks `attacker.example`. - #[test] - fn community_label_is_configured_uri_or_unmapped() { + // Mutation: labelling by the raw Host leaks `attacker.example`; labelling + // an `env` community `bound` drops its per-community series. + #[tokio::test(flavor = "current_thread")] + async fn env_label_is_configured_uri_or_unmapped() { + let map = communities(); let recorder = DebuggingRecorder::new(); - metrics::with_local_recorder(&recorder, || { - for host in [Some(TEST_HOST), Some("attacker.example"), None] { - super::record("http", &with_host(host), &communities(), Ok(())); - } - }); + let _guard = metrics::set_default_local_recorder(&recorder); + for host in [Some(TEST_HOST), Some("attacker.example"), None] { + let community = crate::nip_fi_core::Communities::from(&map) + .resolve(&with_host(host)) + .await; + super::record("http", &community, Ok(())); + } let labels: Vec = shadow_counts(&recorder) .into_iter() .map(|(c, _, _)| c) @@ -311,7 +332,8 @@ mod tests { state.config = Arc::new(config); state.nip98_replay = guard.clone(); let recorder = DebuggingRecorder::new(); - metrics::with_local_recorder(&recorder, || { + { + let _guard = metrics::set_default_local_recorder(&recorder); super::observe_strict_proof(&state, &headers, "bridge", || { crate::api::bridge::verify_bridge_auth_with_options( &headers, @@ -322,8 +344,9 @@ mod tests { true, ) .map(drop) - }); - }); + }) + .await; + } let counts = counts_of(&recorder, STRICT); assert_eq!(counts.len(), expected, "{mode:?}"); assert!( @@ -336,13 +359,15 @@ mod tests { // Both outcomes stay on the proof counter, never an admit // that could be summed with whole-request verdicts. let recorder = DebuggingRecorder::new(); - metrics::with_local_recorder(&recorder, || { - super::observe_strict_proof(&state, &headers, "bridge", || Ok::<_, ()>(())); - super::observe_strict_proof(&state, &headers, "bridge", || Err(())); - }); + { + let _guard = metrics::set_default_local_recorder(&recorder); + super::observe_strict_proof(&state, &headers, "bridge", || Ok::<_, ()>(())) + .await; + super::observe_strict_proof(&state, &headers, "bridge", || Err(())).await; + } let label = |outcome: &str| { ( - "https://relay.example".to_owned(), + TEST_COMMUNITY_URI.to_owned(), "-".to_owned(), outcome.to_owned(), ) diff --git a/crates/buzz-relay/src/nip_fi_shadow_session.rs b/crates/buzz-relay/src/nip_fi_shadow_session.rs index e841737f82..b6fb243882 100644 --- a/crates/buzz-relay/src/nip_fi_shadow_session.rs +++ b/crates/buzz-relay/src/nip_fi_shadow_session.rs @@ -14,7 +14,6 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak}; -use axum::http::HeaderMap; use buzz_auth::{DenialClass, VerifiedAssertion}; use nostr::PublicKey; use tokio_util::sync::CancellationToken; @@ -69,10 +68,12 @@ impl ShadowSessions { } } +/// A shadow session exists only for an assertion verified against a resolved +/// community, whose label all its records carry. pub(crate) struct ShadowSession { id: u64, route: &'static str, - community: String, + community: metrics::SharedString, assertion: VerifiedAssertion, phase: Mutex, /// The socket's cancellation, read only to stop recording. @@ -87,7 +88,7 @@ impl ShadowSession { pub(crate) fn start( state: &AppState, route: &'static str, - headers: &HeaderMap, + community: metrics::SharedString, assertion: VerifiedAssertion, connection_time: chrono::DateTime, ) -> Arc { @@ -101,7 +102,7 @@ impl ShadowSession { let session = Arc::new(Self { id: sessions.next_id.fetch_add(1, Ordering::Relaxed), route, - community: crate::nip_fi_shadow::community_label(headers, &nip_fi.communities), + community, assertion, phase: Mutex::new(Phase::Pending { revoked: false }), cancel: OnceLock::new(), @@ -263,13 +264,13 @@ impl Drop for ShadowSession { /// returns `true` when enforce would have refused the upgrade here. pub(crate) fn upgrade_denied( state: &AppState, - headers: &HeaderMap, + community: &metrics::SharedString, assertion: &VerifiedAssertion, ) -> bool { let denied = deny_listed(state, assertion); if denied { let verdict = Err((Stage::DenySet, DenialClass::AuthorizationDenied)); - crate::nip_fi_shadow::record("ws", headers, &state.config.nip_fi.communities, verdict); + crate::nip_fi_shadow::record_for("ws", community.clone(), verdict); } denied } @@ -383,7 +384,13 @@ pub(crate) mod tests { lifetime: Duration, ) -> Arc { let assertion = VerifiedAssertion::for_test(key, vec![Utc::now() + lifetime]); - ShadowSession::start(state, "ws", &HeaderMap::new(), assertion, Utc::now()) + ShadowSession::start( + state, + "ws", + crate::nip_fi_shadow::BOUND.into(), + assertion, + Utc::now(), + ) } fn auth(session: &Arc, state: &AppState, pubkey: PublicKey) { @@ -629,8 +636,16 @@ pub(crate) mod tests { VerifiedAssertion::for_test(Some(key), vec![Utc::now() + Duration::hours(1)]); let clean = VerifiedAssertion::for_test(Some(Keys::generate().public_key()), vec![Utc::now()]); - assert!(upgrade_denied(&state, &HeaderMap::new(), &listed)); - assert!(!upgrade_denied(&state, &HeaderMap::new(), &clean)); + assert!(upgrade_denied( + &state, + &crate::nip_fi_shadow::BOUND.into(), + &listed + )); + assert!(!upgrade_denied( + &state, + &crate::nip_fi_shadow::BOUND.into(), + &clean + )); }); assert_eq!(records, ["deny_set/denied"]); } diff --git a/crates/buzz-relay/src/nip_fi_upgrade.rs b/crates/buzz-relay/src/nip_fi_upgrade.rs index 7edabfa99c..5c5e864469 100644 --- a/crates/buzz-relay/src/nip_fi_upgrade.rs +++ b/crates/buzz-relay/src/nip_fi_upgrade.rs @@ -15,34 +15,67 @@ use axum::body::Body; use axum::http::{HeaderMap, Response}; use buzz_auth::{DenialClass, NipFiMode, VerifiedAssertion, VerifyAssertion}; -use crate::nip_fi_config::NipFiCommunities; use crate::nip_fi_core::{ - evaluate_attached_assertion, http_denial, resolve_community, AssertionRejection, + evaluate_attached_assertion, http_denial, AdmissionDeadline, AssertionRejection, Communities, + ResolvedCommunity, }; /// Outcome of NIP-FI assertion validation at upgrade time. pub(crate) enum NipFiUpgradeOutcome { - /// Assertion validated successfully. Carry the result into the connection. - Admitted(VerifiedAssertion), + /// Assertion validated successfully. Carry the result into the connection, + /// with the deadline the upgrade re-checks after its tenant lookup. + Admitted(VerifiedAssertion, AdmissionDeadline), /// Off, or Shadow without a passing assertion — none is required. NotRequired, - /// Shadow: the assertion passed and is only observed from here on. - Observed(VerifiedAssertion), + /// Shadow: the assertion passed and is only observed from here on, with + /// its community, whose deadline enforce would re-check after the tenant + /// lookup. + Observed(VerifiedAssertion, ResolvedCommunity), /// Enforcement active but assertion absent/rejected — return the HTTP /// denial response. Denied(Response), } -/// The enforced and the shadow-observed assertion slots of an admitted upgrade. -pub(crate) type UpgradeAssertions = (Option, Option); +/// The enforced and the shadow-observed assertion slots of an admitted upgrade, +/// and the deadline to re-check immediately before accepting the socket. The +/// observed assertion carries its own community, for [`observe_deadline`]. +pub(crate) type UpgradeAssertions = ( + Option, + Option<(VerifiedAssertion, ResolvedCommunity)>, + AdmissionDeadline, +); + +/// Shadow's re-check at the point enforce re-checks its deadline: a binding +/// that went stale during the tenant lookup records enforce's refusal and +/// stops observing the session. The socket itself is still accepted. A +/// session still observed keeps its community label. +pub(crate) fn observe_deadline( + route: crate::nip_fi_session::NipFiWsRoute, + observed: Option<(VerifiedAssertion, ResolvedCommunity)>, +) -> Option<(VerifiedAssertion, metrics::SharedString)> { + let (assertion, community) = observed?; + let label = community.shadow_label(); + match community.deadline().check() { + Ok(()) => Some((assertion, label)), + Err(class) => { + let verdict = Err((crate::nip_fi_shadow::Stage::Deadline, class)); + crate::nip_fi_shadow::record_for(route.shadow_label(), label, verdict); + None + } + } +} impl NipFiUpgradeOutcome { /// The enforced and the shadow-observed assertion, or the denial response. pub(crate) fn into_assertions(self) -> Result>> { match self { - Self::NotRequired => Ok((None, None)), - Self::Admitted(assertion) => Ok((Some(assertion), None)), - Self::Observed(assertion) => Ok((None, Some(assertion))), + Self::NotRequired => Ok((None, None, AdmissionDeadline::default())), + Self::Admitted(assertion, deadline) => Ok((Some(assertion), None, deadline)), + Self::Observed(assertion, community) => Ok(( + None, + Some((assertion, community)), + AdmissionDeadline::default(), + )), Self::Denied(response) => Err(Box::new(response)), } } @@ -61,32 +94,37 @@ impl NipFiUpgradeOutcome { /// be valid but authorization is temporarily unavailable — so "authorization /// denied" would be false. "authorization unavailable, retry after repair" is /// the accurate and correct signal. [FI-TRACE-DENIAL-ORACLE] -pub(crate) fn check_nip_fi_at_upgrade( +pub(crate) async fn check_nip_fi_at_upgrade<'a>( route: crate::nip_fi_session::NipFiWsRoute, headers: &HeaderMap, - communities: &NipFiCommunities, + communities: impl Into>, verifier: Option<&dyn VerifyAssertion>, mode: NipFiMode, ) -> NipFiUpgradeOutcome { + let communities = communities.into(); if !mode.restricts() { // Shadow records an upgrade would-deny; a passing assertion is // observed until AUTH, where its admission verdict is recorded. if mode.observes_only() { - let verdict = resolve_community(headers, communities) - .map_err(|class| (crate::nip_fi_shadow::Stage::Community, class)) - .and_then(|community| { - evaluate_attached_assertion(headers, community, verifier).map_err(|rejection| { + let community = communities.resolve(headers).await; + let verdict = match &community { + Err(class) => Err((crate::nip_fi_shadow::Stage::Community, *class)), + Ok(binding) => { + evaluate_attached_assertion(headers, binding, verifier).map_err(|rejection| { ( crate::nip_fi_shadow::Stage::Assertion, rejection.denial_class(), ) }) - }); - match verdict { - Ok(assertion) => return NipFiUpgradeOutcome::Observed(assertion), - Err(deny) => { - let route = route.shadow_label(); - crate::nip_fi_shadow::record(route, headers, communities, Err(deny)); + } + }; + match (verdict, community) { + (Ok(assertion), Ok(community)) => { + return NipFiUpgradeOutcome::Observed(assertion, community) + } + (verdict, community) => { + let verdict = verdict.map(drop); + crate::nip_fi_shadow::record(route.shadow_label(), &community, verdict); } } } @@ -98,12 +136,12 @@ pub(crate) fn check_nip_fi_at_upgrade( } // Enforce mode: resolve the Host's community, then validate the assertion. - let community = match resolve_community(headers, communities) { + let community = match communities.resolve(headers).await { Ok(community) => community, Err(class) => return NipFiUpgradeOutcome::Denied(http_denial(class)), }; - match evaluate_attached_assertion(headers, community, verifier) { - Ok(assertion) => NipFiUpgradeOutcome::Admitted(assertion), + match evaluate_attached_assertion(headers, &community, verifier) { + Ok(assertion) => NipFiUpgradeOutcome::Admitted(assertion, community.deadline()), Err(rejection) => { if let AssertionRejection::Verifier(err) = rejection { tracing::debug!(code = err.code(), "nip-fi assertion denied at upgrade"); @@ -116,6 +154,7 @@ pub(crate) fn check_nip_fi_at_upgrade( #[cfg(test)] mod tests { use super::*; + use futures_util::FutureExt as _; const ROOT: crate::nip_fi_session::NipFiWsRoute = crate::nip_fi_session::NipFiWsRoute::Root; use crate::nip_fi_core::extract_bearer_token; @@ -393,7 +432,9 @@ mod tests { &communities(), None, buzz_auth::NipFiMode::Enforce, - ); + ) + .now_or_never() + .expect("env resolution never waits"); match outcome { NipFiUpgradeOutcome::Denied(resp) => { assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE); @@ -413,7 +454,9 @@ mod tests { &communities(), None, buzz_auth::NipFiMode::Enforce, - ); + ) + .now_or_never() + .expect("env resolution never waits"); // Missing header → MissingEvidence; but None verifier fires first. // Correct behavior: extract_bearer_token is called before verifier check, // so missing header → 401 (MissingEvidence) before reaching the None verifier path. @@ -443,7 +486,9 @@ mod tests { &communities(), None, buzz_auth::NipFiMode::Off, - ); + ) + .now_or_never() + .expect("env resolution never waits"); assert!( matches!(outcome, NipFiUpgradeOutcome::NotRequired), "Off mode must not require assertion — OSS default must not regress" @@ -471,7 +516,9 @@ mod tests { &communities(), None, buzz_auth::NipFiMode::DenyProtected, - ); + ) + .now_or_never() + .expect("env resolution never waits"); match outcome { NipFiUpgradeOutcome::Denied(resp) => { assert_eq!( @@ -519,13 +566,17 @@ mod tests { ]; for (err, status, body) in rows { let verifier = ScriptedVerifier::new(Err(err)); - let (got_status, got_body) = denied_parts(check_nip_fi_at_upgrade( - ROOT, - &headers_with("Bearer a.b.c"), - &communities(), - Some(&verifier), - NipFiMode::Enforce, - )); + let (got_status, got_body) = denied_parts( + check_nip_fi_at_upgrade( + ROOT, + &headers_with("Bearer a.b.c"), + &communities(), + Some(&verifier), + NipFiMode::Enforce, + ) + .now_or_never() + .expect("env resolution never waits"), + ); assert_eq!(got_status, status, "{err:?}"); assert_eq!(got_body, body, "{err:?}"); } @@ -535,13 +586,17 @@ mod tests { // Mutation: checking the verifier first turns this 403 into 503. #[test] fn characterize_upgrade_transport_precedes_verifier_presence() { - let (status, body) = denied_parts(check_nip_fi_at_upgrade( - ROOT, - &headers_with("junk"), - &communities(), - None, - NipFiMode::Enforce, - )); + let (status, body) = denied_parts( + check_nip_fi_at_upgrade( + ROOT, + &headers_with("junk"), + &communities(), + None, + NipFiMode::Enforce, + ) + .now_or_never() + .expect("env resolution never waits"), + ); assert_eq!(status, StatusCode::FORBIDDEN); assert_eq!(body, b"evidence rejected\n"); } @@ -558,8 +613,11 @@ mod tests { &communities(), Some(&verifier), NipFiMode::Enforce, - ) { - NipFiUpgradeOutcome::Admitted(assertion) => { + ) + .now_or_never() + .expect("env resolution never waits") + { + NipFiUpgradeOutcome::Admitted(assertion, _) => { assert_eq!(assertion.asserted_key(), None); } _ => panic!("claimless assertion must be admitted at upgrade"), @@ -581,16 +639,22 @@ mod tests { &communities(), Some(&verifier), NipFiMode::Off - ), + ) + .now_or_never() + .expect("env resolution never waits"), NipFiUpgradeOutcome::NotRequired )); - let (status, _) = denied_parts(check_nip_fi_at_upgrade( - ROOT, - &headers, - &communities(), - Some(&verifier), - NipFiMode::DenyProtected, - )); + let (status, _) = denied_parts( + check_nip_fi_at_upgrade( + ROOT, + &headers, + &communities(), + Some(&verifier), + NipFiMode::DenyProtected, + ) + .now_or_never() + .expect("env resolution never waits"), + ); assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(verifier.calls(), 0); } @@ -611,18 +675,22 @@ mod tests { Some(&verifier), NipFiMode::Shadow, ) + .now_or_never() + .expect("env resolution never waits") }; - let Ok((None, Some(_))) = check().into_assertions() else { + let Ok((None, Some(_), _)) = check().into_assertions() else { panic!("a passing shadow assertion is observed, never enforced"); }; assert_eq!(verifier.calls(), 1, "shadow must evaluate the assertion"); - let Ok((None, None)) = check_nip_fi_at_upgrade( + let Ok((None, None, _)) = check_nip_fi_at_upgrade( ROOT, &host_headers(), &communities(), Some(&verifier), NipFiMode::Shadow, ) + .now_or_never() + .expect("env resolution never waits") .into_assertions() else { panic!("a missing shadow assertion admits as Off"); }; @@ -647,7 +715,9 @@ mod tests { &communities(), verifier, NipFiMode::Shadow, - ); + ) + .now_or_never() + .expect("env resolution never waits"); assert!(matches!(outcome, NipFiUpgradeOutcome::NotRequired)); } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index e7194c8f72..5970f26f6e 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -189,17 +189,24 @@ async fn nip_fi_assertion_guard( return http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); } - let verdict = nip_fi_guard_steps(&state, request.headers()); + // Shadow resolves exactly as enforce does; enforce hands the binding to + // the handler's admission, so a request costs one resolution. + let community = state.nip_fi_communities().resolve(request.headers()).await; + let verdict = nip_fi_guard_steps(&state, request.headers(), community.as_deref()); // Shadow: transparent, but the guard's verdict is recorded in enforce's // order, ahead of any handler step. + // The resolved binding rides along so the handler's observed admission + // rechecks the same deadline enforce does. if mode.observes_only() { - let headers = request.headers().clone(); - return crate::nip_fi_shadow::observe_guard(&state, &headers, verdict, next.run(request)) - .await; + let label = crate::nip_fi_shadow::label(&community); + let run = crate::nip_fi_http::with_resolved_community(community, next.run(request)); + return crate::nip_fi_shadow::observe_guard(label, verdict, run).await; } - match verdict { - Ok(()) => next.run(request).await, - Err((_, class)) => http_denial(class), + match (verdict, community) { + (Ok(()), Ok(community)) => { + crate::nip_fi_http::with_resolved_community(Ok(community), next.run(request)).await + } + (Err((_, class)), _) | (Ok(()), Err(class)) => http_denial(class), } } @@ -213,10 +220,9 @@ async fn nip_fi_assertion_guard( fn nip_fi_guard_steps( state: &AppState, headers: &axum::http::HeaderMap, + community: Result<&buzz_auth::CommunityBinding, &buzz_auth::DenialClass>, ) -> Result<(), crate::nip_fi_shadow::WouldDeny> { - let community = - crate::nip_fi_core::resolve_community(headers, &state.config.nip_fi.communities) - .map_err(|class| (crate::nip_fi_shadow::Stage::Community, class))?; + let community = community.map_err(|class| (crate::nip_fi_shadow::Stage::Community, *class))?; crate::nip_fi_core::evaluate_attached_assertion( headers, community, @@ -631,15 +637,16 @@ async fn nip11_or_ws_handler( // accepts is also gated — no hand-rolled predicate can diverge from the // extractor's accepted shapes when `http2` is eventually enabled. // - // Zero DB cost invariant: the active HTTP/1.1 fire-point runs before - // `bind_community`, so denied h1 upgrades pay zero DB cost + // No-tenant-lookup invariant: the active HTTP/1.1 fire-point runs before + // `bind_community`, so denied h1 upgrades skip the tenant lookup (a + // resolver cache miss still costs one bounded binding lookup) // [FI-TRACE-TRANSPORT-CLOSED], and tests that assert 401/503 are not // pre-empted by a 404 from an unseeded DB. The `Ok(ws)` backstop runs after // `bind_community`; it is unreachable until `http2` is enabled. // // Keying on the header pair (not on `Accept`) means an HTML Accept header // on a real WS upgrade is still gated correctly. - let (upgrade_checked, nip_fi_assertion, shadow_assertion) = { + let (upgrade_checked, nip_fi_assertion, shadow_assertion, nip_fi_deadline) = { let is_h1_ws_upgrade = headers .get(axum::http::header::UPGRADE) .and_then(|v| v.to_str().ok()) @@ -666,18 +673,19 @@ async fn nip11_or_ws_handler( use crate::nip_fi_upgrade::check_nip_fi_at_upgrade; let mode = state.config.nip_fi.mode; let verifier = state.nip_fi_verifier.as_deref(); - let communities = &state.config.nip_fi.communities; + let communities = state.nip_fi_communities(); match check_nip_fi_at_upgrade(Root, &headers, communities, verifier, mode) + .await .into_assertions() { - Ok((assertion, shadow)) => (true, assertion, shadow), + Ok((assertion, shadow, deadline)) => (true, assertion, shadow, deadline), Err(resp) => return (*resp).into_response(), } } else { // Not a WS upgrade shape — a NIP-11 request or a plain browser GET. // The `Ok(ws)` arm below backstops any extractor-accepted shape this // predicate misses. [F3-H2-GATE] - (false, None, None) + (false, None, None, Default::default()) } }; @@ -689,7 +697,7 @@ async fn nip11_or_ws_handler( // That normative sequence runs in handlers/auth.rs after set_authenticated_pubkey. // // This pre-upgrade check provides a cheap bounce for keys already in the deny - // map before the connection is upgraded — pays zero DB cost and rejects before + // map before the connection is upgraded — needs no DB read and rejects before // tungstenite hands the socket to the application. It is NOT race-free against // a concurrent disconnect (the session isn't registered yet), which is why the // normative post-registration check in auth.rs is the correctness gate. @@ -707,8 +715,9 @@ async fn nip11_or_ws_handler( } } } - let shadow_assertion = shadow_assertion - .filter(|a| !crate::nip_fi_shadow_session::upgrade_denied(&state, &headers, a)); + let shadow_assertion = shadow_assertion.filter(|(a, c)| { + !crate::nip_fi_shadow_session::upgrade_denied(&state, &c.shadow_label(), a) + }); // Row zero: bind the connection to its community from the request host // BEFORE the WebSocket upgrade, so no frame is ever read on an unbound @@ -719,7 +728,7 @@ async fn nip11_or_ws_handler( // `icon` simply absent), so the doc cannot leak which hosts are mapped. // // The NIP-FI upgrade gate runs above (before bind_community) so denied - // upgrades pay zero DB cost; the `Ok(ws)` backstop runs below. + // upgrades skip the tenant lookup; the `Ok(ws)` backstop runs below. let tenant = match crate::tenant::bind_community(&state.db, raw_host).await { Ok(ctx) => ctx, Err(_) => { @@ -733,6 +742,15 @@ async fn nip11_or_ws_handler( .into_response(); } }; + // The tenant lookup awaited since the community resolved; a snapshot + // that went stale meanwhile accepts no socket. + if let Err(class) = nip_fi_deadline.check() { + return http_denial(class).into_response(); + } + let shadow_assertion = crate::nip_fi_upgrade::observe_deadline( + crate::nip_fi_session::NipFiWsRoute::Root, + shadow_assertion, + ); let max_frame_bytes = state.config.max_frame_bytes; @@ -754,11 +772,15 @@ async fn nip11_or_ws_handler( use crate::nip_fi_upgrade::check_nip_fi_at_upgrade; let mode = state.config.nip_fi.mode; let verifier = state.nip_fi_verifier.as_deref(); - let communities = &state.config.nip_fi.communities; + let communities = state.nip_fi_communities(); + // Resolved after every await of this upgrade: nothing to re-check. match check_nip_fi_at_upgrade(Root, &headers, communities, verifier, mode) + .await .into_assertions() { - Ok(assertions) => assertions, + Ok((assertion, shadow, _deadline)) => { + (assertion, shadow.map(|(a, c)| (a, c.shadow_label()))) + } Err(resp) => return (*resp).into_response(), } } else { @@ -779,11 +801,11 @@ async fn nip11_or_ws_handler( // handshake, not the post-community-active-check instant. // [FI-TRACE-LEASE-BOUND] let connection_time = chrono::Utc::now(); - let shadow = shadow_assertion.map(|a| { + let shadow = shadow_assertion.map(|(a, community)| { crate::nip_fi_shadow_session::ShadowSession::start( &state, "ws", - &headers, + community, a, connection_time, ) @@ -2144,6 +2166,7 @@ mod tests { // Enforce that is the startup-race condition that must return 503 for // a token-carrying request. config.nip_fi = NipFiRelayConfig { + source: Default::default(), mode, registry: IssuerRegistry::new(), jwks_configs: vec![], @@ -2349,7 +2372,7 @@ mod tests { // (gate denial codes), which holds regardless of host resolution state. // // Fix 6: corrected the DB-free comment (bind_community is reached by plain - // GETs; only WS-upgrade requests pay zero DB cost via the pre-gate path). + // GETs; only WS-upgrade requests skip the tenant lookup via the pre-gate path). // // Mutation evidence: // A) Move the NIP-FI gate to fire on plain GETs too → response becomes @@ -3926,6 +3949,7 @@ mod tests { }; config.nip_fi = NipFiRelayConfig { + source: Default::default(), mode: NipFiMode::Enforce, registry: { let mut r = IssuerRegistry::new(); diff --git a/crates/buzz-relay/src/startup_steps.rs b/crates/buzz-relay/src/startup_steps.rs index 3980b6b86a..189731fd6b 100644 --- a/crates/buzz-relay/src/startup_steps.rs +++ b/crates/buzz-relay/src/startup_steps.rs @@ -61,6 +61,8 @@ pub enum StartupStep { SearchConnect, /// Warm NIP-FI JWKS snapshots (only when NIP-FI is configured). NipFiJwksWarm, + /// Load the first NIP-FI binding snapshot (only for the `database` source). + NipFiSnapshotLoad, /// Start the inter-relay mesh (no-op when the mesh is off). MeshBoot, /// Run the git object-store conformance probe (when enabled). @@ -79,7 +81,7 @@ pub enum StartupStep { impl StartupStep { /// The complete `phase` label vocabulary. - pub const ALL: [Self; 18] = [ + pub const ALL: [Self; 19] = [ Self::DbConnect, Self::DbMigrate, Self::PartitionEnsure, @@ -91,6 +93,7 @@ impl StartupStep { Self::RedisConnect, Self::SearchConnect, Self::NipFiJwksWarm, + Self::NipFiSnapshotLoad, Self::MeshBoot, Self::GitConformanceProbe, Self::ChannelRosterFence, @@ -114,6 +117,7 @@ impl StartupStep { Self::RedisConnect => "redis_connect", Self::SearchConnect => "search_connect", Self::NipFiJwksWarm => "nip_fi_jwks_warm", + Self::NipFiSnapshotLoad => "nip_fi_snapshot_load", Self::MeshBoot => "mesh_boot", Self::GitConformanceProbe => "git_conformance_probe", Self::ChannelRosterFence => "channel_roster_fence", diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 1e88eb8652..1a20aa6bc4 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1379,6 +1379,10 @@ pub struct AppState { /// 4) and HTTP admission (`nip_fi_http`): one `Arc`, never a second map. pub nip_fi_deny_map: Option>, + /// The database community resolver (`BUZZ_NIP_FI_COMMUNITY_SOURCE=database`), + /// installed by `main.rs`; `None` resolves from `config.nip_fi.communities`. + pub nip_fi_resolver: Option>, + /// Command JWT verifier for the NIP-FI admin disconnect endpoint. /// /// `None` when mode is `Off` (no command API is reachable). When @@ -1399,6 +1403,17 @@ pub struct AppState { } impl AppState { + /// The NIP-FI community source admission resolves against. + pub(crate) fn nip_fi_communities(&self) -> crate::nip_fi_core::Communities<'_> { + use crate::nip_fi_config::CommunitySource; + use crate::nip_fi_core::Communities; + match (self.config.nip_fi.source, &self.nip_fi_resolver) { + (CommunitySource::Env, _) => Communities::Env(&self.config.nip_fi.communities), + (CommunitySource::Database, Some(resolver)) => Communities::Database(resolver), + (CommunitySource::Database, None) => Communities::Unavailable, + } + } + /// Constructs `AppState` from its component services. /// /// Returns `(state, audit_shutdown)`. The caller should call @@ -1600,6 +1615,7 @@ impl AppState { // `main.rs` after startup validation. `None` is safe before that // call: the endpoint returns 503 when the verifier is absent. nip_fi_deny_map: None, + nip_fi_resolver: None, nip_fi_command_verifier: None, nip_fi_command_replay, nip_fi_publish_tasks: tokio_util::task::TaskTracker::new(), diff --git a/crates/buzz-relay/tests/boot_lifecycle.rs b/crates/buzz-relay/tests/boot_lifecycle.rs index 027fc4d1c2..5d7dd2491f 100644 --- a/crates/buzz-relay/tests/boot_lifecycle.rs +++ b/crates/buzz-relay/tests/boot_lifecycle.rs @@ -674,7 +674,10 @@ mod postgres_tests { /// passes. Unlike `run_relay`, a relay that keeps serving is a result to /// assert on rather than a panic, which is the whole point here. fn run_until_exit(environment: &[(&str, &str)], timeout: Duration) -> (bool, Output) { - let mut process = RelayProcess::spawn(environment); + wait_for_exit(RelayProcess::spawn(environment), timeout) + } + + fn wait_for_exit(mut process: RelayProcess, timeout: Duration) -> (bool, Output) { let deadline = Instant::now() + timeout; while Instant::now() < deadline { if process.try_wait().is_some() { @@ -868,4 +871,187 @@ mod postgres_tests { "the sampler must be owned by a relay that finished booting: {logs}" ); } + + const NIP_FI_ISSUERS: &str = r#"[{ + "issuer": "https://idp.example.com", + "command_audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400, + "maximum_command_age_seconds": 30, + "authorized_principals": ["https://idp.example.com/operator"] + }]"#; + /// A suffix that never covers the relay's own host, so deployment + /// bootstrap writes no binding and the startup load is the first reader + /// of `community_nip_fi_issuers`. + const NIP_FI_PROVISION_BINDING: &str = r#"{"host_suffix":"communities.example.com","authorized_issuers":["https://idp.example.com"]}"#; + + /// Boots the relay with the database community source; `extra` adds or + /// overrides variables. Returns the health port alongside the process. + fn spawn_database_source(extra: &[(&str, &str)]) -> (RelayProcess, u16) { + let database_url = std::env::var("DATABASE_URL") + .expect("postgres lane provides DATABASE_URL for each test process"); + let redis_url = std::env::var("REDIS_URL") + .expect("the postgres lane runs alongside Redis and exports REDIS_URL"); + let metrics_port = reserve_closed_port().to_string(); + let health_port = reserve_closed_port(); + let health_port_value = health_port.to_string(); + let bind_addr = format!("127.0.0.1:{}", reserve_closed_port()); + let mut environment = vec![ + ("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY), + ("BUZZ_METRICS_PORT", metrics_port.as_str()), + ("BUZZ_HEALTH_PORT", health_port_value.as_str()), + ("BUZZ_BIND_ADDR", bind_addr.as_str()), + ("DATABASE_URL", database_url.as_str()), + ("REDIS_URL", redis_url.as_str()), + ("BUZZ_GIT_CONFORMANCE_PROBE", "false"), + // Shadow: off builds no resolver, so it loads no snapshot. + ("BUZZ_NIP_FI_MODE", "shadow"), + ("BUZZ_NIP_FI_COMMUNITY_SOURCE", "database"), + ("BUZZ_NIP_FI_ISSUERS", NIP_FI_ISSUERS), + ("BUZZ_NIP_FI_PROVISION_BINDING", NIP_FI_PROVISION_BINDING), + ("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"), + ]; + environment.extend_from_slice(extra); + (RelayProcess::spawn(&environment), health_port) + } + + /// Binds `count` active communities under the provision suffix to the + /// registered issuer, on the test's own database. + fn seed_bindings(count: u32) { + use sqlx::Connection as _; + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async { + let mut connection = sqlx::PgConnection::connect(&database_url) + .await + .expect("connect to the test database"); + // One statement per thousand rows: each insert takes a write-fence + // advisory lock per community, held to the end of its transaction. + for start in (1..=count).step_by(1000) { + sqlx::query( + "WITH c AS ( + INSERT INTO communities (id, host) + SELECT gen_random_uuid(), 'c' || n || '.communities.example.com' + FROM generate_series($1, $2) AS n + RETURNING id + ) + INSERT INTO community_nip_fi_issuers (community_id, issuer) + SELECT id, 'https://idp.example.com' FROM c", + ) + .bind(i64::from(start)) + .bind(i64::from(count.min(start + 999))) + .execute(&mut connection) + .await + .expect("seed NIP-FI bindings"); + } + }); + } + + fn assert_failed_before_listeners( + exited: bool, + output: &Output, + health_port: u16, + failure: &str, + ) { + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + exited && !output.status.success(), + "a failed startup binding load must be startup-fatal: {logs}" + ); + assert!( + logs.contains(&format!("startup binding snapshot load failed: {failure}")), + "the failure must name the snapshot load and why it failed: {logs}" + ); + assert!( + !logs.contains("Health probe listener started"), + "no listener may bind before the snapshot loads: {logs}" + ); + assert!( + TcpListener::bind(("0.0.0.0", health_port)).is_ok(), + "the health port must never have been bound" + ); + } + + /// The database source must never let a pod serve without a snapshot: a + /// load that fails exits non-zero before any listener binds, so the + /// orchestrator restarts the pod instead of routing to it. Two bindings + /// over a one-row cap make the load itself fail after every earlier + /// startup step has read the same table successfully. + #[test] + #[ignore = "requires PostgreSQL"] + fn database_source_failed_snapshot_stops_boot_before_listeners_bind() { + seed_bindings(2); + let (process, health_port) = + spawn_database_source(&[("BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS", "1")]); + let (exited, output) = wait_for_exit(process, CHILD_TIMEOUT); + assert_failed_before_listeners(exited, &output, health_port, "OverCap"); + } + + /// A load that cannot finish in time must hit the refresh timeout and fail + /// the same way, rather than leaving the pod waiting unbound. A + /// millisecond budget cannot cover a scan of this many rows. + #[test] + #[ignore = "requires PostgreSQL"] + fn database_source_slow_snapshot_times_out_before_listeners_bind() { + seed_bindings(20_000); + let (process, health_port) = + spawn_database_source(&[("BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS", "1")]); + let (exited, output) = wait_for_exit(process, CHILD_TIMEOUT); + assert_failed_before_listeners(exited, &output, health_port, "Timeout"); + } + + /// With a loadable snapshot the same configuration boots and readiness + /// turns ready, so the two failure tests above fail for the load alone. One + /// seeded binding proves the snapshot reads rows, not just an empty table. + #[test] + #[ignore = "requires PostgreSQL"] + fn database_source_snapshot_load_lets_readiness_turn_ready() { + seed_bindings(1); + let (mut process, health_port) = spawn_database_source(&[]); + let deadline = Instant::now() + CHILD_TIMEOUT; + let ready = loop { + assert!( + process.try_wait().is_none(), + "relay exited: {}", + String::from_utf8_lossy(&process.terminate().stdout) + ); + let response = TcpStream::connect(("127.0.0.1", health_port)) + .and_then(|mut stream| { + stream.set_read_timeout(Some(Duration::from_secs(1)))?; + stream.write_all( + b"GET /_readiness HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + )?; + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response) + }) + .unwrap_or_default(); + if response.starts_with("HTTP/1.1 200") || Instant::now() >= deadline { + break response; + } + thread::sleep(Duration::from_millis(50)); + }; + let output = process.terminate(); + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + ready.starts_with("HTTP/1.1 200"), + "readiness never turned ready: {ready}: {logs}" + ); + assert!( + logs.contains("NIP-FI: binding snapshot loaded"), + "readiness must follow the snapshot load: {logs}" + ); + } } diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 5704a7622b..f2ef677c9e 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -195,6 +195,15 @@ gating the probe on them removed the whole deployment from the load balancer at once and left a reconnect burst with nowhere to land. There is no separate "starting" state — the health listener does not bind until the database, migrations, Redis, and pub/sub are up, so a process that can answer has booted. +With `BUZZ_NIP_FI_COMMUNITY_SOURCE=database` and a mode other than `off`, the +first NIP-FI binding snapshot also loads before any listener binds (startup +phase `nip_fi_snapshot_load`); a failed load exits the process, so a pod never +reports ready without one. Later refresh failures do not affect readiness — +watch `buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds` instead. The +source's env vars (`BUZZ_NIP_FI_COMMUNITY_SOURCE`, +`BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS`, `BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS`, +`BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX`, `BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT`) +and alerts are in `docs/operator-nip-fi-bindings.md`. Shared-dependency health moved to **`/_status`** on the same private health listener, under a `dependencies` object carrying the `postgres`, `redis`, diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index f5c77be0a0..f63c73e755 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -36,6 +36,29 @@ binds nothing. relay's environment: `BUZZ_NIP_FI_PROVISION_BINDING`, `BUZZ_NIP_FI_ISSUERS`, `BUZZ_NIP_FI_COMMUNITIES`, and `DATABASE_URL`. +### Database community source + +`BUZZ_NIP_FI_COMMUNITY_SOURCE=database` resolves communities from these +bindings instead of `BUZZ_NIP_FI_COMMUNITIES`. The relay refuses to start if +`BUZZ_NIP_FI_PROVISION_BINDING` is unset or `BUZZ_NIP_FI_COMMUNITIES` is set. +In `off` mode no snapshot is loaded. + +| Variable | Default | Bound | +|----------|---------|-------| +| `BUZZ_NIP_FI_COMMUNITY_SOURCE` | `env` | `env` or `database` | +| `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` | 1,000,000 | 1 to 10,000,000 community-issuer rows; a larger load fails | +| `BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS` | 10,000 | under the 30s refresh interval | +| `BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX` | 100,000 | 1 to 1,000,000 hosts | +| `BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT` | 8 | at least 1, clamped below the database pool size | + +Each pod loads a snapshot before it binds any listener, and refreshes it +every 30s. A removed binding stops admitting new connections within 30s plus +the configured refresh timeout (40s by default) while refreshes succeed, and within two minutes of the last good load while they +fail. Open WebSocket sessions are not rechecked when a binding is removed; +they end at their own session deadline. Alert on +`time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds > 120` and on +`absent(buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds)`. + ## Commands ```sh @@ -95,3 +118,98 @@ contract: it exits 0. 3. Do not turn on the database binding source in enforce mode while any bindable community is unbound: an unbound community fails closed. + +## Database source runbook + +`BUZZ_NIP_FI_COMMUNITY_SOURCE=database` reads communities from these bindings +instead of `BUZZ_NIP_FI_COMMUNITIES`. Move one mode at a time, and only move +forward when the step's go signals hold. + +### Off + +Set `BUZZ_NIP_FI_COMMUNITY_SOURCE=database` with `BUZZ_NIP_FI_MODE=off`. No +snapshot loads in `off`, but every `BUZZ_NIP_FI_RESOLVER_*` and +`BUZZ_NIP_FI_SNAPSHOT_*` value is validated, so a bad one fails this deploy. + +- Go: the rollout completes, and `buzz-admin nip-fi backfill` exits 0. +- No-go: a pod fails config validation. Fix the named variable. + +### Shadow + +Set `BUZZ_NIP_FI_MODE=shadow`. Each pod now loads the snapshot before any +listener binds, refreshes it every 30 s, and records what enforce would decide +without refusing anything. Shadow labels database-source records +`community="bound"` or `"unmapped"`, never a host. + +- Go, held for at least a day of normal traffic: + - `time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds` stays + under 60 on every pod. + - `buzz_nip_fi_binding_snapshot_load_failures_total` is flat. + - `buzz_nip_fi_binding_snapshot_rows / buzz_nip_fi_binding_snapshot_max_rows` + is under 0.5. + - `buzz_nip_fi_resolver_miss_rejections_total` is flat. + - The non-`admit` share of `buzz_nip_fi_shadow_total` is only what you expect + to deny; `community="unmapped"` comes only from hosts you do not serve. +- No-go: any of these fails. Return to `off` and investigate. + +### Enforce + +Set `BUZZ_NIP_FI_MODE=enforce`. Watch the same signals, plus the rate of 503 +responses on login. + +- Go: the shadow signals keep holding, and 503s stay at their shadow baseline. +- No-go: logins return 503 or are refused unexpectedly. Set `mode=off` (below). + +### Alerts + +- Stale snapshot: `time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds > 120`, + together with `absent(buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds)` + for a database-source pod. +- Near the row cap: `buzz_nip_fi_binding_snapshot_rows / buzz_nip_fi_binding_snapshot_max_rows > 0.8`. + `buzz_nip_fi_binding_snapshot_rows` is the size of the last good load. +- Over the cap or too slow: any increase of + `buzz_nip_fi_binding_snapshot_load_failures_total{reason=~"over_cap|timeout"}`. +- Live lookups shed: any increase of `buzz_nip_fi_resolver_miss_rejections_total`. + +### When the snapshot is too big or too slow + +A load fails when the snapshot has more rows than +`BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` or takes longer than +`BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS`. Both get worse as communities are +added, so they usually arrive during normal growth, not at a deploy. + +- New pods: the startup load fails and the process exits, so new pods + crash-loop and a rollout stalls. Boot already fails when the database is + unreachable; what this source adds is that snapshot size and load time can + also fail it. +- Running pods: their refreshes fail but the last good snapshot keeps + answering. Once it is 120 s old it is stale, and every login's host goes to + a live lookup. Live lookups share `BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT` slots + (8 by default). Concurrent logins for the same host share one lookup, and a + login waits up to 50 ms for a free slot; how many are shed depends on login + load across distinct hosts and on lookup latency. In enforce, a shed login + gets 503 (`authorization_unavailable`); in shadow it + is only recorded as a would-deny. + +Ways out, in order: + +1. Raise `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` (up to 10,000,000) or + `BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS` (under 30,000), whichever the + failure `reason` names, and roll out. +2. If that cannot land quickly, set `BUZZ_NIP_FI_MODE=off`. `off` loads no + snapshot, so pods boot and admit as they did before NIP-FI. + +## Assertion policy ID change + +This release changes every verified assertion's policy ID once, on purpose. +The base ID (`derive_assertion_policy_id` in `crates/buzz-auth/src/nip_fi/config.rs`) +no longer hashes the configured community audiences. The ID carried on a +verified assertion is now `IssuerPolicy::id_for_community`: a hash of that base +and the one community audience the request resolved to. The issuer is still +committed through the base. + +Policy IDs from earlier releases therefore do not match. Inside the relay the ID +is only carried on the verified assertion and never compared with a stored ID, so +admission is unaffected. Anything outside the relay that recorded old policy IDs, +such as dashboards, log queries or allow-lists, should expect new values after +the upgrade. diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 13b5c73f7c..bd7961124d 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -248,6 +248,9 @@ run_unit_tests() { run_test_step "buzz-relay NIP-FI config tests" \ cargo test -p buzz-relay --lib nip_fi_config:: -- --nocapture + run_test_step "buzz-relay NIP-FI resolver tests" \ + cargo test -p buzz-relay --lib nip_fi_resolver:: -- --nocapture + # Infra-free REQ subscription-lifecycle tests, by exact name: the rest of # handlers::req needs a database. run_test_step "buzz-relay REQ subscription lifecycle tests" \ From 5730c83f7dd2ae409de5efe2d023e46695c78b3e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 19 Oct 2026 09:21:11 -0700 Subject: [PATCH 18/28] fix(release): keep mobile candidates pinned as branches advance (#8454) Mobile candidate publication currently fails if the source branch advances after selection, and the operator can report failure even after the correct immutable tag was created. It also prevents releasing an explicitly selected historical baseline. Add `--commit FULL_SHA` and independently verify in the operator and publisher that the selected commit belongs to the selected source branch's history. Compare immutable SHAs, keep the candidate pinned through publication, and verify the resulting annotated tag instead of requiring the branch to remain stationary. Stable-branch review/check protection and immutable-tag permissions remain enforced. Without `--commit`, selection still defaults to the current source tip. ### Validation - Updated operator and publisher contracts pass: historical main/stable selection, branch advancement during publication, unrelated or missing commits, ancestry lookup failure, and unchanged tag/sequence checks. - Removing ancestry enforcement causes the unrelated-history regression to fail. - Live read-only ancestry verification accepts the original 0.24 baseline. Local publication stops at the existing App-permission check; no tag or build was created. - Full local `just ci` passed. Signed-off-by: Tom Brow --- .../workflows/mobile-release-candidate.yml | 2 +- RELEASING.md | 26 +++++---- scripts/mobile-release-source.sh | 14 +++++ scripts/mobile-release.sh | 57 ++++++++++++------- scripts/publish-mobile-release-candidate.sh | 4 +- ...test-mobile-release-candidate-publisher.sh | 37 +++++++++++- scripts/test-mobile-release-contract.sh | 52 +++++++++++++---- 7 files changed, 143 insertions(+), 49 deletions(-) diff --git a/.github/workflows/mobile-release-candidate.yml b/.github/workflows/mobile-release-candidate.yml index 5f2ecf305a..131e8aab0b 100644 --- a/.github/workflows/mobile-release-candidate.yml +++ b/.github/workflows/mobile-release-candidate.yml @@ -18,7 +18,7 @@ on: default: main type: string target_sha: - description: Exact current source branch commit + description: Pinned commit in the selected source branch history required: true type: string diff --git a/RELEASING.md b/RELEASING.md index 0385dd9e4a..6e251a93d9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -135,11 +135,13 @@ release notes and require operators to upgrade before distributing these clients 1. **Publish a candidate.** From a clean checkout whose `origin` is the canonical `block/buzz` repository, run `scripts/mobile-release.sh candidate X.Y.Z`. The script resolves and fetches - the exact current `origin/main` commit, derives the next number from exact + a snapshot of `origin/main`, derives the next number from exact remote tags for that marketing version, and publishes an annotated `mobile-vX.Y.Z-rc.N` tag there through the dedicated `buzz-release-bot` GitHub App. It never uses the operator's checked-out commit and never moves - an existing candidate. + an existing candidate. To select a historical commit, add `--commit FULL_SHA`. + Both operator and publisher require that commit to be in the selected source + branch's history. Later branch advances do not change or invalidate the tag. 2. **Build the exact tag.** Enter the candidate tag as `mobile_ref` in the private Buzz mobile Buildkite pipeline. OSS CI deliberately cannot trigger that private pipeline. The tag supplies both source commit and release @@ -168,7 +170,9 @@ scripts/mobile-release.sh candidate 0.25.0 --source-branch mobile-v0.25 The branch must match the marketing version's major and minor components: `mobile-v0.25` accepts 0.25.x, but rejects 0.26.x and 1.25.x. Without the option, the source remains `main`. Both the operator and publisher validate the source; -a missing branch or changed tip fails rather than falling back to `main`. +a missing branch or a commit outside its history fails rather than falling back +to `main`. `--commit FULL_SHA` also works with a stable source branch, whose +review and check protections remain required. The GitHub workflow itself continues to run from reviewed `main`. The private pipeline still builds only the resulting immutable candidate tag. @@ -191,7 +195,8 @@ Never merge `main` wholesale. Remote agents and App Clip are post-conference work. There is no preselected fix list and no requirement to create a stable branch for every minor release. -The initial 0.25.0 candidate uses an empty commit above that baseline to verify +The initial 0.25.0 candidate pins that baseline with `--commit`. The second +candidate uses an empty commit above that baseline to verify that the existing pipeline builds a commit outside `main`. Verify identical source trees, absence of that commit from remote `main`, and the exact tag, commit, marketing version and build number in the uploaded artifacts. Upload @@ -394,14 +399,13 @@ option for a stable release. The command publishes a new immutable RC tag at the remote commit. Continue referring to each tested or shipped platform artifact by its own exact tag. -### `scripts/mobile-release.sh candidate` fails because the source branch moved during publication +### The source branch advances during candidate publication -The App-backed workflow may already have published the requested immutable RC -at the prior source branch tip before the operator command detects the race. Do not -move or delete that tag, and do not treat it as the candidate for the current -source branch. Inspect the run URL from the command output, then rerun -`scripts/mobile-release.sh candidate ` to publish the next RC from the -new current source branch tip, preserving the source-branch option. +Publication remains pinned to the selected SHA. The publisher verifies that it +is an ancestor of a resolved source-branch snapshot, then creates and verifies +an immutable annotated tag for that exact SHA. The operator verifies the tag, +not the branch's later tip. A newer commit does not require retrying or creating +another candidate. Missing or unverifiable ancestry still fails closed. ### A mobile candidate command selects the wrong RC number diff --git a/scripts/mobile-release-source.sh b/scripts/mobile-release-source.sh index 6420ae0d03..bc3321132a 100644 --- a/scripts/mobile-release-source.sh +++ b/scripts/mobile-release-source.sh @@ -42,3 +42,17 @@ require_mobile_release_branch_ruleset() { return 1 } } + +# Compare immutable SHAs, not a mutable branch name. The selected branch may +# advance after this snapshot without changing the authorized candidate source. +require_mobile_release_commit_ancestor() { + local target_sha="$1" source_sha="$2" merge_base + merge_base="$(gh api "repos/block/buzz/compare/$target_sha...$source_sha" --jq .merge_base_commit.sha)" || { + echo "Could not verify candidate source ancestry" >&2 + return 1 + } + [[ "$merge_base" == "$target_sha" ]] || { + echo "Candidate commit $target_sha is not in selected source history at $source_sha" >&2 + return 1 + } +} diff --git a/scripts/mobile-release.sh b/scripts/mobile-release.sh index cd81c50fd1..724c8337a5 100755 --- a/scripts/mobile-release.sh +++ b/scripts/mobile-release.sh @@ -4,10 +4,10 @@ set -euo pipefail usage() { cat >&2 <<'USAGE' usage: - scripts/mobile-release.sh candidate X.Y.Z [--source-branch mobile-vX.Y] + scripts/mobile-release.sh candidate X.Y.Z [--source-branch mobile-vX.Y] [--commit FULL_SHA] candidate Publish the next immutable mobile-vX.Y.Z-rc.N candidate tag at the - exact current remote source commit (main unless --source-branch is set). + selected commit in source history (defaults to the current source tip). USAGE exit 2 } @@ -94,16 +94,12 @@ remote_tag_commit_sha() { } remote_source_commit_sha() { - local ref="refs/heads/$MOBILE_RELEASE_SOURCE_BRANCH" line advertised_oid fetched_oid commit - line="$(git ls-remote --refs origin "$ref")" || return 1 - [[ -n "$line" && "$line" != *$'\n'* ]] || return 1 - advertised_oid="${line%%$'\t'*}" - git fetch -q --no-tags origin "$ref" - fetched_oid="$(git rev-parse --verify FETCH_HEAD)" - [[ "$fetched_oid" == "$advertised_oid" ]] || \ - fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH moved while it was being resolved" + local ref="refs/heads/$MOBILE_RELEASE_SOURCE_BRANCH" fetched_oid commit + # Fetch a single snapshot. A later branch advance does not invalidate it. + git fetch -q --no-tags origin "$ref" || return 1 + fetched_oid="$(git rev-parse --verify FETCH_HEAD)" || return 1 commit="$(git rev-parse --verify 'FETCH_HEAD^{commit}')" || return 1 - [[ "$commit" == "$advertised_oid" ]] || \ + [[ "$commit" == "$fetched_oid" ]] || \ fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH did not resolve directly to a commit" printf '%s' "$commit" } @@ -111,13 +107,31 @@ remote_source_commit_sha() { command="${1:-}" case "$command" in candidate) - [[ "$#" -eq 2 || "$#" -eq 4 ]] || usage + [[ "$#" -ge 2 ]] || usage version="$2" + shift 2 MOBILE_RELEASE_SOURCE_BRANCH=main - if [[ "$#" -eq 4 ]]; then - [[ "$3" == --source-branch ]] || usage - MOBILE_RELEASE_SOURCE_BRANCH="$4" - fi + requested_sha="" + source_option_seen=false + commit_option_seen=false + while [[ "$#" -gt 0 ]]; do + [[ "$#" -ge 2 ]] || usage + case "$1" in + --source-branch) + [[ "$source_option_seen" == false ]] || usage + source_option_seen=true + MOBILE_RELEASE_SOURCE_BRANCH="$2" + ;; + --commit) + [[ "$commit_option_seen" == false ]] || usage + commit_option_seen=true + requested_sha="$2" + [[ "$requested_sha" =~ ^[0-9a-f]{40}$ ]] || fail "--commit requires a full commit SHA" + ;; + *) usage ;; + esac + shift 2 + done require_clean_semver "$version" require_mobile_release_source "$version" "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1 require_clean_tree @@ -126,7 +140,9 @@ case "$command" in require_mobile_release_branch_ruleset "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1 local_head_sha="$(git rev-parse --verify 'HEAD^{commit}')" || fail "HEAD is not a commit" - source_sha="$(remote_source_commit_sha)" || fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH does not exist" + source_tip="$(remote_source_commit_sha)" || fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH does not exist" + source_sha="${requested_sha:-$source_tip}" + require_mobile_release_commit_ancestor "$source_sha" "$source_tip" || exit 1 next=1 if ! remote_tags="$(git ls-remote --refs --tags origin "refs/tags/mobile-v${version}-rc.*")"; then fail "could not list existing candidates for $version" @@ -161,17 +177,14 @@ case "$command" in gh run watch "$run_id" --repo block/buzz --exit-status --compact || \ fail "App-backed publication failed: $run_url" - current_source_sha="$(remote_source_commit_sha)" || fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH does not exist after publication" - [[ "$current_source_sha" == "$source_sha" ]] || \ - fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH moved from requested commit $source_sha to $current_source_sha during publication" published_sha="$(remote_tag_commit_sha "refs/tags/$tag")" || \ fail "publication completed without exact annotated candidate tag $tag" [[ "$published_sha" == "$source_sha" ]] || \ fail "$tag resolved to $published_sha instead of requested commit $source_sha" if [[ "$local_head_sha" != "$source_sha" ]]; then - echo "Note: local HEAD is $local_head_sha; candidate source is current origin/$MOBILE_RELEASE_SOURCE_BRANCH $source_sha." >&2 + echo "Note: local HEAD is $local_head_sha; candidate source is pinned from origin/$MOBILE_RELEASE_SOURCE_BRANCH $source_sha." >&2 fi - printf 'Published %s at origin/%s commit %s through buzz-release-bot. Use this exact tag in Release Mobile.\n' \ + printf 'Published %s at pinned origin/%s commit %s through buzz-release-bot. Use this exact tag in Release Mobile.\n' \ "$tag" "$MOBILE_RELEASE_SOURCE_BRANCH" "$source_sha" ;; diff --git a/scripts/publish-mobile-release-candidate.sh b/scripts/publish-mobile-release-candidate.sh index edeb9ad59b..5119611c67 100755 --- a/scripts/publish-mobile-release-candidate.sh +++ b/scripts/publish-mobile-release-candidate.sh @@ -31,12 +31,12 @@ require_mobile_release_branch_ruleset "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1 source_sha="$(gh api "repos/$repo/git/ref/heads/$MOBILE_RELEASE_SOURCE_BRANCH" --jq .object.sha)" || \ fail "could not resolve $repo $MOBILE_RELEASE_SOURCE_BRANCH" -[[ "$source_sha" == "$target_sha" ]] || \ - fail "$repo $MOBILE_RELEASE_SOURCE_BRANCH moved from requested commit $target_sha to $source_sha" +[[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || fail "GitHub returned an invalid source commit SHA" commit_sha="$(gh api "repos/$repo/commits/$target_sha" --jq .sha)" || \ fail "$target_sha is not a commit in $repo" [[ "$commit_sha" == "$target_sha" ]] || \ fail "GitHub resolved requested commit $target_sha as $commit_sha" +require_mobile_release_commit_ancestor "$target_sha" "$source_sha" || exit 1 refs="$( gh api --paginate "repos/$repo/git/matching-refs/tags/mobile-v${version}-rc." --jq '.[].ref' diff --git a/scripts/test-mobile-release-candidate-publisher.sh b/scripts/test-mobile-release-candidate-publisher.sh index 87156a451a..dbfda358b5 100755 --- a/scripts/test-mobile-release-candidate-publisher.sh +++ b/scripts/test-mobile-release-candidate-publisher.sh @@ -35,6 +35,21 @@ case "${1:-}:${2:-}" in api:repos/block/buzz/git/ref/heads/main) printf '%s\n' "$GH_TARGET_SHA" ;; api:repos/block/buzz/git/ref/heads/mobile-v1.2) printf '%s\n' "$GH_STABLE_SHA" ;; api:repos/block/buzz/git/ref/heads/*) printf '%s\n' "$GH_MISMATCH_SHA" ;; + api:repos/block/buzz/compare/*) + [[ "${GH_COMPARE_FAIL:-}" != 1 ]] || exit 1 + pair="${2##*/}" + base="${pair%%...*}"; tip="${pair##*...}" + if [[ -n "${GH_EXPECT_COMPARE:-}" ]]; then + [[ "$pair" == "$GH_EXPECT_COMPARE" ]] || exit 1 + fi + if [[ -n "${GH_COMPARE_BASE_SHA:-}" ]]; then + printf '%s\n' "$GH_COMPARE_BASE_SHA" + elif [[ "$base" == "$tip" ]]; then + printf '%s\n' "$base" + else + printf '%s\n' 9999999999999999999999999999999999999999 + fi + ;; api:repos/block/buzz/commits/*) printf '%s\n' "${2##*/}" ;; api:--paginate) [[ "$3" == "repos/block/buzz/git/matching-refs/tags/mobile-v1.2.3-rc." ]] @@ -116,8 +131,9 @@ if GH_TAG_EXCLUDES=refs/tags/mobile-v0.0.0 "$publisher" 1.2.3 1 "$GH_TARGET_SHA" exit 1 fi if GH_TARGET_SHA=3333333333333333333333333333333333333333 \ + GH_ANNOTATED_TARGET_SHA=1111111111111111111111111111111111111111 \ "$publisher" 1.2.3 1 1111111111111111111111111111111111111111 main >/dev/null 2>&1; then - echo "publisher accepted a moved main branch" >&2 + echo "publisher accepted a commit outside main history" >&2 exit 1 fi if GH_EXISTING_REFS=$'refs/tags/mobile-v1.2.3-rc.1\n' \ @@ -164,6 +180,25 @@ if GITHUB_REPOSITORY=attacker/buzz "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/ fi GH_ANNOTATED_TARGET_SHA="$GH_STABLE_SHA" "$publisher" 1.2.3 1 "$GH_STABLE_SHA" mobile-v1.2 +# Old commits are accepted only when the immutable compare proves ancestry. +old_sha=6666666666666666666666666666666666666666 +for source in main mobile-v1.2; do + tip="$GH_TARGET_SHA" + [[ "$source" != mobile-v1.2 ]] || tip="$GH_STABLE_SHA" + GH_COMPARE_BASE_SHA="$old_sha" GH_EXPECT_COMPARE="$old_sha...$tip" GH_ANNOTATED_TARGET_SHA="$old_sha" \ + "$publisher" 1.2.3 1 "$old_sha" "$source" + before_calls="$(wc -l < "$GH_CALLS")" + for mode in unrelated unavailable; do + if GH_COMPARE_FAIL="$([[ "$mode" == unavailable ]] && echo 1 || echo 0)" \ + GH_COMPARE_BASE_SHA="$GH_MISMATCH_SHA" GH_ANNOTATED_TARGET_SHA="$old_sha" \ + "$publisher" 1.2.3 1 "$old_sha" "$source" >/dev/null 2>&1; then + echo "publisher accepted $mode ancestry for $source" >&2 + exit 1 + fi + done + [[ "$(wc -l < "$GH_CALLS")" == "$before_calls" ]] +done + for source in mobile-v1.3 mobile-v2.2 mobile-v01.2 arbitrary ''; do if GH_ANNOTATED_TARGET_SHA="$GH_MISMATCH_SHA" "$publisher" 1.2.3 1 "$GH_MISMATCH_SHA" "$source" >/dev/null 2>&1; then echo "publisher accepted mismatched source '$source'" >&2 diff --git a/scripts/test-mobile-release-contract.sh b/scripts/test-mobile-release-contract.sh index ca6b79b9e1..395060e4bd 100755 --- a/scripts/test-mobile-release-contract.sh +++ b/scripts/test-mobile-release-contract.sh @@ -32,7 +32,13 @@ case "${1:-}:${2:-}" in *) exit 2 ;; esac ;; + api:repos/block/buzz/compare/*) + [[ "${GH_COMPARE_FAIL:-}" != 1 ]] || exit 1 + pair="${2##*/}" + git --git-dir="$GH_REMOTE" merge-base "${pair%%...*}" "${pair##*...}" + ;; workflow:run) + printf '%s\n' attempted >> "$GH_WORKFLOW_ATTEMPTS" if [[ "${GH_WORKFLOW_DISPATCH_FAIL:-}" == "1" ]]; then printf '%s\n' "${GH_WORKFLOW_DISPATCH_ERROR:-dispatch failed}" >&2 exit 1 @@ -58,7 +64,7 @@ case "${1:-}:${2:-}" in shift done [[ -n "$version" && -n "$number" && -n "$sha" && -n "$source_branch" ]] || exit 1 - [[ "$(git --git-dir="$GH_REMOTE" rev-parse "refs/heads/$source_branch")" == "$sha" ]] || exit 1 + git --git-dir="$GH_REMOTE" merge-base --is-ancestor "$sha" "refs/heads/$source_branch" || exit 1 printf '%s\t%s\t%s\n' "$version" "$number" "$sha" >> "$GH_WORKFLOW_CAPTURE" if [[ "${GH_WORKFLOW_NO_URL:-}" == "1" ]]; then exit 0 @@ -93,6 +99,7 @@ chmod +x "$bin/gh" export PATH="$bin:$PATH" export GH_WORKFLOW_CAPTURE="$tmp/workflow-dispatches" +export GH_WORKFLOW_ATTEMPTS="$tmp/workflow-attempts" export GH_WORKTREE="$work" export GH_REMOTE="$remote" @@ -150,7 +157,7 @@ if git -C "$operator" cat-file -e "$remote_main_sha^{commit}" 2>/dev/null; then fail "stale-clone fixture already contains the remote-only commit" fi run_release "$operator" candidate 1.2.3 > "$tmp/stale-output" 2> "$tmp/stale-error" -grep -Fq "Note: local HEAD is $(git -C "$operator" rev-parse HEAD); candidate source is current origin/main $remote_main_sha." \ +grep -Fq "Note: local HEAD is $(git -C "$operator" rev-parse HEAD); candidate source is pinned from origin/main $remote_main_sha." \ "$tmp/stale-error" cat "$tmp/stale-output" [[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.3-rc.1^{commit}')" == \ @@ -262,19 +269,40 @@ if GH_TAG_VERIFY_LIGHTWEIGHT=1 run_release "$operator" candidate 9.9.2 >/dev/nul fi [[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v9.9.2-rc.1)" == commit ]] -# The publisher and operator both reject a main-tip race. The immutable tag may -# already exist at the prior tip, but the operator must not report it as a -# current-main candidate. +# Advancing main during publication must not invalidate the pinned tag. pre_race_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)" -if GH_MAIN_MOVE_DURING_WORKFLOW=1 run_release "$operator" candidate 9.9.3 > "$tmp/main-race-output" 2>&1; then - fail "candidate succeeded after main moved during publication" -fi +GH_MAIN_MOVE_DURING_WORKFLOW=1 run_release "$operator" candidate 9.9.3 > "$tmp/main-race-output" 2>&1 post_race_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)" [[ "$pre_race_main_sha" != "$post_race_main_sha" ]] -[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v9.9.3-rc.1^{commit}')" == \ - "$pre_race_main_sha" ]] -grep -Fq "origin/main moved from requested commit $pre_race_main_sha to $post_race_main_sha during publication" \ - "$tmp/main-race-output" +[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v9.9.3-rc.1^{commit}')" == "$pre_race_main_sha" ]] +grep -Fq 'Published mobile-v9.9.3-rc.1' "$tmp/main-race-output" + +# Explicit historical source remains pinned even when the source branch advances. +run_release "$operator" candidate 7.7.0 --commit "$remote_main_sha" +[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v7.7.0-rc.1^{commit}')" == "$remote_main_sha" ]] +run_release "$operator" candidate 1.2.5 --commit "$remote_main_sha" --source-branch mobile-v1.2 +[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.5-rc.1^{commit}')" == "$remote_main_sha" ]] +before_attempts="$(wc -l < "$GH_WORKFLOW_ATTEMPTS")" +before_dispatches="$(wc -l < "$GH_WORKFLOW_CAPTURE")" +if run_release "$operator" candidate 7.7.1 --commit "$stable_sha" >/dev/null 2>&1; then + fail "accepted a commit outside main history" +fi +if run_release "$operator" candidate 1.2.6 --source-branch mobile-v1.2 --commit "$new_main_sha" >/dev/null 2>&1; then + fail "accepted a main-only commit outside stable history" +fi +for invalid_sha in main HEAD deadbeef 9999999999999999999999999999999999999999 ''; do + if run_release "$operator" candidate 7.7.1 --commit "$invalid_sha" >/dev/null 2>&1; then + fail "accepted invalid or absent commit '$invalid_sha'" + fi +done +if GH_COMPARE_FAIL=1 run_release "$operator" candidate 7.7.1 --commit "$remote_main_sha" >/dev/null 2>&1; then + fail "accepted unavailable ancestry evidence" +fi +if run_release "$operator" candidate 7.7.1 --commit "$remote_main_sha" --commit "$new_main_sha" >/dev/null 2>&1; then + fail "accepted duplicate commit options" +fi +[[ "$(wc -l < "$GH_WORKFLOW_ATTEMPTS")" == "$before_attempts" ]] +[[ "$(wc -l < "$GH_WORKFLOW_CAPTURE")" == "$before_dispatches" ]] # Publishing through a fork is rejected and unsupported gh versions fail before # dispatching any candidate publication. From dc4cdc4990877d400a435b481a416a73f17247bb Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 19 Oct 2026 10:09:25 -0700 Subject: [PATCH 19/28] fix(mobile): reset community notification preferences (#8455) Community notification choices now use `pushNotificationsEnabledV2`, defaulting ON when absent. This gives every installed community a fresh enabled preference, including communities previously disabled. Subsequent explicit OFF choices persist per community through reloads and reconnects. Previous values are deliberately discarded, not migrated or preserved. There is no migration or compatibility read. Enabled communities enter the existing APNs enrollment and active relay lease publication path when connected to a push-capable relay. iOS still controls notification display authorization. Notification eligibility and lease addressing are unchanged. Related work: [#8425](https://github.com/block/buzz/pull/8425). ### Validation - Extended the settings integration test to load a saved community with the previous preference OFF and no new preference and verify native enrollment plus decrypted active lease publication, then OFF across reconnect and ON again. - The focused model test proves both previous values are ignored and new-key OFF wins even when the previous key says ON. A temporary compatibility-fallback mutation failed this test as expected; the implementation was restored unchanged. - Added storage coverage for independent community choices across reloads; updated existing model round-trip coverage for the new key. - Mobile dependency setup, formatting, analysis, and the full mobile suite passed, including gateway-unconfigured coverage. - Agent review found no blockers. Enrollment coverage uses the real Flutter bootstrap, storage, signing, and lease construction with mocked native and relay boundaries; no physical-device or live APNs delivery test was run. The fresh enabled preference is an intentional product exception to the general zero-notification default in VISION.md. Subsequent user choices and existing eligibility filters remain authoritative. --------- Signed-off-by: Tom Brow --- mobile/lib/shared/community/community.dart | 6 ++-- .../settings/notification_toggle_test.dart | 32 ++++++++++++++++--- .../community/community_storage_test.dart | 27 ++++++++++++++++ .../test/shared/community/community_test.dart | 16 +++++++++- 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 426a34198b..96c2b5f7a2 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -21,6 +21,8 @@ enum SensitiveActionPolicy { enabled, disabledByUser } class Community { static const _defaultPushNotificationsEnabled = true; + static const _pushNotificationsEnabledKey = 'pushNotificationsEnabledV2'; + final String id; final String name; final String relayUrl; @@ -115,7 +117,7 @@ class Community { if (pubkey != null) 'pubkey': pubkey, if (nsec != null) 'nsec': nsec, 'sensitiveActionPolicy': sensitiveActionPolicy.name, - 'pushNotificationsEnabled': pushNotificationsEnabled, + _pushNotificationsEnabledKey: pushNotificationsEnabled, 'pushSubscriptionState': pushSubscriptionState.toJson(), if (pushLeaseInstallationId != null) 'pushLeaseInstallationId': pushLeaseInstallationId, @@ -132,7 +134,7 @@ class Community { ); } final pushNotificationsEnabled = - json['pushNotificationsEnabled'] as bool? ?? + json[_pushNotificationsEnabledKey] as bool? ?? _defaultPushNotificationsEnabled; var pushSubscriptionState = json['pushSubscriptionState'] == null ? const BuzzPushLeaseSubscriptionState.desired() diff --git a/mobile/test/features/settings/notification_toggle_test.dart b/mobile/test/features/settings/notification_toggle_test.dart index 19c80e2522..84f78e536f 100644 --- a/mobile/test/features/settings/notification_toggle_test.dart +++ b/mobile/test/features/settings/notification_toggle_test.dart @@ -37,7 +37,13 @@ void main() { ..addFont(rootBundle.load('assets/fonts/InterVariable.ttf')); await fonts.load(); }); - for (final outcome in ['accepted', 'failed', 'pending', 'scaled']) { + for (final outcome in [ + 'accepted', + 'failed', + 'pending', + 'scaled', + 'unset preference', + ]) { testWidgets('off then on remains usable with $outcome cleanup', ( tester, ) async { @@ -61,7 +67,8 @@ void main() { ); SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final storage = CommunityStorage(secure: FakeSecureStorage()); + final secure = FakeSecureStorage(); + final storage = CommunityStorage(secure: secure); final subscriptions = [ BuzzPushSubscription( filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), @@ -110,7 +117,13 @@ void main() { desired: subscriptions, ).withAccepted(subscriptions: subscriptions, generation: 7), ); - await storage.save(community); + if (outcome == 'unset preference') { + final saved = community.toJson()..remove('pushNotificationsEnabledV2'); + saved['pushNotificationsEnabled'] = false; + secure['buzz_communities'] = jsonEncode([saved]); + } else { + await storage.save(community); + } await storage.saveActiveId(community.id); final cleanup = Completer(); @@ -236,6 +249,15 @@ void main() { reason: 'failed cleanup must not defeat the retry backoff', ); expect(discoveries, 2); + session.setStatus(SessionStatus.disconnected); + await tester.pumpAndSettle(); + session.setStatus(SessionStatus.connected); + await tester.pumpAndSettle(); + expect( + session.leases.where((lease) => lease['active'] == true), + hasLength(1), + ); + expect(discoveries, 3); expect(snapshots.last.single.pushNotificationsEnabled, isFalse); final offSwitch = tester.widget(find.byType(Switch)); expect(offSwitch.value, isFalse); @@ -273,7 +295,7 @@ void main() { isTrue, reason: 'a delayed old tombstone cannot supersede the new active lease', ); - expect(discoveries, 3); + expect(discoveries, 4); // A real reconnect must still discover capability again. (container.read(relaySessionProvider.notifier) as _Connected).setStatus( SessionStatus.disconnected, @@ -284,7 +306,7 @@ void main() { SessionStatus.connected, ); await tester.pumpAndSettle(); - expect(discoveries, 4); + expect(discoveries, 5); expect(tester.widget(find.byType(Switch)).onChanged, isNotNull); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); diff --git a/mobile/test/shared/community/community_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart index 93874aef25..be9c404dbf 100644 --- a/mobile/test/shared/community/community_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -127,6 +127,33 @@ void main() { ); }); + test('push choices persist independently across storage reloads', () async { + final first = Community.create( + name: 'First', + relayUrl: 'https://first.example', + pubkey: 'a' * 64, + ); + final second = Community.create( + name: 'Second', + relayUrl: 'https://second.example', + pubkey: 'b' * 64, + ); + await storage.saveAll([first, second]); + await storage.save(first.copyWith(pushNotificationsEnabled: false)); + + final reloaded = CommunityStorage(secure: fakeSecure); + final communities = await reloaded.loadAll(); + expect(communities.map((c) => c.pushNotificationsEnabled), [false, true]); + expect(communities.map((c) => c.pubkey), [first.pubkey, second.pubkey]); + await reloaded.save(communities.last.copyWith(name: 'Renamed')); + expect( + (await CommunityStorage( + secure: fakeSecure, + ).loadAll()).map((c) => c.pushNotificationsEnabled), + [false, true], + ); + }); + test('round-trips desired push subscription state', () async { final pubkey = 'a' * 64; final subscriptions = buildDesiredBuzzPushSubscriptions( diff --git a/mobile/test/shared/community/community_test.dart b/mobile/test/shared/community/community_test.dart index a5304f643e..93c9881849 100644 --- a/mobile/test/shared/community/community_test.dart +++ b/mobile/test/shared/community/community_test.dart @@ -59,7 +59,8 @@ void main() { 'id': 'one', 'name': 'Buzz', 'relayUrl': 'https://relay.test', - 'pushNotificationsEnabled': enabled, + 'pushNotificationsEnabledV2': enabled, + 'pushNotificationsEnabled': !enabled, 'addedAt': '2026-08-05T00:00:00.000Z', }); expect(community.pushNotificationsEnabled, enabled); @@ -78,6 +79,19 @@ void main() { }); } + test('previous preference values are intentionally not preserved', () { + for (final previous in [false, true]) { + final saved = Community.create( + name: 'Team', + relayUrl: 'https://relay.test', + ).toJson()..remove('pushNotificationsEnabledV2'); + saved['pushNotificationsEnabled'] = previous; + final community = Community.fromJson(saved); + expect(community.pushNotificationsEnabled, isTrue); + expect(community.toJson(), isNot(contains('pushNotificationsEnabled'))); + } + }); + test('malformed stored push lease address id is rejected', () { expect( () => Community.fromJson({ From af7979e1ca0c40af2ba4a8d05edad6bb5796c584 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 19 Oct 2026 13:37:12 -0400 Subject: [PATCH 20/28] test(auth): read the clock once in NIP-FI until test (#8457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 The exact NIP-FI assertion-lifetime boundary test could fail when its JWT helper read the clock, then ES256 signing crossed a whole-second boundary. The token's `until` claim could land one second beyond the verifier's `now + skew + maximum_age` ceiling even though both were intended to use the same test instant. This changes the test helper to accept a captured instant and uses that instant for both the JWT time claims and `verify_at`. The boundary remains strict: `now + 3630s` is accepted and `now + 3631s` is rejected. No production code changes. Signed-off-by: Will Pfleger Signed-off-by: Alia Co-authored-by: Alia --- crates/buzz-auth/src/nip_fi/command.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/buzz-auth/src/nip_fi/command.rs b/crates/buzz-auth/src/nip_fi/command.rs index deeae99e75..4b5da49d48 100644 --- a/crates/buzz-auth/src/nip_fi/command.rs +++ b/crates/buzz-auth/src/nip_fi/command.rs @@ -948,7 +948,17 @@ mod tests { until_offset_secs: i64, overrides: serde_json::Value, ) -> String { - let now = now_ts(); + mint_cmd_jwt_at(target, Utc::now(), until_offset_secs, overrides) + } + + /// Mint a command JWT using the supplied instant for all time claims. + fn mint_cmd_jwt_at( + target: &nostr::PublicKey, + now: DateTime, + until_offset_secs: i64, + overrides: serde_json::Value, + ) -> String { + let now = now.timestamp(); let mut claims = serde_json::json!({ "iss": ISS, "aud": AUD, @@ -1225,14 +1235,14 @@ mod tests { let target = target_key(); let now = Utc::now(); for until_offset in [3600, 30 + 3600, 1] { - let token = mint_cmd_jwt(&target, until_offset, serde_json::json!({})); + let token = mint_cmd_jwt_at(&target, now, until_offset, serde_json::json!({})); assert_eq!( cv.verify_at(&token, METHOD, PATH, &target, now).map(|_| ()), Ok(()), "until = now + {until_offset}s must fit the ceiling" ); } - let token = mint_cmd_jwt(&target, 30 + 3600 + 1, serde_json::json!({})); + let token = mint_cmd_jwt_at(&target, now, 30 + 3600 + 1, serde_json::json!({})); assert_eq!( cv.verify_at(&token, METHOD, PATH, &target, now).map(|_| ()), Err(CommandError::UntilExceedsCeiling), From e73ba22e15807c2a39b0990a9a316d231156a879 Mon Sep 17 00:00:00 2001 From: Diem Nguyen Date: Mon, 19 Oct 2026 12:55:52 -0700 Subject: [PATCH 21/28] fix(desktop): release the live tail after a single scroll back to bottom (#8465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This fixes the Desktop Smoke E2E failure that turned `main` red at af7979e ([run](https://github.com/block/buzz/actions/runs/39295686771)). The failing test was `video-attachment.spec.ts` "playback speed persists across videos and reloads". The cause is a real timeline bug, not a problem in the test. When the reader leaves the bottom, `MessageTimeline` freezes the live tail and sets a one-shot flag. The flag tells it to ignore the next "at bottom" report, because freezing the tail can shorten Virtua's model and produce a false one. If nothing is buffered, the freeze doesn't change the model, so that false report never arrives. The flag then stays set and swallows the reader's real return to the bottom. If the reader gets back to the bottom in one scroll event, the tail stays frozen and new messages wait behind a "1 new message" pill even though the reader is already at the bottom. A scrollbar jump, the End key, or Playwright's scroll-into-view retry all return in one event. In CI, the Play click was intercepted, so Playwright retried it with different scroll alignments. That moved the timeline away from the bottom (scrollTop 430 → 289 → 430, seen in the run's trace), and the second video message was buffered and never rendered. Fix: once the freeze commits with nothing buffered, clear the flag. If the reader is already back at the bottom, release the tail. ### Related issue None found. No open PR touches this. ### Testing - New smoke test `virtualization.spec.ts` "a single scroll back to the bottom releases the live tail": failed 10/10 before the fix, passed 10/10 after (`--repeat-each=10`). - `video-attachment.spec.ts` "playback speed persists…": 20/20 passed with `--repeat-each=20`. - Smoke runs of `virtualization`, `scroll-history`, `video-attachment`, `messaging`, `smoke`, `file-attachment` and `thread-follow`: 188 passed. 3 failed with a blank page before the first click, and all 3 passed on re-run. Unmodified `main` shows the same failure locally, so it's a local preview-server flake and unrelated to this change. - `pnpm typecheck`, `pnpm check`, and `pnpm test` (6829 + 160 tests) all pass. Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz> Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz> --- .../features/messages/ui/MessageTimeline.tsx | 15 ++++++ desktop/tests/e2e/virtualization.spec.ts | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index b293be252a..cecb291883 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -432,6 +432,21 @@ const MessageTimelineBase = React.forwardRef< }, [onVirtualizerAtBottomStateChange, queueSemanticBottom], ); + const hasPendingLiveRows = bufferedTimeline.pendingCount > 0; + React.useEffect(() => { + // With nothing buffered, the freeze is a model no-op, so the synthetic + // bottom report the suppression waits for never arrives. Disarm it, and + // release if the reader already came back, so a single scroll back to + // the floor (scrollbar jump, End key) does not strand the tail frozen. + if (isSemanticallyAtBottom || hasPendingLiveRows) return; + suppressNextSemanticBottomRef.current = false; + if (isAtBottom) queueSemanticBottom(true); + }, [ + hasPendingLiveRows, + isAtBottom, + isSemanticallyAtBottom, + queueSemanticBottom, + ]); const timelineIntroSurface = showTimelineError ? null diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index adc23fdb84..463096ce7b 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -926,3 +926,52 @@ test("live tail arrivals stay buffered while reading and release on jump", async await page.getByTestId("message-scroll-to-latest").click(); await expect(page.getByText("buffered live tail sentinel")).toBeVisible(); }); + +test("a single scroll back to the bottom releases the live tail", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + await page.evaluate(() => { + for (let index = 0; index < 20; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: `single-return seed ${index}\nsecond line ${index}`, + createdAt: 1_700_000_000 + index, + }); + } + }); + await page.getByTestId("channel-general").click(); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline).toContainText("single-return seed 19"); + const distanceFromBottom = () => + timeline.evaluate( + (element) => + element.scrollHeight - element.clientHeight - element.scrollTop, + ); + await expect.poll(distanceFromBottom).toBeLessThanOrEqual(1); + + // Leave the bottom, then come back in one native scroll event, as a + // scrollbar jump, End key, or Playwright's scroll-into-view retry does. + await timeline.evaluate((element) => { + element.scrollTop -= 150; + }); + await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible(); + await timeline.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect(page.getByTestId("message-scroll-to-latest")).toHaveCount(0); + + await page.evaluate(() => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: "single-return live sentinel", + createdAt: 1_800_000_000, + }); + }); + await expect(page.getByText("single-return live sentinel")).toBeVisible(); + await expect(page.getByTestId("message-scroll-to-latest")).toHaveCount(0); +}); From 91e7b469b82b235c62885f82a6323b7e666e9254 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 19 Oct 2026 16:13:27 -0400 Subject: [PATCH 22/28] fix(relay): wait for the peer's Close before dropping any WebSocket (#8426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 A NIP-42 login refused for a NIP-FI key mismatch could reach the client as an abnormal close (`1006`, no reason) instead of the `NOTICE "restricted: authorization denied"` followed by close `1008` that every other authorization denial produces. The relay did write the NOTICE and the `1008` Close, but the connection task dropped TCP as soon as the writer finished, before the peer finished the close handshake. The client could then lose frames it hadn't read yet. It depends on timing, so it showed up intermittently and is not specific to key pairing. ## Relay route - `recv_loop` now hands back the read half even while a frame handler is still running. Frames are handled one at a time while the connection is live. Once it is cancelled, an in-flight handler (for example a Nostr `CLOSE` waiting on the subscription lock behind a REQ stuck in Redis) is spawned to finish instead of holding the reader. It is never dropped partway, so subscription retirement stays atomic, and cleanup joins it before the expiry barrier and application cleanup. - As soon as the writer finishes, `await_client_close` keeps the transport open until the client's Close arrives or the stream ends. It runs concurrently with joining the heartbeat, auth-timeout, expiry and auth-cancel tasks and the connection's application cleanup (subscriptions released, registry entry removed, presence cleared). Cleanup never waits on the peer, so a nonresponsive client cannot delay registry counts, presence or `has_admitted_connection`. The socket never waits on cleanup, so a stalled Redis (the pool has no wait timeout) or an expiry join blocked on its quiescence barrier cannot hold the socket and its file descriptor open past the bounded wait. The active-connection gauge and the connection permit are released after both finish. - The connection permit (`BUZZ_MAX_CONNECTIONS`) is now taken at the start of `handle_connection`, before the NIP-FI gate and the community-active bootstrap check, and held through the active handler or the refusal drain and its bounded wait. Previously a socket in bootstrap, or refused there, held no permit, so a burst of refusals could open sockets beyond the limit. Over the limit, the socket is still dropped with no frames. `buzz_ws_connections_active` still counts only connections that reached the active handler. ## Huddle-audio route - The relay and audio bootstrap drains share one bounded helper. A socket refused before admission waits after its community registry entry is released. - Every exit of the audio handler gets the bounded peer wait. Exits whose cleanup follows their terminal frames (`room_full`, `room_ended`, version mismatch, archived channel, owner rejection or unreachable, failed commit confirmation) and the normal teardown hand the socket to a background task that waits for the client's Close, so lease releases, `48102` writes and room archiving on Postgres and Redis never hold the socket open. The remaining exits send their frames last, and `handle_active_audio_connection` waits once they return. The wrapper joins the background task before returning, so the audio connection permit is held until the socket ends, as on the relay route. - Audio error exits now follow their existing JSON error with a Close frame: | Exit | Close code | | --- | --- | | `huddle_relay_draining` | `1012` service restart | | `join_rejected` (owner resolution failed), `huddle_owner_unreachable`, failed channel lookup | `1011` internal error | | `huddle_audio_unavailable`, `unsupported_version`, `upgrade_required`, `room_full`, `room_ended`, archived channel, owner rejection, NIP-FI authorization denial | `1008` policy | Audio login failures and authorization refusals outside NIP-FI `authorization_denied` still send only their existing error frame, then get the bounded wait. - The audio read half is an enum, so the hand-off has no `expect()` that can panic. ## Timing - The peer wait is bounded by `WS_TERMINAL_FLUSH_TIMEOUT` (1s), and drains to end-of-stream so a client-initiated Close gets its reply. The terminal writer flush has its own 1s budget, so the worst case for terminal transport work is about 2s per refused or closed socket. - Terminal frame writes (`flush_terminal_frames`, `send_exit_frames_bounded`) still give up on the first failure, but a failed or timed-out send is now logged at `debug` instead of being discarded. - The graceful-shutdown comment in `main.rs` now describes per-stage budgets (restart jitter, restart-Close acknowledgement, terminal flush, peer wait) rather than a per-socket total. The 30s hard drain bounds every socket, and the 35s process shutdown budget fits within the chart's 60s `terminationGracePeriodSeconds`. ## Tests Real-socket tests in the infra-free relay lane of `just test-unit` and `scripts/run-tests.sh` unless marked PostgreSQL: - The key-mismatch login and a session-expiry denial (as a control) both assert NOTICE then `1008` / `authorization denied` on the wire. - A raw-TCP client that never answers Close: the relay holds the socket for at least the bounded wait, has deregistered the connection by the time the socket ends, and ends it on its own under a 10s watchdog. - A raw-TCP client that withholds its Close until the connection has deregistered: the relay ends the socket in under the 1s cap, which fails if cleanup waits on the peer. - A raw-TCP client that never answers while final cleanup is stalled at a test pause point: the socket still ends after the bounded wait. - A raw-TCP client that never answers Close on the relay bootstrap drain, the audio bootstrap drain and an early audio-handler exit: the relay holds each socket for the bounded wait. - On the relay route with a one-slot budget through the real `handle_connection`: a refused bootstrap holds the slot until its socket ends, and a bootstrap stalled on the community check already holds the slot before the AUTH challenge is sent. - An established relay session whose reader is blocked in a Nostr `CLOSE` handler on a held subscription lock when the session expires: the socket ends within the bounded wait while the lock is held, and cleanup completes with the subscription retired once it is released. - An established relay session whose expiry task is stalled behind a held session effect permit: the socket still ends within the bounded wait. - PostgreSQL: an established audio session whose teardown is stalled after its Close, and a stalled archived-channel exit, both end within the bounded wait from the hand-off. - PostgreSQL: a fast refusal (unsupported protocol version) through the real `handle_audio_connection` with a one-slot permit budget keeps the permit taken until that socket ends. - PostgreSQL: `huddle_audio_unavailable` sends its error and then Close `1008` as the very next frame. --------- Signed-off-by: Will Pfleger Signed-off-by: Duncan Co-authored-by: Duncan --- Cargo.lock | 1 + Justfile | 11 + crates/buzz-relay/Cargo.toml | 3 + crates/buzz-relay/src/audio/handler.rs | 768 ++++++++++++++-- crates/buzz-relay/src/connection.rs | 967 +++++++++++++++++++-- crates/buzz-relay/src/handlers/close.rs | 6 +- crates/buzz-relay/src/main.rs | 38 +- crates/buzz-relay/src/nip_fi_test_hooks.rs | 13 + scripts/run-tests.sh | 11 + 9 files changed, 1645 insertions(+), 173 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d266b8580a..38ad23b30b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1489,6 +1489,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "tungstenite 0.29.0", "url", "uuid", ] diff --git a/Justfile b/Justfile index e479c467f4..59e98d9c11 100644 --- a/Justfile +++ b/Justfile @@ -577,6 +577,17 @@ test-unit: + test(=connection::tests::expiry_notice_queued_on_ctrl_before_cancel) + test(=connection::tests::f3_root_outer_wrapper_delivers_denial_on_bootstrap_cancellation) + test(=connection::tests::f3_root_pre_built_expired_gate_terminates_connection) + + test(=connection::terminal_close_delivery_tests::key_pairing_mismatch_closes_with_1008_on_real_socket) + + test(=connection::terminal_close_delivery_tests::session_expiry_denial_closes_with_1008_on_real_socket) + + test(=connection::terminal_close_delivery_tests::server_holds_socket_for_client_close_reply) + + test(=connection::terminal_close_delivery_tests::server_finishes_promptly_when_client_answers_close) + + test(=connection::terminal_close_delivery_tests::stalled_cleanup_does_not_hold_socket_open) + + test(=connection::terminal_close_delivery_tests::bootstrap_drain_holds_socket_for_client_close_reply) + + test(=connection::terminal_close_delivery_tests::stalled_expiry_barrier_does_not_hold_socket_open) + + test(=connection::terminal_close_delivery_tests::blocked_frame_handler_does_not_hold_socket_open) + + test(=connection::terminal_close_delivery_tests::refused_bootstrap_holds_connection_slot_until_socket_ends) + + test(=connection::terminal_close_delivery_tests::stalled_bootstrap_holds_connection_slot) + + test(=connection::terminal_close_delivery_tests::peer_wait_replies_to_client_initiated_close) + test(=handlers::auth::tests::b2_pre_cancelled_connection_never_becomes_authenticated) + test(=handlers::auth::tests::fi_ban_check_error_emits_terminal_authorization_unavailable) + test(=handlers::auth::tests::fi_root_authorization_denied_rows_emit_identical_frames) diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 6a4ab379fd..ef0808ef65 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -30,6 +30,9 @@ buzz-relay-mesh = { workspace = true } async-trait = "0.1" postcard = { workspace = true } axum = { workspace = true } +# Axum's WebSocket errors wrap tungstenite's; matching them tells a close +# handshake already under way apart from a failed terminal-frame write. +tungstenite = { version = "0.29", default-features = false } tokio = { workspace = true } # `io` (ReaderStream) is needed to stream git subprocess stdout straight into # the HTTP response body — see api::git::transport read-path streaming. The diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 1a8b251ea2..3a4d2fcde1 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -294,6 +294,11 @@ pub(crate) async fn handle_audio_connection( let rx_for_run = Arc::clone(&rx_shared); let rx_for_drain = Arc::clone(&rx_shared); + // The drain path parks the read half here so the bounded wait for the + // client's Close runs after the registry entry is released. + let unread_shared = Arc::new(tokio::sync::Mutex::new(None)); + let unread_for_drain = Arc::clone(&unread_shared); + run_registered_community_connection( ®istry, Uuid::new_v4(), @@ -330,37 +335,21 @@ pub(crate) async fn handle_audio_connection( }, move || async move { let socket = socket_for_drain.lock().await.take(); - let mut pre_terminal_ctrl_rx = rx_for_drain.lock().await.take(); + let queued = rx_for_drain.lock().await.take(); if let Some(socket) = socket { - let (mut ws_send, _ws_recv) = socket.split(); - if let Some(ref mut rx) = pre_terminal_ctrl_rx { - while let Ok(msg) = rx.try_recv() { - let _ = tokio::time::timeout( - crate::connection::WS_TERMINAL_FLUSH_TIMEOUT, - futures_util::SinkExt::send(&mut ws_send, msg), - ) - .await; - } - } // A published reason (e.g. an expiry denial) closes with its // own frame, exactly as the writer would. let close = drain_reason.borrow().map(|reason| reason.close_message()); - if let Some(close) = close { - let _ = tokio::time::timeout( - crate::connection::WS_TERMINAL_FLUSH_TIMEOUT, - futures_util::SinkExt::send(&mut ws_send, close), - ) - .await; - } - let _ = tokio::time::timeout( - crate::connection::WS_TERMINAL_FLUSH_TIMEOUT, - futures_util::SinkExt::close(&mut ws_send), - ) - .await; + let ws_recv = crate::connection::drain_refused_socket(socket, queued, close).await; + *unread_for_drain.lock().await = Some(ws_recv); } }, ) .await; + let unread = unread_shared.lock().await.take(); + if let Some(ws_recv) = unread { + crate::connection::await_client_close(ws_recv).await; + } } /// Mid-admission cancellation fence for the audio route: if `$cancel` fired, @@ -411,6 +400,7 @@ async fn send_challenge_unless_cancelled( ) -> bool where S: futures_util::Sink + Unpin, + S::Error: std::error::Error + 'static, { tokio::select! { biased; @@ -438,6 +428,10 @@ pub(crate) fn audio_post_auth_register( control.set_proven_identity(pubkey_bytes, nip_fi_issuer); } +/// Runs the audio session, then holds the read half for the client's Close +/// reply, bounded by `WS_TERMINAL_FLUSH_TIMEOUT`. An exit whose cleanup +/// follows its terminal frames hands the socket off first through +/// [`hand_off_socket`], so that cleanup never holds the socket open. #[allow(clippy::too_many_arguments)] pub(crate) async fn handle_active_audio_connection( socket: WebSocket, @@ -451,13 +445,102 @@ pub(crate) async fn handle_active_audio_connection( // which arms them BEFORE the `is_community_active` bootstrap await. // `None` is used by test call sites that bypass the outer wrapper. pre_built: Option, +) { + let (ws_send, ws_recv) = socket.split(); + let mut ws_recv = AudioReadHalf::Held(ws_recv); + run_active_audio_connection( + ws_send, + &mut ws_recv, + state, + tenant, + channel_id, + control, + nip_fi_assertion, + connection_time, + pre_built, + ) + .await; + // Returning releases the caller's connection permit, so the socket must + // have ended first: wait on the read half here, or on the background + // close an exit handed it to. + match ws_recv { + AudioReadHalf::Held(ws_recv) => crate::connection::await_client_close(ws_recv).await, + AudioReadHalf::HandedOff(close) => { + if let Err(error) = close.await { + warn!(channel_id = %channel_id, "audio background close failed: {error}"); + } + } + } +} + +/// The audio session's read half, or the background close it was handed to. +enum AudioReadHalf { + Held(futures_util::stream::SplitStream), + HandedOff(tokio::task::JoinHandle<()>), +} + +/// Hands the socket to a task that waits, bounded, for the client's Close +/// reply and then drops it. Called once the terminal frames are sent and +/// before cleanup that may block on Postgres, Redis or a remote owner, so +/// that cleanup never holds the socket open. Pass the write half when the +/// caller still owns it: either half alone keeps the socket open. The task +/// handle is kept so the wrapper holds the connection permit until the +/// socket ends. +fn hand_off_socket( + ws_recv: &mut AudioReadHalf, + ws_send: Option>, +) { + if let AudioReadHalf::HandedOff(_) = ws_recv { + return; + } + // The task is spawned before the read half moves out of `ws_recv`, so + // `ws_recv` is never left without one of its two states. + let (recv_tx, recv_rx) = tokio::sync::oneshot::channel(); + let close = tokio::spawn(async move { + if let Ok(recv) = recv_rx.await { + crate::connection::await_client_close(recv).await; + } + drop(ws_send); + }); + if let AudioReadHalf::Held(recv) = std::mem::replace(ws_recv, AudioReadHalf::HandedOff(close)) { + let _ = recv_tx.send(recv); + } +} + +/// An admission error frame followed by the Close that ends the socket, so +/// the client gets a clean close handshake instead of waiting out the +/// bounded peer wait for a Close that never comes. `code` is +/// `close_code::POLICY` (1008) for a refusal, `close_code::ERROR` (1011) +/// for a server-side failure and `close_code::RESTART` (1012) when the relay +/// is draining and the client should reconnect. +fn error_exit_frames(error: serde_json::Value, code: u16) -> [WsMessage; 2] { + [WsMessage::Text(error.to_string().into()), exit_close(code)] +} + +fn exit_close(code: u16) -> WsMessage { + WsMessage::Close(Some(axum::extract::ws::CloseFrame { + code, + reason: axum::extract::ws::Utf8Bytes::default(), + })) +} + +#[allow(clippy::too_many_arguments)] +async fn run_active_audio_connection( + mut ws_send: futures_util::stream::SplitSink, + ws_recv: &mut AudioReadHalf, + state: Arc, + tenant: TenantContext, + channel_id: Uuid, + control: CommunityConnectionControl, + nip_fi_assertion: Option, + connection_time: chrono::DateTime, + pre_built: Option, ) { let cancel = control.cancellation_token(); let disconnect_reason = control.disconnect_reason(); // connection_time is threaded in from the HTTP handler (captured immediately // before on_upgrade) so the session partition is rooted at the true upgrade // instant, not the post-community-active-check instant. [FI-TRACE-LEASE-BOUND] - let (mut ws_send, mut ws_recv) = socket.split(); // P2 / Fix 3: Arm the NIP-FI session gate and expiry task. // @@ -550,6 +633,9 @@ pub(crate) async fn handle_active_audio_connection( return; }, result = tokio::time::timeout(AUTH_TIMEOUT, async { + let AudioReadHalf::Held(ws_recv) = ws_recv else { + return None; + }; while let Some(Ok(msg)) = ws_recv.next().await { if let WsMessage::Text(text) = msg { if text.len() > MAX_TEXT_FRAME_BYTES { @@ -569,8 +655,17 @@ pub(crate) async fn handle_active_audio_connection( let auth_msg = match auth_result { Ok(Some(a)) => a, - _ => { - debug!(channel_id = %channel_id, "audio auth timeout or disconnect"); + Ok(None) => { + debug!(channel_id = %channel_id, "audio client disconnected before auth"); + return; + } + Err(_) => { + debug!(channel_id = %channel_id, "audio auth timeout"); + crate::connection::send_exit_frames_bounded( + &mut ws_send, + [exit_close(axum::extract::ws::close_code::POLICY)], + ) + .await; return; } }; @@ -932,15 +1027,14 @@ pub(crate) async fn handle_active_audio_connection( shadow_attempt.refused(); crate::connection::send_exit_frames_bounded( &mut ws_send, - [WsMessage::Text( + error_exit_frames( serde_json::json!({ "type": "error", "code": "huddle_relay_draining", "message": "relay is draining; reconnect" - }) - .to_string() - .into(), - )], + }), + axum::extract::ws::close_code::RESTART, + ), ) .await; return; @@ -993,15 +1087,14 @@ pub(crate) async fn handle_active_audio_connection( ); crate::connection::send_exit_frames_bounded( &mut ws_send, - [WsMessage::Text( + error_exit_frames( serde_json::json!({ "type": "error", "code": "join_rejected", "message": "huddle join rejected" - }) - .to_string() - .into(), - )], + }), + axum::extract::ws::close_code::ERROR, + ), ) .await; return; @@ -1021,15 +1114,14 @@ pub(crate) async fn handle_active_audio_connection( ); crate::connection::send_exit_frames_bounded( &mut ws_send, - [WsMessage::Text( + error_exit_frames( serde_json::json!({ "type": "error", "code": "huddle_audio_unavailable", "message": "huddle audio unavailable in this deployment" - }) - .to_string() - .into(), - )], + }), + axum::extract::ws::close_code::POLICY, + ), ) .await; return; @@ -1052,19 +1144,23 @@ pub(crate) async fn handle_active_audio_connection( // get_or_create (the old room was already cleaned up). This DB check // catches that case. The room-level ended flag (checked inside add_peer) // handles the same-room case. + #[cfg(test)] + crate::nip_fi_test_hooks::before_room_archive_recheck(tenant.community()).await; match state.db.get_channel(tenant.community(), channel_id).await { Ok(ch) if ch.archived_at.is_some() => { shadow_attempt.refused(); debug!(channel_id = %channel_id, "channel archived before room join"); crate::connection::send_exit_frames_bounded( &mut ws_send, - [WsMessage::Text( - serde_json::json!({"type":"error","message":"huddle has ended"}) - .to_string() - .into(), - )], + error_exit_frames( + serde_json::json!({"type":"error","message":"huddle has ended"}), + axum::extract::ws::close_code::POLICY, + ), ) .await; + hand_off_socket(ws_recv, Some(ws_send)); + #[cfg(test)] + crate::nip_fi_test_hooks::before_archived_exit_cleanup(tenant.community()).await; // I1 residual: release lease with an awaited call, not a detached task. if let Some((lease, directory)) = staged_lease { if let Err(e) = directory.release(&lease).await { @@ -1079,6 +1175,12 @@ pub(crate) async fn handle_active_audio_connection( Err(e) => { shadow_attempt.refused(); warn!(channel_id = %channel_id, "pre-join channel check failed (fail-closed): {e}"); + crate::connection::send_exit_frames_bounded( + &mut ws_send, + [exit_close(axum::extract::ws::close_code::ERROR)], + ) + .await; + hand_off_socket(ws_recv, Some(ws_send)); // I1 residual: release lease with an awaited call, not a detached task. if let Some((lease, directory)) = staged_lease { if let Err(re) = directory.release(&lease).await { @@ -1108,7 +1210,7 @@ pub(crate) async fn handle_active_audio_connection( current = CURRENT_PROTOCOL_VERSION, "audio: client requested unsupported protocol version" ); - crate::connection::send_exit_frames_bounded(&mut ws_send, [WsMessage::Text( + crate::connection::send_exit_frames_bounded(&mut ws_send, error_exit_frames( serde_json::json!({ "type": "error", "code": "unsupported_version", @@ -1116,11 +1218,11 @@ pub(crate) async fn handle_active_audio_connection( "huddle audio protocol v{requested_version} not supported; relay max is v{CURRENT_PROTOCOL_VERSION}" ), "current_version": CURRENT_PROTOCOL_VERSION, - }) - .to_string() - .into(), - )]) + }), + axum::extract::ws::close_code::POLICY, + )) .await; + hand_off_socket(ws_recv, Some(ws_send)); if let Some((lease, directory)) = staged_lease { // I1 residual: release lease with an awaited call, not a detached task. if let Err(e) = directory.release(&lease).await { @@ -1182,11 +1284,13 @@ pub(crate) async fn handle_active_audio_connection( warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "huddle owner rejected registration: {reason:?}"); crate::connection::send_exit_frames_bounded( &mut ws_send, - [WsMessage::Text( - remote_rejection_ws_error(&reason).to_string().into(), - )], + error_exit_frames( + remote_rejection_ws_error(&reason), + axum::extract::ws::close_code::POLICY, + ), ) .await; + hand_off_socket(ws_recv, Some(ws_send)); // I3 residual: await expiry task before resource teardown. cancel.cancel(); if let Some(t) = _nip_fi_admission_expiry.take() { @@ -1203,16 +1307,16 @@ pub(crate) async fn handle_active_audio_connection( warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "huddle owner registration failed: {e}"); crate::connection::send_exit_frames_bounded( &mut ws_send, - [WsMessage::Text( + error_exit_frames( serde_json::json!({ "type": "error", "code": "huddle_owner_unreachable", "message": "could not reach the huddle owner" - }) - .to_string() - .into(), - )], + }), + axum::extract::ws::close_code::ERROR, + ), ) .await; + hand_off_socket(ws_recv, Some(ws_send)); // I3 residual: await expiry task before resource teardown. cancel.cancel(); if let Some(t) = _nip_fi_admission_expiry.take() { @@ -1296,7 +1400,8 @@ pub(crate) async fn handle_active_audio_connection( Err(crate::audio::room::AdmissionError::Full) => { shadow_attempt.refused(); warn!(channel_id = %channel_id, "audio room participant capacity reached"); - crate::connection::send_exit_frames_bounded(&mut ws_send, [WsMessage::Text(serde_json::json!({"type":"error","code":"room_full","message":"room participant capacity reached"}).to_string().into())]).await; + crate::connection::send_exit_frames_bounded(&mut ws_send, error_exit_frames(serde_json::json!({"type":"error","code":"room_full","message":"room participant capacity reached"}), axum::extract::ws::close_code::POLICY)).await; + hand_off_socket(ws_recv, Some(ws_send)); // IMPORTANT 3: cancel + await expiry task before guard release. cancel.cancel(); if let Some(t) = _nip_fi_admission_expiry.take() { @@ -1308,7 +1413,8 @@ pub(crate) async fn handle_active_audio_connection( Err(crate::audio::room::AdmissionError::Ended) => { shadow_attempt.refused(); debug!(channel_id = %channel_id, "room ended before admission"); - crate::connection::send_exit_frames_bounded(&mut ws_send, [WsMessage::Text(serde_json::json!({"type":"error","code":"room_ended","message":"huddle has ended"}).to_string().into())]).await; + crate::connection::send_exit_frames_bounded(&mut ws_send, error_exit_frames(serde_json::json!({"type":"error","code":"room_ended","message":"huddle has ended"}), axum::extract::ws::close_code::POLICY)).await; + hand_off_socket(ws_recv, Some(ws_send)); // IMPORTANT 3: cancel + await expiry task before guard release. cancel.cancel(); if let Some(t) = _nip_fi_admission_expiry.take() { @@ -1320,11 +1426,12 @@ pub(crate) async fn handle_active_audio_connection( Err(crate::audio::room::AdmissionError::VersionMismatch { pinned, requested }) => { shadow_attempt.refused(); info!(channel_id = %channel_id, pubkey = %pubkey_hex, pinned, requested, "audio: protocol version mismatch — upgrade required"); - crate::connection::send_exit_frames_bounded(&mut ws_send, [WsMessage::Text(serde_json::json!({ + crate::connection::send_exit_frames_bounded(&mut ws_send, error_exit_frames(serde_json::json!({ "type": "error", "code": "upgrade_required", "message": format!("this huddle is using audio protocol v{pinned}; your client requested v{requested}"), "pinned_version": pinned, "requested_version": requested, - }).to_string().into())]).await; + }), axum::extract::ws::close_code::POLICY)).await; + hand_off_socket(ws_recv, Some(ws_send)); // IMPORTANT 3: cancel + await expiry task before guard release. cancel.cancel(); if let Some(t) = _nip_fi_admission_expiry.take() { @@ -1626,6 +1733,7 @@ pub(crate) async fn handle_active_audio_connection( .map_or(WsMessage::Close(None), |reason| reason.close_message()); let _ = tokio::time::timeout_at(deadline, ws_send.send(close)).await; } + hand_off_socket(ws_recv, Some(ws_send)); if let (Some(session), Some(ref mut stream)) = ( guard.take_remote_session().as_ref(), guard.take_remote_stream().as_mut(), @@ -2021,20 +2129,29 @@ pub(crate) async fn handle_active_audio_connection( None }; - recv_loop( - ws_recv, - Arc::clone(&room), - peer_id, - requested_version, - ctrl_tx, - Arc::clone(&missed_pongs), - cancel.clone(), - remote_session.as_mut(), - ) - .await; + // Every exit that hands the socket off returns, so it is still held here; + // if it were not, the session would skip straight to teardown. + if let AudioReadHalf::Held(ws_recv) = ws_recv { + recv_loop( + ws_recv, + Arc::clone(&room), + peer_id, + requested_version, + ctrl_tx, + Arc::clone(&missed_pongs), + cancel.clone(), + remote_session.as_mut(), + ) + .await; + } cancel.cancel(); let _ = send_task.await; + // The writer has sent the Close and released the write half. Nothing + // below may hold the socket open, including the Postgres and Redis writes. + hand_off_socket(ws_recv, None); + #[cfg(test)] + crate::nip_fi_test_hooks::before_audio_teardown_cleanup(tenant.community()).await; let _ = heartbeat_task.await; let _ = forward_task.await; // The reader task owns the owner control stream; joining it here guarantees @@ -2226,7 +2343,7 @@ fn remote_rejection_ws_error(reason: &crate::audio::join::RegisterRejection) -> /// clarifies at this single call site. #[allow(clippy::too_many_arguments)] async fn recv_loop( - mut ws_recv: futures_util::stream::SplitStream, + ws_recv: &mut futures_util::stream::SplitStream, room: Arc, peer_id: Uuid, protocol_version: u8, @@ -2758,6 +2875,7 @@ pub(crate) async fn deny_audio_authorization( terminal_rx: &mut mpsc::Receiver, ) where S: futures_util::Sink + Unpin, + S::Error: std::error::Error + 'static, { control.disconnect_nip_fi(); crate::connection::send_exit_frames_bounded( @@ -2792,6 +2910,7 @@ async fn exit_authorization_refusal( off_mode: serde_json::Value, ) where S: futures_util::Sink + Unpin, + S::Error: std::error::Error + 'static, { if nip_fi && class == buzz_auth::DenialClass::AuthorizationDenied { deny_audio_authorization(ws_send, control, terminal_rx).await; @@ -5300,6 +5419,13 @@ mod tests { /// Reads `BUZZ_TEST_DATABASE_URL`; falls back to the local development URL. /// Returns `None` if the resolved database is not reachable. async fn audio_test_state_real_db() -> Option> { + audio_test_state_real_db_with(|_| {}).await + } + + /// `audio_test_state_real_db` with a final `configure` pass over the config. + async fn audio_test_state_real_db_with( + configure: impl FnOnce(&mut crate::config::Config), + ) -> Option> { use std::sync::Arc; let db_url = std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| { "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string() // sadscan:disable np.postgres.1 -- local test-only credentials @@ -5312,6 +5438,7 @@ mod tests { config.require_relay_membership = false; config.database_url = db_url.clone(); config.redis_url = "redis://127.0.0.1:1".to_string(); + configure(&mut config); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) @@ -9992,6 +10119,369 @@ mod tests { let _ = server.await; } + /// Answers the server's NIP-42 challenge with an audio `auth` message. + async fn send_audio_auth( + client: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + key: &nostr::Keys, + relay_url: &str, + protocol_version: u8, + ) { + let challenge = + match tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("challenge timeout") + .expect("challenge msg") + .expect("challenge ws msg") + { + tokio_tungstenite::tungstenite::Message::Text(t) => { + serde_json::from_str::(&t).expect("challenge JSON") + ["challenge"] + .as_str() + .expect("challenge field") + .to_string() + } + other => panic!("expected text challenge; got {other:?}"), + }; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(key) + .unwrap(); + let auth_msg = serde_json::json!({ + "type": "auth", + "event": auth_event, + "parent_channel_id": null, + "protocol_version": protocol_version, + }) + .to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("send auth msg"); + } + + /// After an established session's writer sends its Close, the + /// teardown that follows writes to Postgres and Redis, which can stall + /// with no deadline. The socket must still end within the bounded wait + /// for the client's Close reply. This client never replies and the + /// teardown never finishes. + /// + /// Mutation: move `hand_off_socket(ws_recv, None)` to after the + /// teardown → the socket stays open while the teardown is held → + /// `time_to_eof` hits its watchdog and panics. + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + #[tokio::test] + async fn stalled_teardown_does_not_hold_established_socket_open() { + use crate::connection::terminal_close_delivery_tests::time_to_eof; + use crate::connection::WS_TERMINAL_FLUSH_TIMEOUT; + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let state = audio_test_state_real_db().await.expect( + "PostgreSQL must be available — set BUZZ_TEST_DATABASE_URL or start local postgres", + ); + let pool = state.db.pool().clone(); + let (tenant, channel_id, key) = seed_audio_fixture(&pool).await; + let community = tenant.community(); + let assertion = VerifiedAssertion::for_test( + Some(key.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + let relay_url = format!("ws://{}", tenant.host()); + let conn_cancel = CancellationToken::new(); + + let (fanout_rx, fanout_release) = + crate::nip_fi_test_hooks::audio_participant_fanout_hook::arm(community); + let (teardown_rx, teardown_release) = + crate::nip_fi_test_hooks::audio_teardown_cleanup_hook::arm(community); + + let app = axum::Router::new().route( + "/", + axum::routing::get({ + let conn_cancel = conn_cancel.clone(); + move |ws: axum::extract::ws::WebSocketUpgrade| { + let (state, tenant, assertion) = + (Arc::clone(&state), tenant.clone(), assertion.clone()); + let control = + crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + async move { + ws.on_upgrade(move |socket| { + handle_active_audio_connection( + socket, + state, + tenant, + channel_id, + control, + Some(assertion), + Utc::now(), + None, + ) + }) + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + + let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + send_audio_auth(&mut client, &key, &relay_url, 1).await; + + tokio::time::timeout(std::time::Duration::from_secs(10), fanout_rx) + .await + .expect("handler must commit the join within 10s") + .expect("fanout channel closed"); + fanout_release.notify_one(); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + // From here the client only reads raw bytes, so it never answers + // the server's Close. + let tokio_tungstenite::MaybeTlsStream::Plain(socket) = client.get_mut() else { + panic!("plain TCP client"); + }; + let started = std::time::Instant::now(); + conn_cancel.cancel(); + tokio::time::timeout(std::time::Duration::from_secs(5), teardown_rx) + .await + .expect("teardown must reach the stall point") + .expect("teardown hook dropped"); + // The hook runs just after the hand-off, so the socket must end + // within the bounded wait measured from here. + let handed_off = std::time::Instant::now(); + let elapsed = time_to_eof(socket, started).await; + assert!( + elapsed >= WS_TERMINAL_FLUSH_TIMEOUT, + "server must wait for the client's Close reply, ended after {elapsed:?}" + ); + let since_hand_off = handed_off.elapsed(); + assert!( + since_hand_off <= WS_TERMINAL_FLUSH_TIMEOUT + std::time::Duration::from_millis(500), + "socket must end within the bounded wait after the hand-off, took {since_hand_off:?}" + ); + + teardown_release.notify_one(); + server.abort(); + } + + /// The archived-channel exit sends its error and Close, then releases + /// the huddle lease in Redis, which can stall with no deadline. The + /// socket must end within the bounded wait for the client's Close + /// reply, not when that cleanup finishes. This client never replies + /// and the cleanup never finishes. + /// + /// Mutation: move that exit's `hand_off_socket` below the lease + /// release (or remove it) → the socket stays open while the cleanup + /// is held → `time_to_eof` hits its watchdog and panics. + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + #[tokio::test] + async fn stalled_archived_exit_cleanup_does_not_hold_socket_open() { + use crate::connection::terminal_close_delivery_tests::time_to_eof; + use crate::connection::WS_TERMINAL_FLUSH_TIMEOUT; + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let state = audio_test_state_real_db().await.expect( + "PostgreSQL must be available — set BUZZ_TEST_DATABASE_URL or start local postgres", + ); + let pool = state.db.pool().clone(); + let (tenant, channel_id, key) = seed_audio_fixture(&pool).await; + let community = tenant.community(); + let assertion = VerifiedAssertion::for_test( + Some(key.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + let relay_url = format!("ws://{}", tenant.host()); + + let (recheck_rx, recheck_release) = + crate::nip_fi_test_hooks::room_archive_recheck_hook::arm(community); + let (cleanup_rx, cleanup_release) = + crate::nip_fi_test_hooks::archived_exit_cleanup_hook::arm(community); + + let app = axum::Router::new().route( + "/", + axum::routing::get(move |ws: axum::extract::ws::WebSocketUpgrade| { + let (state, tenant, assertion) = + (Arc::clone(&state), tenant.clone(), assertion.clone()); + let control = + crate::state::CommunityConnectionControl::new(CancellationToken::new()); + async move { + ws.on_upgrade(move |socket| { + handle_active_audio_connection( + socket, + state, + tenant, + channel_id, + control, + Some(assertion), + Utc::now(), + None, + ) + }) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + + let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + send_audio_auth(&mut client, &key, &relay_url, 1).await; + + // Admission passed the membership check; archive the channel + // before the re-check so the join takes the archived exit. + tokio::time::timeout(std::time::Duration::from_secs(10), recheck_rx) + .await + .expect("admission must reach the archive re-check") + .expect("re-check hook dropped"); + sqlx::query("UPDATE channels SET archived_at = NOW() WHERE id = $1") + .bind(channel_id) + .execute(&pool) + .await + .expect("archive channel"); + + // From here the client only reads raw bytes, so it never answers + // the server's Close. + let tokio_tungstenite::MaybeTlsStream::Plain(socket) = client.get_mut() else { + panic!("plain TCP client"); + }; + let started = std::time::Instant::now(); + recheck_release.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(5), cleanup_rx) + .await + .expect("the archived exit must reach its cleanup") + .expect("cleanup hook dropped"); + let elapsed = time_to_eof(socket, started).await; + assert!( + elapsed <= WS_TERMINAL_FLUSH_TIMEOUT + std::time::Duration::from_millis(500), + "a stalled exit cleanup must not hold the socket open, ended after {elapsed:?}" + ); + + cleanup_release.notify_one(); + server.abort(); + } + + /// A fast refusal hands the socket to a background close and returns. + /// The connection permit must stay held until that socket ends, or a + /// client that never answers the Close lets open audio sockets exceed + /// `BUZZ_MAX_CONNECTIONS`. + /// + /// Mutation: stop the wrapper from joining the background close → the + /// permit is released while the socket is still open → the + /// `available_permits() == 0` assertion fails. + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + #[tokio::test] + async fn background_close_holds_connection_permit_until_socket_ends() { + use crate::connection::terminal_close_delivery_tests::time_to_eof; + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + use tokio::io::AsyncReadExt as _; + + let state = audio_test_state_real_db().await.expect( + "PostgreSQL must be available — set BUZZ_TEST_DATABASE_URL or start local postgres", + ); + let pool = state.db.pool().clone(); + let (tenant, channel_id, key) = seed_audio_fixture(&pool).await; + let assertion = VerifiedAssertion::for_test( + Some(key.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + let relay_url = format!("ws://{}", tenant.host()); + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + + let app = axum::Router::new().route( + "/", + axum::routing::get({ + let semaphore = Arc::clone(&semaphore); + move |ws: axum::extract::ws::WebSocketUpgrade| { + let (state, tenant, assertion) = + (Arc::clone(&state), tenant.clone(), assertion.clone()); + let permit = Arc::clone(&semaphore) + .try_acquire_owned() + .expect("one connection permit"); + async move { + ws.on_upgrade(move |socket| { + handle_audio_connection( + socket, + state, + tenant, + channel_id, + permit, + Some(assertion), + None, + Utc::now(), + ) + }) + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + + let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + send_audio_auth(&mut client, &key, &relay_url, u8::MAX).await; + + // From here the client only reads raw bytes, so it never answers + // the server's Close. + let tokio_tungstenite::MaybeTlsStream::Plain(socket) = client.get_mut() else { + panic!("plain TCP client"); + }; + let mut received = Vec::new(); + let mut buf = [0u8; 512]; + while !received + .windows(b"unsupported_version".len()) + .any(|w| w == b"unsupported_version") + { + let n = + tokio::time::timeout(std::time::Duration::from_secs(5), socket.read(&mut buf)) + .await + .expect("refusal must arrive") + .expect("read"); + assert_ne!(n, 0, "socket ended before the refusal"); + received.extend_from_slice(&buf[..n]); + } + let started = std::time::Instant::now(); + // The refusal has no staged lease, so its cleanup returns at once. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert_eq!( + semaphore.available_permits(), + 0, + "permit must stay held while the refused socket is open" + ); + + time_to_eof(socket, started).await; + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while semaphore.available_permits() == 0 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("permit must be released once the socket ends"); + + server.abort(); + } + // ── F1: generation fencing witness ──────────────────────────────────────── // // `commit_participant_join` must include `generation` in the committed @@ -13756,6 +14246,39 @@ mod tests { assert_eq!(close.reason.as_str(), "access revoked"); } + /// A deployment without huddle audio refuses the join with its error + /// frame and then a Close 1008, so the client gets a clean handshake + /// instead of waiting out the peer wait. + /// Mutation: drop the Close from the refusal → no Close frame → RED. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn huddle_audio_unavailable_sends_error_then_policy_close() { + let state = audio_test_state_real_db_with(|c| c.huddle_audio_available = false) + .await + .expect( + "PostgreSQL must be available — set BUZZ_TEST_DATABASE_URL or start local postgres", + ); + let (tenant, channel_id, key) = seed_audio_fixture(state.db.pool()).await; + let (mut client, server) = + start_audio_auth(&state, tenant, channel_id, &key, None, None).await; + + let error = next_audio_text(&mut client).await; + assert!( + error.contains("\"huddle_audio_unavailable\""), + "expected the unavailable refusal; got {error}" + ); + match tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("close must follow the refusal") + { + Some(Ok(tokio_tungstenite::tungstenite::Message::Close(Some(frame)))) => { + assert_eq!(u16::from(frame.code), 1008) + } + other => panic!("expected Close 1008 after the refusal; got {other:?}"), + } + server.abort(); + } + /// Adds `key` as a member of the audio fixture's channel. async fn add_channel_member( pool: &sqlx::PgPool, @@ -14662,4 +15185,99 @@ mod tests { future never resolves → test hangs → RED" ); } + + /// Real-socket proof that each audio exit path holds the socket for the + /// client's Close reply. The raw client never replies, so a server that + /// drops the socket right after its Close ends it before the bounded wait. + mod terminal_close_delivery { + use super::*; + use crate::connection::terminal_close_delivery_tests::{ + raw_client_after_server_close, time_to_eof, + }; + use crate::connection::WS_TERMINAL_FLUSH_TIMEOUT; + + fn expired_assertion() -> VerifiedAssertion { + let deadline = chrono::Utc::now() - chrono::Duration::seconds(1); + VerifiedAssertion::for_test(Some(nostr::Keys::generate().public_key()), vec![deadline]) + } + + fn tenant() -> TenantContext { + TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "test.local".to_owned(), + ) + } + + async fn assert_holds_for_close_reply(app: Router) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("listener address"); + tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + + let started = std::time::Instant::now(); + let mut socket = raw_client_after_server_close(address).await; + let elapsed = time_to_eof(&mut socket, started).await; + assert!( + elapsed >= WS_TERMINAL_FLUSH_TIMEOUT, + "server must wait for the client's Close reply, ended after {elapsed:?}" + ); + } + + /// Expired session refused during bootstrap: the `on_not_run` drain. + #[tokio::test] + async fn bootstrap_drain_holds_socket_for_client_close_reply() { + let state = audio_test_state().await; + let app = Router::new().route( + "/", + get(move |ws: WebSocketUpgrade| { + let state = Arc::clone(&state); + let permit = Arc::new(tokio::sync::Semaphore::new(1)) + .try_acquire_owned() + .expect("permit"); + async move { + ws.on_upgrade(move |socket| { + handle_audio_connection( + socket, + state, + tenant(), + Uuid::new_v4(), + permit, + Some(expired_assertion()), + None, + chrono::Utc::now(), + ) + }) + } + }), + ); + assert_holds_for_close_reply(app).await; + } + + /// Expired session inside the active handler: an early admission exit. + #[tokio::test] + async fn active_handler_exit_holds_socket_for_client_close_reply() { + let state = audio_test_state().await; + let app = Router::new().route( + "/", + get(move |ws: WebSocketUpgrade| { + let state = Arc::clone(&state); + async move { + ws.on_upgrade(move |socket| { + let cancel = CancellationToken::new(); + handle_active_audio_connection( + socket, + state, + tenant(), + Uuid::new_v4(), + CommunityConnectionControl::new(cancel), + Some(expired_assertion()), + chrono::Utc::now(), + None, + ) + }) + } + }), + ); + assert_holds_for_close_reply(app).await; + } + } } diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index b341f9b214..ad58fd032f 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -29,8 +29,10 @@ use crate::state::{ /// Maximum time a new socket may hold a connection slot without completing NIP-42 auth. const AUTH_TIMEOUT: Duration = Duration::from_secs(5); -/// Maximum time the writer may spend flushing terminal frames after cancellation. -/// This stays well inside the process-wide 30-second hard drain. +/// Bounds each of the two stages of ending a socket: writing its terminal +/// frames after cancellation, then waiting for the client's Close reply +/// (`await_client_close`). Both stay well inside the process-wide 30-second +/// hard drain. pub(crate) const WS_TERMINAL_FLUSH_TIMEOUT: Duration = Duration::from_secs(1); /// Shared mutable subscription map for a single WebSocket connection: sub ID → @@ -362,6 +364,12 @@ pub(crate) async fn handle_connection( nip_fi_shadow: Option>, connection_time: chrono::DateTime, ) { + // Held through bootstrap, the active handler and any refusal drain, so a + // socket counts against `BUZZ_MAX_CONNECTIONS` for as long as it is open. + let Ok(_permit) = state.conn_semaphore.clone().try_acquire_owned() else { + warn!("Connection limit reached, rejecting {addr}"); + return; + }; let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); @@ -417,6 +425,11 @@ pub(crate) async fn handle_connection( let rx_for_run = Arc::clone(&rx_shared); let rx_for_drain = Arc::clone(&rx_shared); + // The drain path parks the read half here so the bounded wait for the + // client's Close runs after the registry entry is released. + let unread_shared = Arc::new(Mutex::new(None)); + let unread_for_drain = Arc::clone(&unread_shared); + run_registered_community_connection( ®istry, conn_id, @@ -453,45 +466,22 @@ pub(crate) async fn handle_connection( .await }, move || async move { - // Drain the pre-terminal channel (NIP-FI denial frame, if any) and - // close the socket with a bounded timeout so a queued denial is - // delivered even when the active-connection path is never entered. + // Deliver a queued denial frame (if any) and the reason's Close + // even when the active-connection path is never entered. let socket = socket_for_drain.lock().await.take(); - let mut pre_terminal_ctrl_rx = rx_for_drain.lock().await.take(); + let queued = rx_for_drain.lock().await.take(); if let Some(socket) = socket { - let (mut ws_send, _ws_recv) = socket.split(); - // Terminal channel has capacity 1; if the expiry task fired, it holds - // exactly one denial frame here. Deliver it before closing. - if let Some(ref mut rx) = pre_terminal_ctrl_rx { - while let Ok(msg) = rx.try_recv() { - let _ = tokio::time::timeout( - WS_TERMINAL_FLUSH_TIMEOUT, - futures_util::SinkExt::send(&mut ws_send, msg), - ) - .await; - } - } - // A published reason (e.g. an expiry denial) closes with its - // own frame, exactly as the writer would. let close = drain_reason.borrow().map(|reason| reason.close_message()); - if let Some(close) = close { - let _ = tokio::time::timeout( - WS_TERMINAL_FLUSH_TIMEOUT, - futures_util::SinkExt::send(&mut ws_send, close), - ) - .await; - } - // Close the socket so the client sees a clean close rather than - // an abrupt TCP reset. - let _ = tokio::time::timeout( - WS_TERMINAL_FLUSH_TIMEOUT, - futures_util::SinkExt::close(&mut ws_send), - ) - .await; + let ws_recv = drain_refused_socket(socket, queued, close).await; + *unread_for_drain.lock().await = Some(ws_recv); } }, ) .await; + let unread = unread_shared.lock().await.take(); + if let Some(ws_recv) = unread { + await_client_close(ws_recv).await; + } } // `handle_active_connection` inherits the connection handler's natural parameter @@ -517,13 +507,6 @@ async fn handle_active_connection( // connection_time is threaded in from the HTTP handler (captured immediately // before on_upgrade) so the session partition is rooted at the true upgrade // instant, not the post-community-active-check instant. [FI-TRACE-LEASE-BOUND] - let permit = match state.conn_semaphore.clone().try_acquire_owned() { - Ok(p) => p, - Err(_) => { - warn!("Connection limit reached, rejecting {addr}"); - return; - } - }; let challenge = generate_challenge(); @@ -551,6 +534,8 @@ async fn handle_active_connection( let backpressure_count = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); + #[cfg(test)] + test_seam::hold_subscriptions(&subscriptions).await; // Compute the NIP-FI session deadline from the assertion. // @@ -729,7 +714,7 @@ async fn handle_active_connection( }; auth_cancel_conn.finish_pending_auth_on_cancel(outcome); }); - recv_loop( + let (ws_recv, in_flight) = recv_loop( ws_recv, Arc::clone(&conn), Arc::clone(&state), @@ -749,30 +734,43 @@ async fn handle_active_connection( let authenticated = auth_lifecycle.finish(close_outcome); let _ = send_task.await; - let _ = heartbeat_task.await; - let _ = auth_timeout_task.await; - if let Some(task) = nip_fi_expiry_task { - let _ = task.await; - } - let _ = auth_cancel_task.await; - crate::handlers::close::release_connection_subscriptions(&conn, &state).await; - state.conn_manager.deregister(conn.conn_id); - if let Some(auth_ctx) = authenticated { - if !state.conn_manager.has_admitted_connection( - conn.tenant.community(), - auth_ctx.pubkey.to_bytes().as_slice(), - ) { - let _ = state - .pubsub - .clear_presence(&conn.tenant, &auth_ctx.pubkey) - .await; + // The writer is done, so the server's Close (if any) is sent. Wait on the + // peer alongside the remaining joins and cleanup: the expiry barrier and + // Redis can block without a deadline, and neither may hold the socket + // open past the bounded peer wait. + let cleanup = async { + // A frame handler interrupted by cancellation was spawned, so it keeps + // running (and can release any effect permit the expiry barrier waits + // on). Finish it before application cleanup so nothing is half-retired. + if let Some(task) = in_flight { + if let Err(error) = task.await { + warn!(conn_id = %conn.conn_id, "interrupted frame handler failed: {error}"); + } } - } + let _ = heartbeat_task.await; + let _ = auth_timeout_task.await; + if let Some(task) = nip_fi_expiry_task { + let _ = task.await; + } + let _ = auth_cancel_task.await; + crate::handlers::close::release_connection_subscriptions(&conn, &state).await; + state.conn_manager.deregister(conn.conn_id); + if let Some(auth_ctx) = authenticated { + if !state.conn_manager.has_admitted_connection( + conn.tenant.community(), + auth_ctx.pubkey.to_bytes().as_slice(), + ) { + let _ = state + .pubsub + .clear_presence(&conn.tenant, &auth_ctx.pubkey) + .await; + } + } + }; + tokio::join!(cleanup, await_client_close(ws_recv)); metrics::gauge!("buzz_ws_connections_active").decrement(1.0); info!(conn_id = %conn_id, addr = %addr, "WebSocket connection closed"); - - drop(permit); } /// Send WebSocket messages in priority order: control frames before data frames. @@ -871,31 +869,23 @@ async fn flush_terminal_frames( first_ctrl: Option, ) where S: Sink + Unpin, + S::Error: std::error::Error + 'static, { let deadline = tokio::time::Instant::now() + WS_TERMINAL_FLUSH_TIMEOUT; // 1. Drain FI terminal channel first — denial frame must precede the close. while let Ok(terminal_msg) = terminal_ctrl_rx.try_recv() { - if !matches!( - tokio::time::timeout_at(deadline, sink.send(terminal_msg)).await, - Ok(Ok(())) - ) { + if !send_terminal_frame(sink, terminal_msg, deadline, "terminal").await { return; } } // 2. Drain ordinary control frames (ban reason, etc.). if let Some(ctrl_msg) = first_ctrl { - if !matches!( - tokio::time::timeout_at(deadline, sink.send(ctrl_msg)).await, - Ok(Ok(())) - ) { + if !send_terminal_frame(sink, ctrl_msg, deadline, "control").await { return; } } while let Ok(ctrl_msg) = ctrl_rx.try_recv() { - if !matches!( - tokio::time::timeout_at(deadline, sink.send(ctrl_msg)).await, - Ok(Ok(())) - ) { + if !send_terminal_frame(sink, ctrl_msg, deadline, "control").await { return; } } @@ -903,7 +893,46 @@ async fn flush_terminal_frames( let close = disconnect_reason .borrow() .map_or(WsMessage::Close(None), |reason| reason.close_message()); - let _ = tokio::time::timeout_at(deadline, sink.send(close)).await; + send_terminal_frame(sink, close, deadline, "close").await; +} + +/// One deadline-bounded terminal write. Failures stay best-effort but are +/// logged, so an undelivered denial or Close is diagnosable. +async fn send_terminal_frame( + sink: &mut S, + frame: WsMessage, + deadline: tokio::time::Instant, + frame_kind: &'static str, +) -> bool +where + S: Sink + Unpin, + S::Error: std::error::Error + 'static, +{ + match tokio::time::timeout_at(deadline, sink.send(frame)).await { + Ok(Ok(())) => true, + Ok(Err(error)) => { + if !closed_by_peer(&error) { + debug!(frame_kind, "terminal frame send failed: {error}"); + } + false + } + Err(_) => { + debug!(frame_kind, "terminal frame send timed out"); + false + } + } +} + +/// Whether a write failed only because the close handshake is already under +/// way, as when the client closed first. Not a delivery failure, so not logged. +fn closed_by_peer(error: &(dyn std::error::Error + 'static)) -> bool { + use tungstenite::error::{Error, ProtocolError}; + std::iter::successors(Some(error), |error| error.source()).any(|error| { + matches!( + error.downcast_ref::(), + Some(Error::AlreadyClosed | Error::Protocol(ProtocolError::SendAfterClosing)) + ) + }) } /// Writes a WebSocket exit path's frames before a writer task owns the socket. @@ -916,16 +945,55 @@ pub(crate) async fn send_exit_frames_bounded( frames: impl IntoIterator, ) where S: Sink + Unpin, + S::Error: std::error::Error + 'static, { let deadline = tokio::time::Instant::now() + WS_TERMINAL_FLUSH_TIMEOUT; + send_frames_by(sink, frames, deadline).await; +} + +/// Writes `frames` in order until one fails or `deadline` passes. Returns +/// whether every frame was written. +async fn send_frames_by( + sink: &mut S, + frames: impl IntoIterator, + deadline: tokio::time::Instant, +) -> bool +where + S: Sink + Unpin, + S::Error: std::error::Error + 'static, +{ for frame in frames { - if !matches!( - tokio::time::timeout_at(deadline, sink.send(frame)).await, - Ok(Ok(())) - ) { - return; + if !send_terminal_frame(sink, frame, deadline, "exit").await { + return false; } } + true +} + +/// Ends a socket refused before its session handler ran: writes the frames +/// still `queued` for it, then `close`, then closes the sink, all under one +/// `WS_TERMINAL_FLUSH_TIMEOUT` deadline. Returns the read half, which the +/// caller passes to `await_client_close` once it has released anything the +/// wait must not hold. Shared by the relay and audio routes. +pub(crate) async fn drain_refused_socket( + socket: WebSocket, + queued: Option>, + close: Option, +) -> futures_util::stream::SplitStream { + let (mut ws_send, ws_recv) = socket.split(); + let deadline = tokio::time::Instant::now() + WS_TERMINAL_FLUSH_TIMEOUT; + let queued = queued + .into_iter() + .flat_map(|mut rx| std::iter::from_fn(move || rx.try_recv().ok())); + if send_frames_by(&mut ws_send, queued.chain(close), deadline).await { + match tokio::time::timeout_at(deadline, ws_send.close()).await { + Ok(Ok(())) => {} + Ok(Err(error)) if closed_by_peer(&error) => {} + Ok(Err(error)) => debug!("refused socket close failed: {error}"), + Err(_) => debug!("refused socket close timed out"), + } + } + ws_recv } async fn send_loop_inner( @@ -938,6 +1006,7 @@ async fn send_loop_inner( disconnect_reason: watch::Receiver>, ) where S: Sink + Unpin, + S::Error: std::error::Error + 'static, { loop { // Priority: drain all pending control frames before data. @@ -1102,12 +1171,20 @@ async fn heartbeat_loop( } } +/// Reads and handles client frames until the connection is cancelled or the +/// stream ends. Returns the read half, for the bounded wait on the client's +/// Close, and the handle of a frame handler that cancellation interrupted, if +/// any: the caller must join that handle before subscription cleanup so the +/// interrupted handler never leaves a subscription half-retired. async fn recv_loop( mut ws_recv: futures_util::stream::SplitStream, conn: Arc, state: Arc, missed_pongs: Arc, cancel: CancellationToken, +) -> ( + futures_util::stream::SplitStream, + Option>, ) { loop { tokio::select! { @@ -1130,7 +1207,10 @@ async fn recv_loop( break; } trace!(len = text.len(), "frame received"); - handle_text_message(text.to_string(), Arc::clone(&conn), Arc::clone(&state)).await; + let frame = handle_text_message(text.to_string(), Arc::clone(&conn), Arc::clone(&state)); + if let Some(task) = run_frame(frame, &cancel).await { + return (ws_recv, Some(task)); + } } Some(Ok(WsMessage::Binary(bytes))) => { let max_frame_bytes = state.config.max_frame_bytes; @@ -1152,7 +1232,10 @@ async fn recv_loop( // (notably certain Nostr libraries) send text payloads in binary frames. // NIP-01 is text-only, but accepting binary is a common relay extension. if let Ok(text) = String::from_utf8(bytes.to_vec()) { - handle_text_message(text, Arc::clone(&conn), Arc::clone(&state)).await; + let frame = handle_text_message(text, Arc::clone(&conn), Arc::clone(&state)); + if let Some(task) = run_frame(frame, &cancel).await { + return (ws_recv, Some(task)); + } } } Some(Ok(WsMessage::Pong(_))) => { @@ -1181,6 +1264,42 @@ async fn recv_loop( _ = cancel.cancelled() => break, } } + (ws_recv, None) +} + +/// Runs one frame handler inline, keeping frames serial while the connection +/// is live. If the connection is cancelled first, the handler is spawned to +/// finish rather than dropped (dropping could half-retire a subscription), and +/// its handle is returned so the reader can give back its read half at once. +async fn run_frame( + frame: impl std::future::Future + Send + 'static, + cancel: &CancellationToken, +) -> Option> { + let mut frame = Box::pin(frame); + tokio::select! { + biased; + () = &mut frame => None, + () = cancel.cancelled() => Some(tokio::spawn(frame)), + } +} + +/// Holds the read half open until the close handshake finishes and the stream +/// ends, bounded by `WS_TERMINAL_FLUSH_TIMEOUT`. Dropping TCP before the peer +/// finishes the close handshake (including in-flight peer frames) can make the +/// client lose the unread NOTICE and Close and report an abnormal `1006`. +/// +/// Reading on past the client's Close matters: when the client closed first, +/// tungstenite only queues our reply Close and writes it on the next read, so +/// stopping at the client's Close would drop TCP with the reply unsent. Once +/// the handshake completes, the next read ends the stream at once. +pub(crate) async fn await_client_close(mut ws_recv: futures_util::stream::SplitStream) { + let drained = tokio::time::timeout(WS_TERMINAL_FLUSH_TIMEOUT, async { + while let Some(Ok(_)) = ws_recv.next().await {} + }) + .await; + if drained.is_err() { + debug!("client did not answer Close before the terminal deadline"); + } } async fn handle_text_message(text: String, conn: Arc, state: Arc) { @@ -1303,6 +1422,28 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar } } +#[cfg(test)] +pub(crate) mod test_seam { + use super::*; + + pub(crate) type Hold = + tokio::sync::oneshot::Sender>>; + + tokio::task_local! { + /// Hands the test this connection's subscription lock, already held, + /// before the reader can take it. + pub(crate) static SUBSCRIPTIONS_HOLD: StdMutex>; + } + + pub(super) async fn hold_subscriptions(subscriptions: &ConnectionSubscriptions) { + if let Ok(Some(hold)) = + SUBSCRIPTIONS_HOLD.try_with(|hold| hold.lock().expect("hold").take()) + { + let _ = hold.send(Arc::clone(subscriptions).lock_owned().await); + } + } +} + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -3198,3 +3339,665 @@ pub(crate) mod tests { let _ = server.await; } } + +/// Close-code delivery of NIP-FI authorization denials over a real WebSocket. +/// The mock-sink writer tests prove frame order; these prove what a client +/// actually reads off the wire. +#[cfg(test)] +pub(crate) mod terminal_close_delivery_tests { + use super::*; + use axum::{extract::ws::WebSocketUpgrade, routing::get, Router}; + use buzz_auth::VerifiedAssertion; + use nostr::{EventBuilder, Keys, RelayUrl}; + use tokio::net::TcpListener; + use tokio_tungstenite::tungstenite::{protocol::frame::coding::CloseCode, Message}; + + type Client = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >; + + /// Serves `handle_active_connection` for one assertion. `prefire_expiry` + /// arms an already-expired session gate, as `handle_connection` does. + async fn serve( + assertion: VerifiedAssertion, + prefire_expiry: bool, + ) -> (std::net::SocketAddr, RelayUrl, Arc, Uuid) { + serve_with_seams(assertion, prefire_expiry, Seams::default()).await + } + + /// How long after upgrade a session armed with `Seams::effect_hold` + /// expires. Long enough that a loaded runner still delivers a frame the + /// client sends at once before the deadline. + const EFFECT_HOLD_EXPIRY: chrono::Duration = chrono::Duration::seconds(2); + + /// Optional test hooks for `serve_with_seams`. + #[derive(Default)] + struct Seams { + /// Runs the handler inside this pause so the test can stall the + /// connection's final subscription cleanup. + cleanup_pause: Option>, + /// The session instead expires `EFFECT_HOLD_EXPIRY` after upgrade, and + /// the test receives a session effect permit taken before the + /// deadline, so the expiry task stalls at its quiescence barrier. + effect_hold: Option>, + /// Receives the connection's subscription lock, taken before the + /// reader starts, so a frame handler that needs it blocks until the + /// test drops it. + subscriptions_hold: Option, + } + + /// Like `serve`, with the test hooks in `seams`. + async fn serve_with_seams( + assertion: VerifiedAssertion, + prefire_expiry: bool, + Seams { + cleanup_pause, + effect_hold, + subscriptions_hold, + }: Seams, + ) -> (std::net::SocketAddr, RelayUrl, Arc, Uuid) { + let effect_hold = Arc::new(std::sync::Mutex::new(effect_hold)); + let subscriptions_hold = Arc::new(std::sync::Mutex::new(subscriptions_hold)); + let state = crate::state::tests::test_state().await; + let served_state = Arc::clone(&state); + let conn_id = Uuid::new_v4(); + let tenant = TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "test.local".to_owned(), + ); + let relay_url = + crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant) + .parse() + .expect("expected NIP-42 relay URL"); + let app = Router::new().route( + "/", + get(move |ws: WebSocketUpgrade| { + let (state, tenant, assertion, cleanup_pause) = ( + Arc::clone(&state), + tenant.clone(), + assertion.clone(), + cleanup_pause.clone(), + ); + let effect_hold = effect_hold.lock().expect("effect hold").take(); + let subscriptions_hold = subscriptions_hold.lock().expect("subs hold").take(); + async move { + ws.on_upgrade(move |socket| async move { + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let offset = if effect_hold.is_some() { + EFFECT_HOLD_EXPIRY + } else { + chrono::Duration::seconds(-1) + }; + let pre_built = prefire_expiry.then(|| { + let deadline = chrono::Utc::now() + offset; + let (tx, rx) = mpsc::channel::(1); + let gate = + crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel); + let expiry = crate::nip_fi_session::spawn_nip_fi_expiry_task( + deadline, + Arc::clone(&gate), + control.clone(), + tx.clone(), + crate::nip_fi_session::NipFiWsRoute::Root, + ); + (gate, tx, rx, Some(expiry)) + }); + if let (Some(hold), Some((gate, ..))) = (effect_hold, &pre_built) { + let permit = gate.acquire_effect().await.expect("effect permit"); + let _ = hold.send(permit); + } + let handler = handle_active_connection( + socket, + state, + "127.0.0.1:1234".parse().expect("client address"), + tenant, + conn_id, + control, + Some(assertion), + chrono::Utc::now(), + pre_built, + ); + let handler = super::test_seam::SUBSCRIPTIONS_HOLD + .scope(std::sync::Mutex::new(subscriptions_hold), handler); + match cleanup_pause { + Some(pause) => { + crate::handlers::close::test_seam::CLEANUP_PAUSE + .scope(pause, handler) + .await; + } + None => handler.await, + } + }) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("listener address"); + tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + (address, relay_url, served_state, conn_id) + } + + async fn connect(address: std::net::SocketAddr) -> Client { + tokio_tungstenite::connect_async(format!("ws://{address}/")) + .await + .expect("connect") + .0 + } + + fn expired_assertion() -> VerifiedAssertion { + let deadline = chrono::Utc::now() - chrono::Duration::seconds(1); + VerifiedAssertion::for_test(Some(Keys::generate().public_key()), vec![deadline]) + } + + /// Reads until the stream ends. Returns the text frames and the close + /// frame, or the error that ended the stream without one. + async fn read_to_close(client: &mut Client) -> (Vec, Result<(u16, String), String>) { + let mut texts = Vec::new(); + loop { + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("socket must terminate"); + match frame { + Some(Ok(Message::Text(text))) => texts.push(text.to_string()), + Some(Ok(Message::Close(Some(close)))) => { + return (texts, Ok((u16::from(close.code), close.reason.to_string()))) + } + Some(Ok(Message::Close(None))) => return (texts, Err("close without code".into())), + Some(Ok(_)) => {} + Some(Err(error)) => return (texts, Err(error.to_string())), + None => return (texts, Err("stream ended without close".into())), + } + } + } + + fn assert_denied_with_1008(texts: &[String], close: Result<(u16, String), String>) { + assert!( + texts + .iter() + .any(|t| t.contains("restricted: authorization denied")), + "denial NOTICE must arrive, got {texts:?}" + ); + assert_eq!( + close, + Ok(( + u16::from(CloseCode::Policy), + "authorization denied".to_owned() + )), + "denial must end with close 1008" + ); + } + + #[tokio::test] + async fn key_pairing_mismatch_closes_with_1008_on_real_socket() { + let asserted = Keys::generate(); + let assertion = VerifiedAssertion::for_test( + Some(asserted.public_key()), + vec![chrono::Utc::now() + chrono::Duration::hours(1)], + ); + let (address, relay_url, _, _) = serve(assertion, false).await; + let mut client = connect(address).await; + + let Some(Ok(Message::Text(challenge))) = client.next().await else { + panic!("expected AUTH challenge") + }; + let challenge: serde_json::Value = + serde_json::from_str(&challenge).expect("challenge json"); + let auth = EventBuilder::auth(challenge[1].as_str().expect("challenge"), relay_url) + .sign_with_keys(&Keys::generate()) + .expect("sign AUTH"); + client + .send(Message::Text( + serde_json::json!(["AUTH", auth]).to_string().into(), + )) + .await + .expect("send AUTH"); + + let (texts, close) = read_to_close(&mut client).await; + assert_denied_with_1008(&texts, close); + } + + #[tokio::test] + async fn session_expiry_denial_closes_with_1008_on_real_socket() { + let (address, _, _, _) = serve(expired_assertion(), true).await; + let mut client = connect(address).await; + + let (texts, close) = read_to_close(&mut client).await; + assert_denied_with_1008(&texts, close); + } + + /// Upgrades a raw TCP client and reads whole frames (server frames are + /// unmasked) through the server's Close, so a 0x88 byte inside a payload + /// or length cannot end the wait early. + pub(crate) async fn raw_client_after_server_close( + address: std::net::SocketAddr, + ) -> tokio::net::TcpStream { + raw_client_sending_then_after_server_close(address, &[]).await + } + + /// Like `raw_client_after_server_close`, but writes `frames` (masked + /// client frames) once the upgrade completes. + async fn raw_client_sending_then_after_server_close( + address: std::net::SocketAddr, + frames: &[u8], + ) -> tokio::net::TcpStream { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let mut socket = tokio::net::TcpStream::connect(address) + .await + .expect("connect"); + socket + .write_all( + b"GET / HTTP/1.1\r\nHost: test.local\r\nUpgrade: websocket\r\n\ + Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ + Sec-WebSocket-Version: 13\r\n\r\n", + ) + .await + .expect("send upgrade"); + + let mut read_exact = async |len: usize| { + let mut bytes = vec![0u8; len]; + tokio::time::timeout( + std::time::Duration::from_secs(5), + socket.read_exact(&mut bytes), + ) + .await + .expect("server frames must arrive") + .expect("socket ended before the server sent Close"); + bytes + }; + let mut response = Vec::new(); + while !response.ends_with(b"\r\n\r\n") { + response.extend(read_exact(1).await); + } + socket.write_all(frames).await.expect("send frames"); + let mut read_exact = async |len: usize| { + let mut bytes = vec![0u8; len]; + tokio::time::timeout( + std::time::Duration::from_secs(5), + socket.read_exact(&mut bytes), + ) + .await + .expect("server frames must arrive") + .expect("socket ended before the server sent Close"); + bytes + }; + loop { + let header = read_exact(2).await; + let payload_len = match header[1] & 0x7f { + 126 => u64::from(u16::from_be_bytes(read_exact(2).await.try_into().unwrap())), + 127 => u64::from_be_bytes(read_exact(8).await.try_into().unwrap()), + len => u64::from(len), + }; + read_exact(usize::try_from(payload_len).expect("frame length")).await; + if header[0] & 0x0f == 0x8 { + break; + } + } + socket + } + + /// Generous watchdog for a socket the server must end on its own. + const EOF_WATCHDOG: Duration = Duration::from_secs(10); + + /// Reads until EOF and returns the time elapsed since `started`. + pub(crate) async fn time_to_eof( + socket: &mut tokio::net::TcpStream, + started: std::time::Instant, + ) -> Duration { + use tokio::io::AsyncReadExt as _; + + let mut buf = [0u8; 64]; + tokio::time::timeout(EOF_WATCHDOG, async { + while socket.read(&mut buf).await.expect("read") != 0 {} + }) + .await + .expect("server must end the socket on its own"); + started.elapsed() + } + + /// Dropping TCP before the peer finishes the close handshake (including + /// in-flight peer frames) can make a client lose the NOTICE and Close + /// (seen live as `1006`). This raw client never replies: the server must + /// hold the socket for its full bounded wait, which starts only after + /// connect, so timing from before connect makes the lower bound + /// independent of scheduler delay. + #[tokio::test] + async fn server_holds_socket_for_client_close_reply() { + let (address, _, _, _) = serve(expired_assertion(), true).await; + let started = std::time::Instant::now(); + let mut socket = raw_client_after_server_close(address).await; + + let elapsed = time_to_eof(&mut socket, started).await; + assert!( + elapsed >= WS_TERMINAL_FLUSH_TIMEOUT, + "server must wait for the client's Close reply, ended after {elapsed:?}" + ); + } + + /// The client withholds its Close reply until the connection has + /// deregistered, so cleanup must not wait on the peer: a server that + /// cleans up after the wait, or ignores the reply, needs at + /// least `WS_TERMINAL_FLUSH_TIMEOUT` from connect. + #[tokio::test] + async fn server_finishes_promptly_when_client_answers_close() { + use tokio::io::AsyncWriteExt as _; + + let (address, _, state, conn_id) = serve(expired_assertion(), true).await; + let started = std::time::Instant::now(); + let mut socket = raw_client_after_server_close(address).await; + + tokio::time::timeout(EOF_WATCHDOG, async { + while state.conn_manager.community_for_conn(conn_id).is_some() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("connection must deregister before the client replies"); + // Masked Close, code 1000: payload [0x03, 0xe8] XOR a zero mask. + socket + .write_all(&[0x88, 0x82, 0, 0, 0, 0, 0x03, 0xe8]) + .await + .expect("send Close reply"); + let elapsed = time_to_eof(&mut socket, started).await; + assert!( + elapsed < WS_TERMINAL_FLUSH_TIMEOUT, + "server must finish on the client's Close, not the timeout; took {elapsed:?}" + ); + } + + /// Final cleanup can block on Redis with no deadline (the pool has no + /// wait timeout). The peer wait runs alongside it, so a stalled cleanup + /// still lets the socket end once the bounded wait expires. + #[tokio::test] + async fn stalled_cleanup_does_not_hold_socket_open() { + let pause = Arc::new(crate::handlers::close::test_seam::Pause::default()); + let (address, _, _, _) = serve_with_seams( + expired_assertion(), + true, + Seams { + cleanup_pause: Some(Arc::clone(&pause)), + ..Seams::default() + }, + ) + .await; + let started = std::time::Instant::now(); + let mut socket = raw_client_after_server_close(address).await; + + tokio::time::timeout(EOF_WATCHDOG, pause.reached.notified()) + .await + .expect("cleanup never reached the pause point"); + let elapsed = time_to_eof(&mut socket, started).await; + assert!(elapsed >= WS_TERMINAL_FLUSH_TIMEOUT); + } + + /// An established session's expiry task stalls at its quiescence barrier + /// while a handler holds an effect permit (e.g. an ephemeral EVENT blocked + /// on Redis). The writer has already sent the Close, so the socket must + /// still end within the bounded peer wait, not when the permit drops. + #[tokio::test] + async fn stalled_expiry_barrier_does_not_hold_socket_open() { + let (hold_tx, hold_rx) = tokio::sync::oneshot::channel(); + let assertion = VerifiedAssertion::for_test( + Some(Keys::generate().public_key()), + vec![chrono::Utc::now() + chrono::Duration::hours(1)], + ); + let (address, _, _, _) = serve_with_seams( + assertion, + true, + Seams { + effect_hold: Some(hold_tx), + ..Seams::default() + }, + ) + .await; + let mut socket = raw_client_after_server_close(address).await; + let _effect = hold_rx + .await + .expect("effect permit taken before the deadline"); + + let elapsed = time_to_eof(&mut socket, std::time::Instant::now()).await; + assert!( + elapsed < WS_TERMINAL_FLUSH_TIMEOUT + Duration::from_millis(500), + "a stalled expiry barrier must not hold the socket open, ended after {elapsed:?}" + ); + } + + /// A Nostr `CLOSE` is handled inline by the reader and can wait on the + /// subscription lock indefinitely (e.g. behind a REQ stuck in Redis). + /// Once the session expires the reader must give back its read half so + /// the socket ends within the bounded peer wait, and the interrupted + /// handler must still finish once the lock frees, before cleanup. + #[tokio::test] + async fn blocked_frame_handler_does_not_hold_socket_open() { + let (effect_tx, effect_rx) = tokio::sync::oneshot::channel(); + let (subs_tx, subs_rx) = tokio::sync::oneshot::channel(); + let assertion = VerifiedAssertion::for_test( + Some(Keys::generate().public_key()), + vec![chrono::Utc::now() + chrono::Duration::hours(1)], + ); + let pause = Arc::new(crate::handlers::close::test_seam::Pause::default()); + let (address, _, state, conn_id) = serve_with_seams( + assertion, + true, + Seams { + cleanup_pause: Some(Arc::clone(&pause)), + effect_hold: Some(effect_tx), + subscriptions_hold: Some(subs_tx), + }, + ) + .await; + // Masked text frame, zero mask: ["CLOSE","s"]. + let close = br#"["CLOSE","s"]"#; + let mut frame = vec![ + 0x81, + 0x80 | u8::try_from(close.len()).expect("len"), + 0, + 0, + 0, + 0, + ]; + frame.extend_from_slice(close); + let connected = tokio::spawn(async move { + raw_client_sending_then_after_server_close(address, &frame).await + }); + drop(effect_rx.await.expect("effect permit")); + let mut subscriptions = subs_rx.await.expect("subscription lock"); + subscriptions.insert("s".to_owned(), 1); + let mut socket = connected.await.expect("raw client"); + + let elapsed = time_to_eof(&mut socket, std::time::Instant::now()).await; + assert!( + elapsed < WS_TERMINAL_FLUSH_TIMEOUT + Duration::from_millis(500), + "a blocked frame handler must not hold the socket open, ended after {elapsed:?}" + ); + assert!( + state.conn_manager.community_for_conn(conn_id).is_some(), + "cleanup must wait for the interrupted handler" + ); + + let lock = Arc::clone(tokio::sync::OwnedMutexGuard::mutex(&subscriptions)); + assert!( + tokio::time::timeout(Duration::from_millis(200), pause.reached.notified()) + .await + .is_err(), + "cleanup must not start while the handed-off CLOSE is still blocked" + ); + drop(subscriptions); + tokio::time::timeout(EOF_WATCHDOG, pause.reached.notified()) + .await + .expect("cleanup never reached subscription release"); + assert!( + !lock.lock().await.contains_key("s"), + "the handed-off CLOSE must finish retiring before cleanup starts" + ); + pause.resume.notify_one(); + tokio::time::timeout(EOF_WATCHDOG, async { + while state.conn_manager.community_for_conn(conn_id).is_some() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("cleanup must complete once the handler finishes"); + assert!( + lock.lock().await.is_empty(), + "the subscription must be retired" + ); + } + + /// When the client closes first, tungstenite only queues our reply Close + /// and writes it on a later read. The peer wait must keep reading past + /// the client's Close so the reply goes out before TCP is dropped, as on + /// an audio exit that hands the socket off without sending a Close. + #[tokio::test] + async fn peer_wait_replies_to_client_initiated_close() { + let app = Router::new().route( + "/", + get(|ws: WebSocketUpgrade| async move { + ws.on_upgrade(|socket| async move { + let (_ws_send, ws_recv) = socket.split(); + await_client_close(ws_recv).await; + }) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("listener address"); + tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + let mut client = connect(address).await; + + client.close(None).await.expect("send Close"); + let reply = tokio::time::timeout(EOF_WATCHDOG, client.next()) + .await + .expect("server must answer or end the socket"); + assert!( + matches!(reply, Some(Ok(Message::Close(_)))), + "client-initiated Close must get its reply Close, got {reply:?}" + ); + } + + /// Serves the real outer `handle_connection` with `state`. + fn serve_outer( + state: Arc, + assertion: Option, + ) -> impl std::future::Future { + let tenant = TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "test.local".to_owned(), + ); + let app = Router::new().route( + "/", + get(move |ws: WebSocketUpgrade| { + let (state, tenant, assertion) = + (Arc::clone(&state), tenant.clone(), assertion.clone()); + async move { + ws.on_upgrade(move |socket| { + handle_connection( + socket, + state, + "127.0.0.1:1234".parse().expect("client address"), + tenant, + assertion, + None, + chrono::Utc::now(), + ) + }) + } + }), + ); + async move { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("listener address"); + tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + address + } + } + + /// Leaves exactly one free slot in the connection budget. + async fn one_slot(state: &AppState) -> tokio::sync::OwnedSemaphorePermit { + let others = u32::try_from(state.conn_semaphore.available_permits() - 1).expect("permits"); + Arc::clone(&state.conn_semaphore) + .acquire_many_owned(others) + .await + .expect("reserve other slots") + } + + /// A socket refused during bootstrap (here an expired session) gets the + /// denial and Close from the `on_not_run` drain. The raw client never + /// replies, so the server must hold the socket for its bounded wait + /// instead of dropping it with client input possibly unread. + #[tokio::test] + async fn bootstrap_drain_holds_socket_for_client_close_reply() { + let state = crate::state::tests::test_state().await; + let address = serve_outer(state, Some(expired_assertion())).await; + + let started = std::time::Instant::now(); + let mut socket = raw_client_after_server_close(address).await; + let elapsed = time_to_eof(&mut socket, started).await; + assert!( + elapsed >= WS_TERMINAL_FLUSH_TIMEOUT, + "bootstrap drain must wait for the client's Close reply, ended after {elapsed:?}" + ); + } + + /// A socket refused during bootstrap keeps its connection slot until the + /// socket ends, including the bounded wait for a Close reply that never + /// comes, so refusals cannot open sockets beyond `BUZZ_MAX_CONNECTIONS`. + #[tokio::test] + async fn refused_bootstrap_holds_connection_slot_until_socket_ends() { + let state = crate::state::tests::test_state().await; + let _others = one_slot(&state).await; + let address = serve_outer(Arc::clone(&state), Some(expired_assertion())).await; + + let mut socket = raw_client_after_server_close(address).await; + assert_eq!( + state.conn_semaphore.available_permits(), + 0, + "the refused socket must hold its slot while open" + ); + time_to_eof(&mut socket, std::time::Instant::now()).await; + tokio::time::timeout(Duration::from_secs(1), async { + while state.conn_semaphore.available_permits() == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("the slot must be released once the socket ends"); + } + + /// The community check runs inside the connection budget: while it + /// stalls on the database, the socket already holds its slot even though + /// the active handler (which sends the AUTH challenge) has not started. + #[tokio::test] + async fn stalled_bootstrap_holds_connection_slot() { + let fake_database = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind database"); + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(Duration::from_secs(60)) + .connect_lazy(&format!( + "postgres://buzz@{}/buzz", + fake_database.local_addr().expect("database address") + )) + .expect("lazy pool"); + let state = crate::state::tests::test_state_with_database_pool(pool).await; + let _others = one_slot(&state).await; + let address = serve_outer(Arc::clone(&state), None).await; + + let mut client = connect(address).await; + let _stalled_check = tokio::time::timeout(Duration::from_secs(5), fake_database.accept()) + .await + .expect("the community check must reach the database") + .expect("accept database connection"); + assert_eq!( + state.conn_semaphore.available_permits(), + 0, + "a socket in bootstrap must hold its slot" + ); + assert!( + tokio::time::timeout(Duration::from_millis(200), client.next()) + .await + .is_err(), + "the active handler must not have started" + ); + } +} diff --git a/crates/buzz-relay/src/handlers/close.rs b/crates/buzz-relay/src/handlers/close.rs index d900bd4241..33b8b6020f 100644 --- a/crates/buzz-relay/src/handlers/close.rs +++ b/crates/buzz-relay/src/handlers/close.rs @@ -89,6 +89,8 @@ pub(crate) async fn close_if_owner( /// detached REQ task can register after this runs. pub(crate) async fn release_connection_subscriptions(conn: &ConnectionState, state: &AppState) { debug_assert!(conn.cancel.is_cancelled()); + #[cfg(test)] + test_seam::pause_at(&test_seam::CLEANUP_PAUSE).await; let mut subs = conn.subscriptions.lock().await; subs.clear(); for removed in state.sub_registry.remove_connection(conn.conn_id) { @@ -116,7 +118,8 @@ pub(crate) async fn release_scope_topics( /// Test-only pause points, scoped to one task so parallel tests don't /// interfere: `PAUSE` inside `close_if_owner` between teardown and the /// terminal frame, `RELEASE_PAUSE` inside `retire_locked` between the -/// registry removal and the topic release. +/// registry removal and the topic release, `CLEANUP_PAUSE` at the start of +/// `release_connection_subscriptions`. #[cfg(test)] pub(crate) mod test_seam { use std::sync::Arc; @@ -131,6 +134,7 @@ pub(crate) mod test_seam { tokio::task_local! { pub(crate) static PAUSE: Arc; pub(crate) static RELEASE_PAUSE: Arc; + pub(crate) static CLEANUP_PAUSE: Arc; } pub(super) async fn pause_at(point: &'static tokio::task::LocalKey>) { diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index effda6cc0a..a154f70757 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -2087,23 +2087,31 @@ async fn run_periodic_until_cancelled( /// grace, this backstops the whole drain and force-exits the process if /// exceeded. It bounds everything after the grace, not the grace itself. /// -/// A single WebSocket can therefore stay open, from SIGTERM, for up to: +/// Each socket's teardown after the grace runs in stages, each with its own +/// budget: /// -/// ```text -/// 5s grace + up to 20s jitter + up to 5s close-frame ack = 30s -/// (fixed) (MAX_DRAIN_JITTER_MS) (RESTART_CLOSE_ACK_TIMEOUT) -/// ``` +/// - up to 20s jitter before the relay sends its restart Close +/// ([`buzz_relay::config::MAX_DRAIN_JITTER_MS`]); +/// - up to 5s for the writer to acknowledge sending that Close +/// (`RESTART_CLOSE_ACK_TIMEOUT` in `state.rs`). If the acknowledgement +/// times out, the connection is cancelled and its teardown can spend a +/// separate 1s flushing the terminal frame (`WS_TERMINAL_FLUSH_TIMEOUT` in +/// `connection.rs`); +/// - up to 1s waiting for the client's Close reply (the same constant). This +/// wait starts only once the reader has returned and the writer has joined. /// -/// The 5s grace runs before the 30s hard-drain clock starts, so the jitter -/// (capped at [`buzz_relay::config::MAX_DRAIN_JITTER_MS`] = 20s) plus the -/// per-connection close-frame ack wait (`RESTART_CLOSE_ACK_TIMEOUT` = 5s in -/// `state.rs`) sum to 25s and stay inside the 30s hard drain. Total worst -/// case from SIGTERM to forced exit is 5s + 30s = 35s. Both fit inside the -/// chart's `terminationGracePeriodSeconds: 60` (`deploy/charts/buzz/values.yaml`), -/// which leaves headroom but assumes no `preStop` hook adds further delay. -/// With jitter off (`BUZZ_DRAIN_JITTER_MS=0`, the default) sockets close -/// all-at-once right after the grace, so the per-socket delay collapses to -/// roughly the 5s grace plus the ack wait. +/// These are stage budgets, not a guaranteed per-socket total: after a +/// successful acknowledgement nothing cancels the reader, so a silent peer +/// does not go straight into the peer wait. The 30s hard drain is what bounds +/// every socket. The configured process shutdown budget, from SIGTERM to +/// forced exit, is 5s + 30s = 35s, which fits within the chart's +/// `terminationGracePeriodSeconds: 60` (`deploy/charts/buzz/values.yaml`). +/// That leaves headroom but assumes no `preStop` hook adds further delay. +/// With jitter off (`BUZZ_DRAIN_JITTER_MS=0`, the default), `drain_all()` +/// queues every restart Close right after the grace and cancels the +/// connections immediately, without waiting for any acknowledgement. The +/// terminal-flush and peer-wait budgets then apply only to sockets whose +/// teardown reaches those stages. const GRACEFUL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); async fn serve( diff --git a/crates/buzz-relay/src/nip_fi_test_hooks.rs b/crates/buzz-relay/src/nip_fi_test_hooks.rs index 55d8e5b0e8..1769164ef7 100644 --- a/crates/buzz-relay/src/nip_fi_test_hooks.rs +++ b/crates/buzz-relay/src/nip_fi_test_hooks.rs @@ -210,6 +210,19 @@ make_hook!(audio_refusal_frames_hook, before_audio_refusal_frames); make_hook!(join_refusal_rollback_hook, before_join_refusal_rollback); make_hook!(audio_participant_fanout_hook, after_participant_fanout); make_hook!(audio_add_peer_hook, after_add_peer); +// `before_audio_teardown_cleanup`: fires in `run_active_audio_connection` once +// the writer has sent its Close and the socket is handed off, before the +// teardown that writes to Postgres and Redis. Holding it stands in for a +// stalled cleanup that must not hold the socket open. +make_hook!(audio_teardown_cleanup_hook, before_audio_teardown_cleanup); +// `before_room_archive_recheck`: fires in `run_active_audio_connection` after the +// membership check, just before the archive re-check, so a test can archive +// the channel between the two and drive the archived-channel exit. +make_hook!(room_archive_recheck_hook, before_room_archive_recheck); +// `before_archived_exit_cleanup`: fires on the archived-channel exit once the +// socket is handed off, before the lease release. Holding it stands in for a +// stalled Redis release that must not hold the socket open. +make_hook!(archived_exit_cleanup_hook, before_archived_exit_cleanup); // `before_lease_permit`: fires in `handle_active_audio_connection` immediately // before the effect permit that covers join-owner resolution. Cancelling here // proves the resolver (and its lease CAS) never runs without a permit. diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index bd7961124d..3850d288c5 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -343,6 +343,17 @@ run_unit_tests() { connection::tests::expiry_notice_queued_on_ctrl_before_cancel connection::tests::f3_root_outer_wrapper_delivers_denial_on_bootstrap_cancellation connection::tests::f3_root_pre_built_expired_gate_terminates_connection + connection::terminal_close_delivery_tests::key_pairing_mismatch_closes_with_1008_on_real_socket + connection::terminal_close_delivery_tests::session_expiry_denial_closes_with_1008_on_real_socket + connection::terminal_close_delivery_tests::server_holds_socket_for_client_close_reply + connection::terminal_close_delivery_tests::server_finishes_promptly_when_client_answers_close + connection::terminal_close_delivery_tests::stalled_cleanup_does_not_hold_socket_open + connection::terminal_close_delivery_tests::bootstrap_drain_holds_socket_for_client_close_reply + connection::terminal_close_delivery_tests::stalled_expiry_barrier_does_not_hold_socket_open + connection::terminal_close_delivery_tests::blocked_frame_handler_does_not_hold_socket_open + connection::terminal_close_delivery_tests::refused_bootstrap_holds_connection_slot_until_socket_ends + connection::terminal_close_delivery_tests::stalled_bootstrap_holds_connection_slot + connection::terminal_close_delivery_tests::peer_wait_replies_to_client_initiated_close handlers::auth::tests::b2_pre_cancelled_connection_never_becomes_authenticated handlers::auth::tests::fi_ban_check_error_emits_terminal_authorization_unavailable handlers::auth::tests::fi_root_authorization_denied_rows_emit_identical_frames From cc6336d9851983ca341d40cf2332052e9394a4f2 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 19 Oct 2026 16:29:32 -0400 Subject: [PATCH 23/28] feat(relay): tag every membership notice with its channel type (#8406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary - When someone is added to or removed from a system channel, the relay now sends them the usual "you were added / removed" notice. Before, it sent nothing, so a client could learn about a new system channel only by rereading every channel. - **A client sees a notice only if it knows the channel's type.** Every membership notice now carries its channel type. Clients that do not send `#t` (every client today) see only the types that existed before system channels, so they see no change. A type that Buzz adds later is hidden from them by default. - The notices are stored like every other membership notice, so a client that was offline can catch up with `since` instead of rereading all its channels. ## Details - Every kind:44100 (added) and kind:44101 (removed) notice now carries `["t", ]`, for example `["t","stream"]` or `["t","system"]`. - **One place reads the channel type:** `emit_membership_notification` in `side_effects.rs`, where the relay signs the notice. A channel's type is set when it is created and no code path changes it, so the tag stays correct. If the channel row is missing, the relay sends no notice rather than an untagged one. - **The rule (NIP-CL):** - a filter with `#t` matches a notice only when the notice's type is one of its `#t` values; - a filter without `#t` matches only the **legacy types**: `stream`, `forum`, `dm`, `workflow`. This list is frozen (`LEGACY_NOTICE_CHANNEL_TYPES`), so a new type never joins it; - a notice without a `t` tag was stored before this change. It is always a legacy type, so the relay treats it as one, and replay across the upgrade stays complete. - **Fails closed.** The rule lives in two shared places, and both default to the legacy types. A read path that is not updated behaves as it does today: - `filters_match` / `notice_visible` (buzz-core). Live fan-out, the REQ history post-filter, COUNT, the HTTP bridge and push all match through it. - `EventQuery::notice_channel_types` (buzz-db). Its SQL keeps only visible notices before `LIMIT`, so page limits and COUNT stay exact. `None` means legacy types. Only `filter_to_query_params` sets it, from the filter's `#t`. - A client that knows every current type sends one filter: `{"kinds":[44100,44101],"#p":[],"#t":["stream","forum","dm","workflow","system"]}`. NIP-CL and NOSTR.md describe this. - **Only the target receives a notice.** A notice names the target, the channel and who made the change, and the relay signs it, so knowing its id is not permission to read it. 44100/44101 are now in `RESULT_GATED_KINDS`, the one list used by the `ids` exemption check, the per-result reader check (WS and HTTP history, push), live fan-out (same pod and Redis) and COUNT. This also closes the same id lookup for ordinary channels' notices. - No migration: 44100/44101 are already excluded from search indexing. ## Testing - `buzz-core`: a table test covers untagged, `stream`, `system` and a future type against filters without `#t`, with `#t` for one type, and with `#t` for every type. Other kinds keep the plain `#t` match. - Postgres tests (`system_channel_postgres_tests.rs`): live fan-out, REQ history, COUNT and the HTTP bridge, for a filter without `#t`, a `#t:["system"]` filter and an all-types filter; an untagged legacy notice; a non-target with an `ids` filter on each path; and a raw `EventQuery` that does not set the field. - I broke each part on purpose and the tests failed: no type tag on the notice, no rule in `filters_match`, no SQL rule, and no untagged clause in the SQL. - End to end: I built a relay container from f24f8e7ba and ran buzz-app's `container-relay.e2e.test.ts` (block/buzz-app#1290) against it. The live system channel test passed 5 of 5 runs. Paired client change: block/buzz-app#1290. It works unchanged against this relay. --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- NOSTR.md | 10 +- crates/buzz-core/src/filter.rs | 163 +++++++- crates/buzz-core/src/kind.rs | 17 +- crates/buzz-db/src/store/channel_members.rs | 34 +- crates/buzz-db/src/store/event.rs | 58 +++ crates/buzz-relay/src/handlers/count.rs | 2 +- crates/buzz-relay/src/handlers/event.rs | 13 +- crates/buzz-relay/src/handlers/req.rs | 48 ++- .../buzz-relay/src/handlers/side_effects.rs | 26 +- .../handlers/system_channel_postgres_tests.rs | 395 ++++++++++++++++-- docs/nips/NIP-CL.md | 49 ++- 11 files changed, 736 insertions(+), 79 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index 62e438d30d..96ccb0edc0 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -152,7 +152,10 @@ out unless they ask for it: combine with OR, so `#t:["system",