From b36600fc440615e2565f94bffc800e0495180f6b Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 16 Sep 2026 20:29:42 -0700 Subject: [PATCH 01/22] feat(mobile): show build number with version in settings (#7697) Mobile Settings now identifies the installed build as e.g. `v0.16.0 (432)` rather than just `v0.16.0`. If the platform supplies no build number, the footer keeps the existing version-only display. | Before | After | | --- | --- | | Marketing version only | Version and native build number | | ![Before: v0.16.0](https://raw.githubusercontent.com/block/buzz/e8449f63d1fabe80ff86d395ac1177912c5d1bc5/pr-7697--before.png) | ![After: v0.16.0 (432)](https://raw.githubusercontent.com/block/buzz/e8449f63d1fabe80ff86d395ac1177912c5d1bc5/pr-7697--after.png) | --------- Signed-off-by: Tom Brow --- crates/buzz-relay/src/handlers/event.rs | 4 +- .../lib/features/settings/settings_page.dart | 10 ++- .../settings/settings_page_loading_test.dart | 69 +++++++++++++++++++ .../features/settings/settings_page_test.dart | 62 +++++++++++++++++ 4 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 mobile/test/features/settings/settings_page_loading_test.dart diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index c117392c16..3f767c1874 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1523,7 +1523,7 @@ mod tests { conn_id: Uuid::new_v4(), tenant: tenant.clone(), remote_addr: "127.0.0.1:1234".parse().unwrap(), - auth_state: RwLock::new(crate::connection::AuthState::Authenticated( + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( buzz_auth::AuthContext { pubkey: keys.public_key(), scopes: vec![], @@ -1668,7 +1668,7 @@ mod tests { conn_id: Uuid::new_v4(), tenant: tenant.clone(), remote_addr: "127.0.0.1:1234".parse().unwrap(), - auth_state: RwLock::new(crate::connection::AuthState::Authenticated( + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( buzz_auth::AuthContext { pubkey: keys.public_key(), scopes: vec![], diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 3253e2212e..24ac64bdf0 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -225,7 +225,10 @@ class SettingsPage extends HookConsumerWidget { ), ), if (packageInfo.hasData) - _VersionFooter(version: packageInfo.data!.version), + _VersionFooter( + version: packageInfo.data!.version, + buildNumber: packageInfo.data!.buildNumber, + ), ], ), ); @@ -233,9 +236,10 @@ class SettingsPage extends HookConsumerWidget { } class _VersionFooter extends StatelessWidget { - const _VersionFooter({required this.version}); + const _VersionFooter({required this.version, required this.buildNumber}); final String version; + final String buildNumber; @override Widget build(BuildContext context) { @@ -245,7 +249,7 @@ class _VersionFooter extends StatelessWidget { padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs), child: Center( child: Text( - 'v$version', + buildNumber.isEmpty ? 'v$version' : 'v$version ($buildNumber)', style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant.withValues(alpha: 0.6), ), diff --git a/mobile/test/features/settings/settings_page_loading_test.dart b/mobile/test/features/settings/settings_page_loading_test.dart new file mode 100644 index 0000000000..866da15185 --- /dev/null +++ b/mobile/test/features/settings/settings_page_loading_test.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/shared/community/community_membership_provider.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets('settings remains usable while package metadata loads', ( + tester, + ) async { + final metadata = Completer>(); + const channel = MethodChannel('dev.fluttercommunity.plus/package_info'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) => metadata.future, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.admin), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(2)), + child: child!, + ), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + expect(find.text('Invite to community'), findsOneWidget); + + expect(find.text('v0.16.0 (432)'), findsNothing); + expect(find.byTooltip('Close settings'), findsOneWidget); + metadata.complete({ + 'appName': 'Buzz', + 'packageName': 'xyz.block.buzz', + 'version': '0.16.0', + 'buildNumber': '432', + 'buildSignature': '', + }); + await tester.pumpAndSettle(); + expect(find.text('v0.16.0 (432)'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index e8f6fb6ade..1799d7a36d 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -11,9 +11,71 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { + setUp(() { + PackageInfo.setMockInitialValues( + appName: 'Buzz', + packageName: 'xyz.block.buzz', + version: '0.16.0', + buildNumber: '432', + buildSignature: '', + ); + }); + + for (final buildNumber in ['432', '', '2147483647']) { + testWidgets('shows version with build number "$buildNumber"', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + PackageInfo.setMockInitialValues( + appName: 'Buzz', + packageName: 'xyz.block.buzz', + version: '0.16.0', + buildNumber: buildNumber, + buildSignature: '', + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.admin), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(2)), + child: child!, + ), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + expect(find.text('Invite to community'), findsOneWidget); + await tester.pumpAndSettle(); + expect( + find.text(buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + } + testWidgets('shows the persisted per-community push opt-in on iOS', ( tester, ) async { From 4ab4f786085a23fe6126529861840eff6048ceee Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 16 Sep 2026 21:48:05 -0700 Subject: [PATCH 02/22] fix(web): route mobile invite downloads to app stores (#7702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route the invite page’s existing download link to the App Store on iOS/iPadOS and Google Play on Android. Appearance and app-opening behavior stay unchanged. The browser test checks platform routing using shared URL constants. Web build, lint, and all six smoke tests pass. Rebased onto main to pick up the relay test compilation fix. Refs #3357. Alternative to #3464 with a smaller implementation and no duplicated store URLs in tests. --------- Signed-off-by: Tom Brow --- web/src/shared/lib/buzz-download.ts | 30 ++++++++++++++++++++--- web/tests/e2e/smoke.spec.ts | 38 +++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/web/src/shared/lib/buzz-download.ts b/web/src/shared/lib/buzz-download.ts index 3c198382b4..5e2b6670fa 100644 --- a/web/src/shared/lib/buzz-download.ts +++ b/web/src/shared/lib/buzz-download.ts @@ -1,11 +1,22 @@ export const BUZZ_RELEASES_URL = "https://github.com/block/buzz/releases"; +/** Official iOS download destination. */ +export const BUZZ_IOS_APP_STORE_URL = "https://apps.apple.com/app/id6779728271"; +/** Official Android download destination. */ +export const BUZZ_ANDROID_PLAY_STORE_URL = + "https://play.google.com/store/apps/details?id=xyz.block.buzz.mobile"; const BUZZ_RELEASES_API_URL = "https://api.github.com/repos/block/buzz/releases?per_page=10"; const CACHE_KEY = "buzz.latestDownload.v1"; const CACHE_TTL_MS = 60 * 60 * 1000; export type BuzzDownloadPlatform = { - operatingSystem: "linux" | "macos" | "windows" | "unknown"; + operatingSystem: + | "linux" + | "macos" + | "windows" + | "ios" + | "android" + | "unknown"; architecture: "arm64" | "x64" | "unknown"; }; @@ -36,13 +47,17 @@ function normalizeOperatingSystem( // Compatibility tokens are treacherous: iPadOS can report MacIntel and a // Macintosh UA, while Android and ChromeOS expose Linux platform strings. - // Reject non-desktop devices before admitting desktop-looking signals. + // Classify mobile devices before admitting desktop-looking signals. const isIPadDesktopMode = platform === "macintel" && navigatorValue.maxTouchPoints > 1; + if (isIPadDesktopMode || /iphone|ipad|ipod/.test(userAgent)) return "ios"; + // Fire OS identifies as Android but does not ship with Google Play. + if (/kindle|silk/.test(userAgent)) return "unknown"; + if (/android/.test(userAgent) || platform === "android") return "android"; + const isUnsupportedDevice = userAgentData?.mobile === true || - isIPadDesktopMode || - /android|iphone|ipad|ipod|mobile|tablet|windows phone|iemobile|opera mini|opera mobi|webos|blackberry|bb10|kindle|silk|kaios|cros/.test( + /mobile|tablet|windows phone|iemobile|opera mini|opera mobi|webos|blackberry|bb10|kindle|silk|kaios|cros/.test( userAgent, ); if (isUnsupportedDevice) return "unknown"; @@ -87,6 +102,9 @@ export async function detectBuzzDownloadPlatform( navigatorValue, userAgentData, ); + if (operatingSystem === "ios" || operatingSystem === "android") { + return { operatingSystem, architecture: "unknown" }; + } let architecture = normalizeArchitecture(navigatorValue.userAgent); if (userAgentData?.getHighEntropyValues) { @@ -142,6 +160,10 @@ export function selectBuzzDownloadUrl( export async function resolveBuzzDownloadUrlForPlatform( platform: BuzzDownloadPlatform, ): Promise { + if (platform.operatingSystem === "ios") return BUZZ_IOS_APP_STORE_URL; + if (platform.operatingSystem === "android") + return BUZZ_ANDROID_PLAY_STORE_URL; + try { const cached = JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "null") as { expiresAt: number; diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 22e2628165..b33caa62fa 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -1,5 +1,10 @@ import { createHash } from "node:crypto"; import { expect, test } from "@playwright/test"; +import { + BUZZ_ANDROID_PLAY_STORE_URL, + BUZZ_IOS_APP_STORE_URL, + BUZZ_RELEASES_URL, +} from "../../src/shared/lib/buzz-download"; test("home page loads with Buzz branding", async ({ page }) => { await page.goto("/"); @@ -260,12 +265,13 @@ test("invite asks Safari users to choose their Mac download", async ({ await context.close(); }); -test("invite download falls back for mobile and non-desktop devices", async ({ +test("invite download links to the appropriate platform destination", async ({ browser, }) => { - const unsupportedDevices = [ + const devices = [ { name: "iPhone Safari", + expectedUrl: BUZZ_IOS_APP_STORE_URL, platform: "iPhone", userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15", @@ -273,6 +279,7 @@ test("invite download falls back for mobile and non-desktop devices", async ({ }, { name: "iPadOS desktop mode", + expectedUrl: BUZZ_IOS_APP_STORE_URL, platform: "MacIntel", userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15", @@ -280,20 +287,30 @@ test("invite download falls back for mobile and non-desktop devices", async ({ }, { name: "Android phone", + expectedUrl: BUZZ_ANDROID_PLAY_STORE_URL, platform: "Linux armv8l", userAgent: "Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro) AppleWebKit/537.36 Mobile", maxTouchPoints: 5, }, + { + name: "Fire tablet", + expectedUrl: BUZZ_RELEASES_URL, + platform: "Linux armv8l", + userAgent: + "Mozilla/5.0 (Linux; Android 9; KFMAWI) AppleWebKit/537.36 Silk/126.0 like Chrome/126.0.0.0 Safari/537.36", + maxTouchPoints: 5, + }, { name: "ChromeOS", + expectedUrl: BUZZ_RELEASES_URL, platform: "Linux x86_64", userAgent: "Mozilla/5.0 (X11; CrOS x86_64 16093.68.0) AppleWebKit/537.36", maxTouchPoints: 0, }, ]; - for (const device of unsupportedDevices) { + for (const device of devices) { const context = await browser.newContext({ userAgent: device.userAgent }); await context.addInitScript(({ platform, maxTouchPoints }) => { Object.defineProperties(navigator, { @@ -301,7 +318,12 @@ test("invite download falls back for mobile and non-desktop devices", async ({ maxTouchPoints: { configurable: true, value: maxTouchPoints }, userAgentData: { configurable: true, - value: { platform, mobile: maxTouchPoints > 0 }, + value: { + platform, + mobile: maxTouchPoints > 0, + // Store routing must not wait for desktop architecture hints. + getHighEntropyValues: () => new Promise(() => {}), + }, }, }); }, device); @@ -343,7 +365,13 @@ test("invite download falls back for mobile and non-desktop devices", async ({ await expect( page.getByRole("link", { name: "Download it now" }), device.name, - ).toHaveAttribute("href", "https://github.com/block/buzz/releases"); + ).toHaveAttribute("href", device.expectedUrl); + await context.route(device.expectedUrl, (route) => + route.fulfill({ contentType: "text/html", body: "Store destination" }), + ); + const destination = context.waitForEvent("page"); + await page.getByRole("link", { name: "Download it now" }).click(); + await expect(await destination).toHaveURL(device.expectedUrl); await context.close(); } }); From 6b1e45d2f6084a3754a09bb1b800f370ce5399dd Mon Sep 17 00:00:00 2001 From: ravarora2 <130506156+ravarora2@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:34:31 -0400 Subject: [PATCH 03/22] Isolate S3 storage metrics from the relay (#7543) ## Why **This PR moves the S3 storage scan out of the serving relay and into a separate batch job.** The job saves a complete result in PostgreSQL. The relay reads that result, exports the existing storage totals, and reports the snapshot's freshness and database-read health. ```text Today: relay -> scan S3 -> calculate totals -> Datadog metrics After rollout: CronJob -> scan S3 -> PostgreSQL snapshot -> relay -> Datadog metrics ``` The code in this PR is the new pipeline. It adds the worker, database snapshot, relay read mode, and Helm CronJob template. It does **not** enable the pipeline in production. The chart keeps the CronJob disabled, and the relay keeps its current inline mode by default. [squareup/builderbot-platform-core-infrastructure#244](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/244) will set the production limits, enable the job, and switch the relay to the saved snapshot after this PR merges. The relay currently lists the media bucket once an hour and calculates storage totals inside the serving process. The production bucket now exceeds the relay's one million object cap, so each sweep fails and the last good per-community values remain stale. The Datadog dashboard can no longer show current storage use by community. Raising that cap inside the serving relay would move a larger, memory-heavy scan into the process that owns live WebSocket connections. The scan retains state for unique blob hashes and community bindings, so its memory use grows with the bucket. A large scan could raise relay memory use, trigger restarts, and drop user connections. The goal is to restore current metrics without putting a production-scale bucket scan in the relay's failure domain. No media objects move or change. Only the process that calculates the totals and the handoff to the relay change. ## What - Add `buzz-admin storage-snapshot`, a run-once worker that scans S3 and saves one complete accounting snapshot in PostgreSQL. - Add `external` relay mode to read the saved snapshot. Keep the current `inline` mode as the default, and add `off` as a fail-closed option. - Add an opt-in Helm CronJob for the worker. The 10 million object cap exists only on this Job. - Keep the current fleet and per-community gauges, and add snapshot age, generation duration, configured cap, cap utilization, and database-load health gauges. - Add an additive migration for the latest complete snapshot and its run metadata. ## Overall plan 1. Merge this PR to add the worker, database handoff, relay modes, and disabled-by-default chart support. This step does not change production behavior. 2. Publish the relay image and Helm chart `0.1.9` from `main`. 3. Update [squareup/builderbot-platform-core-infrastructure#244](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/244) to pin those final artifacts and rerender its manifests. 4. Use the infrastructure PR to deploy the CronJob with a 10 million object cap, a one-hour deadline, and a 20 GiB memory limit. 5. Start one manual Job during a staffed window and verify its snapshot, duration, memory use, relay stability, and Datadog output. 6. Keep the daily schedule and `external` relay mode only after that gate passes, then add alerts for stale snapshots, snapshot-load failure, failed jobs, cap pressure, duration, and worker memory. The code and infrastructure remain separate so this PR can ship the mechanism without silently enabling a production-scale scan. The infrastructure PR owns the production settings and rollout gate. ## How ### Complete snapshot handoff The worker takes a deployment-wide PostgreSQL advisory lock before it lists S3. This prevents scheduled and manual workers from overlapping, even if Kubernetes starts both. The final database replacement runs through that same lock-owning session. If the session dies during the scan, publication fails instead of letting the stale worker overwrite its successor. A worker that no longer owns the lock cannot publish through another pooled connection. The worker reads S3 in pages of 1,000 objects and folds each page into physical totals, logical per-community usage, and anomaly counts. It stops before folding a page that would exceed `--max-objects` and emits structured progress every 100,000 objects. The worker replaces the singleton database row only after the full scan succeeds. A listing error, malformed page, cap breach, serialization error, lost lock session, or database error cannot publish a partial snapshot. The last completed snapshot stays available. Each stored result includes its completion time, duration, cap, and image or source revision. PostgreSQL is the handoff between the batch worker and the relay. The worker writes one complete deployment-wide snapshot, and the relay reads that snapshot on its normal leader metrics tick. No new network service or worker endpoint is required. ### Relay behavior `BUZZ_STORAGE_METRICS=inline` keeps the existing in-process sweep and its one million object default. `external` loads the newest completed worker snapshot on each leader metrics tick. `off` emits no storage-family metrics. An unknown value fails closed to `off`. External mode republishes the existing fleet, per-community, and anomaly metric names, so those current queries remain valid. Snapshot handoff health uses a separate family: `buzz_storage_snapshot_age_seconds`, `buzz_storage_snapshot_duration_seconds`, `buzz_storage_snapshot_max_objects`, `buzz_storage_snapshot_cap_utilization`, and `buzz_storage_snapshot_load_ok`. The `buzz_storage_sweep_*` gauges remain inline-only because they describe a scan attempted by the relay process. External mode does not synthesize `sweep_ok=1` from the last successful database row: an old row proves only that an earlier worker completed. Kubernetes CronJob/Job status is authoritative for the latest worker result, including non-zero exit, deadline expiry, and OOM termination. The relay keeps the last complete snapshot available when a worker run fails. Snapshot age shows that no replacement arrived on schedule, while `buzz_storage_snapshot_load_ok` distinguishes staleness from a missing row, database read failure, or invalid payload. If a community disappears, changes host, or leaves the configured emission scope, the relay zeroes the old labeled series instead of leaving stale values behind. Per-community bytes are logical referenced media usage. If two communities reference the same blob, each community receives the blob's bytes. Fleet physical totals still count each S3 object once. This PR preserves those existing metric semantics. ### Deployment boundary The CronJob is disabled by default. When enabled, it uses `concurrencyPolicy: Forbid`, no retry, a configurable active deadline, bounded history, explicit CPU and memory resources, a read-only root filesystem, and no Istio sidecar by default. The worker receives only `DATABASE_URL`, S3 settings, optional S3 credential references, and the code revision. It does not receive the relay private key, Git hook HMAC secret, Redis URL, or the full relay secret as environment variables. Operators may reuse the Buzz service account or select a dedicated one. ### Non-goals - This PR does not enable the worker or `external` mode in production. - This PR does not change media uploads, object ownership, retention, or deletion. - This PR does not change the names or meaning of the existing fleet, per-community, or anomaly storage gauges. - This PR does not add a lease service or allow multiple workers to publish concurrently. ## Risk The application and chart defaults do not enable the new worker or raise the serving relay's cap. The database change is an additive deployment-global singleton table. The relay's default remains `inline`, so merging this PR alone does not change the active metrics path. The main rollout risk is worker memory use because the fold retains state for distinct blob hashes and community bindings. The first production-sized run must confirm peak memory and duration before the schedule is trusted. The 10 million cap and one-hour deadline bound the first deployment, but they do not replace measurement. External mode also needs stale-snapshot, snapshot-load, and job-failure monitors because the relay intentionally keeps publishing the last complete result after a failed job or read error. This behavior avoids gaps and partial data, while snapshot age and load health tell operators when the result or PostgreSQL handoff needs attention. Job status remains necessary for immediate failures and termination reasons that a killed process cannot record itself. ## Testing No production S3 scan was run from this PR. Automated regressions verify that external mode keeps stale last-good totals visible without emitting inline attempt-health gauges, reports database-load failure while retaining the cache, and leaves inline telemetry unchanged. Mutation checks also prove those tests fail if external mode is routed through inline emission or a failed load is reported as healthy. After deployment, create one manual Job from the CronJob during a staffed window. Require a completed Job, exactly one new snapshot, fewer objects than the configured cap, acceptable duration and peak memory, no relay restart regression, restored Datadog points, and no OpenMetrics sample truncation. ## Next steps 1. Complete review and merge this PR. 2. Publish chart `0.1.9` and the final main-branch relay image. 3. Replace the provisional pins in [squareup/builderbot-platform-core-infrastructure#244](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/244), rerender, and require clean CI. 4. Run the staffed manual Job gate and record object count, peak memory, duration, snapshot revision, and relay stability. 5. Confirm the relay reads the new snapshot in `external` mode and the existing `block.buzz_relay.*` storage series update again. 6. Enable the daily 03:17 UTC schedule and add dashboard panels and monitors for snapshot age, snapshot-load health, Job failures, cap pressure, duration, and worker memory. If production must roll back to an older relay image, set `BUZZ_STORAGE_METRICS=off` first. Older images do not understand `external` and would resume the failing inline scan. ## Bigger picture This is the application half of the storage-metrics repair. The paired infrastructure PR owns the production resource limits, schedule, artifact pins, secret boundary, and monitored rollout. Keeping those changes separate lets this PR remain safe by default while still providing a complete path to current per-community S3 totals. Related issue: N/A. No matching issue or pull request exists in `block/buzz`. Generated with Codex --------- Signed-off-by: Ravneet Arora Co-authored-by: Codex --- Justfile | 8 +- crates/buzz-admin/src/main.rs | 169 ++++++++ crates/buzz-db/src/lib.rs | 2 +- crates/buzz-db/src/runtime/migration.rs | 8 +- crates/buzz-db/src/store/mod.rs | 2 + .../buzz-db/src/store/storage_accounting.rs | 257 ++++++++++++ crates/buzz-media/src/bucket_index.rs | 25 +- crates/buzz-relay/src/main.rs | 73 +++- crates/buzz-relay/src/storage_sweep.rs | 374 ++++++++++++++++-- deploy/charts/buzz/Chart.yaml | 4 +- deploy/charts/buzz/templates/_helpers.tpl | 8 + deploy/charts/buzz/templates/deployment.yaml | 1 + .../templates/storage-accounting-cronjob.yaml | 74 ++++ .../buzz/tests/storage_accounting_test.yaml | 139 +++++++ deploy/charts/buzz/values.schema.json | 17 + deploy/charts/buzz/values.yaml | 47 ++- .../0046_storage_accounting_snapshots.sql | 15 + schema/schema.sql | 15 + scripts/run-tests.sh | 9 + 19 files changed, 1169 insertions(+), 78 deletions(-) create mode 100644 crates/buzz-db/src/store/storage_accounting.rs create mode 100644 deploy/charts/buzz/templates/storage-accounting-cronjob.yaml create mode 100644 deploy/charts/buzz/tests/storage_accounting_test.yaml create mode 100644 migrations/0046_storage_accounting_snapshots.sql diff --git a/Justfile b/Justfile index 4554c694ef..3f5bec95a6 100644 --- a/Justfile +++ b/Justfile @@ -385,6 +385,12 @@ test-unit: # #[ignore]d, so --lib runs only the infra-free set. Without this gate a # stray file in migrations/ or a broken lint ships green. cargo nextest run -p buzz-db --lib + # Storage accounting crosses three crates whose focused regression + # suites are otherwise absent from the infra-free unit lane. + cargo nextest run -p buzz-media --lib \ + -E 'test(=bucket_index::tests::bucket_snapshot_json_round_trip_preserves_community_keys)' + cargo nextest run -p buzz-admin \ + -E 'test(=storage_snapshot_tests::failed_fold_never_invokes_snapshot_persistence)' # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the @@ -451,7 +457,7 @@ test-unit: # the ~30s sqlx acquire timeout, so they do not belong in the infra-free # unit job either. cargo nextest run -p buzz-relay --lib \ - -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/)' + -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/)' # ACP author-gate and queue tests protect the trust boundary between # relay events and agent prompts. They are infra-free; ignored lifecycle # tests remain excluded and run in their dedicated integration lanes. diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 19a3b1d9d4..bfb256e311 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -22,12 +22,16 @@ mod deletions; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::time::Instant; use anyhow::Result; use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST; use buzz_core::tenant::{relay_url_authority, TenantContext}; use buzz_db::{Db, DbConfig}; +use buzz_media::{BucketSnapshot, MediaConfig, MediaStorage, S3AddressingStyle, SweepError}; use buzz_pubsub::{EventTopic, PubSubManager}; use clap::{Parser, Subcommand}; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -78,6 +82,12 @@ enum Command { GenerateKey, /// Run pending database migrations. Migrate, + /// Compute one complete S3 storage snapshot and persist it for relay readers. + StorageSnapshot { + /// Abort before folding a page that would exceed this object count. + #[arg(long, default_value_t = 10_000_000)] + max_objects: u64, + }, /// Inspect deployment-wide Buzz product feedback. ProductFeedback { #[command(subcommand)] @@ -154,6 +164,7 @@ async fn run(cli: Cli) -> Result { println!("Database migrations complete."); Ok(0) } + Command::StorageSnapshot { max_objects } => cmd_storage_snapshot(max_objects).await, Command::AddMember { pubkey, role } => cmd_add_member(pubkey, role).await, Command::RemoveMember { pubkey, role } => cmd_remove_member(pubkey, role).await, Command::ListMembers => cmd_list_members().await, @@ -168,6 +179,140 @@ async fn run(cli: Cli) -> Result { } } +async fn cmd_storage_snapshot(max_objects: u64) -> Result { + let max_objects_db = i64::try_from(max_objects) + .map_err(|_| anyhow::anyhow!("--max-objects must be at most {}", i64::MAX))?; + if max_objects == 0 { + return Err(anyhow::anyhow!("--max-objects must be greater than zero")); + } + + let db = connect_db().await?; + let mut leader = db.try_lock_storage_accounting().await?.ok_or_else(|| { + anyhow::anyhow!("another storage-snapshot worker already holds the lease") + })?; + let storage = Arc::new(MediaStorage::new(&storage_config_from_env()?)?); + let code_sha = + std::env::var("BUZZ_STORAGE_SNAPSHOT_CODE_SHA").unwrap_or_else(|_| "unknown".to_string()); + if code_sha.is_empty() || code_sha.len() > 128 { + return Err(anyhow::anyhow!( + "BUZZ_STORAGE_SNAPSHOT_CODE_SHA must contain 1 to 128 bytes" + )); + } + + println!( + "{}", + serde_json::json!({ + "event": "storage_snapshot_started", + "max_objects": max_objects, + "code_sha": code_sha, + }) + ); + let run_started = Instant::now(); + let listed_objects = Arc::new(AtomicU64::new(0)); + let fold = buzz_media::fold_bucket_listing(max_objects, move |token| { + let storage = Arc::clone(&storage); + let listed_objects = Arc::clone(&listed_objects); + async move { + let page = storage.list_page(token, 1000).await?; + let page_objects = u64::try_from(page.objects.len()).unwrap_or(u64::MAX); + let before = listed_objects.fetch_add(page_objects, Ordering::Relaxed); + let after = before.saturating_add(page_objects); + if before / 100_000 != after / 100_000 { + println!( + "{}", + serde_json::json!({ + "event": "storage_snapshot_progress", + "listed_objects": after, + "max_objects": max_objects, + }) + ); + } + Ok(page) + } + }); + let snapshot_code_sha = code_sha.clone(); + let persisted = persist_completed_fold(fold, move |encoded, duration_ms| async move { + leader + .save_snapshot(&encoded, duration_ms, max_objects_db, &snapshot_code_sha) + .await?; + Ok(()) + }) + .await; + let (snapshot, duration_ms) = match persisted { + Ok(completed) => completed, + Err(error) => { + println!( + "{}", + serde_json::json!({ + "event": "storage_snapshot_failed", + "duration_ms": i64::try_from(run_started.elapsed().as_millis()).unwrap_or(i64::MAX), + "max_objects": max_objects, + "code_sha": code_sha, + "error": error.to_string(), + }) + ); + return Err(error); + } + }; + println!( + "{}", + serde_json::json!({ + "event": "storage_snapshot_completed", + "duration_ms": duration_ms, + "listed_objects": snapshot.physical_objects, + "listed_bytes": snapshot.physical_bytes, + "logical_objects": snapshot.logical_objects, + "logical_bytes": snapshot.logical_bytes, + "max_objects": max_objects, + "code_sha": code_sha, + }) + ); + Ok(0) +} + +async fn persist_completed_fold( + fold: FoldFuture, + persist: Persist, +) -> Result<(BucketSnapshot, i64)> +where + FoldFuture: Future>, + Persist: FnOnce(serde_json::Value, i64) -> PersistFuture, + PersistFuture: Future>, +{ + let started = Instant::now(); + let snapshot = fold.await?; + let duration_ms = i64::try_from(started.elapsed().as_millis()).unwrap_or(i64::MAX); + let encoded = serde_json::to_value(&snapshot)?; + persist(encoded, duration_ms).await?; + Ok((snapshot, duration_ms)) +} + +fn storage_config_from_env() -> Result { + let required = |name: &str| { + std::env::var(name).map_err(|_| anyhow::anyhow!("{name} must be set for storage-snapshot")) + }; + let addressing_style = std::env::var("BUZZ_S3_ADDRESSING_STYLE") + .unwrap_or_else(|_| "path".to_string()) + .parse::() + .map_err(anyhow::Error::msg)?; + Ok(MediaConfig { + s3_endpoint: std::env::var("BUZZ_S3_ENDPOINT").unwrap_or_default(), + s3_access_key: std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_default(), + s3_secret_key: std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_default(), + s3_bucket: required("BUZZ_S3_BUCKET")?, + s3_region: std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string()), + s3_addressing_style: addressing_style, + max_image_bytes: 1, + max_gif_bytes: 1, + max_video_bytes: 1, + max_file_bytes: 1, + public_base_url: "http://storage-snapshot.invalid/media".to_string(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + }) +} + async fn cmd_add_member(pubkey_arg: String, role: String) -> Result { if let Err(msg) = validate_role(&role) { eprintln!("error: {msg}"); @@ -631,3 +776,27 @@ async fn reconcile_channels( ); Ok(()) } + +#[cfg(test)] +mod storage_snapshot_tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[tokio::test] + async fn failed_fold_never_invokes_snapshot_persistence() { + let persist_calls = Arc::new(AtomicUsize::new(0)); + let observed_calls = Arc::clone(&persist_calls); + let result = persist_completed_fold( + async { Err::(SweepError::MalformedPage) }, + move |_, _| async move { + observed_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await; + + assert!(result.is_err()); + assert_eq!(persist_calls.load(Ordering::SeqCst), 0); + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 625eb50737..cccb1e4b92 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -50,7 +50,7 @@ pub use store::{ admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, reaction, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, - replaceable, thread, usage, user, workflow, + replaceable, storage_accounting, thread, usage, user, workflow, }; pub use allowlist::AllowlistEntry; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index ecd48bef59..aebf8b9092 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -489,6 +489,7 @@ mod postgres_tests { "relay_admin_actions", "relay_admin_outbox", "relay_operator_audit", + "storage_accounting_snapshots", ] { if normalized[insert_pos..].contains(&format!("'{value}'")) { globals.insert(value.to_owned()); @@ -702,7 +703,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 45); + assert_eq!(migrations.len(), 46); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1282,6 +1283,11 @@ mod postgres_tests { // The restored exclusion function must NOT list any NIP-FI relation. assert!(!ledger_removal.contains("'authorization_operation_receipts'")); assert!(!ledger_removal.contains("'identity_bindings'")); + assert_eq!(migrations[45].version, 46); + assert!(migrations[45] + .sql + .as_str() + .contains("CREATE TABLE storage_accounting_snapshots")); // schema.sql exclusion list must match the restored (pre-0041) body. assert!( desired_schema.contains("'rate_limit_violations'\n ]::TEXT[])"), diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 1fa1273eb0..79739373d0 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -46,6 +46,8 @@ pub mod relay_operators; pub mod reminder; /// Replaceable-event persistence and coordinate locking. pub mod replaceable; +/// Durable completed snapshots from the isolated media-storage worker. +pub mod storage_accounting; /// Thread metadata persistence. pub mod thread; /// Per-community usage rollup queries for Prometheus gauges. diff --git a/crates/buzz-db/src/store/storage_accounting.rs b/crates/buzz-db/src/store/storage_accounting.rs new file mode 100644 index 0000000000..73c39bea39 --- /dev/null +++ b/crates/buzz-db/src/store/storage_accounting.rs @@ -0,0 +1,257 @@ +//! Durable handoff for isolated media-storage accounting. + +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use sqlx::postgres::PgConnection; + +use crate::{observability, Db, Result}; + +/// Deployment-global advisory lock for the run-once storage worker. +pub const STORAGE_ACCOUNTING_LOCK_KEY: i64 = 0x4255_5a5a_5354_4f52; + +/// Owns the detached PostgreSQL session that excludes overlapping workers. +pub struct StorageAccountingLeader { + connection: PgConnection, +} + +/// The newest complete snapshot stored by the worker. +#[derive(Debug, Clone)] +pub struct StoredStorageSnapshot { + /// Serialized `buzz_media::BucketSnapshot`. + pub snapshot: serde_json::Value, + /// Database commit time for freshness reporting. + pub completed_at: DateTime, + /// Wall-clock duration of the successful S3 fold. + pub duration_ms: i64, + /// Object ceiling configured for that run. + pub max_objects: i64, + /// Image or source revision supplied by the worker. + pub code_sha: String, +} + +impl StorageAccountingLeader { + /// Atomically replace the singleton through the lock-owning session. + #[datastore_span(name = "save_storage_accounting_snapshot", system = "postgresql")] + pub async fn save_snapshot( + &mut self, + snapshot: &serde_json::Value, + duration_ms: i64, + max_objects: i64, + code_sha: &str, + ) -> Result<()> { + sqlx::query( + "INSERT INTO storage_accounting_snapshots \ + (singleton, snapshot, completed_at, duration_ms, max_objects, code_sha) \ + VALUES (TRUE, $1, transaction_timestamp(), $2, $3, $4) \ + ON CONFLICT (singleton) DO UPDATE SET \ + snapshot = EXCLUDED.snapshot, \ + completed_at = EXCLUDED.completed_at, \ + duration_ms = EXCLUDED.duration_ms, \ + max_objects = EXCLUDED.max_objects, \ + code_sha = EXCLUDED.code_sha", + ) + .bind(snapshot) + .bind(duration_ms) + .bind(max_objects) + .bind(code_sha) + .execute(&mut self.connection) + .await?; + Ok(()) + } +} + +impl Db { + /// Try to acquire the deployment-global storage-worker lease. + #[datastore_span(name = "try_lock_storage_accounting", system = "postgresql")] + pub async fn try_lock_storage_accounting(&self) -> Result> { + let mut connection = observability::acquire_writer_with_legacy_metrics( + &self.pool, + observability::WriterOperation::Maintenance, + ) + .await?; + let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") + .bind(STORAGE_ACCOUNTING_LOCK_KEY) + .fetch_one(&mut *connection) + .await?; + Ok(acquired.then(|| StorageAccountingLeader { + connection: connection.detach(), + })) + } + + /// Load the newest complete worker snapshot from the writer. + #[datastore_span(name = "load_storage_accounting_snapshot", system = "postgresql")] + pub async fn load_storage_accounting_snapshot(&self) -> Result> { + let mut connection = + observability::acquire_writer(&self.pool, observability::WriterOperation::Maintenance) + .await?; + let row = sqlx::query_as::<_, (serde_json::Value, DateTime, i64, i64, String)>( + "SELECT snapshot, completed_at, duration_ms, max_objects, code_sha \ + FROM storage_accounting_snapshots WHERE singleton = TRUE", + ) + .fetch_optional(&mut *connection) + .await?; + Ok(row.map( + |(snapshot, completed_at, duration_ms, max_objects, code_sha)| StoredStorageSnapshot { + snapshot, + completed_at, + duration_ms, + max_objects, + code_sha, + }, + )) + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use sqlx::postgres::PgPoolOptions; + use sqlx::PgPool; + use uuid::Uuid; + + async fn create_scratch_db(admin: &PgPool) -> (PgPool, String) { + let name = format!("storage_accounting_{}", Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) + .execute(admin) + .await + .expect("create scratch db"); + let base = crate::test_support::database_url(); + let idx = base.rfind('/').expect("db url has path"); + let pool = PgPool::connect(&format!("{}/{name}", &base[..idx])) + .await + .expect("connect scratch db"); + crate::migration::run_migrations(&pool) + .await + .expect("migrate scratch db"); + (pool, name) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn complete_snapshot_replaces_atomically_and_worker_lock_excludes_overlap() { + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&crate::test_support::database_url()) + .await + .expect("connect admin"); + let (pool, name) = create_scratch_db(&admin).await; + let first = Db::from_pool(pool.clone()); + let second = Db::from_pool(pool.clone()); + + let mut leader = first + .try_lock_storage_accounting() + .await + .expect("first lock") + .expect("first worker owns lock"); + assert!( + second + .try_lock_storage_accounting() + .await + .expect("second lock") + .is_none(), + "overlapping worker must not start" + ); + + leader + .save_snapshot(&serde_json::json!({"version": 1}), 10, 100, "a") + .await + .expect("save first snapshot"); + leader + .save_snapshot(&serde_json::json!({"version": 2}), 20, 200, "b") + .await + .expect("replace snapshot"); + let stored = second + .load_storage_accounting_snapshot() + .await + .expect("load snapshot") + .expect("snapshot exists"); + assert_eq!(stored.snapshot, serde_json::json!({"version": 2})); + assert_eq!(stored.duration_ms, 20); + assert_eq!(stored.max_objects, 200); + assert_eq!(stored.code_sha, "b"); + + drop(leader); + drop(first); + drop(second); + pool.close().await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(&admin) + .await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn lost_lock_session_cannot_overwrite_successor_snapshot() { + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&crate::test_support::database_url()) + .await + .expect("connect admin"); + let (pool, name) = create_scratch_db(&admin).await; + let first = Db::from_pool(pool.clone()); + let second = Db::from_pool(pool.clone()); + + let mut stale_leader = first + .try_lock_storage_accounting() + .await + .expect("first lock") + .expect("first worker owns lock"); + let stale_backend_pid = sqlx::query_scalar::<_, i32>("SELECT pg_backend_pid()") + .fetch_one(&mut stale_leader.connection) + .await + .expect("load first worker backend pid"); + let terminated = sqlx::query_scalar::<_, bool>("SELECT pg_terminate_backend($1)") + .bind(stale_backend_pid) + .fetch_one(&admin) + .await + .expect("terminate first worker backend"); + assert!(terminated, "first worker backend must terminate"); + + let mut fresh_leader = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let Some(leader) = second + .try_lock_storage_accounting() + .await + .expect("successor lock attempt") + { + break leader; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + }) + .await + .expect("successor acquires released lock"); + + fresh_leader + .save_snapshot(&serde_json::json!({"worker": "fresh"}), 20, 200, "b") + .await + .expect("successor publishes fresh snapshot"); + stale_leader + .save_snapshot(&serde_json::json!({"worker": "stale"}), 30, 100, "a") + .await + .expect_err("worker that lost its lock session cannot publish"); + + let stored = second + .load_storage_accounting_snapshot() + .await + .expect("load snapshot") + .expect("snapshot exists"); + assert_eq!(stored.snapshot, serde_json::json!({"worker": "fresh"})); + assert_eq!(stored.duration_ms, 20); + assert_eq!(stored.max_objects, 200); + assert_eq!(stored.code_sha, "b"); + + drop(stale_leader); + drop(fresh_leader); + drop(first); + drop(second); + pool.close().await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(&admin) + .await; + } +} diff --git a/crates/buzz-media/src/bucket_index.rs b/crates/buzz-media/src/bucket_index.rs index 6c78c2e0a1..86a3eff791 100644 --- a/crates/buzz-media/src/bucket_index.rs +++ b/crates/buzz-media/src/bucket_index.rs @@ -193,7 +193,7 @@ fn parse_auxiliary_key(key: &str) -> Option<(Uuid, String, String)> { } /// Per-community logical storage: bytes and object count of bound shas. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct CommunityStorage { pub bytes: u64, pub objects: u64, @@ -202,7 +202,7 @@ pub struct CommunityStorage { /// The full computed sweep result: fleet physical/logical totals, /// per-community logical breakdown, and anomaly/visibility gauges. Pure /// data — no I/O, cheap to clone into a cached snapshot. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct BucketSnapshot { /// Every listed object, every class (kind=physical). pub physical_bytes: u64, @@ -422,6 +422,27 @@ mod tests { Uuid::from_u128(n) } + #[test] + fn bucket_snapshot_json_round_trip_preserves_community_keys() { + let community = community(7); + let mut snapshot = BucketSnapshot { + physical_objects: 3, + ..BucketSnapshot::default() + }; + snapshot.per_community.insert( + community, + CommunityStorage { + bytes: 42, + objects: 1, + }, + ); + + let encoded = serde_json::to_value(&snapshot).expect("serialize snapshot"); + let decoded: BucketSnapshot = + serde_json::from_value(encoded).expect("deserialize snapshot"); + assert_eq!(decoded, snapshot); + } + // --- classify_key --- #[test] diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index d6b3b55124..532defbd8d 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1764,27 +1764,62 @@ async fn run_storage_sweep_tick( // leader tick, and stable for the process lifetime (env is immutable). // Keeping it here avoids widening Config/AppState for a single consumer. let config = *SWEEP_CONFIG.get_or_init(storage_sweep::StorageSweepConfig::from_env); - if !config.enabled { - return; + match config.mode { + storage_sweep::StorageMetricsMode::Disabled => return, + storage_sweep::StorageMetricsMode::Inline => { + let media_storage = Arc::clone(&state.media_storage); + let max_objects = config.max_objects; + storage_sweep::maybe_spawn_sweep( + &state.storage_sweep, + config.interval, + config.timeout, + async move { + buzz_media::fold_bucket_listing(max_objects, move |token| { + let media_storage = Arc::clone(&media_storage); + async move { media_storage.list_page(token, 1000).await } + }) + .await + }, + ) + .await; + } + storage_sweep::StorageMetricsMode::Snapshot => { + match state.db.load_storage_accounting_snapshot().await { + Ok(Some(stored)) => match serde_json::from_value(stored.snapshot) { + Ok(snapshot) => { + let duration = std::time::Duration::from_millis( + u64::try_from(stored.duration_ms).unwrap_or_default(), + ); + let max_objects = u64::try_from(stored.max_objects).unwrap_or_default(); + storage_sweep::cache_persisted_snapshot( + &state.storage_sweep, + snapshot, + stored.completed_at, + duration, + max_objects, + ) + .await; + } + Err(error) => { + warn!(error = %error, "stored storage snapshot is invalid"); + storage_sweep::record_persisted_snapshot_load_failure(&state.storage_sweep) + .await; + } + }, + Ok(None) => { + storage_sweep::record_persisted_snapshot_load_failure(&state.storage_sweep) + .await; + } + Err(error) => { + warn!(error = %error, "failed to load stored storage snapshot"); + storage_sweep::record_persisted_snapshot_load_failure(&state.storage_sweep) + .await; + } + } + } } - let media_storage = Arc::clone(&state.media_storage); - let max_objects = config.max_objects; - storage_sweep::maybe_spawn_sweep( - &state.storage_sweep, - config.interval, - config.timeout, - async move { - buzz_media::fold_bucket_listing(max_objects, move |token| { - let media_storage = Arc::clone(&media_storage); - async move { media_storage.list_page(token, 1000).await } - }) - .await - }, - ) - .await; - - storage_sweep::emit_storage_metrics(&state.storage_sweep, host_map, |id| { + storage_sweep::emit_storage_metrics(&state.storage_sweep, config.mode, host_map, |id| { emission_scope.allows(id) }) .await; diff --git a/crates/buzz-relay/src/storage_sweep.rs b/crates/buzz-relay/src/storage_sweep.rs index eccadcd835..0e807bca1f 100644 --- a/crates/buzz-relay/src/storage_sweep.rs +++ b/crates/buzz-relay/src/storage_sweep.rs @@ -12,21 +12,33 @@ //! re-publishes the cached snapshot regardless of whether a sweep is //! currently running — DB-derived gauges keep their configured cadence, //! storage gauges lag by at most one tick after a sweep completes. -//! - A cold cache (no sweep has ever succeeded) publishes health gauges only -//! (`sweep_ok=0`); a warm cache re-publishes the last good snapshot even -//! while the newest attempt is failing, so a transient S3 blip never blanks -//! the dashboards. +//! - In inline mode, a cold cache (no sweep has ever succeeded) publishes +//! health gauges only (`sweep_ok=0`); a warm cache re-publishes the last good +//! snapshot even while the newest attempt is failing, so a transient S3 blip +//! never blanks the dashboards. use std::collections::{HashMap, HashSet}; use std::future::Future; use std::time::{Duration, Instant}; +use chrono::{DateTime, Utc}; use tokio::sync::Mutex; use tokio::task::JoinHandle; use uuid::Uuid; use buzz_media::{BucketSnapshot, SweepError}; +/// Where relay storage gauges get their snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StorageMetricsMode { + /// Preserve the existing in-process S3 sweep. + Inline, + /// Read the newest completed snapshot persisted by `buzz-admin`. + Snapshot, + /// Emit no storage-family metrics. + Disabled, +} + /// Sweep knobs, read once at boot. See `PLANS/S3_STORAGE_METRICS_PLAN.md` F7. #[derive(Debug, Clone, Copy)] pub struct StorageSweepConfig { @@ -40,18 +52,16 @@ pub struct StorageSweepConfig { /// Cumulative listed-object cap; a listing that exceeds it fails the /// attempt (old snapshot kept) rather than growing memory unbounded. pub max_objects: u64, - /// Kill switch. `false` ⇒ no sweep ever spawns and no storage-family - /// gauge (including the health gauges) is ever emitted — a relay whose - /// deployment lacks `s3:ListBucket` can turn the whole feature off. - pub enabled: bool, + /// Snapshot source and kill switch. + pub mode: StorageMetricsMode, } impl StorageSweepConfig { /// Reads `BUZZ_STORAGE_SWEEP_INTERVAL_SECS` (default 3600, floor 60), /// `BUZZ_STORAGE_SWEEP_TIMEOUT_SECS` (default 120), - /// `BUZZ_STORAGE_SWEEP_MAX_OBJECTS` (default 1_000_000), and the - /// `BUZZ_STORAGE_METRICS` kill switch (`off` ⇒ disabled, anything else - /// including unset ⇒ enabled). + /// `BUZZ_STORAGE_SWEEP_MAX_OBJECTS` (default 1_000_000), and + /// `BUZZ_STORAGE_METRICS` (`inline`, `external`, or `off`). Unset keeps + /// the legacy inline behavior; unknown values fail closed. pub fn from_env() -> Self { let interval_secs = std::env::var("BUZZ_STORAGE_SWEEP_INTERVAL_SECS") .ok() @@ -66,16 +76,30 @@ impl StorageSweepConfig { .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(1_000_000); - let enabled = std::env::var("BUZZ_STORAGE_METRICS") + let raw_mode = std::env::var("BUZZ_STORAGE_METRICS") .ok() - .map(|v| v.trim().to_ascii_lowercase()) - .as_deref() - != Some("off"); + .map(|v| v.trim().to_ascii_lowercase()); + let mode = parse_storage_metrics_mode(raw_mode.as_deref()); Self { interval: Duration::from_secs(interval_secs), timeout: Duration::from_secs(timeout_secs), max_objects, - enabled, + mode, + } + } +} + +fn parse_storage_metrics_mode(value: Option<&str>) -> StorageMetricsMode { + match value { + Some("off") => StorageMetricsMode::Disabled, + Some("snapshot") | Some("external") => StorageMetricsMode::Snapshot, + Some("inline") | Some("on") | None => StorageMetricsMode::Inline, + Some(other) => { + tracing::error!( + value = other, + "invalid BUZZ_STORAGE_METRICS value; storage metrics disabled" + ); + StorageMetricsMode::Disabled } } } @@ -89,11 +113,14 @@ struct LastAttempt { duration: Duration, } -/// The most recent *successful* sweep, cut as one coherent snapshot. +/// The most recent successfully computed snapshot. #[derive(Debug, Clone)] struct CachedSnapshot { data: BucketSnapshot, completed_at: Instant, + completed_at_wall: DateTime, + duration: Duration, + max_objects: Option, } /// What a spawned sweep task hands back to the tick that harvests it. @@ -141,6 +168,8 @@ pub struct StorageSweepState { cached: Option, last_attempt: Option, failures_total: u64, + /// Whether the latest external-mode read yielded a decodable snapshot. + persisted_load_ok: Option, /// Per-community series emitted on the previous tick — used to zero series /// whose community disappears from the snapshot (see [`emit_storage_metrics`]). previously_emitted: HashSet, @@ -214,6 +243,9 @@ pub async fn maybe_spawn_sweep( // Stamped at harvest, not sweep completion — exported // age/cadence may lag by ≤1 usage tick. completed_at: Instant::now(), + completed_at_wall: Utc::now(), + duration: attempt.duration, + max_objects: None, }); } Err(err) => { @@ -258,10 +290,53 @@ pub async fn maybe_spawn_sweep( state.in_flight = Some(handle); } -/// Emit the storage-family gauges from the cached snapshot. Call every usage -/// tick, leader-only (mirrors `emit_db_usage_metrics`'s leadership gate) — -/// never from the spawned sweep task itself, so a sweep that completes after -/// this pod loses leadership parks its snapshot without ever publishing it. +/// Replace the cache with a complete snapshot loaded from durable storage. +pub async fn cache_persisted_snapshot( + state: &Mutex, + snapshot: BucketSnapshot, + completed_at: DateTime, + duration: Duration, + max_objects: u64, +) { + let age = Utc::now() + .signed_duration_since(completed_at) + .to_std() + .unwrap_or_default(); + let mut state = state.lock().await; + state.cached = Some(CachedSnapshot { + data: snapshot, + completed_at: Instant::now().checked_sub(age).unwrap_or_else(Instant::now), + completed_at_wall: completed_at, + duration, + max_objects: Some(max_objects), + }); + state.persisted_load_ok = Some(true); +} + +/// Record that the latest persisted-snapshot read did not yield a usable row. +pub async fn record_persisted_snapshot_load_failure(state: &Mutex) { + state.lock().await.persisted_load_ok = Some(false); +} + +/// Emit the mode-appropriate storage gauges on each leader metrics tick. +pub async fn emit_storage_metrics( + state: &Mutex, + mode: StorageMetricsMode, + host_map: &HashMap, + allows: impl Fn(&Uuid) -> bool, +) { + match mode { + StorageMetricsMode::Inline => emit_inline_storage_metrics(state, host_map, allows).await, + StorageMetricsMode::Snapshot => { + emit_persisted_storage_metrics(state, host_map, allows).await + } + StorageMetricsMode::Disabled => {} + } +} + +/// Emit inline-sweep health and storage gauges from the cached snapshot. Never +/// called from the spawned sweep task itself, so a sweep that completes after +/// this pod loses leadership parks its snapshot without publishing it. /// /// `host_map` resolves a community UUID to its label string for per- /// community series; `allows` gates those series the same way @@ -276,7 +351,7 @@ pub async fn maybe_spawn_sweep( /// would linger at its last nonzero value until the recorder's idle eviction /// fires (≥3 ticks), producing a transient double-count against the /// `buzz_storage_unmapped_community_bytes` gauge. -pub async fn emit_storage_metrics( +async fn emit_inline_storage_metrics( state: &Mutex, host_map: &HashMap, allows: impl Fn(&Uuid) -> bool, @@ -293,6 +368,34 @@ pub async fn emit_storage_metrics( metrics::gauge!("buzz_storage_sweep_duration_seconds").set(attempt.duration.as_secs_f64()); } + emit_cached_storage_metrics(&mut state, host_map, allows, CachedMetricSource::Inline); +} + +/// Emit storage gauges loaded from the durable worker snapshot. +async fn emit_persisted_storage_metrics( + state: &Mutex, + host_map: &HashMap, + allows: impl Fn(&Uuid) -> bool, +) { + let mut state = state.lock().await; + let load_ok = state.persisted_load_ok.unwrap_or(false); + metrics::gauge!("buzz_storage_snapshot_load_ok").set(if load_ok { 1.0 } else { 0.0 }); + + emit_cached_storage_metrics(&mut state, host_map, allows, CachedMetricSource::Persisted); +} + +#[derive(Clone, Copy)] +enum CachedMetricSource { + Inline, + Persisted, +} + +fn emit_cached_storage_metrics( + state: &mut StorageSweepState, + host_map: &HashMap, + allows: impl Fn(&Uuid) -> bool, + source: CachedMetricSource, +) { // Cold cache + failure (F5): no storage-family/per-community gauges yet. // Zero any previously-emitted per-community series before returning so // they don't linger if we had a warm cache in a prior tick. @@ -302,8 +405,20 @@ pub async fn emit_storage_metrics( } return; }; - metrics::gauge!("buzz_storage_sweep_age_seconds") - .set(cached.completed_at.elapsed().as_secs_f64()); + let age = Utc::now() + .signed_duration_since(cached.completed_at_wall) + .to_std() + .unwrap_or_default(); + match source { + CachedMetricSource::Inline => { + metrics::gauge!("buzz_storage_sweep_age_seconds").set(age.as_secs_f64()); + } + CachedMetricSource::Persisted => { + metrics::gauge!("buzz_storage_snapshot_age_seconds").set(age.as_secs_f64()); + metrics::gauge!("buzz_storage_snapshot_duration_seconds") + .set(cached.duration.as_secs_f64()); + } + } let snapshot = &cached.data; metrics::gauge!("buzz_total_storage_bytes", "kind" => "physical") @@ -322,6 +437,11 @@ pub async fn emit_storage_metrics( metrics::gauge!("buzz_storage_multi_variant_bytes").set(snapshot.multi_variant_bytes as f64); metrics::gauge!("buzz_storage_unknown_key_bytes").set(snapshot.unknown_key_bytes as f64); metrics::gauge!("buzz_storage_unknown_key_objects").set(snapshot.unknown_key_objects as f64); + if let (CachedMetricSource::Persisted, Some(max_objects)) = (source, cached.max_objects) { + metrics::gauge!("buzz_storage_snapshot_max_objects").set(max_objects as f64); + metrics::gauge!("buzz_storage_snapshot_cap_utilization") + .set(snapshot.physical_objects as f64 / max_objects as f64); + } let mut current = HashSet::new(); let mut unmapped_bytes = 0u64; @@ -398,20 +518,36 @@ mod tests { assert_eq!(config.interval, Duration::from_secs(3600)); assert_eq!(config.timeout, Duration::from_secs(120)); assert_eq!(config.max_objects, 1_000_000); - assert!(config.enabled); + assert_eq!(config.mode, StorageMetricsMode::Inline); } #[test] - fn config_kill_switch_only_off_disables() { - assert!(!parse_enabled(Some("off"))); - assert!(!parse_enabled(Some("OFF"))); - assert!(parse_enabled(Some("on"))); - assert!(parse_enabled(Some("anything-else"))); - assert!(parse_enabled(None)); - } - - fn parse_enabled(value: Option<&str>) -> bool { - value.map(str::trim).map(str::to_ascii_lowercase).as_deref() != Some("off") + fn config_mode_accepts_snapshot_and_fails_closed_on_unknown_values() { + assert_eq!( + parse_storage_metrics_mode(Some("off")), + StorageMetricsMode::Disabled + ); + assert_eq!( + parse_storage_metrics_mode(Some("snapshot")), + StorageMetricsMode::Snapshot + ); + assert_eq!( + parse_storage_metrics_mode(Some("external")), + StorageMetricsMode::Snapshot + ); + assert_eq!( + parse_storage_metrics_mode(Some("on")), + StorageMetricsMode::Inline + ); + assert_eq!( + parse_storage_metrics_mode(Some("inline")), + StorageMetricsMode::Inline + ); + assert_eq!( + parse_storage_metrics_mode(Some("anything-else")), + StorageMetricsMode::Disabled + ); + assert_eq!(parse_storage_metrics_mode(None), StorageMetricsMode::Inline); } // --- should_spawn --- @@ -437,6 +573,9 @@ mod tests { let cached = Some(CachedSnapshot { data: BucketSnapshot::default(), completed_at: Instant::now(), + completed_at_wall: Utc::now(), + duration: Duration::from_secs(1), + max_objects: None, }); assert!(should_spawn( &cached, @@ -456,6 +595,9 @@ mod tests { let cached = Some(CachedSnapshot { data: BucketSnapshot::default(), completed_at: now, + completed_at_wall: Utc::now(), + duration: Duration::from_secs(1), + max_objects: None, }); assert!(!should_spawn( &cached, @@ -711,7 +853,12 @@ mod tests { let recorder = DebuggingRecorder::new(); let host_map = HashMap::new(); metrics::with_local_recorder(&recorder, || { - futures::executor::block_on(emit_storage_metrics(&state, &host_map, |_| true)); + futures::executor::block_on(emit_storage_metrics( + &state, + StorageMetricsMode::Inline, + &host_map, + |_| true, + )); }); let values = gauge_snapshot(&recorder); @@ -764,7 +911,12 @@ mod tests { let recorder = DebuggingRecorder::new(); let host_map = HashMap::new(); metrics::with_local_recorder(&recorder, || { - futures::executor::block_on(emit_storage_metrics(&state, &host_map, |_| true)); + futures::executor::block_on(emit_storage_metrics( + &state, + StorageMetricsMode::Inline, + &host_map, + |_| true, + )); }); let values = gauge_snapshot(&recorder); assert_eq!(values.get("buzz_storage_sweep_ok"), Some(&0.0)); @@ -785,7 +937,12 @@ mod tests { let recorder = DebuggingRecorder::new(); let host_map = HashMap::new(); metrics::with_local_recorder(&recorder, || { - futures::executor::block_on(emit_storage_metrics(&state, &host_map, |_| true)); + futures::executor::block_on(emit_storage_metrics( + &state, + StorageMetricsMode::Inline, + &host_map, + |_| true, + )); }); let values = gauge_snapshot(&recorder); assert_eq!(values.get("buzz_storage_sweep_ok"), Some(&1.0)); @@ -814,7 +971,12 @@ mod tests { let host_map = HashMap::new(); metrics::with_local_recorder(&recorder, || { - futures::executor::block_on(emit_storage_metrics(&state, &host_map, |_| true)); + futures::executor::block_on(emit_storage_metrics( + &state, + StorageMetricsMode::Inline, + &host_map, + |_| true, + )); }); let values = gauge_snapshot(&recorder); @@ -864,6 +1026,9 @@ mod tests { cached: Some(CachedSnapshot { data: snapshot, completed_at: Instant::now(), + completed_at_wall: Utc::now(), + duration: Duration::from_millis(500), + max_objects: None, }), last_attempt: Some(LastAttempt { ok: true, @@ -878,9 +1043,12 @@ mod tests { let recorder = DebuggingRecorder::new(); metrics::with_local_recorder(&recorder, || { - futures::executor::block_on(emit_storage_metrics(&state, &host_map, |id| { - *id != excluded - })); + futures::executor::block_on(emit_storage_metrics( + &state, + StorageMetricsMode::Inline, + &host_map, + |id| *id != excluded, + )); }); let values = gauge_snapshot(&recorder); @@ -973,6 +1141,9 @@ mod tests { cached: Some(CachedSnapshot { data: make_snapshot(true, 20), completed_at: Instant::now(), + completed_at_wall: Utc::now(), + duration: Duration::from_millis(100), + max_objects: None, }), last_attempt: Some(LastAttempt { ok: true, @@ -989,7 +1160,12 @@ mod tests { host_map_1.insert(community_b, "host.old".to_string()); host_map_1.insert(community_c, "host.c".to_string()); metrics::with_local_recorder(&recorder, || { - futures::executor::block_on(emit_storage_metrics(&state, &host_map_1, |_| true)); + futures::executor::block_on(emit_storage_metrics( + &state, + StorageMetricsMode::Inline, + &host_map_1, + |_| true, + )); }); { let labeled = labeled_community_gauges(&recorder); @@ -1017,6 +1193,9 @@ mod tests { guard.cached = Some(CachedSnapshot { data: make_snapshot(false, 20), completed_at: Instant::now(), + completed_at_wall: Utc::now(), + duration: Duration::from_millis(100), + max_objects: None, }); } let mut host_map_2 = HashMap::new(); @@ -1026,6 +1205,7 @@ mod tests { metrics::with_local_recorder(&recorder, || { futures::executor::block_on(emit_storage_metrics( &state, + StorageMetricsMode::Inline, &host_map_2, |id| *id != community_c, // (c) scope-excluded )); @@ -1087,4 +1267,112 @@ mod tests { "(c) scope removal: host.c objects must be zeroed" ); } + + #[tokio::test] + async fn persisted_snapshot_emits_snapshot_health_without_attempt_health() { + let community = Uuid::from_u128(77); + let state = Mutex::new(StorageSweepState::default()); + cache_persisted_snapshot( + &state, + snapshot_with(community, 100, 25), + Utc::now() - chrono::Duration::seconds(30), + Duration::from_secs(12), + 100, + ) + .await; + let host_map = HashMap::from([(community, "example.test".to_string())]); + let recorder = DebuggingRecorder::new(); + metrics::with_local_recorder(&recorder, || { + futures::executor::block_on(emit_storage_metrics( + &state, + StorageMetricsMode::Snapshot, + &host_map, + |_| true, + )); + }); + let values = gauge_snapshot(&recorder); + assert_eq!(values.get("buzz_storage_snapshot_load_ok"), Some(&1.0)); + assert_eq!( + values.get("buzz_storage_snapshot_duration_seconds"), + Some(&12.0) + ); + assert_eq!( + values.get("buzz_storage_snapshot_max_objects"), + Some(&100.0) + ); + assert_eq!( + values.get("buzz_storage_snapshot_cap_utilization"), + Some(&0.25) + ); + assert!(values["buzz_storage_snapshot_age_seconds"] >= 30.0); + assert!(!values.contains_key("buzz_storage_sweep_ok")); + assert!(!values.contains_key("buzz_storage_sweep_failures")); + assert!(!values.contains_key("buzz_storage_sweep_duration_seconds")); + assert!(!values.contains_key("buzz_storage_sweep_age_seconds")); + } + + #[tokio::test] + async fn stale_persisted_snapshot_remains_visible_without_claiming_attempt_success() { + let community = Uuid::from_u128(78); + let state = Mutex::new(StorageSweepState::default()); + cache_persisted_snapshot( + &state, + snapshot_with(community, 200, 50), + Utc::now() - chrono::Duration::hours(48), + Duration::from_secs(20), + 1_000, + ) + .await; + + let host_map = HashMap::from([(community, "stale.example.test".to_string())]); + let recorder = DebuggingRecorder::new(); + metrics::with_local_recorder(&recorder, || { + futures::executor::block_on(emit_storage_metrics( + &state, + StorageMetricsMode::Snapshot, + &host_map, + |_| true, + )); + }); + + let values = gauge_snapshot(&recorder); + assert_eq!(values.get("buzz_storage_snapshot_load_ok"), Some(&1.0)); + assert_eq!(values.get("buzz_total_storage_bytes"), Some(&200.0)); + assert!(values["buzz_storage_snapshot_age_seconds"] >= 48.0 * 60.0 * 60.0); + assert!(!values.contains_key("buzz_storage_sweep_ok")); + assert!(!values.contains_key("buzz_storage_sweep_failures")); + } + + #[tokio::test] + async fn failed_persisted_load_keeps_last_good_totals_and_reports_unhealthy_handoff() { + let community = Uuid::from_u128(79); + let state = Mutex::new(StorageSweepState::default()); + cache_persisted_snapshot( + &state, + snapshot_with(community, 300, 75), + Utc::now() - chrono::Duration::minutes(5), + Duration::from_secs(30), + 1_000, + ) + .await; + record_persisted_snapshot_load_failure(&state).await; + + let host_map = HashMap::from([(community, "cached.example.test".to_string())]); + let recorder = DebuggingRecorder::new(); + metrics::with_local_recorder(&recorder, || { + futures::executor::block_on(emit_storage_metrics( + &state, + StorageMetricsMode::Snapshot, + &host_map, + |_| true, + )); + }); + + let values = gauge_snapshot(&recorder); + assert_eq!(values.get("buzz_storage_snapshot_load_ok"), Some(&0.0)); + assert_eq!(values.get("buzz_total_storage_bytes"), Some(&300.0)); + assert!(values["buzz_storage_snapshot_age_seconds"] >= 5.0 * 60.0); + assert!(!values.contains_key("buzz_storage_sweep_ok")); + assert!(!values.contains_key("buzz_storage_sweep_failures")); + } } diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index 49a6fafd19..e26a2815ff 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -7,7 +7,7 @@ description: | PostgreSQL and Redis. Configurable for single-node evaluation (subcharts on) and HA production (external services, existingSecret). type: application -version: 0.1.8 +version: 0.1.9 appVersion: "0.1.0" home: https://github.com/block/buzz sources: @@ -24,7 +24,7 @@ maintainers: annotations: artifacthub.io/changes: | - kind: added - description: Optional immutable relay image digest pinning with backwards-compatible tag fallback. + description: Optional isolated storage-accounting CronJob with durable relay snapshots. artifacthub.io/license: Apache-2.0 # Optional eval-only subcharts. Production deploys disable both and point diff --git a/deploy/charts/buzz/templates/_helpers.tpl b/deploy/charts/buzz/templates/_helpers.tpl index 13efe0d1fd..e5ec9182d6 100644 --- a/deploy/charts/buzz/templates/_helpers.tpl +++ b/deploy/charts/buzz/templates/_helpers.tpl @@ -61,6 +61,14 @@ app.kubernetes.io/component: relay {{- end -}} {{- end -}} +{{- define "buzz.imageRevision" -}} +{{- if .Values.image.digest -}} +{{- .Values.image.digest -}} +{{- else -}} +{{- default .Chart.AppVersion .Values.image.tag -}} +{{- end -}} +{{- end -}} + {{/* Name of the chart-managed Secret holding relay-identity material and any chart-composed connection strings. diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 319ec7f159..59f52f759e 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -172,6 +172,7 @@ spec: - { name: BUZZ_S3_BUCKET, value: {{ .Values.s3.bucket | quote }} } - { name: BUZZ_S3_REGION, value: {{ .Values.s3.region | quote }} } - { name: BUZZ_S3_ADDRESSING_STYLE, value: {{ .Values.s3.addressingStyle | quote }} } + - { name: BUZZ_STORAGE_METRICS, value: {{ .Values.storageAccounting.relayMode | quote }} } # ── Secrets (from chart-managed or existing) ───────────── - name: BUZZ_RELAY_PRIVATE_KEY diff --git a/deploy/charts/buzz/templates/storage-accounting-cronjob.yaml b/deploy/charts/buzz/templates/storage-accounting-cronjob.yaml new file mode 100644 index 0000000000..deaa816888 --- /dev/null +++ b/deploy/charts/buzz/templates/storage-accounting-cronjob.yaml @@ -0,0 +1,74 @@ +{{- if .Values.storageAccounting.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "buzz.fullname" . }}-storage-accounting + labels: + {{- include "buzz.labels" . | nindent 4 }} + app.kubernetes.io/component: storage-accounting +spec: + schedule: {{ .Values.storageAccounting.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: {{ .Values.storageAccounting.successfulJobsHistoryLimit }} + failedJobsHistoryLimit: {{ .Values.storageAccounting.failedJobsHistoryLimit }} + jobTemplate: + spec: + activeDeadlineSeconds: {{ .Values.storageAccounting.activeDeadlineSeconds }} + backoffLimit: 0 + template: + metadata: + labels: + {{- include "buzz.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: storage-accounting + {{- with .Values.storageAccounting.podLabels }} + {{- toYaml . | nindent 12 }} + {{- end }} + annotations: + {{- toYaml .Values.storageAccounting.podAnnotations | nindent 12 }} + spec: + restartPolicy: Never + serviceAccountName: {{ default (include "buzz.serviceAccountName" .) .Values.storageAccounting.serviceAccountName }} + securityContext: + {{- toYaml .Values.relay.securityContext | nindent 12 }} + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 12 }} + {{- end }} + containers: + - name: storage-accounting + image: {{ include "buzz.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- omit .Values.relay.containerSecurityContext "readOnlyRootFilesystem" | toYaml | nindent 16 }} + readOnlyRootFilesystem: true + command: ["/usr/local/bin/buzz-admin"] + args: ["storage-snapshot", "--max-objects", {{ .Values.storageAccounting.maxObjects | int64 | quote }}] + env: + {{- $s3Endpoint := include "buzz.s3Endpoint" . }} + {{- if $s3Endpoint }} + - { name: BUZZ_S3_ENDPOINT, value: {{ $s3Endpoint | quote }} } + {{- end }} + - { name: BUZZ_S3_BUCKET, value: {{ .Values.s3.bucket | quote }} } + - { name: BUZZ_S3_REGION, value: {{ .Values.s3.region | quote }} } + - { name: BUZZ_S3_ADDRESSING_STYLE, value: {{ .Values.s3.addressingStyle | quote }} } + - { name: BUZZ_STORAGE_SNAPSHOT_CODE_SHA, value: {{ include "buzz.imageRevision" . | quote }} } + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: DATABASE_URL + - name: BUZZ_S3_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: BUZZ_S3_ACCESS_KEY + optional: true + - name: BUZZ_S3_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: BUZZ_S3_SECRET_KEY + optional: true + resources: + {{- toYaml .Values.storageAccounting.resources | nindent 16 }} +{{- end }} diff --git a/deploy/charts/buzz/tests/storage_accounting_test.yaml b/deploy/charts/buzz/tests/storage_accounting_test.yaml new file mode 100644 index 0000000000..916e3028d4 --- /dev/null +++ b/deploy/charts/buzz/tests/storage_accounting_test.yaml @@ -0,0 +1,139 @@ +suite: isolated storage accounting +templates: + - templates/storage-accounting-cronjob.yaml + - templates/deployment.yaml + - templates/secret-chart.yaml +tests: + - it: renders no worker by default + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: https://s3.example.com + s3.accessKey: test + s3.secretKey: test + asserts: + - hasDocuments: + count: 0 + template: templates/storage-accounting-cronjob.yaml + - it: renders a non-overlapping worker with an isolated cap + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: https://s3.example.com + s3.bucket: buzz-media-example + s3.region: us-west-2 + s3.addressingStyle: virtual + s3.accessKey: test + s3.secretKey: test + storageAccounting: + enabled: true + relayMode: external + maxObjects: 10000000 + activeDeadlineSeconds: 3600 + serviceAccountName: buzz-storage-accounting + podLabels: + tags.datadoghq.com/env: production + tags.datadoghq.com/service: buzz-storage-accounting + tags.datadoghq.com/version: test-sha + asserts: + - hasDocuments: + count: 1 + template: templates/storage-accounting-cronjob.yaml + - equal: + path: kind + value: CronJob + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.concurrencyPolicy + value: Forbid + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.backoffLimit + value: 0 + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.activeDeadlineSeconds + value: 3600 + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.template.spec.restartPolicy + value: Never + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.template.spec.serviceAccountName + value: buzz-storage-accounting + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem + value: true + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.template.metadata.annotations["sidecar.istio.io/inject"] + value: "false" + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.template.metadata.labels["tags.datadoghq.com/service"] + value: buzz-storage-accounting + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.template.metadata.labels["tags.datadoghq.com/env"] + value: production + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.template.metadata.labels["tags.datadoghq.com/version"] + value: test-sha + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.template.spec.containers[0].args + value: ["storage-snapshot", "--max-objects", "10000000"] + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.template.spec.containers[0].resources.requests.memory + value: 20Gi + template: templates/storage-accounting-cronjob.yaml + - equal: + path: spec.jobTemplate.spec.template.spec.containers[0].resources.limits.memory + value: 20Gi + template: templates/storage-accounting-cronjob.yaml + - contains: + path: spec.jobTemplate.spec.template.spec.containers[0].env + content: + name: BUZZ_STORAGE_SNAPSHOT_CODE_SHA + value: 0.1.0 + template: templates/storage-accounting-cronjob.yaml + - contains: + path: spec.jobTemplate.spec.template.spec.containers[0].env + content: + name: BUZZ_S3_BUCKET + value: buzz-media-example + template: templates/storage-accounting-cronjob.yaml + - lengthEqual: + path: spec.jobTemplate.spec.template.spec.containers[0].env + count: 8 + template: templates/storage-accounting-cronjob.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_STORAGE_METRICS + value: external + template: templates/deployment.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_STORAGE_SWEEP_MAX_OBJECTS + value: "10000000" + template: templates/deployment.yaml + - notContains: + path: spec.jobTemplate.spec.template.spec.containers[0].env + content: + name: BUZZ_RELAY_PRIVATE_KEY + template: templates/storage-accounting-cronjob.yaml + - notContains: + path: spec.jobTemplate.spec.template.spec.containers[0].env + content: + name: BUZZ_GIT_HOOK_HMAC_SECRET + template: templates/storage-accounting-cronjob.yaml diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index aaab848fd3..ea5db6398d 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -217,6 +217,23 @@ "secretKey": { "type": "string" } } }, + "storageAccounting": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "schedule": { "type": "string", "minLength": 1 }, + "maxObjects": { "type": "integer", "minimum": 1 }, + "activeDeadlineSeconds": { "type": "integer", "minimum": 1 }, + "successfulJobsHistoryLimit": { "type": "integer", "minimum": 0 }, + "failedJobsHistoryLimit": { "type": "integer", "minimum": 0 }, + "relayMode": { "type": "string", "enum": ["inline", "external", "off"] }, + "serviceAccountName": { "type": "string" }, + "podLabels": { "type": "object" }, + "podAnnotations": { "type": "object" }, + "resources": { "type": "object" } + } + }, "minio": { "type": "object", "additionalProperties": false, diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index addfa4f5b3..5e41f22f5f 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -328,13 +328,20 @@ externalRedis: # MinIO Deployment, creates the bucket via a post-install Job, and composes # the endpoint + autogenerated credentials automatically. # -# Storage metrics (hourly bucket sweep, BUZZ_STORAGE_METRICS — see env docs): -# the credentials above must additionally grant `s3:ListBucket` on the bucket -# ARN itself (bucket-level; distinct from the object-level GetObject/ -# PutObject/DeleteObject perms already required for media). Without it the -# first sweep fails AccessDenied and buzz_storage_sweep_ok stays 0 — no other -# media functionality is affected. Set BUZZ_STORAGE_METRICS=off to disable -# the sweep entirely on a deployment that can't grant it. +# Storage metrics are controlled by storageAccounting.relayMode below: +# - inline (default) keeps the relay-local hourly bucket sweep. +# - external reads the latest complete snapshot written by the CronJob. +# - off disables storage metrics in the relay. +# storageAccounting.enabled controls the CronJob independently, so operators +# can stage the worker before switching the relay to external mode. Inline and +# worker scans require `s3:ListBucket` on the bucket ARN itself (bucket-level; +# distinct from the object-level GetObject/PutObject/DeleteObject permissions +# already required for media). Without it the scan fails AccessDenied and no +# other media functionality is affected. External mode emits +# buzz_storage_snapshot_{age_seconds,duration_seconds,max_objects, +# cap_utilization,load_ok}; it does not infer worker-attempt health from the +# last successful snapshot. Monitor CronJob/Job status for worker failures, +# including deadline expiry and OOM termination. # # Whole-community deletion (`buzz-admin deletions ...`) permanently removes # tenant-owned object versions through the v5 deletion path. In addition to the @@ -344,8 +351,8 @@ externalRedis: # never-versioned buckets, which S3 exposes through `ListObjectVersions` with the # `null` version id. # -# Note: buzz_storage_sweep_failures is a process-local gauge — on leader -# failover it resets to the new leader's local count, not a global total. +# Note: inline-only buzz_storage_sweep_failures is a process-local gauge — on +# leader failover it resets to the new leader's local count, not a global total. # Note: on a failed sweep attempt, the next retry fires on the next usage tick # (default 300 s BUZZ_USAGE_METRICS_INTERVAL_SECS), not at sweep-interval # cadence — so a permanently missing s3:ListBucket yields one cheap LIST call @@ -363,6 +370,28 @@ s3: accessKey: "" secretKey: "" +# Isolated S3 accounting worker. Disabled by default, so chart upgrades retain +# the existing relay-local sweep until an operator enables and sizes the job. +storageAccounting: + enabled: false + schedule: "0 3 * * *" + maxObjects: 10000000 + activeDeadlineSeconds: 3600 + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + relayMode: inline # inline | external | off + serviceAccountName: "" # defaults to the main Buzz service account + podLabels: {} + podAnnotations: + sidecar.istio.io/inject: "false" + resources: + requests: + cpu: "1" + memory: 20Gi + limits: + cpu: "1" + memory: 20Gi + # In-cluster MinIO for the quickstart profile only. Production deploys leave # this disabled and use s3.* (or secrets.existingSecret) against managed S3. minio: diff --git a/migrations/0046_storage_accounting_snapshots.sql b/migrations/0046_storage_accounting_snapshots.sql new file mode 100644 index 0000000000..363071b51a --- /dev/null +++ b/migrations/0046_storage_accounting_snapshots.sql @@ -0,0 +1,15 @@ +-- Last complete media-storage accounting result produced by the isolated +-- buzz-admin worker. The worker replaces this singleton row only after the +-- full S3 listing and fold succeed, so relay readers never observe a partial +-- snapshot. +CREATE TABLE storage_accounting_snapshots ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + snapshot JSONB NOT NULL CHECK (jsonb_typeof(snapshot) = 'object'), + completed_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + duration_ms BIGINT NOT NULL CHECK (duration_ms >= 0), + max_objects BIGINT NOT NULL CHECK (max_objects > 0), + code_sha TEXT NOT NULL CHECK (octet_length(code_sha) BETWEEN 1 AND 128) +); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('storage_accounting_snapshots', 'deployment-global completed media accounting handoff'); diff --git a/schema/schema.sql b/schema/schema.sql index 0b4b689666..d7074f359d 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1895,3 +1895,18 @@ CREATE INDEX idx_relay_operator_audit_target INSERT INTO _operator_global_tables (table_name, reason) VALUES ('relay_operator_audit', 'deployment-global append-only roster mutation audit trail; no community_id intentionally'); + +-- ── Storage accounting snapshot ───────────────────────────────────────────── +-- Deployment-global singleton produced by the isolated S3 accounting worker. + +CREATE TABLE storage_accounting_snapshots ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + snapshot JSONB NOT NULL CHECK (jsonb_typeof(snapshot) = 'object'), + completed_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + duration_ms BIGINT NOT NULL CHECK (duration_ms >= 0), + max_objects BIGINT NOT NULL CHECK (max_objects > 0), + code_sha TEXT NOT NULL CHECK (octet_length(code_sha) BETWEEN 1 AND 128) +); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('storage_accounting_snapshots', 'deployment-global completed media accounting handoff'); diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 6a14fe0cfe..8395e9d058 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -104,6 +104,12 @@ run_unit_tests() { run_test_step "buzz-db unit tests" \ cargo test -p buzz-db --lib -- --nocapture + run_test_step "buzz-media storage snapshot serialization test" \ + cargo test -p buzz-media --lib bucket_index::tests::bucket_snapshot_json_round_trip_preserves_community_keys -- --exact --nocapture + + run_test_step "buzz-admin completed snapshot persistence test" \ + cargo test -p buzz-admin storage_snapshot_tests::failed_fold_never_invokes_snapshot_persistence -- --exact --nocapture + # Multi-tenant conformance gate: independent replay checker + golden # fixtures (buzz-conformance). Pure in-process trace replay, no infra. run_test_step "buzz-conformance tests" \ @@ -142,6 +148,9 @@ run_unit_tests() { run_test_step "buzz-relay side-effects helper tests" \ cargo test -p buzz-relay --lib handlers::side_effects::tests:: -- --nocapture + + run_test_step "buzz-relay storage snapshot tests" \ + cargo test -p buzz-relay --lib storage_sweep::tests:: -- --nocapture } # ---- DB / integration tests (infra required) -------------------------------- From 4c2086c0844c30a0956aa1e3b1c2901f0357fb56 Mon Sep 17 00:00:00 2001 From: ravarora2 <130506156+ravarora2@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:26:22 -0400 Subject: [PATCH 04/22] feat(db): expose connection setup metrics (#7286) ## Why Current pool metrics show checkout outcomes and pool state after the fact. They do not show when a checkout began or which writer-connection setup step failed. During startup and pool growth, operators need to distinguish pool saturation from a slow or unsafe connection setup. ## What - Count every instrumented database checkout when it starts, with a fixed operation label. - Measure writer-pool creation and every physical writer connection across `physical_connect`, `created_at_floor`, `session_timeouts`, `isolation`, and `ready`. - Publish fixed-cardinality start, terminal-outcome, duration, and waiter metrics without database URLs, SQL, raw errors, connection ordinals, or per-connection lifecycle logs. - Document how to identify the active setup bottleneck during a rollout or incident. ## How The existing typed checkout wrapper records starts and current waiters. The huddle-history path uses that wrapper instead of a raw pool checkout. The production SQLx `after_connect` hook records start, terminal outcome, and duration for each sequential safety step. A drop guard records cancellation once if setup exits before a terminal result. Fixed enums keep every label bounded. For a phase, `started_total - sum(attempts_total)` is the number of in-progress attempts on that pod. Because setup is sequential, a later phase starting also proves the earlier phases succeeded. The database path emits metrics only. This PR intentionally does not add per-connection lifecycle receipts or direct `stderr` writes. ## Risk Medium. This changes the production writer-pool `after_connect` hook and adds one counter update to instrumented checkout paths. It does not change the database safety statements or their failure behavior. The telemetry has fixed labels and no per-connection identifiers. ## Testing At exact head `ffb5fcb114a9c986ffd0cb2cfc9d413a32d5b1aa`, a release relay was started against isolated local PostgreSQL, Redis, and MinIO services. The main health endpoint returned `ok`, readiness returned `{"status":"ready"}`, the connection-step metrics were exported, and no database lifecycle receipts were emitted. A release CLI then created a channel, sent a message, and read the same event back successfully. The earlier staging deployment used pre-rebase head `f58e9480a4f068db0c591f604fd6800fdd4bfc45`. The deployed multi-architecture image came from [GitHub Actions run 33780888255](https://github.com/block/buzz/actions/runs/33780888255), manifest `sha256:b9351fa644e08376cbe1999f9bee311d33d1799a68eadef7929c4f862a832fec`. The [staging deployment](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/317) brought both pods in ReplicaSet `buzz-6c8758bd7d` to Ready with zero restarts, and the connection metrics produced data in the [rollout dashboard](https://app.datadoghq.com/dashboard/tm7-qxr-wt2/buzz-startup--rollout-safety). That staging image predates the cleanup that removed per-connection lifecycle logs; the metric schema and database safety statements are unchanged. ## Verification - `cargo test -p buzz-db -- --test-threads=1`: 128 tests passed across the package and integration target; 255 opt-in tests remained ignored. - Four opt-in production-path PostgreSQL regressions passed: initial minimum connections, post-startup pool growth, isolation failure, and session-timeout setup failure. - All 35 relay media tests passed against the isolated PostgreSQL database. - Rust formatting, workspace clippy, desktop/Tauri clippy, web checks, mobile analysis, security checks, and file-size policy checks passed. - The repository-wide unit stage also exposed three unrelated existing `buzz-acp` failures: one timing-sensitive keepalive test and two environment-default tests. ## Bigger picture This is the database-metrics part of the startup and rollout observability work. The early-startup lifecycle foundation merged in #7258, so the rebase removed that duplicate commit from this PR. Originating discussion: buzz://message?channel=6ac85131-70cd-4bda-a031-38d34114934e&id=fa2bed181c092697210a60bb6eedc55a665d5c1c6cabc1413a04686647011f71 Generated with Codex --------- Signed-off-by: Ravneet Arora --- crates/buzz-db/src/lib.rs | 19 +- .../src/runtime/connection_observability.rs | 344 ++++++++++++++++++ crates/buzz-db/src/runtime/mod.rs | 82 ++++- crates/buzz-db/src/runtime/observability.rs | 30 +- crates/buzz-db/src/runtime/tests.rs | 275 +++++++++++++- crates/buzz-db/src/store/event.rs | 65 +++- crates/buzz-relay/src/metrics.rs | 98 ++++- deploy/charts/buzz/README.md | 47 ++- 8 files changed, 935 insertions(+), 25 deletions(-) create mode 100644 crates/buzz-db/src/runtime/connection_observability.rs diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index cccb1e4b92..d444d9ce52 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -31,8 +31,8 @@ pub mod error; mod test_support; pub use runtime::{ - insert_mentions, migration, replica_fence, Db, DbConfig, DbPoolRole, DbPoolStats, - DbReadinessOutcome, ReadSession, + insert_mentions, migration, replica_fence, Db, DbConfig, DbConnectionOutcome, DbConnectionStep, + DbPoolRole, DbPoolStats, DbReadinessOutcome, ReadSession, }; /// Valid low-cardinality `(pool_role, operation)` pairs for pool-acquisition telemetry. @@ -42,6 +42,21 @@ pub const DB_POOL_ACQUIRE_VALID_PAIRS: [(&str, &str); 11] = /// Raw Prometheus series ceiling per relay pod for the operation-aware contract. pub const DB_POOL_ACQUIRE_RAW_SERIES_PER_POD: usize = runtime::observability::POOL_ACQUIRE_RAW_SERIES_PER_POD; + +/// Valid database connection role/step pairs with a start counter. +pub const DB_CONNECTION_STARTED_STEPS: [(DbPoolRole, DbConnectionStep); 4] = + runtime::CONNECTION_STARTED_STEPS; + +/// Valid database connection role/step pairs with a duration histogram. +pub const DB_CONNECTION_DURATION_STEPS: [(DbPoolRole, DbConnectionStep); 4] = + runtime::CONNECTION_DURATION_STEPS; + +/// Valid database connection role/step/outcome terminal combinations. +pub const DB_CONNECTION_TERMINALS: [(DbPoolRole, DbConnectionStep, DbConnectionOutcome); 15] = + runtime::CONNECTION_TERMINALS; + +/// Raw Prometheus series ceiling per pod for connection-step telemetry. +pub const DB_CONNECTION_RAW_SERIES_PER_POD: usize = runtime::CONNECTION_RAW_SERIES_PER_POD; pub(crate) use runtime::{ insert_mentions_in_transaction, observability, route_proof, ReadSessionInner, RouteDecision, RoutePredicate, diff --git a/crates/buzz-db/src/runtime/connection_observability.rs b/crates/buzz-db/src/runtime/connection_observability.rs new file mode 100644 index 0000000000..a1ab04022f --- /dev/null +++ b/crates/buzz-db/src/runtime/connection_observability.rs @@ -0,0 +1,344 @@ +//! Fixed-vocabulary metrics for writer-pool connection setup. +//! +//! SQLx exposes the point immediately after a physical connection succeeds, +//! but it does not expose a callback immediately before each physical dial. +//! Consequently, `physical_connect` is a success milestone rather than a +//! duration phase. The aggregate `writer_pool` phase owns failures that occur +//! before `after_connect`, while the session phases own their exact failures. + +use std::time::{Duration, Instant}; + +use super::DbPoolRole; + +/// Fixed writer connection-setup steps. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DbConnectionStep { + /// Construct the writer pool and satisfy its initial minimum size. + WriterPool, + /// A physical connection has completed DNS/network/TLS/authentication. + PhysicalConnect, + /// Install the created-at replica-fence floor. + CreatedAtFloor, + /// Install lock, idle-transaction, and statement timeouts. + SessionTimeouts, + /// Verify READ COMMITTED transaction isolation. + Isolation, + /// The physical connection passed every required session premise. + Ready, +} + +impl DbConnectionStep { + /// Complete metric-label vocabulary. + pub const ALL: [Self; 6] = [ + Self::WriterPool, + Self::PhysicalConnect, + Self::CreatedAtFloor, + Self::SessionTimeouts, + Self::Isolation, + Self::Ready, + ]; + + /// Stable metric label. + pub const fn as_str(self) -> &'static str { + match self { + Self::WriterPool => "writer_pool", + Self::PhysicalConnect => "physical_connect", + Self::CreatedAtFloor => "created_at_floor", + Self::SessionTimeouts => "session_timeouts", + Self::Isolation => "isolation", + Self::Ready => "ready", + } + } +} + +/// Bounded terminal outcome for a connection-setup step. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DbConnectionOutcome { + /// The step completed successfully. + Succeeded, + /// The step failed. + Failed, + /// The aggregate pool deadline expired. + TimedOut, + /// The owning future was dropped before a terminal. + Cancelled, +} + +impl DbConnectionOutcome { + /// Stable metric label. + pub const fn as_str(self) -> &'static str { + match self { + Self::Succeeded => "succeeded", + Self::Failed => "failed", + Self::TimedOut => "timed_out", + Self::Cancelled => "cancelled", + } + } +} + +/// Valid role/step pairs with an explicit start counter. +pub const CONNECTION_STARTED_STEPS: [(DbPoolRole, DbConnectionStep); 4] = [ + (DbPoolRole::Writer, DbConnectionStep::WriterPool), + (DbPoolRole::Writer, DbConnectionStep::CreatedAtFloor), + (DbPoolRole::Writer, DbConnectionStep::SessionTimeouts), + (DbPoolRole::Writer, DbConnectionStep::Isolation), +]; + +/// Valid role/step pairs with a duration histogram. +pub const CONNECTION_DURATION_STEPS: [(DbPoolRole, DbConnectionStep); 4] = CONNECTION_STARTED_STEPS; + +/// Valid role/step/outcome terminal combinations. +pub const CONNECTION_TERMINALS: [(DbPoolRole, DbConnectionStep, DbConnectionOutcome); 15] = [ + ( + DbPoolRole::Writer, + DbConnectionStep::WriterPool, + DbConnectionOutcome::Succeeded, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::WriterPool, + DbConnectionOutcome::Failed, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::WriterPool, + DbConnectionOutcome::TimedOut, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::WriterPool, + DbConnectionOutcome::Cancelled, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::PhysicalConnect, + DbConnectionOutcome::Succeeded, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::CreatedAtFloor, + DbConnectionOutcome::Succeeded, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::CreatedAtFloor, + DbConnectionOutcome::Failed, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::CreatedAtFloor, + DbConnectionOutcome::Cancelled, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::SessionTimeouts, + DbConnectionOutcome::Succeeded, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::SessionTimeouts, + DbConnectionOutcome::Failed, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::SessionTimeouts, + DbConnectionOutcome::Cancelled, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::Isolation, + DbConnectionOutcome::Succeeded, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::Isolation, + DbConnectionOutcome::Failed, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::Isolation, + DbConnectionOutcome::Cancelled, + ), + ( + DbPoolRole::Writer, + DbConnectionStep::Ready, + DbConnectionOutcome::Succeeded, + ), +]; + +/// Four start counters + four 13-series histograms + fifteen terminal counters. +pub const CONNECTION_RAW_SERIES_PER_POD: usize = 4 + (4 * 13) + 15; + +pub(crate) struct DbConnectionStepAttempt { + pool_role: DbPoolRole, + step: DbConnectionStep, + started: Instant, + finished: bool, +} + +impl DbConnectionStepAttempt { + pub(crate) fn start(pool_role: DbPoolRole, step: DbConnectionStep) -> Self { + metrics::counter!( + "buzz_db_connection_step_started_total", + "pool_role" => pool_role.as_str(), + "step" => step.as_str(), + ) + .increment(1); + Self { + pool_role, + step, + started: Instant::now(), + finished: false, + } + } + + pub(crate) fn succeed(self) { + self.finish(DbConnectionOutcome::Succeeded); + } + + pub(crate) fn fail(self) { + self.finish(DbConnectionOutcome::Failed); + } + + pub(crate) fn time_out(self) { + self.finish(DbConnectionOutcome::TimedOut); + } + + fn finish(mut self, outcome: DbConnectionOutcome) { + record_terminal( + self.pool_role, + self.step, + outcome, + Some(self.started.elapsed()), + ); + self.finished = true; + } +} + +impl Drop for DbConnectionStepAttempt { + fn drop(&mut self) { + if self.finished { + return; + } + let outcome = if std::thread::panicking() { + DbConnectionOutcome::Failed + } else { + DbConnectionOutcome::Cancelled + }; + record_terminal( + self.pool_role, + self.step, + outcome, + Some(self.started.elapsed()), + ); + self.finished = true; + } +} + +pub(crate) fn record_milestone(pool_role: DbPoolRole, step: DbConnectionStep) { + record_terminal(pool_role, step, DbConnectionOutcome::Succeeded, None); +} + +fn record_terminal( + pool_role: DbPoolRole, + step: DbConnectionStep, + outcome: DbConnectionOutcome, + elapsed: Option, +) { + metrics::counter!( + "buzz_db_connection_step_attempts_total", + "pool_role" => pool_role.as_str(), + "step" => step.as_str(), + "outcome" => outcome.as_str(), + ) + .increment(1); + if let Some(elapsed) = elapsed { + metrics::histogram!( + "buzz_db_connection_step_duration_seconds", + "pool_role" => pool_role.as_str(), + "step" => step.as_str(), + ) + .record(elapsed.as_secs_f64()); + } +} + +pub(crate) fn classify_pool_outcome(error: &sqlx::Error) -> DbConnectionOutcome { + if matches!(error, sqlx::Error::PoolTimedOut) { + DbConnectionOutcome::TimedOut + } else { + DbConnectionOutcome::Failed + } +} + +#[cfg(test)] +mod tests { + use super::*; + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + + #[test] + fn vocabulary_and_series_budget_are_frozen() { + assert_eq!( + DbConnectionStep::ALL.map(DbConnectionStep::as_str), + [ + "writer_pool", + "physical_connect", + "created_at_floor", + "session_timeouts", + "isolation", + "ready", + ] + ); + assert_eq!(CONNECTION_RAW_SERIES_PER_POD, 71); + } + + #[test] + fn dropped_step_is_cancelled_exactly_once_without_sensitive_labels() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let attempt = + DbConnectionStepAttempt::start(DbPoolRole::Writer, DbConnectionStep::CreatedAtFloor); + drop(attempt); + + let metrics = snapshotter.snapshot().into_vec(); + assert_eq!(metrics.len(), 3); + for (key, _, _, value) in metrics { + let labels = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + assert_eq!(labels.get("pool_role"), Some(&"writer")); + assert_eq!(labels.get("step"), Some(&"created_at_floor")); + assert!(!labels.contains_key("reason")); + assert!(!labels.contains_key("connection_ordinal")); + match value { + DebugValue::Counter(value) => { + assert_eq!(value, 1); + if key.key().name() == "buzz_db_connection_step_attempts_total" { + assert_eq!(labels.get("outcome"), Some(&"cancelled")); + } + } + DebugValue::Histogram(values) => assert_eq!(values.len(), 1), + DebugValue::Gauge(_) => panic!("connection setup has no gauges"), + } + } + } + + #[test] + fn pool_timeout_is_distinguished_from_other_failures() { + assert_eq!( + classify_pool_outcome(&sqlx::Error::PoolTimedOut), + DbConnectionOutcome::TimedOut + ); + assert_eq!( + classify_pool_outcome(&sqlx::Error::PoolClosed), + DbConnectionOutcome::Failed + ); + let io = sqlx::Error::Io(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "postgres://secret-user:secret-password@example.invalid/private", + )); + assert_eq!(classify_pool_outcome(&io), DbConnectionOutcome::Failed); + } +} diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 00bb0dc325..150c8f4768 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -1,7 +1,14 @@ +mod connection_observability; pub mod migration; pub(crate) mod observability; pub mod replica_fence; +pub use connection_observability::{DbConnectionOutcome, DbConnectionStep}; +pub(crate) use connection_observability::{ + CONNECTION_DURATION_STEPS, CONNECTION_RAW_SERIES_PER_POD, CONNECTION_STARTED_STEPS, + CONNECTION_TERMINALS, +}; + use crate::{deletion, event, DbError, EventQuery, Result}; use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; @@ -630,6 +637,10 @@ impl Db { /// constructor so they inherit the timeout, floor-guard, and isolation /// policy installed by [`Db::new`]. pub async fn connect_writer_pool(config: &DbConfig) -> Result { + use connection_observability::{ + classify_pool_outcome, record_milestone, DbConnectionStep, DbConnectionStepAttempt, + }; + let lock_timeout_ms = config.lock_timeout_ms; let idle_txn_timeout_ms = config.idle_txn_timeout_ms; let statement_timeout_ms = config.statement_timeout_ms; @@ -641,11 +652,27 @@ impl Db { .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) .after_connect(move |conn, _meta| { Box::pin(async move { + // SQLx 0.9 exposes no callback immediately before each raw + // physical dial. Entering `after_connect` is the truthful + // point at which DNS/network/TLS/authentication succeeded. + record_milestone(DbPoolRole::Writer, DbConnectionStep::PhysicalConnect); + // `SET` cannot take bind parameters; `set_config` can. - sqlx::query("SELECT set_config('buzz.created_at_floor', $1, false)") + let floor = DbConnectionStepAttempt::start( + DbPoolRole::Writer, + DbConnectionStep::CreatedAtFloor, + ); + if let Err(error) = + sqlx::query("SELECT set_config('buzz.created_at_floor', $1, false)") .bind(replica_fence::CREATED_AT_FLOOR_SECS.to_string()) .execute(&mut *conn) - .await?; + .await + { + floor.fail(); + return Err(error); + } + floor.succeed(); + // `lock_timeout` fails the waiting statement; it does not // cancel the holder. `idle_in_transaction_session_timeout` // reaps only holders idling inside an open transaction, @@ -654,7 +681,11 @@ impl Db { // milliseconds. Migration/schema-destruction connections // reset lock and statement timeouts before their intentional // long wait (see `with_exclusive_schema_destruction_lock`). - sqlx::query( + let timeouts = DbConnectionStepAttempt::start( + DbPoolRole::Writer, + DbConnectionStep::SessionTimeouts, + ); + if let Err(error) = sqlx::query( "SELECT set_config('lock_timeout', $1, false), \ set_config('idle_in_transaction_session_timeout', $2, false), \ set_config('statement_timeout', $3, false)", @@ -663,11 +694,29 @@ impl Db { .bind(idle_txn_timeout_ms.to_string()) .bind(statement_timeout_ms.to_string()) .execute(&mut *conn) - .await?; - let isolation: String = sqlx::query_scalar("SHOW transaction_isolation") + .await + { + timeouts.fail(); + return Err(error); + } + timeouts.succeed(); + + let isolation_step = DbConnectionStepAttempt::start( + DbPoolRole::Writer, + DbConnectionStep::Isolation, + ); + let isolation: String = match sqlx::query_scalar("SHOW transaction_isolation") .fetch_one(&mut *conn) - .await?; + .await + { + Ok(isolation) => isolation, + Err(error) => { + isolation_step.fail(); + return Err(error); + } + }; if isolation != "read committed" { + isolation_step.fail(); return Err(sqlx::Error::Configuration( format!( "writer pool requires READ COMMITTED transaction isolation, got {isolation}" @@ -675,10 +724,29 @@ impl Db { .into(), )); } + isolation_step.succeed(); + record_milestone(DbPoolRole::Writer, DbConnectionStep::Ready); Ok(()) }) }); - Ok(options.connect(&config.database_url).await?) + + let pool_attempt = + DbConnectionStepAttempt::start(DbPoolRole::Writer, DbConnectionStep::WriterPool); + match options.connect(&config.database_url).await { + Ok(pool) => { + pool_attempt.succeed(); + Ok(pool) + } + Err(error) => { + let outcome = classify_pool_outcome(&error); + if outcome == connection_observability::DbConnectionOutcome::TimedOut { + pool_attempt.time_out(); + } else { + pool_attempt.fail(); + } + Err(error.into()) + } + } } /// Reader acquire timeout — deliberately far below the writer's diff --git a/crates/buzz-db/src/runtime/observability.rs b/crates/buzz-db/src/runtime/observability.rs index 334ed60255..079c43f98a 100644 --- a/crates/buzz-db/src/runtime/observability.rs +++ b/crates/buzz-db/src/runtime/observability.rs @@ -153,8 +153,8 @@ pub(crate) const POOL_ACQUIRE_VALID_PAIRS: [(&str, &str); 11] = [ (DbPoolRole::Writer.as_str(), "maintenance"), ]; -/// Eleven valid pairs × (12 histogram series + 4 outcome counters + 1 gauge). -pub(crate) const POOL_ACQUIRE_RAW_SERIES_PER_POD: usize = POOL_ACQUIRE_VALID_PAIRS.len() * 17; +/// Eleven valid pairs × (12 histogram series + 1 start counter + 4 outcome counters + 1 gauge). +pub(crate) const POOL_ACQUIRE_RAW_SERIES_PER_POD: usize = POOL_ACQUIRE_VALID_PAIRS.len() * 18; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum LockType { @@ -382,6 +382,12 @@ struct PoolAcquireAttempt { impl PoolAcquireAttempt { fn start(pair: PoolOperation, emit_legacy: bool) -> Self { + metrics::counter!( + "buzz_db_pool_acquire_started_total", + "pool_role" => pair.pool_role(), + "operation" => pair.operation(), + ) + .increment(1); { let mut waiters = POOL_WAITERS[pair.index()] .lock() @@ -606,7 +612,7 @@ mod tests { PoolOperation::ReaderSubscriptionHistory, ] ); - assert_eq!(super::POOL_ACQUIRE_RAW_SERIES_PER_POD, 187); + assert_eq!(super::POOL_ACQUIRE_RAW_SERIES_PER_POD, 198); assert_eq!( LockType::ALL.map(LockType::as_str), [ @@ -863,6 +869,24 @@ mod tests { let _guard = metrics::set_default_local_recorder(&recorder); let attempt = PoolAcquireAttempt::start(PoolOperation::WriterTenantResolution, false); + let in_flight = snapshotter.snapshot().into_vec(); + assert!(in_flight.iter().any(|(key, _, _, value)| { + let labels = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + matches!(value, DebugValue::Counter(1)) + && key.key().name() == "buzz_db_pool_acquire_started_total" + && labels.get("pool_role") == Some(&"writer") + && labels.get("operation") == Some(&"tenant_resolution") + })); + assert!( + in_flight.iter().all(|(key, _, _, _)| { + key.key().name() != "buzz_db_pool_acquire_attempts_total" + }), + "the request-start signal must be observable before its terminal" + ); drop(attempt); refresh_pool_waiters(true); diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index d97ebf0ac5..cce1927be6 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -1,11 +1,69 @@ use super::*; use crate::{relay_members, thread}; use buzz_core::CommunityId; +use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshotter}; use sqlx::{Connection, PgPool}; use uuid::Uuid; const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials +type ConnectionCounters = std::collections::BTreeMap<(String, String, Option), u64>; + +fn connection_counters(snapshotter: &Snapshotter) -> ConnectionCounters { + snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(key, _, _, value)| { + let metric_name = key.key().name(); + if ![ + "buzz_db_connection_step_started_total", + "buzz_db_connection_step_attempts_total", + ] + .contains(&metric_name) + { + return None; + } + let DebugValue::Counter(value) = value else { + panic!("{metric_name} must be a counter"); + }; + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + assert_eq!(labels.get("pool_role").map(String::as_str), Some("writer")); + Some(( + ( + metric_name.to_owned(), + labels + .get("step") + .expect("connection step label") + .to_owned(), + labels.get("outcome").cloned(), + ), + value, + )) + }) + .collect() +} + +fn connection_counter( + counters: &ConnectionCounters, + metric_name: &str, + step: DbConnectionStep, + outcome: Option, +) -> u64 { + counters + .get(&( + metric_name.to_owned(), + step.as_str().to_owned(), + outcome.map(|outcome| outcome.as_str().to_owned()), + )) + .copied() + .unwrap_or_default() +} + async fn setup_db() -> Db { let database_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); let pool = PgPool::connect(&database_url) @@ -2544,9 +2602,151 @@ fn writer_pool_safety_hook_is_single_and_composed() { assert!(!reader_doc.contains("Db::connect_writer_pool")); } -#[tokio::test] +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires Postgres"] +async fn writer_pool_metrics_record_every_initial_connection_ready() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let db = Db::new(&DbConfig { + database_url: crate::test_support::database_url(), + max_connections: 2, + min_connections: 2, + ..DbConfig::default() + }) + .await + .expect("connect instrumented writer pool"); + let counters = connection_counters(&snapshotter); + + assert_eq!( + connection_counter( + &counters, + "buzz_db_connection_step_started_total", + DbConnectionStep::WriterPool, + None, + ), + 1 + ); + assert_eq!( + connection_counter( + &counters, + "buzz_db_connection_step_attempts_total", + DbConnectionStep::WriterPool, + Some(DbConnectionOutcome::Succeeded), + ), + 1 + ); + assert_eq!( + connection_counter( + &counters, + "buzz_db_connection_step_attempts_total", + DbConnectionStep::PhysicalConnect, + Some(DbConnectionOutcome::Succeeded), + ), + 2 + ); + for step in [ + DbConnectionStep::CreatedAtFloor, + DbConnectionStep::SessionTimeouts, + DbConnectionStep::Isolation, + ] { + assert_eq!( + connection_counter( + &counters, + "buzz_db_connection_step_started_total", + step, + None, + ), + 2, + "every initial connection must start {}", + step.as_str(), + ); + assert_eq!( + connection_counter( + &counters, + "buzz_db_connection_step_attempts_total", + step, + Some(DbConnectionOutcome::Succeeded), + ), + 2, + "every initial connection must complete {}", + step.as_str(), + ); + } + assert_eq!( + connection_counter( + &counters, + "buzz_db_connection_step_attempts_total", + DbConnectionStep::Ready, + Some(DbConnectionOutcome::Succeeded), + ), + 2 + ); + db.pool.close().await; +} + +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires Postgres"] +async fn writer_pool_metrics_record_connection_created_after_startup() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let pool = Db::connect_writer_pool(&DbConfig { + database_url: crate::test_support::database_url(), + max_connections: 2, + min_connections: 1, + ..DbConfig::default() + }) + .await + .expect("connect instrumented size-one writer pool"); + let startup_counters = connection_counters(&snapshotter); + assert_eq!( + connection_counter( + &startup_counters, + "buzz_db_connection_step_attempts_total", + DbConnectionStep::Ready, + Some(DbConnectionOutcome::Succeeded), + ), + 1 + ); + + let first = pool.acquire().await.expect("hold initial connection"); + let second = pool + .acquire() + .await + .expect("grow pool with a second connection"); + let growth_counters = connection_counters(&snapshotter); + assert_eq!( + connection_counter( + &growth_counters, + "buzz_db_connection_step_attempts_total", + DbConnectionStep::Ready, + Some(DbConnectionOutcome::Succeeded), + ), + 1, + "a post-startup pool growth connection must traverse the same instrumented safety hook" + ); + assert_eq!( + connection_counter( + &growth_counters, + "buzz_db_connection_step_attempts_total", + DbConnectionStep::PhysicalConnect, + Some(DbConnectionOutcome::Succeeded), + ), + 1, + ); + + drop(second); + drop(first); + pool.close().await; +} + +#[tokio::test(flavor = "current_thread")] #[ignore = "requires Postgres"] async fn writer_pool_rejects_non_read_committed_database_default() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); let admin = PgPool::connect(&admin_url().await) .await .expect("connect admin"); @@ -2576,6 +2776,26 @@ async fn writer_pool_rejects_non_read_committed_database_default() { || error.to_string().contains("pool timed out"), "unexpected isolation rejection: {error}" ); + let counters = connection_counters(&snapshotter); + assert!( + connection_counter( + &counters, + "buzz_db_connection_step_attempts_total", + DbConnectionStep::Isolation, + Some(DbConnectionOutcome::Failed), + ) > 0, + "the failing production hook must record the isolation failure" + ); + assert_eq!( + connection_counter( + &counters, + "buzz_db_connection_step_attempts_total", + DbConnectionStep::Ready, + Some(DbConnectionOutcome::Succeeded), + ), + 0, + "an isolation-rejected connection must never reach ready" + ); sqlx::query(sqlx::AssertSqlSafe(format!( "DROP DATABASE {name} WITH (FORCE)" @@ -2585,6 +2805,59 @@ async fn writer_pool_rejects_non_read_committed_database_default() { .expect("drop isolation test database"); } +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires Postgres"] +async fn writer_pool_metrics_stop_after_session_timeout_setup_failure() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + let error = Db::new(&DbConfig { + database_url: crate::test_support::database_url(), + max_connections: 1, + min_connections: 1, + acquire_timeout_secs: 1, + statement_timeout_ms: u64::MAX, + ..DbConfig::default() + }) + .await + .expect_err("Postgres must reject an out-of-range statement timeout"); + assert!( + error.to_string().contains("pool timed out") + || error.to_string().contains("invalid value for parameter") + ); + let counters = connection_counters(&snapshotter); + + assert!( + connection_counter( + &counters, + "buzz_db_connection_step_attempts_total", + DbConnectionStep::SessionTimeouts, + Some(DbConnectionOutcome::Failed), + ) > 0, + "the failing production hook must record the timeout-setup failure" + ); + assert_eq!( + connection_counter( + &counters, + "buzz_db_connection_step_started_total", + DbConnectionStep::Isolation, + None, + ), + 0, + "a timeout-setup failure must stop before isolation" + ); + assert_eq!( + connection_counter( + &counters, + "buzz_db_connection_step_attempts_total", + DbConnectionStep::Ready, + Some(DbConnectionOutcome::Succeeded), + ), + 0, + "a timeout-setup failure must never reach ready" + ); +} + /// Session-timeout environment overrides retain PostgreSQL's `0 = disabled` /// semantics and ignore invalid values. #[test] diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index cb45809ead..a6c3eae8f0 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -237,6 +237,11 @@ pub async fn huddle_started_links( if parent_channel_ids.is_empty() || ephemeral_channel_ids.is_empty() { return Ok(Vec::new()); } + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; let rows = sqlx::query( r#" SELECT DISTINCT ON (backing.id) @@ -267,7 +272,7 @@ pub async fn huddle_started_links( .bind(KIND_HUDDLE_STARTED as i32) .bind(ephemeral_channel_ids) .bind(HUDDLE_LINK_CONTENT_MAX_BYTES) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; rows.into_iter() @@ -2796,7 +2801,7 @@ mod postgres_tests { ); } - #[tokio::test] + #[tokio::test(flavor = "current_thread")] #[ignore = "requires Postgres"] async fn huddle_started_links_batches_valid_creator_links_and_ignores_malformed_content() { let pool = setup_pool().await; @@ -2831,10 +2836,60 @@ mod postgres_tests { .expect("insert huddle-start candidate"); } - let links = huddle_started_links(&pool, community, &[parent], &[session]) - .await - .expect("batch huddle links"); + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let db = Db::from_pool(pool); + let links = { + let _guard = metrics::set_default_local_recorder(&recorder); + db.huddle_started_links(community, &[parent], &[session]) + .await + } + .expect("batch huddle links"); assert_eq!(links, vec![(session, parent, creator)]); + + let counters = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(key, _, _, value)| { + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + if labels.get("pool_role").map(String::as_str) != Some("writer") + || labels.get("operation").map(String::as_str) != Some("subscription_history") + { + return None; + } + let metrics_util::debugging::DebugValue::Counter(value) = value else { + return None; + }; + Some(( + (key.key().name().to_owned(), labels.get("outcome").cloned()), + value, + )) + }) + .collect::>(); + assert_eq!( + counters, + [ + ( + ("buzz_db_pool_acquire_started_total".to_owned(), None), + 1, + ), + ( + ( + "buzz_db_pool_acquire_attempts_total".to_owned(), + Some("success".to_owned()), + ), + 1, + ), + ] + .into_iter() + .collect(), + "the production huddle lookup must emit one writer/subscription_history start and success terminal" + ); } #[test] diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index a81a432764..c1e72f7b75 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -117,6 +117,12 @@ const DB_POOL_ACQUIRE_DURATION_BUCKETS_S: [f64; 9] = [0.001, 0.005, 0.01, 0.025, 0.05, 0.15, 0.5, 1.0, 3.0]; const DB_POOL_ACQUIRE_DURATION_UNIT: metrics::Unit = metrics::Unit::Seconds; +/// Writer pool/session buckets preserve sub-millisecond setup while retaining +/// seconds-scale failures in the final bucket. +const DB_CONNECTION_STEP_DURATION_BUCKETS_S: [f64; 10] = [ + 0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.15, 0.5, 1.0, 3.0, +]; + /// Seconds-scale buckets for Git hydration and pack streams. const GIT_DURATION_BUCKETS_S: [f64; 13] = [ 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, @@ -189,6 +195,11 @@ fn configured_prometheus_builder(gauge_idle_timeout_secs: u64) -> PrometheusBuil &DB_POOL_ACQUIRE_DURATION_BUCKETS_S, ) .expect("valid DB pool acquisition duration bucket boundaries") + .set_buckets_for_metric( + Matcher::Full("buzz_db_connection_step_duration_seconds".to_owned()), + &DB_CONNECTION_STEP_DURATION_BUCKETS_S, + ) + .expect("valid DB connection-step duration bucket boundaries") .set_buckets_for_metric( Matcher::Full("buzz_git_hydrate_bytes".to_owned()), &GIT_BYTES_BUCKETS, @@ -318,6 +329,10 @@ pub(crate) fn describe_readiness_metrics() { /// Register the frozen operation-aware pool-acquisition contract. pub(crate) fn describe_db_pool_metrics() { + metrics::describe_counter!( + "buzz_db_pool_acquire_started_total", + "Database pool checkout starts by valid pool role and operation" + ); metrics::describe_histogram!( "buzz_db_pool_acquire_duration_seconds", DB_POOL_ACQUIRE_DURATION_UNIT, @@ -331,6 +346,19 @@ pub(crate) fn describe_db_pool_metrics() { "buzz_db_pool_waiters", "Current tracked-operation database pool checkout attempts in progress by valid pool role and operation" ); + metrics::describe_counter!( + "buzz_db_connection_step_started_total", + "Writer connection setup phase starts by fixed pool role and step" + ); + metrics::describe_counter!( + "buzz_db_connection_step_attempts_total", + "Writer connection setup terminals by fixed pool role, step, and outcome" + ); + metrics::describe_histogram!( + "buzz_db_connection_step_duration_seconds", + metrics::Unit::Seconds, + "Writer connection setup phase duration by fixed pool role and step" + ); metrics::describe_gauge!( "buzz_db_pool_connections", "Current Postgres pool connections by physical pool role and bounded utilization state" @@ -810,11 +838,17 @@ mod contract_tests { } #[test] - fn production_builder_exports_frozen_db_pool_contract_and_187_series_budget() { + fn production_builder_exports_frozen_db_pool_and_connection_contracts() { let (recorder, handle) = super::readiness_test_recorder(); metrics::with_local_recorder(&recorder, || { super::describe_db_pool_metrics(); for (pool_role, operation) in buzz_db::DB_POOL_ACQUIRE_VALID_PAIRS { + metrics::counter!( + "buzz_db_pool_acquire_started_total", + "pool_role" => pool_role, + "operation" => operation, + ) + .increment(1); metrics::histogram!( "buzz_db_pool_acquire_duration_seconds", "pool_role" => pool_role, @@ -837,15 +871,44 @@ mod contract_tests { .increment(1); } } + for (pool_role, step) in buzz_db::DB_CONNECTION_STARTED_STEPS { + metrics::counter!( + "buzz_db_connection_step_started_total", + "pool_role" => pool_role.as_str(), + "step" => step.as_str(), + ) + .increment(1); + } + for (pool_role, step) in buzz_db::DB_CONNECTION_DURATION_STEPS { + metrics::histogram!( + "buzz_db_connection_step_duration_seconds", + "pool_role" => pool_role.as_str(), + "step" => step.as_str(), + ) + .record(0.02); + } + for (pool_role, step, outcome) in buzz_db::DB_CONNECTION_TERMINALS { + metrics::counter!( + "buzz_db_connection_step_attempts_total", + "pool_role" => pool_role.as_str(), + "step" => step.as_str(), + "outcome" => outcome.as_str(), + ) + .increment(1); + } }); let scrape = handle.render(); + assert!(scrape.contains("# TYPE buzz_db_pool_acquire_started_total counter")); assert!(scrape.contains("# TYPE buzz_db_pool_acquire_duration_seconds histogram")); assert!(scrape.contains("# TYPE buzz_db_pool_acquire_attempts_total counter")); assert!(scrape.contains("# TYPE buzz_db_pool_waiters gauge")); assert!(scrape.contains("# HELP buzz_db_pool_acquire_duration_seconds Database pool checkout duration by valid pool role and operation")); assert!(scrape.contains("# HELP buzz_db_pool_acquire_attempts_total Database pool checkout terminals by valid pool role, operation, and outcome")); assert!(scrape.contains("# HELP buzz_db_pool_waiters Current tracked-operation database pool checkout attempts in progress by valid pool role and operation")); + assert!(scrape.contains("# TYPE buzz_db_connection_step_started_total counter")); + assert!(scrape.contains("# TYPE buzz_db_connection_step_attempts_total counter")); + assert!(scrape.contains("# TYPE buzz_db_connection_step_duration_seconds histogram")); assert_eq!(super::DB_POOL_ACQUIRE_DURATION_UNIT, metrics::Unit::Seconds); let readiness_buckets = scrape .lines() @@ -870,7 +933,8 @@ mod contract_tests { let raw_series = scrape .lines() .filter(|line| { - line.starts_with("buzz_db_pool_acquire_duration_seconds") + line.starts_with("buzz_db_pool_acquire_started_total") + || line.starts_with("buzz_db_pool_acquire_duration_seconds") || line.starts_with("buzz_db_pool_acquire_attempts_total") || line.starts_with("buzz_db_pool_waiters{") }) @@ -885,7 +949,9 @@ mod contract_tests { let keys = label_keys(line); if line.starts_with("buzz_db_pool_acquire_duration_seconds_bucket") { assert_eq!(keys, BTreeSet::from(["le", "operation", "pool_role"])); - } else if line.starts_with("buzz_db_pool_acquire_duration_seconds") { + } else if line.starts_with("buzz_db_pool_acquire_duration_seconds") + || line.starts_with("buzz_db_pool_acquire_started_total") + { assert_eq!(keys, BTreeSet::from(["operation", "pool_role"])); } else if line.starts_with("buzz_db_pool_acquire_attempts_total") { assert_eq!(keys, BTreeSet::from(["operation", "outcome", "pool_role"])); @@ -895,6 +961,32 @@ mod contract_tests { assert!(!line.contains("operation=\"other\"")); assert!(!line.contains("result=")); } + + let connection_series = scrape + .lines() + .filter(|line| { + line.starts_with("buzz_db_connection_step_started_total") + || line.starts_with("buzz_db_connection_step_attempts_total") + || line.starts_with("buzz_db_connection_step_duration_seconds") + }) + .collect::>(); + assert_eq!( + connection_series.len(), + buzz_db::DB_CONNECTION_RAW_SERIES_PER_POD, + "unexpected DB connection scrape:\n{scrape}" + ); + for line in connection_series { + let keys = label_keys(line); + if line.starts_with("buzz_db_connection_step_duration_seconds_bucket") { + assert_eq!(keys, BTreeSet::from(["le", "pool_role", "step"])); + } else if line.starts_with("buzz_db_connection_step_attempts_total") { + assert_eq!(keys, BTreeSet::from(["outcome", "pool_role", "step"])); + } else { + assert_eq!(keys, BTreeSet::from(["pool_role", "step"])); + } + assert!(!line.contains("reason=")); + assert!(!line.contains("connection_ordinal=")); + } } #[test] diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 91a2b59ea3..a6b74e8757 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -148,8 +148,9 @@ state to zero; it does not fabricate dependency failures or latency samples. ### Operation-aware database pool acquisition contract -The operation-aware families separate three questions: who is waiting now, -how completed/abandoned attempts ended, and how long checkout waits took. +The operation-aware families separate four questions: when an operation asked +for a connection, who is waiting now, how completed/abandoned attempts ended, +and how long checkout waits took. Outcome remains on the terminal counter for historical deployment comparison; it is intentionally absent from the expensive duration histogram. @@ -161,6 +162,7 @@ maximum gauges when diagnosing total capacity pressure. | Metric | Type | Labels | |--------|------|--------| +| `buzz_db_pool_acquire_started_total` | counter | `pool_role`, `operation` | | `buzz_db_pool_acquire_duration_seconds` | histogram | `pool_role`, `operation` | | `buzz_db_pool_acquire_attempts_total` | counter | `pool_role`, `operation`, `outcome` | | `buzz_db_pool_waiters` | gauge | `pool_role`, `operation`; tracked operations only, periodically refreshed including zero | @@ -182,12 +184,49 @@ writer/maintenance ``` Nine finite checkout buckets plus `+Inf`, sum, and count yield 12 histogram -series per valid pair. The new contract therefore has a hard ceiling of 187 -raw Prometheus series per pod: `11 × (12 + 4 + 1)`. The two legacy acquisition +series per valid pair. The new contract therefore has a hard ceiling of 198 +raw Prometheus series per pod: `11 × (1 + 12 + 4 + 1)`. The two legacy acquisition families remain temporarily for dashboard compatibility and are not part of that new-family budget. No `other` operation or request-controlled/sensitive label is valid. +### Writer connection setup contract + +Writer connection setup is separate from checkout. At boot, SQLx constructs +the writer pool and creates its minimum physical connections. Later it may +create more when the pool grows or replaces a broken or expired connection. +Every connected writer session must install the created-at floor, install the +session timeouts, verify READ COMMITTED isolation, and reach `ready` before SQLx +can give it to a caller. + +| Metric | Type | Labels | +|--------|------|--------| +| `buzz_db_connection_step_started_total` | counter | `pool_role`, `step` | +| `buzz_db_connection_step_duration_seconds` | histogram | `pool_role`, `step` | +| `buzz_db_connection_step_attempts_total` | counter | `pool_role`, `step`, `outcome` | + +The fixed steps are `writer_pool`, `physical_connect`, `created_at_floor`, +`session_timeouts`, `isolation`, and `ready`. Measurable phases emit start, +duration, and terminal evidence. `physical_connect` and `ready` are success +milestones: SQLx 0.9 exposes `after_connect` only after DNS, network, TLS, and +authentication finish, so Buzz does not invent separate timings for those +internal phases. Raw connect failures before `after_connect` are classified on +the aggregate `writer_pool` phase during initial construction. Outcomes are +`succeeded`, `failed`, `timed_out`, and `cancelled` where valid. + +For a measurable phase on one pod, subtract all terminal outcomes from its +start counter to derive the number of attempts currently in progress. The +session phases run in order, so a later phase start also proves the earlier +phases succeeded for that attempt. + +Four start counters, four 13-series histograms, and fifteen terminal counters +create a hard ceiling of 71 raw Prometheus series per pod. Connection ordinals, +database URLs, hosts, usernames, SQL, and raw errors are forbidden as metric +labels. + +When audit logging is enabled, the `buzz_db_connection_*` metric totals combine +the main writer pool and the separate audit writer pool. + ### Physical database pool utilization and configuration contract Every physical Postgres pool the relay owns reports current utilization, From d1cae93c1a584d14abc1563132892f9f953e8c1c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 17 Sep 2026 09:30:20 -0700 Subject: [PATCH 05/22] fix(mobile): temporarily disable age gating (#7708) Disable the mobile launch age gate to restore access for users locked out after updating. The production provider now allows access immediately without calling the platform age API, while preserving the existing push snapshot restoration path. This is a temporary disable of age enforcement on both mobile platforms. The original gate implementation remains available for a separate follow-up. Signed-off-by: Tom Brow --- .../age_gate/age_signal_provider.dart | 12 +- .../features/age_gate/age_gate_app_test.dart | 124 ++++++++---------- .../age_gate/age_signal_provider_test.dart | 29 +++- 3 files changed, 94 insertions(+), 71 deletions(-) diff --git a/mobile/lib/features/age_gate/age_signal_provider.dart b/mobile/lib/features/age_gate/age_signal_provider.dart index 2577e342ce..d3c9b408e6 100644 --- a/mobile/lib/features/age_gate/age_signal_provider.dart +++ b/mobile/lib/features/age_gate/age_signal_provider.dart @@ -252,5 +252,15 @@ class AgeSignalNotifier extends Notifier { } final ageSignalProvider = NotifierProvider( - AgeSignalNotifier.new, + _DisabledAgeSignalNotifier.new, ); + +// Temporarily disable age blocking: platform age checks can lock users out of +// the app. Keep the allowed transition so push snapshots are restored normally. +class _DisabledAgeSignalNotifier extends AgeSignalNotifier { + @override + AgeSignalState build() => AgeSignalState.allowed; + + @override + Future request() async {} +} diff --git a/mobile/test/features/age_gate/age_gate_app_test.dart b/mobile/test/features/age_gate/age_gate_app_test.dart index 728505a512..f52aa680dd 100644 --- a/mobile/test/features/age_gate/age_gate_app_test.dart +++ b/mobile/test/features/age_gate/age_gate_app_test.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:buzz/app.dart'; import 'package:buzz/features/age_gate/age_restriction_page.dart'; import 'package:buzz/features/age_gate/age_signal_push_bootstrap.dart'; @@ -98,75 +96,65 @@ void main() { expect(badgeCounts.last, 0); }); - testWidgets('keeps app content unmounted until the signal resolves', ( - tester, - ) async { - final response = Completer(); - final relaySession = _CountingRelaySessionNotifier(); - var requests = 0; - var snapshotSuspensions = 0; - var snapshotRestorations = 0; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(ageSignalChannel, (call) { - requests += 1; - return response.future; - }); - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); + testWidgets( + 'opens app and restores push snapshots without a native age check', + (tester) async { + final relaySession = _CountingRelaySessionNotifier(); + var requests = 0; + var snapshotSuspensions = 0; + var snapshotRestorations = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, (call) { + requests += 1; + throw PlatformException(code: 'unavailable'); + }); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget( - ProviderScope( - overrides: [ - authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), - relaySessionProvider.overrideWith(() => relaySession), - suspendCommunitySnapshotForAgeCheckProvider.overrideWithValue( - () async { - snapshotSuspensions += 1; - if (snapshotSuspensions == 1) { - throw StateError('injected suspension failure'); - } - }, - ), - resumeCommunitySnapshotAfterAgeCheckProvider.overrideWithValue( - () async { - snapshotRestorations += 1; - if (snapshotRestorations == 1) { - throw StateError('injected restoration failure'); - } - }, - ), - ageSignalPushSnapshotRetryWaitProvider.overrideWithValue( - (_) async {}, - ), - savedPrefsProvider.overrideWithValue(prefs), - ], - child: const AgeSignalPushBootstrap(child: App()), - ), - ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), + relaySessionProvider.overrideWith(() => relaySession), + suspendCommunitySnapshotForAgeCheckProvider.overrideWithValue( + () async { + snapshotSuspensions += 1; + if (snapshotSuspensions == 1) { + throw StateError('injected suspension failure'); + } + }, + ), + resumeCommunitySnapshotAfterAgeCheckProvider.overrideWithValue( + () async { + snapshotRestorations += 1; + if (snapshotRestorations == 1) { + throw StateError('injected restoration failure'); + } + }, + ), + ageSignalPushSnapshotRetryWaitProvider.overrideWithValue( + (_) async {}, + ), + savedPrefsProvider.overrideWithValue(prefs), + ], + child: const AgeSignalPushBootstrap(child: App()), + ), + ); - expect(requests, 1); - expect(find.bySemanticsLabel('Checking age eligibility'), findsOneWidget); - expect(find.byType(HomePage), findsNothing); - expect(find.byType(Navigator), findsNothing); - expect(relaySession.builds, 0); - await tester.pump(); - await tester.pump(); - expect(snapshotSuspensions, 2); - expect(snapshotRestorations, 0); + await tester.pump(); + await tester.pump(); + expect(snapshotSuspensions, 0); - response.complete({'status': 'noSignal', 'ageUpper': null}); - await tester.pump(); - await tester.pump(); - - expect(find.bySemanticsLabel('Checking age eligibility'), findsNothing); - expect(find.byType(HomePage), findsOneWidget); - expect(find.byType(Navigator), findsOneWidget); - expect(relaySession.builds, 1); - expect(requests, 1); - await tester.pump(); - await tester.pump(); - expect(snapshotRestorations, 2); - }); + expect(find.bySemanticsLabel('Checking age eligibility'), findsNothing); + expect(find.byType(HomePage), findsOneWidget); + expect(find.byType(Navigator), findsOneWidget); + expect(relaySession.builds, 1); + expect(requests, 0); + await tester.pump(); + await tester.pump(); + expect(snapshotRestorations, 2); + }, + ); testWidgets('offers a retry after the native age check fails', ( tester, diff --git a/mobile/test/features/age_gate/age_signal_provider_test.dart b/mobile/test/features/age_gate/age_signal_provider_test.dart index 3846e651a1..65df38acc1 100644 --- a/mobile/test/features/age_gate/age_signal_provider_test.dart +++ b/mobile/test/features/age_gate/age_signal_provider_test.dart @@ -13,6 +13,27 @@ void main() { .setMockMethodCallHandler(ageSignalChannel, null); }); + test( + 'production launch allows access without requesting a native age signal', + () async { + var nativeCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, (_) async { + nativeCalls += 1; + throw PlatformException(code: 'unavailable'); + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(ageSignalProvider), AgeSignalState.allowed); + await container.read(ageSignalProvider.notifier).request(); + await container.read(ageSignalProvider.notifier).request(); + + expect(container.read(ageSignalProvider), AgeSignalState.allowed); + expect(nativeCalls, 0); + }, + ); + Future requestWithResponse(Object? response) async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(ageSignalChannel, (call) async { @@ -20,7 +41,9 @@ void main() { expect(call.arguments, isNull); return response; }); - final container = ProviderContainer(); + final container = ProviderContainer( + overrides: [ageSignalProvider.overrideWith(AgeSignalNotifier.new)], + ); addTearDown(container.dispose); await container.read(ageSignalProvider.notifier).request(); @@ -506,7 +529,9 @@ void main() { requests += 1; return {'status': 'noSignal', 'ageUpper': null}; }); - final container = ProviderContainer(); + final container = ProviderContainer( + overrides: [ageSignalProvider.overrideWith(AgeSignalNotifier.new)], + ); addTearDown(container.dispose); final notifier = container.read(ageSignalProvider.notifier); From 01b6174a1cbad249e93f31df97d4b2ed1d0e8638 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 17 Sep 2026 11:27:55 -0700 Subject: [PATCH 06/22] fix(ci): don't run desktop tests for purely mobile client changes (#7709) Mobile-only PRs currently trigger desktop CI because the desktop test filter's glob inadvertently matches every path outside Tauri. Fix that filter so that pure mobile changes skip desktop builds, tests, and their relay artifact producer. ### Verification - 12 regression tests exercise the exact pinned paths-filter action on mobile, desktop, Tauri, backend, mixed, workflow, and documentation changes. - Restoring the bad glob makes the mobile-only regression test fail. - The filter tests, existing required-context isolation contract, workflow syntax validation, and Biome pass locally. --------- Signed-off-by: Tom Brow --- .github/workflows/ci.yml | 3 +- scripts/ci-selection.test.mjs | 140 ++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 scripts/ci-selection.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a663ef957c..7b93f32f1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,6 @@ jobs: - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' - 'desktop/**' - - '!desktop/src-tauri/**' - 'pnpm-lock.yaml' desktop-rust: - 'desktop/src-tauri/**' @@ -114,6 +113,8 @@ jobs: scripts/test-rust-cache-contract-regressions.sh - name: CI required-context isolation contract run: scripts/test-ci-required-context-isolation.sh + - name: CI path selection regression tests + run: node --test scripts/ci-selection.test.mjs - name: File size policy run: just file-size-check diff --git a/scripts/ci-selection.test.mjs b/scripts/ci-selection.test.mjs new file mode 100644 index 0000000000..5c2ddfa3de --- /dev/null +++ b/scripts/ci-selection.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { after, test } from "node:test"; + +const workflow = readFileSync( + new URL("../.github/workflows/ci.yml", import.meta.url), + "utf8", +); +const scratch = mkdtempSync(join(tmpdir(), "buzz-ci-selection-")); +after(() => rmSync(scratch, { recursive: true, force: true })); +const actionSha = workflow.match(/uses: dorny\/paths-filter@([a-f0-9]{40})/)[1]; +const filters = workflow + .match(/ {10}filters: \|\n([\s\S]*?)(?= {6}- name:)/)[1] + .split("\n") + .map((line) => line.slice(12)) + .join("\n"); +// GitHub-hosted runners prepare pinned actions beside RUNNER_TEMP before any +// steps run. Reuse that bundle, without another download in the selection gate. +// Locally, point PATHS_FILTER_ACTION at dist/index.js from the pinned action. +const actionPath = + process.env.PATHS_FILTER_ACTION || + (process.env.RUNNER_TEMP && + join( + process.env.RUNNER_TEMP, + "..", + "_actions", + "dorny", + "paths-filter", + actionSha, + "dist", + "index.js", + )); +assert.ok( + actionPath && existsSync(actionPath), + `Set PATHS_FILTER_ACTION to the local dist/index.js from dorny/paths-filter@${actionSha}`, +); +function select(paths) { + const repo = mkdtempSync(join(scratch, "repo-")); + const git = (...args) => + execFileSync("git", args, { cwd: repo, stdio: "pipe", timeout: 10000 }); + git("init", "-q"); + // Fixture repositories must not inherit developer machine hooks. + mkdirSync(join(repo, "empty-hooks")); + git("config", "core.hooksPath", join(repo, "empty-hooks")); + git( + "-c", + "user.name=CI fixture", + "-c", + "user.email=ci@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "--allow-empty", + "-m", + "fixture", + "-s", + ); + for (const path of paths) { + mkdirSync(dirname(join(repo, path)), { recursive: true }); + writeFileSync(join(repo, path), "fixture\n"); + } + git("add", "."); + const output = join(repo, "action-output"); + writeFileSync(output, ""); + const child = spawnSync(process.execPath, [actionPath], { + cwd: repo, + encoding: "utf8", + timeout: 30000, + env: { + ...process.env, + INPUT_BASE: "HEAD", + INPUT_FILTERS: filters, + INPUT_TOKEN: "", + INPUT_REF: "", + GITHUB_OUTPUT: output, + "INPUT_PREDICATE-QUANTIFIER": + workflow.match(/predicate-quantifier: ['"]?([\w-]+)/)?.[1] ?? "some", + }, + }); + assert.equal(child.status, 0, child.stdout + child.stderr); + return Object.fromEntries( + [ + ...readFileSync(output, "utf8").matchAll( + /^(rust|desktop|desktop-rust|web|mobile)<<([^\n]+)\n(true|false)\n\2/gm, + ), + ].map(([, key, , value]) => [key, value]), + ); +} +const scenarios = [ + [ + "mobile source and tests", + [ + "mobile/lib/features/age_gate/age_signal_provider.dart", + "mobile/test/features/age_gate/age_signal_provider_test.dart", + ], + ["mobile"], + ], + ["mobile lockfile", ["mobile/pubspec.lock"], ["mobile"]], + ["mobile release script", ["scripts/mobile-release.sh"], ["mobile"]], + ["desktop", ["desktop/src/main.tsx"], ["desktop"]], + ["Tauri", ["desktop/src-tauri/src/main.rs"], ["desktop", "desktop-rust"]], + ["relay", ["crates/buzz-relay/src/main.rs"], ["rust"]], + ["migration", ["migrations/123.sql"], ["rust"]], + ["shared workflow", [".github/workflows/ci.yml"], ["rust", "mobile"]], + [ + "mixed mobile and relay", + ["mobile/lib/main.dart", "crates/buzz-core/src/lib.rs"], + ["rust", "mobile"], + ], + [ + "mixed mobile and desktop", + ["mobile/lib/main.dart", "desktop/src/main.tsx"], + ["desktop", "mobile"], + ], + ["web", ["web/src/main.tsx"], ["web"]], + ["documentation", ["README.md"], []], +]; +for (const [name, paths, expected] of scenarios) { + test(`real paths-filter: ${name}`, () => { + const outputs = select(paths); + assert.equal(Object.keys(outputs).length, 5); + assert.deepEqual( + Object.keys(outputs) + .filter((key) => outputs[key] === "true") + .sort(), + expected.sort(), + ); + }); +} From 779af8886caae1317b4de962082429867ab61503 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 17 Sep 2026 16:44:08 -0700 Subject: [PATCH 07/22] fix(mobile): fail open when age checks are unavailable (#7714) Age-check errors previously blocked Buzz startup. Normal app startup now proceeds immediately; only a valid under-18 response from the current launch request can restrict access. Age check enforcement remains disabled at compile time by default. Builds can opt in with `--dart-define=BUZZ_AGE_GATING_ENABLED=true`. --------- Signed-off-by: Tom Brow --- .../xyz/block/buzz/mobile/AgeSignalRequest.kt | 62 ++ .../xyz/block/buzz/mobile/MainActivity.kt | 111 +-- .../block/buzz/mobile/AgeSignalPayloadTest.kt | 13 +- .../block/buzz/mobile/AgeSignalRequestTest.kt | 86 +++ .../BuzzPushKit/BuzzAgeRestrictionFence.swift | 212 ------ .../BuzzAgeRestrictionSession.swift | 90 +++ .../BuzzPushKit/BuzzAgeSignalPayload.swift | 7 +- .../BuzzInteractionCleanupRetry.swift | 108 +++ .../BuzzAgeRestrictionSessionTests.swift | 138 ++++ .../BuzzAgeSignalPayloadTests.swift | 10 + .../BuzzInteractionCleanupRetryTests.swift | 133 ++++ .../BuzzNotificationHandoffTests.swift | 8 +- .../BuzzPushPresentationCacheTests.swift | 292 -------- .../NotificationService.swift | 75 +- mobile/ios/Runner/AppDelegate.swift | 76 +- mobile/ios/Runner/PushSnapshotBridge.swift | 137 ++-- .../BuzzCommunicationNotificationTests.swift | 212 +++--- mobile/ios/RunnerTests/RunnerTests.swift | 2 +- mobile/lib/app.dart | 71 +- .../age_gate/age_signal_provider.dart | 262 ++----- .../age_gate/age_signal_push_bootstrap.dart | 120 +--- .../features/pairing/pairing_provider.dart | 42 +- .../lib/features/pairing/pairing_socket.dart | 26 +- .../shared/community/community_provider.dart | 210 +----- mobile/lib/shared/huddle/huddle_session.dart | 6 + mobile/lib/shared/push/push_bridge.dart | 6 + mobile/lib/shared/relay/relay_session.dart | 16 +- mobile/test/features/age_gate/README.md | 43 ++ .../features/age_gate/age_gate_app_test.dart | 415 ++++++----- .../age_gate/age_signal_provider_test.dart | 651 ++++-------------- .../pairing/pairing_provider_test.dart | 98 ++- .../features/pairing/pairing_socket_test.dart | 39 +- .../community/community_provider_test.dart | 455 ------------ .../shared/huddle/huddle_session_test.dart | 46 ++ .../test/shared/relay/relay_session_test.dart | 146 ++++ 35 files changed, 1811 insertions(+), 2613 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AgeSignalRequest.kt create mode 100644 mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalRequestTest.kt delete mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeRestrictionFence.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeRestrictionSession.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzInteractionCleanupRetry.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzAgeRestrictionSessionTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzInteractionCleanupRetryTests.swift create mode 100644 mobile/test/features/age_gate/README.md diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AgeSignalRequest.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AgeSignalRequest.kt new file mode 100644 index 0000000000..2fd857e6a9 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AgeSignalRequest.kt @@ -0,0 +1,62 @@ +package xyz.block.buzz.mobile + +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.TaskExecutors +import com.google.android.play.agesignals.AgeSignalsAccessResult +import com.google.android.play.agesignals.AgeSignalsResult +import com.google.android.play.agesignals.model.AgeSignalsStatus +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.Executor + +/** Owns one native request. Calls and callbacks run on the main thread. */ +internal class AgeSignalRequest(private val executor: Executor = TaskExecutors.MAIN_THREAD) { + private var generation = 0 + private var pending: MethodChannel.Result? = null + + fun retire() { + generation += 1 + pending = null + } + + fun start( + result: MethodChannel.Result, + requestAccess: () -> Task, + checkAge: () -> Task, + ) { + if (pending != null) { + result.error("age_signal_in_flight", "An age signal request is already active.", null) + return + } + val request = ++generation + pending = result + fun current() = generation == request && pending === result + fun complete(reply: () -> Unit) { + if (!current()) return + pending = null + reply() + } + fun fail(error: Exception) = complete { replyWithAgeSignalError(result, error) } + try { + requestAccess() + .addOnSuccessListener(executor) { access -> + if (!current()) return@addOnSuccessListener + if (access.ageSignalsStatus() != AgeSignalsStatus.SHARED) { + complete { result.success(noAgeSignalPayload()) } + return@addOnSuccessListener + } + try { + checkAge() + .addOnSuccessListener(executor) { signal -> + complete { result.success(ageSignalPayload(signal.ageUpper(), signal.ageLower())) } + } + .addOnFailureListener(executor, ::fail) + } catch (error: Exception) { + fail(error) + } + } + .addOnFailureListener(executor, ::fail) + } catch (error: Exception) { + fail(error) + } + } +} diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt index a977aa31b9..7f3017b9cd 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt @@ -1,6 +1,5 @@ package xyz.block.buzz.mobile -import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -15,10 +14,8 @@ import androidx.annotation.RequiresApi import com.google.android.play.agesignals.AgeSignalsException import com.google.android.play.agesignals.model.AgeSignalsErrorCode import com.google.android.play.agesignals.AgeSignalsAccessRequest -import com.google.android.play.agesignals.AgeSignalsManager import com.google.android.play.agesignals.AgeSignalsManagerFactory import com.google.android.play.agesignals.AgeSignalsRequest -import com.google.android.play.agesignals.model.AgeSignalsStatus import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -27,10 +24,12 @@ import java.io.File import java.nio.ByteBuffer import java.util.UUID -internal fun ageSignalPayload(ageUpper: Int?): Map { +internal fun ageSignalPayload(ageUpper: Int?, ageLower: Int? = null): Map { + val validRange = (ageUpper == null || ageUpper >= 0) && + (ageLower == null || (ageLower >= 0 && (ageUpper == null || ageLower <= ageUpper))) return mapOf( "status" to "signal", - "ageUpper" to ageUpper, + "ageUpper" to if (validRange) ageUpper else null, ) } @@ -47,7 +46,7 @@ internal fun replyWithAgeSignalError( ) { // Missing/outdated Play installations and non-Play installs cannot supply // a signal. Preserve Buzz's unsupported-environment no-signal policy. - // Transport, binding, SDK integration, and unknown failures stay gated. + // Other failures remain distinguishable; Flutter preserves access on errors. if (error is AgeSignalsException && error.errorCode in setOf( AgeSignalsErrorCode.API_NOT_AVAILABLE, AgeSignalsErrorCode.PLAY_STORE_NOT_FOUND, @@ -129,8 +128,7 @@ internal object AndroidImageProcessor { class MainActivity : FlutterFragmentActivity() { private var mediaUploadChannel: MethodChannel? = null private var ageSignalChannel: MethodChannel? = null - private var ageSignalRequestGeneration = 0 - private var pendingAgeSignalResult: MethodChannel.Result? = null + private val ageSignalRequest = AgeSignalRequest() private var huddleMediaPlugin: HuddleMediaPlugin? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { @@ -177,101 +175,23 @@ class MainActivity : FlutterFragmentActivity() { channel.setMethodCallHandler { call, result -> when (call.method) { REQUEST_AGE_SIGNAL_METHOD -> { - handleRequestAgeSignal( - AgeSignalsManagerFactory.create(applicationContext), + val manager by lazy { AgeSignalsManagerFactory.create(applicationContext) } + ageSignalRequest.start( result, + requestAccess = { + manager.requestAgeSignalsAccess( + AgeSignalsAccessRequest.builder().setActivity(this).build(), + ) + }, + checkAge = { manager.checkAgeSignals(AgeSignalsRequest.builder().build()) }, ) } - CANCEL_AGE_SIGNAL_METHOD -> cancelAgeSignalRequest(result) - RESTART_AGE_SIGNAL_METHOD -> restartForAgeSignal(result) else -> result.notImplemented() } } } } - private fun handleRequestAgeSignal( - ageSignalsManager: AgeSignalsManager, - result: MethodChannel.Result, - ) { - if (pendingAgeSignalResult != null) { - result.error("age_signal_in_flight", "An age signal request is already active.", null) - return - } - ageSignalRequestGeneration += 1 - val generation = ageSignalRequestGeneration - pendingAgeSignalResult = result - val accessRequest = AgeSignalsAccessRequest.builder() - .setActivity(this) - .build() - ageSignalsManager.requestAgeSignalsAccess(accessRequest) - .addOnSuccessListener { accessResult -> - if (accessResult.ageSignalsStatus() != AgeSignalsStatus.SHARED) { - completeAgeSignalRequest(generation, result) { replyWithNoAgeSignal(result) } - return@addOnSuccessListener - } - - ageSignalsManager.checkAgeSignals(AgeSignalsRequest.builder().build()) - .addOnSuccessListener { ageSignalsResult -> - completeAgeSignalRequest(generation, result) { - replyWithAgeSignal(result, ageSignalsResult.ageUpper()) - } - } - .addOnFailureListener { error -> - completeAgeSignalRequest(generation, result) { - replyWithAgeSignalError(result, error) - } - } - } - .addOnFailureListener { error -> - completeAgeSignalRequest(generation, result) { - replyWithAgeSignalError(result, error) - } - } - } - - private fun completeAgeSignalRequest( - generation: Int, - result: MethodChannel.Result, - reply: () -> Unit, - ) { - if (generation != ageSignalRequestGeneration || pendingAgeSignalResult !== result) return - pendingAgeSignalResult = null - reply() - } - - private fun cancelAgeSignalRequest(result: MethodChannel.Result) { - // Play age-signals 0.0.4 exposes non-cancellable Tasks. Retain the - // original single flight rather than allowing an overlapping prompt. - result.success(false) - } - - private fun restartForAgeSignal(result: MethodChannel.Result) { - val launchIntent = packageManager.getLaunchIntentForPackage(packageName) - if (launchIntent == null) { - result.error("age_signal_restart_failed", "Buzz could not restart.", null) - return - } - result.success(false) - window.decorView.post { - launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - startActivity(launchIntent) - finishAffinity() - Runtime.getRuntime().exit(0) - } - } - - private fun replyWithAgeSignal( - result: MethodChannel.Result, - ageUpper: Int?, - ) { - result.success(ageSignalPayload(ageUpper)) - } - - private fun replyWithNoAgeSignal(result: MethodChannel.Result) { - result.success(noAgeSignalPayload()) - } - override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, @@ -282,6 +202,7 @@ class MainActivity : FlutterFragmentActivity() { } override fun onDestroy() { + ageSignalRequest.retire() huddleMediaPlugin?.dispose() huddleMediaPlugin = null super.onDestroy() @@ -543,8 +464,6 @@ class MainActivity : FlutterFragmentActivity() { private const val MEDIA_UPLOAD_CHANNEL = "buzz/media_upload" private const val AGE_SIGNAL_CHANNEL = "buzz/age_signal" private const val REQUEST_AGE_SIGNAL_METHOD = "requestAgeSignal" - private const val CANCEL_AGE_SIGNAL_METHOD = "cancelAgeSignalRequest" - private const val RESTART_AGE_SIGNAL_METHOD = "restartForAgeSignal" private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload" private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg" private const val TRANSCODE_VIDEO_TO_MP4_METHOD = "transcodeVideoToMp4" diff --git a/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalPayloadTest.kt b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalPayloadTest.kt index 251e5fc39a..6056d0abe0 100644 --- a/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalPayloadTest.kt +++ b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalPayloadTest.kt @@ -40,7 +40,7 @@ class AgeSignalPayloadTest { } @Test - fun `platform failures return a distinct retryable error`() { + fun `platform failures return a distinct error for the fail-open caller`() { val result = RecordingResult() replyWithAgeSignalError(result, IllegalStateException("transient")) @@ -70,7 +70,7 @@ class AgeSignalPayloadTest { } @Test - fun `transient integration and unknown Play errors stay gated`() { + fun `transient integration and unknown Play errors remain distinguishable`() { for (code in listOf( AgeSignalsErrorCode.NETWORK_ERROR, AgeSignalsErrorCode.CANNOT_BIND_TO_SERVICE, @@ -86,6 +86,15 @@ class AgeSignalPayloadTest { } } + @Test + fun `invalid and contradictory ranges do not report an underage upper bound`() { + for ((lower, upper) in listOf(-1 to 17, 18 to 17, 0 to -1, Int.MAX_VALUE to 17)) { + assertNull(ageSignalPayload(upper, lower)["ageUpper"]) + } + assertEquals(17, ageSignalPayload(17, 13)["ageUpper"]) + assertEquals(18, ageSignalPayload(18, 18)["ageUpper"]) + } + private class RecordingResult : MethodChannel.Result { var succeeded = false var payload: Any? = null diff --git a/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalRequestTest.kt b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalRequestTest.kt new file mode 100644 index 0000000000..f1f87c906c --- /dev/null +++ b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalRequestTest.kt @@ -0,0 +1,86 @@ +package xyz.block.buzz.mobile + +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.android.gms.tasks.Tasks +import com.google.android.play.agesignals.AgeSignalsAccessResult +import com.google.android.play.agesignals.AgeSignalsResult +import com.google.android.play.agesignals.model.AgeSignalsStatus +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.Executor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AgeSignalRequestTest { + private val direct = Executor { it.run() } + private fun shared() = AgeSignalsAccessResult.builder().setAgeSignalsStatus(AgeSignalsStatus.SHARED).build() + private fun minor() = AgeSignalsResult.builder().setAgeLower(13).setAgeUpper(17).build() + + @Test fun `shared minor response reaches the Flutter result`() { + val result = Reply() + AgeSignalRequest(direct).start(result, { Tasks.forResult(shared()) }, { Tasks.forResult(minor()) }) + assertEquals(listOf(mapOf("status" to "signal", "ageUpper" to 17)), result.values) + assertNull(result.error) + } + + @Test fun `unknown access does not request or report age`() { + val result = Reply() + AgeSignalRequest(direct).start(result, { + Tasks.forResult(AgeSignalsAccessResult.builder().setAgeSignalsStatus(null).build()) + }, { error("Age lookup must require shared access") }) + assertEquals(listOf(mapOf("status" to "noSignal", "ageUpper" to null)), result.values) + } + + @Test fun `synchronous and asynchronous failures never report a minor`() { + for (stage in 0..3) { + val result = Reply() + val failure = IllegalStateException("injected") + AgeSignalRequest(direct).start(result, { + when (stage) { + 0 -> throw failure + 1 -> Tasks.forException(failure) + else -> Tasks.forResult(shared()) + } + }, { + if (stage == 2) throw failure + Tasks.forException(failure) + }) + assertEquals(emptyList(), result.values, "stage=$stage") + assertEquals("age_signal_unavailable", result.error, "stage=$stage") + } + } + + @Test fun `retired access callback cannot start age lookup`() { + val access = TaskCompletionSource() + val request = AgeSignalRequest(direct) + val result = Reply() + request.start(result, { access.task }, { error("Retired request must not query age") }) + request.retire() + access.setResult(shared()) + assertEquals(emptyList(), result.values) + assertNull(result.error) + } + + @Test fun `retired minor callback cannot complete a replacement request`() { + val signal = TaskCompletionSource() + val request = AgeSignalRequest(direct) + val old = Reply() + request.start(old, { Tasks.forResult(shared()) }, { signal.task }) + request.retire() + val replacement = Reply() + request.start(replacement, { Tasks.forResult(shared()) }, { + Tasks.forResult(AgeSignalsResult.builder().setAgeLower(18).build()) + }) + signal.setResult(minor()) + assertEquals(emptyList(), old.values) + assertEquals(listOf(mapOf("status" to "signal", "ageUpper" to null)), replacement.values) + } + + private class Reply : MethodChannel.Result { + val values = mutableListOf() + var error: String? = null + override fun success(result: Any?) { values.add(result) } + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { error = errorCode } + override fun notImplemented() = Unit + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeRestrictionFence.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeRestrictionFence.swift deleted file mode 100644 index 3b7aea43cf..0000000000 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeRestrictionFence.swift +++ /dev/null @@ -1,212 +0,0 @@ -import Darwin -import Foundation - -/// Cross-process generation fence for age-restricted notification delivery. -public struct BuzzAgeRestrictionFence: Codable, Equatable, Sendable { - /// Opaque generation changed at both ends of a restricted cleanup. - public let token: String - - /// Whether native notification state is still being cleared. - public let isFencing: Bool - - /// Creates a persisted cross-process fence value. - public init(token: String, isFencing: Bool) { - self.token = token - self.isFencing = isFencing - } - - /// In-memory sentinel used before a notification extension loads the store. - public static let initial = BuzzAgeRestrictionFence( - token: "initial", - isFencing: false - ) - - /// Fail-closed value used when the shared fence cannot be read. - public static let unavailable = BuzzAgeRestrictionFence( - token: "unavailable", - isFencing: true - ) - - /// Whether an extension started under [earlier] must discard its result. - public func requiresDiscard(since earlier: BuzzAgeRestrictionFence) -> Bool { - isFencing || token != earlier.token - } -} - -/// The app begins a durable fence before clearing native notification state. -/// A notification extension discards resolved content while the fence is -/// active or whenever the token differs from the one captured at startup. -public final class BuzzAgeRestrictionFenceStore: @unchecked Sendable { - /// App-group file shared by Runner and the notification extension. - public static let fileName = "age-restriction-fence.json" - private static let lockFileName = "age-restriction-fence.lock" - - private let fileURL: URL - private let lockFileURL: URL - private let beforeSettledWrite: (() -> Void)? - private let lock = NSLock() - - /// Creates a fence store rooted in the app-group container. - public convenience init(containerURL: URL) { - self.init(containerURL: containerURL, beforeSettledWrite: nil) - } - - init(containerURL: URL, beforeSettledWrite: (() -> Void)?) { - fileURL = containerURL.appendingPathComponent(Self.fileName) - lockFileURL = containerURL.appendingPathComponent(Self.lockFileName) - self.beforeSettledWrite = beforeSettledWrite - } - - /// Returns the latest fence, failing closed when persisted data is absent or - /// malformed. An absent file can represent an upgraded installation whose - /// legacy notification credentials have not passed the age gate yet. - public func current() -> BuzzAgeRestrictionFence { - lock.lock() - defer { lock.unlock() } - return (try? withProcessLock { loadLocked() }) ?? .unavailable - } - - /// Protects notifications before startup or an age request. If the shared - /// fence cannot be written, removes presentation credentials independently, - /// then propagates the failure so the caller stays gated and can retry. - public static func beginLaunch( - containerURL: URL?, - clearPresentationCredentials: () throws -> Void - ) throws { - do { - guard let containerURL else { - throw NSError( - domain: "BuzzAgeRestrictionFenceStore", - code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "The push app-group container is unavailable." - ] - ) - } - try BuzzAgeRestrictionFenceStore(containerURL: containerURL).begin() - } catch { - // A failed atomic write can leave an older allowed fence readable. Remove - // the extension's signing keys independently of the app-group filesystem. - // Even successful removal must not turn a failed fence into an age result. - try clearPresentationCredentials() - throw error - } - } - - /// Starts a durable cleanup phase with a fresh generation token. - @discardableResult - public func begin() throws -> BuzzAgeRestrictionFence { - lock.lock() - defer { lock.unlock() } - return try withProcessLock { - let fence = BuzzAgeRestrictionFence( - token: UUID().uuidString.lowercased(), - isFencing: true - ) - try writeLocked(fence) - return fence - } - } - - /// Rotates the durable fence before cleanup begins and settles it only after - /// every cleanup write succeeds. A thrown cleanup leaves the fence active so - /// notification extensions continue to fail closed. - public func performFencedCleanup(_ cleanup: () throws -> Void) throws { - let active = try begin() - try cleanup() - try settleIfFencing(expectedToken: active.token) - } - - /// Rotates the durable fence before asynchronous cleanup begins and settles - /// it only after the cleanup callback acknowledges success. A thrown setup - /// error or callback error leaves the fence active. - public func performFencedAsyncCleanup( - _ cleanup: (@escaping (Error?) -> Void) throws -> Void, - completion: @escaping (Error?) -> Void - ) throws { - let active = try begin() - try cleanup { [self] error in - guard error == nil else { - completion(error) - return - } - do { - try settleIfFencing(expectedToken: active.token) - completion(nil) - } catch { - completion(error) - } - } - } - - /// Performs a synchronous handoff only when the persisted fence still - /// matches [earlier], while keeping cleanup transitions ordered behind it. - public func performIfUnchanged( - since earlier: BuzzAgeRestrictionFence, - _ handoff: () -> Void - ) throws -> Bool { - lock.lock() - defer { lock.unlock() } - return try withProcessLock { - guard !loadLocked().requiresDiscard(since: earlier) else { return false } - handoff() - return true - } - } - - /// Completes a cleanup phase with another generation change. - @discardableResult - public func settleIfFencing(expectedToken: String? = nil) throws -> BuzzAgeRestrictionFence { - lock.lock() - defer { lock.unlock() } - return try withProcessLock { - let current = loadLocked() - guard current.isFencing, - expectedToken == nil || current.token == expectedToken - else { return current } - beforeSettledWrite?() - let settled = BuzzAgeRestrictionFence( - token: UUID().uuidString.lowercased(), - isFencing: false - ) - try writeLocked(settled) - return settled - } - } - - private func withProcessLock(_ operation: () throws -> T) throws -> T { - let descriptor = open(lockFileURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) - guard descriptor >= 0 else { throw currentPOSIXError() } - guard flock(descriptor, LOCK_EX) == 0 else { - let error = currentPOSIXError() - close(descriptor) - throw error - } - defer { - flock(descriptor, LOCK_UN) - close(descriptor) - } - return try operation() - } - - private func currentPOSIXError() -> POSIXError { - POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } - - private func loadLocked() -> BuzzAgeRestrictionFence { - guard FileManager.default.fileExists(atPath: fileURL.path) else { - return .unavailable - } - guard let data = try? Data(contentsOf: fileURL), - let fence = try? JSONDecoder().decode(BuzzAgeRestrictionFence.self, from: data) - else { - return .unavailable - } - return fence - } - - private func writeLocked(_ fence: BuzzAgeRestrictionFence) throws { - let data = try JSONEncoder().encode(fence) - try data.write(to: fileURL, options: .atomic) - } -} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeRestrictionSession.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeRestrictionSession.swift new file mode 100644 index 0000000000..671bcca549 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeRestrictionSession.swift @@ -0,0 +1,90 @@ +import Darwin +import Foundation + +/// Restriction authority exists only while a confirming app process holds its +/// exclusive lock. File contents and files left behind by old processes carry +/// no age information and cannot restrict a later launch. +public final class BuzzAgeRestrictionSession { + /// Shared app-group file used only for process-owned locking. + public static let fileName = "age-restriction-session.lock" + private var descriptor: Int32 = -1 + + /// Creates an unrestricted session. + public init() {} + + deinit { release() } + + /// Called on the native bridge's serial queue after a confirmed restriction. + public func restrict(containerURL: URL) throws { + if descriptor < 0 { + let path = containerURL.appendingPathComponent(Self.fileName).path + let opened = open(path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard opened >= 0 else { throw Self.posixError() } + guard flock(opened, LOCK_EX | LOCK_NB) == 0 else { + let error = Self.posixError() + close(opened) + throw error + } + descriptor = opened + } + // Authority is already visible to every new handoff. Do not purge until + // previously committed deliveries finish, but never block the release + // queue on an extension that iOS may suspend. A busy handoff leaves our + // process-owned authority intact and the bridge retries cleanup later. + let handoff = open(containerURL.appendingPathComponent(Self.handoffFileName).path, + O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard handoff >= 0 else { throw Self.posixError() } + defer { close(handoff) } + guard flock(handoff, LOCK_EX | LOCK_NB) == 0 else { throw Self.posixError() } + flock(handoff, LOCK_UN) + } + + /// Releases authority without requiring a successful filesystem write. + public func release() { + guard descriptor >= 0 else { return } + flock(descriptor, LOCK_UN) + close(descriptor) + descriptor = -1 + } + + /// A missing file, inaccessible container, or unknown OS error means allowed. + /// A shared nonblocking probe conflicts only with a live exclusive holder. + public static func isRestricted(containerURL: URL?) -> Bool { + guard let containerURL else { return false } + let opened = open(containerURL.appendingPathComponent(fileName).path, O_RDONLY) + guard opened >= 0 else { return false } + defer { close(opened) } + if flock(opened, LOCK_SH | LOCK_NB) == 0 { + flock(opened, LOCK_UN) + return false + } + return errno == EWOULDBLOCK || errno == EAGAIN + } + + private static let handoffFileName = "age-notification-handoff.lock" + + /// Serializes committed delivery with purge separately from restriction + /// authority. Confirmed restriction immediately prevents newer handoffs even + /// while an older delivery is finishing. Storage failures still fail open. + public static func handoffIfAllowed(containerURL: URL?, deliver: () -> Void) -> Bool { + guard let containerURL else { deliver(); return true } + let opened = open(containerURL.appendingPathComponent(handoffFileName).path, + O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + if opened >= 0 { + defer { close(opened) } + if flock(opened, LOCK_SH | LOCK_NB) == 0 { + defer { flock(opened, LOCK_UN) } + if isRestricted(containerURL: containerURL) { return false } + deliver() + return true + } + } + if isRestricted(containerURL: containerURL) { return false } + deliver() + return true + } + + private static func posixError() -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeSignalPayload.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeSignalPayload.swift index 8db96ae034..7a67c825dc 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeSignalPayload.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeSignalPayload.swift @@ -3,8 +3,11 @@ import Foundation /// Converts Apple's age gates to the inclusive upper age used by Flutter. public enum BuzzAgeSignalPayload { /// Apple's upper bound is the gate the person is under; nil means unbounded. - public static func sharing(exclusiveUpperBound: Int?) -> [String: Any] { - let ageUpper = exclusiveUpperBound.map { ($0 - 1) as Any } ?? NSNull() + public static func sharing(exclusiveUpperBound: Int?, lowerBound: Int? = nil) -> [String: Any] { + // Buzz requests only the 18-year gate. Unexpected bounds are not evidence + // of minority; never subtract an unvalidated integer (including Int.min). + let validLower = lowerBound.map { $0 >= 0 && $0 < 18 } ?? true + let ageUpper: Any = exclusiveUpperBound == 18 && validLower ? 17 : NSNull() return ["status": "signal", "ageUpper": ageUpper] } } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzInteractionCleanupRetry.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzInteractionCleanupRetry.swift new file mode 100644 index 0000000000..b359db6782 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzInteractionCleanupRetry.swift @@ -0,0 +1,108 @@ +import Darwin +import Foundation + +/// A durable retry for deleting restricted communication interactions. +/// This journal never grants authority to restrict app or notification access. +public final class BuzzInteractionCleanupRetry { + private let containerURL: URL? + private let deletion: BuzzInteractionDeletionDeadline + + /// Uses the shared app-group directory and a bounded platform deletion. + public init(containerURL: URL?, deletion: BuzzInteractionDeletionDeadline) { + self.containerURL = containerURL + self.deletion = deletion + } + + /// Records intent before starting deletion. Failed or timed-out deletion + /// leaves the record available to a subsequent app process. + public func request(completion: @escaping (Error?) -> Void) { + do { + let token = UUID() + guard let containerURL else { throw CocoaError(.fileNoSuchFile) } + // Writers never wait for acknowledgements. Atomic replacement records + // newer intent even when another process holds the completion lock. + try Data(token.uuidString.utf8).write(to: recordURL(containerURL), options: .atomic) + delete(token: token, completion: completion) + } catch { completion(error) } + } + + /// Retries only recorded cleanup; normal startup creates no cleanup intent. + public func retryPending(completion: @escaping (Error?) -> Void) { + guard containerURL != nil else { completion(nil); return } + do { + guard let containerURL, + let token = try readToken(recordURL(containerURL)), + token != (try completedToken(containerURL)) + else { + completion(nil) + return + } + delete(token: token, completion: completion) + } catch { completion(error) } + } + + private func delete(token: UUID, completion: @escaping (Error?) -> Void) { + deletion.deleteAll { [self] error in + if let error { completion(error); return } + do { + try withLock { directory in + // Never remove intent: a concurrent writer may replace it at any + // instant. Acknowledging this token leaves any newer token pending. + // Serialize acknowledgements so an older completion cannot overwrite + // a newer acknowledgement after checking the requested token. + if try readToken(recordURL(directory)) == token { + try Data(token.uuidString.utf8).write(to: completedURL(directory), options: .atomic) + } + } + completion(nil) + } catch { completion(error) } + } + } + + private func recordURL(_ directory: URL) -> URL { + directory.appendingPathComponent("interaction-cleanup-retry") + } + + private func completedURL(_ directory: URL) -> URL { + directory.appendingPathComponent("interaction-cleanup-completed") + } + + private func completedToken(_ directory: URL) throws -> UUID? { + do { + return try readToken(completedURL(directory)) + } catch let error as CocoaError where error.code == .fileReadCorruptFile { + // A damaged acknowledgement is not evidence that deletion succeeded. + return nil + } + } + + private func readToken(_ url: URL) throws -> UUID? { + let handle: FileHandle + do { + handle = try FileHandle(forReadingFrom: url) + } catch let error as CocoaError where error.code == .fileReadNoSuchFile || error.code == .fileNoSuchFile { + return nil + } + defer { try? handle.close() } + let bytes = try handle.read(upToCount: 64) ?? Data() + guard bytes.count == 36, let value = String(data: bytes, encoding: .utf8), + let token = UUID(uuidString: value) + else { throw CocoaError(.fileReadCorruptFile) } + return token + } + + private func withLock(_ body: (URL) throws -> T) throws -> T { + guard let containerURL else { throw CocoaError(.fileNoSuchFile) } + let path = containerURL.appendingPathComponent("interaction-cleanup-retry.lock").path + let descriptor = open(path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw posixError() } + defer { close(descriptor) } + guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { throw posixError() } + defer { flock(descriptor, LOCK_UN) } + return try body(containerURL) + } + + private func posixError() -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzAgeRestrictionSessionTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzAgeRestrictionSessionTests.swift new file mode 100644 index 0000000000..e90a8df035 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzAgeRestrictionSessionTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Testing + +@testable import BuzzPushKit + +struct BuzzAgeRestrictionSessionTests { + private func directory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test func confirmedRestrictionIsReleasedWithoutAStorageWrite() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + let session = BuzzAgeRestrictionSession() + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + try session.restrict(containerURL: url) + #expect(BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + try session.restrict(containerURL: url) + session.release() + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + } + + @Test func missingOrStaleReadOnlyFilesCannotRestrict() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: nil)) + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + let file = url.appendingPathComponent(BuzzAgeRestrictionSession.fileName) + try Data("old or malformed restriction".utf8).write(to: file) + try FileManager.default.setAttributes([.posixPermissions: 0o400], ofItemAtPath: file.path) + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + } + + @Test func acquisitionFailureCannotCreateAuthority() { + let missing = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let session = BuzzAgeRestrictionSession() + #expect(throws: (any Error).self) { try session.restrict(containerURL: missing) } + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: missing)) + } + + @Test func `Pending restriction prevents newer handoffs before the current delivery finishes`() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + let session = BuzzAgeRestrictionSession() + var delivered = false + let allowed = BuzzAgeRestrictionSession.handoffIfAllowed(containerURL: url) { + #expect(throws: (any Error).self) { try session.restrict(containerURL: url) } + #expect(BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + #expect(!BuzzAgeRestrictionSession.handoffIfAllowed(containerURL: url) { + Issue.record("Pending restriction must prevent a newer handoff") + }) + delivered = true + } + #expect(allowed) + #expect(delivered) + try session.restrict(containerURL: url) + #expect(!BuzzAgeRestrictionSession.handoffIfAllowed(containerURL: url) { + Issue.record("A confirmed restriction must suppress ordinary content") + }) + session.release() + } + + @Test func `Allowed restoration does not wait for a suspended handoff`() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + let session = BuzzAgeRestrictionSession() + let allowed = BuzzAgeRestrictionSession.handoffIfAllowed(containerURL: url) { + #expect(throws: (any Error).self) { try session.restrict(containerURL: url) } + session.release() + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + #expect(BuzzAgeRestrictionSession.handoffIfAllowed(containerURL: url) {}) + } + #expect(allowed) + } + + @Test func `Handoff delivers when restriction storage is unavailable`() { + var deliveries = 0 + #expect(BuzzAgeRestrictionSession.handoffIfAllowed(containerURL: nil) { deliveries += 1 }) + let missing = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + #expect(BuzzAgeRestrictionSession.handoffIfAllowed(containerURL: missing) { deliveries += 1 }) + #expect(deliveries == 2) + } + + #if os(macOS) + @Test(.timeLimit(.minutes(1))) func processExitReleasesAuthorityWithoutCleanup() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + let process = Process() + let input = Pipe() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + process.arguments = ["-c", """ + import fcntl, os, sys + fd = os.open(sys.argv[1], os.O_CREAT | os.O_RDWR, 0o600) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + os.write(1, b'1') + os.read(0, 1) + os._exit(0) + """, url.appendingPathComponent(BuzzAgeRestrictionSession.fileName).path] + process.standardInput = input + process.standardOutput = output + try process.run() + defer { if process.isRunning { process.terminate() } } + #expect(try output.fileHandleForReading.read(upToCount: 1) == Data("1".utf8)) + #expect(BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + try input.fileHandleForWriting.close() + process.waitUntilExit() + #expect(process.terminationStatus == 0) + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + #expect(FileManager.default.fileExists(atPath: url.appendingPathComponent(BuzzAgeRestrictionSession.fileName).path)) + } + + @Test(.timeLimit(.minutes(1))) func nativeRestrictionIsVisibleToAnotherProcess() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + let session = BuzzAgeRestrictionSession() + try session.restrict(containerURL: url) + defer { session.release() } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + process.arguments = ["-c", """ + import fcntl, os, sys + fd = os.open(sys.argv[1], os.O_RDONLY) + try: + fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB) + except BlockingIOError: + sys.exit(0) + sys.exit(1) + """, url.appendingPathComponent(BuzzAgeRestrictionSession.fileName).path] + try process.run() + defer { if process.isRunning { process.terminate() } } + process.waitUntilExit() + #expect(process.terminationStatus == 0) + } + #endif +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzAgeSignalPayloadTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzAgeSignalPayloadTests.swift index 67c7966a4e..5bb5d2ea17 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzAgeSignalPayloadTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzAgeSignalPayloadTests.swift @@ -15,4 +15,14 @@ struct BuzzAgeSignalPayloadTests { #expect(payload["status"] as? String == "signal") #expect(payload["ageUpper"] is NSNull) } + @Test(arguments: [Int.min, -1, 0, 1, 17, 19, Int.max]) + func unexpectedAppleBoundsAreNotRestrictionEvidence(upper: Int) { + let payload = BuzzAgeSignalPayload.sharing(exclusiveUpperBound: upper) + #expect(payload["ageUpper"] is NSNull) + } + @Test(arguments: [Int.min, -1, 18, 19, Int.max]) + func contradictoryAppleRangeIsNotRestrictionEvidence(lower: Int) { + let payload = BuzzAgeSignalPayload.sharing(exclusiveUpperBound: 18, lowerBound: lower) + #expect(payload["ageUpper"] is NSNull) + } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzInteractionCleanupRetryTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzInteractionCleanupRetryTests.swift new file mode 100644 index 0000000000..5999f2845d --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzInteractionCleanupRetryTests.swift @@ -0,0 +1,133 @@ +import Darwin +import Foundation +import Testing + +@testable import BuzzPushKit + +struct BuzzInteractionCleanupRetryTests { + private func directory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func retry(_ url: URL?, delete: @escaping BuzzInteractionDeletionDeadline.DeleteAllInteractions) + -> BuzzInteractionCleanupRetry { + BuzzInteractionCleanupRetry(containerURL: url, deletion: BuzzInteractionDeletionDeadline( + timeout: 5, deleteAllInteractions: delete, scheduleTimeout: { _, _ in })) + } + + @Test func `Failed cleanup survives recreation without restricting access`() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + let failure = NSError(domain: "InjectedFailure", code: 1) + var failures = 0 + retry(url, delete: { $0(failure) }).request { error in + #expect(error != nil) + failures += 1 + } + #expect(failures == 1) + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + var deletes = 0 + retry(url, delete: { completion in deletes += 1; completion(nil) }).retryPending { + #expect($0 == nil) + } + #expect(deletes == 1) + retry(url, delete: { _ in Issue.record("Successful cleanup must clear its retry") }) + .retryPending { #expect($0 == nil) } + } + + @Test func `An older success cannot erase newer pending cleanup`() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + var first: ((Error?) -> Void)? + var second: ((Error?) -> Void)? + retry(url, delete: { first = $0 }).request { #expect($0 == nil) } + retry(url, delete: { second = $0 }).request { #expect($0 != nil) } + first?(nil) + second?(NSError(domain: "LaterDeletionFailed", code: 1)) + var deletes = 0 + retry(url, delete: { completion in deletes += 1; completion(nil) }) + .retryPending { #expect($0 == nil) } + #expect(deletes == 1) + } + + @Test func `An older completion cannot undo a newer acknowledgement`() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + var first: ((Error?) -> Void)? + retry(url, delete: { first = $0 }).request { #expect($0 == nil) } + retry(url, delete: { $0(nil) }).request { #expect($0 == nil) } + first?(nil) + retry(url, delete: { _ in Issue.record("Newer cleanup was already acknowledged") }) + .retryPending { #expect($0 == nil) } + } + + @Test func `A damaged acknowledgement leaves cleanup retryable`() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + retry(url, delete: { $0(nil) }).request { #expect($0 == nil) } + try Data("damaged".utf8).write( + to: url.appendingPathComponent("interaction-cleanup-completed"), options: .atomic) + var deletes = 0 + retry(url, delete: { completion in deletes += 1; completion(nil) }) + .retryPending { #expect($0 == nil) } + #expect(deletes == 1) + } + + @Test func `A busy acknowledgement cannot lose newer cleanup intent`() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + let lockPath = url.appendingPathComponent("interaction-cleanup-retry.lock").path + let descriptor = open(lockPath, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + #expect(descriptor >= 0) + defer { close(descriptor) } + #expect(flock(descriptor, LOCK_EX | LOCK_NB) == 0) + var deletes = 0 + retry(url, delete: { completion in deletes += 1; completion(nil) }).request { + #expect($0 != nil) + } + #expect(deletes == 1) + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + flock(descriptor, LOCK_UN) + retry(url, delete: { completion in deletes += 1; completion(nil) }).retryPending { + #expect($0 == nil) + } + #expect(deletes == 2) + retry(url, delete: { _ in Issue.record("Acknowledged retry must not repeat") }) + .retryPending { #expect($0 == nil) } + } + + @Test func `Missing deletion callback remains pending after its deadline`() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + var timeout: (() -> Void)? + var failures = 0 + let deletion = BuzzInteractionDeletionDeadline(timeout: 5, + deleteAllInteractions: { _ in }, scheduleTimeout: { _, action in timeout = action }) + BuzzInteractionCleanupRetry(containerURL: url, deletion: deletion).request { + #expect($0 != nil) + failures += 1 + } + timeout?() + #expect(failures == 1) + var deletes = 0 + retry(url, delete: { completion in deletes += 1; completion(nil) }) + .retryPending { #expect($0 == nil) } + #expect(deletes == 1) + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: url)) + } + + @Test func `Normal startup creates no deletion and absent storage cannot restrict`() throws { + let url = try directory() + defer { try? FileManager.default.removeItem(at: url) } + retry(url, delete: { _ in Issue.record("No cleanup was requested") }) + .retryPending { #expect($0 == nil) } + retry(nil, delete: { _ in Issue.record("No shared store is configured") }) + .retryPending { #expect($0 == nil) } + let missing = url.appendingPathComponent("missing-directory") + retry(missing, delete: { _ in Issue.record("Journal failure must propagate") }) + .request { #expect($0 != nil) } + #expect(!BuzzAgeRestrictionSession.isRestricted(containerURL: missing)) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationHandoffTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationHandoffTests.swift index c4f09554e6..aa09bcd67e 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationHandoffTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationHandoffTests.swift @@ -8,9 +8,9 @@ struct BuzzNotificationHandoffTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } - let store = BuzzAgeRestrictionFenceStore(containerURL: directory) - let initial = store.current() - try store.begin() + let session = BuzzAgeRestrictionSession() + try session.restrict(containerURL: directory) + defer { session.release() } var delivered: [String] = [] var cleanupCount = 0 var deletionCompletion: ((Error?) -> Void)? @@ -25,7 +25,7 @@ struct BuzzNotificationHandoffTests { content, restrictedFallback: "Open Buzz to view this message.", handoffIfAllowed: { deliver in - (try? store.performIfUnchanged(since: initial, deliver)) ?? false + BuzzAgeRestrictionSession.handoffIfAllowed(containerURL: directory, deliver: deliver) }, cleanup: { // Neither the Intents callback nor its timer needs to fire for delivery. diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift index 5ea6fcb7c9..096e77382e 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -11,253 +11,6 @@ struct BuzzPushPresentationCacheTests { private let relayKey = String(repeating: "0", count: 63) + "2" private let otherRelayKey = String(repeating: "0", count: 63) + "3" - @Test("Age restriction fence persists a new token for other processes") - func ageRestrictionFencePersistsGeneration() throws { - let directory = try temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let writer = BuzzAgeRestrictionFenceStore(containerURL: directory) - let reader = BuzzAgeRestrictionFenceStore(containerURL: directory) - - #expect(reader.current() == .unavailable) - let first = try writer.begin() - #expect(first.isFencing) - #expect(reader.current() == first) - let second = try writer.settleIfFencing() - #expect(!second.isFencing) - #expect(second.token != first.token) - #expect(reader.current() == second) - } - - @Test("Legacy notification state without a fence fails closed until restored") - func legacyNotificationStateWithoutFenceFailsClosed() throws { - let directory = try temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let legacySnapshot = directory.appendingPathComponent( - BuzzPushPresentationCacheStore.fileName - ) - try Data("legacy notification snapshot".utf8).write(to: legacySnapshot) - let store = BuzzAgeRestrictionFenceStore(containerURL: directory) - - let beforeAgeCheck = store.current() - #expect(beforeAgeCheck == .unavailable) - #expect(beforeAgeCheck.requiresDiscard(since: beforeAgeCheck)) - - let restored = try store.settleIfFencing() - #expect(!restored.isFencing) - #expect(store.current() == restored) - #expect(!restored.requiresDiscard(since: restored)) - } - - @Test("Fenced cleanup rotates before work and settles only after success") - func fencedCleanupOrdersTransitions() throws { - let directory = try temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let writer = BuzzAgeRestrictionFenceStore(containerURL: directory) - let reader = BuzzAgeRestrictionFenceStore(containerURL: directory) - let initial = reader.current() - var observedDuringCleanup: BuzzAgeRestrictionFence? - - try writer.performFencedCleanup { - observedDuringCleanup = reader.current() - } - - let active = try #require(observedDuringCleanup) - let settled = reader.current() - #expect(active.isFencing) - #expect(active.token != initial.token) - #expect(!settled.isFencing) - #expect(settled.token != active.token) - } - - @Test("Failed fenced cleanup remains active") - func failedFencedCleanupRemainsActive() throws { - struct CleanupFailure: Error {} - - let directory = try temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let store = BuzzAgeRestrictionFenceStore(containerURL: directory) - - #expect(throws: CleanupFailure.self) { - try store.performFencedCleanup { - throw CleanupFailure() - } - } - #expect(store.current().isFencing) - } - - @Test("Asynchronous fenced cleanup waits for acknowledged success") - func asynchronousFencedCleanupWaitsForSuccess() throws { - let directory = try temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let store = BuzzAgeRestrictionFenceStore(containerURL: directory) - var acknowledge: ((Error?) -> Void)? - var didComplete = false - - try store.performFencedAsyncCleanup( - { acknowledge = $0 }, - completion: { error in - #expect(error == nil) - didComplete = true - } - ) - - #expect(store.current().isFencing) - #expect(!didComplete) - let acknowledgeCleanup = try #require(acknowledge) - acknowledgeCleanup(nil) - #expect(!store.current().isFencing) - #expect(didComplete) - } - - @Test("Failed asynchronous cleanup remains fenced") - func failedAsynchronousCleanupRemainsFenced() throws { - struct CleanupFailure: Error {} - - let directory = try temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let store = BuzzAgeRestrictionFenceStore(containerURL: directory) - var reportedFailure = false - - try store.performFencedAsyncCleanup( - { $0(CleanupFailure()) }, - completion: { error in - reportedFailure = error is CleanupFailure - } - ) - - #expect(reportedFailure) - #expect(store.current().isFencing) - } - - @Test("Older asynchronous cleanup cannot settle a newer fence") - func olderAsynchronousCleanupCannotSettleNewerFence() throws { - let directory = try temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let store = BuzzAgeRestrictionFenceStore(containerURL: directory) - var acknowledgeOlder: ((Error?) -> Void)? - var acknowledgeNewer: ((Error?) -> Void)? - - try store.performFencedAsyncCleanup( - { acknowledgeOlder = $0 }, - completion: { _ in } - ) - try store.performFencedAsyncCleanup( - { acknowledgeNewer = $0 }, - completion: { _ in } - ) - let newerFence = store.current() - - let completeOlder = try #require(acknowledgeOlder) - completeOlder(nil) - #expect(store.current() == newerFence) - #expect(store.current().isFencing) - - let completeNewer = try #require(acknowledgeNewer) - completeNewer(nil) - #expect(!store.current().isFencing) - } - - @Test("Cross-process lock prevents an older settle from overwriting a newer fence") - func crossProcessLockSerializesSettleAndBegin() throws { - let directory = try temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let settleReachedWrite = DispatchSemaphore(value: 0) - let releaseSettle = DispatchSemaphore(value: 0) - let settleFinished = DispatchSemaphore(value: 0) - let beginFinished = DispatchSemaphore(value: 0) - let settlingStore = BuzzAgeRestrictionFenceStore( - containerURL: directory, - beforeSettledWrite: { - settleReachedWrite.signal() - releaseSettle.wait() - } - ) - let beginningStore = BuzzAgeRestrictionFenceStore(containerURL: directory) - let active = try settlingStore.begin() - - DispatchQueue.global().async { - _ = try? settlingStore.settleIfFencing(expectedToken: active.token) - settleFinished.signal() - } - #expect(settleReachedWrite.wait(timeout: .now() + 1) == .success) - - DispatchQueue.global().async { - _ = try? beginningStore.begin() - beginFinished.signal() - } - #expect(beginFinished.wait(timeout: .now() + 0.05) == .timedOut) - - releaseSettle.signal() - #expect(settleFinished.wait(timeout: .now() + 1) == .success) - #expect(beginFinished.wait(timeout: .now() + 1) == .success) - let newest = beginningStore.current() - #expect(newest.isFencing) - #expect(newest.token != active.token) - } - - @Test("Cross-process lock orders a final handoff before cleanup begins") - func crossProcessLockSerializesHandoffAndBegin() throws { - let directory = try temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let handoffEntered = DispatchSemaphore(value: 0) - let releaseHandoff = DispatchSemaphore(value: 0) - let handoffFinished = DispatchSemaphore(value: 0) - let beginFinished = DispatchSemaphore(value: 0) - let handingOffStore = BuzzAgeRestrictionFenceStore(containerURL: directory) - let beginningStore = BuzzAgeRestrictionFenceStore(containerURL: directory) - let settled = try handingOffStore.settleIfFencing() - - DispatchQueue.global().async { - _ = try? handingOffStore.performIfUnchanged(since: settled) { - handoffEntered.signal() - releaseHandoff.wait() - } - handoffFinished.signal() - } - #expect(handoffEntered.wait(timeout: .now() + 1) == .success) - - DispatchQueue.global().async { - _ = try? beginningStore.begin() - beginFinished.signal() - } - #expect(beginFinished.wait(timeout: .now() + 0.05) == .timedOut) - - releaseHandoff.signal() - #expect(handoffFinished.wait(timeout: .now() + 1) == .success) - #expect(beginFinished.wait(timeout: .now() + 1) == .success) - #expect(beginningStore.current().isFencing) - } - - @Test("A changed fence refuses the final handoff") - func changedFenceRefusesHandoff() throws { - let directory = try temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let store = BuzzAgeRestrictionFenceStore(containerURL: directory) - let settled = try store.settleIfFencing() - _ = try store.begin() - var handedOff = false - - let accepted = try store.performIfUnchanged(since: settled) { - handedOff = true - } - - #expect(!accepted) - #expect(!handedOff) - } - - @Test("Age restriction fence discards active and superseded resolutions") - func ageRestrictionFenceDiscardPolicy() { - let initial = BuzzAgeRestrictionFence.initial - let active = BuzzAgeRestrictionFence(token: "active", isFencing: true) - let settled = BuzzAgeRestrictionFence(token: "settled", isFencing: false) - - #expect(!initial.requiresDiscard(since: initial)) - #expect(active.requiresDiscard(since: initial)) - #expect(BuzzAgeRestrictionFence.unavailable.requiresDiscard(since: initial)) - #expect(settled.requiresDiscard(since: initial)) - #expect(!settled.requiresDiscard(since: settled)) - } - @Test("Verified profile uses display_name, then name, and attaches a bounded local avatar") func verifiedProfilePrecedenceAndAvatar() throws { let directory = try temporaryDirectory() @@ -1117,48 +870,3 @@ struct BuzzPushPresentationCacheTests { ) } } - -struct BuzzLaunchNotificationProtectionTests { - @Test func failedLaunchClearsCredentialsAndStillRequiresRetry() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { - try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) - try? FileManager.default.removeItem(at: directory) - } - let store = BuzzAgeRestrictionFenceStore(containerURL: directory) - try store.begin() - let allowed = try store.settleIfFencing() - try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: directory.path) - var credentials = ["community": "saved signing key"] - - #expect(throws: (any Error).self) { - try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: directory) { - credentials.removeAll() - } - } - #expect(credentials.isEmpty) - #expect(store.current() == allowed, "The old allowed fence remains readable") - - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) - try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: directory) { - Issue.record("Recovered storage should establish the fence") - } - #expect(store.current().isFencing) - } - - @Test func missingContainerStillAttemptsCredentialRemoval() { - var cleared = false - #expect(throws: (any Error).self) { - try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: nil) { cleared = true } - } - #expect(cleared) - } - - @Test func credentialRemovalFailurePropagates() { - let failure = NSError(domain: "test.keychain", code: 1) - #expect(throws: failure) { - try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: nil) { throw failure } - } - } -} diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index ac3191b169..d2cf00a25e 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -8,8 +8,15 @@ final class NotificationService: UNNotificationServiceExtension { private var handoff: BuzzNotificationHandoff? private var bestAttemptContent: UNMutableNotificationContent? private var restrictedFallbackContent: UNMutableNotificationContent? - private var restrictionFenceAtStart = BuzzAgeRestrictionFence.initial - private let communicationPresenter = BuzzCommunicationNotificationPresenter() + private lazy var interactionCleanup = BuzzInteractionCleanupRetry( + containerURL: ageRestrictionContainerURL, deletion: interactionDeletionDeadline) + private lazy var communicationPresenter = BuzzCommunicationNotificationPresenter( + donate: { interaction, completion in interaction.donate(completion: completion) }, + deleteAllInteractions: { [cleanup = interactionCleanup] completion in + cleanup.request(completion: completion) + }, + updateContent: { content, intent in try content.updating(from: intent) } + ) private let interactionDeletionDeadline = BuzzInteractionDeletionDeadline( timeout: 5, deleteAllInteractions: { completion in @@ -53,9 +60,6 @@ final class NotificationService: UNNotificationServiceExtension { withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { handoff = BuzzNotificationHandoff(handler: contentHandler) - restrictionFenceAtStart = Self.loadRestrictionFence( - appGroupIdentifier: appGroupIdentifier - ) restrictedFallbackContent = Self.restrictedFallback(from: request.content) guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { finish(request.content) @@ -87,10 +91,10 @@ final class NotificationService: UNNotificationServiceExtension { ordinaryContent: content, resolution: resolution, isStillAllowed: { [weak self] in - self?.restrictionFenceIsUnchanged() ?? false + !(self?.isAgeRestricted() ?? false) }, - onDeletionFailure: { [weak self] _ in - self?.activateRestrictionFence() + onDeletionFailure: { error in + NSLog("Notification cleanup retry failed: %@", String(describing: error)) } ) { [weak self] specializedContent in self?.finish(specializedContent) @@ -112,11 +116,8 @@ final class NotificationService: UNNotificationServiceExtension { content, restrictedFallback: restrictedFallbackContent ?? Self.restrictedFallback(from: content), handoffIfAllowed: { deliver in - Self.handoffIfRestrictionFenceUnchanged( - appGroupIdentifier: appGroupIdentifier, - since: restrictionFenceAtStart, - handoff: deliver - ) + BuzzAgeRestrictionSession.handoffIfAllowed( + containerURL: ageRestrictionContainerURL, deliver: deliver) } ) { [self] in // The service deadline cannot wait for Intents cleanup. The safe content @@ -124,45 +125,22 @@ final class NotificationService: UNNotificationServiceExtension { let center = UNUserNotificationCenter.current() center.removeAllDeliveredNotifications() center.removeAllPendingNotificationRequests() - interactionDeletionDeadline.deleteAll { [weak self] error in - if error != nil { self?.activateRestrictionFence() } + interactionCleanup.request { error in + if error != nil { NSLog("Notification cleanup failed; age access is unchanged.") } center.removeAllDeliveredNotifications() center.removeAllPendingNotificationRequests() } } } - private func restrictionFenceIsUnchanged() -> Bool { - !Self.loadRestrictionFence( - appGroupIdentifier: appGroupIdentifier - ).requiresDiscard(since: restrictionFenceAtStart) - } - - private static func handoffIfRestrictionFenceUnchanged( - appGroupIdentifier: String?, - since earlier: BuzzAgeRestrictionFence, - handoff: () -> Void - ) -> Bool { - guard let appGroupIdentifier, - let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier - ) - else { return false } - do { - return try BuzzAgeRestrictionFenceStore(containerURL: container) - .performIfUnchanged(since: earlier, handoff) - } catch { - return false + private var ageRestrictionContainerURL: URL? { + appGroupIdentifier.flatMap { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0) } } - private func activateRestrictionFence() { - guard let appGroupIdentifier, - let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier - ) - else { return } - try? BuzzAgeRestrictionFenceStore(containerURL: container).begin() + private func isAgeRestricted() -> Bool { + BuzzAgeRestrictionSession.isRestricted(containerURL: ageRestrictionContainerURL) } private static func restrictedFallback( @@ -181,17 +159,6 @@ final class NotificationService: UNNotificationServiceExtension { return fallback } - private static func loadRestrictionFence( - appGroupIdentifier: String? - ) -> BuzzAgeRestrictionFence { - guard let appGroupIdentifier, - let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier - ) - else { return .unavailable } - return BuzzAgeRestrictionFenceStore(containerURL: container).current() - } - private static func loadPrivateKey( communityID: String, keychainAccessGroup: String? diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index cf7f1a8422..271e76c9dd 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -31,6 +31,8 @@ import os.log private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var ageSignalChannel: FlutterMethodChannel? + var requestPlatformAgeSignal: @MainActor (UIViewController) async throws -> [String: Any] = + AppDelegate.platformAgeSignal private var ageSignalTask: Task? private var ageSignalRequestID: UUID? private var ageSignalResult: FlutterResult? @@ -45,28 +47,12 @@ import os.log _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - do { - try prepareLaunchAgeRestrictionFence() - } catch { - // Flutter must start so the existing age-check retry screen is reachable. - // requestAgeSignal retries this protection before returning any age result. - os_log( - "Launch notification protection failed: %{public}@", type: .error, - error.localizedDescription) - } + // Age checking and notification restoration run asynchronously from + // Flutter. No age-related storage or platform request may delay launch. UNUserNotificationCenter.current().delegate = self return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - private func prepareLaunchAgeRestrictionFence() throws { - let container = appGroupIdentifier.flatMap { - FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0) - } - try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: container) { - try BuzzPushKeychain.replace(signingKeys: [:], accessGroup: self.pushKeychainAccessGroup) - } - } - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) let messenger = engineBridge.applicationRegistrar.messenger() @@ -278,18 +264,6 @@ import os.log result(FlutterMethodNotImplemented) return } - do { - try prepareLaunchAgeRestrictionFence() - } catch { - result( - FlutterError( - code: "age_signal_notification_protection_failed", - message: "Unable to protect notifications before checking age. Please retry.", - details: error.localizedDescription - ) - ) - return - } guard #available(iOS 26.0, *) else { result(Self.noAgeSignalResponse) return @@ -308,31 +282,11 @@ import os.log let requestID = UUID() ageSignalRequestID = requestID ageSignalResult = result + let request = requestPlatformAgeSignal ageSignalTask = Task { @MainActor [weak self] in do { - let response = try await AgeRangeService.shared.requestAgeRange( - ageGates: 18, - in: viewController - ) - switch response { - case .declinedSharing: - self?.completeAgeSignalRequest(requestID, value: Self.noAgeSignalResponse) - case .sharing(let range): - self?.completeAgeSignalRequest( - requestID, - value: BuzzAgeSignalPayload.sharing(exclusiveUpperBound: range.upperBound) - ) - @unknown default: - self?.completeAgeSignalRequest( - requestID, - value: - FlutterError( - code: "age_signal_unavailable", - message: "The age signal response is unsupported.", - details: nil - ) - ) - } + let payload = try await request(viewController) + self?.completeAgeSignalRequest(requestID, value: payload) } catch { self?.completeAgeSignalRequest( requestID, @@ -347,6 +301,22 @@ import os.log } } + @MainActor + private static func platformAgeSignal(_ viewController: UIViewController) async throws -> [String: Any] { + guard #available(iOS 26.0, *) else { return noAgeSignalResponse } + let response = try await AgeRangeService.shared.requestAgeRange(ageGates: 18, in: viewController) + switch response { + case .declinedSharing: + return noAgeSignalResponse + case .sharing(let range): + return BuzzAgeSignalPayload.sharing( + exclusiveUpperBound: range.upperBound, lowerBound: range.lowerBound) + @unknown default: + throw NSError(domain: "BuzzAgeSignal", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unsupported age signal response"]) + } + } + private func completeAgeSignalRequest(_ requestID: UUID, value: Any?) { guard ageSignalRequestID == requestID, let result = ageSignalResult else { return } ageSignalRequestID = nil diff --git a/mobile/ios/Runner/PushSnapshotBridge.swift b/mobile/ios/Runner/PushSnapshotBridge.swift index cf2201d29c..d01a9e2e68 100644 --- a/mobile/ios/Runner/PushSnapshotBridge.swift +++ b/mobile/ios/Runner/PushSnapshotBridge.swift @@ -5,7 +5,7 @@ import Intents import UserNotifications final class BuzzPushSnapshotBridge { - private let appGroupIdentifier: String? + private let containerURL: () -> URL? private let endpointGrantStore: BuzzPushEndpointGrantKeychainStore private let interactionDeletionDeadline: BuzzInteractionDeletionDeadline private let keychainAccessGroup: String? @@ -14,26 +14,16 @@ final class BuzzPushSnapshotBridge { qos: .utility ) private lazy var store: BuzzPushPresentationCacheStore? = { - guard let appGroupIdentifier, - let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier - ) - else { return nil } + guard let container = containerURL() else { return nil } return BuzzPushPresentationCacheStore(containerURL: container) }() - private lazy var ageRestrictionFenceStore: BuzzAgeRestrictionFenceStore? = { - guard let appGroupIdentifier, - let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier - ) - else { return nil } - return BuzzAgeRestrictionFenceStore(containerURL: container) - }() + private let ageRestrictionSession = BuzzAgeRestrictionSession() init( appGroupIdentifier: String?, endpointGrantStore: BuzzPushEndpointGrantKeychainStore, keychainAccessGroup: String?, + containerURL: (() -> URL?)? = nil, interactionDeletionDeadline: BuzzInteractionDeletionDeadline = BuzzInteractionDeletionDeadline( timeout: 5, @@ -48,7 +38,11 @@ final class BuzzPushSnapshotBridge { } ) ) { - self.appGroupIdentifier = appGroupIdentifier + self.containerURL = containerURL ?? { + guard let appGroupIdentifier else { return nil } + return FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier) + } self.endpointGrantStore = endpointGrantStore self.keychainAccessGroup = keychainAccessGroup self.interactionDeletionDeadline = interactionDeletionDeadline @@ -56,6 +50,21 @@ final class BuzzPushSnapshotBridge { @discardableResult func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) -> Bool { + if call.method == "restoreAgeRestrictedNotifications" { + queue.async { [self] in + ageRestrictionSession.release() + BuzzInteractionCleanupRetry( + containerURL: containerURL(), deletion: interactionDeletionDeadline + ).retryPending { error in + Self.complete(result, value: error.map { + FlutterError(code: "interaction_cleanup_retry_failed", + message: "Unable to finish pending interaction cleanup.", + details: $0.localizedDescription) + }) + } + } + return true + } if call.method == "purgeAgeRestrictedNotifications" { purgeAgeRestrictedNotifications(result: result) return true @@ -85,32 +94,30 @@ final class BuzzPushSnapshotBridge { private func purgeAgeRestrictedNotifications(result: @escaping FlutterResult) { queue.async { [weak self] in do { - guard let self, let store, let ageRestrictionFenceStore - else { + guard let self, let container = containerURL() else { throw NSError( domain: "BuzzPushSnapshotBridge", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "The age-restriction fence store is unavailable." + NSLocalizedDescriptionKey: "The age-restriction session container is unavailable." ] ) } - try ageRestrictionFenceStore.begin() - try store.replaceCommunities([]) - try BuzzPushKeychain.replace( - signingKeys: [:], - accessGroup: self.keychainAccessGroup - ) + try ageRestrictionSession.restrict(containerURL: container) + // Preserve snapshots and credentials. Restriction authority expires + // with this process even when cleanup or a later storage read fails. let center = UNUserNotificationCenter.current() center.removeAllDeliveredNotifications() center.removeAllPendingNotificationRequests() - self.interactionDeletionDeadline.deleteAll { error in + BuzzInteractionCleanupRetry( + containerURL: container, deletion: interactionDeletionDeadline + ).request { error in Self.complete( result, value: error.map { FlutterError( code: "age_restriction_purge_failed", - message: "Unable to fence and purge restricted notifications.", + message: "Unable to suppress and purge restricted notifications.", details: $0.localizedDescription ) } @@ -121,7 +128,7 @@ final class BuzzPushSnapshotBridge { result, value: FlutterError( code: "age_restriction_purge_failed", - message: "Unable to fence and purge restricted notifications.", + message: "Unable to suppress and purge restricted notifications.", details: error.localizedDescription ) ) @@ -192,71 +199,12 @@ final class BuzzPushSnapshotBridge { } let data = try JSONSerialization.data(withJSONObject: enriched, options: [.sortedKeys]) let decoded = try JSONDecoder().decode([PushLeaseCommunity].self, from: data) - let settleFence = arguments["settleFence"] as? Bool ?? false - let replaceSnapshot = { - try store.replaceCommunities(decoded) - try BuzzPushKeychain.replace( - signingKeys: signingKeys, - accessGroup: self.keychainAccessGroup - ) - } - if requiresStore && decoded.isEmpty { - guard let ageRestrictionFenceStore else { - throw NSError( - domain: "BuzzPushSnapshotBridge", - code: 2, - userInfo: [ - NSLocalizedDescriptionKey: "The age-restriction fence store is unavailable." - ] - ) - } - let cleanup = { (completion: @escaping (Error?) -> Void) throws in - try replaceSnapshot() - let center = UNUserNotificationCenter.current() - center.removeAllDeliveredNotifications() - center.removeAllPendingNotificationRequests() - self.interactionDeletionDeadline.deleteAll(completion: completion) - } - let completion = { (error: Error?) in - Self.complete( - result, - value: error.map { - FlutterError( - code: "snapshot_sync_failed", - message: "Unable to sync push community state.", - details: $0.localizedDescription - ) - } - ) - } - if settleFence { - try ageRestrictionFenceStore.performFencedAsyncCleanup( - cleanup, - completion: completion - ) - } else { - try ageRestrictionFenceStore.begin() - try cleanup(completion) - } - return - } else { - try replaceSnapshot() - if requiresStore { - guard settleFence else { - throw NSError( - domain: "BuzzPushSnapshotBridge", - code: 3, - userInfo: [ - NSLocalizedDescriptionKey: - "Only an allowed age-gate transition may restore push state." - ] - ) - } - // Only the acknowledged age-gate path may end a failed purge fence. - // An older best-effort export must never reopen notification access. - try ageRestrictionFenceStore?.settleIfFencing() - } - } + // Ordinary snapshot maintenance never changes age access. + try store.replaceCommunities(decoded) + try BuzzPushKeychain.replace( + signingKeys: signingKeys, + accessGroup: self.keychainAccessGroup + ) Self.complete(result, value: nil) } catch { Self.complete( @@ -417,10 +365,7 @@ final class BuzzPushSnapshotBridge { } private func community(id: String) -> PushLeaseCommunity? { - guard let appGroupIdentifier, - let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier - ), + guard let container = containerURL(), let data = try? Data( contentsOf: container.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)), let snapshot = try? JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: data) diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index c07ce6787d..40b3cadee8 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -208,70 +208,6 @@ final class BuzzCommunicationNotificationTests: XCTestCase { XCTAssertFalse(donateCalled) } - func testFailedLateDonationDeletionReactivatesFenceUntilAppRetry() throws { - let ordinary = UNMutableNotificationContent() - ordinary.title = "Alice" - let directory = FileManager.default.temporaryDirectory.appendingPathComponent( - UUID().uuidString, - isDirectory: true - ) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let fenceStore = BuzzAgeRestrictionFenceStore(containerURL: directory) - let fenceAtStart = fenceStore.current() - var order: [String] = [] - let deletionFailure = NSError(domain: "test", code: 3) - let presenter = BuzzCommunicationNotificationPresenter( - donate: { _, completion in - order.append("donate") - do { - try fenceStore.performFencedCleanup {} - } catch { - XCTFail("Unable to prepare the donation cleanup: \(error)") - } - completion(nil) - }, - deleteAllInteractions: { completion in - order.append("delete") - completion(deletionFailure) - }, - updateContent: { content, _ in content } - ) - let completed = expectation(description: "failed deletion fenced") - - presenter.present( - ordinaryContent: ordinary, - resolution: communicationResolution(), - isStillAllowed: { - !fenceStore.current().requiresDiscard(since: fenceAtStart) - }, - onDeletionFailure: { error in - XCTAssertEqual(error as NSError, deletionFailure) - do { - try fenceStore.begin() - } catch { - XCTFail("Unable to reactivate the restriction: \(error)") - } - order.append("fence") - } - ) { content in - order.append("complete") - XCTAssertEqual(content.title, "Alice") - completed.fulfill() - } - - wait(for: [completed], timeout: 1) - XCTAssertEqual(order, ["donate", "delete", "fence", "complete"]) - XCTAssertTrue(fenceStore.current().isFencing) - - var retryError: Error? - try fenceStore.performFencedAsyncCleanup( - { $0(nil) }, - completion: { retryError = $0 } - ) - XCTAssertNil(retryError) - XCTAssertFalse(fenceStore.current().isFencing) - } private func communicationResolution( displayName: String = "Alice", @@ -307,34 +243,148 @@ final class BuzzCommunicationNotificationTests: XCTestCase { } final class BuzzPushSnapshotEnrichmentTests: XCTestCase { - func testLaunchBeginsAgeRestrictionFenceBeforeFlutterState() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent( - UUID().uuidString, - isDirectory: true - ) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: directory) { - XCTFail("A successful fence must not remove credentials") - } - - XCTAssertTrue(BuzzAgeRestrictionFenceStore(containerURL: directory).current().isFencing) - } - - func testAgeSignalRequestRejectsFailedLaunchProtectionBeforeCheckingAge() { + func testAgeSignalRequestDoesNotDependOnNotificationStorage() { let delegate = MissingAppGroupDelegate() var completed = false delegate.handleAgeSignalMethodCall( FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil), viewController: nil ) { value in - XCTAssertEqual((value as? FlutterError)?.code, "age_signal_notification_protection_failed") + if #available(iOS 26.0, *) { + XCTAssertEqual((value as? FlutterError)?.code, "age_signal_unavailable") + } else { + XCTAssertEqual((value as? [String: Any])?["status"] as? String, "noSignal") + } completed = true } XCTAssertTrue(completed) } + @MainActor + func testNativeAgeRequestDeliversKnownMinorAndFailureThroughFlutterCallback() async { + guard #available(iOS 26.0, *) else { return } + let delegate = MissingAppGroupDelegate() + delegate.requestPlatformAgeSignal = { _ in + BuzzAgeSignalPayload.sharing(exclusiveUpperBound: 18, lowerBound: 13) + } + let minor = expectation(description: "native minor response") + delegate.handleAgeSignalMethodCall( + FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil), + viewController: UIViewController() + ) { value in + XCTAssertEqual((value as? [String: Any])?["status"] as? String, "signal") + XCTAssertEqual((value as? [String: Any])?["ageUpper"] as? Int, 17) + minor.fulfill() + } + await fulfillment(of: [minor], timeout: 1) + + delegate.requestPlatformAgeSignal = { _ in + throw NSError(domain: "InjectedAgeFailure", code: 1) + } + let failed = expectation(description: "native error response") + delegate.handleAgeSignalMethodCall( + FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil), + viewController: UIViewController() + ) { value in + XCTAssertEqual((value as? FlutterError)?.code, "age_signal_unavailable") + failed.fulfill() + } + await fulfillment(of: [failed], timeout: 1) + } + + func testAllowedNotificationRestoreNeedsNoAppGroupStore() { + let bridge = BuzzPushSnapshotBridge( + appGroupIdentifier: nil, + endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil), + keychainAccessGroup: nil + ) + let completed = expectation(description: "allowed without storage") + XCTAssertTrue(bridge.handle( + FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil) + ) { value in + XCTAssertNil(value) + completed.fulfill() + }) + wait(for: [completed], timeout: 1) + } + + func testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + try store.replaceCommunities([ + PushLeaseCommunity(id: "retained", name: "Retained", relayUrl: "https://relay.example", + pubkey: nil, policies: []) + ]) + let snapshotURL = directory.appendingPathComponent(BuzzPushPresentationCacheStore.fileName) + let original = try Data(contentsOf: snapshotURL) + var deletionCalls = 0 + let bridge = BuzzPushSnapshotBridge( + appGroupIdentifier: nil, + endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil), + keychainAccessGroup: nil, + containerURL: { directory }, + interactionDeletionDeadline: BuzzInteractionDeletionDeadline( + timeout: 5, + deleteAllInteractions: { completion in + deletionCalls += 1 + completion(deletionCalls == 1 ? NSError(domain: "InjectedCleanupFailure", code: 1) : nil) + }, + scheduleTimeout: { _, _ in } + ) + ) + let restricted = expectation(description: "restriction despite cleanup failure") + XCTAssertTrue(bridge.handle( + FlutterMethodCall(methodName: "purgeAgeRestrictedNotifications", arguments: nil) + ) { value in + XCTAssertEqual((value as? FlutterError)?.code, "age_restriction_purge_failed") + XCTAssertTrue(BuzzAgeRestrictionSession.isRestricted(containerURL: directory)) + restricted.fulfill() + }) + wait(for: [restricted], timeout: 1) + XCTAssertEqual(try Data(contentsOf: snapshotURL), original) + + let restored = expectation(description: "allowed without restoring snapshot") + XCTAssertTrue(bridge.handle( + FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil) + ) { value in + XCTAssertNil(value) + XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory)) + restored.fulfill() + }) + wait(for: [restored], timeout: 1) + XCTAssertEqual(deletionCalls, 2) + XCTAssertEqual(try Data(contentsOf: snapshotURL), original) + } + + func testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let failure = NSError(domain: "InjectedCleanupFailure", code: 1) + let deletion = BuzzInteractionDeletionDeadline(timeout: 5, + deleteAllInteractions: { $0(failure) }, scheduleTimeout: { _, _ in }) + BuzzInteractionCleanupRetry(containerURL: directory, deletion: deletion).request { + XCTAssertNotNil($0) + } + let bridge = BuzzPushSnapshotBridge( + appGroupIdentifier: nil, + endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil), + keychainAccessGroup: nil, containerURL: { directory }, + interactionDeletionDeadline: deletion + ) + let completed = expectation(description: "retry failure stays allowed") + XCTAssertTrue(bridge.handle( + FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil) + ) { value in + XCTAssertEqual((value as? FlutterError)?.code, "interaction_cleanup_retry_failed") + XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory)) + completed.fulfill() + }) + wait(for: [completed], timeout: 1) + } + func testStrictAgeGateWriteFailsWhenAppGroupStoreIsUnavailable() { let bridge = BuzzPushSnapshotBridge( appGroupIdentifier: nil, diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 272c3a2aba..d58661396b 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -400,7 +400,7 @@ class RunnerTests: XCTestCase { let size = NativeAttachmentMenuLayout.size(compatibleWith: traits) XCTAssertEqual(size.width, 216) - XCTAssertEqual(size.height, 264) + XCTAssertEqual(size.height, 324) // Five actions, including voice notes. XCTAssertEqual(NativeAttachmentMenuLayout.contentPadding, 16) XCTAssertEqual( NativeAttachmentMenuLayout.itemHeight(compatibleWith: traits), diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 6531bd26f6..7a48123824 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -20,6 +20,7 @@ import 'features/home/home_page.dart'; import 'features/invites/invite_create_page.dart'; import 'features/invites/invite_join_provider.dart'; import 'features/pairing/pairing_page.dart'; +import 'features/pairing/pairing_provider.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/channel_detail_page.dart'; import 'features/channels/deep_link_dispatcher.dart'; @@ -295,7 +296,12 @@ class App extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final ageSignalState = ref.watch(ageSignalProvider); - final communityTheme = ageSignalState == AgeSignalState.allowed + ref.listen(ageSignalProvider, (_, next) { + if (next == AgeSignalState.restricted) { + ref.read(pairingProvider.notifier).reset(); + } + }); + final communityTheme = ageSignalState != AgeSignalState.restricted ? ref.watch(communityThemeProvider) : defaultCommunityTheme; final themeMode = communityTheme.mode; @@ -335,7 +341,7 @@ class App extends HookConsumerWidget { // Eagerly initialize websocket session and lifecycle observer when // authenticated. These providers connect and manage the websocket. var hasUnreadInbox = false; - if (ageSignalState == AgeSignalState.allowed && + if (ageSignalState != AgeSignalState.restricted && authState.value?.status == AuthStatus.authenticated) { ref.watch(relaySessionProvider); ref.watch(observerRelayProvider); @@ -364,14 +370,14 @@ class App extends HookConsumerWidget { } useEffect(() { - if (ageSignalState == AgeSignalState.allowed) { + if (ageSignalState != AgeSignalState.restricted) { applyBadge(ref.read(unreadBadgeProvider)); } else { AppBadgePlus.updateBadge(0); } return null; }, [ageSignalState]); - if (ageSignalState == AgeSignalState.allowed) { + if (ageSignalState != AgeSignalState.restricted) { ref.listen(unreadBadgeProvider, (_, next) { applyBadge(next); }); @@ -393,10 +399,8 @@ class App extends HookConsumerWidget { // Above the navigator, so an age restriction cannot be bypassed by a // route that was pushed while the store signal request was in flight. builder: (context, child) => switch (ageSignalState) { - AgeSignalState.checking => const _AgeSignalLoadingPage(), - AgeSignalState.retryableFailure => const _AgeSignalRetryPage(), AgeSignalState.restricted => const AgeRestrictionPage(), - AgeSignalState.allowed => AppMarkdownTheme( + _ => AppMarkdownTheme( child: MobileHuddleShell( navigatorKey: _mobileRootNavigatorKey, child: EmojiBurstOverlay(child: child ?? const SizedBox.shrink()), @@ -455,56 +459,3 @@ class _SplashScreen extends StatelessWidget { ); } } - -class _AgeSignalLoadingPage extends StatelessWidget { - const _AgeSignalLoadingPage(); - - @override - Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: BuzzLoadingIndicator( - size: 56, - semanticLabel: 'Checking age eligibility', - ), - ), - ); - } -} - -class _AgeSignalRetryPage extends ConsumerWidget { - const _AgeSignalRetryPage(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - body: Center( - child: Padding( - padding: const EdgeInsets.all(Grid.sm), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Unable to check age eligibility', - style: context.textTheme.titleLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: Grid.xxs), - Text( - 'Check your connection and try again.', - style: context.textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: Grid.xs), - FilledButton( - onPressed: () => - unawaited(ref.read(ageSignalProvider.notifier).request()), - child: const Text('Try again'), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/features/age_gate/age_signal_provider.dart b/mobile/lib/features/age_gate/age_signal_provider.dart index d3c9b408e6..09b8ea47b5 100644 --- a/mobile/lib/features/age_gate/age_signal_provider.dart +++ b/mobile/lib/features/age_gate/age_signal_provider.dart @@ -1,266 +1,94 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; const ageSignalChannel = MethodChannel('buzz/age_signal'); -/// Delay before the single retry of a failed native age-signal request. -const ageSignalRetryDelay = Duration(seconds: 1); - -/// Maximum time allowed for each native age-signal request attempt. +/// Maximum lifetime of a launch age check. Access never waits for this timer. const ageSignalRequestTimeout = Duration(seconds: 30); -/// Maximum time allowed for native cancellation acknowledgement. -const ageSignalCancellationTimeout = Duration(seconds: 5); - /// Invokes the native age-signal request. typedef AgeSignalRequest = Future?> Function(); -/// Waits before retrying a failed native age-signal request. -typedef AgeSignalDelay = Future Function(Duration duration); -typedef AgeSignalCancel = Future Function(); - -/// Returns true only when native state is retired for an in-process retry. -typedef AgeSignalRestart = Future Function(); - Future?> _requestPlatformAgeSignal() => ageSignalChannel.invokeMapMethod('requestAgeSignal'); -Future _delayAgeSignalRetry(Duration duration) => - Future.delayed(duration); -Future _cancelPlatformAgeSignal() async => - await ageSignalChannel.invokeMethod('cancelAgeSignalRequest') ?? - false; -Future _restartForPlatformAgeSignal() async { - final retired = await ageSignalChannel.invokeMethod( - 'restartForAgeSignal', - ); - if (retired == null) { - throw StateError('Missing age signal reset acknowledgement.'); - } - return retired; -} - +/// Returns true only for an explicit, valid, inclusive upper age below 18. +/// Unknown envelopes and impossible bounds cannot authorize restriction. bool shouldBlockForAgeSignal(Map response) { if (response.length != 2 || - !response.containsKey('status') || + response['status'] != 'signal' || !response.containsKey('ageUpper')) { - throw StateError('Unexpected age signal response.'); - } - - final status = response['status']; - if (status == 'noSignal') { - if (response['ageUpper'] != null) { - throw StateError('Unexpected age signal upper bound.'); - } return false; } - if (status != 'signal') { - throw StateError('Unexpected age signal status.'); - } - - // Native adapters provide an inclusive upper age. iOS converts its - // exclusive age gate (18 for a minor) to 17 before sending this payload. - final ageUpper = response['ageUpper']; - if (ageUpper == null) { - return false; - } - if (ageUpper is! int) { - throw StateError('Unexpected age signal upper bound.'); - } - return ageUpper < 18; + final upper = response['ageUpper']; + return upper is int && upper >= 0 && upper < 18; } -/// Result of the platform age-signal check for this app launch. -enum AgeSignalState { checking, retryableFailure, allowed, restricted } +/// Access decision, independent of whether a platform check has completed. +enum AgeSignalState { allowed, restricted } +/// Observes one native result per launch without gating normal startup. class AgeSignalNotifier extends Notifier { - /// Creates an age-signal notifier, optionally with test request hooks. AgeSignalNotifier({ AgeSignalRequest? requestSignal, - AgeSignalDelay? delay, - AgeSignalCancel? cancelSignal, - AgeSignalRestart? restartSignal, Duration requestTimeout = ageSignalRequestTimeout, - Duration cancellationTimeout = ageSignalCancellationTimeout, }) : _requestSignal = requestSignal ?? _requestPlatformAgeSignal, - _delay = delay ?? _delayAgeSignalRetry, - _cancelSignal = cancelSignal ?? _cancelPlatformAgeSignal, - _restartSignal = restartSignal ?? _restartForPlatformAgeSignal, - _requestTimeout = requestTimeout, - _cancellationTimeout = cancellationTimeout; - - static const _maxAttempts = 2; + _requestTimeout = requestTimeout; final AgeSignalRequest _requestSignal; - final AgeSignalDelay _delay; - final AgeSignalCancel _cancelSignal; - final AgeSignalRestart _restartSignal; final Duration _requestTimeout; - final Duration _cancellationTimeout; - bool _completed = false; - bool _restartRequired = false; - Future? _requestInFlight; - Future?>? _nativeRequestInFlight; + Future? _attempt; + int _generation = 0; @override - AgeSignalState build() => AgeSignalState.checking; - - Future request() async { - if (_completed) return; - final requestInFlight = _requestInFlight; - if (requestInFlight != null) { - await requestInFlight; - return; - } - - state = AgeSignalState.checking; - final request = _requestWithRetry(); - _requestInFlight = request; - try { - await request; - } finally { - if (identical(_requestInFlight, request)) { - _requestInFlight = null; - } - } + AgeSignalState build() { + // Invalidation/disposal retires the request even if native code completes + // later. A new provider lifecycle starts allowed, without cached denial. + _generation += 1; + _attempt = null; + ref.onDispose(() => _generation += 1); + return AgeSignalState.allowed; } - Future _requestWithRetry() async { - if (_restartRequired) { - var retired = false; - try { - retired = await _restartSignal().timeout(_cancellationTimeout); - } on TimeoutException { - // Keep the retry affordance when native recovery does not acknowledge. - } on MissingPluginException { - // A missing recovery handler must not allow access. - } on PlatformException { - // A failed reset must not start an overlapping native request. - } on TypeError { - // A malformed acknowledgement is not evidence of retirement. - } on StateError { - // A missing acknowledgement is not evidence of retirement. - } - if (!retired) { - state = AgeSignalState.retryableFailure; - return; - } - _nativeRequestInFlight = null; - _restartRequired = false; - } - for (var attempt = 0; attempt < _maxAttempts; attempt += 1) { - final Map? response; - try { - response = await _awaitNativeRequest(); - } on MissingPluginException { - if (attempt + 1 < _maxAttempts) { - await _delay(ageSignalRetryDelay); - continue; - } - // A missing channel is an integration failure, not evidence that the - // platform has no age signal. Keep the launch gated for a retry. - state = AgeSignalState.retryableFailure; - return; - } on PlatformException { - if (attempt + 1 < _maxAttempts) { - await _delay(ageSignalRetryDelay); - continue; - } - // A transient native failure is not evidence that access is allowed. - // Keep the launch gated and expose a deliberate retry action. - state = AgeSignalState.retryableFailure; - return; - } on TimeoutException { - if (attempt + 1 < _maxAttempts) { - await _delay(ageSignalRetryDelay); - continue; - } - await _retireNativeRequest(); - // A stalled platform flow is not evidence that access is allowed. - // Keep the launch gated and expose a deliberate retry action. - state = AgeSignalState.retryableFailure; - return; - } on TypeError { - // A method-channel envelope with the wrong shape fails before the map - // validator runs. Keep the launch gated with an explicit retry. - state = AgeSignalState.retryableFailure; - return; - } + /// Checks once. Failure ends the attempt; neither retry nor cleanup gates UI. + Future request() => _attempt ??= _check(++_generation); - final bool shouldBlock; - try { - if (response == null) { - throw StateError('Missing age signal response.'); - } - shouldBlock = shouldBlockForAgeSignal(response); - } on StateError { - // A malformed native response is an integration failure, not evidence - // that access is allowed. Keep the launch gated for a deliberate retry. - state = AgeSignalState.retryableFailure; - return; - } - - _completed = true; - state = shouldBlock ? AgeSignalState.restricted : AgeSignalState.allowed; - return; - } - } - - Future?> _awaitNativeRequest() async { - final request = _nativeRequestInFlight ??= _requestSignal(); + Future _check(int generation) async { try { - final response = await request.timeout(_requestTimeout); - if (identical(_nativeRequestInFlight, request)) { - _nativeRequestInFlight = null; - } - return response; - } on TimeoutException { - // A Dart timeout cannot cancel the platform consent flow. Keep this - // future as the single flight so retries cannot start overlapping native - // prompts and can still consume a late response. - rethrow; - } catch (_) { - if (identical(_nativeRequestInFlight, request)) { - _nativeRequestInFlight = null; - } - rethrow; - } - } - - Future _retireNativeRequest() async { - var retired = false; - try { - retired = await _cancelSignal().timeout(_cancellationTimeout); - } on TimeoutException { - // Stay gated if cancellation does not acknowledge within its deadline. - } on MissingPluginException { - // Stay gated if cancellation cannot be acknowledged. - } on PlatformException { - // Stay gated if cancellation cannot be acknowledged. - } on TypeError { - // Stay gated if the method channel returns a malformed acknowledgement. - } finally { - if (retired) { - _nativeRequestInFlight = null; - } else { - _restartRequired = true; + final response = await Future.sync( + _requestSignal, + ).timeout(_requestTimeout); + if (generation != _generation) return; + // Retire before interpreting the result. Only this current completion + // can commit a restriction; timeout and abandoned callbacks cannot. + _generation += 1; + if (response != null && shouldBlockForAgeSignal(response)) { + state = AgeSignalState.restricted; } + } catch (error) { + if (generation != _generation) return; + _generation += 1; + // This is an intentional fail-open boundary, including codec and plugin + // errors. Record only the category, never age data or exception payloads. + debugPrint( + 'Age check unavailable (${error.runtimeType}); access allowed.', + ); } } } +/// Explicit dogfood opt-in. Production builds keep enforcement disabled. +const ageGatingEnabled = bool.fromEnvironment('BUZZ_AGE_GATING_ENABLED'); + final ageSignalProvider = NotifierProvider( - _DisabledAgeSignalNotifier.new, + ageGatingEnabled ? AgeSignalNotifier.new : _DisabledAgeSignalNotifier.new, ); -// Temporarily disable age blocking: platform age checks can lock users out of -// the app. Keep the allowed transition so push snapshots are restored normally. +// Remains disabled until the fail-open implementation is validated in dogfood. class _DisabledAgeSignalNotifier extends AgeSignalNotifier { - @override - AgeSignalState build() => AgeSignalState.allowed; - @override Future request() async {} } diff --git a/mobile/lib/features/age_gate/age_signal_push_bootstrap.dart b/mobile/lib/features/age_gate/age_signal_push_bootstrap.dart index 073302a926..281ce3c766 100644 --- a/mobile/lib/features/age_gate/age_signal_push_bootstrap.dart +++ b/mobile/lib/features/age_gate/age_signal_push_bootstrap.dart @@ -5,15 +5,14 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../shared/community/community_provider.dart'; import '../../shared/push/push_bootstrap.dart'; import '../../shared/push/push_bridge.dart'; import 'age_signal_provider.dart'; -/// Delay between failed age-gate snapshot transitions. +/// Delay between failed age-gate notification transitions. const ageSignalPushSnapshotInitialRetryDelay = Duration(seconds: 5); -/// Maximum delay between failed age-gate snapshot transitions. +/// Maximum delay between failed age-gate notification transitions. const ageSignalPushSnapshotMaximumRetryDelay = Duration(minutes: 5); /// Exponential retry delay for a zero-based consecutive failure count. @@ -26,16 +25,22 @@ Duration ageSignalPushSnapshotRetryDelay(int failures) { ); } -/// Waits before retrying a failed age-gate snapshot transition. +/// Waits before retrying a failed age-gate notification transition. typedef AgeSignalPushSnapshotRetryWait = Future Function(Duration duration); -/// Retry wait used by the launch age gate's notification snapshot boundary. +/// Retry wait used by the launch age gate's notification boundary. final ageSignalPushSnapshotRetryWaitProvider = Provider((ref) { return Future.delayed; }); +/// Releases only the current process's confirmed notification restriction. +final ageAllowedNotificationRestorerProvider = + Provider Function()>( + (ref) => restoreAgeRestrictedBuzzNotifications, + ); + /// Native notification purge performed once restriction is confirmed. final ageRestrictedNotificationPurgerProvider = Provider Function()>( @@ -63,7 +68,7 @@ final ageRestrictedNotificationMaintenanceScheduleProvider = }; }); -/// Starts the push lifecycle only after the launch age check allows access. +/// Starts push normally unless a confirmed age restriction is active. class AgeSignalPushBootstrap extends HookConsumerWidget { /// Creates the production push boundary around [child]. const AgeSignalPushBootstrap({required this.child, super.key}); @@ -73,56 +78,42 @@ class AgeSignalPushBootstrap extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(ageSignalProvider); - final suspendSnapshot = ref.watch( - suspendCommunitySnapshotForAgeCheckProvider, - ); - final resumeSnapshot = ref.watch( - resumeCommunitySnapshotAfterAgeCheckProvider, + final restoreNotifications = ref.watch( + ageAllowedNotificationRestorerProvider, ); final waitBeforeRetry = ref.watch(ageSignalPushSnapshotRetryWaitProvider); final retryGeneration = useState(0); final consecutiveFailures = useRef(0); final previousState = useRef(null); - useEffect( - () { - if (previousState.value != state) { - previousState.value = state; + useEffect(() { + if (previousState.value != state) { + previousState.value = state; + consecutiveFailures.value = 0; + } + if (state == AgeSignalState.restricted) return null; + var cancelled = false; + unawaited(() async { + try { + await restoreNotifications(); consecutiveFailures.value = 0; - } - var cancelled = false; - unawaited(() async { - try { - await (state == AgeSignalState.allowed - ? resumeSnapshot() - : suspendSnapshot()); - consecutiveFailures.value = 0; - } catch (_) { - final delay = ageSignalPushSnapshotRetryDelay( - consecutiveFailures.value, - ); - await waitBeforeRetry(delay); - if (!cancelled) { - consecutiveFailures.value += 1; - retryGeneration.value += 1; - } + } catch (_) { + final delay = ageSignalPushSnapshotRetryDelay( + consecutiveFailures.value, + ); + await waitBeforeRetry(delay); + if (!cancelled) { + consecutiveFailures.value += 1; + retryGeneration.value += 1; } - }()); - return () => cancelled = true; - }, - [ - state, - suspendSnapshot, - resumeSnapshot, - waitBeforeRetry, - retryGeneration.value, - ], - ); + } + }()); + return () => cancelled = true; + }, [state, restoreNotifications, waitBeforeRetry, retryGeneration.value]); return switch (state) { - AgeSignalState.allowed => BuzzPushBootstrap(child: child), AgeSignalState.restricted => _AgeRestrictedPushCleanup(child: child), - AgeSignalState.checking || AgeSignalState.retryableFailure => child, + _ => BuzzPushBootstrap(child: child), }; } } @@ -134,7 +125,6 @@ class _AgeRestrictedPushCleanup extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final communitiesReady = ref.watch(communityListProvider).hasValue; final waitBeforeRetry = ref.watch(ageSignalPushSnapshotRetryWaitProvider); final purgeNotifications = ref.watch( ageRestrictedNotificationPurgerProvider, @@ -143,8 +133,6 @@ class _AgeRestrictedPushCleanup extends HookConsumerWidget { ageRestrictedNotificationMaintenanceScheduleProvider, ); final resumeGeneration = useState(0); - final retryGeneration = useState(0); - final consecutiveFailures = useRef(0); final purgeRetryGeneration = useState(0); final consecutivePurgeFailures = useRef(0); final remainingMaintenancePurges = useRef( @@ -154,9 +142,6 @@ class _AgeRestrictedPushCleanup extends HookConsumerWidget { useEffect(() { final listener = AppLifecycleListener( onResume: () { - if (ref.read(communityListProvider).hasError) { - ref.invalidate(communityListProvider); - } remainingMaintenancePurges.value = ageRestrictedNotificationMaintenancePurgeLimit; resumeGeneration.value += 1; @@ -207,39 +192,6 @@ class _AgeRestrictedPushCleanup extends HookConsumerWidget { ], ); - useEffect( - () { - var cancelled = false; - if (communitiesReady) { - unawaited(() async { - try { - await ref - .read(communityListProvider.notifier) - .enforceAgeRestrictionOnPush(); - consecutiveFailures.value = 0; - } catch (error, stackTrace) { - reportPushLeaseCleanupError(error, stackTrace); - final delay = ageSignalPushSnapshotRetryDelay( - consecutiveFailures.value, - ); - await waitBeforeRetry(delay); - if (!cancelled) { - consecutiveFailures.value += 1; - retryGeneration.value += 1; - } - } - }()); - } - return () => cancelled = true; - }, - [ - communitiesReady, - waitBeforeRetry, - resumeGeneration.value, - retryGeneration.value, - ], - ); - return child; } } diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 6f50b6be2f..ecf61dd106 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -94,6 +94,8 @@ final identityExportClockProvider = Provider((ref) { class PairingNotifier extends Notifier { final PairingSocketFactory _socketFactory; final PairingCredentialValidator? _credentialValidator; + final RelaySocketFactory _validationSocketFactory; + RelaySocket? _validationSocket; PairingSocket? _socket; Timer? _sessionTimeout; Community? _identityExportCommunity; @@ -102,8 +104,10 @@ class PairingNotifier extends Notifier { PairingNotifier({ PairingSocketFactory? socketFactory, PairingCredentialValidator? credentialValidator, + RelaySocketFactory validationSocketFactory = RelaySocket.new, }) : _socketFactory = socketFactory ?? _createPairingSocket, - _credentialValidator = credentialValidator; + _credentialValidator = credentialValidator, + _validationSocketFactory = validationSocketFactory; static PairingSocket _createPairingSocket({ required String wsUrl, @@ -118,7 +122,10 @@ class PairingNotifier extends Notifier { ); @override - PairingState build() => const PairingState(); + PairingState build() { + ref.onDispose(_cleanup); + return const PairingState(); + } Future pair(String rawInput) async { if (state.status == PairingStatus.connecting || @@ -317,6 +324,8 @@ class PairingNotifier extends Notifier { void _cleanup() { _pairingGeneration++; + _validationSocket?.dispose(); + _validationSocket = null; _sessionTimeout?.cancel(); _sessionTimeout = null; _socket?.dispose(); @@ -350,6 +359,7 @@ class PairingNotifier extends Notifier { final Set _processedEventIds = {}; // NIP-AB §Duplicate Event Handling Future _pairNipAb(String uri) async { + final generation = _pairingGeneration; state = const PairingState(status: PairingStatus.connecting); try { @@ -383,11 +393,16 @@ class PairingNotifier extends Notifier { final socket = _socketFactory( wsUrl: relayWsUrl, ephemeralPrivkey: _ephemeralPrivkey!, - onMessage: _handleRelayMessage, - onDisconnected: _handleDisconnected, + onMessage: (message) { + if (generation == _pairingGeneration) _handleRelayMessage(message); + }, + onDisconnected: (error) { + if (generation == _pairingGeneration) _handleDisconnected(error); + }, ); _socket = socket; await socket.connect(); + if (generation != _pairingGeneration) return; if (!socket.isConnected) { throw StateError('Pairing socket did not reach the connected state'); @@ -399,6 +414,7 @@ class PairingNotifier extends Notifier { // 6. Wait briefly for EOSE, then send offer. // (In practice, we send the offer immediately — the relay will buffer it.) await Future.delayed(const Duration(milliseconds: 500)); + if (generation != _pairingGeneration) return; // 7. Build and send the offer event. final offerContent = _encryptMessage({ @@ -435,12 +451,14 @@ class PairingNotifier extends Notifier { } }); } on FormatException catch (e) { + if (generation != _pairingGeneration) return; _cleanup(); state = PairingState( status: PairingStatus.error, errorMessage: 'Invalid pairing code: ${e.message}', ); } catch (e) { + if (generation != _pairingGeneration) return; debugPrint('Pairing connection error: $e'); _cleanup(); state = PairingState( @@ -831,25 +849,28 @@ class PairingNotifier extends Notifier { // ── Legacy buzz:// flow ─────────────────────────────────────────────── Future _pairLegacy(String rawInput) async { + final generation = _pairingGeneration; state = const PairingState(status: PairingStatus.connecting); try { final community = _parseLegacyInput(rawInput); - await _validateCredentials( - relayUrl: community.relayUrl, - nsec: community.nsec, - ); + final validator = _credentialValidator ?? _validateCredentials; + await validator(relayUrl: community.relayUrl, nsec: community.nsec); + if (generation != _pairingGeneration) return; await ref .read(authProvider.notifier) .authenticateWithCommunity(community); + if (generation != _pairingGeneration) return; state = const PairingState(status: PairingStatus.success); } on FormatException catch (e) { + if (generation != _pairingGeneration) return; state = PairingState( status: PairingStatus.error, errorMessage: 'Invalid pairing code: ${e.message}', ); } on RelayException catch (e) { + if (generation != _pairingGeneration) return; state = PairingState( status: PairingStatus.error, errorMessage: @@ -857,6 +878,7 @@ class PairingNotifier extends Notifier { 'Check that the pairing code is valid.', ); } catch (e) { + if (generation != _pairingGeneration) return; state = PairingState( status: PairingStatus.error, errorMessage: @@ -877,16 +899,18 @@ class PairingNotifier extends Notifier { final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; final wsUrl = uri.replace(scheme: scheme).toString(); - final socket = RelaySocket( + final socket = _validationSocketFactory( wsUrl: wsUrl, nsec: nsec, onMessage: (_) {}, onConnected: () {}, onDisconnected: (_) {}, ); + _validationSocket = socket; try { await socket.connect().timeout(const Duration(seconds: 8)); } finally { + if (identical(_validationSocket, socket)) _validationSocket = null; await socket.disconnect(); } } diff --git a/mobile/lib/features/pairing/pairing_socket.dart b/mobile/lib/features/pairing/pairing_socket.dart index 73999dd02d..004f63d6c7 100644 --- a/mobile/lib/features/pairing/pairing_socket.dart +++ b/mobile/lib/features/pairing/pairing_socket.dart @@ -38,6 +38,7 @@ class PairingSocket { Timer? _authResponseTimer; String? _pendingAuthEventId; bool _connected = false; + final Completer _closed = Completer(); PairingSocket({ required String wsUrl, @@ -60,8 +61,15 @@ class PairingSocket { /// Connect and answer a NIP-42 challenge when the relay requires one. Future connect() async { - _channel = _channelFactory(Uri.parse(_wsUrl)); - await _channel!.ready; + if (_closed.isCompleted) { + throw const PairingAuthException('Pairing connection cancelled'); + } + final channel = _channelFactory(Uri.parse(_wsUrl)); + _channel = channel; + await Future.any([channel.ready, _closed.future]); + if (_closed.isCompleted) { + throw const PairingAuthException('Pairing connection cancelled'); + } _authCompleter = Completer(); @@ -82,10 +90,14 @@ class PairingSocket { try { await _authCompleter!.future; + if (_closed.isCompleted) { + throw const PairingAuthException('Pairing connection cancelled'); + } _connected = true; } catch (error) { + final deliberatelyClosed = _closed.isCompleted; await disconnect(); - _onDisconnected(error); + if (!deliberatelyClosed) _onDisconnected(error); rethrow; } finally { _authChallengeTimer?.cancel(); @@ -116,6 +128,7 @@ class PairingSocket { } Future disconnect() async { + _retireConnection(); _connected = false; _subscription?.cancel(); _subscription = null; @@ -129,6 +142,7 @@ class PairingSocket { } void dispose() { + _retireConnection(); _connected = false; _subscription?.cancel(); _channel?.sink.close(); @@ -137,6 +151,12 @@ class PairingSocket { _authResponseTimer?.cancel(); } + void _retireConnection() { + if (!_closed.isCompleted) _closed.complete(); + final auth = _authCompleter; + if (auth != null && !auth.isCompleted) auth.complete(); + } + void _failAuth(Object? error) { final authError = error ?? Exception('Connection closed'); if (_authCompleter != null && !_authCompleter!.isCompleted) { diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 1af81bc600..525bc6956f 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -80,69 +80,17 @@ final communityStorageProvider = Provider((ref) { typedef CommunitySnapshotWriter = Future Function(List communities); -typedef AgeGateCommunitySnapshotWriter = - Future Function( - List communities, { - required bool settleFence, - }); - -/// Writes the complete persisted community set to storage shared with the iOS -/// notification service extension. Tests override this provider to verify that -/// every persistence path refreshes (or clears) the native snapshot. +/// Exports persisted communities to the notification service extension. final communitySnapshotWriterProvider = Provider(( ref, ) { return registerBuzzPushCommunitySnapshot; }); -/// Strict writer used only for security-sensitive launch age-gate transitions. -final ageGateCommunitySnapshotWriterProvider = - Provider((ref) { - return registerBuzzPushCommunitySnapshotStrict; - }); - final _communitySnapshotSyncProvider = Provider<_CommunitySnapshotSync>((ref) { - return _CommunitySnapshotSync( - ref.read(communitySnapshotWriterProvider), - ref.read(ageGateCommunitySnapshotWriterProvider), - ); + return _CommunitySnapshotSync(ref.read(communitySnapshotWriterProvider)); }); -/// Suspends or restores notification-service state at the launch age gate. -typedef CommunitySnapshotAgeGateAction = Future Function(); - -/// Temporarily removes notification-service presentation material while the -/// launch age signal has not allowed access. -final suspendCommunitySnapshotForAgeCheckProvider = - Provider((ref) { - return () async { - try { - await ref.read(_communitySnapshotSyncProvider).suspendForAgeCheck(); - pushCommunitySnapshotError.value = null; - } catch (error, stackTrace) { - reportPushCommunitySnapshotError(error, stackTrace); - Error.throwWithStackTrace(error, stackTrace); - } - }; - }); - -/// Restores notification-service presentation material after the launch age -/// signal allows access. -final resumeCommunitySnapshotAfterAgeCheckProvider = - Provider((ref) { - return () async { - try { - await ref - .read(_communitySnapshotSyncProvider) - .resumeAfterAgeCheck(ref.read(communityStorageProvider).loadAll); - pushCommunitySnapshotError.value = null; - } catch (error, stackTrace) { - reportPushCommunitySnapshotError(error, stackTrace); - Error.throwWithStackTrace(error, stackTrace); - } - }; - }); - typedef CommunityPushLeaseDeactivator = Future Function(Community community, {int? generation}); @@ -228,13 +176,10 @@ Future _deactivateCommunityPushLease( } class _CommunitySnapshotSync { - _CommunitySnapshotSync(this._writer, this._ageGateWriter); + _CommunitySnapshotSync(this._writer); final CommunitySnapshotWriter _writer; - final AgeGateCommunitySnapshotWriter _ageGateWriter; - ({String content, bool strict, bool settleFence})? _lastSuccessfulSnapshot; - bool _ageRestricted = false; - bool _ageCheckSuspended = false; + String? _lastSuccessfulSnapshot; Future _mutationTail = Future.value(); Future _serializeMutation(Future Function() operation) { @@ -246,51 +191,11 @@ class _CommunitySnapshotSync { return result; } - Future suspendForAgeCheck() => _serializeMutation(() async { - if (!_ageRestricted) _ageCheckSuspended = true; - await _write( - const [], - useAgeGateWriter: true, - settleAgeGateFence: false, - ); - }); + Future write(List communities) => + _serializeMutation(() => _write(communities)); - Future resumeAfterAgeCheck( - Future> Function() loadCommunities, - ) => _serializeMutation(() async { - if (_ageRestricted) return; - final communities = await loadCommunities(); - _ageCheckSuspended = false; - await _write(communities, useAgeGateWriter: true, settleAgeGateFence: true); - }); - - Future write( - List communities, { - bool enforceAgeRestriction = false, - bool useAgeGateWriter = false, - bool settleAgeGateFence = false, - }) { - if (enforceAgeRestriction) _ageRestricted = true; - // Ordinary exports must queue behind the entire restore, including its - // storage read. Otherwise an export can finish during that read and then - // be overwritten by the older list it returned. - return _serializeMutation( - () => _write( - communities, - useAgeGateWriter: useAgeGateWriter, - settleAgeGateFence: settleAgeGateFence, - ), - ); - } - - Future _write( - List communities, { - bool useAgeGateWriter = false, - bool settleAgeGateFence = false, - }) async { - final effectiveCommunities = _ageRestricted || _ageCheckSuspended - ? const [] - : communities; + Future _write(List communities) async { + final effectiveCommunities = communities; final contentFingerprint = effectiveCommunities .map( (community) => [ @@ -306,24 +211,9 @@ class _CommunitySnapshotSync { ].join('\u0000'), ) .join('\u0001'); - // Both paths replace the same native data. Strict writes additionally - // acknowledge the age-gate fence, so their mode is part of the request. - final fingerprint = ( - content: contentFingerprint, - strict: useAgeGateWriter, - settleFence: settleAgeGateFence, - ); - if (fingerprint == _lastSuccessfulSnapshot) return; - - if (useAgeGateWriter) { - await _ageGateWriter( - effectiveCommunities, - settleFence: settleAgeGateFence, - ); - } else { - await _writer(effectiveCommunities); - } - _lastSuccessfulSnapshot = fingerprint; + if (contentFingerprint == _lastSuccessfulSnapshot) return; + await _writer(effectiveCommunities); + _lastSuccessfulSnapshot = contentFingerprint; } } @@ -341,22 +231,6 @@ Future syncStoredCommunitySnapshot(Ref ref) async { await syncCommunitySnapshot(ref, communities); } -Future _enforceAgeRestrictedCommunitySnapshot(Ref ref) async { - try { - await ref - .read(_communitySnapshotSyncProvider) - .write( - const [], - enforceAgeRestriction: true, - useAgeGateWriter: true, - settleAgeGateFence: false, - ); - pushCommunitySnapshotError.value = null; - } catch (error, stackTrace) { - reportPushCommunitySnapshotError(error, stackTrace); - } -} - class CommunityListNotifier extends AsyncNotifier> { Future _pushMutationTail = Future.value(); final Map> _tombstoneAttempts = {}; @@ -623,68 +497,6 @@ class CommunityListNotifier extends AsyncNotifier> { } } - /// Disables every stored push lease after the platform age gate restricts - /// access, and retries journals left pending by an earlier launch. - Future enforceAgeRestrictionOnPush() async { - // Fence every older or later authenticated export before touching storage. - // The final empty write wins even if a stale export is already in I/O. - await _enforceAgeRestrictedCommunitySnapshot(ref); - final attempts = <({String id, bool advanceGeneration})>[]; - await _serializePushMutation(() async { - final storage = ref.read(communityStorageProvider); - final current = state.value ?? await storage.loadAll(); - final updatedList = [...current]; - var changed = false; - - for (var index = 0; index < current.length; index += 1) { - final community = current[index]; - final pending = - community.pushSubscriptionState.pendingTombstoneGeneration; - if (!community.pushNotificationsEnabled) { - if (pending != null) { - attempts.add((id: community.id, advanceGeneration: true)); - } - continue; - } - - var pushState = community.pushSubscriptionState; - if (pushState.acceptedGeneration != null || - pushState.generationCursor != null) { - final cursor = - pushState.generationCursor ?? pushState.acceptedGeneration ?? 0; - pushState = pushState.withPendingTombstone(cursor + 1); - attempts.add((id: community.id, advanceGeneration: false)); - } - final updated = community.copyWith( - pushNotificationsEnabled: false, - pushSubscriptionState: pushState, - ); - updatedList[index] = updated; - changed = true; - } - - if (changed) { - // Persist the complete restricted state in one secure-storage write so - // termination can never leave later communities push-enabled. - await storage.saveAll(updatedList); - state = AsyncData(updatedList); - } - // A failed native clear is retried on the next restricted launch/resume. - await syncCommunitySnapshot(ref, updatedList); - }); - - for (final attempt in attempts) { - try { - await retryPendingPushLeaseTombstone( - attempt.id, - advanceGeneration: attempt.advanceGeneration, - ); - } catch (_) { - // The durable journal remains available for the next launch/resume. - } - } - } - /// Publishes a durably journaled opt-out tombstone. /// /// A retry advances the generation before network I/O. That makes an diff --git a/mobile/lib/shared/huddle/huddle_session.dart b/mobile/lib/shared/huddle/huddle_session.dart index bb96f2aa2c..e08d28c5d1 100644 --- a/mobile/lib/shared/huddle/huddle_session.dart +++ b/mobile/lib/shared/huddle/huddle_session.dart @@ -4,6 +4,7 @@ import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../features/age_gate/age_signal_provider.dart'; import 'huddle_auth.dart'; import 'huddle_media.dart'; import 'huddle_transport.dart'; @@ -184,6 +185,7 @@ final class HuddleSessionNotifier extends Notifier { StreamSubscription? _transportIssueSubscription; StreamSubscription? _peerEventSubscription; var _generation = 0; + bool _ageRestricted = false; var _receivedFrames = 0; var _sentFrames = 0; static const _playbackQueueCapacityPerPeer = 10; @@ -201,6 +203,7 @@ final class HuddleSessionNotifier extends Notifier { @override HuddleSessionState build() { + _ageRestricted = ref.watch(ageSignalProvider) == AgeSignalState.restricted; ref.onDispose(() { _generation += 1; unawaited(_disposeResources()); @@ -214,6 +217,9 @@ final class HuddleSessionNotifier extends Notifier { bool isCreator = false, String? startedEventId, }) async { + if (_ageRestricted) { + throw StateError('Huddle unavailable while age-restricted'); + } if (state.isInSession) { if (state.ephemeralChannelId == parameters.ephemeralChannelId) return; state = state.copyWith( diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 636f40a78d..6500301162 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -273,6 +273,12 @@ Future registerBuzzPushCommunitySnapshotStrict( settleFence: settleFence, ); +/// Restores notification presentation without reading community storage. +Future restoreAgeRestrictedBuzzNotifications() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + await _channel.invokeMethod('restoreAgeRestrictedNotifications'); +} + /// Removes notifications rendered before a confirmed age restriction. Future purgeAgeRestrictedBuzzNotifications() async { if (defaultTargetPlatform != TargetPlatform.iOS) return; diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 6a787cce12..51fb8bf51d 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -10,6 +10,7 @@ import 'package:uuid/uuid.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../features/age_gate/age_signal_provider.dart'; import '../auth/auth.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; @@ -120,9 +121,11 @@ class RelaySessionNotifier extends Notifier { int _reconnectDelayMs = _baseReconnectDelayMs; int _subIdCounter = 0; bool _disposed = false; + bool _ageRestricted = false; bool _paused = false; bool _hasConnectedOnce = false; int _connectionGeneration = 0; + int _contextGeneration = 0; final Map _visibleChannelsByOwner = {}; final Map Function()> _beforePauseCallbacks = {}; bool _socketConnected = false; @@ -132,6 +135,7 @@ class RelaySessionNotifier extends Notifier { SessionState build() { final config = ref.watch(relayConfigProvider); final authState = ref.watch(authProvider); + _ageRestricted = ref.watch(ageSignalProvider) == AgeSignalState.restricted; // Reset disposed flag — build() may re-run on the same Notifier instance // after a provider dependency changes (e.g. auth completing). @@ -141,7 +145,7 @@ class RelaySessionNotifier extends Notifier { // Auto-connect when authenticated and we have a signing key (NIP-42 AUTH). final isAuthenticated = authState.value?.status == AuthStatus.authenticated; - if (isAuthenticated && config.nsec != null) { + if (!_ageRestricted && isAuthenticated && config.nsec != null) { // Schedule connection after build completes. Future.microtask(() => _connect(config)); } @@ -154,6 +158,10 @@ class RelaySessionNotifier extends Notifier { List filters, { Duration timeout = const Duration(seconds: 8), }) async { + if (_disposed || _ageRestricted) { + throw StateError('Relay session is unavailable'); + } + final generation = _contextGeneration; final config = ref.read(relayConfigProvider); final url = Uri.parse(config.baseUrl).resolve('/query').toString(); final bodyBytes = utf8.encode( @@ -175,6 +183,9 @@ class RelaySessionNotifier extends Notifier { body: bodyBytes, timeout: timeout, ); + if (_disposed || _ageRestricted || generation != _contextGeneration) { + throw StateError('Relay query belongs to a retired session'); + } if (response.statusCode < 200 || response.statusCode >= 300) { _activateRateLimitGateFromHttpError(response.body); throw RelayException(response.statusCode, response.body); @@ -473,7 +484,7 @@ class RelaySessionNotifier extends Notifier { } Future _connect(RelayConfig config) async { - if (_disposed) return; + if (_disposed || _ageRestricted) return; final generation = ++_connectionGeneration; state = SessionState( @@ -958,6 +969,7 @@ class RelaySessionNotifier extends Notifier { void _dispose() { _disposed = true; + _contextGeneration++; _beforePauseCallbacks.clear(); _connectionGeneration++; _reconnectTimer?.cancel(); diff --git a/mobile/test/features/age_gate/README.md b/mobile/test/features/age_gate/README.md new file mode 100644 index 0000000000..b8fa485e6f --- /dev/null +++ b/mobile/test/features/age_gate/README.md @@ -0,0 +1,43 @@ +# Age-check contract + +Normal app, relay, and push startup do not depend on an age result. The only +operation that can restrict access is a valid under-18 response from the current +launch request. Errors, malformed responses, timeout, disposal, and late callbacks +leave access allowed. There is one attempt per provider lifecycle, with no +blocking retry or restart flow. + +Production enforcement remains disabled. Dogfood builds can opt in with the +Flutter define `BUZZ_AGE_GATING_ENABLED=true`. This enables the same notifier used +by the native-channel and app tests. No real-device build is a PR prerequisite; +actual OS prompts, signing, and account behavior will be checked in dogfood. + +## Automated evidence + +- `age_signal_provider_test.dart`: real method-channel codec, allowed/under-18 + boundaries, malformed payloads, exceptions, missing plugin, timeout, late + completion, concurrent callers, invalidation, disposal, and the build opt-in. +- `age_gate_app_test.dart`: full app and push bootstrap remain mounted while the + native response is pending or fails, and unmount only for a current under-18 + response. Notification restoration failure does not gate app access. +- Notification restriction leaves saved snapshots, signing keys, preferences, + and remote leases intact. Only the confirming app process holds restriction + authority; a later launch does not need storage restoration to allow access. +- Swift and Kotlin adapter tests: platform-specific bounds are normalized + conservatively, including contradictory ranges and extreme integers. Android + SDK task tests exercise shared access, minor response delivery, both failure + stages, and retired callbacks through the production request coordinator. +- iOS simulator RunnerTests: the production method handlers deliver minor/error + callbacks, allow notification restoration without app-group storage, and + preserve snapshots through restriction, cleanup failure, and restoration. +- Swift notification tests: real cross-process lock contention suppresses + presentation. Process exit releases restriction without cleanup. Missing, stale, + and inaccessible lock files cannot establish a restriction. + +Run the Flutter age-gate suite both with and without the dogfood define. A test +must fail if pending/error becomes restricted or an expired request can restrict. +The implementation was mutation-tested for pending and error blocking. + +An already-running platform consent dialog cannot be proven dismissible by Dart +unit tests. That behavior remains part of dogfood validation before general +re-enablement. Old releases that permanently saved push opt-out preferences did +not record their cause; this change does not guess which opt-outs to reverse. diff --git a/mobile/test/features/age_gate/age_gate_app_test.dart b/mobile/test/features/age_gate/age_gate_app_test.dart index f52aa680dd..5a378fe656 100644 --- a/mobile/test/features/age_gate/age_gate_app_test.dart +++ b/mobile/test/features/age_gate/age_gate_app_test.dart @@ -1,10 +1,19 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:buzz/app.dart'; import 'package:buzz/features/age_gate/age_restriction_page.dart'; import 'package:buzz/features/age_gate/age_signal_push_bootstrap.dart'; import 'package:buzz/features/age_gate/age_signal_provider.dart'; import 'package:buzz/features/channels/unread_badge/unread_badge_provider.dart'; import 'package:buzz/features/home/home_page.dart'; +import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/huddle/huddle.dart'; +import 'package:nostr/nostr.dart' as nostr; +import '../../shared/community/community_storage_test.dart' + show FakeSecureStorage; +import 'package:buzz/shared/push/push_bootstrap.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme_provider.dart'; import 'package:flutter/material.dart'; @@ -24,6 +33,92 @@ void main() { .setMockMethodCallHandler(appBadgeChannel, null); }); + testWidgets( + 'an in-flight credential commit cannot reopen restricted access', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = _PausedCommunityStorage(); + final age = _MutableAgeSignalNotifier(); + var connections = 0; + final relay = RelaySessionNotifier( + socketFactory: + ({ + required wsUrl, + required nsec, + required onMessage, + required onConnected, + required onDisconnected, + }) { + connections++; + throw StateError( + 'Restricted authentication must not open a socket', + ); + }, + ); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + ageSignalProvider.overrideWith(() => age), + relaySessionProvider.overrideWith(() => relay), + pairingProvider.overrideWith( + () => PairingNotifier( + credentialValidator: + ({required relayUrl, required nsec}) async {}, + ), + ), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + final listener = container.listen(relaySessionProvider, (_, _) {}); + addTearDown(listener.close); + await tester.pumpWidget( + UncontrolledProviderScope(container: container, child: const App()), + ); + await tester.pump(); + final code = base64Url.encode( + utf8.encode( + jsonEncode({ + 'relayUrl': 'https://relay.example', + 'nsec': nostr.Keys.generate().nsec, + }), + ), + ); + final pairing = container.read(pairingProvider.notifier).pair(code); + await tester.pump(); + expect(storage.started.isCompleted, isTrue); + age.setState(AgeSignalState.restricted); + await tester.pump(); + expect(find.byType(AgeRestrictionPage), findsOneWidget); + storage.release.complete(); + await pairing; + await tester.pump(); + expect((await storage.loadAll()), hasLength(1)); + expect( + (await container.read(authProvider.future)).status, + AuthStatus.authenticated, + ); + expect(container.read(pairingProvider).status, PairingStatus.idle); + expect(find.byType(AgeRestrictionPage), findsOneWidget); + expect( + container.read(relaySessionProvider).status, + SessionStatus.disconnected, + ); + expect(connections, 0); + expect( + container.read(huddleSessionProvider).phase, + HuddleSessionPhase.idle, + ); + await tester.pumpWidget(const SizedBox.shrink()); + listener.close(); + container.dispose(); + await tester.pump(const Duration(milliseconds: 1)); + }, + ); + test('backs off repeated snapshot transition failures', () { expect(ageSignalPushSnapshotRetryDelay(0), const Duration(seconds: 5)); expect(ageSignalPushSnapshotRetryDelay(1), const Duration(seconds: 10)); @@ -32,6 +127,55 @@ void main() { expect(ageSignalPushSnapshotRetryDelay(100), const Duration(minutes: 5)); }); + for (final restrict in [false, true]) { + testWidgets('pending legacy pairing follows restriction=$restrict', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final age = _MutableAgeSignalNotifier(); + final auth = _RecordingPairAuthNotifier(); + final validation = Completer(); + final pairing = PairingNotifier( + credentialValidator: ({required relayUrl, required nsec}) => + validation.future, + ); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + authProvider.overrideWith(() => auth), + ageSignalProvider.overrideWith(() => age), + pairingProvider.overrideWith(() => pairing), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope(container: container, child: const App()), + ); + await tester.pump(); + final code = base64Url.encode( + utf8.encode( + jsonEncode({ + 'relayUrl': 'https://relay.example', + 'nsec': 'pending-key', + }), + ), + ); + final pending = container.read(pairingProvider.notifier).pair(code); + expect(container.read(pairingProvider).status, PairingStatus.connecting); + if (restrict) age.setState(AgeSignalState.restricted); + await tester.pump(); + validation.complete(); + await pending; + expect(auth.imports, restrict ? 0 : 1); + expect( + container.read(pairingProvider).status, + restrict ? PairingStatus.idle : PairingStatus.success, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + testWidgets('blocks authenticated app content', (tester) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -52,7 +196,7 @@ void main() { expect(find.byType(HomePage), findsNothing); }); - testWidgets('clears the app badge until age access is allowed', ( + testWidgets('clears the app badge only after a confirmed restriction', ( tester, ) async { final badgeCounts = []; @@ -85,13 +229,13 @@ void main() { await tester.pump(); expect(badgeCounts, isNotEmpty); - expect(badgeCounts.last, 0); + expect(badgeCounts.last, 7); ageSignal.setState(AgeSignalState.allowed); await tester.pump(); expect(badgeCounts.last, 7); - ageSignal.setState(AgeSignalState.retryableFailure); + ageSignal.setState(AgeSignalState.restricted); await tester.pump(); expect(badgeCounts.last, 0); }); @@ -101,7 +245,6 @@ void main() { (tester) async { final relaySession = _CountingRelaySessionNotifier(); var requests = 0; - var snapshotSuspensions = 0; var snapshotRestorations = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(ageSignalChannel, (call) { @@ -116,22 +259,12 @@ void main() { overrides: [ authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), relaySessionProvider.overrideWith(() => relaySession), - suspendCommunitySnapshotForAgeCheckProvider.overrideWithValue( - () async { - snapshotSuspensions += 1; - if (snapshotSuspensions == 1) { - throw StateError('injected suspension failure'); - } - }, - ), - resumeCommunitySnapshotAfterAgeCheckProvider.overrideWithValue( - () async { - snapshotRestorations += 1; - if (snapshotRestorations == 1) { - throw StateError('injected restoration failure'); - } - }, - ), + ageAllowedNotificationRestorerProvider.overrideWithValue(() async { + snapshotRestorations += 1; + if (snapshotRestorations == 1) { + throw StateError('injected restoration failure'); + } + }), ageSignalPushSnapshotRetryWaitProvider.overrideWithValue( (_) async {}, ), @@ -143,123 +276,89 @@ void main() { await tester.pump(); await tester.pump(); - expect(snapshotSuspensions, 0); expect(find.bySemanticsLabel('Checking age eligibility'), findsNothing); expect(find.byType(HomePage), findsOneWidget); expect(find.byType(Navigator), findsOneWidget); expect(relaySession.builds, 1); - expect(requests, 0); + expect(requests, ageGatingEnabled ? 1 : 0); await tester.pump(); await tester.pump(); expect(snapshotRestorations, 2); }, ); - testWidgets('offers a retry after the native age check fails', ( - tester, - ) async { - var requests = 0; - var snapshotSuspensions = 0; - var snapshotRestorations = 0; - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), - ageSignalProvider.overrideWith( - () => AgeSignalNotifier( - requestSignal: () async { - requests += 1; - if (requests <= 2) { - throw PlatformException(code: 'unavailable'); - } - return {'status': 'noSignal', 'ageUpper': null}; - }, - delay: (_) async {}, + for (final outcome in ['minor', 'adult', 'error', 'malformed', 'timeout']) { + testWidgets('normal app and push start before native $outcome result', ( + tester, + ) async { + final response = Completer(); + var requests = 0; + var restrictions = 0; + final relaySession = _CountingRelaySessionNotifier(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, (_) { + requests += 1; + return response.future; + }); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), + relaySessionProvider.overrideWith(() => relaySession), + ageSignalProvider.overrideWith( + () => + AgeSignalNotifier(requestTimeout: const Duration(seconds: 1)), ), - ), - suspendCommunitySnapshotForAgeCheckProvider.overrideWithValue( - () async => snapshotSuspensions += 1, - ), - resumeCommunitySnapshotAfterAgeCheckProvider.overrideWithValue( - () async => snapshotRestorations += 1, - ), - savedPrefsProvider.overrideWithValue(prefs), - ], - child: const AgeSignalPushBootstrap(child: App()), - ), - ); - await tester.pump(); - await tester.pump(); + ageAllowedNotificationRestorerProvider.overrideWithValue( + () async {}, + ), + ageRestrictedNotificationPurgerProvider.overrideWithValue(() async { + restrictions++; + }), + savedPrefsProvider.overrideWithValue(prefs), + ], + child: const AgeSignalPushBootstrap(child: App()), + ), + ); + await tester.pump(); + expect(find.byType(HomePage), findsOneWidget); + expect(find.byType(BuzzPushBootstrap), findsOneWidget); + expect(relaySession.builds, 1); + expect(requests, 1); - expect(find.text('Try again'), findsOneWidget); - expect(find.byType(HomePage), findsNothing); - expect(snapshotSuspensions, greaterThanOrEqualTo(1)); - expect(snapshotRestorations, 0); - - await tester.tap(find.text('Try again')); - await tester.pump(); - await tester.pump(); - - expect(requests, 3); - expect(find.text('Try again'), findsNothing); - expect(find.byType(HomePage), findsOneWidget); - expect(snapshotRestorations, 1); - }); - - testWidgets('reloads failed community storage on resume before cleanup', ( - tester, - ) async { - final communities = _RecoveringCommunityListNotifier(); - - await tester.pumpWidget( - ProviderScope( - retry: (_, _) => null, - overrides: [ - ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()), - communityListProvider.overrideWith(() => communities), - ], - child: const AgeSignalPushBootstrap(child: SizedBox()), - ), - ); - await tester.pump(); - - expect(communities.builds, 1); - expect(communities.cleanups, 0); - - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - await tester.pump(); - await tester.pump(); - - expect(communities.builds, 2); - expect(communities.cleanups, 1); - }); - - testWidgets('retries failed restricted push cleanup without an app resume', ( - tester, - ) async { - final communities = _RetryingCleanupCommunityListNotifier(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()), - communityListProvider.overrideWith(() => communities), - ageSignalPushSnapshotRetryWaitProvider.overrideWithValue( - (_) async {}, - ), - ], - child: const AgeSignalPushBootstrap(child: SizedBox()), - ), - ); - await tester.pump(); - await tester.pump(); - - expect(communities.cleanups, 2); - }); + if (outcome == 'timeout') { + await tester.pump(const Duration(seconds: 2)); + response.complete({'status': 'signal', 'ageUpper': 17}); + } else if (outcome == 'error') { + response.completeError(PlatformException(code: 'unavailable')); + } else if (outcome == 'malformed') { + response.complete({'status': 'signal', 'ageUpper': '17'}); + } else { + response.complete({ + 'status': 'signal', + 'ageUpper': outcome == 'minor' ? 17 : 18, + }); + } + await tester.pump(); + await tester.pump(); + final restricted = outcome == 'minor'; + expect(restrictions, restricted ? 1 : 0); + expect( + find.byType(AgeRestrictionPage), + restricted ? findsOneWidget : findsNothing, + ); + expect(find.byType(HomePage), restricted ? findsNothing : findsOneWidget); + expect( + find.byType(BuzzPushBootstrap), + restricted ? findsNothing : findsOneWidget, + ); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + } testWidgets( 'purges restricted notifications before community storage recovers', @@ -295,7 +394,7 @@ void main() { overrides: [ ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()), communityListProvider.overrideWith( - () => _RetryingCleanupCommunityListNotifier(), + () => _UnavailableCommunityListNotifier(), ), ageRestrictedNotificationPurgerProvider.overrideWithValue(() async { purges += 1; @@ -327,7 +426,7 @@ void main() { overrides: [ ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()), communityListProvider.overrideWith( - () => _SuccessfulCleanupCommunityListNotifier(), + () => _UnavailableCommunityListNotifier(), ), ageRestrictedNotificationPurgerProvider.overrideWithValue(() async { purges += 1; @@ -377,7 +476,7 @@ class _UnauthenticatedAuthNotifier extends AuthNotifier { class _MutableAgeSignalNotifier extends AgeSignalNotifier { @override - AgeSignalState build() => AgeSignalState.checking; + AgeSignalState build() => AgeSignalState.allowed; @override Future request() async {} @@ -403,49 +502,31 @@ class _CountingRelaySessionNotifier extends RelaySessionNotifier { } } -class _RecoveringCommunityListNotifier extends CommunityListNotifier { - int builds = 0; - int cleanups = 0; - - @override - Future> build() async { - builds += 1; - if (builds == 1) throw StateError('secure storage unavailable'); - return const []; - } - - @override - Future enforceAgeRestrictionOnPush() async { - cleanups += 1; - } -} - -class _RetryingCleanupCommunityListNotifier extends CommunityListNotifier { - int cleanups = 0; - - @override - Future> build() async => const []; - - @override - Future enforceAgeRestrictionOnPush() async { - cleanups += 1; - if (cleanups == 1) { - throw StateError('injected restricted cleanup failure'); - } - } -} - -class _SuccessfulCleanupCommunityListNotifier extends CommunityListNotifier { - @override - Future> build() async => const []; - - @override - Future enforceAgeRestrictionOnPush() async {} -} - class _UnavailableCommunityListNotifier extends CommunityListNotifier { @override Future> build() async { throw StateError('secure storage unavailable'); } } + +class _RecordingPairAuthNotifier extends _UnauthenticatedAuthNotifier { + int imports = 0; + + @override + Future authenticateWithCommunity(Community community) async { + imports += 1; + } +} + +class _PausedCommunityStorage extends CommunityStorage { + _PausedCommunityStorage() : super(secure: FakeSecureStorage()); + final started = Completer(); + final release = Completer(); + + @override + Future save(Community community) async { + started.complete(); + await release.future; + await super.save(community); + } +} diff --git a/mobile/test/features/age_gate/age_signal_provider_test.dart b/mobile/test/features/age_gate/age_signal_provider_test.dart index 65df38acc1..5444cd0e34 100644 --- a/mobile/test/features/age_gate/age_signal_provider_test.dart +++ b/mobile/test/features/age_gate/age_signal_provider_test.dart @@ -7,553 +7,194 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(ageSignalChannel, null); }); - test( - 'production launch allows access without requesting a native age signal', - () async { - var nativeCalls = 0; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(ageSignalChannel, (_) async { - nativeCalls += 1; - throw PlatformException(code: 'unavailable'); - }); - final container = ProviderContainer(); - addTearDown(container.dispose); - - expect(container.read(ageSignalProvider), AgeSignalState.allowed); - await container.read(ageSignalProvider.notifier).request(); - await container.read(ageSignalProvider.notifier).request(); - - expect(container.read(ageSignalProvider), AgeSignalState.allowed); - expect(nativeCalls, 0); - }, - ); - - Future requestWithResponse(Object? response) async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(ageSignalChannel, (call) async { - expect(call.method, 'requestAgeSignal'); - expect(call.arguments, isNull); - return response; - }); + ProviderContainer enabledContainer({AgeSignalNotifier Function()? create}) { final container = ProviderContainer( - overrides: [ageSignalProvider.overrideWith(AgeSignalNotifier.new)], + overrides: [ + ageSignalProvider.overrideWith(create ?? AgeSignalNotifier.new), + ], ); addTearDown(container.dispose); - - await container.read(ageSignalProvider.notifier).request(); - return container.read(ageSignalProvider); + return container; } - test( - 'notification protection failure stays gated until a successful retry', - () async { - var protected = false; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(ageSignalChannel, (call) async { - expect(call.method, 'requestAgeSignal'); - if (!protected) { - throw PlatformException( - code: 'age_signal_notification_protection_failed', - ); - } - return {'status': 'noSignal', 'ageUpper': null}; - }); - final container = ProviderContainer( - overrides: [ - ageSignalProvider.overrideWith( - () => AgeSignalNotifier(delay: (_) async {}), - ), - ], - ); - addTearDown(container.dispose); - final notifier = container.read(ageSignalProvider.notifier); + test('production provider respects the explicit dogfood opt-in', () async { + var calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, (_) async { + calls += 1; + return {'status': 'signal', 'ageUpper': 17}; + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + await container.read(ageSignalProvider.notifier).request(); + expect( + container.read(ageSignalProvider), + ageGatingEnabled ? AgeSignalState.restricted : AgeSignalState.allowed, + ); + expect(calls, ageGatingEnabled ? 1 : 0); + }); - await notifier.request(); - expect( - container.read(ageSignalProvider), - AgeSignalState.retryableFailure, - ); - protected = true; - await notifier.request(); + for (final upper in [-100, -1, 0, 1, 12, 16, 17, 18, 19, 120, 999999]) { + test('native inclusive upper bound $upper', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + ageSignalChannel, + (_) async => {'status': 'signal', 'ageUpper': upper}, + ); + final container = enabledContainer(); expect(container.read(ageSignalProvider), AgeSignalState.allowed); - }, - ); - - for (final failure in ['missing', 'malformed', 'timeout']) { - test('failed native recovery stays retryable: $failure', () async { - TestWidgetsFlutterBinding.ensureInitialized(); - var requests = 0; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(ageSignalChannel, (call) async { - if (call.method == 'requestAgeSignal') { - requests += 1; - return Completer().future; - } - if (call.method == 'cancelAgeSignalRequest') return false; - if (failure == 'malformed') return 'invalid'; - if (failure == 'timeout') return Completer().future; - throw MissingPluginException(); - }); - addTearDown( - () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(ageSignalChannel, null), - ); - final container = ProviderContainer( - overrides: [ - ageSignalProvider.overrideWith( - () => AgeSignalNotifier( - delay: (_) async {}, - requestTimeout: const Duration(milliseconds: 1), - cancellationTimeout: const Duration(milliseconds: 1), - ), - ), - ], - ); - addTearDown(container.dispose); - final notifier = container.read(ageSignalProvider.notifier); - await notifier.request(); - await notifier.request(); - expect(requests, 1); + await container.read(ageSignalProvider.notifier).request(); expect( container.read(ageSignalProvider), - AgeSignalState.retryableFailure, + upper >= 0 && upper < 18 + ? AgeSignalState.restricted + : AgeSignalState.allowed, ); }); } - test('native recovery acknowledgement permits a fresh request', () async { - TestWidgetsFlutterBinding.ensureInitialized(); - var requests = 0; - var resets = 0; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(ageSignalChannel, (call) async { - if (call.method == 'requestAgeSignal') { - requests += 1; - if (requests == 1) return Completer().future; - return {'status': 'signal', 'ageUpper': 17}; - } - if (call.method == 'cancelAgeSignalRequest') return false; - if (call.method == 'restartForAgeSignal') { - resets += 1; - return true; - } - throw MissingPluginException(); - }); - addTearDown( - () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(ageSignalChannel, null), - ); - final container = ProviderContainer( - overrides: [ - ageSignalProvider.overrideWith( - () => AgeSignalNotifier( - delay: (_) async {}, - requestTimeout: const Duration(milliseconds: 1), - ), + final invalid = [ + null, + 'wrong envelope', + ['signal', 17], + {}, + {'status': 'signal'}, + {'ageUpper': 17}, + {'status': 'unknown', 'ageUpper': 17}, + {'status': 'noSignal', 'ageUpper': 17}, + {'status': 'signal', 'ageUpper': '17'}, + {'status': 'signal', 'ageUpper': 17.0}, + {'status': 'signal', 'ageUpper': true}, + {'status': 'signal', 'ageUpper': null}, + {'status': 'signal', 'ageUpper': 17, 'unexpected': true}, + ]; + for (var index = 0; index < invalid.length; index++) { + test('malformed or unknown native response $index allows access', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + ageSignalChannel, + (_) async => invalid[index], + ); + final container = enabledContainer(); + await container.read(ageSignalProvider.notifier).request(); + expect(container.read(ageSignalProvider), AgeSignalState.allowed); + }); + } + + for (final error in [ + PlatformException(code: 'unavailable'), + MissingPluginException(), + StateError('synchronous failure'), + FormatException('bad codec'), + ArgumentError('unexpected integration error'), + ]) { + test('${error.runtimeType} preserves access and is not retried', () async { + var calls = 0; + final container = enabledContainer( + create: () => AgeSignalNotifier( + requestSignal: () { + calls++; + throw error; + }, ), - ], - ); - addTearDown(container.dispose); - final notifier = container.read(ageSignalProvider.notifier); - await notifier.request(); - expect(container.read(ageSignalProvider), AgeSignalState.retryableFailure); - await notifier.request(); - expect(resets, 1); - expect(requests, 2); - expect(container.read(ageSignalProvider), AgeSignalState.restricted); + ); + final notifier = container.read(ageSignalProvider.notifier); + await notifier.request(); + await notifier.request(); + expect(container.read(ageSignalProvider), AgeSignalState.allowed); + expect(calls, 1); + }); + } + + test('platform exception crosses the real channel and fails open', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, (_) async { + throw PlatformException( + code: 'age_signal_notification_protection_failed', + ); + }); + final container = enabledContainer(); + await container.read(ageSignalProvider.notifier).request(); + expect(container.read(ageSignalProvider), AgeSignalState.allowed); }); - test('blocks when the signal upper bound is 17', () async { - expect( - await requestWithResponse({'status': 'signal', 'ageUpper': 17}), - AgeSignalState.restricted, - ); - }); - - test('allows when the signal upper bound is 18', () async { - expect( - await requestWithResponse({'status': 'signal', 'ageUpper': 18}), - AgeSignalState.allowed, - ); - }); - - test('allows when a signal has an open-ended upper bound', () async { - expect( - await requestWithResponse({'status': 'signal', 'ageUpper': null}), - AgeSignalState.allowed, - ); - }); - - test('allows when no signal is available', () async { - expect( - await requestWithResponse({'status': 'noSignal', 'ageUpper': null}), - AgeSignalState.allowed, - ); - }); - - test('exposes a gated retry after exhausted platform failures', () async { - var requests = 0; - var delays = 0; - final provider = NotifierProvider( - () => AgeSignalNotifier( - requestSignal: () async { - requests += 1; - if (requests <= 2) { - throw PlatformException(code: 'unavailable'); - } - return {'status': 'noSignal', 'ageUpper': null}; - }, - delay: (duration) async { - expect(duration, ageSignalRetryDelay); - delays += 1; - }, - ), - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(provider.notifier).request(); - - expect(container.read(provider), AgeSignalState.retryableFailure); - expect(requests, 2); - expect(delays, 1); - - await container.read(provider.notifier).request(); - - expect(container.read(provider), AgeSignalState.allowed); - expect(requests, 3); - expect(delays, 1); - }); - - test('retries a transient platform failure and applies the signal', () async { - var requests = 0; - final provider = NotifierProvider( - () => AgeSignalNotifier( - requestSignal: () async { - requests += 1; - if (requests == 1) { - throw PlatformException(code: 'age_signal_unavailable'); - } - return {'status': 'signal', 'ageUpper': 17}; - }, - delay: (duration) async { - expect(duration, ageSignalRetryDelay); - }, - ), - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(provider.notifier).request(); - - expect(container.read(provider), AgeSignalState.restricted); - expect(requests, 2); + test('missing native channel fails open', () async { + final container = enabledContainer(); + await container.read(ageSignalProvider.notifier).request(); + expect(container.read(ageSignalProvider), AgeSignalState.allowed); }); test( - 'times out a stalled single native request and exposes a retry', + 'pending request allows access and concurrent callers share it', () async { - var requests = 0; - var delays = 0; - final provider = NotifierProvider( - () => AgeSignalNotifier( + final response = Completer?>(); + var calls = 0; + final container = enabledContainer( + create: () => AgeSignalNotifier( requestSignal: () { - requests += 1; - return Completer?>().future; + calls++; + return response.future; }, - delay: (duration) async { - expect(duration, ageSignalRetryDelay); - delays += 1; - }, - requestTimeout: const Duration(milliseconds: 1), - cancelSignal: () async => false, ), ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(provider.notifier).request(); - - expect(container.read(provider), AgeSignalState.retryableFailure); - expect(requests, 1); - expect(delays, 1); + final notifier = container.read(ageSignalProvider.notifier); + final first = notifier.request(); + final second = notifier.request(); + expect(container.read(ageSignalProvider), AgeSignalState.allowed); + response.complete({'status': 'signal', 'ageUpper': 17}); + await Future.wait([first, second]); + expect(calls, 1); + expect(container.read(ageSignalProvider), AgeSignalState.restricted); }, ); - test('a retry consumes the late result from a timed-out request', () async { - var requests = 0; - var delays = 0; + test('timeout retires request even when late result is under 18', () async { final response = Completer?>(); - final provider = NotifierProvider( - () => AgeSignalNotifier( - requestSignal: () { - requests += 1; - return response.future; - }, - delay: (duration) async { - expect(duration, ageSignalRetryDelay); - delays += 1; - response.complete({'status': 'signal', 'ageUpper': 17}); - }, - requestTimeout: const Duration(milliseconds: 1), - cancelSignal: () async => false, + final container = enabledContainer( + create: () => AgeSignalNotifier( + requestSignal: () => response.future, + requestTimeout: Duration.zero, ), ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(provider.notifier).request(); - - expect(container.read(provider), AgeSignalState.restricted); - expect(requests, 1); - expect(delays, 1); - }); - - test('a deliberate retry replaces an exhausted stalled request', () async { - var requests = 0; - var cancellations = 0; - final provider = NotifierProvider( - () => AgeSignalNotifier( - requestSignal: () { - requests += 1; - if (requests == 1) { - return Completer?>().future; - } - return Future.value({'status': 'noSignal', 'ageUpper': null}); - }, - delay: (_) async {}, - cancelSignal: () async { - cancellations += 1; - return true; - }, - requestTimeout: const Duration(milliseconds: 1), - ), - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(provider.notifier).request(); - expect(container.read(provider), AgeSignalState.retryableFailure); - await container.read(provider.notifier).request(); - - expect(container.read(provider), AgeSignalState.allowed); - expect(requests, 2); - expect(cancellations, 1); - }); - - test('an uncancellable stalled request remains the single flight', () async { - var requests = 0; - var restarts = 0; - final provider = NotifierProvider( - () => AgeSignalNotifier( - requestSignal: () { - requests += 1; - return Completer?>().future; - }, - delay: (_) async {}, - cancelSignal: () async => false, - restartSignal: () async { - restarts += 1; - return false; - }, - requestTimeout: const Duration(milliseconds: 1), - ), - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(provider.notifier).request(); - await container.read(provider.notifier).request(); - - expect(container.read(provider), AgeSignalState.retryableFailure); - expect(requests, 1); - expect(restarts, 1); - }); - - test('a stalled cancellation still exposes the retry action', () async { - var cancellations = 0; - var restarts = 0; - final provider = NotifierProvider( - () => AgeSignalNotifier( - requestSignal: () => Completer?>().future, - delay: (_) async {}, - cancelSignal: () { - cancellations += 1; - return Completer().future; - }, - restartSignal: () async { - restarts += 1; - return false; - }, - requestTimeout: const Duration(milliseconds: 1), - cancellationTimeout: const Duration(milliseconds: 1), - ), - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(provider.notifier).request(); - expect(container.read(provider), AgeSignalState.retryableFailure); - await container.read(provider.notifier).request(); - - expect(cancellations, 1); - expect(restarts, 1); - }); - - test('a malformed cancellation still exposes the retry action', () async { - final provider = NotifierProvider( - () => AgeSignalNotifier( - requestSignal: () => Completer?>().future, - delay: (_) async {}, - cancelSignal: () async { - final dynamic malformed = 'not-a-boolean'; - return malformed; - }, - requestTimeout: const Duration(milliseconds: 1), - ), - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(provider.notifier).request(); - - expect(container.read(provider), AgeSignalState.retryableFailure); - }); - - test('keeps a missing native channel gated and retryable', () async { - var requests = 0; - final provider = NotifierProvider( - () => AgeSignalNotifier( - requestSignal: () async { - requests += 1; - throw MissingPluginException('buzz/age_signal'); - }, - delay: (duration) async { - expect(duration, ageSignalRetryDelay); - }, - ), - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(provider.notifier).request(); - - expect(container.read(provider), AgeSignalState.retryableFailure); - expect(requests, 2); - }); - - test('keeps malformed platform responses gated and retryable', () async { - expect( - await requestWithResponse({'status': 'unknown', 'ageUpper': null}), - AgeSignalState.retryableFailure, - ); - expect( - await requestWithResponse({'status': 'signal', 'ageUpper': '17'}), - AgeSignalState.retryableFailure, - ); - expect( - await requestWithResponse({ - 'status': 'signal', - 'ageUpper': 17, - 'ageLower': 13, - }), - AgeSignalState.retryableFailure, - ); - expect( - await requestWithResponse({'status': 'noSignal', 'ageUpper': 17}), - AgeSignalState.retryableFailure, - ); - expect(await requestWithResponse(null), AgeSignalState.retryableFailure); - expect( - await requestWithResponse(['not', 'a', 'map']), - AgeSignalState.retryableFailure, - ); - }); - - test('a deliberate retry can recover from a malformed response', () async { - var requests = 0; - final provider = NotifierProvider( - () => AgeSignalNotifier( - requestSignal: () async { - requests += 1; - return requests == 1 - ? {'status': 'unknown', 'ageUpper': null} - : {'status': 'noSignal', 'ageUpper': null}; - }, - ), - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(provider.notifier).request(); - expect(container.read(provider), AgeSignalState.retryableFailure); - - await container.read(provider.notifier).request(); - expect(container.read(provider), AgeSignalState.allowed); - expect(requests, 2); - }); - - test('a deliberate retry can recover from a null response', () async { - var requests = 0; - final provider = NotifierProvider( - () => AgeSignalNotifier( - requestSignal: () async { - requests += 1; - return requests == 1 - ? null - : {'status': 'noSignal', 'ageUpper': null}; - }, - ), - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - await container.read(provider.notifier).request(); - expect(container.read(provider), AgeSignalState.retryableFailure); - - await container.read(provider.notifier).request(); - expect(container.read(provider), AgeSignalState.allowed); - expect(requests, 2); - }); - - test('requests the signal at most once', () async { - var requests = 0; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(ageSignalChannel, (_) async { - requests += 1; - return {'status': 'noSignal', 'ageUpper': null}; - }); - final container = ProviderContainer( - overrides: [ageSignalProvider.overrideWith(AgeSignalNotifier.new)], - ); - addTearDown(container.dispose); final notifier = container.read(ageSignalProvider.notifier); - await notifier.request(); + expect(container.read(ageSignalProvider), AgeSignalState.allowed); + response.complete({'status': 'signal', 'ageUpper': 17}); + await response.future; await notifier.request(); - - expect(requests, 1); + expect(container.read(ageSignalProvider), AgeSignalState.allowed); }); - test('remains checking until the platform request completes', () async { + test('invalidating a provider retires the previous request', () async { final response = Completer?>(); - final provider = NotifierProvider( - () => AgeSignalNotifier(requestSignal: () => response.future), + final container = enabledContainer( + create: () => AgeSignalNotifier(requestSignal: () => response.future), ); - final container = ProviderContainer(); - addTearDown(container.dispose); - - final request = container.read(provider.notifier).request(); - - expect(container.read(provider), AgeSignalState.checking); - response.complete({'status': 'noSignal', 'ageUpper': null}); + final request = container.read(ageSignalProvider.notifier).request(); + container.invalidate(ageSignalProvider); + expect(container.read(ageSignalProvider), AgeSignalState.allowed); + response.complete({'status': 'signal', 'ageUpper': 17}); + await request; + expect(container.read(ageSignalProvider), AgeSignalState.allowed); + }); + + test('disposal retires the request without an unhandled error', () async { + final response = Completer?>(); + final container = ProviderContainer( + overrides: [ + ageSignalProvider.overrideWith( + () => AgeSignalNotifier(requestSignal: () => response.future), + ), + ], + ); + final request = container.read(ageSignalProvider.notifier).request(); + container.dispose(); + response.complete({'status': 'signal', 'ageUpper': 17}); await request; - expect(container.read(provider), AgeSignalState.allowed); }); } diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index 335ec09f53..9223ef9035 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -14,23 +14,8 @@ import 'package:buzz/shared/crypto/nip44.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/security/sensitive_action_authorizer.dart'; -/// Tests for [PairingNotifier]'s legacy `buzz://` payload parsing and -/// SSRF-prevention validation. -/// -/// The pairing flow used to validate by calling `GET /api/users/me/profile` -/// over HTTP. That has been replaced with a NIP-42 WebSocket handshake via -/// [RelaySocket], which is constructed directly inside the provider with no -/// dependency-injection hook — so the "happy path" that exercises the -/// network is no longer mockable in a unit test. -/// -/// What we still cover here: -/// - Initial state. -/// - Parsing every documented payload format (raw base64, `buzz://` -/// prefix, whitespace). -/// - Failure modes that return BEFORE any network call: invalid base64, -/// wrong shape (non-object, missing fields, missing nsec), and SSRF -/// guards (private IPs, non-http schemes). -/// - `reset()` returning to idle from an error state. +/// Exercises payload validation, credential import, and cancellation across +/// pairing and credential-validation socket lifetimes. void main() { group('PairingNotifier', () { late ProviderContainer container; @@ -45,6 +30,45 @@ void main() { tearDown(() => container.dispose()); + test( + 'reset closes legacy validation and rejects its late success', + () async { + final socket = _PendingValidationSocket(); + final auth = FakeAuthNotifier(); + final notifier = PairingNotifier( + validationSocketFactory: + ({ + required wsUrl, + required nsec, + required onMessage, + required onConnected, + required onDisconnected, + }) => socket, + ); + container = ProviderContainer( + overrides: [ + pairingProvider.overrideWith(() => notifier), + authProvider.overrideWith(() => auth), + ], + ); + final input = base64Url.encode( + utf8.encode( + jsonEncode({ + 'relayUrl': 'https://relay.example', + 'nsec': 'pending-key', + }), + ), + ); + final pending = container.read(pairingProvider.notifier).pair(input); + notifier.reset(); + expect(socket.disposed, isTrue); + socket.connection.complete(); + await pending; + expect(auth.lastCommunity, isNull); + expect(container.read(pairingProvider).status, PairingStatus.idle); + }, + ); + test('starts in idle state', () { container = createContainer(); final state = container.read(pairingProvider); @@ -77,7 +101,7 @@ void main() { expect(container.read(pairingProvider).status, PairingStatus.error); expect( container.read(pairingProvider).errorMessage, - contains('internal error'), + contains('Lost connection to pairing relay'), ); }, ); @@ -314,6 +338,25 @@ void main() { ); }); + test('reset during NIP-AB connection retires its continuation', () async { + final pending = notifier.pair(pairingCode); + notifier.reset(); + await pending; + expect(container.read(pairingProvider).status, PairingStatus.idle); + expect(socket.isConnected, isFalse); + expect(socket.decryptedPublishedMessages(sourceSecret), isEmpty); + }); + + test('reset during NIP-AB offer delay prevents a late offer', () async { + final pending = notifier.pair(pairingCode); + await Future.delayed(Duration.zero); + notifier.reset(); + await pending; + expect(container.read(pairingProvider).status, PairingStatus.idle); + expect(socket.isConnected, isFalse); + expect(socket.decryptedPublishedMessages(sourceSecret), isEmpty); + }); + test('stale biometric approval cannot advance a reset import', () async { final pendingAuthorization = Completer(); authorizer.pending = pendingAuthorization; @@ -1104,3 +1147,22 @@ class _ControllableSocket extends PairingSocket { relayMessageCallback(['EVENT', 'pair', event.toMap()]); } } + +class _PendingValidationSocket extends RelaySocket { + _PendingValidationSocket() + : super( + wsUrl: 'wss://relay.example', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) {}, + ); + final connection = Completer(); + bool disposed = false; + @override + Future connect() => connection.future; + @override + void dispose() => disposed = true; + @override + Future disconnect() async => disposed = true; +} diff --git a/mobile/test/features/pairing/pairing_socket_test.dart b/mobile/test/features/pairing/pairing_socket_test.dart index ce785009b8..99e3fe44de 100644 --- a/mobile/test/features/pairing/pairing_socket_test.dart +++ b/mobile/test/features/pairing/pairing_socket_test.dart @@ -11,6 +11,38 @@ const _privateKey = void main() { group('PairingSocket', () { + for (final ready in [false, true]) { + test('dispose settles connection while channel ready=$ready', () async { + final readiness = Completer(); + final channel = _ControlledWebSocketChannel( + readiness: readiness.future, + ); + var disconnected = 0; + final socket = _socket( + 'ws://unused', + channelFactory: (_) => channel, + onDisconnected: (_) => disconnected++, + ); + final connecting = socket.connect(); + final cancelled = expectLater( + connecting, + throwsA(isA()), + ); + if (ready) { + readiness.complete(); + await Future.delayed(Duration.zero); + channel.emit(jsonEncode(['AUTH', 'pending-auth'])); + await Future.delayed(Duration.zero); + } + socket.dispose(); + await cancelled.timeout(const Duration(seconds: 1)); + if (!ready) readiness.complete(); + await Future.delayed(Duration.zero); + expect(socket.isConnected, isFalse); + expect(disconnected, 0); + }); + } + test('connects when the pairing relay sends no AUTH challenge', () async { final server = await _TestRelay.start((_) {}); addTearDown(server.close); @@ -195,6 +227,11 @@ PairingSocket _socket( ); class _ControlledWebSocketChannel implements WebSocketChannel { + _ControlledWebSocketChannel({Future? readiness}) + : _readiness = readiness ?? Future.value(); + + final Future _readiness; + void emit(String message) => _streamController.add(message); final StreamController _streamController = StreamController(); final WebSocketSink _sink = _ControlledWebSocketSink(); @@ -203,7 +240,7 @@ class _ControlledWebSocketChannel implements WebSocketChannel { Future closeStream() => _streamController.close(); @override - Future get ready => Future.value(); + Future get ready => _readiness; @override Stream get stream => _streamController.stream; diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index 59722b91e1..9052040c4b 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:buzz/shared/auth/auth_provider.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; @@ -48,20 +47,10 @@ void main() { snapshots.add(List.of(communities)); } - Future writeAgeGateSnapshot( - List communities, { - required bool settleFence, - }) async { - snapshots.add(List.of(communities)); - } - return ProviderContainer( overrides: [ communityStorageProvider.overrideWithValue(communityStorage), communitySnapshotWriterProvider.overrideWithValue(writeSnapshot), - ageGateCommunitySnapshotWriterProvider.overrideWithValue( - writeAgeGateSnapshot, - ), communityPushLeaseDeactivatorProvider.overrideWithValue(deactivator), communityPushLeaseRevocationEnqueuerProvider.overrideWithValue(( community, @@ -84,93 +73,6 @@ void main() { expect(snapshots, [isEmpty]); }); - test( - 'age gate strict clear is not deduplicated against an ordinary clear', - () async { - final ordinarySnapshots = >[]; - final ageGateSnapshots = >[]; - final settleFenceValues = []; - container = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(communityStorage), - communitySnapshotWriterProvider.overrideWithValue(( - communities, - ) async { - ordinarySnapshots.add(List.of(communities)); - }), - ageGateCommunitySnapshotWriterProvider.overrideWithValue(( - communities, { - required settleFence, - }) async { - ageGateSnapshots.add(List.of(communities)); - settleFenceValues.add(settleFence); - }), - ], - ); - - await container.read(communityListProvider.future); - await container.read(suspendCommunitySnapshotForAgeCheckProvider)(); - - expect(ordinarySnapshots, [isEmpty]); - expect(ageGateSnapshots, [isEmpty]); - expect(settleFenceValues, [isFalse]); - }, - ); - - test( - 'age gate strict clear keeps retrying after restriction becomes final', - () async { - var strictAttempts = 0; - container = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(communityStorage), - communitySnapshotWriterProvider.overrideWithValue((_) async {}), - ageGateCommunitySnapshotWriterProvider.overrideWithValue(( - communities, { - required settleFence, - }) async { - strictAttempts += 1; - expect(communities, isEmpty); - expect(settleFence, isFalse); - if (strictAttempts < 3) { - throw StateError('strict snapshot unavailable'); - } - }), - ], - ); - - await expectLater( - container.read(suspendCommunitySnapshotForAgeCheckProvider)(), - throwsStateError, - ); - await container - .read(communityListProvider.notifier) - .enforceAgeRestrictionOnPush(); - await container.read(suspendCommunitySnapshotForAgeCheckProvider)(); - - expect(strictAttempts, 3); - }, - ); - - test('empty community resume still acknowledges the age fence', () async { - final acknowledgements = []; - container = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(communityStorage), - ageGateCommunitySnapshotWriterProvider.overrideWithValue(( - communities, { - required settleFence, - }) async { - expect(communities, isEmpty); - acknowledgements.add(settleFence); - }), - ], - ); - await container.read(suspendCommunitySnapshotForAgeCheckProvider)(); - await container.read(resumeCommunitySnapshotAfterAgeCheckProvider)(); - expect(acknowledgements, [false, true]); - }); - test('exports migrated communities on startup', () async { final community = Community.create( name: 'Migrated', @@ -506,332 +408,6 @@ void main() { }, ); - test( - 'age restriction clears push state and retries pending leases', - () async { - var failPendingOnce = true; - deactivator = (community, {generation}) async { - deactivatedCommunityIds.add(community.id); - deactivationGenerations.add(generation); - if (community.name == 'Pending' && failPendingOnce) { - failPendingOnce = false; - throw StateError('injected pending failure'); - } - }; - container = createContainer(); - await container.read(communityListProvider.future); - final subscription = BuzzPushSubscription( - filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), - notificationClass: 'default', - ); - final active = - Community.create( - name: 'Active', - relayUrl: 'https://active.example.com', - ).copyWith( - pushNotificationsEnabled: true, - pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( - desired: [subscription], - ).withAccepted(subscriptions: [subscription], generation: 4), - ); - final pending = - Community.create( - name: 'Pending', - relayUrl: 'https://pending.example.com', - ).copyWith( - pushSubscriptionState: - BuzzPushLeaseSubscriptionState.desired( - desired: [subscription], - ) - .withAccepted( - subscriptions: [subscription], - generation: 6, - ) - .withPendingTombstone(7), - ); - final notifier = container.read(communityListProvider.notifier); - await notifier.addCommunity(active); - await notifier.addCommunity(pending); - - await notifier.enforceAgeRestrictionOnPush(); - await notifier.enforceAgeRestrictionOnPush(); - - final stored = await communityStorage.loadAll(); - expect( - stored.every((community) => !community.pushNotificationsEnabled), - isTrue, - ); - expect( - stored.every( - (community) => - community.pushSubscriptionState.pendingTombstoneGeneration == - null, - ), - isTrue, - ); - expect( - snapshots.last.every( - (community) => !community.pushNotificationsEnabled, - ), - isTrue, - ); - expect(deactivatedCommunityIds, [active.id, pending.id, pending.id]); - expect(deactivationGenerations, [5, 8, 9]); - }, - ); - - test( - 'age restriction persists all disabled communities in one write', - () async { - container = createContainer(); - final first = Community.create( - name: 'First', - relayUrl: 'https://first.example.com', - ).copyWith(pushNotificationsEnabled: true); - final second = Community.create( - name: 'Second', - relayUrl: 'https://second.example.com', - ).copyWith(pushNotificationsEnabled: true); - await communityStorage.save(first); - await communityStorage.save(second); - await container.read(communityListProvider.future); - final writesBefore = fakeSecure.writeCount('buzz_communities'); - - await container - .read(communityListProvider.notifier) - .enforceAgeRestrictionOnPush(); - - final stored = await communityStorage.loadAll(); - expect(stored, hasLength(2)); - expect( - stored.every((community) => !community.pushNotificationsEnabled), - isTrue, - ); - expect(fakeSecure.writeCount('buzz_communities') - writesBefore, 1); - }, - ); - - test( - 'age restriction fences a stale authenticated snapshot export', - () async { - final staleWriteStarted = Completer(); - final releaseStaleWrite = Completer(); - final completedSnapshots = >[]; - final community = Community.create( - name: 'Restricted', - relayUrl: 'https://restricted.example.com', - nsec: nostr.Keys.generate().nsec, - ).copyWith(pushNotificationsEnabled: true); - await communityStorage.save(community); - await communityStorage.saveActiveId(community.id); - - container = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(communityStorage), - communitySnapshotWriterProvider.overrideWithValue(( - communities, - ) async { - final captured = List.of(communities); - if (captured.isNotEmpty && !staleWriteStarted.isCompleted) { - staleWriteStarted.complete(); - await releaseStaleWrite.future; - } - completedSnapshots.add(captured); - }), - ageGateCommunitySnapshotWriterProvider.overrideWithValue(( - communities, { - required settleFence, - }) async { - completedSnapshots.add(List.of(communities)); - expect(settleFence, isFalse); - }), - ], - ); - - final staleAuthBuild = container.read(authProvider.future); - await staleWriteStarted.future; - final restriction = container - .read(communityListProvider.notifier) - .enforceAgeRestrictionOnPush(); - - releaseStaleWrite.complete(); - await staleAuthBuild; - await restriction; - - expect( - completedSnapshots.any((snapshot) => snapshot.isNotEmpty), - isTrue, - ); - expect(completedSnapshots.last, isEmpty); - }, - ); - - test( - 'age check suspension clears a stale snapshot and allowed restores it', - () async { - final staleWriteStarted = Completer(); - final releaseStaleWrite = Completer(); - final completedSnapshots = >[]; - final settleFenceValues = []; - final community = Community.create( - name: 'Age gated', - relayUrl: 'https://age-gated.example.com', - nsec: nostr.Keys.generate().nsec, - ).copyWith(pushNotificationsEnabled: true); - await communityStorage.save(community); - - container = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(communityStorage), - communitySnapshotWriterProvider.overrideWithValue(( - communities, - ) async { - final captured = List.of(communities); - if (captured.isNotEmpty && !staleWriteStarted.isCompleted) { - staleWriteStarted.complete(); - await releaseStaleWrite.future; - } - completedSnapshots.add(captured); - }), - ageGateCommunitySnapshotWriterProvider.overrideWithValue(( - communities, { - required settleFence, - }) async { - completedSnapshots.add(List.of(communities)); - settleFenceValues.add(settleFence); - }), - ], - ); - - final staleExport = container.read(communityListProvider.future); - await staleWriteStarted.future; - final suspension = container.read( - suspendCommunitySnapshotForAgeCheckProvider, - )(); - - releaseStaleWrite.complete(); - await staleExport; - await suspension; - - expect(completedSnapshots.last, isEmpty); - expect(settleFenceValues, [isFalse]); - - await container.read(resumeCommunitySnapshotAfterAgeCheckProvider)(); - - expect(completedSnapshots.last.single.id, community.id); - expect(settleFenceValues, [isFalse, isTrue]); - }, - ); - - test( - 'removal clears an ordinary empty snapshot after strict restoration', - () async { - final community = Community.create( - name: 'Restored', - relayUrl: 'https://restored.example.com', - nsec: nostr.Keys.generate().nsec, - ).copyWith(pushNotificationsEnabled: true); - await communityStorage.save(community); - container = createContainer(); - await container.read(suspendCommunitySnapshotForAgeCheckProvider)(); - // Bootstrap loads C while suspension exports an ordinary empty snapshot. - await container.read(communityListProvider.future); - await container.read(resumeCommunitySnapshotAfterAgeCheckProvider)(); - expect(snapshots.last.single.nsec, community.nsec); - - await container - .read(communityListProvider.notifier) - .removeCommunity(community.id); - - expect(await communityStorage.loadAll(), isEmpty); - expect( - snapshots.map((items) => items.map((c) => c.id).toList()).toList(), - [ - [], - [], - [community.id], - [], - ], - ); - expect(snapshots.last, isEmpty); - }, - ); - - test('resume reloads storage after an in-flight snapshot mutation', () async { - final community = Community.create( - name: 'Changing', - relayUrl: 'https://changing.example.com', - nsec: nostr.Keys.generate().nsec, - ).copyWith(pushNotificationsEnabled: true); - await communityStorage.save(community); - final writeStarted = Completer(); - final releaseWrite = Completer(); - final strictSnapshots = >[]; - container = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(communityStorage), - communitySnapshotWriterProvider.overrideWithValue((_) async { - writeStarted.complete(); - await releaseWrite.future; - await communityStorage.save( - community.copyWith(pushNotificationsEnabled: false), - ); - }), - ageGateCommunitySnapshotWriterProvider.overrideWithValue(( - communities, { - required settleFence, - }) async { - strictSnapshots.add(List.of(communities)); - }), - ], - ); - await container.read(suspendCommunitySnapshotForAgeCheckProvider)(); - final bootstrap = container.read(communityListProvider.future); - await writeStarted.future; - final resume = container.read( - resumeCommunitySnapshotAfterAgeCheckProvider, - )(); - // Let resume reach its in-flight-write barrier before completing the mutation. - await Future(() {}); - releaseWrite.complete(); - await bootstrap; - await resume; - expect(strictSnapshots.last.single.pushNotificationsEnabled, isFalse); - }); - - test('removal during resume cannot leave restored credentials', () async { - final controlledStorage = _PausedCommunityStorage(); - communityStorage = controlledStorage; - final community = Community.create( - name: 'Removing', - relayUrl: 'https://removing.example.com', - nsec: nostr.Keys.generate().nsec, - ).copyWith(pushNotificationsEnabled: true); - await communityStorage.save(community); - container = createContainer(); - await container.read(communityListProvider.future); - await container.read(suspendCommunitySnapshotForAgeCheckProvider)(); - - final removal = container - .read(communityListProvider.notifier) - .removeCommunity(community.id); - await controlledStorage.removeStarted.future; - controlledStorage.pauseNextLoad = true; - final resume = container.read( - resumeCommunitySnapshotAfterAgeCheckProvider, - )(); - await controlledStorage.loadStarted.future; - controlledStorage.releaseRemoval.complete(); - await controlledStorage.removalPersisted.future; - // Let the removal submit its native update while resume holds the old list. - await Future(() {}); - controlledStorage.releaseLoad.complete(); - await Future.wait([removal, resume]); - - expect(await communityStorage.loadAll(), isEmpty); - expect(snapshots.last, isEmpty); - }); - test('removeCommunity removes from list', () async { container = createContainer(); await container.read(communityListProvider.future); @@ -1132,34 +708,3 @@ void main() { }); }); } - -class _PausedCommunityStorage extends CommunityStorage { - _PausedCommunityStorage() : super(secure: FakeSecureStorage()); - - final removeStarted = Completer(); - final releaseRemoval = Completer(); - final removalPersisted = Completer(); - final loadStarted = Completer(); - final releaseLoad = Completer(); - bool pauseNextLoad = false; - - @override - Future remove(String id) async { - removeStarted.complete(); - await releaseRemoval.future; - await super.remove(id); - removalPersisted.complete(); - } - - @override - Future> loadAll() async { - final pause = pauseNextLoad; - pauseNextLoad = false; - final communities = await super.loadAll(); - if (pause) { - loadStarted.complete(); - await releaseLoad.future; - } - return communities; - } -} diff --git a/mobile/test/shared/huddle/huddle_session_test.dart b/mobile/test/shared/huddle/huddle_session_test.dart index 650771d8de..9f4a6cb9fa 100644 --- a/mobile/test/shared/huddle/huddle_session_test.dart +++ b/mobile/test/shared/huddle/huddle_session_test.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'dart:typed_data'; import 'package:buzz/shared/huddle/huddle.dart'; +import 'package:buzz/features/age_gate/age_signal_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -12,6 +13,47 @@ const _parentChannelId = '11111111-2222-4333-8444-555555555555'; const _ephemeralChannelId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; void main() { + test( + 'confirmed restriction stops retained Huddle media and transport', + () async { + final age = _MutableAgeNotifier(); + final media = _FakeMedia(); + final transport = _FakeTransport(); + final container = ProviderContainer( + overrides: [ + ageSignalProvider.overrideWith(() => age), + huddleMediaFactoryProvider.overrideWithValue(() => media), + huddleTransportFactoryProvider.overrideWithValue((_) => transport), + ], + ); + addTearDown(container.dispose); + final listener = container.listen(huddleSessionProvider, (_, _) {}); + addTearDown(listener.close); + final controller = container.read(huddleSessionProvider.notifier); + await controller.join(_parameters()); + expect(container.read(huddleSessionProvider).isConnected, isTrue); + expect(media.startCalls, 1); + expect(media.disposeCalls, 0); + age.setState(AgeSignalState.restricted); + await Future.delayed(Duration.zero); + expect(media.disposeCalls, 1); + expect(transport.disposeCalls, 1); + expect( + container.read(huddleSessionProvider).phase, + HuddleSessionPhase.idle, + ); + await expectLater(controller.join(_parameters()), throwsStateError); + expect(media.startCalls, 1); + expect(transport.connectCalls, 1); + age.setState(AgeSignalState.allowed); + await Future.delayed(Duration.zero); + await controller.join(_parameters()); + expect(container.read(huddleSessionProvider).isConnected, isTrue); + expect(media.startCalls, 2); + await controller.leave(); + }, + ); + test('joins unmuted and bridges remote and local Opus frames', () async { final media = _FakeMedia(); final transport = _FakeTransport(); @@ -595,3 +637,7 @@ Future _waitUntil(bool Function() predicate) async { } fail('Timed out waiting for asynchronous Huddle state'); } + +class _MutableAgeNotifier extends AgeSignalNotifier { + void setState(AgeSignalState value) => state = value; +} diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index aca113451f..413f12a82e 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -7,10 +7,137 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; +import 'package:buzz/features/age_gate/age_signal_provider.dart'; +import 'package:buzz/features/channels/agent_activity/observer_subscription.dart'; +import 'package:buzz/features/channels/agent_activity/observer_models.dart'; import 'package:buzz/shared/auth/auth_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { + test( + 'confirmed minor tears down retained relay and observer providers', + () async { + final age = _MutableAgeNotifier(); + final sockets = <_ControlledRelaySocket>[]; + final keychain = nostr.Keys.generate(); + var httpCalls = 0; + final pendingResponse = Completer(); + final session = RelaySessionNotifier( + httpClient: http_testing.MockClient((_) { + httpCalls++; + return pendingResponse.future; + }), + socketFactory: + ({ + required wsUrl, + required nsec, + required onMessage, + required onConnected, + required onDisconnected, + }) { + final socket = _ControlledRelaySocket( + wsUrl: wsUrl, + nsec: nsec, + onMessage: onMessage, + onConnected: onConnected, + onDisconnected: onDisconnected, + ); + sockets.add(socket); + return socket; + }, + ); + final container = ProviderContainer( + overrides: [ + ageSignalProvider.overrideWith(() => age), + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + baseUrl: 'https://relay.example', + nsec: keychain.nsec, + ), + ), + authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + // Keep both providers watched even after restriction, as retained caches do. + final relayListener = container.listen(relaySessionProvider, (_, _) {}); + final observerListener = container.listen( + observerRelayProvider, + (_, _) {}, + ); + addTearDown(relayListener.close); + addTearDown(observerListener.close); + await Future.delayed(Duration.zero); + expect(sockets, hasLength(1)); + sockets.single.connectSuccessfully(); + await Future.delayed(Duration.zero); + expect( + container.read(relaySessionProvider).status, + SessionStatus.connected, + ); + expect( + container.read(observerRelayProvider).connection, + isNot(ObserverConnectionState.idle), + ); + + final pendingQuery = session.queryRelay(const [ + NostrFilter(kinds: [1]), + ]); + final retiredQuery = expectLater(pendingQuery, throwsStateError); + await Future.delayed(Duration.zero); + expect(httpCalls, 1); + age.setState(AgeSignalState.restricted); + await Future.delayed(Duration.zero); + expect(sockets.single.disposeCalls, 1); + expect( + container.read(relaySessionProvider).status, + SessionStatus.disconnected, + ); + expect( + container.read(observerRelayProvider).connection, + ObserverConnectionState.idle, + ); + await expectLater( + session.queryRelay(const [ + NostrFilter(kinds: [1]), + ]), + throwsStateError, + ); + expect(httpCalls, 1); + pendingResponse.complete(http.Response('[]', 200)); + await retiredQuery; + await session.reconnect(); + session.onAppResumed(); + sockets.first.disconnectWith(Exception('late old-socket callback')); + await Future.delayed(Duration.zero); + expect(sockets, hasLength(1)); + expect( + container.read(relaySessionProvider).status, + SessionStatus.disconnected, + ); + + age.setState(AgeSignalState.allowed); + await Future.delayed(Duration.zero); + expect(sockets, hasLength(2)); + final reconnectQuery = session.queryRelay(const [ + NostrFilter(kinds: [1]), + ]); + await session.reconnect(); + expect(await reconnectQuery, isEmpty); + expect(httpCalls, 2); + session.debugDispose(); + await expectLater( + session.queryRelay(const [ + NostrFilter(kinds: [1]), + ]), + throwsStateError, + ); + expect(httpCalls, 2); + }, + ); + test('queryRelay sends NIP-98 auth over POST /query', () async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; @@ -22,6 +149,7 @@ void main() { final session = RelaySessionNotifier(httpClient: client); final container = ProviderContainer( overrides: [ + authProvider.overrideWith(() => _PendingAuthNotifier()), relaySessionProvider.overrideWith(() => session), relayConfigProvider.overrideWith( () => _FakeRelayConfigNotifier( @@ -88,6 +216,7 @@ void main() { ); final container = ProviderContainer( overrides: [ + authProvider.overrideWith(() => _PendingAuthNotifier()), relaySessionProvider.overrideWith(() => session), relayConfigProvider.overrideWith( () => _FakeRelayConfigNotifier( @@ -116,6 +245,7 @@ void main() { ); final container = ProviderContainer( overrides: [ + authProvider.overrideWith(() => _PendingAuthNotifier()), relaySessionProvider.overrideWith(() => session), relayConfigProvider.overrideWith( () => _FakeRelayConfigNotifier( @@ -155,6 +285,7 @@ void main() { ); final container = ProviderContainer( overrides: [ + authProvider.overrideWith(() => _PendingAuthNotifier()), relaySessionProvider.overrideWith(() => session), relayConfigProvider.overrideWith( () => _FakeRelayConfigNotifier( @@ -1595,6 +1726,7 @@ _QueryHarness _queryHarness({ final session = RelaySessionNotifier(httpClient: client, rateLimitGate: gate); final container = ProviderContainer( overrides: [ + authProvider.overrideWith(() => _PendingAuthNotifier()), relaySessionProvider.overrideWith(() => session), relayConfigProvider.overrideWith( () => _FakeRelayConfigNotifier( @@ -1750,3 +1882,17 @@ class _ManualTimer implements Timer { @override int get tick => _active ? 0 : 1; } + +class _MutableAgeNotifier extends AgeSignalNotifier { + @override + AgeSignalState build() => AgeSignalState.allowed; + + void setState(AgeSignalState value) => state = value; +} + +// HTTP transport tests hold authentication steady, avoiding unrelated native +// storage initialization and provider retirement while a query is in flight. +class _PendingAuthNotifier extends AuthNotifier { + @override + Future build() => Completer().future; +} From 8953cbfff58ed768d996677fed3af0e3bac64a20 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 18 Sep 2026 05:14:28 -0700 Subject: [PATCH 08/22] fix(desktop): keep managed agent avatars usable across communities (#7732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fix Desktop-managed agents losing avatars after joining another community. The saved persona/instance stores one desired source URL, but authenticated media belongs to a community. Republishing community A’s URL in B does not make that image accessible in B. - Before comparing or publishing kind-0, reuse or copy configured-community avatar media into the pinned destination. Keep exact image bytes, including animation, and leave the saved source unchanged. - Keep the previous profile on transfer/publication failure so existing reconciliation can retry. Reuse destination bytes during source outages; after source removal, reuse only an already-published signed destination picture with the same original content hash. - Refresh source permissions through narrow, serialized IPC without reconnecting or resetting the active community. Startup waits until the latest permission update completes before restoring agents. ### Related issue Refs #2366 (avatar portion only, not runtime availability). Related #2659 deliberately excludes avatar/media mirroring; this change addresses that media boundary without introducing runtime fan-out. Searched existing issues/PRs for `avatar community` before opening. ## Contract and safety Desktop-managed profiles still follow the saved persona source; this does not introduce community-specific persona editing. Agent-managed profiles still opt out of automatic reconciliation. Authenticated transfer requests use the fixed agent key/NIP-OA tag, origin-scoped Blossom auth, and the pinned destination, never mutable active-workspace owner credentials. They refuse redirects, enforce 30-second request timeouts and bounded bodies, verify SHA-256 and detected image MIME, and validate the returned descriptor’s origin/hash/MIME/size. Source permission is rechecked after an awaited destination HEAD miss. The removed-source fallback verifies the destination profile’s signature, kind, signer, origin, and original-media hash without contacting the removed source. Ordinary external images retain passthrough behavior and receive no community credentials. No public-media policy change, image-reader URL rewriting, new persistence, background service, or global profile merge. ## Testing Normal commit and push hooks passed on exact head `925781eece66d5f6a4b084b3b3abd6309cda357e`; working tree clean before and after: - Desktop formatting/lint, typecheck, and **6,508 tests**. - Full native workspace suite, including **3,204 desktop-library tests** (19 ignored), integration tests, terminal crates, and doc tests. - Native Clippy on default and `mesh-llm` workspace/all-target configurations with warnings denied; repository file-size and branch-skew gates. Regression coverage exercises production reconciliation/shared publication against two local HTTP fixtures: add/edit/restart, upload and rejected-publication retry, removed-source local-image preservation, fixed signer/destination, redirect/hash/descriptor/size failures, revocation during pending HEAD, and opt-out no-I/O. Mounted initialization tests cover inactive-list changes without workspace reset, two deferred permission updates, and rejection of either update. Earlier working-tree validation at base `4ab4f786085a23fe6126529861840eff6048ceee` also passed the E2E frontend build and four Chromium mock-bridge community flows (boot, switch, active removal/fallback, leave-final/setup). Subsequent production changes only serialize permission writes and wait for a stable latest update; the complete suites above cover the final commit. Mutations independently removing serialization or stable waiting failed the new regression. ## Remaining validation and limitations - No live production avatar was changed. Native GUI rendering against two hosted communities remains to be verified with a build containing this fix; local HTTP fixtures and the browser mock bridge do not establish that outcome. - A removed/unreachable source with no existing destination copy cannot be recovered automatically. Choose a reachable image/source in that case. - This is a publication/reconciliation fix, not a visual redesign. No before/after native GUI screenshots are claimed. Broad repository CI remains required; it was not duplicated locally. ## Review Independent security/scope and caller/ordering reviews are clear after the bounded fixes. Final hook-ordering reread covered the staged tree that became this commit. Commit authorship/sign-off use the implementing Carl identity and configured Carl signing key. Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/app_state.rs | 4 + .../src-tauri/src/commands/agents_profile.rs | 51 +- desktop/src-tauri/src/commands/media.rs | 2 +- desktop/src-tauri/src/commands/workspace.rs | 14 + desktop/src-tauri/src/egress_guard_tests.rs | 1 + desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/relay.rs | 24 +- desktop/src-tauri/src/relay/profile_avatar.rs | 287 +++++++++ .../src/relay/profile_avatar/tests.rs | 552 ++++++++++++++++++ desktop/src/app/App.tsx | 1 + desktop/src/features/agents/AGENTS.md | 19 + .../communities/communityRelaySet.test.mjs | 45 ++ .../features/communities/communityRelaySet.ts | 26 + .../communities/useCommunityInit.test.mjs | 178 ++++++ .../features/communities/useCommunityInit.ts | 37 +- desktop/src/shared/api/tauriWorkspace.ts | 4 + desktop/src/testing/e2eBridge.ts | 1 + 17 files changed, 1234 insertions(+), 13 deletions(-) create mode 100644 desktop/src-tauri/src/relay/profile_avatar.rs create mode 100644 desktop/src-tauri/src/relay/profile_avatar/tests.rs create mode 100644 desktop/src/features/communities/communityRelaySet.test.mjs create mode 100644 desktop/src/features/communities/communityRelaySet.ts create mode 100644 desktop/src/features/communities/useCommunityInit.test.mjs diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index f1136e8892..3eac0d0816 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -32,6 +32,9 @@ pub struct AppState { /// validated relay origin. pub media_fetch_client: reqwest::Client, pub relay_url_override: Mutex>, + /// User-configured communities, supplied by narrow workspace IPC, never learned + /// from profile URLs. Only these origins may supply portable agent media. + pub agent_avatar_communities: Mutex>, pub workspace_apply_lock: Arc>, pub workspace_apply_generation: AtomicU64, /// Defers managed-agent restore until `apply_workspace` installs relay and identity. @@ -213,6 +216,7 @@ pub fn build_app_state() -> AppState { header across origins (redirect-hop SSRF)", ), relay_url_override: Mutex::new(None), + agent_avatar_communities: Mutex::new(Vec::new()), workspace_apply_lock: Arc::new(AsyncMutex::new(())), workspace_apply_generation: AtomicU64::new(0), managed_agent_restore_pending: AtomicBool::new(false), diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 66d2ae2749..9726593923 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -25,7 +25,8 @@ pub(crate) struct ProfileReconcileData { /// left unpinned (no tenant boundary) resolves the current workspace at /// execution time. See `resolve_reconcile_relay`. pub(crate) target_relay_url: Option, - /// Expected avatar URL for the published profile. `None` for legacy records + /// Saved avatar source, localized to the target community before publishing. + /// `None` for legacy records /// that predate the `avatar_url` field — these will be backfilled from the /// relay's existing kind:0 profile on first reconciliation. pub(crate) avatar_url: Option, @@ -195,7 +196,7 @@ pub(crate) async fn reconcile_agent_profile( agent_pubkey: &str, data: &ProfileReconcileData, ) -> Result { - use crate::relay::{query_agent_profile, sync_managed_agent_profile}; + use crate::relay::query_agent_profile; // Resolved ONCE and used for both the read and the write-back. A pinned // `target_relay_url` wins unconditionally — see `resolve_reconcile_relay`. @@ -260,8 +261,45 @@ pub(crate) async fn reconcile_agent_profile( Some(expected_avatar) }; - if !profile_needs_sync( + reconcile_profile_at( + state, + &relay_url, + data, + expected_avatar.as_deref(), existing.as_ref(), + ) + .await +} + +/// Network half of startup/restore reconciliation, after legacy source backfill. +/// Kept separate from the disk migration so the production compare-and-publish +/// seam can be exercised against two communities without a GUI runtime. +pub(crate) async fn reconcile_profile_at( + state: &AppState, + relay_url: &str, + data: &ProfileReconcileData, + expected_avatar: Option<&str>, + existing: Option<&crate::relay::AgentProfileInfo>, +) -> Result { + if !state + .managed_agent_profile_reconcile_enabled() + .load(std::sync::atomic::Ordering::Acquire) + { + return Ok(ProfileReconcileOutcome::SkippedDisabled); + } + let agent_keys = Keys::parse(&data.private_key_nsec) + .map_err(|e| format!("failed to parse agent keys: {e}"))?; + let expected_avatar = crate::relay::profile_avatar::localize_avatar( + state, + relay_url, + &agent_keys, + expected_avatar, + data.auth_tag.as_deref(), + ) + .await?; + + if !profile_needs_sync( + existing, &data.name, expected_avatar.as_deref(), data.about.as_deref(), @@ -269,9 +307,6 @@ pub(crate) async fn reconcile_agent_profile( return Ok(ProfileReconcileOutcome::Reconciled); } - let agent_keys = Keys::parse(&data.private_key_nsec) - .map_err(|e| format!("failed to parse agent keys: {e}"))?; - if !state .managed_agent_profile_reconcile_enabled() .load(std::sync::atomic::Ordering::Acquire) @@ -279,9 +314,9 @@ pub(crate) async fn reconcile_agent_profile( return Ok(ProfileReconcileOutcome::SkippedDisabled); } - sync_managed_agent_profile( + crate::relay::sync_managed_agent_profile( state, - &relay_url, + relay_url, &agent_keys, &data.name, expected_avatar.as_deref(), diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 8cf8cc4174..7e908d35c5 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -359,7 +359,7 @@ pub(crate) fn mint_media_get_auth(state: &AppState, base_url: &str) -> Option Result<(), String> { .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Refresh avatar source trust without reconnecting or restoring the workspace. +/// Only the user's saved community list may supply these origins. +#[tauri::command] +pub fn set_agent_avatar_communities( + relay_urls: Vec, + state: State<'_, AppState>, +) -> Result<(), String> { + *state + .agent_avatar_communities + .lock() + .map_err(|e| e.to_string())? = relay_urls; + Ok(()) +} + /// Apply a workspace's configuration to the backend session. /// /// Called by the frontend on app init (after reload) to configure the diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index fe053602f9..b6035d2ca3 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -273,6 +273,7 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ ("src/relay_admission.rs", 1, 0), ("src/native_relay_client_transport_tests.rs", 1, 0), ("src/archive/mod_tests.rs", 1, 0), + ("src/relay/profile_avatar/tests.rs", 1, 0), ("src/managed_agents/persona_events/tests.rs", 1, 0), ("src/commands/team_snapshot/tests.rs", 1, 0), // Mock-relay route in its in-file tests; production publish goes through diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 43d7b03857..e8b767e7c5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -834,6 +834,7 @@ pub fn run() { confirm_pairing_sas, cancel_pairing, apply_workspace, + set_agent_avatar_communities, validate_repos_dir, get_active_workspace, fetch_workspace_icon, diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 538fa0098f..e11c33637f 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -535,6 +535,8 @@ fn build_profile_event( .map_err(|e| format!("failed to sign profile event: {e}")) } +pub(crate) mod profile_avatar; + // ── Managed-agent profile sync ────────────────────────────────────────────── /// Sync a managed agent's kind:0 profile event to the relay using NIP-98 auth. @@ -544,6 +546,8 @@ fn build_profile_event( /// description (see `managed_agents::record_effective_description`); the /// relay treats kind:0 /// fields as absolute, so passing `None` clears any previously published about. +/// Configured-community avatar media is copied into this target before signing; +/// transfer failure never replaces the existing profile with a foreign URL. pub async fn sync_managed_agent_profile( state: &AppState, relay_url: &str, @@ -554,8 +558,17 @@ pub async fn sync_managed_agent_profile( auth_tag: Option<&str>, // NIP-OA auth tag JSON ) -> Result<(), String> { crate::relay_admission::wait_for_rate_limit().await; - // Build a signed kind:0 profile event (with optional NIP-OA auth tag). - let event = build_profile_event(agent_keys, display_name, avatar_url, about, auth_tag)?; + // Resolve media before replacing the complete profile. A failed transfer + // leaves the previous kind:0 untouched and the saved source available to retry. + let avatar_url = + profile_avatar::localize_avatar(state, relay_url, agent_keys, avatar_url, auth_tag).await?; + let event = build_profile_event( + agent_keys, + display_name, + avatar_url.as_deref(), + about, + auth_tag, + )?; let event_json = event.as_json(); let body_bytes = event_json.into_bytes(); crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "agent profile sync")?; @@ -564,8 +577,9 @@ pub async fn sync_managed_agent_profile( let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, &url, &body_bytes)?; let mut request = state - .http_client + .media_fetch_client .post(&url) + .timeout(std::time::Duration::from_secs(30)) .header("Authorization", auth) .header("Content-Type", "application/json"); if let Some(tag) = auth_tag { @@ -584,6 +598,10 @@ pub async fn sync_managed_agent_profile( )); } + let result: SubmitEventResponse = parse_json_response(response).await?; + if !result.accepted { + return Err(format!("relay rejected agent profile: {}", result.message)); + } Ok(()) } diff --git a/desktop/src-tauri/src/relay/profile_avatar.rs b/desktop/src-tauri/src/relay/profile_avatar.rs new file mode 100644 index 0000000000..7d6240134c --- /dev/null +++ b/desktop/src-tauri/src/relay/profile_avatar.rs @@ -0,0 +1,287 @@ +//! Materialize an agent avatar in the community receiving its kind:0 profile. +//! The saved source is never replaced with the community-local projection. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use futures_util::StreamExt; +use nostr::{JsonUtil, Keys}; +use sha2::{Digest, Sha256}; + +use crate::{ + app_state::AppState, + commands::media::{ + detect_and_validate_mime, sign_blossom_get_auth_header, sign_blossom_upload_auth, + BlobDescriptor, + }, +}; + +use super::{classify_request_error, relay_http_base_url}; + +const MAX_AVATAR_BYTES: usize = 10 * 1024 * 1024; +const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +fn community_base(relay: &str) -> Result { + let url = url::Url::parse(&relay_http_base_url(relay)).map_err(|e| e.to_string())?; + let local = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + if !(url.scheme() == "https" || (url.scheme() == "http" && local)) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + return Err("avatar community must be an HTTPS origin (HTTP only on localhost)".into()); + } + Ok(url) +} + +fn media_hash(url: &url::Url) -> Result<&str, String> { + let filename = url.path().strip_prefix("/media/").unwrap_or(""); + let hash = filename.split('.').next().unwrap_or(""); + if hash.len() != 64 + || !hash + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || filename.contains('/') + || filename.contains(".thumb.") + || url.query().is_some() + || url.fragment().is_some() + || !url.username().is_empty() + || url.password().is_some() + { + return Err("agent avatar must reference an original content-addressed image".into()); + } + Ok(hash) +} + +/// Copy only user-configured community media. Public external images and inline +/// emoji retain their existing behavior; no credentials are sent to their hosts. +/// All I/O uses the caller's agent identity and target, never active workspace keys. +pub(crate) async fn localize_avatar( + state: &AppState, + relay: &str, + keys: &Keys, + avatar: Option<&str>, + auth_tag: Option<&str>, +) -> Result, String> { + let Some(avatar) = avatar else { + return Ok(None); + }; + let Ok(source) = url::Url::parse(avatar) else { + return Ok(Some(avatar.into())); + }; + if !matches!(source.scheme(), "http" | "https") || !source.path().starts_with("/media/") { + return Ok(Some(avatar.into())); + } + let target = community_base(relay)?; + if source.origin() == target.origin() { + return Ok(Some(avatar.into())); + } + // Classify against user configuration, never a profile-provided host. + // The source permission is rechecked after the target probe below. + let communities = state + .agent_avatar_communities + .lock() + .map_err(|e| e.to_string())? + .clone(); + let Some(source_base) = communities + .iter() + .filter_map(|relay| community_base(relay).ok()) + .find(|base| base.origin() == source.origin()) + else { + // A removed community is no longer trusted for source reads. Its saved + // source may still have an identical target projection: keep that image + // for both reconcile and explicit metadata edits, without rewriting + // unrelated public URLs or authenticating to their hosts. + if media_hash(&source).is_ok() { + if let Some(picture) = + existing_projection(state, &target, keys, &source, auth_tag).await? + { + return Ok(Some(picture)); + } + } + return Ok(Some(avatar.into())); + }; + let hash = media_hash(&source)?; + // Content-addressing gives a deterministic projection. Once copied, an + // unavailable source community must not break restart/reconciliation. + let localized = target.join(source.path()).map_err(|e| e.to_string())?; + let target_auth = sign_blossom_get_auth_header(keys, target.as_str(), 60)?; + let mut probe = state + .media_fetch_client + .head(localized.clone()) + .timeout(TIMEOUT) + .header("Authorization", target_auth); + if let Some(tag) = auth_tag { + probe = probe.header("x-auth-tag", tag); + } + let probe = probe.send().await.map_err(|e| classify_request_error(&e))?; + if probe.status().is_success() { + return Ok(Some(localized.into())); + } + if probe.status() != reqwest::StatusCode::NOT_FOUND { + return Err(format!( + "could not check the community avatar: HTTP {}", + probe.status() + )); + } + if !state + .agent_avatar_communities + .lock() + .map_err(|e| e.to_string())? + .iter() + .filter_map(|relay| community_base(relay).ok()) + .any(|base| base.origin() == source_base.origin()) + { + return Err( + "avatar source community was removed during transfer; retry after choosing an image" + .into(), + ); + } + let auth = sign_blossom_get_auth_header(keys, source_base.as_str(), 60)?; + let mut request = state + .media_fetch_client + .get(source.clone()) + .timeout(TIMEOUT) + .header("Authorization", auth); + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } + let response = request + .send() + .await + .map_err(|e| classify_request_error(&e))?; + let bytes = read_bounded(response, MAX_AVATAR_BYTES).await?; + if hex::encode(Sha256::digest(&bytes)) != hash { + return Err("agent avatar content hash does not match its URL".into()); + } + let mime = detect_and_validate_mime(&bytes)?; + if !mime.starts_with("image/") { + return Err("agent avatar must be an image".into()); + } + // Preserve the already-uploaded bytes (including animation). Re-encoding + // changes the content hash and needlessly destroys the portable identity. + let upload_auth = sign_blossom_upload_auth(keys, hash, 60, target.as_str())?; + let auth = format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(upload_auth.as_json().as_bytes()) + ); + let body = bytes::Bytes::from(bytes); + let mut response = None; + for path in ["upload", "media/upload"] { + let url = target.join(path).map_err(|e| e.to_string())?; + let mut request = state + .media_fetch_client + .put(url) + .timeout(TIMEOUT) + .header("Authorization", &auth) + .header("Content-Type", &mime) + .header("X-SHA-256", hash) + .body(body.clone()); + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } + let uploaded = request + .send() + .await + .map_err(|e| classify_request_error(&e))?; + let legacy = matches!( + uploaded.status(), + reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED + ); + response = Some(uploaded); + if !legacy { + break; + } + } + let response = response.ok_or("agent avatar upload did not run")?; + let descriptor: BlobDescriptor = + serde_json::from_slice(&read_bounded(response, 64 * 1024).await?) + .map_err(|e| format!("invalid avatar upload response: {e}"))?; + let localized = url::Url::parse(&descriptor.url).map_err(|e| e.to_string())?; + if localized.origin() != target.origin() + || media_hash(&localized)? != hash + || descriptor.sha256 != hash + || descriptor.mime_type != mime + || descriptor.size != body.len() as u64 + { + return Err("avatar upload returned a mismatched image or community".into()); + } + Ok(Some(localized.into())) +} + +/// The target's signed profile is evidence of a prior projection, not source trust. +async fn existing_projection( + state: &AppState, + target: &url::Url, + keys: &Keys, + source: &url::Url, + auth_tag: Option<&str>, +) -> Result, String> { + let url = target.join("query").map_err(|e| e.to_string())?; + let body = serde_json::to_vec(&serde_json::json!([{ + "authors": [keys.public_key().to_hex()], "kinds": [0], "limit": 1 + }])) + .map_err(|e| e.to_string())?; + let auth = + super::build_nip98_auth_header_for_keys(keys, &reqwest::Method::POST, url.as_str(), &body)?; + let response = super::build_authenticated_relay_request( + &state.media_fetch_client, + reqwest::Method::POST, + url.as_str(), + &auth, + Some(body), + auth_tag, + Some(TIMEOUT), + ) + .send() + .await + .map_err(|e| classify_request_error(&e))?; + let events: Vec = + serde_json::from_slice(&read_bounded(response, 64 * 1024).await?) + .map_err(|e| format!("invalid avatar profile response: {e}"))?; + let Some(event) = events.first() else { + return Ok(None); + }; + if event.kind.as_u16() != 0 || event.pubkey != keys.public_key() || event.verify().is_err() { + return Err("invalid avatar profile signer or kind".into()); + } + let profile: serde_json::Value = + serde_json::from_str(&event.content).map_err(|e| e.to_string())?; + let Some(picture) = profile["picture"] + .as_str() + .and_then(|p| url::Url::parse(p).ok()) + else { + return Ok(None); + }; + Ok((picture.origin() == target.origin() + && media_hash(&picture).ok() == Some(media_hash(source)?)) + .then(|| picture.into())) +} + +async fn read_bounded(response: reqwest::Response, cap: usize) -> Result, String> { + if !response.status().is_success() { + return Err(format!( + "agent avatar transfer failed: HTTP {} (redirects are not followed)", + response.status() + )); + } + if response + .content_length() + .is_some_and(|size| size > cap as u64) + { + return Err("agent avatar transfer exceeds size limit".into()); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| classify_request_error(&e))?; + if bytes.len().saturating_add(chunk.len()) > cap { + return Err("agent avatar transfer exceeds size limit".into()); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/relay/profile_avatar/tests.rs b/desktop/src-tauri/src/relay/profile_avatar/tests.rs new file mode 100644 index 0000000000..7778f63801 --- /dev/null +++ b/desktop/src-tauri/src/relay/profile_avatar/tests.rs @@ -0,0 +1,552 @@ +use super::*; +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post, put}, + Json, Router, +}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +#[derive(Default)] +struct Data { + blobs: HashMap>, + profiles: Vec, + requests: Vec<(String, nostr::Event)>, + fail_upload: bool, + reject_profile: bool, + redirect: Option, + upload_url: Option, + pause_read: Option<(Arc, Arc)>, +} +#[derive(Clone)] +struct ServerState { + data: Arc>, + base: String, +} +struct Server { + state: ServerState, + task: tokio::task::JoinHandle<()>, +} +impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn auth(headers: &HeaderMap, method: &str, state: &ServerState) { + let encoded = headers["authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap(); + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .or_else(|_| base64::engine::general_purpose::STANDARD.decode(encoded)) + .unwrap(); + let event = nostr::Event::from_json(bytes).unwrap(); + event.verify().unwrap(); + if event.kind.as_u16() == 24242 { + let authority = state.base.strip_prefix("http://").unwrap(); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["server", authority])); + } + state + .data + .lock() + .unwrap() + .requests + .push((method.into(), event)); +} +async fn read( + State(state): State, + Path(path): Path, + headers: HeaderMap, +) -> Response { + auth(&headers, "read", &state); + let pause = state.data.lock().unwrap().pause_read.clone(); + if let Some((entered, release)) = pause { + entered.notify_one(); + release.notified().await; + } + let data = state.data.lock().unwrap(); + if let Some(url) = &data.redirect { + return (StatusCode::TEMPORARY_REDIRECT, [("location", url.clone())]).into_response(); + } + match data.blobs.get(&path) { + Some(bytes) => ([("content-type", "image/png")], bytes.clone()).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} +async fn upload(State(state): State, headers: HeaderMap, bytes: Bytes) -> Response { + auth(&headers, "upload", &state); + let mut data = state.data.lock().unwrap(); + if data.fail_upload { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + let hash = hex::encode(Sha256::digest(&bytes)); + assert_eq!(headers["x-sha-256"].to_str().unwrap(), hash); + data.blobs.insert(format!("{hash}.png"), bytes.to_vec()); + Json(serde_json::json!({ + "url": data.upload_url.clone().unwrap_or_else(|| format!("{}/media/{hash}.png", state.base)), + "sha256": hash, "size": bytes.len(), "type": "image/png", "uploaded": 1 + })).into_response() +} +async fn publish( + State(state): State, + headers: HeaderMap, + Json(event): Json, +) -> Response { + auth(&headers, "profile", &state); + event.verify().unwrap(); + let mut data = state.data.lock().unwrap(); + let accepted = !data.reject_profile; + let id = event.id.to_hex(); + if accepted { + data.profiles.push(event); + } + Json(serde_json::json!({"event_id": id, "accepted": accepted, "message": "test verdict"})) + .into_response() +} +async fn query(State(state): State, headers: HeaderMap) -> Response { + auth(&headers, "query", &state); + let data = state.data.lock().unwrap(); + if let Some(url) = &data.redirect { + return (StatusCode::TEMPORARY_REDIRECT, [("location", url.clone())]).into_response(); + } + Json( + data.profiles + .last() + .into_iter() + .cloned() + .collect::>(), + ) + .into_response() +} +async fn server() -> Server { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let state = ServerState { + data: Arc::new(Mutex::new(Data::default())), + base: format!("http://{}", listener.local_addr().unwrap()), + }; + let app = Router::new() + .route("/media/{path}", get(read)) + .route("/upload", put(upload)) + .route("/events", post(publish)) + .route("/query", post(query)) + .with_state(state.clone()); + let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + Server { state, task } +} +fn image(seed: u8) -> Vec { + let image = image::RgbaImage::from_pixel(2, 2, image::Rgba([seed, 0, 0, 255])); + let mut output = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut output, image::ImageFormat::Png) + .unwrap(); + output.into_inner() +} +fn seed(server: &Server, bytes: Vec) -> String { + let path = format!("{}.png", hex::encode(Sha256::digest(&bytes))); + server + .state + .data + .lock() + .unwrap() + .blobs + .insert(path.clone(), bytes); + format!("{}/media/{path}", server.state.base) +} +fn state(servers: &[&Server]) -> AppState { + let state = crate::app_state::build_app_state(); + *state.agent_avatar_communities.lock().unwrap() = + servers.iter().map(|s| s.state.base.clone()).collect(); + state +} +fn picture(server: &Server) -> String { + let data = server.state.data.lock().unwrap(); + let event = data.profiles.last().unwrap(); + serde_json::from_str::(&event.content).unwrap()["picture"] + .as_str() + .unwrap() + .into() +} +async fn sync(state: &AppState, target: &Server, keys: &Keys, avatar: &str) -> Result<(), String> { + crate::relay::sync_managed_agent_profile( + state, + &target.state.base, + keys, + "Carl", + Some(avatar), + None, + None, + ) + .await +} + +#[tokio::test] +async fn two_communities_add_edit_restart_keeps_local_images_and_fixed_agent_signer() { + let a = server().await; + let b = server().await; + let c = server().await; + let state = state(&[&a, &b]); + let keys = Keys::generate(); + let original = seed(&a, image(1)); + sync(&state, &a, &keys, &original).await.unwrap(); + sync(&state, &b, &keys, &original).await.unwrap(); + assert_eq!(picture(&b), original.replace(&a.state.base, &b.state.base)); + assert_eq!(picture(&a), original); + let edited = seed(&b, image(2)); + sync(&state, &b, &keys, &edited).await.unwrap(); + // A pending A task must not use a newly active C or its owner signer. + *state.relay_url_override.lock().unwrap() = Some(c.state.base.clone()); + *state.keys.lock().unwrap() = Keys::generate(); + sync(&state, &a, &keys, &edited).await.unwrap(); + let projected = edited.replace(&b.state.base, &a.state.base); + assert_eq!(picture(&a), projected); + assert_eq!(picture(&b), edited); + // The copied bytes survive source loss; subsequent reconciliation only HEADs A. + b.state.data.lock().unwrap().blobs.clear(); + sync(&state, &a, &keys, &edited).await.unwrap(); + assert_eq!(picture(&a), projected); + assert!(c.state.data.lock().unwrap().requests.is_empty()); + for server in [&a, &b] { + let data = server.state.data.lock().unwrap(); + assert!(data + .requests + .iter() + .all(|(_, event)| event.pubkey == keys.public_key())); + } + let path = projected.split("/media/").nth(1).unwrap(); + assert_eq!(a.state.data.lock().unwrap().blobs[path], image(2)); +} + +#[tokio::test] +async fn failed_transfer_or_profile_rejection_preserves_previous_picture_and_retry_succeeds() { + let a = server().await; + let b = server().await; + let state = state(&[&a, &b]); + let keys = Keys::generate(); + let old = seed(&b, image(1)); + sync(&state, &b, &keys, &old).await.unwrap(); + let new = seed(&a, image(2)); + b.state.data.lock().unwrap().fail_upload = true; + assert!(sync(&state, &b, &keys, &new).await.is_err()); + assert_eq!(picture(&b), old); + b.state.data.lock().unwrap().fail_upload = false; + b.state.data.lock().unwrap().reject_profile = true; + assert!(sync(&state, &b, &keys, &new) + .await + .unwrap_err() + .contains("rejected agent profile")); + assert_eq!(picture(&b), old); + b.state.data.lock().unwrap().reject_profile = false; + sync(&state, &b, &keys, &new).await.unwrap(); + assert_eq!(picture(&b), new.replace(&a.state.base, &b.state.base)); +} + +#[tokio::test] +async fn untrusted_origin_redirect_hash_and_descriptor_mismatch_cannot_publish() { + let a = server().await; + let b = server().await; + let trap = server().await; + let state = state(&[&a, &b]); + let keys = Keys::generate(); + let avatar = seed(&a, image(1)); + let unknown = avatar.replace(&a.state.base, &trap.state.base); + sync(&state, &b, &keys, &unknown).await.unwrap(); + b.state.data.lock().unwrap().profiles.clear(); + assert!(trap.state.data.lock().unwrap().requests.is_empty()); + a.state.data.lock().unwrap().redirect = Some(unknown); + assert!(sync(&state, &b, &keys, &avatar).await.is_err()); + assert!(trap.state.data.lock().unwrap().requests.is_empty()); + a.state.data.lock().unwrap().redirect = None; + let path = avatar.split("/media/").nth(1).unwrap(); + a.state + .data + .lock() + .unwrap() + .blobs + .insert(path.into(), image(2)); + assert!(sync(&state, &b, &keys, &avatar) + .await + .unwrap_err() + .contains("content hash")); + a.state + .data + .lock() + .unwrap() + .blobs + .insert(path.into(), image(1)); + b.state.data.lock().unwrap().upload_url = Some(avatar.clone()); + assert!(sync(&state, &b, &keys, &avatar) + .await + .unwrap_err() + .contains("mismatched")); + assert!(b.state.data.lock().unwrap().profiles.is_empty()); +} + +#[tokio::test] +async fn non_media_external_and_inline_avatars_are_not_fetched() { + let b = server().await; + let state = state(&[&b]); + let keys = Keys::generate(); + for avatar in [ + "https://example.org/avatar.png", + "https://example.org/media/avatar.png", + "data:image/svg+xml,%3Csvg/%3E", + ] { + sync(&state, &b, &keys, avatar).await.unwrap(); + assert_eq!(picture(&b), avatar); + } + assert!(b + .state + .data + .lock() + .unwrap() + .requests + .iter() + .all(|(kind, _)| kind == "profile")); +} + +#[test] +fn trusted_community_and_original_image_validation() { + for value in [ + "wss://a.example", + "https://a.example/", + "ws://localhost:1234", + ] { + assert!(community_base(value).is_ok(), "{value}"); + } + for value in [ + "http://a.example", + "https://user@a.example", + "https://a.example/path", + "https://a.example?query", + ] { + assert!(community_base(value).is_err(), "{value}"); + } + let hash = "a".repeat(64); + for suffix in [".png", ""] { + assert!(media_hash( + &url::Url::parse(&format!("https://a.example/media/{hash}{suffix}")).unwrap() + ) + .is_ok()); + } + for suffix in [".thumb.jpg", ".png?query", ".png#fragment", "/other"] { + assert!(media_hash( + &url::Url::parse(&format!("https://a.example/media/{hash}{suffix}")).unwrap() + ) + .is_err()); + } +} + +#[tokio::test] +async fn removing_source_community_revokes_authenticated_fetches() { + let a = server().await; + let b = server().await; + let state = state(&[&a, &b]); + let keys = Keys::generate(); + let avatar = seed(&a, image(1)); + *state.agent_avatar_communities.lock().unwrap() = vec![b.state.base.clone()]; + // Unknown public hosts retain passthrough compatibility, but never get an + // authenticated fetch after removal from the configured list. + sync(&state, &b, &keys, &avatar).await.unwrap(); + assert!(a.state.data.lock().unwrap().requests.is_empty()); + assert_eq!(picture(&b), avatar, "never rewrite an arbitrary public URL"); +} + +#[tokio::test] +async fn source_removed_while_target_head_pending_cannot_start_authenticated_get() { + let a = server().await; + let b = server().await; + let state = Arc::new(state(&[&a, &b])); + let keys = Keys::generate(); + let avatar = seed(&a, image(1)); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + b.state.data.lock().unwrap().pause_read = Some((entered.clone(), release.clone())); + let task = tokio::spawn({ + let state = state.clone(); + let target = b.state.base.clone(); + async move { localize_avatar(&state, &target, &keys, Some(&avatar), None).await } + }); + tokio::time::timeout(TIMEOUT, entered.notified()) + .await + .unwrap(); + *state.agent_avatar_communities.lock().unwrap() = vec![b.state.base.clone()]; + release.notify_one(); + assert!(task + .await + .unwrap() + .unwrap_err() + .contains("removed during transfer")); + assert!(a.state.data.lock().unwrap().requests.is_empty()); + assert!(b.state.data.lock().unwrap().profiles.is_empty()); +} + +#[tokio::test] +async fn startup_reconcile_compares_local_projection_retries_saved_edit_and_honors_opt_out() { + use crate::commands::{reconcile_profile_at, ProfileReconcileData, ProfileReconcileOutcome}; + use nostr::ToBech32; + let a = server().await; + let b = server().await; + let state = state(&[&a, &b]); + let keys = Keys::generate(); + let source = seed(&a, image(1)); + let mut data = ProfileReconcileData { + private_key_nsec: keys.secret_key().to_bech32().unwrap(), + name: "Carl".into(), + relay_url: a.state.base.clone(), + target_relay_url: Some(b.state.base.clone()), + avatar_url: Some(source.clone()), + auth_tag: None, + pubkey: keys.public_key().to_hex(), + agent_command: "goose".into(), + persona_id: None, + about: None, + }; + async fn run( + state: &AppState, + target: &str, + data: &ProfileReconcileData, + existing: Option, + ) -> Result { + reconcile_profile_at( + state, + target, + data, + data.avatar_url.as_deref(), + existing.as_ref(), + ) + .await + } + assert_eq!( + run(&state, &b.state.base, &data, None).await.unwrap(), + ProfileReconcileOutcome::Reconciled + ); + let first = picture(&b); + let existing = crate::relay::AgentProfileInfo { + display_name: Some("Carl".into()), + picture: Some(first.clone()), + about: None, + }; + run(&state, &b.state.base, &data, Some(existing.clone())) + .await + .unwrap(); + assert_eq!( + b.state.data.lock().unwrap().profiles.len(), + 1, + "local projection must not cause perpetual republish" + ); + data.avatar_url = Some(seed(&a, image(2))); + b.state.data.lock().unwrap().fail_upload = true; + assert!(run(&state, &b.state.base, &data, Some(existing.clone())) + .await + .is_err()); + assert_eq!(picture(&b), first); + b.state.data.lock().unwrap().fail_upload = false; + b.state.data.lock().unwrap().reject_profile = true; + assert!(run(&state, &b.state.base, &data, Some(existing.clone())) + .await + .is_err()); + assert_eq!(picture(&b), first); + b.state.data.lock().unwrap().reject_profile = false; + run(&state, &b.state.base, &data, Some(existing.clone())) + .await + .unwrap(); + let second = picture(&b); + assert_ne!(second, first); + // Removing A revokes reads, not the identical copy already published in B. + *state.agent_avatar_communities.lock().unwrap() = vec![b.state.base.clone()]; + let source_requests = a.state.data.lock().unwrap().requests.len(); + data.name = "Renamed Carl".into(); + data.about = Some("New description".into()); + run( + &state, + &b.state.base, + &data, + Some(crate::relay::AgentProfileInfo { + picture: Some(second.clone()), + ..existing.clone() + }), + ) + .await + .unwrap(); + assert_eq!(picture(&b), second); + // Direct rename/persona-about writers use this same production writer. + crate::relay::sync_managed_agent_profile( + &state, + &b.state.base, + &keys, + "Another name", + data.avatar_url.as_deref(), + Some("Another about"), + None, + ) + .await + .unwrap(); + assert_eq!(picture(&b), second); + assert_eq!(a.state.data.lock().unwrap().requests.len(), source_requests); + state + .managed_agent_profile_reconcile_enabled() + .store(false, std::sync::atomic::Ordering::Release); + let requests = b.state.data.lock().unwrap().requests.len(); + assert_eq!( + run(&state, &b.state.base, &data, Some(existing)) + .await + .unwrap(), + ProfileReconcileOutcome::SkippedDisabled + ); + assert_eq!(b.state.data.lock().unwrap().requests.len(), requests); +} + +#[tokio::test] +async fn oversized_and_non_image_sources_do_not_upload_or_replace_profile() { + let a = server().await; + let b = server().await; + let state = state(&[&a, &b]); + let keys = Keys::generate(); + for bytes in [vec![0u8; MAX_AVATAR_BYTES + 1], b"not an image".to_vec()] { + let avatar = seed(&a, bytes); + assert!(sync(&state, &b, &keys, &avatar).await.is_err()); + } + let data = b.state.data.lock().unwrap(); + assert!(data.profiles.is_empty()); + assert!(data.requests.iter().all(|(method, _)| method != "upload")); +} + +#[tokio::test] +async fn removed_source_projection_query_uses_fixed_signer_and_cannot_redirect() { + let a = server().await; + let b = server().await; + let trap = server().await; + let state = state(&[&a, &b]); + let keys = Keys::generate(); + let avatar = seed(&a, image(1)); + sync(&state, &b, &keys, &avatar).await.unwrap(); + let projected = picture(&b); + *state.agent_avatar_communities.lock().unwrap() = vec![b.state.base.clone()]; + *state.relay_url_override.lock().unwrap() = Some(trap.state.base.clone()); + *state.keys.lock().unwrap() = Keys::generate(); + sync(&state, &b, &keys, &avatar).await.unwrap(); + assert_eq!(picture(&b), projected); + assert!(b + .state + .data + .lock() + .unwrap() + .requests + .iter() + .all(|(_, event)| event.pubkey == keys.public_key())); + b.state.data.lock().unwrap().redirect = Some(format!("{}/query", trap.state.base)); + assert!(sync(&state, &b, &keys, &avatar).await.is_err()); + assert!(trap.state.data.lock().unwrap().requests.is_empty()); + assert_eq!(picture(&b), projected); +} diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index e632e158de..a05b66f016 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -431,6 +431,7 @@ function CommunityApp({ communityKey, sharedIdentity, isFindingCommunityAfterLeave, + communities, ); const transitionCommunity = useCallback( diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 7f1f73b175..aa586b206a 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -381,6 +381,25 @@ buzz messages send --channel --reply-to \ - Rust: `definition_validation` and inbound persona tests pin the shared Unicode/control-character policy at local, import, publish, and sync gates. +## Managed avatar media + +Desktop-managed profiles retain the saved persona/instance avatar as the desired +source. Never publish another configured community's authenticated `/media/` URL +verbatim: `relay::profile_avatar::localize_avatar` verifies/copies its bytes into +the caller-pinned target before kind:0 comparison/publication. The shared record +keeps the source URL, not the target projection. Transfer or kind:0 rejection +leaves the previous profile intact so normal reconciliation can retry. The +Agent-managed profiles opt-out still disables automatic reconciliation. + +The configured community origin set is refreshed through narrow workspace IPC +before startup restore and when inactive communities change, without resetting +active community state. After source removal, the shared writer may reuse an +already-published target-local picture with the same original content hash; +this is not permission to fetch the removed source. It is not learned from profile URLs. Media transfer +uses the fixed agent signer, origin-scoped Blossom auth, no redirects, byte caps, +and hash/descriptor verification; ordinary public external avatars remain +unauthenticated passthrough. No image-reader proxy or tenant isolation exception. + ## Keep this file true **If you change how agent configuration is modeled, rendered, persisted, diff --git a/desktop/src/features/communities/communityRelaySet.test.mjs b/desktop/src/features/communities/communityRelaySet.test.mjs new file mode 100644 index 0000000000..e856f11e04 --- /dev/null +++ b/desktop/src/features/communities/communityRelaySet.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { communityRelaySetKey } from "./communityRelaySet.ts"; + +const communities = (...urls) => urls.map((relayUrl) => ({ relayUrl })); + +test("inactive community add/edit/removal changes trust while reorder and names do not", () => { + const a = "wss://a.example"; + const b = "wss://b.example"; + const key = communityRelaySetKey(communities(a, b)); + assert.equal( + key, + communityRelaySetKey(communities(b, a, "https://a.example/")), + ); + assert.notEqual(key, communityRelaySetKey(communities(b))); + assert.notEqual( + key, + communityRelaySetKey(communities("wss://new-a.example", b)), + ); + assert.notEqual( + key, + communityRelaySetKey(communities(a, b, "wss://c.example")), + ); + assert.equal( + key, + communityRelaySetKey( + communities(a, b).map((c) => ({ ...c, name: "new label" })), + ), + ); +}); + +test("invalid URLs cannot become trusted origins by normalization", () => { + assert.equal( + communityRelaySetKey( + communities( + "bad", + "file:///etc", + "https://user@a.example", + "wss://a.example/path", + "https://a.example?q=1", + ), + ), + "[]", + ); +}); diff --git a/desktop/src/features/communities/communityRelaySet.ts b/desktop/src/features/communities/communityRelaySet.ts new file mode 100644 index 0000000000..a9c73b2e0e --- /dev/null +++ b/desktop/src/features/communities/communityRelaySet.ts @@ -0,0 +1,26 @@ +import type { Community } from "./types"; + +/** Stable dependency for the backend's user-configured avatar source origins. */ +export function communityRelaySetKey( + communities: readonly Pick[], +): string { + const origins = communities.flatMap(({ relayUrl }) => { + try { + const url = new URL(relayUrl.replace(/^ws(s?):/, "http$1:")); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + return []; + } + return [url.origin]; + } catch { + return []; + } + }); + return JSON.stringify([...new Set(origins)].sort()); +} diff --git a/desktop/src/features/communities/useCommunityInit.test.mjs b/desktop/src/features/communities/useCommunityInit.test.mjs new file mode 100644 index 0000000000..046d86437e --- /dev/null +++ b/desktop/src/features/communities/useCommunityInit.test.mjs @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +let renderHook, waitFor, cleanup, act, useCommunityInit, relayClient; +const calls = []; +const pendingTrust = []; +let holdTrust = false; +const a = { id: "a", relayUrl: "wss://a.example", name: "A" }; +const b = { id: "b", relayUrl: "wss://b.example", name: "B" }; + +before(async () => { + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + calls.push([command, args]); + if (command === "set_agent_avatar_communities" && holdTrust) { + await new Promise((resolve, reject) => { + pendingTrust.push({ resolve, reject }); + }); + } + if (command === "get_identity") return { pubkey: "a".repeat(64) }; + if (command === "get_relay_url") return b.relayUrl; + return undefined; + }, + transformCallback: () => 1, + }; + globalThis.__TAURI_INTERNALS__ = dom.window.__TAURI_INTERNALS__; + ({ renderHook, waitFor, cleanup, act } = await import( + "@testing-library/react" + )); + ({ useCommunityInit } = await import("./useCommunityInit.ts")); + ({ relayClient } = await import("@/shared/api/relayClient")); +}); +afterEach(async () => { + cleanup(); + holdTrust = false; + await act(async () => { + for (const deferred of pendingTrust) deferred.resolve(); + }); + pendingTrust.length = 0; + calls.length = 0; + localStorage.clear(); +}); +after(() => dom.window.close()); + +function mount(communities) { + return renderHook((list) => useCommunityInit(b, "b", false, false, list), { + initialProps: communities, + }); +} + +test("inactive relay removal/edit/add refreshes trust without reapplying or disconnecting active community", async () => { + let disconnects = 0; + const original = relayClient.disconnect; + relayClient.disconnect = () => { + disconnects += 1; + }; + try { + const { result, rerender } = mount([a, b]); + await waitFor(() => assert.equal(result.current.isReady, true)); + assert.equal(calls.filter(([cmd]) => cmd === "apply_workspace").length, 1); + for (const list of [ + [b], + [{ ...a, relayUrl: "wss://new-a.example" }, b], + [a, b], + ]) { + const before = calls.filter( + ([cmd]) => cmd === "set_agent_avatar_communities", + ).length; + rerender(list); + await waitFor(() => + assert.equal( + calls.filter(([cmd]) => cmd === "set_agent_avatar_communities") + .length, + before + 1, + ), + ); + assert.equal(result.current.isReady, true); + assert.equal( + calls.filter(([cmd]) => cmd === "apply_workspace").length, + 1, + ); + assert.equal(disconnects, 0); + } + const count = calls.length; + rerender([{ ...b, name: "New label" }, a]); + assert.equal(calls.length, count); + const updates = calls.filter( + ([cmd]) => cmd === "set_agent_avatar_communities", + ); + assert.deepEqual(updates[1][1].relayUrls, ["https://b.example"]); + } finally { + relayClient.disconnect = original; + } +}); + +test("initial workspace restore waits for avatar trust IPC", async () => { + holdTrust = true; + const { result } = mount([a, b]); + await waitFor(() => assert.equal(pendingTrust.length, 1)); + // Let identity resolution and any unguarded apply settle while trust is held. + await act(async () => {}); + assert.equal( + calls.some(([cmd]) => cmd === "apply_workspace"), + false, + ); + await act(async () => pendingTrust[0].resolve()); + await waitFor(() => assert.equal(result.current.isReady, true)); + assert.equal(calls.filter(([cmd]) => cmd === "apply_workspace").length, 1); +}); + +test("source removal during pending trust serializes IPC and blocks restore until the latest update", async (t) => { + holdTrust = true; + const disconnect = t.mock.method(relayClient, "disconnect", () => {}); + const { result, rerender } = mount([a, b]); + await waitFor(() => assert.equal(pendingTrust.length, 1)); + await act(async () => {}); + + rerender([b]); + await act(async () => {}); + assert.equal( + pendingTrust.length, + 1, + "P2 must not dispatch before P1 settles", + ); + assert.equal(result.current.isReady, false); + + await act(async () => pendingTrust[0].resolve()); + await waitFor(() => assert.equal(pendingTrust.length, 2)); + assert.deepEqual( + calls + .filter(([cmd]) => cmd === "set_agent_avatar_communities") + .map(([, args]) => args.relayUrls), + [["https://a.example", "https://b.example"], ["https://b.example"]], + ); + assert.equal( + calls.filter(([cmd]) => cmd === "apply_workspace").length, + 0, + "P1 alone must not release restoration while source removal is pending", + ); + assert.equal(result.current.isReady, false); + + await act(async () => pendingTrust[1].resolve()); + await waitFor(() => assert.equal(result.current.isReady, true)); + assert.equal(calls.filter(([cmd]) => cmd === "apply_workspace").length, 1); + assert.equal(disconnect.mock.callCount(), 0); +}); + +for (const failingUpdate of [0, 1]) { + test(`trust update ${failingUpdate + 1} rejection does not release queued restoration`, async (t) => { + holdTrust = true; + t.mock.method(console, "error", () => {}); + const { result, rerender } = mount([a, b]); + await waitFor(() => assert.equal(pendingTrust.length, 1)); + rerender([b]); + await act(async () => {}); + if (failingUpdate === 1) { + await act(async () => pendingTrust[0].resolve()); + await waitFor(() => assert.equal(pendingTrust.length, 2)); + } + await act(async () => + pendingTrust[failingUpdate].reject(new Error("IPC failed")), + ); + assert.equal(result.current.isReady, false); + assert.ok(result.current.error); + assert.equal(calls.filter(([cmd]) => cmd === "apply_workspace").length, 0); + assert.equal(pendingTrust.length, failingUpdate + 1); + }); +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 21a49fb729..665dfb96ed 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -8,7 +8,10 @@ import { autoConnectDefaultRelayEnabled, getDefaultRelayUrl, } from "@/shared/api/tauri"; -import { applyCommunity } from "@/shared/api/tauriWorkspace"; +import { + applyCommunity, + setAgentAvatarCommunities, +} from "@/shared/api/tauriWorkspace"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { clearTrayAgentActivity } from "@/shared/api/trayMenu"; import { getOverrides } from "@/shared/features"; @@ -48,6 +51,7 @@ import { shouldAutoConnectDefaultRelay, } from "./communityStorage"; import type { Community } from "./types"; +import { communityRelaySetKey } from "./communityRelaySet"; /** * Tear down all community-scoped module singletons so the new @@ -126,13 +130,36 @@ export function useCommunityInit( communityKey: string, isSharedIdentity: boolean, suppressAutoConnect = false, + communities: readonly Community[] = [], ): CommunityInitResult { + const communityRelaysKey = communityRelaySetKey(communities); const [result, setResult] = useState({ isReady: false, needsSetup: false, appliedKey: null, }); + // Trust-list edits must not reset active drafts, connections, or providers. + // Startup waits for this narrow IPC before workspace apply can restore agents. + const avatarTrustUpdateRef = useRef(Promise.resolve()); + useEffect(() => { + // Keep native writes ordered too, not just the frontend waiters. A failed + // update leaves the chain rejected so restoration stays blocked until reload. + const update = avatarTrustUpdateRef.current.then(() => + setAgentAvatarCommunities(JSON.parse(communityRelaysKey) as string[]), + ); + avatarTrustUpdateRef.current = update; + void update.catch((error) => { + console.error("Failed to refresh avatar source communities:", error); + setResult({ + isReady: false, + needsSetup: false, + appliedKey: null, + error: "Could not refresh avatar source permissions. Reload to retry.", + }); + }); + }, [communityRelaysKey]); + // Track whether this is the initial mount or a community switch. // On the initial mount we skip resetting singletons (they're fresh). const hasInitializedRef = useRef(false); @@ -309,6 +336,14 @@ export function useCommunityInit( // imported key. `loadCommunities()` strips lingering `nsec` fields from // legacy entries; this site refuses to apply one even if present. try { + // A list edit can replace the promise while startup is awaiting it. + // Only the latest completed allowlist may release agent restoration. + let trustUpdate: Promise; + do { + trustUpdate = avatarTrustUpdateRef.current; + await trustUpdate; + if (cancelled) return; + } while (trustUpdate !== avatarTrustUpdateRef.current); await applyCommunity( activeCommunity.relayUrl, undefined, diff --git a/desktop/src/shared/api/tauriWorkspace.ts b/desktop/src/shared/api/tauriWorkspace.ts index ff74ab0942..6a141508e4 100644 --- a/desktop/src/shared/api/tauriWorkspace.ts +++ b/desktop/src/shared/api/tauriWorkspace.ts @@ -18,3 +18,7 @@ export async function applyCommunity( export const setAgentManagedProfiles = (enabled: boolean) => invokeTauri("set_agent_managed_profiles", { enabled }); + +/** Refresh source trust without applying/resetting the active workspace. */ +export const setAgentAvatarCommunities = (relayUrls: string[]) => + invokeTauri("set_agent_avatar_communities", { relayUrls }); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 78f2ed3eb5..3b42a2be85 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -14049,6 +14049,7 @@ export function maybeInstallE2eTauriMocks() { // Post-create bootstrap reconcile: no new pairs in the mock world. return []; case "set_agent_managed_profiles": + case "set_agent_avatar_communities": return undefined; case "set_managed_agent_auto_restart": return handleSetManagedAgentAutoRestart( From 4af2324d85e927518c59adc8ad15043233d8cd53 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 18 Sep 2026 12:10:26 -0700 Subject: [PATCH 09/22] fix(mobile): keep relay sessions stable during push lease updates (#7745) Enabling push notifications could repeatedly reset the relay session: reserving a lease generation updates community metadata, which rebuilt the relay configuration even though the connection identity was unchanged. This made the avatar and inbox flash and prevented the new lease from publishing. Select only the community ID, relay URL, and signing identity as configuration dependencies. Push bookkeeping keeps the session intact; real community, URL, and identity changes still notify listeners. ### Validation The production-provider regression test failed before the fix and passes afterward; 48 focused mobile tests and static analysis pass. A personal signed profile build on a physical iPhone published a fresh lease and received a background notification with the actual message text. The user confirmed the flashing stopped and tapping the notification opened the correct community/channel and highlighted the exact message. Signed-off-by: Tom Brow --- mobile/lib/shared/relay/relay_provider.dart | 16 ++-- .../push_relay_config_regression_test.dart | 82 +++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 mobile/test/shared/community/push_relay_config_regression_test.dart diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 73643ff020..7e3f0d00a2 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -82,12 +82,16 @@ class Env { class RelayConfigNotifier extends Notifier { @override RelayConfig build() { - // Watch the active community so that when it changes (community switch), - // the config rebuilds, triggering the full provider cascade. - final activeAsync = ref.watch(activeCommunityProvider); - final active = activeAsync.value; - if (active != null) { - return RelayConfig(baseUrl: active.relayUrl, nsec: active.nsec); + // Push lease bookkeeping updates the community while using its live + // session. Only connection-context changes may restart that session. + final context = ref.watch( + activeCommunityProvider.select((value) { + final active = value.value; + return (id: active?.id, relayUrl: active?.relayUrl, nsec: active?.nsec); + }), + ); + if (context.relayUrl != null) { + return RelayConfig(baseUrl: context.relayUrl!, nsec: context.nsec); } // Fallback to compile-time env config (dev mode). diff --git a/mobile/test/shared/community/push_relay_config_regression_test.dart b/mobile/test/shared/community/push_relay_config_regression_test.dart new file mode 100644 index 0000000000..53a9fb564e --- /dev/null +++ b/mobile/test/shared/community/push_relay_config_regression_test.dart @@ -0,0 +1,82 @@ +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'community_storage_test.dart'; + +void main() { + test( + 'reserving a push lease does not notify relay config listeners', + () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final community = Community.create( + name: 'Tom Town', + relayUrl: 'https://relay.example.com', + ).copyWith(pushNotificationsEnabled: true); + await storage.save(community); + await storage.saveActiveId(community.id); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + ], + ); + addTearDown(container.dispose); + await container.read(activeCommunityProvider.future); + final changes = []; + container.listen(relayConfigProvider, (_, next) => changes.add(next)); + await container + .read(communityListProvider.notifier) + .reservePushLeaseGeneration(community.id); + await container.read(activeCommunityProvider.future); + await container.pump(); + expect( + changes, + isEmpty, + reason: 'Push metadata must not reset the connected relay session', + ); + }, + ); + for (final change in ['community', 'relay', 'identity']) { + test('$change change notifies relay config listeners', () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final original = Community.create( + name: 'Original', + relayUrl: 'https://relay.example.com', + nsec: 'first', + ); + await storage.save(original); + await storage.saveActiveId(original.id); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + ], + ); + addTearDown(container.dispose); + await container.read(activeCommunityProvider.future); + final changes = []; + container.listen(relayConfigProvider, (_, next) => changes.add(next)); + final updated = switch (change) { + 'community' => Community.create( + name: 'Other', + relayUrl: original.relayUrl, + nsec: original.nsec, + ), + 'relay' => original.copyWith(relayUrl: 'https://other.example.com'), + _ => original.copyWith(nsec: 'second'), + }; + await storage.save(updated); + await storage.saveActiveId(updated.id); + container.invalidate(communityListProvider); + await container.read(activeCommunityProvider.future); + await container.pump(); + expect(changes, hasLength(1)); + expect(changes.single.baseUrl, updated.relayUrl); + expect(changes.single.nsec, updated.nsec); + }); + } +} From 4e1770aa3ee7552e2bb50e897e113c22e30488de Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 18 Sep 2026 12:17:10 -0700 Subject: [PATCH 10/22] Add mobile VISION (#7710) ## Summary Add the Buzz Mobile vision covering intended experience, architecture, shared client behavior, verification, release cadence, and critical functionality. Link it from the mobile README; the existing agent instruction to read relevant `VISION_*.md` documents makes it discoverable during planning and review. ### Related issue No duplicate issue or PR found. ### Testing Documentation-only change. Diff checks and pre-push hooks passed. Repository-wide `just ci` is running locally; Rust formatting has passed. --------- Signed-off-by: Tom Brow --- .github/CODEOWNERS | 1 + AGENTS.md | 4 +++ VISION_MOBILE.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++ mobile/README.md | 3 +++ 4 files changed, 75 insertions(+) create mode 100644 VISION_MOBILE.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e9504afc70..e3ed4a3244 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,2 @@ * @block/buzz-oss-team +/VISION_MOBILE.md @brow diff --git a/AGENTS.md b/AGENTS.md index 7afdc429fc..58309c0865 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,10 @@ See CONTRIBUTING.md for full setup details and dependency requirements. Run `just ci` before every PR — it runs repository-wide formatting, lint, and static checks; Rust, Tauri, desktop, and mobile tests; and desktop and web builds. Clippy passing does not mean fmt passes; run both. +For changes limited to Flutter/Dart code in the mobile app, run +`just mobile-install mobile-check mobile-test` instead of `just ci`. +Native code and build-configuration changes also require the corresponding +platform checks. Run `just test` for integration tests if you touched `buzz-relay`, `buzz-db`, or `buzz-auth` — these require a running Postgres and Redis. diff --git a/VISION_MOBILE.md b/VISION_MOBILE.md new file mode 100644 index 0000000000..0cf7f42822 --- /dev/null +++ b/VISION_MOBILE.md @@ -0,0 +1,67 @@ +# Buzz Mobile + +Buzz Mobile is a complete, offline-first Buzz client for iOS and Android. It can be used in concert with desktop Buzz, or as one's first and only Buzz client. + +This document describes its intended behavior and architecture. For that purpose, this document takes priority over any other document or code. Not all of the documented intents are realized in the implementation yet. + +When writing a spec or implementing significant changes or tests, check them for consistency with this doc. If not yet consistent, it may be this doc that needs to change, but that change should be reviewed by this doc's owner. + +## Experience principles +- **Offline first.** Show cached content immediately; support reading, composing, and queuing actions regardless of connectivity. Sync in the background without disrupting use. Make unavailable content and prolonged sync problems clear. +- **Responsive UI.** The user interface does not hang or stutter, regardless of the scale of a community or number of communities joined. Expensive or slow operations happen in parallel with UI feedback and animation. Actions are reflected in UI instantly and optimistically. +- **Don't lose user-generated data.** Drafts and pending actions survive restarts, app updates, and cache rebuilds. Important pending actions (e.g. undelivered sent messages) are clearly identified visually and offer affordances for retrying or recovering the uncommitted data. +- **Don't trust relays.** Their behavior is outside our and users' control. Critical user intents (leaving a community, stopping notifications from a relay, etc.) should never be blocked on a response from a relay. The behavior or performance of one relay that the client is connected to should not degrade the experience of using other relays or non-relay-dependent functionality in the app, including by adding latency. +- **Accessibility.** The app supports platform accessibility features, including screen readers, text scaling, and reduced motion. + +## Relationship to other clients + +Mobile and desktop follow shared rules for behavior such as channel ordering, unread state, and autocomplete. Presentation and interaction should suit each platform. + +The desktop implementation is a reference we can use to inform defining those rules, but it contains bugs and unintended behavior and is not itself the specification. Written intent takes priority over implementation precedent. A clearer vision or spec developed for mobile can establish the intended shared behavior for desktop too. + +## Architecture + +The following are preferred architectural approaches in service of the experience principles. They can evolve when a better approach is established. + +- Verify events on receipt and fold them into the persistent store. Avoid re-verifying events already verified in the current persistent store. UI subscribes to the persistent store, never to events. +- Avoid migrations of the persistent store. It should be fully derived from received events and should be discarded as needed, such as when an app update changes the schema nontrivially. Uncommitted user-generated data that cannot be lost, including drafts and pending actions, should be kept in a separate, minimally scoped persistent store (or multiple). +- Avoid blocking the UI isolate. E.g. with expensive work such as signature verification, JSON or Markdown parsing, writing to the persistent store, or even unbounded text layout. +- Keep background work, storage, and network use bounded, with attention to battery and data consumption. + +## Verification + +- Tests should be fast and deterministic (not flaky), including end-to-end tests. Dependencies should be replaced with fast, deterministic mocks in tests where possible. +- After a code change, neither the developer nor CI should rerun tests that cannot have been affected by the change. E.g. a pure mobile client change shouldn't rerun relay or desktop client tests, and vice versa. +- Tests and implementations should reflect the intended behavior described here and in applicable specs. + +## Release cycle + +- We want to minimize the average time elapsed between (a) merging a change and (b) the change appearing in Comp Portal and the app stores. +- We should always have a release `Waiting for Review` / `In Review` in the iOS App Store. Immediately a release is approved, we submit a new one. +- We should build and upload release candidates frequently. Every RC should go to Comp Portal and TestFlight. Building and distributing an RC from a commit on main should require only a single click/command. When the time arrives to submit the next release to public app stores, there should already be a suitable RC uploaded and dogfooded. + +## Critical functionality + +### Channels and conversations + +- Once fetched, the channel list remains available during refreshes and connectivity loss. +- Channel ordering and unread highlights and badges follow shared cross-client rules. +- Channel and thread conversations support reading and composing with immediate local feedback. +- Sending distinguishes pending, sent, and failed states. Sent means relay acceptance is confirmed, not that another person or device has received or read the message. Uncertain acceptance remains pending; failures that need intervention are visible. +- Retrying a pending send does not create a duplicate message. + +### Unread state + +- Unread highlights and numerical badges follow shared rules, including how thread activity contributes to a channel's unread state. +- Reading updates local unread state immediately, and read state synchronizes across devices. + +### Autocomplete and search + +- `@` autocomplete should show results instantaneously. Results and ordering must follow shared cross-client rules, including humans and agents, both remote and local, both in the channel and not. +- Search queries the relay rather than being limited to locally cached content. + +### Notifications + +- Message notifications show the Buzz icon, sender avatar, channel name, and message snippet, subject to privacy choices. +- Notifications respect shared preferences and mute state and avoid alerts for activity already known to be read. +- Opening a notification reaches the relevant community and conversation, including from a cold start. Inaccessible content has a clear explanation. diff --git a/mobile/README.md b/mobile/README.md index e58a6387aa..6811f3759b 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -2,6 +2,9 @@ Flutter mobile client for Buzz. +See [VISION_MOBILE.md](../VISION_MOBILE.md) for intended behavior and +architecture. + ## Setup Use the Flutter SDK pinned by the repository. Activate Hermit from the repo From f18ea456984a1a94d444615fa3b38ec326a79793 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 18 Sep 2026 15:26:19 -0700 Subject: [PATCH 11/22] Replace personal and internal data in desktop test fixtures (#7748) Replace personal contributor identities, copied internal image content, and production Databricks hostnames in desktop tests and mocks with synthetic fixtures. The related path and mock-profile fixtures use the same example identity, and the image regression still verifies reserved layout space while its mocked request remains pending. Contributor matching and configuration decoding behavior are preserved. Checked on Blox: 36 targeted JavaScript tests, 30 focused Rust tests, the Playwright image-height regression, TypeScript/Biome, formatting, security/file-size gates, 6,508 desktop tests, the full Tauri test workspace (including 3,204 main-crate tests), and desktop/web builds. The full `just ci` run could not finish mobile checks because Blox connections to `pub.dev` were reset and a locked dependency was missing from its offline cache. The broader Rust unit run passed 16 lanes but hit an unrelated catalog timeout in `buzz-agent`; that exact test passed in isolation. Signed-off-by: jm Co-authored-by: Codex --- .../src-tauri/src/managed_agents/agent_env.rs | 7 +-- .../src/managed_agents/config_bridge/goose.rs | 10 ++--- .../ui/agentSessionToolSummary.test.mjs | 2 +- .../lib/projectContributorMatching.test.mjs | 44 +++++++++---------- .../projects/lib/projectPathDisplay.test.mjs | 6 +-- desktop/src/testing/e2eBridge.ts | 12 ++--- desktop/tests/e2e/scroll-history.spec.ts | 32 +++++++------- 7 files changed, 57 insertions(+), 56 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index 59b300d9d1..1900ade0d0 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -320,16 +320,17 @@ mod tests { #[test] fn build_env_map_agent_env_blob_is_decoded_and_folded() { use base64::Engine as _; - let raw = "DATABRICKS_HOST=https://block-lakehouse-production.cloud.databricks.com/\nDATABRICKS_MODEL=goose-claude-opus-4-8"; + let raw = + "DATABRICKS_HOST=https://workspace.example.com/\nDATABRICKS_MODEL=synthetic-chat-model"; let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); let map = build_env_map(None, None, Some(&blob)); assert_eq!( map.get("DATABRICKS_HOST").map(String::as_str), - Some("https://block-lakehouse-production.cloud.databricks.com/") + Some("https://workspace.example.com/") ); assert_eq!( map.get("DATABRICKS_MODEL").map(String::as_str), - Some("goose-claude-opus-4-8") + Some("synthetic-chat-model") ); } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs index fce54edc40..2cdc4fed23 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -268,17 +268,17 @@ extensions: #[test] fn databricks_host_without_explicit_provider_infers_databricks() { let yaml = r#" -DATABRICKS_HOST: https://block-lakehouse-production.cloud.databricks.com/ +DATABRICKS_HOST: https://workspace.example.com/ GOOSE_TELEMETRY_ENABLED: false "#; let cfg = parse_goose_config(yaml).unwrap(); assert_eq!(cfg.provider.as_deref(), Some("databricks")); // The flat DATABRICKS_HOST key must be canonical in extra so that - // `file_key_present("DATABRICKS_HOST")` returns true. This is Will's - // typical config — flat key, no active_provider. + // `file_key_present("DATABRICKS_HOST")` returns true for a flat key + // with no active_provider. assert_eq!( cfg.extra.get("DATABRICKS_HOST").map(|s| s.as_str()), - Some("https://block-lakehouse-production.cloud.databricks.com/"), + Some("https://workspace.example.com/"), "flat DATABRICKS_HOST must be stored as 'DATABRICKS_HOST' in extra" ); } @@ -304,7 +304,7 @@ DATABRICKS_HOST: https://dbc.example.com fn explicit_provider_wins_over_databricks_inference() { let yaml = r#" GOOSE_PROVIDER: anthropic -DATABRICKS_HOST: https://block-lakehouse-production.cloud.databricks.com/ +DATABRICKS_HOST: https://workspace.example.com/ "#; let cfg = parse_goose_config(yaml).unwrap(); assert_eq!(cfg.provider.as_deref(), Some("anthropic")); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index ca83ab8b43..d88a771206 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -88,7 +88,7 @@ test("buildCompactToolSummary formats shell command preview", () => { test("buildCompactToolSummary formats view_image thumbnail source", () => { const source = - "https://sprout-oss.stage.blox.sqprod.co/media/ffd1b2721f2d52e19f0ca2be9aa7842cdec5b4e0215aaab2a67c26a2a76a6a83.png"; + "https://media.example.com/media/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789.png"; const summary = buildCompactToolSummary( makeTool({ toolName: "buzz-dev-mcp__view_image", diff --git a/desktop/src/features/projects/lib/projectContributorMatching.test.mjs b/desktop/src/features/projects/lib/projectContributorMatching.test.mjs index e652f72053..223d3f3249 100644 --- a/desktop/src/features/projects/lib/projectContributorMatching.test.mjs +++ b/desktop/src/features/projects/lib/projectContributorMatching.test.mjs @@ -20,20 +20,20 @@ const PROFILES = { isAgent: true, }, [USER_PUBKEY]: { - displayName: "Thomas P", - avatarUrl: "https://example.com/thomas.png", - nip05Handle: "thomasp@example.com", + displayName: "Avery E", + avatarUrl: "https://example.com/avatars/avery.png", + nip05Handle: "avery@example.com", }, }; function makeCommit(overrides = {}) { return { - hash: "fed2b2c993896352400f3d8c574fa31a84188f18", - shortHash: "fed2b2c", - authorName: "Thomas Petersen", - authorEmail: "thomasp@squareup.com", + hash: "0123456789abcdef0123456789abcdef01234567", + shortHash: "0123456", + authorName: "Avery Example", + authorEmail: "avery@example.org", timestamp: 1_700_000_000, - subject: "Add simple score HUD", + subject: "Add sample status widget", ...overrides, }; } @@ -191,7 +191,7 @@ test("profileForCommit prefers the signed PR-event mapping", () => { test("profileForCommit falls back to exact git author matching", () => { // No mapping entry — the git author email matches the user's NIP-05. - const commit = makeCommit({ authorEmail: "thomasp@example.com" }); + const commit = makeCommit({ authorEmail: "avery@example.com" }); const matched = profileForCommit(commit, PROFILES, new Map()); assert.equal(matched?.pubkey, USER_PUBKEY); }); @@ -200,7 +200,7 @@ test("profileForCommit resolves an npub git author to its profile", () => { const commit = makeCommit({ authorName: pubkeyToNpub(USER_PUBKEY) }); const matched = profileForCommit(commit, PROFILES, new Map()); assert.equal(matched?.pubkey, USER_PUBKEY); - assert.equal(matched?.profile.displayName, "Thomas P"); + assert.equal(matched?.profile.displayName, "Avery E"); }); test("profileForCommit ignores malformed npub git authors", () => { @@ -217,23 +217,23 @@ test("profileForCommit returns null when nothing matches", () => { }); test("mapped pubkey without a fetched profile falls back to git author", () => { - const commit = makeCommit({ authorEmail: "thomasp@example.com" }); + const commit = makeCommit({ authorEmail: "avery@example.com" }); const map = new Map([[commit.hash, "f".repeat(64)]]); const matched = profileForCommit(commit, PROFILES, map); assert.equal(matched?.pubkey, USER_PUBKEY); }); test("viewer git identity attributes their own commits", () => { - // Git author "Thomas Petersen " matches no profile + // The commit author's example.org address matches no profile // field, but it is the viewer's own git config identity. const commit = makeCommit(); const matched = profileForCommit(commit, PROFILES, new Map(), { pubkey: USER_PUBKEY, - name: "Thomas Petersen", - email: "thomasp@squareup.com", + name: "Avery Example", + email: "avery@example.org", }); assert.equal(matched?.pubkey, USER_PUBKEY); - assert.equal(matched?.profile.displayName, "Thomas P"); + assert.equal(matched?.profile.displayName, "Avery E"); }); test("viewer git identity does not claim other authors' commits", () => { @@ -243,8 +243,8 @@ test("viewer git identity does not claim other authors' commits", () => { }); const matched = profileForCommit(commit, PROFILES, new Map(), { pubkey: USER_PUBKEY, - name: "Thomas Petersen", - email: "thomasp@squareup.com", + name: "Avery Example", + email: "avery@example.org", }); assert.equal(matched, null); }); @@ -254,13 +254,13 @@ test("a shared display name alone never borrows the viewer's identity", () => { // the viewer's own commits actually carry — may attribute a commit to the // viewer's pubkey. const commit = makeCommit({ - authorName: "Thomas Petersen", + authorName: "Avery Example", authorEmail: "impostor@example.org", }); const matched = profileForCommit(commit, PROFILES, new Map(), { pubkey: USER_PUBKEY, - name: "Thomas Petersen", - email: "thomasp@squareup.com", + name: "Avery Example", + email: "avery@example.org", }); assert.equal(matched, null); }); @@ -270,8 +270,8 @@ test("signed PR mapping wins over the viewer git identity", () => { const map = new Map([[commit.hash, AGENT_PUBKEY]]); const matched = profileForCommit(commit, PROFILES, map, { pubkey: USER_PUBKEY, - name: "Thomas Petersen", - email: "thomasp@squareup.com", + name: "Avery Example", + email: "avery@example.org", }); assert.equal(matched?.pubkey, AGENT_PUBKEY); }); diff --git a/desktop/src/features/projects/lib/projectPathDisplay.test.mjs b/desktop/src/features/projects/lib/projectPathDisplay.test.mjs index a2e0e35b63..64dcbf71b9 100644 --- a/desktop/src/features/projects/lib/projectPathDisplay.test.mjs +++ b/desktop/src/features/projects/lib/projectPathDisplay.test.mjs @@ -9,14 +9,14 @@ test("keeps short repository paths intact", () => { test("shortens long repository paths to their trailing segments", () => { assert.equal( - shortenProjectPath("/Users/thomasp/sprout/projects/buzz"), + shortenProjectPath("/Users/sample-user/sprout/projects/buzz"), "…/sprout/projects/buzz", ); }); test("normalizes Windows separators for display", () => { assert.equal( - shortenProjectPath("C:\\Users\\thomasp\\repos\\buzz"), - "…/thomasp/repos/buzz", + shortenProjectPath("C:\\Users\\sample-user\\repos\\buzz"), + "…/sample-user/repos/buzz", ); }); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 3b42a2be85..ae641800c5 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12895,9 +12895,9 @@ export function maybeInstallE2eTauriMocks() { // to in-app activity tracking. return null; case "get_git_identity": - // Matches the "Thomas P" author on a mock snapshot commit so the + // Matches the synthetic human author on a mock snapshot commit so the // viewer-identity avatar attribution is exercised in e2e. - return { name: "Thomas P", email: "thomasp@example.com" }; + return { name: "Avery E", email: "avery@example.com" }; case "get_project_repo_snapshot": if (activeConfig?.mock?.projectRepoSnapshotDelayMs) { await new Promise((resolve) => @@ -12931,8 +12931,8 @@ export function maybeInstallE2eTauriMocks() { { hash: "123456789abcdef0123456789abcdef012345678", short_hash: "1234567", - author_name: "Thomas P", - author_email: "thomasp@example.com", + author_name: "Avery E", + author_email: "avery@example.com", timestamp: Math.floor(Date.now() / 1000) - 1_800, subject: "Point project repository details at active branch", }, @@ -12961,8 +12961,8 @@ export function maybeInstallE2eTauriMocks() { last_commit_at: Math.floor(Date.now() / 1000) - 600, }, { - name: "Thomas P", - email: "thomasp@example.com", + name: "Avery E", + email: "avery@example.com", commit_count: 3, last_commit_at: Math.floor(Date.now() / 1000) - 1_800, }, diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 0e1d4cfed6..6c0b9afd82 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -553,23 +553,23 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a expect(lastRowOffset as number).toBeLessThanOrEqual(200); }); -const REAL_BUZZ_BUGS_IMAGE_SHA = - "ff2862080bac3d009f97cad4bb94e6efec328eaaee058a405e854acd49fc1483"; -const REAL_BUZZ_BUGS_IMAGE_URL = `https://sprout-oss.stage.blox.sqprod.co/media/${REAL_BUZZ_BUGS_IMAGE_SHA}.png`; -const REAL_BUZZ_BUGS_IMAGE_TAG = [ +const SYNTHETIC_WIDE_IMAGE_SHA = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const SYNTHETIC_WIDE_IMAGE_URL = `https://media.example.com/media/${SYNTHETIC_WIDE_IMAGE_SHA}.png`; +const SYNTHETIC_WIDE_IMAGE_TAG = [ "imeta", - `url ${REAL_BUZZ_BUGS_IMAGE_URL}`, + `url ${SYNTHETIC_WIDE_IMAGE_URL}`, "m image/png", - `x ${REAL_BUZZ_BUGS_IMAGE_SHA}`, - "size 26257", - "dim 951x244", - "filename image.png", + `x ${SYNTHETIC_WIDE_IMAGE_SHA}`, + "size 24576", + "dim 960x240", + "filename sample-banner.png", ] as string[]; -test("reserves real buzz-bugs imeta image height before image loads", async ({ +test("reserves imeta image height before a synthetic image loads", async ({ page, }) => { - await page.route("**/media/**", () => new Promise(() => {})); + await page.route(SYNTHETIC_WIDE_IMAGE_URL, () => new Promise(() => {})); await installMockBridge(page); await page.goto("/"); await page.waitForFunction( @@ -585,15 +585,15 @@ test("reserves real buzz-bugs imeta image height before image loads", async ({ }); }, { - content: `this setting gets reverted on every update\n![image](${REAL_BUZZ_BUGS_IMAGE_URL})`, - extraTags: [REAL_BUZZ_BUGS_IMAGE_TAG], + content: `Synthetic wide image fixture\n![sample banner](${SYNTHETIC_WIDE_IMAGE_URL})`, + extraTags: [SYNTHETIC_WIDE_IMAGE_TAG], }, ); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - const image = page.getByAltText("image").last(); + const image = page.getByAltText("sample banner").last(); const rect = await image.evaluate((element) => { const img = element as HTMLImageElement; const box = img.getBoundingClientRect(); @@ -606,8 +606,8 @@ test("reserves real buzz-bugs imeta image height before image loads", async ({ width: box.width, }; }); - expect(rect.attrWidth).toBe("951"); - expect(rect.attrHeight).toBe("244"); + expect(rect.attrWidth).toBe("960"); + expect(rect.attrHeight).toBe("240"); expect(rect.offsetHeight).toBeGreaterThan(80); }); From fe808eae0e1194be48923567c4e0f179aead5acf Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 18 Sep 2026 16:46:49 -0700 Subject: [PATCH 12/22] fix(push): label plaintext push gateway service as HTTP (#7717) The gateway serves plaintext HTTP on its public container port, but the chart currently advertises that Service port as `https`. Label it `http` so protocol-aware proxies discover the application's actual protocol. Numeric port 8080, target port `public`, and the application version remain unchanged. Consumers that refer to the old Service port name must update that reference. Prepare chart version 0.3.1, which is absent from the current release tags and OCI registry. This ordinary fix branch does not trigger the chart release lane. Publishing and downstream dependency updates remain separate steps. This corrects protocol metadata; it is not evidence of a TLS-origination failure. Istio controls upstream TLS through DestinationRules and auto-mTLS independently of this port label. Validation: added rendered-Service and container-port assertions across default, production, and Gateway API fixtures. The assertion fails with the old label and passes with the fix. The full chart render/lint suite and release-contract checks pass. Repository-wide `just ci` is still running locally. --------- Signed-off-by: Tom Brow --- .../charts/buzz-push-gateway/templates/service.yaml | 2 +- deploy/charts/buzz-push-gateway/tests/render.sh | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/deploy/charts/buzz-push-gateway/templates/service.yaml b/deploy/charts/buzz-push-gateway/templates/service.yaml index 73c08c1efa..1ae933d329 100644 --- a/deploy/charts/buzz-push-gateway/templates/service.yaml +++ b/deploy/charts/buzz-push-gateway/templates/service.yaml @@ -6,4 +6,4 @@ metadata: spec: selector: {{- include "push.runtimeLabels" . | nindent 4 }} ports: - - { name: https, port: {{ .Values.service.port }}, targetPort: public } + - { name: http, port: {{ .Values.service.port }}, targetPort: public } diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index 675fea6fde..0f4f00bb65 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -34,6 +34,18 @@ def assert!(condition, detail = "assertion failed") raise detail unless condition end +# The application serves plaintext HTTP behind ingress TLS termination. Check +# every ingress mode so service discovery never advertises application TLS. +ARGV.each do |path| + resources = YAML.load_stream(File.read(path)).compact + service = resources.find { |x| x["kind"] == "Service" } + ports = service.dig("spec", "ports") + assert!(ports == [{ "name" => "http", "port" => 8080, "targetPort" => "public" }], ports.inspect) + deployment = resources.find { |x| x["kind"] == "Deployment" } + container_ports = deployment.dig("spec", "template", "spec", "containers", 0, "ports") + assert!(container_ports.any? { |port| port["name"] == "public" && port["containerPort"] == 8080 }) +end + xs = YAML.load_stream(File.read(ARGV[0])).compact svc = xs.find { |x| x["kind"] == "Service" } assert!(svc.dig("spec", "ports").map { |port| port["targetPort"] } == ["public"]) From 4e65148e76bd4f8dff757da4014a37fbc95fcc12 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 18 Sep 2026 17:04:41 -0700 Subject: [PATCH 13/22] release: push gateway chart 0.3.1 (#7749) Publishes chart 0.3.1 for `buzz-push-gateway`, containing #7717. Signed-off-by: Tom Brow --- deploy/charts/buzz-push-gateway/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/charts/buzz-push-gateway/Chart.yaml b/deploy/charts/buzz-push-gateway/Chart.yaml index c466034ce1..7b630c9363 100644 --- a/deploy/charts/buzz-push-gateway/Chart.yaml +++ b/deploy/charts/buzz-push-gateway/Chart.yaml @@ -3,6 +3,6 @@ apiVersion: v2 # branches (see docs/push-gateway-deployment.md, "Gateway chart release"). name: buzz-push-gateway description: Public capability-gated APNs last-hop gateway for Buzz -version: 0.3.0 +version: 0.3.1 appVersion: "0.1.0" type: application From 5511b56fcf0047f9d0d4887dc75125b6933a6bbd Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 19 Sep 2026 01:39:37 -0700 Subject: [PATCH 14/22] fix(relay): exclude ephemeral activity from message quota (#7736) ## Summary Stop charging ephemeral WebSocket activity against the per-minute `Messages` quota. Presence, typing, and observer traffic should not consume the allowance intended for persistable event attempts. - Use the existing `buzz_core::kind::is_ephemeral` predicate (kinds `20000..=29999`) at the second admission gate. All authenticated EVENT/REQ/COUNT traffic still consumes the shared `WsEvents` budget first. Non-ephemeral EVENT attempts remain charged, including attempts later rejected by authorization or validation. - Add bounded `bucket` labels (`messages`, `ws_operations`, `api_calls`) to `buzz_admission_rejections_total`. HTTP changes are metric labeling only. Wire rejection text remains byte-for-byte unchanged, preserving existing client parsers. - Add real-Redis admission regressions and select them in the existing relay integration CI job. No quota increases, identity/keying changes, client changes, new limiter, or deployment. Downstream authorization and fail-closed Redis behavior are unchanged. ### Related issue Duplicate search found overlapping work: open #4912 and closed #4902. They propose the same ephemeral-accounting correction alongside broader wire/client/config changes. The relay fix is a deliberately narrower four-file implementation on the current admission module, with metrics-only diagnostics and real-Redis regression coverage. A separate test-only CI repair is documented below. It does not close or claim to supersede those PRs. Related symptom: #7411. This PR does **not** resolve subscription bursts exhausting the shared WS budget; that budget is intentionally preserved. It also does not establish or fix the cause of HTTP quota exhaustion. ### Testing Validation ran against base `779af8886caae1317b4de962082429867ab61503` plus the exact patch now committed as `70ea0528594e25e6dcb40cd74620bd5ef0177592` (binary diff SHA-256 `5064e1cbb87ecb9eff10336bae14b912cac442e1edc247aa4f093de30e4f9717`). Normal commit/push hooks subsequently passed on the committed head, without bypass. Passed: - Independent review and rerun: 11 rejection tests (including real Redis) plus 4 admission tests. Covers ephemeral range boundaries, human/agent budgets, persistable events, ephemeral admission after message exhaustion, mixed EVENT/REQ/COUNT flood exhaustion, Redis outage, exact wire payloads, and bucket metrics. Three mutations were caught: reintroducing ephemeral billing, exempting stored events, and bypassing WS rejection. - `just test-unit`: all 17 runner groups; `just test-integration`: all 3 runner groups. Auth integration reported no tests; ignored database integration tests are not claimed as executed. - Relay all-target Clippy, formatting, workflow actionlint, pre-commit/signoff hooks, and all applicable pre-push lanes (`push-head-scope`, `branch-skew`, `file-size-check`, `rust-tests`, `desktop-tauri-checks`). - Release relay/CLI/admin build and isolated release-binary workflow: channel creation, signed root/reply sends, exact-ID readback, and thread retrieval. Debug and release WS smoke admitted three presence events with a Messages limit of two, admitted two persistable events, rejected the third, still admitted presence afterward, and rejected author mismatch. Debug Prometheus confirmed `bucket="messages"`. **Outstanding validation gap:** the additional `cargo test -p buzz-relay --no-fail-fast` run was not green: library 1,055 passed / 2 failed / 100 ignored; binary 13 passed / 1 ignored; boot integration 9 passed. Failures were `api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` (504 instead of 200) and `telemetry::tests::trace_context_lookup_does_not_enable_callsites` (tracing assertion). Neither failure was diagnosed or repaired in this scope, and this PR does not claim a fully green package suite. `just ci` was not run locally; hosted CI remains to validate the PR. All runtime tests used isolated local services. Owned services have been stopped; nothing was deployed and no production data was touched. ### CI cancellation-test repair At the original relay head, hosted Rust unit tests failed on the pre-existing cancellation harness race tracked in #4945. With owner authorization, this PR now adapts the one-file repair from #5003 (original commit `cc8dc6124957acfd67fe2336396b58e0ff43ec60`), preserving author credit. #5003's old hosted workflows failed without starting jobs; their status is not used as validation evidence. Commit `55fe5f16618a51d03ad48d6f2f97b2f8b463607a` holds the fake provider response until a successful cancellation acknowledgement, then replays all pre-acknowledgement usage/prompt frames in order. It retains the cancellation and usage-before-response assertions and explicitly requires prompt completion. No production code changes in this follow-up. Validation on parent `70ea052859` plus this test patch, before the pre-commit-only assertion wrapping: full `cargo test -p buzz-agent --no-fail-fast` passed (715 tests, one ignored); 100 targeted repetitions passed with four concurrent workers; all-target buzz-agent Clippy passed. The unmodified parent also passed 50 local repetitions, so this is not claimed as a local reproduction of the intermittent CI failure. The synchronization defect was confirmed from the source, original CI log, and #4945. Hosted CI must validate the updated head. Independent source review cleared exact commit `55fe5f16618a51d03ad48d6f2f97b2f8b463607a`. Normal pre-commit/signoff and all applicable pre-push hooks passed without bypass. The original relay-package mesh-demo and telemetry failures remain disclosed above and were not modified by this test-only repair. --------- Signed-off-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: kiranmagic7 <262980978+kiranmagic7@users.noreply.github.com> --- .github/workflows/_ci-relay.yml | 8 + crates/buzz-agent/tests/fake_llm.rs | 36 ++- crates/buzz-relay/src/api/bridge.rs | 4 +- crates/buzz-relay/src/rejection.rs | 29 +- .../buzz-relay/src/rejection/quota_tests.rs | 250 ++++++++++++++++++ 5 files changed, 304 insertions(+), 23 deletions(-) create mode 100644 crates/buzz-relay/src/rejection/quota_tests.rs diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index 388d48dd94..5776f37fe3 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -497,6 +497,14 @@ jobs: --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and test(/api::git::settings::tests::external_infra::/)' \ --run-ignored ignored-only + - name: Relay quota accounting and rejection regressions + env: + BUZZ_TEST_REDIS_URL: redis://localhost:6379 + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and (test(/^rejection::/) or test(/^admission::/))' \ + --run-ignored all - name: Workflow message provenance unit tests # The relay's workflow_sink suite is not selected by the infra-free # unit job. Its ignored database cases run in the isolated PostgreSQL diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index 9822243d5f..2451deae08 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -1320,7 +1320,7 @@ async fn mid_turn_usage_includes_earlier_turns() { /// Setup: round 1 is a tool call WITH usage (tokens are captured). After the /// tool_call_update notification (proving round 1 is fully processed), we gate /// the round-2 LLM response behind a `oneshot` barrier that only releases after -/// cancel is sent. This guarantees the turn exits with `stopReason: "cancelled"` +/// cancel is acknowledged. This guarantees the turn exits with `stopReason: "cancelled"` /// deterministically, even on a slow CI worker. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancelled_turn_with_usage_emits_notification_before_response() { @@ -1335,7 +1335,7 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { // in-flight TCP request can resolve. The queue is empty for round 2, so the // agent receives the fallback "no canned response" body which it treats as // an LLM error; the cancel check at the round boundary fires first because - // the gate is only released after cancel is enqueued. + // the gate is only released after cancel is acknowledged. let responses = vec![openai_tool_call_with_usage( "call_cancel_test", "fake__noop", @@ -1371,7 +1371,7 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { } } // For request 2+ (round 2), wait for the gate to open before - // responding. This ensures cancel is sent before round 2 resolves, + // responding. This ensures cancel is processed before round 2 resolves, // making stopReason: cancelled deterministic. if req_num >= 2 { let rx = gate.lock().await.take(); @@ -1415,20 +1415,27 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { }) .await; - // Now send cancel and release the round-2 gate. Cancel is enqueued before - // round 2 can respond, so the turn exits with stopReason: cancelled. + // Writing to stdin does not prove the agent processed cancel. Keep the + // provider blocked until the acknowledgement, retaining all earlier frames + // so usage/prompt ordering is checked even if the turn finishes before ACK. let c_id = h.send("session/cancel", json!({"sessionId": sid})).await; + let (frames_before_cancel_ack, cancel_ack) = + recv_until_with_drain(&mut h, |v| v["id"] == json!(c_id)).await; + assert_eq!(cancel_ack.get("result"), Some(&Value::Null), "{cancel_ack}"); + assert!(cancel_ack.get("error").is_none(), "{cancel_ack}"); let _ = gate_tx.send(()); // unblock round 2 let mut saw_usage_before_prompt_response = false; let mut saw_usage = false; - let mut saw_cancel_ok = false; let mut saw_prompt_response = false; - for _ in 0..40 { - let v = h.recv().await; - if v["id"] == json!(c_id) { - saw_cancel_ok = true; - } else if is_usage_update(&v) { + let mut pending_frames = VecDeque::from(frames_before_cancel_ack); + let frame_budget = 40 + pending_frames.len(); + for _ in 0..frame_budget { + let v = match pending_frames.pop_front() { + Some(v) => v, + None => h.recv().await, + }; + if is_usage_update(&v) { saw_usage = true; if !saw_prompt_response { saw_usage_before_prompt_response = true; @@ -1441,11 +1448,14 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { "turn must end with stopReason: cancelled" ); } - if saw_usage && saw_prompt_response && saw_cancel_ok { + if saw_usage && saw_prompt_response { break; } } - assert!(saw_cancel_ok, "session/cancel was not acknowledged"); + assert!( + saw_prompt_response, + "session/prompt did not finish after cancel" + ); assert!( saw_usage, "expected usage_update notification for cancelled turn with observed tokens" diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 37c549610d..6ec0483cb9 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -39,14 +39,14 @@ pub(crate) async fn enforce_http_admission( { Ok(()) => Ok(()), Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => { - metrics::counter!("buzz_admission_rejections_total", "transport" => "http", "reason" => "quota").increment(1); + metrics::counter!("buzz_admission_rejections_total", "transport" => "http", "reason" => "quota", "bucket" => "api_calls").increment(1); Err(api_error( StatusCode::TOO_MANY_REQUESTS, &format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"), )) } Err(crate::admission::AdmissionError::Unavailable) => { - metrics::counter!("buzz_admission_rejections_total", "transport" => "http", "reason" => "unavailable").increment(1); + metrics::counter!("buzz_admission_rejections_total", "transport" => "http", "reason" => "unavailable", "bucket" => "api_calls").increment(1); Err(api_error( StatusCode::SERVICE_UNAVAILABLE, "rate-limited: shared admission unavailable", diff --git a/crates/buzz-relay/src/rejection.rs b/crates/buzz-relay/src/rejection.rs index 733c427f95..1fb760ec4d 100644 --- a/crates/buzz-relay/src/rejection.rs +++ b/crates/buzz-relay/src/rejection.rs @@ -75,11 +75,12 @@ pub(crate) async fn enforce_ws_admission( ws_limit, ) .await; - if !send_admission_result(conn, ws_result, msg) { + if !send_admission_result(conn, ws_result, msg, "ws_operations") { return false; } - if is_event { + if matches!(msg, ClientMessage::Event(event) if !buzz_core::kind::is_ephemeral(event.kind.as_u16() as u32)) + { let message_limit = if is_agent { limits.agent_standard_messages_per_min } else { @@ -94,9 +95,9 @@ pub(crate) async fn enforce_ws_admission( message_limit, ) .await; - // The per-minute message quota only applies to EVENTs, and its - // rejection must be as correlatable as the burst quota's. - if !send_admission_result(conn, message_result, msg) { + // Only persistable EVENT attempts consume the minute quota. Ephemeral activity + // still passes the shared WS flood limit above and normal event auth. + if !send_admission_result(conn, message_result, msg, "messages") { return false; } } @@ -115,12 +116,13 @@ fn send_admission_result( conn: &ConnectionState, result: Result<(), AdmissionError>, msg: &ClientMessage, + bucket: &'static str, ) -> bool { let target = rejection_target_for(msg); match result { Ok(()) => true, Err(AdmissionError::Exceeded { reset_in_secs }) => { - metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "quota").increment(1); + metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "quota", "bucket" => bucket).increment(1); conn.send(request_rejection_message( target, &format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"), @@ -128,7 +130,7 @@ fn send_admission_result( false } Err(AdmissionError::Unavailable) => { - metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "unavailable").increment(1); + metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "unavailable", "bucket" => bucket).increment(1); conn.send(request_rejection_message( target, "rate-limited: shared admission unavailable", @@ -198,6 +200,7 @@ mod tests { &conn, Err(AdmissionError::Exceeded { reset_in_secs: 7 }), &msg, + "ws_operations", ); assert!(!admitted, "an over-quota frame is not admitted"); @@ -227,7 +230,12 @@ mod tests { let (conn, mut rx) = test_conn(); let (msg, event_id) = parsed_event_message(); - send_admission_result(&conn, Err(AdmissionError::Unavailable), &msg); + send_admission_result( + &conn, + Err(AdmissionError::Unavailable), + &msg, + "ws_operations", + ); let frame = sent_frame(&mut rx); assert_eq!(frame[0], "OK"); @@ -247,6 +255,7 @@ mod tests { &conn, Err(AdmissionError::Exceeded { reset_in_secs: 7 }), &msg, + "ws_operations", ); let frame = sent_frame(&mut rx); @@ -269,6 +278,7 @@ mod tests { &conn, Err(AdmissionError::Exceeded { reset_in_secs: 7 }), &msg, + "ws_operations", ); let frame = sent_frame(&mut rx); @@ -333,3 +343,6 @@ mod tests { assert_eq!(frame[1], "history-abc"); } } + +#[cfg(test)] +mod quota_tests; diff --git a/crates/buzz-relay/src/rejection/quota_tests.rs b/crates/buzz-relay/src/rejection/quota_tests.rs new file mode 100644 index 0000000000..9f546469e2 --- /dev/null +++ b/crates/buzz-relay/src/rejection/quota_tests.rs @@ -0,0 +1,250 @@ +//! Real Redis counters through the production admission entrypoints. Tests use +//! disposable identities and expiring keys, never FLUSHDB or a shared identity. + +use super::*; +use axum::extract::ws::Message as WsMessage; +use buzz_auth::{AuthService, RateLimitConfig}; +use buzz_pubsub::rate_limiter::RedisRateLimiter; +use metrics_util::debugging::{DebugValue, DebuggingRecorder}; +use nostr::{EventBuilder, Keys, Kind}; +use std::sync::Arc; +use tokio::sync::mpsc; + +use crate::connection::tests::{authenticated_state, read_frame, test_conn_with_auth}; + +async fn state_with_limits(messages: u64, agent_messages: u64, ws: u64) -> AppState { + let url = std::env::var("BUZZ_TEST_REDIS_URL").expect("explicit isolated BUZZ_TEST_REDIS_URL"); + let mut state = (*crate::state::tests::test_state().await).clone(); + let mut auth = state.auth.config().clone(); + auth.rate_limits = RateLimitConfig { + human_messages_per_min: messages, + agent_standard_messages_per_min: agent_messages, + human_ws_events_per_sec: ws, + human_api_calls_per_min: 2, + ..RateLimitConfig::default() + }; + state.auth = Arc::new(AuthService::new(auth)); + state.redis_pool = deadpool_redis::Config::from_url(url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + state.admission_rate_limiter = Arc::new(RedisRateLimiter::new(state.redis_pool.clone())); + state +} + +fn connection(agent: bool) -> (Arc, mpsc::Receiver) { + let AuthState::Authenticated(mut auth) = authenticated_state() else { + unreachable!() + }; + if agent { + auth.agent_owner_pubkey = Some(Keys::generate().public_key()); + } + test_conn_with_auth(AuthState::Authenticated(auth)) +} + +fn event(kind: u16) -> ClientMessage { + let event = EventBuilder::new(Kind::from(kind), "") + .sign_with_keys(&Keys::generate()) + .unwrap(); + ClientMessage::parse(&serde_json::json!(["EVENT", event]).to_string()).unwrap() +} + +fn pubkey(conn: &ConnectionState) -> nostr::PublicKey { + let AuthState::Authenticated(auth) = conn.auth_state_snapshot() else { + unreachable!() + }; + auth.pubkey +} + +async fn counter(state: &AppState, conn: &ConnectionState, bucket: LimitType) -> Option { + let key = buzz_auth::rate_limit::rate_limit_key(&conn.tenant, &pubkey(conn), &bucket); + let mut redis = state.redis_pool.get().await.unwrap(); + redis::cmd("GET") + .arg(key) + .query_async(&mut *redis) + .await + .unwrap() +} + +fn assert_metric( + recorder: &DebuggingRecorder, + transport: &str, + reason: &str, + bucket: &str, + count: u64, +) { + let snapshot = recorder.snapshotter().snapshot().into_vec(); + let labels = [ + ("transport", transport), + ("reason", reason), + ("bucket", bucket), + ]; + let actual = snapshot.iter().find_map(|(key, _, _, value)| { + (key.key().name() == "buzz_admission_rejections_total" + && labels + .iter() + .all(|(k, v)| key.key().labels().any(|l| l.key() == *k && l.value() == *v))) + .then(|| match value { + DebugValue::Counter(value) => *value, + _ => panic!("expected counter"), + }) + }); + assert_eq!(actual, Some(count)); +} + +fn assert_quota_frame(frame: &serde_json::Value, msg: &ClientMessage, max_retry: u64) { + let reason = match msg { + ClientMessage::Event(event) => { + assert_eq!(frame[0], "OK"); + assert_eq!(frame[1], event.id.to_hex()); + assert_eq!(frame[2], false); + frame[3].as_str().unwrap() + } + ClientMessage::Req { sub_id, .. } | ClientMessage::Count { sub_id, .. } => { + assert_eq!(frame[0], "CLOSED"); + assert_eq!(frame[1], *sub_id); + frame[2].as_str().unwrap() + } + _ => panic!("unexpected frame"), + }; + // Exact grammar: clients including buzz-app anchor both ends of this hint. + let seconds: u64 = reason + .strip_prefix("rate-limited: quota exceeded; retry in ") + .unwrap() + .strip_suffix('s') + .unwrap() + .parse() + .unwrap(); + assert!(seconds <= max_retry); +} + +#[tokio::test] +#[ignore = "requires explicit isolated BUZZ_TEST_REDIS_URL"] +async fn ephemeral_events_leave_human_and_agent_message_budgets_untouched() { + let state = state_with_limits(2, 3, 100).await; + let recorder = DebuggingRecorder::new(); + let _guard = metrics::set_default_local_recorder(&recorder); + // Both range boundaries, typing, presence, and observer frames. Adjacent + // non-ephemeral kinds below must still consume the message allowance. + let ephemeral_kinds = [20000, 20001, 20002, 24200, 29999]; + for (agent, limit) in [(false, 2), (true, 3)] { + for stored_kind in [0, 1, 9, 19999, 30000, 40002] { + let (conn, mut rx) = connection(agent); + for kind in ephemeral_kinds { + assert!(enforce_ws_admission(&event(kind), &conn, &state).await); + } + assert_eq!(counter(&state, &conn, LimitType::Messages).await, None); + assert_eq!(counter(&state, &conn, LimitType::WsEvents).await, Some(5)); + for expected in 1..=limit { + assert!(enforce_ws_admission(&event(stored_kind), &conn, &state).await); + assert_eq!( + counter(&state, &conn, LimitType::Messages).await, + Some(expected) + ); + } + let rejected = event(stored_kind); + assert!(!enforce_ws_admission(&rejected, &conn, &state).await); + assert_quota_frame(&read_frame(&mut rx), &rejected, 60); + for kind in ephemeral_kinds { + assert!(enforce_ws_admission(&event(kind), &conn, &state).await); + } + assert_eq!( + counter(&state, &conn, LimitType::Messages).await, + Some(limit + 1) + ); + assert!(rx.try_recv().is_err()); + } + } + assert_metric(&recorder, "websocket", "quota", "messages", 12); +} + +#[tokio::test] +#[ignore = "requires explicit isolated BUZZ_TEST_REDIS_URL"] +async fn ws_flood_budget_is_shared_by_ephemeral_stored_req_and_count() { + let state = state_with_limits(10, 20, 1).await; // Five operations per window. + let recorder = DebuggingRecorder::new(); + let _guard = metrics::set_default_local_recorder(&recorder); + let (conn, mut rx) = connection(false); + let req = ClientMessage::parse(r#"["REQ","history",{"kinds":[9]}]"#).unwrap(); + let count = ClientMessage::parse(r#"["COUNT","count",{"kinds":[9]}]"#).unwrap(); + for msg in [ + event(20001), + event(20002), + event(9), + req.clone(), + count.clone(), + ] { + assert!(enforce_ws_admission(&msg, &conn, &state).await); + } + assert_eq!(counter(&state, &conn, LimitType::Messages).await, Some(1)); + for msg in [event(24200), event(9), req, count] { + assert!(!enforce_ws_admission(&msg, &conn, &state).await); + assert_quota_frame(&read_frame(&mut rx), &msg, 5); + } + assert_eq!(counter(&state, &conn, LimitType::Messages).await, Some(1)); + assert_metric(&recorder, "websocket", "quota", "ws_operations", 4); +} + +#[tokio::test] +#[ignore = "requires explicit isolated BUZZ_TEST_REDIS_URL"] +async fn http_quota_preserves_wire_error_and_reports_api_bucket() { + let state = state_with_limits(0, 0, 0).await; + let recorder = DebuggingRecorder::new(); + let _guard = metrics::set_default_local_recorder(&recorder); + let (conn, _) = connection(false); + for _ in 0..2 { + crate::api::bridge::enforce_http_admission(&state, &conn.tenant, &pubkey(&conn)) + .await + .unwrap(); + } + let (status, body) = + crate::api::bridge::enforce_http_admission(&state, &conn.tenant, &pubkey(&conn)) + .await + .unwrap_err(); + assert_eq!(status, axum::http::StatusCode::TOO_MANY_REQUESTS); + let seconds: u64 = body.0["error"] + .as_str() + .unwrap() + .strip_prefix("rate-limited: quota exceeded; retry in ") + .unwrap() + .strip_suffix('s') + .unwrap() + .parse() + .unwrap(); + assert!(seconds <= 60); + assert_eq!(counter(&state, &conn, LimitType::Messages).await, None); + assert_eq!(counter(&state, &conn, LimitType::WsEvents).await, None); + assert_metric(&recorder, "http", "quota", "api_calls", 1); +} + +#[tokio::test] +async fn ephemeral_and_http_admission_still_fail_closed_when_redis_is_unavailable() { + let state = crate::state::tests::test_state().await; + let recorder = DebuggingRecorder::new(); + let _guard = metrics::set_default_local_recorder(&recorder); + let (conn, mut rx) = connection(false); + let msg = event(20002); + assert!(!enforce_ws_admission(&msg, &conn, &state).await); + let ClientMessage::Event(event) = msg else { + unreachable!() + }; + assert_eq!( + read_frame(&mut rx), + serde_json::json!([ + "OK", + event.id.to_hex(), + false, + "rate-limited: shared admission unavailable" + ]) + ); + assert_metric(&recorder, "websocket", "unavailable", "ws_operations", 1); + let (status, body) = + crate::api::bridge::enforce_http_admission(&state, &conn.tenant, &pubkey(&conn)) + .await + .unwrap_err(); + assert_eq!(status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + body.0["error"], + "rate-limited: shared admission unavailable" + ); + assert_metric(&recorder, "http", "unavailable", "api_calls", 1); +} From 4ee9f8b35a612b5c83459af2fcee9f77000a29f6 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 19 Sep 2026 10:25:18 -0700 Subject: [PATCH 15/22] fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758) Long-pressing a message while the keyboard dismissed could leave the iOS action menu visible but unresponsive. Rebuilds installed a new selection handler before the previous hook's cleanup removed it. Keep that handler for the native view's lifetime and dispatch to the latest callback. Also let taps on decorative action icons reach the row control. Added regression coverage for callback rebuilds, view replacement/dismissal, and native icon/label/edge hit targets. The callback regression fails before the fix and passes after it. Validation: all 2,257 mobile tests (4 skipped), three unconfigured-push tests, mobile analysis, 42 focused action/handler tests, and three native XCTest checks passed. Physical iOS 26.5 taps verified keyboard-open long press, Edit, Copy (including clipboard contents), repeated opening, and outside dismissal. Desktop tests/builds, Tauri checks/tests, and the web build passed. The full local gate encountered unrelated Rust agent timeout failures; all 105 tests in the affected ACP test module passed when rerun serially. GitHub CI passed, including mobile tests, Swift tests, Android build, and iOS simulator/release builds. Codex code review completed without findings. No matching open issue or PR found. ### Before Synthetic message using the production long-press/menu components: Edit and Copy taps leave the menu open; tapping outside still dismisses it. https://github.com/user-attachments/assets/5e062d38-cbca-4be2-98b5-ca9a47b89df3 ### After With the keyboard open, the first Edit icon tap opens the editor. Reopening and tapping Copy copies the message and dismisses the menu. https://github.com/user-attachments/assets/49e11bfd-d531-4fa1-b551-5abd4fec83d7 Signed-off-by: Tom Brow --- .../Runner/NativeMessageActionSurface.swift | 2 + mobile/ios/RunnerTests/RunnerTests.swift | 36 +++++++++++ .../features/channels/message_actions.dart | 1 + .../message_action_popover.dart | 12 +--- .../native_message_action_selection.dart | 24 ++++++++ .../native_message_action_selection_test.dart | 60 +++++++++++++++++++ 6 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 mobile/lib/features/channels/message_actions/native_message_action_selection.dart create mode 100644 mobile/test/features/channels/native_message_action_selection_test.dart diff --git a/mobile/ios/Runner/NativeMessageActionSurface.swift b/mobile/ios/Runner/NativeMessageActionSurface.swift index 4b0d637ced..f71889eb34 100644 --- a/mobile/ios/Runner/NativeMessageActionSurface.swift +++ b/mobile/ios/Runner/NativeMessageActionSurface.swift @@ -157,6 +157,8 @@ final class NativeMessageActionRowControl: UIControl { ) let iconColumn = UIView() + // Decorative content must leave the entire row tappable. + iconColumn.isUserInteractionEnabled = false iconColumn.translatesAutoresizingMaskIntoConstraints = false actionImageView.translatesAutoresizingMaskIntoConstraints = false actionTitleLabel.translatesAutoresizingMaskIntoConstraints = false diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index d58661396b..00ce050b4f 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -904,6 +904,42 @@ class RunnerTests: XCTestCase { XCTAssertTrue(selected) } + @MainActor + func testNativeMessageActionRowReceivesTapsAcrossItsWholeSurface() throws { + let definition = try XCTUnwrap( + NativeMessageActionDefinition( + arguments: [ + "id": "edit", "title": "Edit message", + "symbol": "pencil", "group": "primary", + ] + ) + ) + var selectionCount = 0 + let row = NativeMessageActionRowControl( + definition: definition, + foregroundColor: .label, + destructiveColor: .systemRed, + onSelected: { selectionCount += 1 } + ) + row.frame = CGRect(x: 0, y: 0, width: 288, height: 48) + row.layoutIfNeeded() + + let iconCenter = row.actionImageView.convert( + CGPoint(x: row.actionImageView.bounds.midX, y: row.actionImageView.bounds.midY), + to: row + ) + let labelCenter = row.actionTitleLabel.convert( + CGPoint(x: row.actionTitleLabel.bounds.midX, y: row.actionTitleLabel.bounds.midY), + to: row + ) + for point in [CGPoint(x: 4, y: 24), iconCenter, labelCenter, CGPoint(x: 284, y: 24)] { + let target = row.hitTest(point, with: nil) + XCTAssertTrue(target === row, "Tap at \(point) must reach the action control") + (target as? UIControl)?.sendActions(for: .touchUpInside) + } + XCTAssertEqual(selectionCount, 4) + } + @MainActor func testNativeMessageActionRowExpandsForAccessibilityTypography() throws { let traits = UITraitCollection( diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 53dd4c30f6..a8f5bbe08f 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -30,6 +30,7 @@ import '../../shared/reminders/reminder_service.dart'; import 'channel_management_provider.dart'; import 'emoji_picker.dart'; import 'message_action_backdrop_state.dart'; +import 'message_actions/native_message_action_selection.dart'; import 'reaction_row.dart'; import 'recent_emoji_provider.dart'; import '../../shared/read_state/message_read_state.dart'; diff --git a/mobile/lib/features/channels/message_actions/message_action_popover.dart b/mobile/lib/features/channels/message_actions/message_action_popover.dart index f56fb7d3ac..863043307b 100644 --- a/mobile/lib/features/channels/message_actions/message_action_popover.dart +++ b/mobile/lib/features/channels/message_actions/message_action_popover.dart @@ -477,17 +477,7 @@ class _IosNativeMessageActionSurface extends HookWidget { @override Widget build(BuildContext context) { final viewId = useState(null); - useEffect(() { - final id = viewId.value; - if (id == null) return null; - final channel = MethodChannel('buzz/native_message_action_surface/$id'); - channel.setMethodCallHandler((call) async { - if (call.method != 'selected' || call.arguments is! Map) return; - final actionId = (call.arguments as Map)['id']; - if (actionId is String) onSelected(actionId); - }); - return () => channel.setMethodCallHandler(null); - }, [viewId.value, onSelected]); + useNativeMessageActionSelection(viewId.value, onSelected); return UiKitView( key: const ValueKey('ios-native-message-action-surface'), diff --git a/mobile/lib/features/channels/message_actions/native_message_action_selection.dart b/mobile/lib/features/channels/message_actions/native_message_action_selection.dart new file mode 100644 index 0000000000..70122c4ff0 --- /dev/null +++ b/mobile/lib/features/channels/message_actions/native_message_action_selection.dart @@ -0,0 +1,24 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Receives selections from the native message menu while [viewId] is mounted. +void useNativeMessageActionSelection( + int? viewId, + ValueChanged onSelected, +) { + final latestOnSelected = useRef(onSelected); + latestOnSelected.value = onSelected; + + // Callback changes must not replace this effect: the previous hook's cleanup + // can clear the new handler on the same channel after a keyboard-driven rebuild. + useEffect(() { + if (viewId == null) return null; + final channel = MethodChannel('buzz/native_message_action_surface/$viewId'); + channel.setMethodCallHandler((call) async { + if (call.method != 'selected' || call.arguments is! Map) return; + final actionId = (call.arguments as Map)['id']; + if (actionId is String) latestOnSelected.value(actionId); + }); + return () => channel.setMethodCallHandler(null); + }, [viewId]); +} diff --git a/mobile/test/features/channels/native_message_action_selection_test.dart b/mobile/test/features/channels/native_message_action_selection_test.dart new file mode 100644 index 0000000000..204950f3ad --- /dev/null +++ b/mobile/test/features/channels/native_message_action_selection_test.dart @@ -0,0 +1,60 @@ +import 'package:buzz/features/channels/message_actions/native_message_action_selection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _select(WidgetTester tester, int viewId, String action) async { + tester.binding.channelBuffers.push( + 'buzz/native_message_action_surface/$viewId', + const StandardMethodCodec().encodeMethodCall( + MethodCall('selected', {'id': action}), + ), + (_) {}, + ); + await tester.pump(); +} + +Widget _menu(int? viewId, ValueChanged onSelected) => HookBuilder( + builder: (_) { + useNativeMessageActionSelection(viewId, onSelected); + return const SizedBox(); + }, +); + +void main() { + testWidgets('keeps native selections connected through callback rebuilds', ( + tester, + ) async { + final selections = []; + await tester.pumpWidget(_menu(null, (id) => selections.add('initial:$id'))); + await tester.pumpWidget(_menu(41, (id) => selections.add('created:$id'))); + await _select(tester, 41, 'copyText'); + expect(selections, ['created:copyText']); + + // Keyboard inset changes rebuild the popover with a fresh callback. + for (var frame = 0; frame < 5; frame++) { + await tester.pumpWidget( + _menu(41, (id) => selections.add('frame$frame:$id')), + ); + } + await _select(tester, 41, 'edit'); + expect(selections, ['created:copyText', 'frame4:edit']); + }); + + testWidgets('moves the handler to a new view and removes it on dismissal', ( + tester, + ) async { + final selections = []; + await tester.pumpWidget(_menu(51, selections.add)); + await _select(tester, 51, 'copyText'); + await tester.pumpWidget(_menu(52, selections.add)); + await _select(tester, 51, 'edit'); + await _select(tester, 52, 'delete'); + expect(selections, ['copyText', 'delete']); + + await tester.pumpWidget(const SizedBox()); + await _select(tester, 52, 'edit'); + expect(selections, ['copyText', 'delete']); + }); +} From ef2aa1ae38fadcc0bc22b8bf6ed96b35933146be Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 19 Sep 2026 11:00:47 -0700 Subject: [PATCH 16/22] fix(mobile): preserve thread replies through refresh failures (#7757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A failed mobile thread refresh could briefly remove replies and show “0 replies” even though Riverpod still retained the previous result. Read the retained reply value directly and merge it with pending local replies through errors and retries. When a reopened thread has no retained result, show loading or failure instead of a false zero count. Error status remains visible alongside retained replies. No additional cache or retention is introduced. Current main intentionally disposes thread queries on close; reopening still fetches fresh data. The recorded symptom is reproduced by the failed-refresh test, but the recording alone does not establish its network trigger. ### Related issue Related to #4831 (thread refresh and disposal behavior), incorporated into main through #6691. No duplicate for this rendering defect found. ### Testing - Reproduced disappearing cached replies in widget tests before the fix; the new retry/recovery, terminal-error, reopen/loading, initial-error, and empty-result tests now pass. - Provider coverage verifies cached and pending local replies survive a failed refresh and confirmation still clears the local overlay. - All five new widget regressions, the existing hydration/scroll-position regression, all nine thread-provider tests, Flutter analysis, and the final-head full mobile pre-push gate passed (2,261 tests passed; four skipped). - Phone-width and large-text rendering checked with synthetic messages. [Before/after screenshots](https://github.com/block/buzz/pull/7757#issuecomment-5743469556). - All final-head GitHub checks passed, including mobile tests, Android debug APK, iOS simulator app, and unsigned iOS release builds. - Codex code review and security review are clean for `4e5f606ae`; the earlier accessibility finding is fixed and resolved. Builderbot review was requested but has not returned a result. - Broad local `just ci` could not finish: unchanged ACP timing tests failed, including `keepalive_resets_idle_past_deadline` on the lower-concurrency retry. The separate ACP run passed all 941 tests. Initial parallel mobile runs also hit timing flakes; the final-head full mobile gate passed. No unrelated native code was changed. --------- Signed-off-by: Tom Brow --- .../features/channels/thread_detail_page.dart | 18 +- .../thread_detail_page/message_list.dart | 34 ++- .../channels/thread_replies_provider.dart | 9 +- .../channels/channel_detail_page_test.dart | 3 + .../thread_replies_provider_test.dart | 47 +++++ .../channels/thread_reply_refresh_cases.dart | 195 ++++++++++++++++++ 6 files changed, 286 insertions(+), 20 deletions(-) create mode 100644 mobile/test/features/channels/thread_reply_refresh_cases.dart diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 154193b3d2..0bb4cedb34 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -128,14 +128,15 @@ class ThreadDetailPage extends HookConsumerWidget { final liveChannelEvents = ref.watch(channelMessagesProvider(channelId)).value ?? const []; - final replyMessages = repliesState.whenData((events) { - return formatTimeline( - mergeThreadEvents(events, liveChannelEvents), - currentPubkey: currentPubkey, - ); - }); - - final fetchedReplies = replyMessages.value; + // Loading/error states can still carry replies from the last query. + // whenData drops that retained value during retries. + final replyEvents = repliesState.value; + final fetchedReplies = replyEvents == null + ? null + : formatTimeline( + mergeThreadEvents(replyEvents, liveChannelEvents), + currentPubkey: currentPubkey, + ); final hasFetchedReplies = fetchedReplies != null; // A terminal query error cannot produce a more authoritative list. Keep // loading states provisional, but let the hydrated route snapshot drive @@ -892,6 +893,7 @@ class ThreadDetailPage extends HookConsumerWidget { itemPositionsListener: itemPositionsListener, bottomInset: timelineBottomInset, replies: replies, + relayReplyState: relayReplyState, localSendAnimations: localSendAnimations, trackActiveScrollPosition: trackActiveScrollPosition, headIsDeleted: liveDeletionHidesHead, 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 90e227ab7d..a33aa9a4ee 100644 --- a/mobile/lib/features/channels/thread_detail_page/message_list.dart +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -9,6 +9,7 @@ class _ThreadMessageList extends StatelessWidget { final ItemPositionsListener itemPositionsListener; final double bottomInset; final List replies; + final AsyncValue> relayReplyState; final Map localSendAnimations; final Widget Function(Widget child) trackActiveScrollPosition; final bool headIsDeleted; @@ -34,6 +35,7 @@ class _ThreadMessageList extends StatelessWidget { required this.itemPositionsListener, required this.bottomInset, required this.replies, + required this.relayReplyState, required this.localSendAnimations, required this.trackActiveScrollPosition, required this.headIsDeleted, @@ -51,6 +53,18 @@ class _ThreadMessageList extends StatelessWidget { required this.childrenByParent, }); + String get _replySummary { + if (!relayReplyState.hasValue && replies.isEmpty) { + return relayReplyState.isLoading + ? 'Loading replies…' + : 'Couldn’t load replies'; + } + final count = + '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}'; + if (!relayReplyState.hasError) return count; + return '$count · ${relayReplyState.isLoading ? 'Retrying…' : 'Couldn’t refresh'}'; + } + @override Widget build(BuildContext context) { const headIndex = 0; @@ -136,13 +150,19 @@ class _ThreadMessageList extends StatelessWidget { ), child: Row( children: [ - Text( - '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium - ?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), + Flexible( + child: Semantics( + liveRegion: true, + child: Text( + _replySummary, + style: context.textTheme.labelMedium + ?.copyWith( + color: + context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), ), const SizedBox(width: Grid.xxs), Expanded( diff --git a/mobile/lib/features/channels/thread_replies_provider.dart b/mobile/lib/features/channels/thread_replies_provider.dart index 1fb19432f1..c49461834e 100644 --- a/mobile/lib/features/channels/thread_replies_provider.dart +++ b/mobile/lib/features/channels/thread_replies_provider.dart @@ -130,11 +130,10 @@ final threadRepliesWithLocalProvider = Provider.autoDispose } } if (localReplies.isEmpty) return relayReplies; - return relayReplies.when( - data: (events) => AsyncData(_mergeReplies(events, localReplies)), - loading: () => AsyncData(localReplies), - error: (error, stackTrace) => AsyncData(localReplies), - ); + // This provider supplies display events; the original query remains + // the source of loading/error status. Preserve its retained value when + // merging optimistic replies during a failed refresh or retry. + return AsyncData(_mergeReplies(authoritative ?? const [], localReplies)); }); /// Union two event lists by id, newest-wins, in timeline order. diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 7159fb2fd1..23a83ddab3 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -64,6 +64,8 @@ import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; import 'package:shared_preferences/shared_preferences.dart'; +part 'thread_reply_refresh_cases.dart'; + const _channelId = '11111111-2222-4333-8444-555555555555'; const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c'; const _otherChannelId = '22222222-3333-4444-8555-666666666666'; @@ -471,6 +473,7 @@ double? effectiveFontSizeForText( } void main() { + threadReplyRefreshTests(); setUp(() async { SharedPreferences.setMockInitialValues({}); _testPrefs = await SharedPreferences.getInstance(); diff --git a/mobile/test/features/channels/thread_replies_provider_test.dart b/mobile/test/features/channels/thread_replies_provider_test.dart index cdfc16fe81..419bd1e533 100644 --- a/mobile/test/features/channels/thread_replies_provider_test.dart +++ b/mobile/test/features/channels/thread_replies_provider_test.dart @@ -215,6 +215,53 @@ void main() { }, ); + test( + 'local replies preserve cached authoritative replies after refresh failure', + () async { + final cached = _reply('cached', 1000); + final local = _reply('local', 1001); + final session = _FakeRelaySession()..replies = [cached]; + final container = ProviderContainer( + retry: (_, _) => null, + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + final combined = container.listen( + threadRepliesWithLocalProvider(args), + (_, _) {}, + ); + await container.read(threadRepliesProvider(args).future); + container.read(threadLocalRepliesProvider(args).notifier).add(local); + await container.pump(); + final refresh = Completer>(); + session.nextQueryGate = refresh; + container.invalidate(threadRepliesProvider(args)); + await container.pump(); + expect(combined.read().value?.map((event) => event.id), [ + 'cached', + 'local', + ]); + refresh.completeError(Exception('Refresh failed')); + await container.pump(); + expect(container.read(threadRepliesProvider(args)).hasError, isTrue); + expect(combined.read().value?.map((event) => event.id), [ + 'cached', + 'local', + ]); + expect(container.read(threadLocalRepliesProvider(args)), [local]); + session.replies = [cached, local]; + container.invalidate(threadRepliesProvider(args)); + await container.read(threadRepliesProvider(args).future); + await container.pump(); + expect(combined.read().value?.map((event) => event.id), [ + 'cached', + 'local', + ]); + await Future.delayed(Duration.zero); + expect(container.read(threadLocalRepliesProvider(args)), isEmpty); + }, + ); + test('reopening a disposed thread performs a fresh load', () async { final (container, fakeSession, subscription) = makeHarness([ _reply('r1', 1000), diff --git a/mobile/test/features/channels/thread_reply_refresh_cases.dart b/mobile/test/features/channels/thread_reply_refresh_cases.dart new file mode 100644 index 0000000000..c788731fc5 --- /dev/null +++ b/mobile/test/features/channels/thread_reply_refresh_cases.dart @@ -0,0 +1,195 @@ +part of 'channel_detail_page_test.dart'; + +void threadReplyRefreshTests() { + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'refresh-root'); + final root = _textMsg( + id: args.rootId, + pubkey: 'alice', + content: 'Refresh root', + ); + final reply = _textMsg( + id: 'cached-reply', + pubkey: 'bob', + content: 'Cached reply', + createdAt: 1001, + extraTags: [ + ['e', args.rootId, '', 'reply'], + ], + ); + + Future<(NavigatorState, ProviderContainer)> mount( + WidgetTester tester, + Future> Function() load, { + bool retry = false, + TextScaler textScaler = TextScaler.noScaling, + }) async { + await tester.pumpWidget( + _buildTestable( + messages: [root], + textScaler: textScaler, + threadReplyLoaders: {args.rootId: load}, + providerRetry: (_, _) => + retry ? const Duration(milliseconds: 200) : null, + ), + ); + await tester.pumpAndSettle(); + final context = tester.element(find.byType(ChannelDetailPage)); + return (Navigator.of(context), ProviderScope.containerOf(context)); + } + + void open(NavigatorState navigator) { + final head = formatTimeline([root]).single; + navigator.push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: head, + allMessages: [head], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + } + + testWidgets( + 'thread refresh keeps cached replies through retry and recovery', + (tester) async { + var calls = 0; + final refresh = Completer>(); + final retry = Completer>(); + final (navigator, container) = await mount(tester, () { + calls++; + return switch (calls) { + 1 => Future.value([reply]), + 2 => refresh.future, + _ => retry.future, + }; + }, retry: true); + open(navigator); + await tester.pumpAndSettle(); + expect(find.text('1 reply'), findsOneWidget); + container.invalidate(threadRepliesProvider(args)); + await tester.pump(); + expect(find.text('1 reply'), findsOneWidget); + refresh.completeError(Exception('Transient refresh failure')); + await tester.pump(); + for (var frame = 0; frame < 20; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(find.text('0 replies'), findsNothing, reason: 'frame $frame'); + expect( + find.byKey(const ValueKey('thread-message-group-cached-reply')), + findsOneWidget, + ); + expect(container.read(threadRepliesProvider(args)).value, [reply]); + } + expect(calls, 3); + retry.complete([ + reply, + _textMsg( + id: 'fresh-reply', + pubkey: 'bob', + content: 'Fresh reply', + createdAt: 1002, + extraTags: [ + ['e', args.rootId, '', 'reply'], + ], + ), + ]); + await tester.pumpAndSettle(); + expect(find.text('2 replies'), findsOneWidget); + expect( + find.byKey(const ValueKey('thread-message-group-fresh-reply')), + findsOneWidget, + ); + }, + ); + + testWidgets('thread refresh error keeps cached replies visible', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + var calls = 0; + final refresh = Completer>(); + tester.view.physicalSize = const Size(320, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final (navigator, container) = await mount( + tester, + () => ++calls == 1 ? Future.value([reply]) : refresh.future, + textScaler: const TextScaler.linear(2), + ); + open(navigator); + await tester.pumpAndSettle(); + container.invalidate(threadRepliesProvider(args)); + await tester.pump(); + refresh.completeError(Exception('Refresh failed')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('thread-message-group-cached-reply')), + findsOneWidget, + ); + expect(find.text('0 replies'), findsNothing); + expect(find.text('1 reply · Couldn’t refresh'), findsOneWidget); + expect( + tester + .getSemantics(find.text('1 reply · Couldn’t refresh')) + .getSemanticsData() + .flagsCollection + .isLiveRegion, + isTrue, + ); + expect(tester.takeException(), isNull); + expect(container.read(threadRepliesProvider(args)).hasError, isTrue); + semantics.dispose(); + }); + + testWidgets('thread reopen shows loading until the fresh query completes', ( + tester, + ) async { + var calls = 0; + final reopened = Completer>(); + final (navigator, _) = await mount( + tester, + () => ++calls == 1 ? Future.value([reply]) : reopened.future, + ); + open(navigator); + await tester.pumpAndSettle(); + navigator.pop(); + await tester.pumpAndSettle(); + open(navigator); + await tester.pumpAndSettle(); + expect(calls, 2); + expect(find.text('0 replies'), findsNothing); + expect(find.text('Loading replies…'), findsOneWidget); + reopened.complete([reply]); + await tester.pumpAndSettle(); + expect(find.text('1 reply'), findsOneWidget); + expect(find.text('Loading replies…'), findsNothing); + }); + + testWidgets('thread initial error is distinct from an empty thread', ( + tester, + ) async { + final query = Completer>(); + final (navigator, _) = await mount(tester, () => query.future); + open(navigator); + await tester.pumpAndSettle(); + query.completeError(Exception('Initial load failed')); + await tester.pumpAndSettle(); + expect(find.text('0 replies'), findsNothing); + expect(find.text('Couldn’t load replies'), findsOneWidget); + }); + testWidgets('thread successful empty query displays zero replies', ( + tester, + ) async { + final (navigator, _) = await mount(tester, () async => []); + open(navigator); + await tester.pumpAndSettle(); + expect(find.text('0 replies'), findsOneWidget); + expect(find.text('Loading replies…'), findsNothing); + expect(find.text('Couldn’t load replies'), findsNothing); + }); +} From 5079c770fe30bb3d8204822ce6c2431eacac6d4b Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 21 Sep 2026 07:37:14 -0700 Subject: [PATCH 17/22] fix(workflows): make deletion persistent and retryable (#7735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fix workflow deletion leaving its definition queryable, and make public deletion acceptance agree atomically with executable/definition state. - Delete the executable workflow and soft-delete its definition under the existing coordinate lock, in the same transaction as kind-5 acceptance and mention indexing. Failed deletion leaves no public accepted event; identical retries can succeed. Legacy already-stored failed requests still repair. - Choose one committed dispatch/audit owner for concurrent identical requests. Completed duplicates remain silent. Preserve owner/community isolation, newer revisions, orphan cleanup, and intentional backdated recreation. - Align Desktop confirmation and architecture documentation with recreation semantics. Add deterministic PostgreSQL, live WebSocket, and Desktop regressions. No schema migration or new retry/delivery subsystem. ## Current validation Head: `40d86ddab0853ffb292316653b22c3fd4f983f0c`. - **Exact clean head:** live relay workflow-deletion E2E **4/4 passed**. All applicable normal pre-push hooks passed: Rust unit suites, full Desktop unit suite, Desktop lint/typecheck, Tauri clippy/tests, and file-size/branch checks. No hook bypass. - **Pre-commit implementation tree, then committed at this head:** full discovered DB/relay PostgreSQL lane **351/351 passed**; built Desktop deletion Playwright smoke **3/3 passed** (success, failure preserving editor/draft, corrected confirmation copy). - **Deterministic mutation evidence:** unchanged previous relay `659b79282` fails both new regressions: concurrent identical requests produce two normal/dispatch-owner responses, and rejected deletion leaves one public event. Both controls pass. Fixed relay passes all four. - **Independent source review:** transaction/authorization/lock-order and regression/cleanup lanes clear. Reviewers did not run tests independently. ## Honest limits / merge gates The broad pre-commit DB/relay/test-client selection passed **1,215/1,216**; the untouched mesh-demo echo test returned HTTP 504 instead of 200. It remains outside this repair and is not counted green. Initial hook attempts also exposed an untouched Desktop inventory ordering assertion and unidentified ACP/agent test failures; standalone package reruns and the final complete hooks passed without changing those packages. Causes are not established. The previous Desktop CI shard failed before tests on a Hermit `just` download (HTTP 500). New-head CI/security review and reviewer disposition remain required. No deployment, fresh interactive Desktop session, full local `just ci`, or multi-replica runtime validation claimed. Postgres/Redis validation used task-owned local containers, fresh identities, and disposable fixtures. ## Compatibility No automatic cleanup of existing workflows: repeating deletion repairs definitions left by the old path. Canonical UUID coordinates and legacy name fallback (`LIMIT 1` for duplicate names) are unchanged. Backdated recreation remains intentional per [Tyler’s decision](https://github.com/block/buzz/pull/7735#issuecomment-5729881295), not an irreversible tombstone. Mention-index failure now rolls back the complete deletion rather than best-effort accepting partial work. Detailed reviewer disposition and evidence: https://github.com/block/buzz/pull/7735#issuecomment-5737367825 --------- Signed-off-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Mordecai --- ARCHITECTURE.md | 9 + crates/buzz-db/src/store/workflow.rs | 3 + crates/buzz-db/src/store/workflow/deletion.rs | 162 ++++ .../src/store/workflow/deletion_tests.rs | 294 +++++++ crates/buzz-relay/src/handlers/ingest.rs | 12 +- .../buzz-relay/src/handlers/side_effects.rs | 156 ++-- .../tests/e2e_workflow_deletion.rs | 780 ++++++++++++++++++ .../workflows/ui/WorkflowDeleteDialog.tsx | 2 +- desktop/tests/e2e/workflows.spec.ts | 9 +- 9 files changed, 1368 insertions(+), 59 deletions(-) create mode 100644 crates/buzz-db/src/store/workflow/deletion.rs create mode 100644 crates/buzz-db/src/store/workflow/deletion_tests.rs create mode 100644 crates/buzz-test-client/tests/e2e_workflow_deletion.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf435c270a..ae82c131ec 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -561,6 +561,15 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg **Cron scheduler:** loop ticks every 60 seconds, evaluates cron expressions with window-based matching, and creates workflow runs for matched triggers. Fully implemented. +**Deletion and recreation:** An authorized kind:5 `a`-tag deletion commits its +public event, executable workflow removal, and visible kind:30620 removal in one +transaction. Rejected deletions are not accepted history; identical concurrent +requests have one dispatch/audit owner. Deletions older than a live definition +leave that newer version intact. Deletion is not a permanent coordinate ban: +clients may publish a distinct signed definition afterward, including a backdated +version, to recreate the workflow and enable triggers again. This intentionally +retains arrival-order recreation without a deletion watermark. + **Does NOT:** recursively resolve templates (single-pass only). Does NOT queue workflow runs when at capacity — returns `CapacityExceeded` immediately. --- diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index 3ceed9ea32..043ab112cf 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -7,6 +7,9 @@ //! - Approval tokens are stored as SHA-256 hashes (never plaintext). //! - All list queries have a bounded LIMIT to prevent unbounded scans. +mod deletion; +pub use deletion::WorkflowDeletionOutcome; + use std::fmt; use std::str::FromStr; diff --git a/crates/buzz-db/src/store/workflow/deletion.rs b/crates/buzz-db/src/store/workflow/deletion.rs new file mode 100644 index 0000000000..f9b8273566 --- /dev/null +++ b/crates/buzz-db/src/store/workflow/deletion.rs @@ -0,0 +1,162 @@ +//! Keep the executable workflow and its client-visible definition in sync. + +use buzz_core::{kind::KIND_WORKFLOW_DEF, CommunityId, StoredEvent}; +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use nostr::Event; +use sqlx::{Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{Db, DbError, Result}; + +/// Committed changes made by a workflow-coordinate deletion. +#[derive(Debug, Default)] +pub struct WorkflowDeletionOutcome { + /// Whether either the executable workflow or a visible definition was removed. + pub changed: bool, + /// Removed executable workflow's channel, for trigger-cache invalidation. + pub channel_id: Option, +} + +impl Db { + /// Delete an authorized workflow coordinate and its definition atomically. + /// + /// Shares the replacement lock with definition saves. A deletion older than + /// the live definition is a no-op. Missing projections are tolerated so a + /// retry can remove definitions left behind by older relay versions. + /// Reports committed changes independently of the optional workflow channel. + /// No tombstone is retained: clients may intentionally publish backdated definitions. + #[datastore_span(name = "delete_workflow_by_coordinate", system = "postgresql")] + pub async fn delete_workflow_by_coordinate( + &self, + community_id: CommunityId, + owner_pubkey: &[u8], + d_tag: &str, + deletion_created_at_secs: i64, + ) -> Result { + let mut tx = self.begin_event_write_transaction().await?; + let outcome = delete_workflow_in_transaction( + &mut tx, + community_id, + owner_pubkey, + d_tag, + deletion_created_at_secs, + ) + .await?; + tx.commit().await?; + Ok(outcome) + } + + /// Commit the public deletion request and both workflow projections together. + /// + /// The returned boolean grants dispatch ownership: either this transaction + /// inserted the request, or it repaired an older relay's incomplete deletion. + /// Concurrent identical requests cannot split those two outcomes across commits. + /// Authorization of the coordinate is the caller's responsibility. + pub async fn insert_workflow_deletion( + &self, + community_id: CommunityId, + event: &Event, + owner_pubkey: &[u8], + d_tag: &str, + ) -> Result<(StoredEvent, bool, Option)> { + let mut tx = self.begin_event_write_transaction().await?; + let (stored, inserted) = + crate::event::insert_event_in_transaction(&mut tx, community_id, event, None).await?; + if inserted { + // Unlike best-effort indexing for ordinary events, deletion fails + // closed: its public request and discoverability commit together. + crate::runtime::insert_mentions_in_transaction(&mut tx, community_id, event, None) + .await?; + } + let outcome = delete_workflow_in_transaction( + &mut tx, + community_id, + owner_pubkey, + d_tag, + event.created_at.as_secs() as i64, + ) + .await?; + tx.commit().await?; + Ok((stored, inserted || outcome.changed, outcome.channel_id)) + } +} + +async fn delete_workflow_in_transaction( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + owner_pubkey: &[u8], + d_tag: &str, + deletion_created_at_secs: i64, +) -> Result { + let cutoff = DateTime::from_timestamp(deletion_created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?; + let lock_key = crate::store::replaceable::event_replacement_lock_key( + community_id, + KIND_WORKFLOW_DEF as i32, + owner_pubkey, + Some(d_tag.as_bytes()), + ); + crate::observability::observe_advisory_lock( + crate::observability::LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut **tx), + ) + .await?; + + let head: Option> = sqlx::query_scalar( + "SELECT created_at FROM events WHERE community_id = $1 AND kind = $2 \ + AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ + ORDER BY created_at DESC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(KIND_WORKFLOW_DEF as i32) + .bind(owner_pubkey) + .bind(d_tag) + .fetch_optional(&mut **tx) + .await?; + if head.is_some_and(|created_at| created_at > cutoff) { + return Ok(WorkflowDeletionOutcome::default()); + } + + // UUID coordinates are canonical; retain the legacy name-based path. + // The owner predicate remains in the mutation, not just a prior check. + let workflow_id = Uuid::parse_str(d_tag).ok(); + let row = sqlx::query( + "DELETE FROM workflows WHERE community_id = $1 AND owner_pubkey = $2 \ + AND id = COALESCE($3::uuid, (SELECT id FROM workflows \ + WHERE community_id = $1 AND owner_pubkey = $2 AND name = $4 LIMIT 1)) \ + RETURNING channel_id", + ) + .bind(community_id.as_uuid()) + .bind(owner_pubkey) + .bind(workflow_id) + .bind(d_tag) + .fetch_optional(&mut **tx) + .await?; + let definitions = sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND kind = $2 \ + AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL AND created_at <= $5", + ) + .bind(community_id.as_uuid()) + .bind(KIND_WORKFLOW_DEF as i32) + .bind(owner_pubkey) + .bind(d_tag) + .bind(cutoff) + .execute(&mut **tx) + .await?; + let changed = row.is_some() || definitions.rows_affected() > 0; + let channel_id = row + .map(|row| row.try_get("channel_id")) + .transpose()? + .flatten(); + Ok(WorkflowDeletionOutcome { + changed, + channel_id, + }) +} + +#[cfg(test)] +#[path = "deletion_tests.rs"] +mod postgres_tests; diff --git a/crates/buzz-db/src/store/workflow/deletion_tests.rs b/crates/buzz-db/src/store/workflow/deletion_tests.rs new file mode 100644 index 0000000000..04aa90326b --- /dev/null +++ b/crates/buzz-db/src/store/workflow/deletion_tests.rs @@ -0,0 +1,294 @@ +use super::*; +use crate::event::EventQuery; +use crate::workflow::{create_workflow_run, get_workflow, upsert_workflow}; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + +async fn setup() -> (Db, CommunityId) { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect test database"); + let db = Db::from_pool(pool); + let community = db + .ensure_configured_community(&format!("workflow-delete-{}.example", Uuid::new_v4())) + .await + .expect("create community") + .id; + (db, community) +} + +async fn seed( + db: &Db, + community: CommunityId, + keys: &Keys, + workflow_id: Uuid, + d_tag: &str, + created_at: u64, +) -> EventQuery { + let owner = keys.public_key().to_bytes(); + crate::user::ensure_user(&db.pool, community, &owner) + .await + .expect("owner"); + upsert_workflow( + &db.pool, + community, + workflow_id, + None, + &owner, + d_tag, + r#"{"trigger":{"on":"manual"},"steps":[]}"#, + &[0; 32], + ) + .await + .expect("seed executable workflow"); + let event = EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_DEF as u16), + "name: deletion-test", + ) + .tags([Tag::parse(["d", d_tag]).expect("d tag")]) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .expect("sign definition"); + db.replace_parameterized_event(community, &event, d_tag, None) + .await + .expect("seed definition"); + EventQuery { + kinds: Some(vec![KIND_WORKFLOW_DEF as i32]), + pubkey: Some(owner.to_vec()), + d_tag: Some(d_tag.to_string()), + ..EventQuery::for_community(community) + } +} + +async fn assert_present(db: &Db, query: &EventQuery, id: Uuid) { + assert_eq!( + db.query_events(query) + .await + .expect("fresh definition query") + .len(), + 1 + ); + assert!(get_workflow(&db.pool, query.community_id, id).await.is_ok()); +} + +async fn assert_absent(db: &Db, query: &EventQuery, id: Uuid) { + assert!(db + .query_events(query) + .await + .expect("fresh definition query") + .is_empty()); + assert!(matches!( + get_workflow(&db.pool, query.community_id, id).await, + Err(DbError::NotFound(_)) + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_deletion_removes_both_projections_and_is_scoped_and_retryable() { + let (db, community) = setup().await; + let other = db + .ensure_configured_community(&format!("other-{}.example", Uuid::new_v4())) + .await + .expect("other community") + .id; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + let query = seed(&db, community, &keys, id, &d_tag, now).await; + let other_query = seed(&db, other, &keys, id, &d_tag, now).await; + assert_present(&db, &query, id).await; + + let unchanged = db + .delete_workflow_by_coordinate( + community, + &Keys::generate().public_key().to_bytes(), + &d_tag, + now as i64, + ) + .await + .expect("wrong owner is a no-op"); + assert!(!unchanged.changed); + assert_present(&db, &query, id).await; + + let run_id = create_workflow_run(&db.pool, community, id, None, None) + .await + .expect("run"); + // A fired schedule links both the workflow and a cascading run. Deletion + // must also remove the claim despite its NO ACTION run foreign key. + sqlx::query( + "INSERT INTO scheduled_workflow_fires \ + (community_id, workflow_id, scheduled_for, workflow_run_id) VALUES ($1, $2, NOW(), $3)", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(run_id) + .execute(&db.pool) + .await + .expect("scheduled claim linked to run"); + let (first, second) = tokio::join!( + db.delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64), + db.delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64), + ); + let first = first.expect("first deletion"); + let second = second.expect("concurrent deletion"); + assert_ne!( + first.changed, second.changed, + "only one deletion changes state" + ); + assert_eq!( + first.channel_id, None, + "channel-less mutation still reports change" + ); + assert_eq!(second.channel_id, None); + assert_absent(&db, &query, id).await; + assert_present(&db, &other_query, id).await; + assert!(matches!( + db.get_workflow_run(community, run_id).await, + Err(DbError::NotFound(_)) + )); + let unchanged = db + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await + .expect("repeat deletion"); + assert!(!unchanged.changed); + assert_absent(&db, &query, id).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_deletion_preserves_newer_definition_and_cleans_legacy_orphan() { + let (db, community) = setup().await; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + let query = seed(&db, community, &keys, id, &d_tag, now).await; + let unchanged = db + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64 - 1) + .await + .expect("stale deletion"); + assert!(!unchanged.changed); + assert_present(&db, &query, id).await; + + // Old relay releases deleted the executable row, but not the definition. + db.delete_workflow_for_owner(community, id, &owner) + .await + .expect("legacy deletion"); + assert_eq!( + db.query_events(&query) + .await + .expect("orphan definition") + .len(), + 1 + ); + let repaired = db + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await + .expect("repair deletion"); + assert!(repaired.changed, "orphan-only cleanup must dispatch"); + assert_eq!(repaired.channel_id, None); + assert_absent(&db, &query, id).await; + + let legacy_id = Uuid::new_v4(); + let legacy = seed(&db, community, &keys, legacy_id, "legacy-name", now).await; + db.soft_delete_by_coordinate( + community, + KIND_WORKFLOW_DEF as i32, + &owner, + "legacy-name", + now as i64, + ) + .await + .expect("remove definition to leave only executable projection"); + let repaired = db + .delete_workflow_by_coordinate(community, &owner, "legacy-name", now as i64) + .await + .expect("name coordinate deletion"); + assert!(repaired.changed, "projection-only cleanup must dispatch"); + assert_absent(&db, &legacy, legacy_id).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_deletion_rolls_back_both_representations_on_failure() { + let (db, community) = setup().await; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + let query = seed(&db, community, &keys, id, &d_tag, now).await; + // A failing event update simulates a failure after projection removal. + // The CI runner isolates this test's database from other tests. + sqlx::raw_sql( + "CREATE FUNCTION reject_workflow_definition_delete() RETURNS trigger \ + LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected deletion failure'; END $$; \ + CREATE TRIGGER reject_workflow_definition_delete BEFORE UPDATE ON events \ + FOR EACH ROW WHEN (OLD.kind = 30620 AND NEW.deleted_at IS NOT NULL) \ + EXECUTE FUNCTION reject_workflow_definition_delete();", + ) + .execute(&db.pool) + .await + .expect("install failure trigger"); + let result = db + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await; + sqlx::raw_sql( + "DROP TRIGGER reject_workflow_definition_delete ON events; \ + DROP FUNCTION reject_workflow_definition_delete();", + ) + .execute(&db.pool) + .await + .expect("remove failure trigger"); + assert!(result.is_err()); + assert_present(&db, &query, id).await; + db.delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await + .expect("retry after failure"); + assert_absent(&db, &query, id).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn legacy_stored_deletion_repair_has_one_committed_dispatch_owner() { + let (db, community) = setup().await; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + let query = seed(&db, community, &keys, id, &d_tag, now).await; + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags([Tag::parse([ + "a", + &format!("30620:{}:{d_tag}", keys.public_key().to_hex()), + ]) + .expect("coordinate")]) + .custom_created_at(Timestamp::from(now)) + .sign_with_keys(&keys) + .expect("sign legacy deletion"); + // Older relay versions could persist the request without applying deletion. + db.insert_event_with_thread_metadata(community, &deletion, None, None) + .await + .expect("legacy request"); + let (first, second) = tokio::join!( + db.insert_workflow_deletion(community, &deletion, &owner, &d_tag), + db.insert_workflow_deletion(community, &deletion, &owner, &d_tag), + ); + let (_, first_dispatch, _) = first.expect("first repair"); + let (_, second_dispatch, _) = second.expect("concurrent repair"); + assert_ne!( + first_dispatch, second_dispatch, + "only one committed repair dispatches" + ); + assert_absent(&db, &query, id).await; + assert!( + !db.insert_workflow_deletion(community, &deletion, &owner, &d_tag) + .await + .expect("completed duplicate") + .1 + ); +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ee1d0312be..7f343c2c10 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3144,7 +3144,15 @@ async fn ingest_event_inner( }); } - let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) { + let workflow_deletion = crate::handlers::side_effects::is_workflow_deletion(&event); + let (stored_event, was_inserted) = 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. + crate::handlers::side_effects::persist_workflow_deletion(tenant, &event, state) + .await + .map_err(|e| IngestError::Internal(format!("error: workflow deletion failed: {e}")))? + } else if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. state @@ -3211,7 +3219,7 @@ async fn ingest_event_inner( }); } - if crate::handlers::side_effects::is_side_effect_kind(kind_u32) { + if !workflow_deletion && crate::handlers::side_effects::is_side_effect_kind(kind_u32) { if let Err(e) = crate::handlers::side_effects::handle_side_effects(tenant, kind_u32, &event, state) .await diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 7282c91342..702a0b62ca 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2089,6 +2089,56 @@ async fn handle_leave_request( // handle_reaction() removed — kind:7 reaction dedup and DB writes are now // handled inline in ingest_event() before storage (see ingest.rs step 20a). +/// Whether the standard deletion handler will process a workflow coordinate. +pub(crate) fn is_workflow_deletion(event: &Event) -> bool { + // Match authorization and dispatch: e-tags take precedence, otherwise only + // the first a-tag is authorized and processed. + event.kind == Kind::EventDeletion + && !has_e_tag(event) + && event + .tags + .iter() + .find(|tag| tag.kind().to_string() == "a") + .and_then(|tag| tag.content()) + .is_some_and(|value| { + value + .split(':') + .next() + .and_then(|kind| kind.parse::().ok()) + == Some(buzz_core::kind::KIND_WORKFLOW_DEF) + }) +} + +/// Persist an already-authorized workflow deletion and its domain changes atomically. +pub(crate) async fn persist_workflow_deletion( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> anyhow::Result<(buzz_core::StoredEvent, bool)> { + let coordinate = event + .tags + .iter() + .find(|tag| tag.kind().to_string() == "a") + .and_then(|tag| tag.content()) + .ok_or_else(|| anyhow::anyhow!("missing workflow coordinate"))?; + let mut parts = coordinate.splitn(3, ':'); + let _kind = parts.next(); + let owner = hex::decode(parts.next().unwrap_or_default())?; + let d_tag = parts + .next() + .ok_or_else(|| anyhow::anyhow!("invalid workflow coordinate"))?; + let (stored, dispatch, channel_id) = state + .db + .insert_workflow_deletion(tenant.community(), event, &owner, d_tag) + .await?; + if let Some(channel_id) = channel_id { + state + .workflow_engine + .invalidate_channel_workflows(tenant.community(), channel_id); + } + Ok((stored, dispatch)) +} + /// Handle NIP-09 deletion via `a` tag (addressable/parameterized-replaceable events). /// Parses "kind:pubkey:d-tag" and deletes the corresponding DB record. async fn handle_a_tag_deletion( @@ -2112,7 +2162,6 @@ async fn handle_a_tag_deletion( .map_err(|_| anyhow::anyhow!("invalid kind in a-tag"))?; let pubkey_hex = parts[1]; let d_tag = parts[2]; - let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key()); match kind_num { // kind:30350 revocation is exclusively a higher-generation inactive replacement. @@ -2120,60 +2169,12 @@ async fn handle_a_tag_deletion( tracing::debug!(d_tag, "NIP-09 deletion ignored for push lease"); } buzz_core::kind::KIND_WORKFLOW_DEF => { - // Try UUID first (workflow_id); fall back to name-based lookup. - if let Ok(wf_id) = uuid::Uuid::parse_str(d_tag) { - let channel_id = state - .db - .delete_workflow_for_owner(tenant.community(), wf_id, &actor_bytes) - .await - .map_err(|e| anyhow::anyhow!("failed to delete workflow {wf_id}: {e}"))?; - if let Some(channel_id) = channel_id { - state - .workflow_engine - .invalidate_channel_workflows(tenant.community(), channel_id); - } - tracing::info!(workflow_id = %wf_id, "Workflow deleted via NIP-09 a-tag (UUID)"); - } else { - // Name-based lookup - match state - .db - .find_workflow_by_owner_and_name(tenant.community(), &actor_bytes, d_tag) - .await - { - Ok(Some(wf)) => { - let channel_id = state - .db - .delete_workflow_for_owner(tenant.community(), wf.id, &actor_bytes) - .await - .map_err(|e| { - anyhow::anyhow!("failed to delete workflow {}: {e}", wf.id) - })?; - if let Some(channel_id) = channel_id { - state - .workflow_engine - .invalidate_channel_workflows(tenant.community(), channel_id); - } - tracing::info!(workflow_id = %wf.id, name = d_tag, "Workflow deleted via NIP-09 a-tag (name)"); - } - Ok(None) => { - tracing::warn!( - "NIP-09 a-tag deletion: no workflow '{d_tag}' found for owner" - ); - } - Err(e) => { - tracing::warn!("NIP-09 a-tag deletion: DB lookup failed: {e}"); - } - } - } + // Workflow deletion belongs to the atomic persistence path in ingest. + return Err(anyhow::anyhow!( + "workflow deletion requires atomic persistence" + )); } - // Generic NIP-33 (parameterized-replaceable) soft-delete by coordinate. - // - // Listed after the workflow branch so workflow's bespoke deletion - // (which doesn't soft-delete the `events` row by design — that's a - // separate concern) takes precedence. For every other addressable - // kind, including kind:30023 (NIP-23 long-form), we soft-delete the - // live row matching `(kind, pubkey, d_tag)` so REQs stop returning it. - // See https://github.com/block/sprout/issues/714. + // Other NIP-33 events have no executable workflow projection. k if is_parameterized_replaceable(k) => { let pubkey_bytes = match hex::decode(pubkey_hex) { Ok(b) => b, @@ -3655,6 +3656,53 @@ pub async fn publish_nipia_unarchived( mod tests { use super::*; + #[test] + fn workflow_deletion_retry_matches_authorized_dispatch() { + let keys = nostr::Keys::generate(); + let workflow = format!("30620:{}:workflow", keys.public_key()); + let other = format!("30023:{}:article", keys.public_key()); + for (kind, tags, expected) in [ + ( + Kind::EventDeletion, + vec![vec!["a", workflow.as_str()]], + true, + ), + ( + Kind::EventDeletion, + vec![vec!["a", workflow.as_str()], vec!["e", "malformed"]], + false, + ), + ( + Kind::EventDeletion, + vec![vec!["a", other.as_str()], vec!["a", workflow.as_str()]], + false, + ), + ( + Kind::EventDeletion, + vec![vec!["a", workflow.as_str()], vec!["a", other.as_str()]], + true, + ), + ( + Kind::EventDeletion, + vec![vec!["a", "306200:owner:id"]], + false, + ), + ( + Kind::EventDeletion, + vec![vec!["a", "30620abc:owner:id"]], + false, + ), + (Kind::EventDeletion, vec![], false), + (Kind::TextNote, vec![vec!["a", workflow.as_str()]], false), + ] { + let event = EventBuilder::new(kind, "") + .tags(tags.into_iter().map(|tag| Tag::parse(tag).expect("tag"))) + .sign_with_keys(&keys) + .expect("sign"); + assert_eq!(is_workflow_deletion(&event), expected, "{:?}", event.tags); + } + } + #[test] fn nip43_reconciliation_compatibility_alias_is_preserved() { #[allow(deprecated)] diff --git a/crates/buzz-test-client/tests/e2e_workflow_deletion.rs b/crates/buzz-test-client/tests/e2e_workflow_deletion.rs new file mode 100644 index 0000000000..a46777307b --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_workflow_deletion.rs @@ -0,0 +1,780 @@ +//! Relay-backed regression coverage for workflow deletion. +//! +//! # Running +//! +//! Start an isolated relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 DATABASE_URL=postgres://… cargo test \ +//! -p buzz-test-client --test e2e_workflow_deletion -- --ignored +//! ``` + +use std::panic::AssertUnwindSafe; +use std::time::Duration; + +use buzz_test_client::{BuzzTestClient, RelayMessage, TestClientError}; +use futures_util::FutureExt; +use nostr::{Alphabet, Event, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; +use sqlx::PgPool; +use uuid::Uuid; + +const KIND_WORKFLOW_DEFINITION: u16 = 30_620; +const KIND_WORKFLOW_TRIGGER: u16 = 46_020; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn database_url() -> String { + std::env::var("DATABASE_URL").expect("DATABASE_URL required for failure injection") +} + +fn sub_id(phase: &str) -> String { + format!("e2e-workflow-deletion-{phase}-{}", Uuid::new_v4()) +} + +fn definition_filter(keys: &Keys, workflow_id: &str) -> Filter { + Filter::new() + .kind(Kind::Custom(KIND_WORKFLOW_DEFINITION)) + .author(keys.public_key()) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [workflow_id]) +} + +async fn query_definition( + client: &mut BuzzTestClient, + keys: &Keys, + workflow_id: &str, +) -> Vec { + let subscription = sub_id("definition-query"); + client + .subscribe(&subscription, vec![definition_filter(keys, workflow_id)]) + .await + .expect("subscribe for fresh workflow definition query"); + let events = client + .collect_until_eose(&subscription, Duration::from_secs(5)) + .await + .expect("query workflow definition"); + client + .close_subscription(&subscription) + .await + .expect("close definition query"); + events +} + +async fn query_deletion(client: &mut BuzzTestClient, deletion: &Event) -> Vec { + let subscription = sub_id("deletion-query"); + client + .subscribe( + &subscription, + vec![Filter::new().kind(Kind::EventDeletion).id(deletion.id)], + ) + .await + .expect("fresh exact-ID deletion query"); + let events = client + .collect_until_eose(&subscription, Duration::from_secs(5)) + .await + .expect("query deletion history"); + client + .close_subscription(&subscription) + .await + .expect("close deletion query"); + events +} + +async fn database_representation_counts( + pool: &PgPool, + keys: &Keys, + workflow_id: Uuid, +) -> (i64, i64) { + let owner = keys.public_key().to_bytes().to_vec(); + let d_tag = workflow_id.to_string(); + let workflow_rows: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM workflows WHERE id = $1 AND owner_pubkey = $2") + .bind(workflow_id) + .bind(&owner) + .fetch_one(pool) + .await + .expect("count executable workflow representation"); + let definition_rows: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events WHERE kind = $1 AND pubkey = $2 AND d_tag = $3 \ + AND deleted_at IS NULL", + ) + .bind(i32::from(KIND_WORKFLOW_DEFINITION)) + .bind(&owner) + .bind(d_tag) + .fetch_one(pool) + .await + .expect("count visible workflow definition representation"); + (workflow_rows, definition_rows) +} + +struct FailureInjector { + pool: PgPool, +} + +impl FailureInjector { + async fn install(pool: PgPool, keys: &Keys, workflow_id: Uuid) -> Self { + // This fixture runs only against a dedicated disposable database. The + // shared trigger is inert unless its private table contains this test's + // exact owner + d-tag coordinate. + sqlx::raw_sql( + "CREATE TABLE workflow_delete_failure_injection (\ + owner_pubkey BYTEA NOT NULL, d_tag TEXT NOT NULL, \ + PRIMARY KEY (owner_pubkey, d_tag)\ + ); \ + CREATE FUNCTION reject_injected_workflow_delete() RETURNS trigger \ + LANGUAGE plpgsql AS $$ \ + BEGIN \ + IF OLD.kind = 30620 AND OLD.deleted_at IS NULL \ + AND NEW.deleted_at IS NOT NULL \ + AND EXISTS (SELECT 1 FROM workflow_delete_failure_injection \ + WHERE owner_pubkey = OLD.pubkey AND d_tag = OLD.d_tag) \ + THEN RAISE EXCEPTION 'injected workflow deletion failure'; \ + END IF; \ + RETURN NEW; \ + END $$; \ + CREATE TRIGGER reject_injected_workflow_delete \ + BEFORE UPDATE ON events FOR EACH ROW \ + EXECUTE FUNCTION reject_injected_workflow_delete();", + ) + .execute(&pool) + .await + .expect("install coordinate-scoped deletion failure injector"); + sqlx::query( + "INSERT INTO workflow_delete_failure_injection (owner_pubkey, d_tag) VALUES ($1, $2)", + ) + .bind(keys.public_key().to_bytes().to_vec()) + .bind(workflow_id.to_string()) + .execute(&pool) + .await + .expect("arm deletion failure injector for test coordinate"); + Self { pool } + } + + async fn remove(&mut self) { + sqlx::raw_sql( + "DROP TRIGGER IF EXISTS reject_injected_workflow_delete ON events; \ + DROP FUNCTION IF EXISTS reject_injected_workflow_delete(); \ + DROP TABLE IF EXISTS workflow_delete_failure_injection;", + ) + .execute(&self.pool) + .await + .expect("remove deletion failure injector"); + } +} + +async fn create_channel(client: &mut BuzzTestClient, keys: &Keys, channel_id: &str) { + let create_channel = EventBuilder::new(Kind::Custom(9007), "") + .tags([ + Tag::parse(["h", channel_id]).expect("channel h tag"), + Tag::parse(["name", "workflow-deletion-e2e"]).expect("channel name tag"), + Tag::parse(["channel_type", "stream"]).expect("channel type tag"), + Tag::parse(["visibility", "open"]).expect("channel visibility tag"), + ]) + .sign_with_keys(keys) + .expect("sign channel creation"); + let created = client + .send_event(create_channel) + .await + .expect("create channel"); + assert!( + created.accepted, + "channel creation rejected: {}", + created.message + ); +} + +fn workflow_definition(channel_id: &str, workflow_id: &str) -> EventBuilder { + EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_DEFINITION), + "name: deletion-regression\ntrigger:\n on: message_posted\nsteps:\n - id: pause\n action: delay\n duration: 1s\n", + ) + .tags([ + Tag::parse(["d", workflow_id]).expect("workflow d tag"), + Tag::parse(["h", channel_id]).expect("workflow h tag"), + ]) +} + +async fn create_workflow( + client: &mut BuzzTestClient, + keys: &Keys, + channel_id: &str, + workflow_id: &str, +) -> nostr::EventId { + let definition = workflow_definition(channel_id, workflow_id) + .sign_with_keys(keys) + .expect("sign workflow definition"); + let definition_id = definition.id; + let created = client + .send_event(definition) + .await + .expect("create workflow"); + assert!( + created.accepted, + "workflow creation rejected: {}", + created.message + ); + definition_id +} + +fn workflow_deletion(keys: &Keys, workflow_id: &str) -> Event { + let coordinate = format!( + "{KIND_WORKFLOW_DEFINITION}:{}:{workflow_id}", + keys.public_key().to_hex() + ); + EventBuilder::new(Kind::EventDeletion, "") + .tags([Tag::parse(["a", coordinate.as_str()]).expect("workflow coordinate")]) + .sign_with_keys(keys) + .expect("sign workflow deletion") +} + +async fn assert_trigger_rejected(client: &mut BuzzTestClient, keys: &Keys, workflow_id: &str) { + let trigger = EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER), "{}") + .tags([Tag::parse(["d", workflow_id]).expect("trigger d tag")]) + .sign_with_keys(keys) + .expect("sign workflow trigger"); + let triggered = client + .send_event(trigger) + .await + .expect("submit post-deletion trigger"); + assert!(!triggered.accepted, "deleted workflow remained triggerable"); + assert!( + triggered.message.contains("workflow not found"), + "unexpected post-deletion trigger rejection: {}", + triggered.message + ); +} + +async fn assert_no_live_deletion(listener: &mut BuzzTestClient) { + assert!( + matches!( + listener.recv_event(Duration::from_millis(300)).await, + Err(TestClientError::Timeout) + ), + "rejected/completed duplicate deletion must not be delivered" + ); +} + +async fn assert_live_event(listener: &mut BuzzTestClient, subscription: &str, expected: &Event) { + match listener + .recv_event(Duration::from_secs(5)) + .await + .expect("receive live event") + { + RelayMessage::Event { + subscription_id, + event, + } => { + assert_eq!(subscription_id, subscription); + assert_eq!(*event, *expected, "dispatch the exact signed event"); + } + other => panic!("expected live event, got {other:?}"), + } +} + +async fn audit_count(pool: &PgPool, event: &Event) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM audit_log WHERE object_id = $1 AND action = 'event_created' AND actor_pubkey = $2 AND detail->>'event_kind' = $3") + .bind(event.id.to_hex()) + .bind(event.pubkey.to_bytes().to_vec()) + .bind(event.kind.as_u16().to_string()) + .fetch_one(pool).await.expect("count event audit entries") +} + +async fn flush_audit(client: &mut BuzzTestClient, keys: &Keys, pool: &PgPool) -> Event { + // Audit enqueue is awaited before OK. A later marker in the same relay's + // FIFO audit queue gives a causal flush fence instead of an arbitrary sleep. + let marker = EventBuilder::new(Kind::TextNote, Uuid::new_v4().to_string()) + .sign_with_keys(keys) + .expect("sign audit fence"); + assert!( + client + .send_event(marker.clone()) + .await + .expect("publish audit fence") + .accepted + ); + tokio::time::timeout(Duration::from_secs(5), async { + while audit_count(pool, &marker).await == 0 { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("audit worker must persist fence"); + marker +} + +#[tokio::test] +#[ignore] +async fn deleting_workflow_removes_definition_and_rejects_manual_trigger() { + let keys = Keys::generate(); + let channel_id = Uuid::new_v4().to_string(); + let workflow_id = Uuid::new_v4().to_string(); + let mut client = BuzzTestClient::connect(&relay_url(), &keys) + .await + .expect("connect"); + + create_channel(&mut client, &keys, &channel_id).await; + let definition_id = create_workflow(&mut client, &keys, &channel_id, &workflow_id).await; + + let before = query_definition(&mut client, &keys, &workflow_id).await; + assert_eq!( + before.len(), + 1, + "definition must be queryable before deletion" + ); + assert_eq!(before[0].id, definition_id); + + let deletion = workflow_deletion(&keys, &workflow_id); + let deleted = client.send_event(deletion).await.expect("delete workflow"); + assert!( + deleted.accepted, + "workflow deletion rejected: {}", + deleted.message + ); + + let after = query_definition(&mut client, &keys, &workflow_id).await; + assert!( + after.is_empty(), + "deleted workflow definition remained queryable: {after:?}" + ); + + assert_trigger_rejected(&mut client, &keys, &workflow_id).await; + + client.disconnect().await.expect("disconnect"); +} + +async fn run_failed_deletion_replay_scenario(pool: &PgPool) { + let keys = Keys::generate(); + let channel_id = Uuid::new_v4().to_string(); + let workflow_id = Uuid::new_v4(); + let workflow_id_text = workflow_id.to_string(); + let mut client = BuzzTestClient::connect(&relay_url(), &keys) + .await + .expect("connect"); + + create_channel(&mut client, &keys, &channel_id).await; + create_workflow(&mut client, &keys, &channel_id, &workflow_id_text).await; + assert_eq!( + database_representation_counts(pool, &keys, workflow_id).await, + (1, 1), + "workflow setup must create both representations" + ); + + let deletion = workflow_deletion(&keys, &workflow_id_text); + let deletion_id = deletion.id; + let mut listener = BuzzTestClient::connect(&relay_url(), &keys) + .await + .expect("connect live listener"); + let subscription = sub_id("live-deletion"); + listener + .subscribe( + &subscription, + vec![Filter::new() + .kinds([Kind::EventDeletion, Kind::TextNote]) + .author(keys.public_key())], + ) + .await + .expect("subscribe before failure"); + assert!(listener + .collect_until_eose(&subscription, Duration::from_secs(5)) + .await + .expect("establish live subscription") + .is_empty()); + let mut injector = FailureInjector::install(pool.clone(), &keys, workflow_id).await; + let rejected = client + .send_event(deletion.clone()) + .await + .expect("submit injected-failure deletion"); + assert!( + !rejected.accepted, + "injected deletion failure was acknowledged" + ); + // WebSocket ingestion deliberately redacts internal database errors. + assert_eq!(rejected.message, "error: internal server error"); + let marker = flush_audit(&mut client, &keys, pool).await; + assert_live_event(&mut listener, &subscription, &marker).await; + assert_no_live_deletion(&mut listener).await; + assert_eq!( + audit_count(pool, &deletion).await, + 0, + "failed deletion must not be audited as accepted" + ); + + assert_eq!( + database_representation_counts(pool, &keys, workflow_id).await, + (1, 1), + "failed deletion must retain both workflow representations" + ); + let after_failure = query_definition(&mut client, &keys, &workflow_id_text).await; + assert_eq!( + after_failure.len(), + 1, + "definition disappeared after rejected deletion" + ); + + let stored_requests: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE id = $1") + .bind(deletion_id.to_bytes().to_vec()) + .fetch_one(pool) + .await + .expect("count stored deletion"); + assert_eq!( + stored_requests, 0, + "rejected deletion must roll back its public event" + ); + assert!( + query_deletion(&mut client, &deletion).await.is_empty(), + "rejected deletion must not appear in fresh REQ history" + ); + injector.remove().await; + let replayed = client + .send_event(deletion.clone()) + .await + .expect("replay identical signed deletion"); + assert!( + replayed.accepted, + "identical deletion replay rejected: {}", + replayed.message + ); + assert_eq!(replayed.event_id, deletion_id.to_hex()); + assert_eq!( + query_deletion(&mut client, &deletion).await, + vec![deletion.clone()] + ); + assert_live_event(&mut listener, &subscription, &deletion).await; + let marker = flush_audit(&mut client, &keys, pool).await; + assert_live_event(&mut listener, &subscription, &marker).await; + assert_eq!( + audit_count(pool, &deletion).await, + 1, + "repair must enqueue normal audit exactly once" + ); + assert_eq!( + database_representation_counts(pool, &keys, workflow_id).await, + (0, 0), + "successful replay must remove both workflow representations" + ); + assert!( + query_definition(&mut client, &keys, &workflow_id_text) + .await + .is_empty(), + "definition remained queryable after successful replay" + ); + assert_trigger_rejected(&mut client, &keys, &workflow_id_text).await; + let duplicate = client + .send_event(deletion.clone()) + .await + .expect("repeat completed deletion"); + assert!(duplicate.accepted && duplicate.message.starts_with("duplicate:")); + let marker = flush_audit(&mut client, &keys, pool).await; + assert_live_event(&mut listener, &subscription, &marker).await; + assert_no_live_deletion(&mut listener).await; + assert_eq!( + audit_count(pool, &deletion).await, + 1, + "completed duplicate must not re-audit" + ); + listener.disconnect().await.expect("disconnect listener"); + client.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn failed_workflow_deletion_rolls_back_and_identical_event_can_be_replayed() { + let pool = PgPool::connect(&database_url()) + .await + .expect("connect to isolated workflow-deletion database"); + let result = AssertUnwindSafe(run_failed_deletion_replay_scenario(&pool)) + .catch_unwind() + .await; + + // Clean up even when the scenario panics so a failed run cannot poison the + // disposable relay for subsequent validation. + sqlx::raw_sql( + "DROP TRIGGER IF EXISTS reject_injected_workflow_delete ON events; \ + DROP FUNCTION IF EXISTS reject_injected_workflow_delete(); \ + DROP TABLE IF EXISTS workflow_delete_failure_injection;", + ) + .execute(&pool) + .await + .expect("final deletion failure injector cleanup"); + + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +} + +#[tokio::test] +#[ignore] +async fn workflow_can_be_intentionally_recreated_with_a_backdated_definition() { + let keys = Keys::generate(); + let channel_id = Uuid::new_v4().to_string(); + let workflow_id = Uuid::new_v4(); + let workflow_id_text = workflow_id.to_string(); + let mut client = BuzzTestClient::connect(&relay_url(), &keys) + .await + .expect("connect"); + create_channel(&mut client, &keys, &channel_id).await; + create_workflow(&mut client, &keys, &channel_id, &workflow_id_text).await; + let deletion = workflow_deletion(&keys, &workflow_id_text); + let backdated = workflow_definition(&channel_id, &workflow_id_text) + .custom_created_at(Timestamp::from(deletion.created_at.as_secs() - 60)) + .sign_with_keys(&keys) + .expect("sign intentional backdated recreation"); + assert!(client.send_event(deletion).await.expect("delete").accepted); + assert!(query_definition(&mut client, &keys, &workflow_id_text) + .await + .is_empty()); + let recreated = client + .send_event(backdated.clone()) + .await + .expect("recreate backdated workflow"); + assert!( + recreated.accepted, + "backdating is intentional: {}", + recreated.message + ); + let definitions = query_definition(&mut client, &keys, &workflow_id_text).await; + assert_eq!(definitions.len(), 1); + assert_eq!(definitions[0].id, backdated.id); + let pool = PgPool::connect(&database_url()) + .await + .expect("isolated database"); + assert_eq!( + database_representation_counts(&pool, &keys, workflow_id).await, + (1, 1) + ); + let trigger = EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER), "{}") + .tags([Tag::parse(["d", workflow_id_text.as_str()]).expect("trigger coordinate")]) + .sign_with_keys(&keys) + .expect("sign recreated workflow trigger"); + assert!( + client + .send_event(trigger) + .await + .expect("trigger recreated workflow") + .accepted + ); + client.disconnect().await.expect("disconnect"); +} + +async fn concurrent_deletion_scenario( + pool: &PgPool, + barrier: &mut Option>, + tasks: &mut tokio::task::JoinSet>, +) { + let keys = Keys::generate(); + let channel_id = Uuid::new_v4().to_string(); + let workflow_id = Uuid::new_v4(); + let workflow_id_text = workflow_id.to_string(); + let mut client = BuzzTestClient::connect(&relay_url(), &keys) + .await + .expect("connect"); + create_channel(&mut client, &keys, &channel_id).await; + create_workflow(&mut client, &keys, &channel_id, &workflow_id_text).await; + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags([ + Tag::parse([ + "a", + &format!("30620:{}:{workflow_id}", keys.public_key().to_hex()), + ]) + .expect("coordinate"), + Tag::public_key(Keys::generate().public_key()), + ]) + .sign_with_keys(&keys) + .expect("sign deletion"); + let mut listener = BuzzTestClient::connect(&relay_url(), &keys) + .await + .expect("listener"); + let subscription = sub_id("concurrent-listener"); + listener + .subscribe( + &subscription, + vec![Filter::new() + .kinds([Kind::EventDeletion, Kind::TextNote]) + .author(keys.public_key())], + ) + .await + .expect("subscribe before concurrent requests"); + assert!(listener + .collect_until_eose(&subscription, Duration::from_secs(5)) + .await + .expect("listener EOSE") + .is_empty()); + + // Block A's first-insert-only mention indexing. In the old split path its + // public event is already committed and B can repair while A is held. In + // the atomic path B must wait on A's uncommitted unique event insertion. + // Both orderings are causally observed in Postgres, never guessed by sleep. + let lock_key = 7_735_306_205_i64; + *barrier = Some(pool.begin().await.expect("barrier transaction")); + let held = barrier.as_mut().expect("barrier exists"); + let controller: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut **held) + .await + .expect("controller pid"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut **held) + .await + .expect("hold indexing barrier"); + // Interpolated values are a fixed integer and an EventId's hex encoding, + // neither of which can contain SQL syntax. + sqlx::raw_sql(sqlx::AssertSqlSafe(format!( + "CREATE FUNCTION block_workflow_deletion_index() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.event_id = decode('{}', 'hex') THEN + PERFORM pg_advisory_xact_lock({lock_key}); + END IF; RETURN NEW; END $$; + CREATE TRIGGER block_workflow_deletion_index BEFORE INSERT ON event_mentions + FOR EACH ROW EXECUTE FUNCTION block_workflow_deletion_index();", + deletion.id.to_hex() + ))) + .execute(pool) + .await + .expect("install event-scoped indexing barrier"); + + let mut second_client = BuzzTestClient::connect(&relay_url(), &keys) + .await + .expect("second publisher"); + let first_event = deletion.clone(); + tasks.spawn(async move { client.send_event(first_event).await }); + let first_pid: i32 = + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let pid = sqlx::query_scalar::<_, i32>( + "SELECT pid FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid)) LIMIT 1") + .bind(controller).fetch_optional(pool).await.expect("find held first ingest"); + if let Some(pid) = pid { + break pid; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("first ingest must reach indexing barrier"); + let second_event = deletion.clone(); + let second = tasks.spawn(async move { second_client.send_event(second_event).await }); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let blocked: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid)))") + .bind(first_pid).fetch_one(pool).await.expect("observe second ingest wait"); + if blocked || second.is_finished() { break; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("second ingest must either block on A or finish its repair"); + barrier + .take() + .expect("held barrier") + .commit() + .await + .expect("release first ingest"); + let first = tasks + .join_next() + .await + .expect("first task") + .expect("join first") + .expect("first response"); + let second = tasks + .join_next() + .await + .expect("second task") + .expect("join second") + .expect("second response"); + assert!( + first.accepted && second.accepted, + "both requests accepted: {first:?}, {second:?}" + ); + let mut client = BuzzTestClient::connect(&relay_url(), &keys) + .await + .expect("verification client"); + let marker = flush_audit(&mut client, &keys, pool).await; + assert_eq!( + audit_count(pool, &deletion).await, + 1, + "concurrent ingests must have one dispatch/audit owner" + ); + assert_eq!( + [&first, &second] + .iter() + .filter(|response| response.message.is_empty()) + .count(), + 1, + "exactly one ingest owns normal acceptance" + ); + assert_eq!( + [&first, &second] + .iter() + .filter(|response| response.message.starts_with("duplicate:")) + .count(), + 1, + "the other ingest must be a completed duplicate" + ); + // Fan-out tasks can complete out of order; require each exact event once. + let mut expected = vec![deletion.clone(), marker]; + for _ in 0..2 { + match listener + .recv_event(Duration::from_secs(5)) + .await + .expect("receive deletion/fence") + { + RelayMessage::Event { + subscription_id, + event, + } => { + assert_eq!(subscription_id, subscription); + let index = expected + .iter() + .position(|candidate| candidate == event.as_ref()) + .expect("one copy of each exact deletion/fence event"); + expected.remove(index); + } + other => panic!("expected deletion/fence, got {other:?}"), + } + } + assert_no_live_deletion(&mut listener).await; + assert_eq!(query_deletion(&mut client, &deletion).await, vec![deletion]); + assert_eq!( + database_representation_counts(pool, &keys, workflow_id).await, + (0, 0) + ); + assert!(query_definition(&mut client, &keys, &workflow_id_text) + .await + .is_empty()); + assert_trigger_rejected(&mut client, &keys, &workflow_id_text).await; +} + +#[tokio::test] +#[ignore] +async fn concurrent_identical_deletions_dispatch_and_audit_once() { + let pool = PgPool::connect(&database_url()) + .await + .expect("isolated database"); + let mut barrier = None; + let mut tasks = tokio::task::JoinSet::new(); + let result = AssertUnwindSafe(concurrent_deletion_scenario( + &pool, + &mut barrier, + &mut tasks, + )) + .catch_unwind() + .await; + // Release blocked server transactions before dropping a trigger they use. + if let Some(barrier) = barrier { + barrier + .rollback() + .await + .expect("release failed scenario barrier"); + } + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + sqlx::raw_sql( + "DROP TRIGGER IF EXISTS block_workflow_deletion_index ON event_mentions; + DROP FUNCTION IF EXISTS block_workflow_deletion_index();", + ) + .execute(&pool) + .await + .expect("remove indexing barrier"); + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +} diff --git a/desktop/src/features/workflows/ui/WorkflowDeleteDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDeleteDialog.tsx index 1c0b1aeb05..0e07c28a9b 100644 --- a/desktop/src/features/workflows/ui/WorkflowDeleteDialog.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDeleteDialog.tsx @@ -39,7 +39,7 @@ export function WorkflowDeleteDialog({ Delete workflow? {workflow - ? `Delete "${workflow.name}". This will stop all future triggers and remove the workflow permanently.` + ? `Delete "${workflow.name}". This removes the current workflow and stops new triggers. Publishing its definition again, even an older version, can recreate it.` : "Delete this workflow."} {error ? ( diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 16844a2f3e..baf7e29a9c 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -642,8 +642,13 @@ test("deleting the open workflow closes its editor", async ({ page }) => { await expect(detailDialog).toBeVisible(); await detailDialog.getByRole("button", { name: "Workflow actions" }).click(); await page.getByRole("menuitem", { name: "Delete" }).click(); - await expect(page.getByRole("alertdialog")).toContainText(workflowName); - await page.getByRole("button", { name: "Delete" }).click(); + const confirmation = page.getByRole("alertdialog"); + await expect(confirmation).toContainText(workflowName); + await expect(confirmation).toContainText( + "Publishing its definition again, even an older version, can recreate it.", + ); + await expect(confirmation).not.toContainText("permanently"); + await confirmation.getByRole("button", { name: "Delete" }).click(); await expect(detailDialog).toHaveCount(0); await expect(page).toHaveURL(/#\/workflows(?:\?|$)/); From a61239f0d8036aff58176f5c0ce7f080c66e21b7 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 21 Sep 2026 08:51:05 -0700 Subject: [PATCH 18/22] fix(mobile): avoid opening empty threads on message tap (#7756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a mobile channel message with no replies currently opens an empty thread. Only enable message-row thread navigation when the existing thread summary reports replies, including replies known to the relay but not loaded locally. Long-press → Reply still starts a new thread. Added widget tests for zero replies, loaded replies, relay-only replies, and starting a thread through Reply. The zero-reply test failed before the fix and passes afterward. Validation: all 227 channel-page tests, the full mobile pre-push checks, and all PR CI checks passed. The full repository gate failed in unchanged Rust ACP timing tests (idle reset, keepalive, and steering deadlines); this PR changes only mobile Dart files. No duplicate open PR or issue found. --------- Signed-off-by: Tom Brow --- .github/workflows/_ci-relay.yml | 2 +- crates/buzz-db/src/store/thread.rs | 484 +- crates/buzz-relay/src/api/bridge.rs | 24 +- .../buzz-relay/src/api/bridge/thread_roots.rs | 123 + .../tests/e2e_thread_roots.rs | 148 + docs/nips/NIP-CW.md | 22 + .../channels/channel_detail_page.dart | 1 + .../channel_detail_page/message_bubble.dart | 19 +- .../channel_detail_page/message_list.dart | 1 + .../channel_detail_page/system_rows.dart | 5 +- .../channels/channel_messages_provider.dart | 502 +- .../thread_summaries.dart | 674 +++ .../lib/features/channels/channel_window.dart | 12 +- .../nested_thread_summary_row.dart | 5 +- .../channels/thread_replies_provider.dart | 151 +- .../channels/thread_reply_ownership.dart | 26 + .../thread_summary_refresh_queue.dart | 143 + .../features/channels/timeline_message.dart | 31 +- .../channels/channel_detail_page_test.dart | 269 +- .../channel_messages_provider_test.dart | 4154 ++++++++++++++++- .../thread_replies_provider_test.dart | 86 + .../channels/thread_reply_ownership_test.dart | 30 + .../channels/timeline_message_test.dart | 22 + 23 files changed, 6777 insertions(+), 157 deletions(-) create mode 100644 crates/buzz-relay/src/api/bridge/thread_roots.rs create mode 100644 crates/buzz-test-client/tests/e2e_thread_roots.rs create mode 100644 mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart create mode 100644 mobile/lib/features/channels/thread_reply_ownership.dart create mode 100644 mobile/lib/features/channels/thread_summary_refresh_queue.dart create mode 100644 mobile/test/features/channels/thread_reply_ownership_test.dart diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index 5776f37fe3..3d7bc6e35c 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -587,7 +587,7 @@ jobs: ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | - cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_thread_roots --test e2e_project -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_departure_wire -- --ignored --nocapture diff --git a/crates/buzz-db/src/store/thread.rs b/crates/buzz-db/src/store/thread.rs index a38ac2b038..b64ff09c31 100644 --- a/crates/buzz-db/src/store/thread.rs +++ b/crates/buzz-db/src/store/thread.rs @@ -405,7 +405,7 @@ pub(crate) async fn get_thread_replies_on( // Decode cursor bytes -> keyset (timestamp, optional event_id) for the // WHERE condition. Layout: 8-byte BE i64 seconds, then the raw event_id. // An 8-byte-only cursor is legacy timestamp-only paging (no tiebreak). - let cursor_key: Option<(DateTime, Option>)> = match cursor { + let mut cursor_key: Option<(DateTime, Option>)> = match cursor { Some(bytes) if bytes.len() >= 8 => { let secs = i64::from_be_bytes(bytes[..8].try_into().expect("length checked")); DateTime::from_timestamp(secs, 0).map(|ts| { @@ -420,11 +420,20 @@ pub(crate) async fn get_thread_replies_on( _ => None, }; - // Build the query dynamically based on optional filters. - // Track the next positional parameter index. - let mut param_idx = 3u32; // $1 is community_id, $2 is root_event_id - let mut sql = String::from( - r#" + let mut replies = Vec::new(); + // Damaged rows must not cause unbounded work on a held writer connection. + // Fail explicitly rather than exposing a partial page as authoritative EOF. + const MAX_RAW_PAGES: usize = 64; + for _ in 0..MAX_RAW_PAGES { + let remaining = limit.saturating_sub(replies.len() as u32); + if remaining == 0 { + return Ok(replies); + } + // Build the query dynamically based on optional filters. + // Track the next positional parameter index. + let mut param_idx = 3u32; // $1 is community_id, $2 is root_event_id + let mut sql = String::from( + r#" SELECT tm.event_id, e.id, @@ -450,91 +459,103 @@ pub(crate) async fn get_thread_replies_on( AND tm.root_event_id = $2 AND e.deleted_at IS NULL "#, - ); + ); - if depth_limit.is_some() { - sql.push_str(&format!(" AND tm.depth <= ${param_idx}")); - param_idx += 1; - } - match &cursor_key { - Some((_, Some(_))) => { - // Composite keyset: strict row comparison with an event_id tiebreak - // so same-second replies paginate without gaps or duplicates. - let ts_idx = param_idx; - let id_idx = param_idx + 1; - sql.push_str(&format!( - " AND (tm.event_created_at, tm.event_id) > (${ts_idx}, ${id_idx})" - )); - param_idx += 2; - } - Some((_, None)) => { - // Legacy timestamp-only cursor (no tiebreak). - sql.push_str(&format!(" AND tm.event_created_at > ${param_idx}")); + if depth_limit.is_some() { + sql.push_str(&format!(" AND tm.depth <= ${param_idx}")); param_idx += 1; } - None => {} - } - - sql.push_str(&format!( - " ORDER BY tm.event_created_at ASC, tm.event_id ASC LIMIT ${param_idx}" - )); - - let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)) - .bind(community_id.as_uuid()) - .bind(root_event_id); - - if let Some(dl) = depth_limit { - q = q.bind(dl as i32); - } - match &cursor_key { - Some((ts, Some(id))) => { - q = q.bind(*ts).bind(id.clone()); + match &cursor_key { + Some((_, Some(_))) => { + // Composite keyset: strict row comparison with an event_id tiebreak + // so same-second replies paginate without gaps or duplicates. + let ts_idx = param_idx; + let id_idx = param_idx + 1; + sql.push_str(&format!( + " AND (tm.event_created_at, tm.event_id) > (${ts_idx}, ${id_idx})" + )); + param_idx += 2; + } + Some((_, None)) => { + // Legacy timestamp-only cursor (no tiebreak). + sql.push_str(&format!(" AND tm.event_created_at > ${param_idx}")); + param_idx += 1; + } + None => {} } - Some((ts, None)) => { - q = q.bind(*ts); + + sql.push_str(&format!( + " ORDER BY tm.event_created_at ASC, tm.event_id ASC LIMIT ${param_idx}" + )); + + let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)) + .bind(community_id.as_uuid()) + .bind(root_event_id); + + if let Some(dl) = depth_limit { + q = q.bind(dl as i32); + } + match &cursor_key { + Some((ts, Some(id))) => { + q = q.bind(*ts).bind(id.clone()); + } + Some((ts, None)) => { + q = q.bind(*ts); + } + None => {} + } + q = q.bind(remaining as i32); + + let rows = q.fetch_all(&mut *conn).await?; + + let raw_count = rows.len(); + if let Some(last) = rows.last() { + cursor_key = Some(( + last.try_get("event_created_at")?, + Some(last.try_get("event_id")?), + )); + } + for row in rows { + let event_id: Vec = row.try_get("event_id")?; + let parent_event_id: Option> = row.try_get("parent_event_id")?; + let root_event_id_col: Option> = row.try_get("root_event_id")?; + let channel_id: Uuid = row.try_get("channel_id")?; + let pubkey: Vec = row.try_get("pubkey")?; + let tags: serde_json::Value = row.try_get("tags")?; + let depth: i32 = row.try_get("depth")?; + let created_at: DateTime = row.try_get("event_created_at")?; + let broadcast_val: bool = row.try_get("broadcast")?; + + // Skip rows that fail event reconstruction (e.g. corrupt signature) + // while continuing past the raw page boundary to fill the requested + // page. A short response must mean SQL EOF, not a skipped stored row. + let stored_event = match row_to_stored_event(row)? { + Some(se) => se, + None => continue, + }; + + replies.push(ThreadReply { + event_id, + parent_event_id, + root_event_id: root_event_id_col, + channel_id, + pubkey, + tags, + content: stored_event.event.content.clone(), + stored_event, + depth, + created_at, + broadcast: broadcast_val, + }); + } + + if raw_count < remaining as usize || replies.len() == limit as usize { + return Ok(replies); } - None => {} } - q = q.bind(limit as i32); - - let rows = q.fetch_all(&mut *conn).await?; - - let mut replies = Vec::with_capacity(rows.len()); - for row in rows { - let event_id: Vec = row.try_get("event_id")?; - let parent_event_id: Option> = row.try_get("parent_event_id")?; - let root_event_id_col: Option> = row.try_get("root_event_id")?; - let channel_id: Uuid = row.try_get("channel_id")?; - let pubkey: Vec = row.try_get("pubkey")?; - let tags: serde_json::Value = row.try_get("tags")?; - let depth: i32 = row.try_get("depth")?; - let created_at: DateTime = row.try_get("event_created_at")?; - let broadcast_val: bool = row.try_get("broadcast")?; - - // Skip rows that fail event reconstruction (e.g. corrupt signature) - // rather than failing the whole thread query, matching the - // skip-and-continue semantics of the prior get_events_by_ids path. - let stored_event = match row_to_stored_event(row)? { - Some(se) => se, - None => continue, - }; - - replies.push(ThreadReply { - event_id, - parent_event_id, - root_event_id: root_event_id_col, - channel_id, - pubkey, - tags, - content: stored_event.event.content.clone(), - stored_event, - depth, - created_at, - broadcast: broadcast_val, - }); - } - - Ok(replies) + Err(crate::error::DbError::InvalidData( + "thread page exceeded the corrupt-row refill limit".into(), + )) } /// Fetch aggregated thread stats for a single event, plus up to 10 participant pubkeys. @@ -1043,6 +1064,62 @@ impl Db { crate::thread::get_thread_summary(&self.pool, community_id, event_id).await } + /// Resolve channel-scoped reply IDs, including tombstones, to current root + /// summaries on the writer. Returns metadata only, never target payloads. + #[datastore_span(name = "resolve_thread_root_summaries", system = "postgresql")] + pub async fn resolve_thread_root_summaries( + &self, + community_id: CommunityId, + channel_id: Uuid, + target_ids: &[Vec], + ) -> Result, ThreadSummary)>> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; + let rows = sqlx::query( + r#" + SELECT root.event_id, root.reply_count, root.descendant_count, root.last_reply_at, + ARRAY( + SELECT e.pubkey + FROM thread_metadata tm + JOIN events e ON e.community_id = tm.community_id + AND e.created_at = tm.event_created_at AND e.id = tm.event_id + WHERE tm.community_id = root.community_id AND tm.root_event_id = root.event_id + AND tm.channel_id = root.channel_id AND e.deleted_at IS NULL + GROUP BY e.pubkey ORDER BY MAX(e.created_at) DESC LIMIT 10 + ) AS participants + FROM thread_metadata root + WHERE root.community_id = $1 AND root.channel_id = $2 + AND root.event_id IN ( + SELECT COALESCE(target.root_event_id, target.parent_event_id) + FROM thread_metadata target + WHERE target.community_id = $1 AND target.channel_id = $2 + AND target.event_id = ANY($3) AND target.parent_event_id IS NOT NULL + ) + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(target_ids) + .fetch_all(&mut *connection) + .await?; + rows.into_iter() + .map(|row| { + Ok(( + row.try_get("event_id")?, + ThreadSummary { + reply_count: row.try_get("reply_count")?, + descendant_count: row.try_get("descendant_count")?, + last_reply_at: row.try_get("last_reply_at")?, + participants: row.try_get("participants")?, + }, + )) + }) + .collect() + } + /// One channel window: top-level rows + summaries + server `has_more`. /// /// Convenience wrapper over [`Db::get_channel_window_with_session`] for @@ -1290,6 +1367,83 @@ mod postgres_tests { .map(|channel| (channel, buzz_core::CommunityId::from_uuid(community_id))) } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn resolve_deleted_roots_preserves_community_scope_for_colliding_ids() { + let pool = setup_pool().await; + let db = Db::from_pool(pool.clone()); + let author = Keys::generate(); + let channel = Uuid::new_v4(); + let communities = [ + CommunityId::from_uuid(make_test_community(&pool).await), + CommunityId::from_uuid(make_test_community(&pool).await), + ]; + let root = make_stream_event(&author, "shared root identity"); + let reply = make_stream_event(&author, "shared reply identity"); + for community in communities { + crate::channel::create_channel_with_id( + &pool, + community, + channel, + &format!("root-scope-{channel}"), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("channel"); + for (event, parent) in [(&root, None), (&reply, Some(&root))] { + insert_event_with_thread_metadata( + &pool, + community, + event, + Some(channel), + Some(ThreadMetadataParams { + event_id: event.id.as_bytes(), + event_created_at: event_created_at(event), + channel_id: channel, + parent_event_id: parent.map(|parent| parent.id.as_bytes().as_slice()), + parent_event_created_at: parent.map(event_created_at), + root_event_id: parent.map(|parent| parent.id.as_bytes().as_slice()), + root_event_created_at: parent.map(event_created_at), + depth: i32::from(parent.is_some()), + broadcast: false, + }), + ) + .await + .expect("thread event"); + } + } + db.soft_delete_event_and_update_thread( + communities[0], + reply.id.as_bytes(), + Some(root.id.as_bytes()), + Some(root.id.as_bytes()), + ) + .await + .expect("delete only A"); + for (community, expected) in [(communities[0], 0), (communities[1], 1)] { + let summaries = db + .resolve_thread_root_summaries(community, channel, &[reply.id.as_bytes().to_vec()]) + .await + .expect("metadata query"); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].0, root.id.as_bytes()); + assert_eq!(summaries[0].1.descendant_count, expected); + } + assert!(db + .resolve_thread_root_summaries( + communities[0], + Uuid::new_v4(), + &[reply.id.as_bytes().to_vec()] + ) + .await + .expect("wrong channel") + .is_empty()); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn get_thread_metadata_by_event_is_scoped_when_event_id_collides_across_communities() { @@ -1755,7 +1909,10 @@ mod postgres_tests { .expect("insert root event"); // Two replies under the same root: one stays valid, one we corrupt. - let good = make_stream_event(&author, "good"); + let good = EventBuilder::new(Kind::Custom(9), "good") + .custom_created_at(nostr::Timestamp::from(root.created_at.as_secs() + 2)) + .sign_with_keys(&author) + .expect("good reply"); let good_id = good.id.to_hex(); let good_created_at = event_created_at(&good); insert_event_with_thread_metadata( @@ -1778,7 +1935,10 @@ mod postgres_tests { .await .expect("insert good reply"); - let bad = make_stream_event(&author, "bad"); + let bad = EventBuilder::new(Kind::Custom(9), "bad") + .custom_created_at(nostr::Timestamp::from(root.created_at.as_secs() + 1)) + .sign_with_keys(&author) + .expect("bad reply"); let bad_created_at = event_created_at(&bad); insert_event_with_thread_metadata( &pool, @@ -1821,6 +1981,156 @@ mod postgres_tests { assert_eq!(replies.len(), 1); assert_eq!(replies[0].stored_event.event.id.to_hex(), good_id); assert_eq!(replies[0].stored_event.event.content, "good"); + + let tail = EventBuilder::new(Kind::Custom(9), "tail") + .custom_created_at(nostr::Timestamp::from(root.created_at.as_secs() + 3)) + .sign_with_keys(&author) + .expect("tail reply"); + insert_event_with_thread_metadata( + &pool, + community, + &tail, + Some(channel.id), + Some(ThreadMetadataParams { + event_id: tail.id.as_bytes(), + event_created_at: event_created_at(&tail), + channel_id: channel.id, + parent_event_id: Some(root.id.as_bytes()), + parent_event_created_at: Some(root_created_at), + root_event_id: Some(root.id.as_bytes()), + root_event_created_at: Some(root_created_at), + depth: 1, + broadcast: false, + }), + ) + .await + .expect("insert tail"); + let mut origin = (-1_i64).to_be_bytes().to_vec(); + origin.extend_from_slice(&[0; 32]); + let page = get_thread_replies( + &pool, + community, + root.id.as_bytes(), + Some(10), + 2, + Some(&origin), + ) + .await + .expect("fill page past corrupt first row"); + assert_eq!( + page.iter() + .map(|reply| reply.stored_event.event.id) + .collect::>(), + [good.id, tail.id] + ); + let first = get_thread_replies( + &pool, + community, + root.id.as_bytes(), + Some(10), + 1, + Some(&origin), + ) + .await + .expect("page entirely corrupt must not signal EOF"); + assert_eq!(first[0].stored_event.event.id, good.id); + let mut after = (tail.created_at.as_secs() as i64).to_be_bytes().to_vec(); + after.extend_from_slice(tail.id.as_bytes()); + assert!(get_thread_replies( + &pool, + community, + root.id.as_bytes(), + Some(10), + 2, + Some(&after) + ) + .await + .expect("true EOF") + .is_empty()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn get_thread_replies_bounds_corrupt_row_refills() { + let pool = setup_pool().await; + let author = Keys::generate(); + let (channel, community) = create_test_channel( + &pool, + &format!("corrupt-bound-{}", Uuid::new_v4()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("channel"); + let root = make_stream_event(&author, "root"); + insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) + .await + .expect("root"); + let mut corrupt_ids = Vec::new(); + for index in 0..65 { + let reply = EventBuilder::new(Kind::Custom(9), format!("reply-{index}")) + .custom_created_at(nostr::Timestamp::from( + root.created_at.as_secs() + index + 1, + )) + .sign_with_keys(&author) + .expect("reply"); + insert_event_with_thread_metadata( + &pool, + community, + &reply, + Some(channel.id), + Some(ThreadMetadataParams { + event_id: reply.id.as_bytes(), + event_created_at: event_created_at(&reply), + channel_id: channel.id, + parent_event_id: Some(root.id.as_bytes()), + parent_event_created_at: Some(event_created_at(&root)), + root_event_id: Some(root.id.as_bytes()), + root_event_created_at: Some(event_created_at(&root)), + depth: 1, + broadcast: false, + }), + ) + .await + .expect("reply metadata"); + if index < 64 { + corrupt_ids.push(reply.id.as_bytes().to_vec()); + } + } + sqlx::query("UPDATE events SET sig = $1 WHERE community_id = $2 AND id = ANY($3)") + .bind(vec![0u8; 32]) + .bind(community.as_uuid()) + .bind(&corrupt_ids) + .execute(&pool) + .await + .expect("damage first 64 rows"); + let error = get_thread_replies(&pool, community, root.id.as_bytes(), Some(10), 1, None) + .await + .expect_err("refill cap must fail, never return partial EOF or scan to the tail"); + assert!( + matches!(error, crate::error::DbError::InvalidData(ref message) + if message.contains("corrupt-row refill limit")) + ); + // A larger page needs fewer than 64 raw reads and still finds the tail. + let replies = get_thread_replies(&pool, community, root.id.as_bytes(), Some(10), 2, None) + .await + .expect("bounded skip remains supported"); + assert_eq!(replies.len(), 1); + assert_eq!(replies[0].stored_event.event.content, "reply-64"); + // The final permitted raw page may be valid; accept it at the cap. + sqlx::query("UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(&corrupt_ids[0]) + .execute(&pool) + .await + .expect("hide one corrupt row"); + let boundary = get_thread_replies(&pool, community, root.id.as_bytes(), Some(10), 1, None) + .await + .expect("valid final refill page must succeed"); + assert_eq!(boundary[0].stored_event.event.content, "reply-64"); } /// Insert one top-level event (root metadata, broadcast) into a channel. diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 6ec0483cb9..93e4f1769f 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -21,6 +21,8 @@ use crate::state::AppState; use super::{api_error, internal_error, not_found}; +mod thread_roots; + pub(crate) async fn enforce_http_admission( state: &AppState, tenant: &TenantContext, @@ -1185,10 +1187,30 @@ async fn query_events_authed( let mut events: Vec = Vec::new(); let mut handled: std::collections::HashSet = std::collections::HashSet::new(); + let ownership_targets: usize = raw_filters + .iter() + .zip(&filters) + .filter(|(raw, _)| extension_flag(raw, "resolve_thread_roots")) + .map(|(_, filter)| filter.ids.as_ref().map_or(0, |ids| ids.len())) + .sum(); + if ownership_targets > 100 { + return Err(api_error( + StatusCode::BAD_REQUEST, + "resolve_thread_roots permits at most 100 targets per request", + )); + } + // Resolve reply owners from retained thread metadata, including tombstones. + for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { + if extension_flag(raw, "resolve_thread_roots") { + events.extend(thread_roots::query(state, tenant, filter, &accessible_channels).await?); + handled.insert(idx); + } + } + // Channel-window filters (`top_level: true`) — the GUI read-model surface. // Dispatched first: a window filter is never a feed/thread/catchall query. for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { - if !extension_flag(raw, "top_level") { + if handled.contains(&idx) || !extension_flag(raw, "top_level") { continue; } handle_channel_window_filter( diff --git a/crates/buzz-relay/src/api/bridge/thread_roots.rs b/crates/buzz-relay/src/api/bridge/thread_roots.rs new file mode 100644 index 0000000000..ada811b8d6 --- /dev/null +++ b/crates/buzz-relay/src/api/bridge/thread_roots.rs @@ -0,0 +1,123 @@ +//! Metadata-only recovery after a reply deletion whose live recount was missed. + +use axum::{http::StatusCode, Json}; +use buzz_core::{kind::KIND_THREAD_SUMMARY, TenantContext}; +use nostr::{EventBuilder, Filter, Kind, Tag}; +use serde_json::Value; +use uuid::Uuid; + +use super::{api_error, extract_channel_from_filter, internal_error}; +use crate::state::AppState; + +type QueryError = (StatusCode, Json); + +fn request_channel(filter: &Filter) -> Result { + let channel = extract_channel_from_filter(filter).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "resolve_thread_roots requires exactly one #h channel", + ) + })?; + if !filter.kinds.as_ref().is_some_and(|kinds| { + kinds.len() == 1 && kinds.contains(&Kind::Custom(KIND_THREAD_SUMMARY as u16)) + }) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "resolve_thread_roots requires kinds [39005]", + )); + } + if !filter + .ids + .as_ref() + .is_some_and(|ids| !ids.is_empty() && ids.len() <= 100) + { + return Err(api_error( + StatusCode::BAD_REQUEST, + "resolve_thread_roots requires 1 to 100 target ids", + )); + } + Ok(channel) +} + +/// Return signed root summaries from retained, channel-scoped reply metadata. +pub(super) async fn query( + state: &AppState, + tenant: &TenantContext, + filter: &Filter, + accessible_channels: &[Uuid], +) -> Result, QueryError> { + let channel = request_channel(filter)?; + if !accessible_channels.contains(&channel) { + return Ok(Vec::new()); + } + let ids = filter + .ids + .iter() + .flatten() + .map(|id| id.as_bytes().to_vec()) + .collect::>(); + let summaries = state + .db + .resolve_thread_root_summaries(tenant.community(), channel, &ids) + .await + .map_err(|e| internal_error(&format!("thread ownership summaries: {e}")))?; + let mut events = Vec::with_capacity(summaries.len()); + for (root, summary) in summaries { + let root_hex = hex::encode(root); + let channel_hex = channel.to_string(); + let tags = [ + ["e", root_hex.as_str()], + ["d", root_hex.as_str()], + ["h", channel_hex.as_str()], + ] + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|e| internal_error(&format!("thread summary tags: {e}")))?; + let content = serde_json::json!({ + "reply_count": summary.reply_count, + "descendant_count": summary.descendant_count, + "last_reply_at": summary.last_reply_at.map(|t| t.timestamp()), + "participants": summary.participants.iter().map(hex::encode).collect::>(), + }); + let event = EventBuilder::new( + Kind::Custom(KIND_THREAD_SUMMARY as u16), + content.to_string(), + ) + .tags(tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| internal_error(&format!("thread summary signing: {e}")))?; + events.push( + serde_json::to_value(event) + .map_err(|e| internal_error(&format!("thread summary serialization: {e}")))?, + ); + } + Ok(events) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn filter(ids: usize) -> Filter { + serde_json::from_value(serde_json::json!({ + "kinds": [39005], "#h": ["11111111-1111-4111-8111-111111111111"], + "ids": (0..ids).map(|id| format!("{id:064x}")).collect::>() + })) + .expect("valid filter") + } + + #[test] + fn ownership_request_requires_bounded_ids_and_explicit_scope() { + assert!(request_channel(&filter(1)).is_ok()); + assert!(request_channel(&filter(100)).is_ok()); + assert!(request_channel(&filter(0)).is_err()); + assert!(request_channel(&filter(101)).is_err()); + let mut wrong_kind = filter(1); + wrong_kind.kinds = Some([Kind::TextNote].into_iter().collect()); + assert!(request_channel(&wrong_kind).is_err()); + let mut unscoped = filter(1); + unscoped.generic_tags.clear(); + assert!(request_channel(&unscoped).is_err()); + } +} diff --git a/crates/buzz-test-client/tests/e2e_thread_roots.rs b/crates/buzz-test-client/tests/e2e_thread_roots.rs new file mode 100644 index 0000000000..2794e54b16 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_thread_roots.rs @@ -0,0 +1,148 @@ +//! Tombstone-aware ownership recovery through the authenticated Nostr bridge. +//! Run against a local development relay with `--ignored`. + +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use serde_json::{json, Value}; + +fn url() -> String { + std::env::var("RELAY_URL") + .unwrap_or_else(|_| "ws://localhost:3000".into()) + .replace("ws://", "http://") + .replace("wss://", "https://") +} + +async fn post(keys: &Keys, event: &Event) { + let response = reqwest::Client::new() + .post(format!("{}/events", url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .json(event) + .send() + .await + .expect("event request"); + assert!( + response.status().is_success(), + "event status {}", + response.status() + ); + let body: Value = response.json().await.expect("event response"); + assert_eq!(body["accepted"], true, "event rejected: {body}"); +} + +async fn query(keys: &Keys, filter: Value) -> Vec { + let response = reqwest::Client::new() + .post(format!("{}/query", url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .json(&vec![filter]) + .send() + .await + .expect("query request"); + assert!( + response.status().is_success(), + "query status {}", + response.status() + ); + response.json().await.expect("signed event array") +} + +async fn channel(keys: &Keys, private: bool) -> String { + let id = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags([ + Tag::parse(["h", &id]).unwrap(), + Tag::parse(["name", &format!("thread-roots-{id}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", if private { "private" } else { "open" }]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + post(keys, &event).await; + id +} + +fn request(channel: &str, target: &Event) -> Value { + json!({"kinds": [39005], "ids": [target.id.to_hex()], "#h": [channel], "resolve_thread_roots": true}) +} + +#[tokio::test] +#[ignore] +async fn deleted_reply_roots_are_recovered_without_content_or_scope_leaks() { + let owner = Keys::generate(); + let outsider = Keys::generate(); + let other_channel = channel(&owner, false).await; + let bounded_filter = json!({ + "kinds": [39005], "#h": [&other_channel], "resolve_thread_roots": true, + "ids": (0..60).map(|id| format!("{id:064x}")).collect::>() + }); + let oversized = reqwest::Client::new() + .post(format!("{}/query", url())) + .header("X-Pubkey", owner.public_key().to_hex()) + .json(&vec![bounded_filter.clone(), bounded_filter]) + .send() + .await + .expect("bounded batch"); + assert_eq!( + oversized.status(), + reqwest::StatusCode::BAD_REQUEST, + "the target cap applies across filters, not just within each filter" + ); + for deletion_kind in [5, 9005] { + let channel_id = channel(&owner, true).await; + let root = EventBuilder::new(Kind::Custom(9), "root") + .tags([Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + post(&owner, &root).await; + let reply = EventBuilder::new(Kind::Custom(9), "deleted secret payload") + .tags([ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["e", &root.id.to_hex(), "", "reply"]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + post(&owner, &reply).await; + let deletion = EventBuilder::new(Kind::Custom(deletion_kind), "") + .tags([ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["e", &reply.id.to_hex()]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + post(&owner, &deletion).await; + + let ordinary = query( + &owner, + json!({"kinds":[9],"ids":[reply.id.to_hex()],"#h":[channel_id]}), + ) + .await; + assert!( + ordinary.is_empty(), + "ordinary history must exclude the deleted payload" + ); + let summaries = query(&owner, request(&channel_id, &reply)).await; + assert_eq!(summaries.len(), 1, "metadata must survive deletion"); + let summary = &summaries[0]; + summary.verify().expect("relay signature"); + assert_eq!(summary.kind, Kind::Custom(39005)); + assert!(summary + .tags + .iter() + .any(|tag| tag.as_slice() == ["e", &root.id.to_hex()])); + let counts: Value = serde_json::from_str(&summary.content).unwrap(); + assert_eq!(counts["reply_count"], 0); + assert_eq!(counts["descendant_count"], 0); + assert!(!summary.content.contains("deleted secret payload")); + + assert!( + query(&owner, request(&other_channel, &reply)) + .await + .is_empty(), + "wrong channel must not leak ownership" + ); + assert!( + query(&outsider, request(&channel_id, &reply)) + .await + .is_empty(), + "private channel membership is required" + ); + } +} diff --git a/docs/nips/NIP-CW.md b/docs/nips/NIP-CW.md index f21b63405b..c279cb156e 100644 --- a/docs/nips/NIP-CW.md +++ b/docs/nips/NIP-CW.md @@ -205,3 +205,25 @@ A client with neither an authenticated transport nor a verifiable relay identity - **NIP-50** and relay-side search: sibling precedent — a relay-computed view requested through extended filter fields, invisible to relays that do not implement it. - **NIP-98**: Authenticates the HTTP query surface Buzz serves windows on. - **NIP-11**: Names the relay identity that signs overlays and the natural place to advertise support. + +## Recovering summaries after deletion + +The HTTP bridge additionally supports `resolve_thread_roots: true` with +`kinds: [39005]`, exactly one accessible `#h` channel, and 1–100 full event +`ids`. The IDs identify target replies, including soft-deleted replies, +not the IDs of the returned summary events. The request permits at most +100 targets across all such filters. + +The relay resolves ownership from retained thread metadata on the writer, +checks both target and root channel scope, and returns one signed +`kind:39005` summary per distinct owning root, including zero counts. +It returns no original target content, author, or signature. Missing or +non-reply targets and inaccessible channels produce no summaries. +These reads use the writer so deletion recovery does not depend on +replica replay or the delivery of a live summary. No bounds event is returned. + +Clients use this bounded metadata operation when a deletion target has +left their reply cache. Ordinary event queries exclude tombstones and +cannot perform this recovery. This extension requires a supporting relay; +clients must not interpret an unsupported/empty response as proof that an +unknown target's thread is empty. diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index d07cf2425b..468dbb0486 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -76,6 +76,7 @@ import '../profile/user_profile_sheet.dart'; import 'small_avatar.dart'; import 'sticky_date_header.dart'; import 'thread_detail_page.dart'; +import 'thread_replies_provider.dart'; import 'timeline_message.dart'; part 'channel_detail_page/message_list.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 18bbfb6703..33ad732802 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -3,6 +3,7 @@ part of '../channel_detail_page.dart'; class _MessageBubble extends HookConsumerWidget { final TimelineMessage message; final bool showAuthor; + final bool hasReplies; final Map channelNames; final String currentChannelId; final String? currentPubkey; @@ -15,6 +16,7 @@ class _MessageBubble extends HookConsumerWidget { const _MessageBubble({ required this.message, required this.showAuthor, + required this.hasReplies, required this.channelNames, required this.currentChannelId, required this.currentPubkey, @@ -28,6 +30,19 @@ class _MessageBubble extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final messageSnapshotKey = useMemoized(GlobalKey.new, const []); + final hasLocalReplies = ref.watch( + threadLocalRepliesProvider( + ThreadRepliesArgs( + channelId: currentChannelId, + rootId: message.rootId ?? message.id, + ), + ).select( + (replies) => replies.any((reply) { + final thread = reply.threadReference; + return thread.parentId == message.id || thread.rootId == message.id; + }), + ), + ); // Watch only this user's profile to avoid rebuilding on unrelated cache changes. final pk = message.pubkey.toLowerCase(); final profile = @@ -115,9 +130,9 @@ class _MessageBubble extends HookConsumerWidget { borderRadius: BorderRadius.circular(Radii.md), highlightColor: context.colors.primary.withValues(alpha: 0.1), snapshotKey: messageSnapshotKey, - // Tap opens the thread; long-press still opens the action sheet. + // Tap opens existing threads; long-press can start a new one. // MessageContent handles mention, channel-link, and media taps. - onTap: allMessages == null + onTap: (!hasReplies && !hasLocalReplies) || allMessages == null ? null : () => Navigator.of(context).push( MaterialPageRoute( 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 90af149fe5..00d386310a 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -851,6 +851,7 @@ class _MessageList extends HookConsumerWidget { _MessageBubble( message: message, showAuthor: showAuthor, + hasReplies: entry.summary != null, channelNames: channelNamesMap, currentChannelId: channelId, currentPubkey: currentPubkey, 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 8084d6c01b..318aa6691a 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -613,8 +613,9 @@ class _ThreadSummaryRow extends ConsumerWidget { TextSpan( children: [ TextSpan( - text: - '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + text: summary.isCountPending + ? 'Replies' + : '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}', style: replyPreviewTextStyle.copyWith( color: context.colors.primary, ), diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index 32f4824df2..221aef367a 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -6,6 +6,15 @@ import 'channel_event_order.dart'; import 'pending_local_messages_provider.dart'; import 'channel_window.dart'; import 'thread_replies_provider.dart'; +import 'thread_summary_refresh_queue.dart'; +import 'thread_reply_ownership.dart'; +import 'timeline_message.dart'; + +part 'channel_messages_provider/thread_summaries.dart'; + +// Channel history keeps partial reply evidence; full threads stay route-scoped. +const _maxCachedRepliesPerRoot = 256; +const _maxCachedReplies = 2048; const _channelLiveEventKinds = [ ...EventKind.channelEventKinds, @@ -28,9 +37,43 @@ class ChannelMessagesNotifier extends Notifier>> { final Set _liveSummaryRootsDuringInitialWindowQuery = {}; final Map _deepLinkEvents = {}; final Set _retainedDeepLinkEventIds = {}; + final Map _localReplyRoots = {}; + final Map _queryThreadSummaries = {}; + final Map _overflowFloors = {}; + final Map _threadQueryVersions = {}; + final Map _threadEvidenceVersions = {}; + int _threadQuerySerial = 0; + final _deletionSummaryUncertainty = {}; + final _replyOwnership = ThreadReplyOwnership(); + final _processedDeletionTargets = {}; + final _deferredDeletionTargets = + candidates, int version})>{}; + late final _deletionOwnerQueue = _DeletionOwnerQueue( + onCapacity: _drainDeferredDeletionOwners, + canRun: () => + ref.mounted && + _hasListeners && + ref.read(relaySessionProvider).status == SessionStatus.connected, + ); + bool _hasListeners = true; + late final _summaryRefreshes = ThreadSummaryRefreshQueue( + refresh: _refreshOverflowSummary, + canRun: () => + ref.mounted && + _hasListeners && + ref.read(relaySessionProvider).status == SessionStatus.connected, + onSettled: () { + final events = _lastKnownMessages; + if (events != null) state = AsyncData(events.toList()); + }, + ); ChannelMessagesNotifier(this.channelId); + bool get _summaryMounted => ref.mounted; + RelaySessionNotifier get _summarySession => + ref.read(relaySessionProvider.notifier); + /// Last successfully loaded messages, preserved across reconnections so the /// UI can show stale data instead of a blank loading spinner. List? _lastKnownMessages; @@ -41,14 +84,48 @@ class ChannelMessagesNotifier extends Notifier>> { /// empty value returned while the relay is not yet connected. bool get hasLoadedMessages => _lastKnownMessages != null; - Map get threadSummaries => - channelWindowThreadSummaries(_windowStore); + Map get threadSummaries => { + for (final entry in _baseThreadSummaries.entries) + entry.key: _deletionSummaryUncertainty.containsKey(entry.key) + ? ChannelWindowThreadSummary( + replyCount: entry.value.replyCount, + descendantCount: entry.value.descendantCount, + lastReplyAt: entry.value.lastReplyAt, + participantPubkeys: entry.value.participantPubkeys, + isLowerBound: true, + isCountPending: true, + ) + : entry.value, + }; + + void _publishSummaryChange() { + final events = _lastKnownMessages; + if (events != null) state = AsyncData(events.toList()); + } + + Map get _baseThreadSummaries => { + ...channelWindowThreadSummaries(_windowStore), + ..._queryThreadSummaries, + ..._overflowFloors, + }; @override AsyncValue> build() { final sessionState = ref.watch(relaySessionProvider); + ref.onCancel(() { + _hasListeners = false; + _deletionOwnerQueue.pause(); + _summaryRefreshes.pause(); + }); + ref.onResume(() { + _hasListeners = true; + Future.microtask(_summaryRefreshes.resume); + Future.microtask(_deletionOwnerQueue.resume); + }); ref.onDispose(() { _initVersion++; + _deletionOwnerQueue.pause(); + _summaryRefreshes.pause(); _clearSubscription(); }); @@ -62,6 +139,16 @@ class ChannelMessagesNotifier extends Notifier>> { _reachedOldest = false; _windowStore = const ChannelWindowStore.empty(); + // A reconnect window can lag a confirmed thread query and contains only + // top-level rows. Keep known replies as summary evidence, including their + // deletion markers so a reset cannot resurrect a deleted reply. + for (final event in _lastKnownMessages ?? const []) { + if (event.threadReference.parentId != null || + event.kind == EventKind.deletion || + event.kind == EventKind.nip29DeleteEvent) { + _mergeWindowEventIntoStore(event); + } + } _usingChannelWindow = false; _initialWindowQueryInFlight = false; _liveSummaryRootsDuringInitialWindowQuery.clear(); @@ -103,6 +190,7 @@ class ChannelMessagesNotifier extends Notifier>> { ); } + final historyVersion = ++_threadQuerySerial; final history = await _fetchNewestHistory(session); if (!_isCurrentInit(initVersion)) return; _confirmLocalMessages(history.map((event) => event.id)); @@ -114,7 +202,10 @@ class ChannelMessagesNotifier extends Notifier>> { ...history.where((event) => existingIds.add(event.id)), ]); _lastKnownMessages = merged; + _reconcileRetainedSummaryPayloads(merged); + _reconcileRetainedAggregates(historyVersion); state = AsyncData(merged); + _summaryRefreshes.resume(); } catch (e, st) { if (!_isCurrentInit(initVersion)) return; final fallbackMessages = state.value ?? _lastKnownMessages; @@ -129,6 +220,7 @@ class ChannelMessagesNotifier extends Notifier>> { } finally { if (_isCurrentInit(initVersion)) { _initInFlight = false; + _deletionOwnerQueue.resume(); } } } @@ -138,6 +230,7 @@ class ChannelMessagesNotifier extends Notifier>> { ) async { try { _initialWindowQueryInFlight = true; + final pageVersion = ++_threadQuerySerial; final page = await _fetchWindowPage(session, null); _initialWindowQueryInFlight = false; _windowStore = replaceNewestChannelWindow( @@ -146,7 +239,9 @@ class ChannelMessagesNotifier extends Notifier>> { retainLiveSummaryRootIds: _liveSummaryRootsDuringInitialWindowQuery, ); _liveSummaryRootsDuringInitialWindowQuery.clear(); + _pruneOffWindowReplies(); _usingChannelWindow = true; + _reconcilePageSummaries(page, pageVersion); _reachedOldest = !channelWindowHasMore(_windowStore); return flattenChannelWindowEvents(_windowStore); } catch (error) { @@ -187,7 +282,13 @@ class ChannelMessagesNotifier extends Notifier>> { }, ); - void _handleLiveEvent(NostrEvent event, {bool authoritative = true}) { + void _handleLiveEvent( + NostrEvent event, { + bool authoritative = true, + int? summaryVersion, + }) { + final before = _lastKnownMessages ?? const []; + _replyOwnership.record([event]); // Invalidate the thread query independently of the selected channel-history // path. The websocket fallback does not merge through the window store. _invalidateThreadReplies(event); @@ -198,7 +299,7 @@ class ChannelMessagesNotifier extends Notifier>> { final rootId = _initialWindowQueryInFlight ? event.getTagValue('e') : null; - if (_mergeWindowEventIntoStore(event)) { + if (_mergeWindowEventIntoStore(event, summaryVersion: summaryVersion)) { if (rootId != null) { _liveSummaryRootsDuringInitialWindowQuery.add(rootId); } @@ -211,24 +312,56 @@ class ChannelMessagesNotifier extends Notifier>> { return; } - // Reply ownership and its thread-local overlay must transition together. - // The authoritative thread query performs both confirmations after it - // contains the reply; a live echo only triggers that query below. - if (authoritative && event.threadReference.parentId == null) { - _confirmLocalMessages([event.id]); - } if (_usingChannelWindow) { - _handleWindowLiveEvent(event); + _handleWindowLiveEvent(event, summaryVersion: summaryVersion); } else { final current = state.value ?? _lastKnownMessages ?? const []; - final merged = _mergeEvent(current, event); + final merged = _boundEventReplies(_mergeEvent(current, event)); _lastKnownMessages = merged; state = AsyncData(merged); } + final rootId = event.threadReference.rootId; + if (event.threadReference.parentId != null && + rootId != null && + cachedThreadReplyIds(rootId).length >= _maxCachedRepliesPerRoot) { + _queueOverflowSummary(rootId); + } + if (authoritative) { + if (event.kind == EventKind.deletion || + event.kind == EventKind.nip29DeleteEvent) { + final targets = event.tags + .where((tag) => tag.length > 1 && tag[0] == 'e') + .map((tag) => tag[1]) + .toSet(); + _refreshDeletedSummaries(targets, before); + } + if (event.kind == EventKind.deletion || + event.kind == EventKind.nip29DeleteEvent) { + _confirmIndexedLocalReplies( + event.tags + .where((tag) => tag.length > 1 && tag[0] == 'e') + .map((tag) => tag[1]), + ); + } + _localReplyRoots.remove(event.id); + // Store the reply first so clearing its overlay cannot disable its root. + _confirmLocalMessages([event.id]); + final thread = event.threadReference; + if (thread.parentId != null && thread.rootId != null) { + final localReplies = threadLocalRepliesProvider( + ThreadRepliesArgs(channelId: channelId, rootId: thread.rootId!), + ); + if (ref.exists(localReplies)) { + ref.read(localReplies.notifier).confirm({event.id}); + } + } + } } - void _handleWindowLiveEvent(NostrEvent event) { - if (!_mergeWindowEventIntoStore(event)) return; + void _handleWindowLiveEvent(NostrEvent event, {int? summaryVersion}) { + if (!_mergeWindowEventIntoStore(event, summaryVersion: summaryVersion)) { + return; + } final windowEvents = flattenChannelWindowEvents(_windowStore); // Flattening already orders the window. Only merge and sort again when // there are retained deep-link events to include. @@ -262,7 +395,7 @@ class ChannelMessagesNotifier extends Notifier>> { } } - bool _mergeWindowEventIntoStore(NostrEvent event) { + bool _mergeWindowEventIntoStore(NostrEvent event, {int? summaryVersion}) { final isTimelineRow = EventKind.channelTimelineContentKinds.contains( event.kind, ); @@ -288,18 +421,333 @@ class ChannelMessagesNotifier extends Notifier>> { _windowStore, event, isTimelineRow: isTimelineRow, + // Deep-link roots stay visible independently of the newest window. + // Keep their reply evidence under the same payload bounds as other roots. + retainOutsideWindow: + thread?.parentId != null && + _retainedDeepLinkEventIds.contains(thread?.rootId), ); if (identical(next, _windowStore)) return false; _windowStore = next; + if (event.kind == EventKind.channelThreadSummary) { + _applyLiveThreadSummary(event, summaryVersion); + } + _trimReplyOverlay(); + if (event.kind == EventKind.channelThreadSummary) { + _reconcileLiveSummaryPayloads(event); + } return true; } + List _boundedReplies(Iterable events) { + final sorted = events.toList() + ..sort((a, b) => compareThreadRepliesChronologically(b, a)); + final counts = {}; + final seen = {}; + final retained = []; + for (final event in sorted) { + if (!seen.add(event.id)) continue; + final root = event.threadReference.rootId; + final count = counts[root] ?? 0; + if (count >= _maxCachedRepliesPerRoot) continue; + retained.add(event); + counts[root] = count + 1; + if (retained.length == _maxCachedReplies) break; + } + return retained; + } + + List _boundEventReplies(List events) { + final replies = events.where( + (event) => + EventKind.channelTimelineContentKinds.contains(event.kind) && + event.threadReference.parentId != null, + ); + final retained = _boundedReplies(replies).map((event) => event.id).toSet(); + _reconcileEvictedReplies(replies, retained); + return events + .where( + (event) => + !EventKind.channelTimelineContentKinds.contains(event.kind) || + event.threadReference.parentId == null || + retained.contains(event.id), + ) + .toList(); + } + + void _trimReplyOverlay() { + final replies = _windowStore.liveOverlay + .where((event) => event.threadReference.parentId != null) + .toList(); + if (replies.length <= _maxCachedRepliesPerRoot) return; + final retained = _boundedReplies(replies).map((event) => event.id).toSet(); + if (retained.length == replies.length) return; + _reconcileEvictedReplies(replies, retained); + _windowStore = ChannelWindowStore( + pages: _windowStore.pages, + liveOverlay: _windowStore.liveOverlay + .where( + (event) => + event.threadReference.parentId == null || + retained.contains(event.id), + ) + .toList(), + liveAux: _windowStore.liveAux, + liveThreadSummaries: _windowStore.liveThreadSummaries, + ); + } + + void _confirmIndexedLocalReplies(Iterable ids) { + final targets = ids.toSet(); + for (final id in targets) { + final root = _localReplyRoots.remove(id); + if (root == null) continue; + final provider = threadLocalRepliesProvider( + ThreadRepliesArgs(channelId: channelId, rootId: root), + ); + if (ref.exists(provider)) ref.read(provider.notifier).confirm({id}); + } + _confirmLocalMessages(targets); + } + void _confirmLocalMessages(Iterable eventIds) { ref .read(pendingLocalMessagesProvider(channelId).notifier) .confirm(eventIds); } + /// Reserves a version for a thread query without treating it as evidence. + int beginThreadQuery(String rootId) => _reserveThreadQuery(rootId); + + /// Releases a failed or disposed query while preserving newer evidence. + void failThreadQuery(String rootId, int version) => + _retireThreadQuery(rootId, version); + + /// Snapshots unacknowledged replies that still require explicit deletion proof. + Set unconfirmedThreadReplyIds(String rootId) => { + for (final entry in _localReplyRoots.entries) + if (entry.value == rootId) entry.key, + }; + + /// Reserves the next bounded proof batch, rotating pending sends fairly. + List nextThreadDeletionProofTargets(Set missingIds) => + _rotateDeletionProofTargets(missingIds); + + /// Snapshots cached overlay replies covered by a query for this outer root. + Set cachedThreadReplyIds(String rootId) { + final deletedIds = { + for (final event in [ + ..._windowStore.liveAux, + if (!_usingChannelWindow) ...?_lastKnownMessages, + for (final page in _windowStore.pages) ...page.aux, + ]) + if (event.kind == EventKind.deletion || + event.kind == EventKind.nip29DeleteEvent) + for (final tag in event.tags) + if (tag.length > 1 && tag[0] == 'e') tag[1], + }; + return { + for (final entry in _localReplyRoots.entries) + if (entry.value == rootId && !deletedIds.contains(entry.key)) entry.key, + for (final event in _cachedThreadEvents) + if (event.threadReference.parentId != null && + event.threadReference.rootId == rootId && + !deletedIds.contains(event.id)) + event.id, + }; + } + + /// Applies explicit deletion evidence fetched for cached replies. + /// [scopedTargetIds] are targets of the authenticated channel-scoped query; + /// a matching target permits kind-5 wire events without an h tag, including + /// multi-target markers that also name events outside this query. + /// [reconciledTargetIds] were already excluded by an applied complete scan. + void cacheThreadDeletions( + Iterable deletions, { + Set scopedTargetIds = const {}, + Set reconciledTargetIds = const {}, + }) { + var events = state.value ?? _lastKnownMessages ?? const []; + for (final event in deletions) { + final targets = event.tags + .where((tag) => tag.length > 1 && tag[0] == 'e') + .map((tag) => tag[1]) + .toSet(); + final scopedStandardDeletion = + event.kind == EventKind.deletion && + event.channelId == null && + targets.isNotEmpty && + targets.any(scopedTargetIds.contains); + if ((!scopedStandardDeletion && event.channelId != channelId) || + (event.kind != EventKind.deletion && + event.kind != EventKind.nip29DeleteEvent)) { + throw StateError('Expected a deletion in channel $channelId.'); + } + _replyOwnership.record(events); + final affectedTargets = scopedStandardDeletion + ? targets + .where( + (id) => + scopedTargetIds.contains(id) || + _replyOwnership.rootFor(id) != null || + events.any((cached) => cached.id == id), + ) + .toSet() + : targets; + final reconciled = affectedTargets.intersection(reconciledTargetIds); + _retireDeferredDeletionTargets(reconciled); + _rememberDeletionTargets(reconciled); + final before = events; + _mergeWindowEventIntoStore(event); + events = _mergeEvent(events, event); + _refreshDeletedSummaries(affectedTargets, before); + _confirmIndexedLocalReplies(affectedTargets); + } + _lastKnownMessages = events; + state = AsyncData(events); + } + + void _pruneOffWindowReplies() { + _rememberDeletionTargets({ + for (final event in [ + ..._windowStore.liveAux, + for (final page in _windowStore.pages) ...page.aux, + ]) + if (event.kind == EventKind.deletion || + event.kind == EventKind.nip29DeleteEvent) + for (final tag in event.tags) + if (tag.length > 1 && tag[0] == 'e') tag[1], + }); + final roots = { + for (final page in _windowStore.pages) + for (final row in page.rows) row.event.id, + for (final event in _windowStore.liveOverlay) + if (event.threadReference.parentId == null) event.id, + ..._retainedDeepLinkEventIds, + }; + bool keepReply(NostrEvent event) => + event.threadReference.parentId == null || + roots.contains(event.threadReference.rootId); + final overlay = _windowStore.liveOverlay.where(keepReply).toList(); + final retainedIds = {...roots, ...overlay.map((event) => event.id)}; + _windowStore = ChannelWindowStore( + pages: _windowStore.pages, + liveOverlay: overlay, + liveAux: _windowStore.liveAux + .where( + (event) => event.tags.any( + (tag) => + tag.length > 1 && + tag[0] == 'e' && + retainedIds.contains(tag[1]), + ), + ) + .toList(), + liveThreadSummaries: _windowStore.liveThreadSummaries, + ); + // Keep the currently displayed snapshot intact until the next window + // publication drops off-window roots and their reply evidence together. + } + + /// Publishes an insertion-complete thread scan, preserving aggregate facts + /// before payload eviction. Only absent pre-query, accepted IDs are removed; + /// in-flight sends and arrivals after the query began remain provisional. + /// Returns whether this scan applied, rather than losing to newer evidence. + bool cacheCompleteThreadQuery( + String rootId, + Set queriedIds, + List replies, { + Set provisionalReplyIds = const {}, + int? queryVersion, + }) { + if (queryVersion != null && _threadQueryVersions[rootId] != queryVersion) { + return false; + } + final evidenceVersion = queryVersion ?? ++_threadQuerySerial; + _setThreadQueryVersion(rootId, evidenceVersion); + _overflowFloors.remove(rootId); + _clearDeletionUncertainty(rootId, evidenceVersion); + final resultIds = replies.map((event) => event.id).toSet(); + final missing = queriedIds.difference(resultIds) + ..removeAll(_localReplyRoots.keys) + ..removeAll(provisionalReplyIds); + _rememberDeletionTargets(missing); + _retireDeferredDeletionTargets(missing); + final retainedIds = cachedThreadReplyIds(rootId); + final later = _cachedThreadEvents + .where( + (event) => + event.threadReference.rootId == rootId && + retainedIds.contains(event.id) && + (!queriedIds.contains(event.id) || + provisionalReplyIds.contains(event.id)) && + !resultIds.contains(event.id), + ) + .toList(); + _windowStore = ChannelWindowStore( + pages: _windowStore.pages, + liveOverlay: _windowStore.liveOverlay + .where((event) => !missing.contains(event.id)) + .toList(), + liveAux: _windowStore.liveAux, + liveThreadSummaries: _windowStore.liveThreadSummaries, + ); + final current = state.value ?? _lastKnownMessages ?? const []; + final remaining = current + .where((event) => !missing.contains(event.id)) + .toList(); + _lastKnownMessages = remaining; + state = AsyncData(remaining); + final aggregate = { + ...{for (final event in replies) event.id: event}, + ...{for (final event in later) event.id: event}, + }.values.toList()..sort(compareThreadRepliesChronologically); + _queryThreadSummaries.remove(rootId); + _queryThreadSummaries[rootId] = ChannelWindowThreadSummary( + replyCount: aggregate + .where((event) => event.threadReference.parentId == rootId) + .length, + descendantCount: aggregate.length, + lastReplyAt: aggregate.isEmpty ? null : aggregate.last.createdAt, + participantPubkeys: aggregate.reversed + .map((event) => event.pubkey) + .toSet() + .take(5) + .toList(), + ); + _reconcileNestedThreadSummaries(rootId, aggregate, evidenceVersion); + // Metadata is small but still bounded independently of payload retention. + while (_queryThreadSummaries.length > 2048) { + _queryThreadSummaries.remove(_queryThreadSummaries.keys.first); + } + cacheConfirmedThreadReplies(replies); + return true; + } + + /// Caches confirmed thread replies before their optimistic overlay is cleared. + /// Thread queries must not invalidate themselves as live relay events do. + void cacheConfirmedThreadReplies(Iterable replies) { + _replyOwnership.record(replies); + var events = state.value ?? _lastKnownMessages ?? const []; + for (final reply in _boundedReplies(replies)) { + if (reply.channelId != channelId || + reply.threadReference.parentId == null) { + throw StateError('Expected a reply in channel $channelId.'); + } + _mergeWindowEventIntoStore(reply); + if (!_usingChannelWindow) { + events = _mergeEvent(events, reply); + } + } + if (_usingChannelWindow) { + events = _withDeepLinkEvents(flattenChannelWindowEvents(_windowStore)); + } + events = _boundEventReplies(events); + _lastKnownMessages = events; + state = AsyncData(events); + _confirmIndexedLocalReplies(replies.map((event) => event.id)); + } + /// Adds a just-signed outgoing message before the relay acknowledges it. /// The live relay echo is deduplicated by event id. void addLocalMessage(NostrEvent event) { @@ -310,6 +758,7 @@ class ChannelMessagesNotifier extends Notifier>> { if (rootId == null) { throw StateError('Reply ${event.id} has a parent but no thread root.'); } + _localReplyRoots[event.id] = rootId; ref .read( threadLocalRepliesProvider( @@ -333,11 +782,21 @@ class ChannelMessagesNotifier extends Notifier>> { _handleLiveEvent(event, authoritative: false); } - /// Releases rollback ownership after the publish future succeeds. The - /// optimistic row (and any thread overlay) remains visible until relay data - /// replaces it, because OK and EVENT delivery are unordered. + /// Releases rollback ownership after the publish future succeeds. + /// Accepted replies move into the bounded channel cache even if the relay's + /// EVENT echo never arrives, so an unobserved thread overlay can dispose. void completeLocalMessage(String eventId) { - _confirmLocalMessages([eventId]); + final accepted = ref + .read(pendingLocalMessagesProvider(channelId).notifier) + .take(eventId); + if (accepted?.threadReference.parentId != null) { + cacheConfirmedThreadReplies([accepted!]); + final root = accepted.threadReference.rootId; + if (root != null && + cachedThreadReplyIds(root).length >= _maxCachedRepliesPerRoot) { + _queueOverflowSummary(root); + } + } } /// Rolls back a local message when its publish is rejected or times out. @@ -347,6 +806,7 @@ class ChannelMessagesNotifier extends Notifier>> { .take(eventId); if (pending == null) return; + _localReplyRoots.remove(eventId); final thread = pending.threadReference; if (thread.parentId != null) { final rootId = thread.rootId; @@ -437,6 +897,7 @@ class ChannelMessagesNotifier extends Notifier>> { state.value ?? _lastKnownMessages ?? const [], ); _lastKnownMessages = merged; + _reconcileRetainedSummaryPayloads(events); state = AsyncData(merged); } @@ -467,13 +928,16 @@ class ChannelMessagesNotifier extends Notifier>> { return false; } try { + final pageVersion = ++_threadQuerySerial; final page = await _fetchWindowPage(session, cursor); _windowStore = appendOlderChannelWindow(_windowStore, page); + _reconcilePageSummaries(page, pageVersion); _reachedOldest = !channelWindowHasMore(_windowStore); final flattened = _withDeepLinkEvents( flattenChannelWindowEvents(_windowStore), ); _lastKnownMessages = flattened; + _reconcileRetainedSummaryPayloads(page.rows.map((row) => row.event)); state = AsyncData(flattened); return page.rows.isNotEmpty || page.aux.isNotEmpty; } catch (error) { diff --git a/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart b/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart new file mode 100644 index 0000000000..7fa36c2b14 --- /dev/null +++ b/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart @@ -0,0 +1,674 @@ +part of '../channel_messages_provider.dart'; + +extension _ThreadSummaryState on ChannelMessagesNotifier { + void _reconcilePageSummaries(ChannelWindowPage page, int pageVersion) { + for (final row in page.rows) { + final root = row.event.id; + if ((_threadEvidenceVersions[root] ?? 0) > pageVersion) continue; + _queryThreadSummaries.remove(root); + _overflowFloors.remove(root); + _summaryRefreshes.cancel(root); + _setThreadQueryVersion(root, pageVersion); + _clearDeletionUncertainty(root, pageVersion); + if (cachedThreadReplyIds(root).length > + (row.thread?.descendantCount ?? 0)) { + _queueOverflowSummary(root); + } + } + } + + Iterable get _cachedThreadEvents => [ + ..._windowStore.liveOverlay, + if (!_usingChannelWindow) ...?_lastKnownMessages, + ]; + + List _rotateDeletionProofTargets(Set missingIds) { + final targets = missingIds.take(20).toList(); + // Reuse the pending-send order instead of retaining a second per-root + // cursor. It survives route disposal and disappears as sends settle. + for (final id in targets) { + final root = _localReplyRoots.remove(id); + if (root != null) _localReplyRoots[id] = root; + } + return targets; + } + + int _reserveThreadQuery(String root) { + final version = ++_threadQuerySerial; + _setThreadQueryVersion(root, version, pending: true); + return version; + } + + void _retireThreadQuery(String root, int version) { + if (_threadQueryVersions[root] != version) return; + final evidence = _threadEvidenceVersions[root]; + if (evidence == null) { + _threadQueryVersions.remove(root); + } else { + _threadQueryVersions[root] = evidence; + } + } + + void _setThreadQueryVersion( + String root, + int version, { + bool pending = false, + }) { + final current = _threadQueryVersions[root]; + if (!pending) _threadEvidenceVersions[root] = version; + _threadQueryVersions.remove(root); + // A page may apply while a newer query is pending. Preserve that query's + // reservation so its eventual result can still replace the page evidence. + _threadQueryVersions[root] = current != null && current > version + ? current + : version; + while (_threadQueryVersions.length > 2048) { + final oldest = _threadQueryVersions.keys.first; + _threadQueryVersions.remove(oldest); + _threadEvidenceVersions.remove(oldest); + } + } + + bool _isVisibleRoot(String root) => + _retainedDeepLinkEventIds.contains(root) || + (!_usingChannelWindow && + (_lastKnownMessages?.any((event) => event.id == root) ?? false)) || + _windowStore.pages.any( + (page) => page.rows.any((row) => row.event.id == root), + ) || + _windowStore.liveOverlay.any((event) => event.id == root); + + bool _hasVisibleThreadRows(String root) => + _isVisibleRoot(root) || _visibleNestedRows(root).isNotEmpty; + + ChannelWindowThreadSummary? _emptyPageSummary(String root) { + // A page's implicit zero is no longer the only evidence once replies arrive. + if (cachedThreadReplyIds(root).isNotEmpty) return null; + for (final page in _windowStore.pages) { + for (final row in page.rows) { + if (row.event.id != root) continue; + if (row.thread != null) return null; + return const ChannelWindowThreadSummary( + replyCount: 0, + descendantCount: 0, + lastReplyAt: null, + participantPubkeys: [], + ); + } + } + return null; + } + + void _applyLiveThreadSummary(NostrEvent event, int? summaryVersion) { + final root = event.getTagValue('e'); + if (root == null) return; + final exact = _queryThreadSummaries[root] ?? _emptyPageSummary(root); + if (summaryVersion == null && exact != null) { + // WebSocket arrival order cannot establish freshness relative to HTTP + // scans. Preserve the completed scan and reconcile conflicting counts. + final incoming = parseChannelWindowThreadSummary(event); + // Rejected payloads must not regain authority when a later page clears + // the query overlay (zero-count page summaries are omitted by the relay). + _windowStore = ChannelWindowStore( + pages: _windowStore.pages, + liveOverlay: _windowStore.liveOverlay, + liveAux: _windowStore.liveAux, + liveThreadSummaries: { + for (final entry in _windowStore.liveThreadSummaries.entries) + if (entry.key != root) entry.key: entry.value, + }, + ); + if (incoming.replyCount != exact.replyCount || + incoming.descendantCount != exact.descendantCount) { + _queueOverflowSummary(root, countPending: true); + } + return; + } + _queryThreadSummaries.remove(root); + _overflowFloors.remove(root); + _summaryRefreshes.cancel(root); + final version = summaryVersion ?? ++_threadQuerySerial; + _setThreadQueryVersion(root, version); + _clearDeletionUncertainty(root, version); + } + + Iterable _visibleNestedRows(String root) => + (_lastKnownMessages ?? const []).where( + (event) => + event.threadReference.rootId == root && + event.threadReference.parentId != null && + event.tags.any( + (tag) => tag.length > 1 && tag[0] == 'broadcast' && tag[1] == '1', + ) && + _isVisibleRoot(event.id), + ); + + void _reconcileNestedThreadSummaries( + String root, + List replies, + int version, + ) { + final rows = _visibleNestedRows(root).toList(); + if (rows.isEmpty) return; + final entries = buildMainTimelineEntries( + formatTimeline([ + ...{ + for (final event in [...rows, ...replies]) event.id: event, + }.values, + ]), + ); + for (final row in rows) { + if ((_threadQueryVersions[row.id] ?? 0) > version) continue; + final summary = entries + .where((entry) => entry.message.id == row.id) + .firstOrNull + ?.summary; + _setThreadQueryVersion(row.id, version); + _clearDeletionUncertainty(row.id, version); + _overflowFloors.remove(row.id); + _queryThreadSummaries.remove(row.id); + _queryThreadSummaries[row.id] = ChannelWindowThreadSummary( + replyCount: replies + .where((event) => event.threadReference.parentId == row.id) + .length, + descendantCount: summary?.replyCount ?? 0, + lastReplyAt: summary?.lastReplyAt, + participantPubkeys: summary?.participantPubkeys ?? const [], + ); + } + } + + void _reconcileLiveSummaryPayloads(NostrEvent event) { + final root = event.getTagValue('e'); + if (root != null) _reconcileThreadSummaryPayloads(root); + } + + void _reconcileRetainedSummaryPayloads(Iterable events) { + // Evidence can precede the page that first exposes a broadcast branch. + final roots = events + .map((event) => event.threadReference.rootId) + .whereType() + .where(_windowStore.liveThreadSummaries.containsKey) + .toSet(); + for (final root in roots) { + _reconcileThreadSummaryPayloads(root); + } + } + + void _reconcileThreadSummaryPayloads(String root) { + if (!_hasVisibleThreadRows(root)) return; + final summary = _baseThreadSummaries[root]; + final confirmedIds = cachedThreadReplyIds(root) + ..removeAll(_localReplyRoots.keys); + // Relay deletion fan-out updates the outer root only. Its visible broadcast + // children need the same complete scan to refresh their direct-reply facts. + final nestedNeedsRecount = + !_queryThreadSummaries.containsKey(root) && + _visibleNestedRows(root).any((row) { + final nested = _baseThreadSummaries[row.id]; + return nested != null && + (nested.replyCount > 0 || nested.descendantCount > 0); + }); + if (!nestedNeedsRecount && + (summary == null || confirmedIds.length <= summary.descendantCount)) { + return; + } + // The summary may lag a new reply, or its deletion marker may have been + // missed. A fresh complete query resolves either case without discarding + // valid local payloads merely because a summary count is lower. + _queueOverflowSummary(root, countPending: true); + } + + void _reconcileRetainedAggregates(int historyVersion) { + // Bounded history cannot refresh retained roots outside the newest page. + // Recount visible cached aggregates, marking their old totals uncertain in + // the meantime. Requests or live summaries newer than this history win. + final roots = { + ..._queryThreadSummaries.keys, + ..._overflowFloors.keys, + for (final event in _windowStore.liveOverlay) + if (event.threadReference.parentId != null && + event.threadReference.rootId != null && + !_localReplyRoots.containsKey(event.id)) + event.threadReference.rootId!, + }; + for (final root in roots) { + if ((_threadQueryVersions[root] ?? 0) > historyVersion) continue; + _setThreadQueryVersion(root, historyVersion); + if (!_hasVisibleThreadRows(root)) { + _queryThreadSummaries.remove(root); + _overflowFloors.remove(root); + _deletionSummaryUncertainty.remove(root); + _summaryRefreshes.cancel(root); + continue; + } + _queueOverflowSummary(root, countPending: true); + } + } + + void _reconcileEvictedReplies( + Iterable replies, + Set retained, + ) { + for (final root + in replies + .where((event) => !retained.contains(event.id)) + .map((event) => event.threadReference.rootId) + .whereType() + .toSet()) { + if (!_queryThreadSummaries.containsKey(root)) _queueOverflowSummary(root); + } + } + + void _queueOverflowSummary(String root, {bool countPending = false}) { + if (!_hasVisibleThreadRows(root)) return; + final ids = cachedThreadReplyIds(root)..removeAll(_localReplyRoots.keys); + final events = + _cachedThreadEvents.where((event) => ids.contains(event.id)).toList() + ..sort(compareThreadRepliesChronologically); + final previous = _baseThreadSummaries[root]; + final count = (previous?.descendantCount ?? 0) > ids.length + ? previous!.descendantCount + : ids.length; + _overflowFloors.remove(root); + _overflowFloors[root] = ChannelWindowThreadSummary( + replyCount: count, + descendantCount: count, + lastReplyAt: + previous?.lastReplyAt ?? + (events.isEmpty ? null : events.last.createdAt), + participantPubkeys: + previous?.participantPubkeys ?? + events.reversed.map((event) => event.pubkey).toSet().take(5).toList(), + isLowerBound: true, + isCountPending: countPending || (previous?.isCountPending ?? false), + ); + // The outer scan also owns reconciliation of visible broadcast branches. + // Their old totals are uncertain for the entire wait, including exhaustion. + for (final row in _visibleNestedRows(root)) { + final nested = _baseThreadSummaries[row.id]; + if (nested == null) continue; + _overflowFloors[row.id] = ChannelWindowThreadSummary( + replyCount: nested.replyCount, + descendantCount: nested.descendantCount, + lastReplyAt: nested.lastReplyAt, + participantPubkeys: nested.participantPubkeys, + isLowerBound: true, + isCountPending: true, + ); + } + while (_overflowFloors.length > 2048) { + _overflowFloors.remove(_overflowFloors.keys.first); + } + _summaryRefreshes.enqueue(root); + } + + Future _refreshOverflowSummary(String root, {int attempt = 0}) async { + if (!_hasVisibleThreadRows(root)) return; + final generation = _initVersion; + final version = beginThreadQuery(root); + final snapshot = cachedThreadReplyIds(root); + final provisional = unconfirmedThreadReplyIds(root); + bool current() => + _summaryMounted && + _hasListeners && + generation == _initVersion && + _threadQueryVersions[root] == version; + try { + final replies = await fetchCompleteThreadReplies( + _summarySession, + ThreadRepliesArgs(channelId: channelId, rootId: root), + isCurrent: current, + ); + if (current()) { + cacheCompleteThreadQuery( + root, + snapshot, + replies, + provisionalReplyIds: provisional, + queryVersion: version, + ); + if (_summaryRefreshes.isDirty(root)) _queueOverflowSummary(root); + } + } catch (error) { + if (!current()) return; + if (attempt < 2) { + // Retain this active queue slot during backoff, so retries share the + // same concurrency budget and stop when newer work supersedes them. + await Future.delayed(Duration(milliseconds: 500 << attempt)); + if (current()) { + await _refreshOverflowSummary(root, attempt: attempt + 1); + } + return; + } + debugPrint( + '[ChannelMessagesNotifier] thread recount failed for $root: $error', + ); + } finally { + _retireThreadQuery(root, version); + } + } + + void _refreshDeletedSummaries(Set targets, List before) { + _replyOwnership.record(before); + final alreadyDeleted = { + ..._processedDeletionTargets, + for (final event in before) + if (event.kind == EventKind.deletion || + event.kind == EventKind.nip29DeleteEvent) + for (final tag in event.tags) + if (tag.length > 1 && tag[0] == 'e') tag[1], + }; + final fresh = targets.difference(alreadyDeleted); + _rememberDeletionTargets(targets); + if (fresh.isEmpty) return; + final candidates = _applyDeletedSummaries(fresh, before); + final unknown = fresh + .where( + (id) => + _replyOwnership.rootFor(id) == null && + !before.any((event) => event.id == id), + ) + .toSet(); + if (unknown.isNotEmpty) _resolveDeletionOwners(unknown, candidates); + } + + void _rememberDeletionTargets(Iterable targets) { + for (final target in targets) { + _processedDeletionTargets.remove(target); + _processedDeletionTargets.add(target); + } + while (_processedDeletionTargets.length > 8192) { + _processedDeletionTargets.remove(_processedDeletionTargets.first); + } + } + + Set _applyDeletedSummaries( + Set targets, + List before, + ) { + final owners = {}; + for (final id in targets) { + final root = _replyOwnership.rootFor(id); + if (root != null) owners[id] = root; + } + // Fence responses started before this deletion, including during debounce. + for (final root in owners.values.toSet()) { + _setThreadQueryVersion(root, ++_threadQuerySerial); + } + final summaries = _baseThreadSummaries; + final candidates = + lowerBoundSummariesAfterDeletion(summaries, before, targets, owners) + .entries + .where( + (entry) => + entry.value.isCountPending && + _hasVisibleThreadRows(entry.key), + ) + .map((entry) => entry.key) + .toSet(); + final floors = lowerBoundSummariesAfterDeletion( + summaries, + before, + targets.where(owners.containsKey).toSet(), + owners, + ); + for (final entry in floors.entries) { + if (!_hasVisibleThreadRows(entry.key)) continue; + _overflowFloors[entry.key] = entry.value; + // Unknown ownership changes presentation only; never scan every candidate. + if (owners.containsValue(entry.key)) _queueOverflowSummary(entry.key); + } + for (final root in owners.values.toSet()) { + // An off-window outer root may have no summary, while its broadcast row + // still needs reconciliation after a known child's deletion. + if (!floors.containsKey(root)) _queueOverflowSummary(root); + } + while (_overflowFloors.length > 2048) { + _overflowFloors.remove(_overflowFloors.keys.first); + } + return candidates; + } + + void _resolveDeletionOwners(Set targets, Set candidates) { + // Register every target before sending the first bounded-queue query. A + // successful target must not retire uncertainty from a queued sibling. + final versions = { + for (final target in targets) target: ++_threadQuerySerial, + }; + for (final root in candidates) { + final pending = _deletionSummaryUncertainty[root] ??= + _DeletionSummaryUncertainty(); + for (final version in versions.values) { + pending.begin(version); + } + } + while (_deletionSummaryUncertainty.length > 2048) { + _deletionSummaryUncertainty.remove( + _deletionSummaryUncertainty.keys.first, + ); + } + _publishSummaryChange(); + // The shared queue bounds work across events as well as within each batch. + for (final entry in versions.entries) { + if (!_enqueueDeletionOwner(entry.key, candidates, entry.value)) { + _deferredDeletionTargets[entry.key] = ( + candidates: candidates, + version: entry.value, + ); + while (_deferredDeletionTargets.length > 8192) { + final oldest = _deferredDeletionTargets.keys.first; + final dropped = _deferredDeletionTargets.remove(oldest)!; + _finishDeletionLookup( + dropped.candidates, + dropped.version, + resolved: false, + ); + } + } + } + } + + bool _enqueueDeletionOwner( + String target, + Set candidates, + int version, + ) => _deletionOwnerQueue.enqueue(() async { + if (!_summaryMounted) return true; + return _resolveDeletionOwner(target, candidates, _initVersion, version); + }); + + void _drainDeferredDeletionOwners() { + if (!_summaryMounted) return; + while (_deletionOwnerQueue.hasCapacity && + _deferredDeletionTargets.isNotEmpty) { + final target = _deferredDeletionTargets.keys.first; + final work = _deferredDeletionTargets.remove(target)!; + _enqueueDeletionOwner(target, work.candidates, work.version); + } + } + + void _retireDeferredDeletionTargets(Iterable targets) { + for (final target in targets) { + final work = _deferredDeletionTargets.remove(target); + if (work != null) { + _finishDeletionLookup(work.candidates, work.version, resolved: true); + } + } + } + + Future _resolveDeletionOwner( + String target, + Set candidates, + int generation, + int version, { + int attempt = 0, + }) async { + try { + final events = await _summarySession.queryRelay([ + NostrFilter( + ids: [target], + kinds: const [EventKind.channelThreadSummary], + extensions: const {'resolve_thread_roots': true}, + tags: { + '#h': [channelId], + }, + limit: 1, + ), + ]); + if (!_summaryMounted) return true; + if (generation != _initVersion) return false; + if (events.length > 1) { + throw StateError('Expected at most one owner for a deletion target.'); + } + var applied = false; + String? resolvedRoot; + for (final event in events) { + if (event.channelId != channelId || + event.kind != EventKind.channelThreadSummary) { + throw StateError('Expected a channel-scoped thread summary.'); + } + final root = event.getTagValue('e'); + resolvedRoot = root; + if (root != null && (_threadQueryVersions[root] ?? 0) <= version) { + _handleLiveEvent(event, summaryVersion: version); + applied = _threadQueryVersions[root] == version; + } + } + // An empty response does not prove ownership (including on old relays). + // A response fenced by a newer query is not applied evidence either: + // that query can still fail, leaving the old count uncertain. + _finishDeletionLookup( + candidates, + version, + resolved: resolvedRoot != null, + unresolvedRoot: applied ? null : resolvedRoot, + ); + } catch (error) { + if (!_summaryMounted) return true; + if (generation != _initVersion) return false; + if (error is! StateError && error is! FormatException && attempt < 2) { + // Keep the queue slot and original version while backing off. Retries + // cannot exceed the shared concurrency cap or supersede newer evidence. + await Future.delayed(Duration(milliseconds: 500 << attempt)); + if (!_summaryMounted) return true; + if (generation != _initVersion) return false; + return _resolveDeletionOwner( + target, + candidates, + generation, + version, + attempt: attempt + 1, + ); + } + // Exhausted or invalid responses remain uncertain until fresh evidence. + if (_summaryMounted && generation == _initVersion) { + _finishDeletionLookup(candidates, version, resolved: false); + debugPrint( + '[ChannelMessagesNotifier] deletion ownership lookup failed: $error', + ); + } + } + return true; + } + + void _clearDeletionUncertainty(String root, int version) { + final pending = _deletionSummaryUncertainty[root]; + if (pending == null) return; + pending.clearThrough(version); + if (pending.isEmpty) _deletionSummaryUncertainty.remove(root); + } + + void _finishDeletionLookup( + Set candidates, + int version, { + required bool resolved, + String? unresolvedRoot, + }) { + for (final root in candidates) { + final pending = _deletionSummaryUncertainty[root]; + if (pending == null) continue; + pending.finish(version, resolved: resolved && root != unresolvedRoot); + if (pending.isEmpty) _deletionSummaryUncertainty.remove(root); + } + _publishSummaryChange(); + } +} + +// Track uncertainty independently of counts so resolving one deletion cannot +// erase another pending deletion, or leave unrelated exact counts downgraded. +class _DeletionSummaryUncertainty { + final _requests = {}; + int? _unresolvedVersion; + + bool get isEmpty => _requests.isEmpty && _unresolvedVersion == null; + + void begin(int version) { + _requests.add(version); + if (_requests.length > 256) { + final oldest = _requests.first; + if (oldest > (_unresolvedVersion ?? -1)) _unresolvedVersion = oldest; + _requests.remove(oldest); + } + } + + void finish(int version, {required bool resolved}) { + if (!_requests.remove(version)) return; + if (!resolved && version > (_unresolvedVersion ?? -1)) { + _unresolvedVersion = version; + } + } + + void clearThrough(int version) { + _requests.removeWhere((request) => request <= version); + if ((_unresolvedVersion ?? -1) <= version) _unresolvedVersion = null; + } +} + +// Bound retained work and concurrent HTTP requests for the whole channel. +class _DeletionOwnerQueue { + final bool Function() canRun; + final void Function() onCapacity; + final _pending = Function()>[]; + int _active = 0; + bool _paused = false; + + _DeletionOwnerQueue({required this.canRun, required this.onCapacity}); + + bool get hasCapacity => _pending.length + _active < 258; + + bool enqueue(Future Function() request) { + if (!hasCapacity) return false; + _pending.add(request); + _drain(); + return true; + } + + void pause() => _paused = true; + + void resume() { + _paused = false; + _drain(); + onCapacity(); + } + + void _drain() { + while (!_paused && canRun() && _active < 2 && _pending.isNotEmpty) { + _active++; + _run(_pending.removeAt(0)); + } + } + + Future _run(Future Function() request) async { + try { + // Interrupted work keeps its place in the same total admission budget. + // Reconnect resumes it with a fresh connection generation, but the + // original summary version still fences it against newer root evidence. + if (!await request()) _pending.insert(0, request); + } finally { + _active--; + _drain(); + onCapacity(); + } + } +} diff --git a/mobile/lib/features/channels/channel_window.dart b/mobile/lib/features/channels/channel_window.dart index ca2a644c9f..96c5eda549 100644 --- a/mobile/lib/features/channels/channel_window.dart +++ b/mobile/lib/features/channels/channel_window.dart @@ -16,11 +16,19 @@ class ChannelWindowThreadSummary { final int? lastReplyAt; final List participantPubkeys; + /// True while capped evidence awaits a complete recount. + final bool isLowerBound; + + /// The last known total is awaiting reconciliation and should not be displayed. + final bool isCountPending; + const ChannelWindowThreadSummary({ required this.replyCount, required this.descendantCount, required this.lastReplyAt, required this.participantPubkeys, + this.isLowerBound = false, + this.isCountPending = false, }); } @@ -273,6 +281,7 @@ ChannelWindowStore mergeLiveChannelWindowEvent( ChannelWindowStore current, NostrEvent event, { required bool isTimelineRow, + bool retainOutsideWindow = false, }) { // A reply doesn't reach the main timeline itself — the root's "N replies" row // comes from this summary event, which the relay re-emits on every reply. It @@ -331,7 +340,8 @@ ChannelWindowStore mergeLiveChannelWindowEvent( final oldest = oldestPage?.rows.isEmpty ?? true ? null : oldestPage!.rows.last.event; - if (oldest != null && + if (!retainOutsideWindow && + oldest != null && (event.createdAt < oldest.createdAt || (oldestPage!.hasMore && _compareRelayOrder(event, oldest) >= 0))) { return current; 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 1dcf40f579..71b80a5cfe 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 @@ -76,8 +76,9 @@ class _NestedThreadSummaryRow extends ConsumerWidget { TextSpan( children: [ TextSpan( - text: - '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + text: summary.isCountPending + ? 'Replies' + : '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}', style: replyPreviewTextStyle.copyWith( color: context.colors.primary, ), diff --git a/mobile/lib/features/channels/thread_replies_provider.dart b/mobile/lib/features/channels/thread_replies_provider.dart index c49461834e..b1a72ad99a 100644 --- a/mobile/lib/features/channels/thread_replies_provider.dart +++ b/mobile/lib/features/channels/thread_replies_provider.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart' show KeepAliveLink; import '../../shared/relay/relay.dart'; import 'channel_event_order.dart'; +import 'channel_messages_provider.dart'; import 'pending_local_messages_provider.dart'; class ThreadRepliesArgs { @@ -42,20 +44,111 @@ final threadRepliesProvider = FutureProvider.autoDispose } }); final session = ref.read(relaySessionProvider.notifier); - final replies = []; - _ThreadCursor? cursor; - for (var page = 0; page < 500; page++) { - final events = await session.queryRelay([ - _threadRepliesFilter(args, cursor), - ]); - replies.addAll(events); - if (events.length < 200) return replies; - final last = events.last; - cursor = _ThreadCursor(createdAt: last.createdAt, eventId: last.id); + final channelProvider = channelMessagesProvider(args.channelId); + final channelMessages = ref.exists(channelProvider) + ? ref.read(channelProvider.notifier) + : null; + final cachedReplyIds = channelMessages?.cachedThreadReplyIds(args.rootId); + final unconfirmedIds = channelMessages?.unconfirmedThreadReplyIds( + args.rootId, + ); + final queryVersion = channelMessages?.beginThreadQuery(args.rootId); + if (queryVersion != null) { + ref.onDispose( + () => channelMessages?.failThreadQuery(args.rootId, queryVersion), + ); + } + try { + final replies = await fetchCompleteThreadReplies(session, args); + // Explicit markers also settle unacknowledged local sends, whose + // absence cannot prove deletion even in an insertion-complete scan. + final missingIds = + unconfirmedIds?.difference( + replies.map((event) => event.id).toSet(), + ) ?? + {}; + final deletions = []; + // One bounded request per scan keeps opening a thread responsive even + // when many sends are awaiting ACKs. Excess IDs remain provisional. + final targets = + channelMessages?.nextThreadDeletionProofTargets(missingIds) ?? + const []; + // Per-target limits keep repeated markers from crowding another ID out. + if (targets.isNotEmpty) { + deletions.addAll( + await session.queryRelay([ + for (final target in targets) + NostrFilter( + kinds: const [EventKind.deletion, EventKind.nip29DeleteEvent], + tags: { + '#h': [args.channelId], + '#e': [target], + }, + limit: 1, + ), + ]), + ); + } + if (ref.mounted && ref.exists(channelProvider)) { + final channel = ref.read(channelProvider.notifier); + final deletedTargets = { + for (final event in deletions) + for (final tag in event.tags) + if (tag.length > 1 && tag[0] == 'e') tag[1], + }; + final applied = channel.cacheCompleteThreadQuery( + args.rootId, + cachedReplyIds ?? {}, + replies, + provisionalReplyIds: missingIds.difference(deletedTargets), + queryVersion: queryVersion, + ); + if (deletions.isNotEmpty) { + channel.cacheThreadDeletions( + deletions, + scopedTargetIds: targets.toSet(), + reconciledTargetIds: applied ? missingIds : const {}, + ); + } + } + return replies; + } catch (_) { + if (queryVersion != null) { + channelMessages?.failThreadQuery(args.rootId, queryVersion); + } + rethrow; } - throw Exception('Thread ${args.rootId} exceeded the page safety limit.'); }); +/// Exhaustively scans a thread using insertion-complete cursor pages. +/// [isCurrent] lets a background refresh stop between pages after disposal. +Future> fetchCompleteThreadReplies( + RelaySessionNotifier session, + ThreadRepliesArgs args, { + bool Function()? isCurrent, +}) async { + final replies = []; + // -1 precedes unsigned Nostr timestamps. A non-null cursor selects the + // insertion-complete route and writer-verified EOF instead of a stale head. + _ThreadCursor? cursor = const _ThreadCursor( + createdAt: -1, + eventId: '0000000000000000000000000000000000000000000000000000000000000000', + ); + for (var page = 0; page < 500; page++) { + if (isCurrent != null && !isCurrent()) { + throw StateError('Thread scan superseded'); + } + final events = await session.queryRelay([ + _threadRepliesFilter(args, cursor), + ]); + replies.addAll(events); + if (events.length < 200) return replies; + final last = events.last; + cursor = _ThreadCursor(createdAt: last.createdAt, eventId: last.id); + } + throw Exception('Thread ${args.rootId} exceeded the page safety limit.'); +} + NostrFilter _threadRepliesFilter( ThreadRepliesArgs args, _ThreadCursor? cursor, @@ -68,7 +161,8 @@ NostrFilter _threadRepliesFilter( }, limit: 200, extensions: { - 'depth_limit': 64, + // The relay binds this as signed i32. Include every representable depth. + 'depth_limit': 0x7fffffff, if (cursor != null) 'thread_cursor': cursor.createdAt, if (cursor != null) 'thread_cursor_id': cursor.eventId, }, @@ -81,7 +175,19 @@ class ThreadLocalRepliesNotifier extends Notifier> { ThreadLocalRepliesNotifier(this.args); @override - List build() => const []; + List build() { + // Keep optimistic replies across route disposal, but release empty overlays. + KeepAliveLink? retention; + listenSelf((previous, next) { + if (next.isNotEmpty) { + retention ??= ref.keepAlive(); + } else { + retention?.close(); + retention = null; + } + }); + return const []; + } void add(NostrEvent event) { state = _mergeReplies(state, [event]); @@ -97,12 +203,10 @@ class ThreadLocalRepliesNotifier extends Notifier> { } } -final threadLocalRepliesProvider = - NotifierProvider.family< - ThreadLocalRepliesNotifier, - List, - ThreadRepliesArgs - >(ThreadLocalRepliesNotifier.new); +final threadLocalRepliesProvider = NotifierProvider.autoDispose + .family, ThreadRepliesArgs>( + ThreadLocalRepliesNotifier.new, + ); /// Relay-backed replies merged with signed local replies that are still /// waiting for acknowledgement. @@ -123,7 +227,16 @@ final threadRepliesWithLocalProvider = Provider.autoDispose final pendingMessagesNotifier = ref.read( pendingLocalMessagesProvider(args.channelId).notifier, ); + final channelMessages = ref.read( + channelMessagesProvider(args.channelId).notifier, + ); + final confirmedReplies = authoritative + .where( + (event) => localReplies.any((local) => local.id == event.id), + ) + .toList(); Future.microtask(() { + channelMessages.cacheConfirmedThreadReplies(confirmedReplies); localRepliesNotifier.confirm(authoritativeIds); pendingMessagesNotifier.confirm(authoritativeIds); }); diff --git a/mobile/lib/features/channels/thread_reply_ownership.dart b/mobile/lib/features/channels/thread_reply_ownership.dart new file mode 100644 index 0000000000..49051936a6 --- /dev/null +++ b/mobile/lib/features/channels/thread_reply_ownership.dart @@ -0,0 +1,26 @@ +import '../../shared/relay/relay.dart'; + +/// Bounded identity-only evidence for replies whose payloads may be evicted. +class ThreadReplyOwnership { + final Map _roots = {}; + + /// Remembers recent ownership without retaining event payloads. + void record(Iterable events) { + for (final event in events) { + final thread = event.threadReference; + if (!EventKind.channelTimelineContentKinds.contains(event.kind) || + thread.parentId == null || + thread.rootId == null) { + continue; + } + _roots.remove(event.id); + _roots[event.id] = thread.rootId!; + while (_roots.length > 8192) { + _roots.remove(_roots.keys.first); + } + } + } + + /// Returns known ownership or null when the identity evidence was evicted. + String? rootFor(String eventId) => _roots[eventId]; +} diff --git a/mobile/lib/features/channels/thread_summary_refresh_queue.dart b/mobile/lib/features/channels/thread_summary_refresh_queue.dart new file mode 100644 index 0000000000..bf38b7e197 --- /dev/null +++ b/mobile/lib/features/channels/thread_summary_refresh_queue.dart @@ -0,0 +1,143 @@ +import 'dart:async'; +import 'dart:math' as math; + +import '../../shared/relay/relay.dart'; +import 'channel_window.dart'; + +/// Coalesces visible-thread recounts, with at most two concurrent scans. +class ThreadSummaryRefreshQueue { + /// Builds a queue whose owner handles refresh errors and lifecycle validity. + ThreadSummaryRefreshQueue({ + required this.refresh, + required this.canRun, + required this.onSettled, + }); + + /// Loads and publishes one root's complete summary. + final Future Function(String rootId) refresh; + + /// Whether the owner is connected and still mounted. + final bool Function() canRun; + + /// Notifies the owner after an active request releases its slot. + final void Function() onSettled; + final Set _queued = {}; + final Set _active = {}; + Timer? _timer; + + /// Marks a root dirty. Repeated arrivals collapse into one pending scan. + void enqueue(String rootId) { + _queued.remove(rootId); + _queued.add(rootId); + // Dropped requests retain their lower-bound display until the thread opens. + while (_queued.length > 256) { + _queued.remove(_queued.first); + } + resume(); + } + + /// Removes queued work when a relay summary already supplied the answer. + void cancel(String rootId) => _queued.remove(rootId); + + /// Whether another scan was requested after the active scan began. + bool isDirty(String rootId) => _queued.contains(rootId); + + /// Cancels a scheduled wake-up while preserving dirty roots for reconnect. + void pause() { + _queued.addAll(_active); + _timer?.cancel(); + _timer = null; + } + + /// Resumes queued work after the owning connection recovers. + void resume() { + if (_timer != null || _queued.isEmpty || !canRun()) return; + _timer = Timer(const Duration(milliseconds: 200), _drain); + } + + void _drain() { + _timer = null; + if (!canRun()) return; + for (final root in _queued.toList()) { + if (_active.length >= 2) break; + if (_active.contains(root)) continue; + _queued.remove(root); + _active.add(root); + unawaited(_run(root)); + } + } + + Future _run(String root) async { + try { + await refresh(root); + } finally { + _active.remove(root); + if (canRun()) { + onSettled(); + resume(); + } + } + } +} + +/// Conservatively adjusts known totals after explicit deletions, including +/// targets whose payloads have been evicted. Unknown ownership preserves navigation +/// and hides the stale number until a complete recount restores exactness. +Map lowerBoundSummariesAfterDeletion( + Map summaries, + List events, + Set targets, + Map owners, +) { + final byId = {for (final event in events) event.id: event}; + final knownRoots = owners.values.toSet(); + final knownTargetsPerRoot = {}; + for (final root in owners.values) { + knownTargetsPerRoot[root] = (knownTargetsPerRoot[root] ?? 0) + 1; + } + final hasUnknownTarget = targets.any( + (id) => !owners.containsKey(id) && !byId.containsKey(id), + ); + final deleted = { + ...targets, + for (final event in events) + if (event.kind == EventKind.deletion || + event.kind == EventKind.nip29DeleteEvent) + for (final tag in event.tags) + if (tag.length > 1 && tag[0] == 'e') tag[1], + }; + final cached = {}; + final remaining = {}; + for (final event in byId.values) { + if (!EventKind.channelTimelineContentKinds.contains(event.kind) || + event.threadReference.parentId == null) { + continue; + } + final root = event.threadReference.rootId; + cached[root] = (cached[root] ?? 0) + 1; + if (!deleted.contains(event.id)) { + remaining[root] = (remaining[root] ?? 0) + 1; + } + } + return { + for (final entry in summaries.entries) + if (knownRoots.contains(entry.key) || + (hasUnknownTarget && + math.max(entry.value.replyCount, entry.value.descendantCount) > + (cached[entry.key] ?? 0))) + entry.key: ChannelWindowThreadSummary( + replyCount: math.max( + 0, + entry.value.replyCount - (knownTargetsPerRoot[entry.key] ?? 0), + ), + descendantCount: math.max( + remaining[entry.key] ?? 0, + entry.value.descendantCount - (knownTargetsPerRoot[entry.key] ?? 0), + ), + lastReplyAt: entry.value.lastReplyAt, + participantPubkeys: entry.value.participantPubkeys, + isLowerBound: true, + isCountPending: hasUnknownTarget || entry.value.isCountPending, + ), + }; +} diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 716e9f247d..59e02afb37 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -219,6 +219,12 @@ class ThreadSummary { final String threadHeadId; final int replyCount; + /// Whether this count is a minimum pending a complete recount. + final bool isLowerBound; + + /// The last known total is awaiting reconciliation and should not be displayed. + final bool isCountPending; + /// Up to 3 most recent unique participant pubkeys. final List participantPubkeys; final int? lastReplyAt; @@ -226,6 +232,8 @@ class ThreadSummary { const ThreadSummary({ required this.threadHeadId, required this.replyCount, + this.isLowerBound = false, + this.isCountPending = false, required this.participantPubkeys, this.lastReplyAt, }); @@ -560,10 +568,10 @@ bool _isBroadcastReply(TimelineMessage message) { /// order is mobile's own (see [_mergeParticipants]) because this file already /// renders relay participants in the order the relay sent them. /// -/// Both halves count descendants, not direct replies: the relay's -/// `descendant_count` and the locally assembled [_buildDescendantStats]. A badge -/// on the main timeline stands for the whole thread under that message, so a -/// reply to a reply has to raise it. +/// Both halves represent descendants. The relay only increments descendant +/// counts on the outer root, so a broadcast reply's direct-reply count is also +/// positive evidence, and a lower bound on its descendants. Locally assembled +/// [_buildDescendantStats] includes nested replies under every ancestor. ThreadSummary? _buildSummary( String messageId, Map descendantStats, @@ -576,6 +584,8 @@ ThreadSummary? _buildSummary( return ThreadSummary( threadHeadId: messageId, + isLowerBound: relay.isLowerBound, + isCountPending: relay.isCountPending, replyCount: local.replyCount > relay.replyCount ? local.replyCount : relay.replyCount, @@ -684,10 +694,19 @@ ThreadSummary? _buildRelaySummary( String messageId, ChannelWindowThreadSummary? relaySummary, ) { - if (relaySummary == null || relaySummary.descendantCount <= 0) return null; + if (relaySummary == null) return null; + // The relay counts direct children on every parent, but descendants only on + // the outer root. Broadcast replies can therefore have valid 1/0 counters. + final useDirectCount = relaySummary.replyCount > relaySummary.descendantCount; + final count = useDirectCount + ? relaySummary.replyCount + : relaySummary.descendantCount; + if (count <= 0) return null; return ThreadSummary( threadHeadId: messageId, - replyCount: relaySummary.descendantCount, + replyCount: count, + isLowerBound: relaySummary.isLowerBound || useDirectCount, + isCountPending: relaySummary.isCountPending, participantPubkeys: relaySummary.participantPubkeys.take(3).toList(), lastReplyAt: relaySummary.lastReplyAt, ); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 23a83ddab3..20251f2195 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -19,6 +19,7 @@ import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_detail_page.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channel_messages_provider.dart'; +import 'package:buzz/features/channels/channel_window.dart'; import 'package:buzz/features/channels/channel_mutes/channel_mutes_provider.dart'; import 'package:buzz/features/channels/channel_mutes/channel_mutes_storage.dart'; import 'package:buzz/features/channels/channel_stars/channel_stars_provider.dart'; @@ -558,14 +559,25 @@ void main() { pubkey: 'bot', content: 'Bot message', ); + final reply = _textMsg( + id: 'bot-reply', + pubkey: 'alice', + content: 'Reply to bot', + createdAt: 1100, + extraTags: const [ + ['e', 'bot-message', '', 'reply'], + ], + ); await tester.pumpWidget( _buildTestable( - messages: [message], + messages: [message, reply], users: const { 'bot': UserProfile(pubkey: 'bot', displayName: 'Bot'), }, loadChannelBotPubkeys: () async => const {'bot'}, - threadReplies: const {'bot-message': []}, + threadReplies: { + 'bot-message': [reply], + }, ), ); await tester.pumpAndSettle(); @@ -10255,6 +10267,254 @@ void main() { }); }); + group('Message row thread navigation', () { + testWidgets('a local reply makes its thread immediately tappable', ( + tester, + ) async { + final root = _textMsg( + id: 'local-root', + pubkey: 'alice', + content: 'Start a local thread', + ); + final reply = _textMsg( + id: 'local-reply', + pubkey: 'self', + content: 'Waiting for relay echo', + createdAt: 1100, + extraTags: const [ + ['e', 'local-root', '', 'reply'], + ], + ); + final query = Completer>(); + final notifier = _FakeMessagesNotifier([root]); + await tester.pumpWidget( + _buildTestable( + messages: [root], + messagesNotifier: notifier, + pendingThreadReplies: {'local-root': query.future}, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(findRichText('Start a local thread')); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsNothing); + + notifier.addLocalMessage(reply); + notifier.completeLocalMessage('local-reply'); + await tester.pumpAndSettle(); + await tester.tap(findRichText('Start a local thread')); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsOneWidget); + expect(findRichText('Waiting for relay echo'), findsOneWidget); + + query.complete([reply]); + await tester.pumpAndSettle(); + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.tap(findRichText('Start a local thread')); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsOneWidget); + expect(findRichText('Waiting for relay echo'), findsOneWidget); + }); + + testWidgets( + 'pending recount renders a lower-bound count and remains tappable', + (tester) async { + final root = _textMsg( + id: 'overflow-root', + pubkey: 'alice', + content: 'Overflow thread', + ); + await tester.pumpWidget( + _buildTestable( + messages: [root], + messagesNotifier: _FakeMessagesNotifier( + [root], + summaries: const { + 'overflow-root': ChannelWindowThreadSummary( + replyCount: 256, + descendantCount: 256, + lastReplyAt: 1100, + participantPubkeys: ['bob'], + isLowerBound: true, + ), + }, + ), + threadReplies: const {'overflow-root': []}, + ), + ); + await tester.pumpAndSettle(); + expect(findRichText('256+ replies'), findsOneWidget); + await tester.tap(findRichText('Overflow thread')); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsOneWidget); + }, + ); + + testWidgets( + 'unknown deletion hides the number while preserving thread navigation', + (tester) async { + final root = _textMsg( + id: 'overflow-root', + pubkey: 'alice', + content: 'Overflow thread', + ); + await tester.pumpWidget( + _buildTestable( + messages: [root], + messagesNotifier: _FakeMessagesNotifier( + [root], + summaries: const { + 'overflow-root': ChannelWindowThreadSummary( + replyCount: 256, + descendantCount: 256, + lastReplyAt: 1100, + participantPubkeys: ['bob'], + isCountPending: true, + ), + }, + ), + threadReplies: const {'overflow-root': []}, + ), + ); + await tester.pumpAndSettle(); + expect(findRichText('Replies'), findsOneWidget); + await tester.tap(findRichText('Overflow thread')); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsOneWidget); + }, + ); + + testWidgets('tapping a message with unloaded replies opens its thread', ( + tester, + ) async { + final root = _textMsg( + id: 'remote-root', + pubkey: 'alice', + content: 'Replies are on the relay', + ); + await tester.pumpWidget( + _buildTestable( + messages: [root], + messagesNotifier: _FakeMessagesNotifier( + [root], + summaries: const { + 'remote-root': ChannelWindowThreadSummary( + replyCount: 1, + descendantCount: 1, + lastReplyAt: 1100, + participantPubkeys: ['bob'], + ), + }, + ), + threadReplies: const {'remote-root': []}, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(findRichText('Replies are on the relay')); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsOneWidget); + }); + + for (final directReplies in [0, 1]) { + testWidgets( + 'cold broadcast row with $directReplies relay-only direct replies', + (tester) async { + final root = _textMsg( + id: 'outer-root', + pubkey: 'bob', + content: 'Outer root', + ); + final broadcast = _textMsg( + id: 'broadcast-reply', + pubkey: 'alice', + content: 'Broadcast reply with unloaded children', + extraTags: const [ + ['e', 'outer-root', '', 'root'], + ['e', 'outer-root', '', 'reply'], + ['broadcast', '1'], + ], + ); + await tester.pumpWidget( + _buildTestable( + messages: [root, broadcast], + messagesNotifier: _FakeMessagesNotifier( + [root, broadcast], + summaries: { + 'broadcast-reply': ChannelWindowThreadSummary( + replyCount: directReplies, + descendantCount: 0, + lastReplyAt: 1100, + participantPubkeys: const ['bob'], + ), + }, + ), + threadReplies: const {'broadcast-reply': []}, + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + findRichText('Broadcast reply with unloaded children'), + ); + await tester.pumpAndSettle(); + expect( + find.byType(ThreadDetailPage), + directReplies > 0 ? findsOneWidget : findsNothing, + ); + }, + ); + } + + for (final hasReplies in [false, true]) { + testWidgets( + hasReplies + ? 'tapping a message with replies opens its thread' + : 'tapping a message without replies stays in the channel', + (tester) async { + final root = _textMsg( + id: 'tap-root', + pubkey: 'alice', + content: 'Tap this message', + ); + final replies = [ + if (hasReplies) + _textMsg( + id: 'tap-reply', + pubkey: 'bob', + content: 'A reply', + createdAt: 1100, + extraTags: const [ + ['e', 'tap-root', '', 'reply'], + ], + ), + ]; + await tester.pumpWidget( + _buildTestable( + messages: [root, ...replies], + threadReplies: {'tap-root': replies}, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(findRichText('Tap this message')); + await tester.pumpAndSettle(); + + expect( + find.byType(ThreadDetailPage), + hasReplies ? findsOneWidget : findsNothing, + ); + if (!hasReplies) { + await tester.longPress(findRichText('Tap this message')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reply')); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsOneWidget); + } + }, + ); + } + }); + group('Deep-link navigation', () { testWidgets('fades the target highlight in after the thread route lands', ( tester, @@ -14347,6 +14607,7 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier { bool _hasLoadedMessages; final List> _olderPages; final bool failOlderFetch; + final Map summaries; int fetchOlderCalls = 0; _FakeMessagesNotifier( @@ -14355,6 +14616,7 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier { bool hasLoadedMessages = true, List> olderPages = const [], this.failOlderFetch = false, + this.summaries = const {}, }) : _hasLoadedMessages = hasLoadedMessages, _olderPages = [...olderPages], super(channelId); @@ -14365,6 +14627,9 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier { @override bool get hasLoadedMessages => _hasLoadedMessages; + @override + Map get threadSummaries => summaries; + @override bool get reachedOldest => _olderPages.isEmpty && !failOlderFetch; diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index 9e2d93d2a7..729bf45fc7 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -506,7 +506,11 @@ void main() { () async { final relaySession = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [ + _event(id: 'root', createdAt: 10), + _summary(rootId: 'root', replyCount: 1), + _bounds(), + ], ], ); final container = _buildContainer(relaySession); @@ -577,6 +581,62 @@ void main() { ); }); + for (final nested in [false, true]) { + test( + 'live reply settles a closed thread overlay, nested=$nested', + () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [ + if (nested) + _event( + id: 'parent', + createdAt: 15, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + _event(id: 'root', createdAt: 10), + _bounds(), + ], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + final reply = _event( + id: 'reply', + createdAt: 20, + extraTags: [ + if (nested) ['e', 'root', '', 'root'], + ['e', nested ? 'parent' : 'root', '', 'reply'], + ], + ); + notifier.addLocalMessage(reply); + notifier.completeLocalMessage(reply.id); + relaySession.emit(reply); + await _pumpEventQueue(); + expect(container.exists(threadLocalRepliesProvider(args)), isFalse); + expect( + container.read(pendingLocalMessagesProvider(_channelId)), + isEmpty, + ); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + ); + expect(entries.single.summary?.replyCount, nested ? 2 : 1); + }, + ); + } + test( 'thread replies are inserted, deduped, and rolled back locally', () async { @@ -668,7 +728,7 @@ void main() { ); test( - 'thread live echo keeps ownership until the authoritative refetch succeeds', + 'thread live echo settles ownership even when the refetch fails', () async { final relaySession = _RecordingRelaySessionNotifier( queryResults: [ @@ -705,21 +765,14 @@ void main() { relaySession.emit(reply); await _pumpEventQueue(); - expect(container.read(pendingLocalMessagesProvider(_channelId)).keys, [ - 'reply', - ]); + expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty); + expect(container.read(threadLocalRepliesProvider(args)), isEmpty); expect( container - .read(threadLocalRepliesProvider(args)) + .read(channelMessagesProvider(_channelId)) + .value! .map((event) => event.id), - ['reply'], - ); - expect( - container - .read(threadRepliesWithLocalProvider(args)) - .value - ?.map((event) => event.id), - ['reply'], + ['history', 'reply'], ); }, ); @@ -902,6 +955,4065 @@ void main() { }, ); + for (final deleted in [false, true]) { + test( + 'confirmed reply evidence survives stale reconnect (deleted: $deleted)', + () async { + final root = _event(id: 'root', createdAt: 10); + final reply = _event( + id: 'reply', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [root, _bounds()], + [root, _bounds()], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + final subscription = container.listen( + channelMessagesProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(subscription.close); + await relaySession.subscribed; + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + // The thread query confirmed the reply, but its live echo and recount + // were missed. A bounded-staleness reconnect window can still omit it. + notifier.cacheConfirmedThreadReplies([reply]); + if (deleted) { + relaySession.emit( + NostrEvent( + id: 'delete-reply', + pubkey: 'alice', + createdAt: 30, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'reply'], + ], + content: '', + sig: 'sig', + ), + ); + await _pumpEventQueue(); + } + relaySession.setConnected(false); + await _pumpEventQueue(); + relaySession.setConnected(true); + await _pumpEventQueue(); + // A subsequent live event rebuilds state from the window store. + relaySession.emit(_event(id: 'unrelated', createdAt: 40)); + await _pumpEventQueue(); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + final rootEntry = entries.singleWhere( + (entry) => entry.message.id == 'root', + ); + if (deleted) { + expect(rootEntry.summary, isNull); + } else { + expect(rootEntry.summary?.replyCount, 1); + } + }, + ); + } + + test( + 'scan preserves a reply acknowledged after query start without an echo', + () async { + final scan = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + scan.future, + [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.addLocalMessage( + _event( + id: 'accepted-late', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.listen(threadRepliesProvider(args), (_, _) {}); + final result = container.read(threadRepliesProvider(args).future); + await _pumpEventQueue(); + notifier.completeLocalMessage('accepted-late'); + scan.complete([]); + await result; + expect(notifier.cachedThreadReplyIds('root'), contains('accepted-late')); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary!.replyCount, 1); + }, + ); + + test( + 'thread scan bounds pending deletion proofs and retains excess replies', + () async { + NostrEvent marker(int i) => NostrEvent( + id: 'deletion-$i', + pubkey: 'author', + createdAt: 100, + kind: EventKind.deletion, + tags: [ + ['h', _channelId], + ['e', 'pending-$i'], + ], + content: '', + sig: '', + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + [], + [for (var i = 0; i < 20; i++) marker(i)], + [], + [for (var i = 20; i < 25; i++) marker(i)], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (var i = 0; i < 25; i++) { + notifier.addLocalMessage( + _event( + id: 'pending-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ); + } + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.listen(threadRepliesProvider(args), (_, _) {}); + await container.read(threadRepliesProvider(args).future); + int proofs() => session.queryFilters + .where((f) => f.kinds.contains(EventKind.deletion)) + .length; + expect(proofs(), 20); + expect(notifier.unconfirmedThreadReplyIds('root'), { + for (var i = 20; i < 25; i++) 'pending-$i', + }); + expect(container.read(threadLocalRepliesProvider(args)), hasLength(5)); + container.invalidate(threadRepliesProvider(args)); + await container.read(threadRepliesProvider(args).future); + expect(proofs(), 25); + expect(notifier.unconfirmedThreadReplyIds('root'), isEmpty); + expect(container.read(threadLocalRepliesProvider(args)), isEmpty); + }, + ); + + test( + 'deletion proof batches progress past persistent pending replies', + () async { + final deletion = NostrEvent( + id: 'deleted-tail', + pubkey: 'author', + createdAt: 100, + kind: EventKind.deletion, + tags: const [ + ['e', 'pending-24'], + ], + content: '', + sig: '', + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + [], + [], + [], + [deletion], + [], + [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (var i = 0; i < 25; i++) { + notifier.addLocalMessage( + _event( + id: 'pending-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ); + } + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + final subscription = container.listen( + threadRepliesProvider(args), + (_, _) {}, + ); + await container.read(threadRepliesProvider(args).future); + expect(notifier.unconfirmedThreadReplyIds('root'), hasLength(25)); + subscription.close(); + await _pumpEventQueue(); + container.listen(threadRepliesProvider(args), (_, _) {}); + await container.read(threadRepliesProvider(args).future); + final targets = session.queryFilters + .where((f) => f.kinds.contains(EventKind.deletion)) + .map((f) => f.tags['#e']!.single) + .toList(); + expect(targets, hasLength(40)); + expect(targets.take(20), isNot(contains('pending-24'))); + expect(targets.skip(20), contains('pending-24')); + expect(notifier.unconfirmedThreadReplyIds('root'), hasLength(24)); + expect( + notifier.cachedThreadReplyIds('root'), + isNot(contains('pending-24')), + ); + container.invalidate(threadRepliesProvider(args)); + await container.read(threadRepliesProvider(args).future); + final third = session.queryFilters + .where((f) => f.kinds.contains(EventKind.deletion)) + .skip(40) + .map((f) => f.tags['#e']!.single) + .toList(); + expect(third, hasLength(20)); + expect(third.first, 'pending-15'); + expect(third, isNot(contains('pending-24'))); + }, + ); + + for (final count in [1, 300]) { + for (final acknowledgeDuringQuery in [false, true]) { + test( + 'proof tombstone preserves $count surviving replies (late ACK: $acknowledgeDuringQuery)', + () async { + final scan = Completer>(); + final survivors = [ + for (var i = 0; i < count; i++) + _event( + id: 'survivor-$i', + createdAt: 30 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + scan.future, + if (count >= 200) survivors.skip(200).toList(), + [ + NostrEvent( + id: 'offline-deletion', + pubkey: 'author', + createdAt: 25, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'deleted-local'], + ], + content: '', + sig: '', + ), + ], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.addLocalMessage( + _event( + id: 'deleted-local', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.listen(threadRepliesProvider(args), (_, _) {}); + final result = container.read(threadRepliesProvider(args).future); + await _pumpEventQueue(); + if (acknowledgeDuringQuery) { + notifier.completeLocalMessage('deleted-local'); + } + scan.complete(survivors.take(200).toList()); + expect(await result, survivors); + expect(notifier.threadSummaries['root']!.descendantCount, count); + expect(notifier.unconfirmedThreadReplyIds('root'), isEmpty); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary!.replyCount, count); + }, + ); + } + } + + for (final kind in [EventKind.deletion, EventKind.nip29DeleteEvent]) { + test( + 'late live marker does not recount scan-removed reply (kind: $kind)', + () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + Exception('recount unavailable'), + Exception('recount unavailable'), + Exception('recount unavailable'), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheConfirmedThreadReplies([ + _event( + id: 'deleted', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]); + final snapshot = notifier.cachedThreadReplyIds('root'); + expect(snapshot, contains('deleted')); + notifier.cacheCompleteThreadQuery('root', snapshot, [ + for (var i = 0; i < 300; i++) + _event( + id: 'survivor-$i', + createdAt: 30 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]); + expect(notifier.threadSummaries['root']!.descendantCount, 300); + session.emit( + NostrEvent( + id: 'late-deletion', + pubkey: 'author', + createdAt: 25, + kind: kind, + tags: const [ + ['h', _channelId], + ['e', 'deleted'], + ], + content: '', + sig: '', + ), + ); + expect(notifier.threadSummaries['root']!.descendantCount, 300); + expect(notifier.threadSummaries['root']!.isLowerBound, isFalse); + await Future.delayed(const Duration(milliseconds: 300)); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + isEmpty, + ); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary!.replyCount, 300); + }, + ); + } + + for (final deletionMarkerAvailable in [false, true]) { + for (final lateArrival in [false, true]) { + test( + 'complete thread scan removes absent replies (marker: $deletionMarkerAvailable, late arrival: $lateArrival)', + () async { + final query = Completer>(); + final root = _event(id: 'root', createdAt: 10); + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [root, _bounds()], + [root, _bounds()], + query.future, + [ + if (deletionMarkerAvailable) + NostrEvent( + id: 'offline-deletion', + pubkey: 'alice', + createdAt: 25, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'deleted-offline'], + ], + content: '', + sig: 'sig', + ), + ], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + final channelSubscription = container.listen( + channelMessagesProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(channelSubscription.close); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheConfirmedThreadReplies([ + _event( + id: 'deleted-offline', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]); + relaySession.setConnected(false); + await _pumpEventQueue(); + relaySession.setConnected(true); + await _pumpEventQueue(); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + final threadSubscription = container.listen( + threadRepliesProvider(args), + (_, _) {}, + ); + addTearDown(threadSubscription.close); + await _pumpEventQueue(); + // Another confirmation arrives after the query starts. Its absence from + // that query must not erase it along with the old cached reply. + if (lateArrival) { + notifier.cacheConfirmedThreadReplies([ + _event( + id: 'new-arrival', + createdAt: 30, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]); + } + query.complete([]); + await container.read(threadRepliesProvider(args).future); + await _pumpEventQueue(); + relaySession.emit(_event(id: 'unrelated', createdAt: 40)); + await _pumpEventQueue(); + final events = container + .read(channelMessagesProvider(_channelId)) + .value!; + expect(formatTimeline(events).map((event) => event.id), [ + 'root', + if (lateArrival) 'new-arrival', + 'unrelated', + ]); + final merged = mergeThreadEvents( + container.read(threadRepliesProvider(args)).value!, + events, + ); + expect( + formatTimeline( + merged, + ).any((event) => event.id == 'deleted-offline'), + isFalse, + ); + final rootEntry = buildMainTimelineEntries( + formatTimeline(events), + relaySummaries: notifier.threadSummaries, + ).singleWhere((entry) => entry.message.id == 'root'); + final expectedCount = lateArrival ? 1 : 0; + expect( + rootEntry.summary?.replyCount, + expectedCount == 0 ? null : expectedCount, + ); + }, + ); + } + } + test( + 'query-discovered replies enable the root without a local overlay', + () async { + final reply = _event( + id: 'discovered', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + [reply], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + final thread = container.listen(threadRepliesProvider(args), (_, _) {}); + await container.read(threadRepliesProvider(args).future); + thread.close(); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary?.replyCount, 1); + expect(container.exists(threadLocalRepliesProvider(args)), isFalse); + }, + ); + + test('reconnect evicts reply evidence outside the newest window', () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'old-root', createdAt: 10), _bounds()], + [_event(id: 'new-root', createdAt: 30), _bounds()], + [_event(id: 'new-root', createdAt: 30), _bounds()], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheConfirmedThreadReplies([ + _event( + id: 'old-reply', + createdAt: 20, + extraTags: const [ + ['e', 'old-root', '', 'reply'], + ], + ), + ]); + for (var cycle = 0; cycle < 2; cycle++) { + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await _pumpEventQueue(); + expect(notifier.cachedThreadReplyIds('old-root'), isEmpty); + session.emit( + _event(id: 'window-publication-$cycle', createdAt: 50 + cycle), + ); + await _pumpEventQueue(); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value! + .any((event) => event.id == 'old-reply'), + isFalse, + ); + notifier.cacheConfirmedThreadReplies([ + _event( + id: 'new-reply', + createdAt: 40, + extraTags: const [ + ['e', 'new-root', '', 'reply'], + ], + ), + ]); + } + expect(notifier.cachedThreadReplyIds('new-root'), {'new-reply'}); + }); + + for (final nested in [false, true]) { + for (final fetched in [false, true]) { + test( + 'deletion settles retained local overlay (nested: $nested, fetched: $fetched)', + () async { + final deletion = NostrEvent( + id: 'delete', + pubkey: 'alice', + createdAt: 30, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'local'], + ], + content: '', + sig: 'sig', + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + [], + [deletion], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.addLocalMessage( + _event( + id: 'local', + createdAt: 20, + extraTags: [ + if (nested) ['e', 'root', '', 'root'], + ['e', nested ? 'parent' : 'root', '', 'reply'], + ], + ), + ); + notifier.completeLocalMessage('local'); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + if (fetched) { + container.listen(threadRepliesProvider(args), (_, _) {}); + await container.read(threadRepliesProvider(args).future); + } else { + session.emit(deletion); + } + await _pumpEventQueue(); + expect(container.exists(threadLocalRepliesProvider(args)), isFalse); + expect(notifier.cachedThreadReplyIds('root'), isEmpty); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary, isNull); + }, + ); + } + } + + test( + 'complete scan clears all missing retained replies beyond the old deletion cap', + () async { + final replies = [ + for (var i = 0; i < 600; i++) + _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + replies.take(200).toList(), + replies.skip(200).take(200).toList(), + replies.skip(400).toList(), + [], + [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.listen(threadRepliesProvider(args), (_, _) {}); + expect( + await container.read(threadRepliesProvider(args).future), + hasLength(600), + ); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + expect(notifier.cachedThreadReplyIds('root'), hasLength(256)); + expect( + container.read(channelMessagesProvider(_channelId)).value, + hasLength(257), + ); + container.invalidate(threadRepliesProvider(args)); + await container.read(threadRepliesProvider(args).future); + final deletionFilters = session.queryFilters + .where((filter) => filter.kinds.contains(EventKind.deletion)) + .toList(); + expect(deletionFilters, isEmpty); + expect(notifier.threadSummaries['root']?.descendantCount, 0); + expect( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ).map((event) => event.id), + ['root'], + ); + }, + ); + + test( + 'reply payload cache has a channel-wide bound across many roots', + () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheConfirmedThreadReplies([ + for (var root = 0; root < 12; root++) + for (var i = 0; i < 300; i++) + _event( + id: '$root-$i', + createdAt: 20 + root * 300 + i, + extraTags: [ + ['e', 'root-$root', '', 'reply'], + ], + ), + ]); + final events = container.read(channelMessagesProvider(_channelId)).value!; + expect( + events.where((event) => event.threadReference.parentId != null), + hasLength(2048), + ); + for (var root = 0; root < 12; root++) { + expect( + notifier.cachedThreadReplyIds('root-$root').length, + lessThanOrEqualTo(256), + ); + } + }, + ); + + test( + 'pinned off-window root retains query-discovered reply evidence', + () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'newest', createdAt: 100), _bounds()], + [ + _event( + id: 'old-reply', + createdAt: 20, + extraTags: const [ + ['e', 'old-root', '', 'reply'], + ], + ), + ], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final load = notifier.loadEventsById(['old-root']); + session.completeTargetHistory([_event(id: 'old-root', createdAt: 10)]); + await load; + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'old-root'); + final thread = container.listen(threadRepliesProvider(args), (_, _) {}); + await container.read(threadRepliesProvider(args).future); + thread.close(); + await _pumpEventQueue(); + session.emit(_event(id: 'live', createdAt: 110)); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries + .singleWhere((entry) => entry.message.id == 'old-root') + .summary + ?.replyCount, + 1, + ); + }, + ); + + for (final source in ['ack', 'live']) { + test( + '$source replies obey payload bounds without reopening threads', + () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (var root = 0; root < 9; root++) { + for (var i = 0; i < 300; i++) { + final reply = _event( + id: '$root-$i', + createdAt: 20 + root * 300 + i, + extraTags: [ + ['e', 'root-$root', '', 'reply'], + ], + ); + if (source == 'ack') { + notifier.addLocalMessage(reply); + notifier.completeLocalMessage(reply.id); + } else { + session.emit(reply); + } + } + expect( + notifier.cachedThreadReplyIds('root-$root').length, + lessThanOrEqualTo(256), + ); + } + await _pumpEventQueue(); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value! + .where((event) => event.threadReference.parentId != null), + hasLength(2048), + ); + expect( + container.read(pendingLocalMessagesProvider(_channelId)), + isEmpty, + ); + for (var root = 0; root < 9; root++) { + expect( + container.exists( + threadLocalRepliesProvider( + ThreadRepliesArgs(channelId: _channelId, rootId: 'root-$root'), + ), + ), + isFalse, + ); + } + }, + ); + } + + for (final count in [257, 600]) { + test( + 'complete scan preserves $count reply summary through reconnect and reopen', + () async { + final replies = [ + for (var i = 0; i < count; i++) + _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: [ + if (count == 600 && i > 0) ['e', 'root', '', 'root'], + ['e', count == 600 && i > 0 ? 'reply-0' : 'root', '', 'reply'], + ], + ), + ]; + final pages = >[]; + for (var start = 0; start < count; start += 200) { + pages.add(replies.skip(start).take(200).toList()); + } + if (count % 200 == 0) pages.add([]); + final window = [_event(id: 'root', createdAt: 10), _bounds()]; + final session = _RecordingRelaySessionNotifier( + queryResults: [window, ...pages, window, ...pages, ...pages], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + final thread = container.listen(threadRepliesProvider(args), (_, _) {}); + expect( + await container.read(threadRepliesProvider(args).future), + hasLength(count), + ); + thread.close(); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + void checkSummary() { + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries + .singleWhere((entry) => entry.message.id == 'root') + .summary + ?.replyCount, + count, + ); + expect(notifier.cachedThreadReplyIds('root'), hasLength(256)); + expect(notifier.threadSummaries['root']?.lastReplyAt, 19 + count); + expect(notifier.threadSummaries['root']?.participantPubkeys, [ + 'alice', + ]); + } + + checkSummary(); + if (count == 600) { + expect( + notifier.cachedThreadReplyIds('root').contains('reply-0'), + isFalse, + ); + } + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await _pumpEventQueue(); + await Future.delayed(const Duration(milliseconds: 300)); + session.emit(_event(id: 'unrelated', createdAt: 1000)); + checkSummary(); + final reopened = container.listen( + threadRepliesProvider(args), + (_, _) {}, + ); + expect( + await container.read(threadRepliesProvider(args).future), + hasLength(count), + ); + checkSummary(); + reopened.close(); + final scans = session.queryFilters + .where((filter) => filter.extensions.containsKey('depth_limit')) + .toList(); + expect(scans.first.extensions['thread_cursor'], -1); + expect(scans.first.extensions['thread_cursor_id'], '0' * 64); + }, + ); + } + + test('unscoped proof reconciles other retained channel targets', () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'root', createdAt: 10), + _event(id: 'other-root', createdAt: 9), + _event(id: 'unrelated', createdAt: 8), + _summary(rootId: 'unrelated', replyCount: 2), + _bounds(), + ], + [], + [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (final root in ['root', 'other-root']) { + notifier.cacheCompleteThreadQuery(root, {}, [ + _event( + id: '$root-reply', + createdAt: 20, + extraTags: [ + ['e', root, '', 'reply'], + ], + ), + ]); + } + final deletion = NostrEvent( + id: 'multi-target-proof', + pubkey: 'author', + createdAt: 30, + kind: EventKind.deletion, + tags: const [ + ['e', 'root-reply'], + ['e', 'other-root-reply'], + ['e', 'outside-channel'], + ], + content: '', + sig: 'original-signature', + ); + notifier.cacheThreadDeletions([deletion], scopedTargetIds: {'root-reply'}); + expect(notifier.cachedThreadReplyIds('other-root'), isEmpty); + expect(notifier.threadSummaries['other-root']?.descendantCount, 0); + expect(notifier.threadSummaries['unrelated']!.isCountPending, isFalse); + await Future.delayed(const Duration(milliseconds: 350)); + expect(notifier.threadSummaries['other-root']?.descendantCount, 0); + expect(notifier.threadSummaries['other-root']?.isLowerBound, isFalse); + expect( + session.queryFilters.where( + (filter) => filter.extensions['resolve_thread_roots'] == true, + ), + isEmpty, + ); + final cached = container + .read(channelMessagesProvider(_channelId)) + .value! + .singleWhere((event) => event.id == deletion.id); + expect(cached, same(deletion)); + }); + + for (final multiTarget in [false, true]) { + test( + 'unacknowledged reply needs deletion proof (multi-target: $multiTarget)', + () async { + final deletion = NostrEvent( + id: 'delete-pending', + pubkey: 'alice', + createdAt: 30, + kind: EventKind.deletion, + tags: [ + ['e', 'pending'], + if (multiTarget) ['e', 'another-target'], + ], + content: '', + sig: 'sig', + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'root', createdAt: 10), + _event(id: 'unrelated', createdAt: 9), + _summary(rootId: 'unrelated', replyCount: 2), + _bounds(), + ], + [], + [], + [], + [deletion], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.addLocalMessage( + _event( + id: 'pending', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.listen(threadRepliesProvider(args), (_, _) {}); + await container.read(threadRepliesProvider(args).future); + expect( + container.read(threadLocalRepliesProvider(args)).single.id, + 'pending', + ); + container.invalidate(threadRepliesProvider(args)); + await container.read(threadRepliesProvider(args).future); + await _pumpEventQueue(); + expect(container.exists(threadLocalRepliesProvider(args)), isFalse); + expect( + container.read(pendingLocalMessagesProvider(_channelId)), + isEmpty, + ); + expect(notifier.threadSummaries['unrelated']!.isCountPending, isFalse); + expect( + session.queryFilters.where( + (filter) => filter.extensions['resolve_thread_roots'] == true, + ), + isEmpty, + ); + final filters = session.queryFilters.where( + (filter) => filter.kinds.contains(EventKind.deletion), + ); + expect(filters, hasLength(2)); + expect( + filters.every( + (filter) => + filter.limit == 1 && filter.tags['#e']!.single == 'pending', + ), + isTrue, + ); + }, + ); + } + + test( + '600 live replies coalesce into one exact recount before reopen', + () async { + final replies = [ + for (var i = 0; i < 600; i++) + _event( + id: 'live-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final pages = [ + replies.take(200).toList(), + replies.skip(200).take(200).toList(), + replies.skip(400).toList(), + [], + ]; + final window = [_event(id: 'root', createdAt: 10), _bounds()]; + final session = _RecordingRelaySessionNotifier( + queryResults: [window, ...pages, window, ...pages, ...pages], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (final reply in replies) { + session.emit(reply); + } + expect(notifier.threadSummaries['root']?.isLowerBound, isTrue); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + isEmpty, + ); + await Future.delayed(const Duration(milliseconds: 300)); + expect(notifier.threadSummaries['root']?.descendantCount, 600); + expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + expect(notifier.cachedThreadReplyIds('root'), hasLength(256)); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + hasLength(4), + ); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + expect(container.exists(threadRepliesProvider(args)), isFalse); + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await _pumpEventQueue(); + await Future.delayed(const Duration(milliseconds: 300)); + session.emit(_event(id: 'unrelated', createdAt: 1000)); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries + .singleWhere((entry) => entry.message.id == 'root') + .summary + ?.replyCount, + 600, + ); + final reopened = container.listen(threadRepliesProvider(args), (_, _) {}); + expect( + await container.read(threadRepliesProvider(args).future), + hasLength(600), + ); + expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + reopened.close(); + }, + ); + + test( + 'overflow refreshes run at most two scans and stop on disposal', + () async { + final gates = [for (var i = 0; i < 3; i++) Completer>()]; + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + for (var root = 2; root >= 0; root--) + _event(id: 'root-$root', createdAt: 10 + root), + _bounds(), + ], + ...gates.map((gate) => gate.future), + ], + ); + final container = _buildContainer(session); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + for (var root = 0; root < 3; root++) { + for (var i = 0; i < 300; i++) { + session.emit( + _event( + id: '$root-$i', + createdAt: 20 + i, + extraTags: [ + ['e', 'root-$root', '', 'reply'], + ], + ), + ); + } + } + await Future.delayed(const Duration(milliseconds: 300)); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + hasLength(2), + ); + gates[0].complete([]); + gates[1].complete([]); + await Future.delayed(const Duration(milliseconds: 300)); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + hasLength(3), + ); + container.dispose(); + gates[2].complete([]); + await _pumpEventQueue(); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + hasLength(3), + ); + }, + ); + + test( + 'reconnect supersedes an in-flight overflow recount and resumes it', + () async { + final oldScan = Completer>(); + final replies = [ + for (var i = 0; i < 300; i++) + _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final window = [_event(id: 'root', createdAt: 10), _bounds()]; + final session = _RecordingRelaySessionNotifier( + queryResults: [ + window, + oldScan.future, + window, + replies.take(200).toList(), + replies.skip(200).toList(), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (final reply in replies) { + session.emit(reply); + } + await Future.delayed(const Duration(milliseconds: 300)); + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await _pumpEventQueue(); + oldScan.complete(replies.take(200).toList()); + await Future.delayed(const Duration(milliseconds: 300)); + expect(notifier.threadSummaries['root']?.descendantCount, 300); + expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + hasLength(3), + ); + }, + ); + + test('exhausted overflow recount resumes on new activity', () async { + final replies = [ + for (var i = 0; i < 301; i++) + _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + for (var i = 0; i < 3; i++) Exception('recount unavailable'), + replies.take(200).toList(), + replies.skip(200).toList(), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (final reply in replies.take(300)) { + session.emit(reply); + } + await Future.delayed(const Duration(milliseconds: 2000)); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + hasLength(3), + ); + expect(notifier.threadSummaries['root']?.isLowerBound, isTrue); + session.emit(replies.last); + await Future.delayed(const Duration(milliseconds: 300)); + expect(notifier.threadSummaries['root']?.descendantCount, 301); + expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + }); + + test('a fresh relay summary cancels the queued overflow scan', () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + for (var i = 0; i < 300; i++) { + session.emit( + _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ); + } + session.emit(_summary(rootId: 'root', replyCount: 300, createdAt: 400)); + await Future.delayed(const Duration(milliseconds: 300)); + final summary = container + .read(channelMessagesProvider(_channelId).notifier) + .threadSummaries['root']; + expect(summary?.descendantCount, 300); + expect(summary?.isLowerBound, isFalse); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + isEmpty, + ); + }); + + test('overflow recounts pause while the channel has no listeners', () async { + final replies = [ + for (var i = 0; i < 300; i++) + _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + replies.take(200).toList(), + replies.skip(200).toList(), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + final subscription = container.listen( + channelMessagesProvider(_channelId), + (_, _) {}, + ); + await _pumpEventQueue(); + for (final reply in replies) { + session.emit(reply); + } + subscription.close(); + await Future.delayed(const Duration(milliseconds: 300)); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + isEmpty, + ); + final resumed = container.listen( + channelMessagesProvider(_channelId), + (_, _) {}, + ); + await Future.delayed(const Duration(milliseconds: 300)); + final summary = container + .read(channelMessagesProvider(_channelId).notifier) + .threadSummaries['root']; + expect(summary?.descendantCount, 300); + expect(summary?.isLowerBound, isFalse); + resumed.close(); + }); + + test('a newer route query wins over an older background recount', () async { + final oldScan = Completer>(); + final replies = [ + for (var i = 0; i < 400; i++) + _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + oldScan.future, + replies.take(200).toList(), + replies.skip(200).toList(), + [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + for (final reply in replies.take(300)) { + session.emit(reply); + } + await Future.delayed(const Duration(milliseconds: 300)); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.listen(threadRepliesProvider(args), (_, _) {}); + expect( + await container.read(threadRepliesProvider(args).future), + hasLength(400), + ); + oldScan.complete([]); + await _pumpEventQueue(); + final summary = container + .read(channelMessagesProvider(_channelId).notifier) + .threadSummaries['root']; + expect(summary?.descendantCount, 400); + expect(summary?.isLowerBound, isFalse); + }); + + for (final fetched in [false, true]) { + for (final count in [1, 257, 600]) { + test( + '$fetched deletion recounts a queried $count-reply aggregate', + () async { + final replies = [ + for (var i = 0; i < count; i++) + _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final remaining = replies.skip(1).toList(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + for (var i = 0; i < remaining.length; i += 200) + remaining.skip(i).take(200).toList(), + if (remaining.length % 200 == 0) [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheCompleteThreadQuery('root', {}, replies); + expect(notifier.threadSummaries['root']?.descendantCount, count); + if (count == 600) { + expect( + notifier.cachedThreadReplyIds('root'), + isNot(contains('reply-0')), + ); + } + final deletion = NostrEvent( + id: 'delete-first', + pubkey: 'author', + createdAt: 1000, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'reply-0'], + ], + content: '', + sig: '', + ); + if (fetched) { + notifier.cacheThreadDeletions([deletion]); + } else { + session.emit(deletion); + } + expect(notifier.threadSummaries['root']?.descendantCount, count - 1); + expect(notifier.threadSummaries['root']?.isCountPending, isFalse); + expect(notifier.threadSummaries['root']?.isLowerBound, isTrue); + await Future.delayed(const Duration(milliseconds: 300)); + expect(notifier.threadSummaries['root']?.descendantCount, count - 1); + expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries + .singleWhere((entry) => entry.message.id == 'root') + .summary + ?.replyCount, + count == 1 ? null : count - 1, + ); + }, + ); + } + } + + for (final refreshedCount in [-1, 0, 300, 700]) { + test( + 'reconnect summary replaces queried count with $refreshedCount', + () async { + final root = _event(id: 'root', createdAt: 10); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [root, _bounds()], + [ + root, + if (refreshedCount >= 0) + _summary( + rootId: 'root', + replyCount: refreshedCount, + createdAt: 1000, + ), + _bounds(), + ], + if (refreshedCount <= 0) [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheCompleteThreadQuery('root', {}, [ + for (var i = 0; i < 600; i++) + _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]); + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await _pumpEventQueue(); + await Future.delayed(const Duration(milliseconds: 300)); + expect( + notifier.threadSummaries['root']?.descendantCount, + refreshedCount < 0 ? 0 : refreshedCount, + ); + expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries + .singleWhere((entry) => entry.message.id == 'root') + .summary + ?.replyCount, + refreshedCount <= 0 ? null : refreshedCount, + ); + }, + ); + } + + test( + 'unknown deletion preserves unrelated navigation after recount failure', + () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'unrelated', createdAt: 10), + _summary(rootId: 'unrelated', replyCount: 1), + _bounds(), + ], + Exception('recount unavailable'), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + session.emit( + NostrEvent( + id: 'deletion', + pubkey: 'author', + createdAt: 1000, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'unknown-old-reply'], + ], + content: '', + sig: '', + ), + ); + await Future.delayed(const Duration(milliseconds: 300)); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final summary = notifier.threadSummaries['unrelated']; + expect(summary?.descendantCount, 1); + expect(summary?.isCountPending, isTrue); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary?.replyCount, 1); + expect(entries.single.summary?.isCountPending, isTrue); + }, + ); + + for (final olderCount in [0, 3]) { + test( + 'pagination reconciles pinned-root query totals to $olderCount', + () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'newest', createdAt: 100), + _bounds(hasMore: true, cursorCreatedAt: 100, cursorId: 'newest'), + ], + [ + _event(id: 'old-root', createdAt: 10), + if (olderCount > 0) + _summary(rootId: 'old-root', replyCount: olderCount), + _bounds(dTag: '${_channelId.toLowerCase()}:100:newest'), + ], + if (olderCount == 0) [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final load = notifier.loadEventsById(['old-root']); + session.completeTargetHistory([_event(id: 'old-root', createdAt: 10)]); + await load; + notifier.cacheCompleteThreadQuery('old-root', {}, [ + _event( + id: 'old-reply', + createdAt: 20, + extraTags: const [ + ['e', 'old-root', '', 'reply'], + ], + ), + ]); + expect(notifier.threadSummaries['old-root']?.descendantCount, 1); + expect(await notifier.fetchOlder(), isTrue); + await Future.delayed(const Duration(milliseconds: 300)); + expect( + notifier.threadSummaries['old-root']?.descendantCount, + olderCount, + ); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries + .singleWhere((entry) => entry.message.id == 'old-root') + .summary + ?.replyCount, + olderCount == 0 ? null : olderCount, + ); + }, + ); + } + + test( + 'continued replies do not restart an active paginated recount', + () async { + final replies = [ + for (var i = 0; i < 603; i++) + _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final gates = [for (var i = 0; i < 4; i++) Completer>()]; + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + for (final gate in gates) gate.future, + for (var i = 0; i < 603; i += 200) replies.skip(i).take(200).toList(), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + for (final reply in replies.take(300)) { + session.emit(reply); + } + await Future.delayed(const Duration(milliseconds: 300)); + for (var page = 0; page < 3; page++) { + session.emit(replies[600 + page]); + gates[page].complete(replies.skip(page * 200).take(200).toList()); + await _pumpEventQueue(); + final scans = session.queryFilters + .where((filter) => filter.extensions.containsKey('depth_limit')) + .toList(); + expect(scans, hasLength(page + 2)); + expect( + scans.last.extensions['thread_cursor'], + replies[(page + 1) * 200 - 1].createdAt, + ); + } + gates.last.complete([]); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + expect(notifier.threadSummaries['root']?.descendantCount, 603); + expect(notifier.threadSummaries['root']?.isLowerBound, isTrue); + await Future.delayed(const Duration(milliseconds: 300)); + expect(notifier.threadSummaries['root']?.descendantCount, 603); + expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + hasLength(8), + ); + }, + ); + + for (final newest in [false, true]) { + test( + 'page launched before route query cannot supersede it (newest: $newest)', + () async { + final page = Completer>(); + final scan = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'newest', createdAt: 100), + _bounds(hasMore: true, cursorCreatedAt: 100, cursorId: 'newest'), + ], + page.future, + scan.future, + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final load = notifier.loadEventsById(['old-root']); + session.completeTargetHistory([_event(id: 'old-root', createdAt: 10)]); + await load; + Future? older; + if (newest) { + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await _pumpEventQueue(); + } else { + older = notifier.fetchOlder(); + } + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: 'old-root', + ); + container.listen(threadRepliesProvider(args), (_, _) {}); + final thread = container.read(threadRepliesProvider(args).future); + await _pumpEventQueue(); + page.complete([ + _event(id: 'old-root', createdAt: 10), + _bounds( + dTag: newest + ? '${_channelId.toLowerCase()}:head' + : '${_channelId.toLowerCase()}:100:newest', + ), + ]); + if (older != null) await older; + await _pumpEventQueue(); + scan.complete([ + _event( + id: 'reply', + createdAt: 20, + extraTags: const [ + ['e', 'old-root', '', 'reply'], + ], + ), + ]); + await thread; + expect( + session.historyFilters.where((filter) => filter.ids == null), + isEmpty, + ); + expect(notifier.threadSummaries['old-root']?.descendantCount, 1); + }, + ); + } + + test( + 'unknown deletion performs one ownership lookup without recount fan-out', + () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + for (var i = 39; i >= 0; i--) ...[ + _event(id: 'root-$i', createdAt: 10 + i), + _summary(rootId: 'root-$i', replyCount: 1), + ], + _bounds(), + ], + [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + for (final kind in [EventKind.deletion, EventKind.nip29DeleteEvent]) { + session.emit( + NostrEvent( + id: 'delete-$kind', + pubkey: 'author', + createdAt: 100, + kind: kind, + tags: const [ + ['h', _channelId], + ['e', 'unknown'], + ], + content: '', + sig: '', + ), + ); + } + await Future.delayed(const Duration(milliseconds: 300)); + expect( + session.queryFilters.where( + (filter) => filter.ids?.contains('unknown') ?? false, + ), + hasLength(1), + ); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + isEmpty, + ); + final summaries = container + .read(channelMessagesProvider(_channelId).notifier) + .threadSummaries; + expect(summaries, hasLength(40)); + expect( + summaries.values.every( + (summary) => summary.descendantCount == 1 && summary.isCountPending, + ), + isTrue, + ); + }, + ); + + test( + 'known deletion fences an older ownership response before recount', + () async { + final ownership = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + ownership.future, + [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheCompleteThreadQuery('root', {}, [ + _event( + id: 'known', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]); + for (final target in ['unknown', 'known']) { + session.emit( + NostrEvent( + id: 'delete-$target', + pubkey: 'author', + createdAt: 100, + kind: EventKind.deletion, + tags: [ + ['h', _channelId], + ['e', target], + ], + content: '', + sig: '', + ), + ); + } + ownership.complete([_summary(rootId: 'root', replyCount: 1)]); + await _pumpEventQueue(); + expect(notifier.threadSummaries['root']!.descendantCount, 0); + await Future.delayed(const Duration(milliseconds: 300)); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries.singleWhere((e) => e.message.id == 'root').summary, + isNull, + ); + }, + ); + + for (final source in ['overflow', 'fallback']) { + for (final outcome in ['recovered', 'exhausted', 'disposed']) { + test('thread recount retry $source: $outcome', () async { + final replies = [ + for (var i = 0; i < (source == 'overflow' ? 257 : 1); i++) + _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + if (source == 'fallback') Exception('NIP-CW unavailable'), + Exception('temporary recount outage'), + if (outcome == 'recovered') ...[ + if (source == 'overflow') ...[ + replies.take(200).toList(), + replies.skip(200).toList(), + ] else + [], + ] else ...[ + Exception('still unavailable'), + Exception('still unavailable'), + ], + ], + historyResults: [ + [_event(id: 'root', createdAt: 10)], + ], + ); + final container = _buildContainer(session); + if (outcome != 'disposed') addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (final reply in replies) { + session.emit(reply); + } + if (source == 'fallback') { + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + } + await Future.delayed(const Duration(milliseconds: 300)); + int scans() => session.queryFilters + .where((f) => f.extensions.containsKey('depth_limit')) + .length; + expect(scans(), 1); + if (outcome == 'disposed') container.dispose(); + await Future.delayed( + Duration(milliseconds: outcome == 'exhausted' ? 3800 : 800), + ); + expect( + scans(), + outcome == 'disposed' + ? 1 + : outcome == 'exhausted' || source == 'overflow' + ? 3 + : 2, + ); + if (outcome == 'disposed') return; + final summary = notifier.threadSummaries['root']!; + if (outcome == 'exhausted') { + expect(summary.isLowerBound, isTrue); + } else { + expect(summary.isLowerBound, isFalse); + expect(summary.isCountPending, isFalse); + expect(summary.descendantCount, source == 'overflow' ? 257 : 0); + if (source == 'fallback') { + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary, isNull); + } + } + }); + } + } + + for (final failureFirst in [false, true]) { + test( + 'failed route query does not fence successful page (failure first: $failureFirst)', + () async { + final page = Completer>(); + final route = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + page.future, + route.future, + [], + ], + ); + final container = ProviderContainer( + retry: (_, _) => null, + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheCompleteThreadQuery('root', {}, [ + _event( + id: 'deleted-offline', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]); + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await _pumpEventQueue(); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.listen(threadRepliesProvider(args), (_, _) {}); + final failure = expectLater( + container.read(threadRepliesProvider(args).future), + throwsException, + ); + await _pumpEventQueue(); + if (failureFirst) { + route.completeError(Exception('route query failed')); + await failure; + } + page.complete([_event(id: 'root', createdAt: 10), _bounds()]); + await _pumpEventQueue(); + if (!failureFirst) { + route.completeError(Exception('route query failed')); + await failure; + } + await Future.delayed(const Duration(milliseconds: 300)); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary, isNull); + expect(notifier.cachedThreadReplyIds('root'), isEmpty); + }, + ); + } + + for (final survives in [false, true]) { + test( + 'successful reconnect refreshes retained root (survives: $survives)', + () async { + final reply = _event( + id: 'old-reply', + createdAt: 20, + extraTags: const [ + ['e', 'retained', '', 'reply'], + ], + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'retained', createdAt: 10), _bounds()], + [_event(id: 'newest', createdAt: 200), _bounds()], + [if (survives) reply], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + await notifier.loadEventsById(['retained']); + notifier.cacheCompleteThreadQuery('retained', {}, [reply]); + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await Future.delayed(const Duration(milliseconds: 350)); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + expect( + notifier.threadSummaries['retained']?.descendantCount, + survives ? 1 : 0, + ); + expect(notifier.threadSummaries['retained']?.isCountPending, isFalse); + expect( + notifier.cachedThreadReplyIds('retained'), + survives ? {'old-reply'} : isEmpty, + ); + }, + ); + } + + for (final phase in ['queued', 'inflight', 'late', 'backoff', 'exhausted']) { + test('ownership recovery resumes across reconnect: $phase', () async { + final first = Completer>(); + final second = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'retained', createdAt: 10), + _summary(rootId: 'retained', replyCount: 1), + _bounds(), + ], + if (phase == 'backoff') + Exception('temporary outage') + else + first.future, + if (phase == 'queued') second.future, + [_event(id: 'newest', createdAt: 200), _bounds()], + if (phase == 'queued') ...[[], []], + if (phase == 'exhausted') ...[ + Exception('unavailable'), + Exception('unavailable'), + Exception('unavailable'), + ] else + [_summary(rootId: 'retained', replyCount: 0)], + if (phase != 'exhausted') [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + await notifier.loadEventsById(['retained']); + notifier.cacheCompleteThreadQuery('retained', {}, [ + for (var i = 0; i < 257; i++) + _event( + id: 'old-reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'retained', '', 'reply'], + ], + ), + ]); + + for (var i = 0; i < (phase == 'queued' ? 3 : 1); i++) { + session.emit( + NostrEvent( + id: 'delete-$i', + pubkey: 'author', + createdAt: 100 + i, + kind: EventKind.deletion, + tags: [ + ['h', _channelId], + ['e', 'unknown-$i'], + ], + content: '', + sig: '', + ), + ); + } + await _pumpEventQueue(); + session.setConnected(false); + await _pumpEventQueue(); + if (phase != 'backoff' && phase != 'late') first.complete([]); + if (phase == 'queued') second.complete([]); + await Future.delayed(const Duration(milliseconds: 600)); + int requests() => session.queryFilters + .where((f) => f.extensions['resolve_thread_roots'] == true) + .length; + expect( + requests(), + phase == 'queued' ? 2 : 1, + reason: 'no ownership work runs while disconnected', + ); + session.setConnected(true); + await _pumpEventQueue(); + if (phase == 'late') first.complete([]); + await Future.delayed( + Duration(milliseconds: phase == 'exhausted' ? 3800 : 400), + ); + expect( + requests(), + phase == 'queued' + ? 5 + : phase == 'exhausted' + ? 4 + : 2, + ); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + final retained = entries.singleWhere((e) => e.message.id == 'retained'); + if (phase == 'exhausted') { + expect(retained.summary!.isCountPending, isTrue); + } else { + expect( + retained.summary, + isNull, + reason: 'the off-window retained root is reconciled', + ); + } + }); + } + + for (final querySucceeds in [false, true]) { + test( + 'skipped ownership response preserves uncertainty (query succeeds: $querySucceeds)', + () async { + final ownership = Completer>(); + final threadQuery = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'root', createdAt: 10), + _summary(rootId: 'root', replyCount: 1), + _bounds(), + ], + ownership.future, + threadQuery.future, + ], + ); + final container = ProviderContainer( + retry: (_, _) => null, + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + session.emit( + NostrEvent( + id: 'delete-unknown', + pubkey: 'author', + createdAt: 100, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'unknown'], + ], + content: '', + sig: '', + ), + ); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.listen(threadRepliesProvider(args), (_, _) {}); + final result = container.read(threadRepliesProvider(args).future); + await _pumpEventQueue(); + ownership.complete([_summary(rootId: 'root', replyCount: 0)]); + await _pumpEventQueue(); + expect(notifier.threadSummaries['root']!.isCountPending, isTrue); + if (querySucceeds) { + threadQuery.complete([]); + await result; + } else { + final failure = expectLater(result, throwsException); + threadQuery.completeError(Exception('newer query failed')); + await failure; + } + await _pumpEventQueue(); + expect( + notifier.threadSummaries['root']!.isCountPending, + !querySucceeds, + ); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + if (querySucceeds) { + expect(entries.single.summary, isNull); + } else { + expect(entries.single.summary!.isCountPending, isTrue); + } + }, + ); + } + + for (final outcome in ['recovered', 'exhausted', 'disposed']) { + test('deletion ownership retries are bounded: $outcome', () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'root', createdAt: 10), + _summary(rootId: 'root', replyCount: 1), + _bounds(), + ], + Exception('temporary outage'), + if (outcome == 'recovered') + [_summary(rootId: 'root', replyCount: 0)] + else ...[ + Exception('still unavailable'), + Exception('still unavailable'), + ], + ], + ); + final container = _buildContainer(session); + if (outcome != 'disposed') addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final deletion = NostrEvent( + id: 'delete-unknown', + pubkey: 'author', + createdAt: 100, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'unknown'], + ], + content: '', + sig: '', + ); + session.emit(deletion); + await _pumpEventQueue(); + int requests() => session.queryFilters + .where((f) => f.extensions['resolve_thread_roots'] == true) + .length; + expect(requests(), 1); + expect(notifier.threadSummaries['root']!.isCountPending, isTrue); + // A replay is deduplicated, but must not prevent the scheduled recovery. + session.emit(deletion); + if (outcome == 'disposed') container.dispose(); + await Future.delayed( + Duration(milliseconds: outcome == 'exhausted' ? 3800 : 800), + ); + expect(requests(), switch (outcome) { + 'recovered' => 2, + 'disposed' => 1, + _ => 3, + }); + if (outcome == 'recovered') { + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary, isNull); + expect(notifier.threadSummaries['root']!.isCountPending, isFalse); + } else if (outcome == 'exhausted') { + expect(notifier.threadSummaries['root']!.isCountPending, isTrue); + } + }); + } + + for (final count in [150, 300]) { + test( + 'large deletion event queues $count targets within shared bounds', + () async { + final first = Completer>(); + final second = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'root', createdAt: 10), + _summary(rootId: 'root', replyCount: 1), + _bounds(), + ], + first.future, + second.future, + for (var i = 2; i < count; i++) + i == count - 1 + ? [_summary(rootId: 'root', replyCount: 0)] + : [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final deletion = NostrEvent( + id: 'large-delete', + pubkey: 'author', + createdAt: 100, + kind: EventKind.deletion, + tags: [ + ['h', _channelId], + for (var i = 0; i < count; i++) ['e', 'unknown-$i'], + ], + content: '', + sig: '', + ); + session.emit(deletion); + int requests() => session.queryFilters + .where((f) => f.extensions['resolve_thread_roots'] == true) + .length; + await _pumpEventQueue(); + expect(requests(), 2); + first.complete([]); + second.complete([]); + await _pumpEventQueue(); + expect(requests(), count); + if (count == 150) { + expect(notifier.threadSummaries['root']!.descendantCount, 0); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary, isNull); + } else { + expect(notifier.threadSummaries['root']!.descendantCount, 0); + expect(notifier.threadSummaries['root']!.isCountPending, isFalse); + session.emit(deletion); + await _pumpEventQueue(); + expect( + requests(), + count, + reason: 'admitted targets stay deduplicated', + ); + } + }, + ); + } + + test('deferred ownership keeps its original evidence version', () async { + final first = Completer>(); + final second = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'root', createdAt: 10), + _summary(rootId: 'root', replyCount: 1), + _bounds(), + ], + first.future, + second.future, + for (var i = 2; i < 258; i++) [], + [_summary(rootId: 'root', replyCount: 0)], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + session.emit( + NostrEvent( + id: 'large-delete', + pubkey: 'author', + createdAt: 100, + kind: EventKind.deletion, + tags: [ + ['h', _channelId], + for (var i = 0; i < 259; i++) ['e', 'unknown-$i'], + ], + content: '', + sig: '', + ), + ); + session.emit(_summary(rootId: 'root', replyCount: 1)); + first.complete([]); + second.complete([]); + await _pumpEventQueue(); + expect( + session.queryFilters.where( + (f) => f.extensions['resolve_thread_roots'] == true, + ), + hasLength(259), + ); + final summary = container + .read(channelMessagesProvider(_channelId).notifier) + .threadSummaries['root']!; + expect(summary.descendantCount, 1); + expect(summary.isCountPending, isFalse); + }); + + for (final disposeEarly in [false, true]) { + test( + 'ownership recovery bounds deletion bursts (dispose: $disposeEarly)', + () async { + final first = Completer>(); + final second = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'root', createdAt: 10), + _summary(rootId: 'root', replyCount: 1), + _bounds(), + ], + first.future, + second.future, + for (var i = 0; i < 298; i++) [], + ], + ); + final container = _buildContainer(session); + if (!disposeEarly) addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (var i = 0; i < 300; i++) { + session.emit( + NostrEvent( + id: 'delete-$i', + pubkey: 'author', + createdAt: 100 + i, + kind: EventKind.deletion, + tags: [ + ['h', _channelId], + ['e', 'unknown-$i'], + ], + content: '', + sig: '', + ), + ); + } + int requests() => session.queryFilters + .where((f) => f.extensions['resolve_thread_roots'] == true) + .length; + await _pumpEventQueue(); + expect( + requests(), + 2, + reason: 'requests share one concurrency budget across events', + ); + if (disposeEarly) container.dispose(); + first.complete([]); + second.complete([]); + await _pumpEventQueue(); + expect( + requests(), + disposeEarly ? 2 : 300, + reason: 'deferred targets drain only while mounted', + ); + if (!disposeEarly) { + expect(notifier.threadSummaries['root']!.isCountPending, isTrue); + session.emit(_summary(rootId: 'root', replyCount: 1)); + expect(notifier.threadSummaries['root']!.isCountPending, isFalse); + } + }, + ); + } + + for (final newestFirst in [false, true]) { + test( + 'overlapping deletion lookups preserve start order (newest first: $newestFirst)', + () async { + final first = Completer>(); + final second = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + for (final id in ['owner', 'unrelated']) ...[ + _event(id: id, createdAt: 10), + _summary(rootId: id, replyCount: 2), + ], + _bounds(), + ], + first.future, + second.future, + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (var i = 0; i < 2; i++) { + session.emit( + NostrEvent( + id: 'delete-$i', + pubkey: 'author', + createdAt: 100 + i, + kind: EventKind.deletion, + tags: [ + ['h', _channelId], + ['e', 'unknown-$i'], + ], + content: '', + sig: '', + ), + ); + } + await _pumpEventQueue(); + expect(notifier.threadSummaries['unrelated']!.isCountPending, isTrue); + if (newestFirst) { + second.complete([_summary(rootId: 'owner', replyCount: 0)]); + } else { + first.complete([_summary(rootId: 'owner', replyCount: 1)]); + } + await _pumpEventQueue(); + expect(notifier.threadSummaries['unrelated']!.isCountPending, isTrue); + if (newestFirst) { + first.complete([_summary(rootId: 'owner', replyCount: 1)]); + } else { + second.complete([_summary(rootId: 'owner', replyCount: 0)]); + } + await _pumpEventQueue(); + expect(notifier.threadSummaries['owner']!.descendantCount, 0); + expect(notifier.threadSummaries['unrelated']!.descendantCount, 2); + expect(notifier.threadSummaries['unrelated']!.isCountPending, isFalse); + expect(notifier.threadSummaries['unrelated']!.isLowerBound, isFalse); + expect(notifier.threadSummaries['owner']!.isCountPending, isFalse); + }, + ); + } + + for (final secondResolves in [true, false]) { + test( + 'multi-target deletion correlates shared roots (second resolves: $secondResolves)', + () async { + final first = Completer>(); + final second = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + for (final id in ['owner', 'unrelated']) ...[ + _event(id: id, createdAt: 10), + _summary(rootId: id, replyCount: 2), + ], + _bounds(), + ], + first.future, + second.future, + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + session.emit( + NostrEvent( + id: 'multi-delete', + pubkey: 'author', + createdAt: 100, + kind: EventKind.nip29DeleteEvent, + tags: const [ + ['h', _channelId], + ['e', 'first-target'], + ['e', 'second-target'], + ], + content: '', + sig: '', + ), + ); + await _pumpEventQueue(); + first.complete([_summary(rootId: 'owner', replyCount: 1)]); + await _pumpEventQueue(); + expect( + notifier.threadSummaries['unrelated']!.isCountPending, + isTrue, + reason: 'the queued sibling still has unresolved ownership', + ); + expect(notifier.threadSummaries['owner']!.isCountPending, isTrue); + second.complete( + secondResolves ? [_summary(rootId: 'owner', replyCount: 0)] : [], + ); + await _pumpEventQueue(); + expect(notifier.threadSummaries['unrelated']!.descendantCount, 2); + expect( + notifier.threadSummaries['unrelated']!.isCountPending, + !secondResolves, + ); + expect( + notifier.threadSummaries['owner']!.descendantCount, + secondResolves ? 0 : 1, + ); + final requests = session.queryFilters.where( + (f) => f.extensions['resolve_thread_roots'] == true, + ); + expect(requests.map((f) => f.ids!.single), [ + 'first-target', + 'second-target', + ]); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries.singleWhere((e) => e.message.id == 'owner').summary == null, + secondResolves, + ); + }, + ); + } + + test( + 'failed overlapping ownership lookup keeps uncertainty until fresh summary', + () async { + final first = Completer>(); + final second = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + for (final id in ['owner', 'unrelated']) ...[ + _event(id: id, createdAt: 10), + _summary(rootId: id, replyCount: 2), + ], + _bounds(), + ], + first.future, + second.future, + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (var i = 0; i < 2; i++) { + session.emit( + NostrEvent( + id: 'delete-$i', + pubkey: 'author', + createdAt: 100 + i, + kind: EventKind.deletion, + tags: [ + ['h', _channelId], + ['e', 'unknown-$i'], + ], + content: '', + sig: '', + ), + ); + } + first.completeError(Exception('ownership unavailable')); + await _pumpEventQueue(); + second.complete([_summary(rootId: 'owner', replyCount: 0)]); + await _pumpEventQueue(); + expect(notifier.threadSummaries['owner']!.descendantCount, 0); + expect(notifier.threadSummaries['unrelated']!.isCountPending, isTrue); + session.emit(_summary(rootId: 'unrelated', replyCount: 2)); + expect(notifier.threadSummaries['unrelated']!.isCountPending, isFalse); + expect(notifier.threadSummaries['unrelated']!.isLowerBound, isFalse); + }, + ); + + test( + 'unscoped deletion evidence requires a matching queried target', + () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (final kind in [EventKind.deletion, EventKind.nip29DeleteEvent]) { + final deletion = NostrEvent( + id: 'delete-$kind', + pubkey: 'author', + createdAt: 30, + kind: kind, + tags: const [ + ['e', 'other-target'], + ], + content: '', + sig: '', + ); + expect( + () => notifier.cacheThreadDeletions( + [deletion], + scopedTargetIds: {'queried-target'}, + ), + throwsStateError, + ); + } + }, + ); + + for (final survives in [false, true]) { + test( + 'initial page reconciles earlier outer evidence (survives: $survives)', + () async { + final page = Completer>(); + final broadcast = _event( + id: 'broadcast', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'root', '', 'reply'], + ['broadcast', '1'], + ], + ); + final outer = _summary( + rootId: 'root', + replyCount: 1, + descendantCount: survives ? 2 : 1, + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + page.future, + [outer], + [ + broadcast, + if (survives) + _event( + id: 'survivor', + createdAt: 30, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'broadcast', '', 'reply'], + ], + ), + ], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + session.emit(outer); + session.emit( + NostrEvent( + id: 'delete-child', + pubkey: 'author', + createdAt: 40, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'deleted-child'], + ], + content: '', + sig: '', + ), + ); + await _pumpEventQueue(); + expect( + session.queryFilters.where( + (f) => f.extensions['resolve_thread_roots'] == true, + ), + hasLength(1), + ); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + isEmpty, + ); + page.complete([ + broadcast, + _summary( + rootId: 'broadcast', + replyCount: survives ? 2 : 1, + descendantCount: 0, + ), + _bounds(), + ]); + await Future.delayed(const Duration(milliseconds: 350)); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final events = container + .read(channelMessagesProvider(_channelId)) + .value!; + expect(events.any((e) => e.id == 'root'), isFalse); + final entries = buildMainTimelineEntries( + formatTimeline(events), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries + .singleWhere((e) => e.message.id == 'broadcast') + .summary + ?.replyCount, + survives ? 1 : null, + ); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + }, + ); + } + + test( + 'unchanged outer evidence does not repeat an exact nested recount', + () async { + final broadcast = _event( + id: 'broadcast', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'root', '', 'reply'], + ['broadcast', '1'], + ], + ); + final child = _event( + id: 'child', + createdAt: 30, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'broadcast', '', 'reply'], + ], + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + broadcast, + _event(id: 'root', createdAt: 10), + _summary(rootId: 'broadcast', replyCount: 1, descendantCount: 0), + _bounds(), + ], + [broadcast, child], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + session.emit(_summary(rootId: 'root', replyCount: 1, descendantCount: 2)); + await Future.delayed(const Duration(milliseconds: 350)); + expect(notifier.threadSummaries['broadcast']?.replyCount, 1); + for (var i = 0; i < 3; i++) { + session.emit( + _summary( + rootId: 'root', + replyCount: 1, + descendantCount: 2, + createdAt: 40 + i, + ), + ); + await Future.delayed(const Duration(milliseconds: 250)); + } + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + expect(notifier.threadSummaries['broadcast']?.isCountPending, isFalse); + }, + ); + + for (final lookupFails in [false, true]) { + test( + 'direct-only broadcast deletion stays uncertain (lookup fails: $lookupFails)', + () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event( + id: 'broadcast', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'root', '', 'reply'], + ['broadcast', '1'], + ], + ), + _summary(rootId: 'broadcast', replyCount: 1, descendantCount: 0), + _bounds(), + ], + if (lookupFails) + for (var i = 0; i < 3; i++) Exception('ownership unavailable') + else + [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + session.emit( + NostrEvent( + id: 'delete-evicted-child', + pubkey: 'author', + createdAt: 30, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'evicted-child'], + ], + content: '', + sig: 'sig', + ), + ); + expect(notifier.threadSummaries['broadcast']?.isCountPending, isTrue); + await Future.delayed( + Duration(milliseconds: lookupFails ? 1800 : 50), + ); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary?.isCountPending, isTrue); + expect(entries.single.summary?.isLowerBound, isTrue); + expect( + session.queryFilters.where( + (f) => f.extensions['resolve_thread_roots'] == true, + ), + hasLength(lookupFails ? 3 : 1), + ); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + isEmpty, + ); + }, + ); + } + + for (final rootVisible in [false, true]) { + for (final source in ['known', 'owner', 'live']) { + for (final survives in [false, true]) { + test( + 'nested deletion reconciles broadcast summary (root visible: $rootVisible, source: $source, survives: $survives)', + () async { + final broadcast = _event( + id: 'broadcast', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'root', '', 'reply'], + ['broadcast', '1'], + ], + ); + NostrEvent child(String id) => _event( + id: id, + createdAt: 30, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'broadcast', '', 'reply'], + ], + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + broadcast, + if (rootVisible) _event(id: 'root', createdAt: 10), + if (rootVisible) + _summary( + rootId: 'root', + replyCount: 1, + descendantCount: survives ? 3 : 2, + ), + _summary( + rootId: 'broadcast', + replyCount: survives ? 2 : 1, + descendantCount: 0, + ), + _bounds(), + ], + if (source == 'owner') + [_summary(rootId: 'root', replyCount: survives ? 2 : 1)], + [broadcast, if (survives) child('survivor')], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + if (source == 'known') { + notifier.cacheConfirmedThreadReplies([child('deleted-child')]); + } + expect( + container + .read(channelMessagesProvider(_channelId)) + .value! + .any((e) => e.id == 'root'), + rootVisible, + ); + session.emit( + source == 'live' + ? _summary( + rootId: 'root', + replyCount: 1, + descendantCount: survives ? 2 : 1, + ) + : NostrEvent( + id: 'delete-child', + pubkey: 'author', + createdAt: 40, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'deleted-child'], + ], + content: '', + sig: '', + ), + ); + await Future.delayed(const Duration(milliseconds: 350)); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries + .singleWhere((e) => e.message.id == 'broadcast') + .summary + ?.replyCount, + survives ? 1 : null, + ); + }, + ); + } + } + } + + for (final fails in [false, true]) { + test('nested broadcast recount remains honest (fails: $fails)', () async { + final recount = Completer>(); + final broadcast = _event( + id: 'broadcast', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'root', '', 'reply'], + ['broadcast', '1'], + ], + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + broadcast, + _event(id: 'root', createdAt: 10), + _summary(rootId: 'root', replyCount: 1, descendantCount: 2), + _summary(rootId: 'broadcast', replyCount: 1, descendantCount: 0), + _bounds(), + ], + recount.future, + if (fails) ...[Exception('offline'), Exception('offline')], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + // The deleted child was evicted. Only the outer-root update is delivered. + session.emit(_summary(rootId: 'root', replyCount: 1, descendantCount: 1)); + expect(notifier.threadSummaries['broadcast']!.isCountPending, isTrue); + expect(notifier.threadSummaries['broadcast']!.isLowerBound, isTrue); + await Future.delayed(const Duration(milliseconds: 300)); + if (fails) { + recount.completeError(Exception('offline')); + } else { + recount.complete([broadcast]); + } + await Future.delayed(Duration(milliseconds: fails ? 2000 : 100)); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + final summary = entries + .singleWhere((e) => e.message.id == 'broadcast') + .summary; + if (fails) { + expect(summary!.isCountPending, isTrue); + expect(summary.isLowerBound, isTrue); + } else { + expect(summary, isNull); + expect(notifier.threadSummaries['broadcast']!.isCountPending, isFalse); + } + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + hasLength(fails ? 3 : 1), + ); + }); + } + + test('deleted-target metadata disables only the owning empty root', () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + for (final id in ['owner', 'unrelated']) ...[ + _event(id: id, createdAt: 10), + _summary(rootId: id, replyCount: 1), + ], + _bounds(), + ], + _DeletedTargetSummaryResponse(_summary(rootId: 'owner', replyCount: 0)), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + session.emit( + NostrEvent( + id: 'delete', + pubkey: 'author', + createdAt: 100, + kind: EventKind.deletion, + tags: const [ + ['h', _channelId], + ['e', 'unknown'], + ], + content: '', + sig: '', + ), + ); + await Future.delayed(const Duration(milliseconds: 300)); + final scans = session.queryFilters + .where((filter) => filter.extensions.containsKey('depth_limit')) + .toList(); + expect(scans, isEmpty); + final summaries = container + .read(channelMessagesProvider(_channelId).notifier) + .threadSummaries; + expect(summaries['owner']?.descendantCount, 0); + expect(summaries['unrelated']?.descendantCount, 1); + expect(summaries['unrelated']?.isCountPending, isFalse); + expect(summaries['unrelated']?.isLowerBound, isFalse); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: summaries, + ); + expect( + entries.singleWhere((entry) => entry.message.id == 'owner').summary, + isNull, + ); + expect( + entries + .singleWhere((entry) => entry.message.id == 'unrelated') + .summary + ?.replyCount, + 1, + ); + }); + + for (final recountFirst in [false, true]) { + test( + 'pagination cannot revive a rejected live summary (recount first: $recountFirst)', + () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'newest', createdAt: 100), + _bounds(hasMore: true, cursorCreatedAt: 100, cursorId: 'newest'), + ], + if (recountFirst) [], + [ + _event(id: 'old-root', createdAt: 10), + _bounds(dTag: '${_channelId.toLowerCase()}:100:newest'), + ], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final load = notifier.loadEventsById(['old-root']); + session.completeTargetHistory([_event(id: 'old-root', createdAt: 10)]); + await load; + notifier.cacheCompleteThreadQuery('old-root', {}, []); + session.emit(_summary(rootId: 'old-root', replyCount: 1)); + expect(notifier.threadSummaries['old-root']!.descendantCount, 0); + if (recountFirst) { + await Future.delayed(const Duration(milliseconds: 300)); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + expect(notifier.threadSummaries['old-root']!.descendantCount, 0); + } + expect(await notifier.fetchOlder(), isTrue); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries.singleWhere((e) => e.message.id == 'old-root').summary, + isNull, + ); + }, + ); + } + + for (final newReplyExists in [false, true]) { + test( + 'live summary after empty older page is reconciled (new reply: $newReplyExists)', + () async { + final recount = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'newest', createdAt: 100), + _bounds(hasMore: true, cursorCreatedAt: 100, cursorId: 'newest'), + ], + [ + _event(id: 'root', createdAt: 10), + _bounds(dTag: '${_channelId.toLowerCase()}:100:newest'), + ], + recount.future, + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + expect(await notifier.fetchOlder(), isTrue); + session.emit(_summary(rootId: 'root', replyCount: 1)); + expect(notifier.threadSummaries['root']!.descendantCount, 0); + await Future.delayed(const Duration(milliseconds: 300)); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + recount.complete([ + if (newReplyExists) + _event( + id: 'new-reply', + createdAt: 30, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]); + await _pumpEventQueue(); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries + .singleWhere((e) => e.message.id == 'root') + .summary + ?.replyCount, + newReplyExists ? 1 : null, + ); + }, + ); + } + + for (final newReplyExists in [false, true]) { + test( + 'live summary after empty scan is reconciled (new reply: $newReplyExists)', + () async { + final recount = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + recount.future, + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheCompleteThreadQuery('root', {}, []); + session.emit(_summary(rootId: 'root', replyCount: 1)); + expect(notifier.threadSummaries['root']!.descendantCount, 0); + await Future.delayed(const Duration(milliseconds: 300)); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + recount.complete([ + if (newReplyExists) + _event( + id: 'new-reply', + createdAt: 30, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]); + await _pumpEventQueue(); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect(entries.single.summary?.replyCount, newReplyExists ? 1 : null); + }, + ); + } + + for (final stillExists in [false, true]) { + test( + 'live summary reconciles contradictory cached replies (exists: $stillExists)', + () async { + final reply = _event( + id: 'cached-reply', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + stillExists ? [reply] : [], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheCompleteThreadQuery('root', {}, [reply]); + session.emit(_summary(rootId: 'root', replyCount: 0)); + await Future.delayed(const Duration(milliseconds: 300)); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries.singleWhere((e) => e.message.id == 'root').summary == null, + !stillExists, + ); + expect( + notifier.cachedThreadReplyIds('root').contains('cached-reply'), + stillExists, + ); + expect(notifier.threadSummaries['root']!.isCountPending, isFalse); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + }, + ); + } + + for (final stillExists in [false, true]) { + test( + 'WebSocket fallback recounts payload-only roots (exists: $stillExists)', + () async { + final reply = _event( + id: 'live-reply', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + Exception('NIP-CW unavailable'), + stillExists ? [reply] : [], + ], + historyResults: [ + [_event(id: 'root', createdAt: 10)], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + session.emit(reply); + expect( + notifier.threadSummaries, + isEmpty, + reason: 'only reply payloads supply the initial badge', + ); + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await Future.delayed(const Duration(milliseconds: 300)); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries.singleWhere((e) => e.message.id == 'root').summary == null, + !stillExists, + ); + expect( + notifier.cachedThreadReplyIds('root').contains('live-reply'), + stillExists, + ); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + }, + ); + } + + test('fallback global eviction preserves another root summary', () async { + final reply = _event( + id: 'old-reply', + createdAt: 20, + extraTags: const [ + ['e', 'old-root', '', 'reply'], + ], + ); + final recount = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [Exception('NIP-CW unavailable'), recount.future], + historyResults: [ + [ + _event(id: 'old-root', createdAt: 10), + reply, + for (var i = 0; i < 16; i++) _event(id: 'other-$i', createdAt: 10), + ], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + for (var i = 0; i < 2048; i++) { + session.emit( + _event( + id: 'new-$i', + createdAt: 100 + i, + extraTags: [ + ['e', 'other-${i ~/ 128}', '', 'reply'], + ], + ), + ); + } + expect(notifier.cachedThreadReplyIds('old-root'), isEmpty); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries + .singleWhere((e) => e.message.id == 'old-root') + .summary + ?.replyCount, + 1, + ); + await Future.delayed(const Duration(milliseconds: 300)); + final scans = session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ); + expect(scans, hasLength(1)); + expect(scans.single.tags['#e'], ['old-root']); + recount.complete([reply]); + await _pumpEventQueue(); + expect(notifier.threadSummaries['old-root']?.descendantCount, 1); + expect(notifier.threadSummaries['old-root']?.isLowerBound, isFalse); + }); + + for (final fromHistory in [false, true]) { + for (final survives in [false, true]) { + test( + 'fallback reply snapshot reconciles (history: $fromHistory, survives: $survives)', + () async { + final reply = _event( + id: 'fallback-reply', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final scan = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [Exception('NIP-CW unavailable'), scan.future], + historyResults: [ + [_event(id: 'root', createdAt: 10), if (fromHistory) reply], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + if (!fromHistory) session.emit(reply); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.listen(threadRepliesProvider(args), (_, _) {}); + final result = container.read(threadRepliesProvider(args).future); + scan.complete([if (survives) reply]); + await result; + final events = container + .read(channelMessagesProvider(_channelId)) + .value!; + expect(events.any((e) => e.id == reply.id), survives); + final entries = buildMainTimelineEntries( + formatTimeline(events), + relaySummaries: notifier.threadSummaries, + ); + expect( + entries + .singleWhere((e) => e.message.id == 'root') + .summary + ?.replyCount, + survives ? 1 : null, + ); + expect( + notifier.cachedThreadReplyIds('root').contains(reply.id), + survives, + ); + }, + ); + } + } + + for (final outcome in ['deleted', 'surviving', 'unavailable']) { + test( + 'WebSocket fallback reconciles cached thread truth: $outcome', + () async { + final reply = _event( + id: 'cached-reply', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final recount = Completer>(); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + Exception('NIP-CW unavailable'), + recount.future, + ], + historyResults: [ + [_event(id: 'root', createdAt: 10)], + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheCompleteThreadQuery('root', {}, [reply]); + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await _pumpEventQueue(); + if (outcome != 'deleted') { + expect(notifier.threadSummaries['root']!.isCountPending, isTrue); + } + await Future.delayed(const Duration(milliseconds: 250)); + if (outcome == 'unavailable') { + recount.completeError(Exception('thread query unavailable')); + } else { + recount.complete(outcome == 'deleted' ? [] : [reply]); + } + await _pumpEventQueue(); + final entries = buildMainTimelineEntries( + formatTimeline( + container.read(channelMessagesProvider(_channelId)).value!, + ), + relaySummaries: notifier.threadSummaries, + ); + final summary = entries + .singleWhere((entry) => entry.message.id == 'root') + .summary; + if (outcome == 'deleted') { + expect(summary, isNull); + expect(notifier.cachedThreadReplyIds('root'), isEmpty); + } else { + expect(summary, isNotNull); + expect(summary!.replyCount, 1); + expect( + notifier.threadSummaries['root']!.isCountPending, + outcome == 'unavailable', + ); + } + await Future.delayed(const Duration(milliseconds: 250)); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + }, + ); + } + + test( + 'slow fallback history cannot supersede a newer complete thread query', + () async { + final reply = _event( + id: 'cached-reply', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + Exception('NIP-CW unavailable'), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheCompleteThreadQuery('root', {}, [reply]); + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await _pumpEventQueue(); + final snapshot = notifier.cachedThreadReplyIds('root'); + final version = notifier.beginThreadQuery('root'); + notifier.cacheCompleteThreadQuery( + 'root', + snapshot, + [], + queryVersion: version, + ); + session.completeHistory([_event(id: 'root', createdAt: 10)]); + await Future.delayed(const Duration(milliseconds: 300)); + expect(notifier.threadSummaries['root']!.descendantCount, 0); + expect(notifier.threadSummaries['root']!.isCountPending, isFalse); + expect( + session.queryFilters.where( + (f) => f.extensions.containsKey('depth_limit'), + ), + isEmpty, + ); + }, + ); + + test('reconnect pruning preserves duplicate deletion idempotence', () async { + final survivor = _event( + id: 'survivor', + createdAt: 21, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'root', createdAt: 10), + _summary(rootId: 'root', replyCount: 2), + _bounds(), + ], + [survivor], + [ + _event(id: 'root', createdAt: 10), + // A new reply arrived while disconnected; its payload is not cached. + _summary(rootId: 'root', replyCount: 2), + _bounds(), + ], + Exception('recount unavailable'), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheConfirmedThreadReplies([ + _event( + id: 'target', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]); + NostrEvent deletion(int kind) => NostrEvent( + id: 'delete-$kind', + pubkey: 'author', + createdAt: 100, + kind: kind, + tags: const [ + ['h', _channelId], + ['e', 'target'], + ], + content: '', + sig: '', + ); + final snapshot = notifier.cachedThreadReplyIds('root'); + session.emit(deletion(EventKind.deletion)); + final queryVersion = notifier.beginThreadQuery('root'); + notifier.cacheCompleteThreadQuery('root', snapshot, [ + survivor, + ], queryVersion: queryVersion); + await Future.delayed(const Duration(milliseconds: 300)); + expect(notifier.cachedThreadReplyIds('root'), isNot(contains('target'))); + session.setConnected(false); + await _pumpEventQueue(); + session.setConnected(true); + await _pumpEventQueue(); + session.emit(_event(id: 'after-reconnect', createdAt: 101)); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value! + .any((event) => event.id == 'delete-5'), + isFalse, + reason: 'the marker payload was pruned on reconnect', + ); + expect(notifier.threadSummaries['root']!.descendantCount, 2); + session.emit(deletion(EventKind.nip29DeleteEvent)); + await Future.delayed(const Duration(milliseconds: 300)); + expect(notifier.threadSummaries['root']!.descendantCount, 2); + expect(notifier.threadSummaries['root']!.isLowerBound, isFalse); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + }); + + for (final fetched in [false, true]) { + test( + 'duplicate deletion markers decrement a partial summary only once (fetched: $fetched)', + () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'root', createdAt: 10), + _summary(rootId: 'root', replyCount: 2), + _bounds(), + ], + Exception('recount unavailable'), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.cacheConfirmedThreadReplies([ + _event( + id: 'target', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]); + for (final kind in [ + EventKind.deletion, + EventKind.deletion, + EventKind.nip29DeleteEvent, + ]) { + final deletion = NostrEvent( + id: 'delete-$kind', + pubkey: 'author', + createdAt: 100, + kind: kind, + tags: const [ + ['h', _channelId], + ['e', 'target'], + ], + content: '', + sig: '', + ); + if (fetched) { + notifier.cacheThreadDeletions([deletion]); + } else { + session.emit(deletion); + } + } + await Future.delayed(const Duration(milliseconds: 300)); + expect(notifier.threadSummaries['root']?.descendantCount, 1); + expect( + session.queryFilters.where( + (filter) => filter.extensions.containsKey('depth_limit'), + ), + hasLength(1), + ); + }, + ); + } + test('a reply newer than the relay recount raises the badge', () async { final relaySession = _RecordingRelaySessionNotifier( queryResults: [ @@ -1135,6 +5247,7 @@ NostrEvent _huddleEvent({ NostrEvent _summary({ required String rootId, required int replyCount, + int? descendantCount, int createdAt = 20, }) { return NostrEvent( @@ -1148,7 +5261,7 @@ NostrEvent _summary({ ], content: jsonEncode({ 'reply_count': replyCount, - 'descendant_count': replyCount, + 'descendant_count': descendantCount ?? replyCount, 'last_reply_at': 20, 'participants': ['alice'], }), @@ -1185,6 +5298,11 @@ Future _pumpEventQueue() async { await Future.delayed(Duration.zero); } +class _DeletedTargetSummaryResponse { + final NostrEvent summary; + _DeletedTargetSummaryResponse(this.summary); +} + class _RecordingRelaySessionNotifier extends RelaySessionNotifier { final bool failSubscribe; final Queue _queryResults; @@ -1226,6 +5344,12 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier { if (_queryResults.isEmpty) throw Exception('unsupported'); final result = _queryResults.removeFirst(); if (result is Exception) throw result; + if (result is _DeletedTargetSummaryResponse) { + // Normal history cannot return soft-deleted reply payloads. + return filters.single.extensions['resolve_thread_roots'] == true + ? [result.summary] + : []; + } if (result is Future>) return await result; return (result as List).toList(); } diff --git a/mobile/test/features/channels/thread_replies_provider_test.dart b/mobile/test/features/channels/thread_replies_provider_test.dart index 419bd1e533..b23f475c28 100644 --- a/mobile/test/features/channels/thread_replies_provider_test.dart +++ b/mobile/test/features/channels/thread_replies_provider_test.dart @@ -8,6 +8,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; class _FakeRelaySession extends RelaySessionNotifier { int queryCount = 0; + bool honorDepthLimit = false; + final filtersSeen = []; List replies = const []; Completer>? nextQueryGate; @@ -24,11 +26,16 @@ class _FakeRelaySession extends RelaySessionNotifier { Duration timeout = const Duration(seconds: 8), }) async { queryCount++; + filtersSeen.addAll(filters); final gate = nextQueryGate; if (gate != null) { nextQueryGate = null; return gate.future; } + if (honorDepthLimit) { + final depth = filters.single.extensions['depth_limit'] as int; + return replies.where((event) => event.createdAt <= depth).toList(); + } return replies; } } @@ -49,6 +56,85 @@ NostrEvent _reply(String id, int createdAt) => NostrEvent( void main() { const args = ThreadRepliesArgs(channelId: 'chan', rootId: 'root'); + test('complete scan includes a legal 80-deep reply chain', () async { + final session = _FakeRelaySession() + ..honorDepthLimit = true + ..replies = [ + for (var depth = 1; depth <= 80; depth++) + NostrEvent( + id: 'reply-$depth', + pubkey: 'bob', + createdAt: depth, + kind: EventKind.streamMessage, + tags: [ + ['h', 'chan'], + ['e', 'root', '', 'root'], + ['e', depth == 1 ? 'root' : 'reply-${depth - 1}', '', 'reply'], + ], + content: '', + sig: '', + ), + ]; + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + container.listen(threadRepliesProvider(args), (_, _) {}); + expect( + await container.read(threadRepliesProvider(args).future), + hasLength(80), + ); + }); + + test( + 'origin cursor includes epoch-zero events in an exhaustive scan', + () async { + final session = _FakeRelaySession()..replies = [_reply('epoch', 0)]; + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + container.listen(threadRepliesProvider(args), (_, _) {}); + expect( + (await container.read( + threadRepliesProvider(args).future, + )).single.createdAt, + 0, + ); + expect(session.filtersSeen.single.extensions['thread_cursor'], -1); + expect( + session.filtersSeen.single.extensions['thread_cursor_id'], + '0' * 64, + ); + }, + ); + + test( + 'disposes empty local overlays after their last listener leaves', + () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + final provider = threadLocalRepliesProvider(args); + final subscription = container.listen(provider, (_, _) {}); + subscription.close(); + await container.pump(); + expect(container.exists(provider), isFalse); + }, + ); + + test('retains local replies without listeners until confirmation', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + final provider = threadLocalRepliesProvider(args); + final notifier = container.read(provider.notifier); + notifier.add(_reply('pending', 1000)); + await container.pump(); + expect(container.read(provider).single.id, 'pending'); + notifier.confirm({'pending'}); + await container.pump(); + expect(container.exists(provider), isFalse); + }); + (ProviderContainer, _FakeRelaySession, ProviderSubscription) makeHarness(List initialReplies) { final fakeSession = _FakeRelaySession()..replies = initialReplies; diff --git a/mobile/test/features/channels/thread_reply_ownership_test.dart b/mobile/test/features/channels/thread_reply_ownership_test.dart new file mode 100644 index 0000000000..1088bc75c4 --- /dev/null +++ b/mobile/test/features/channels/thread_reply_ownership_test.dart @@ -0,0 +1,30 @@ +import 'package:buzz/features/channels/thread_reply_ownership.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'identity evidence is bounded while keeping recent evicted-payload owners', + () { + final owners = ThreadReplyOwnership(); + owners.record([ + for (var i = 0; i < 8193; i++) + NostrEvent( + id: 'reply-$i', + pubkey: 'author', + createdAt: i, + kind: EventKind.streamMessage, + tags: const [ + ['h', 'channel'], + ['e', 'root', '', 'reply'], + ], + content: '', + sig: '', + ), + ]); + expect(owners.rootFor('reply-0'), isNull); + expect(owners.rootFor('reply-1'), 'root'); + expect(owners.rootFor('reply-8192'), 'root'); + }, + ); +} diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart index 71ed82d0e1..7db7e3e4aa 100644 --- a/mobile/test/features/channels/timeline_message_test.dart +++ b/mobile/test/features/channels/timeline_message_test.dart @@ -1050,6 +1050,28 @@ void main() { expect(entries.single.summary!.replyCount, 5); }); + test('relay direct replies are a lower bound for a broadcast row', () { + final messages = formatTimeline([ + _textMsg(id: 'a'), + _replyMsg( + id: 'broadcast', + parentId: 'a', + extraTags: const [ + ['broadcast', '1'], + ], + ), + ]); + final entries = buildMainTimelineEntries( + messages, + relaySummaries: { + 'broadcast': relaySummary(replyCount: 1, descendantCount: 0), + }, + ); + final summary = entries.last.summary!; + expect(summary.replyCount, 1); + expect(summary.isLowerBound, isTrue); + }); + test('a nested reply badges the reply it answers', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), From a3117a4762e3349054474e221c2e9cc5fea60dd9 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 21 Sep 2026 11:12:38 -0700 Subject: [PATCH 19/22] fix(desktop): register macOS badges for new and existing installs (#7783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Request macOS badge authorization alongside alerts and sounds. - Repair existing bundled installations at startup only when native status is `Authorized` and `badgeSetting` is `NotSupported` (the omitted/unregistered interaction). Leave user-disabled badges, denied notifications, new users, and provisional/ephemeral authorization untouched. - Reuse the native query/request path in one background startup operation. No frontend changes, read-state resets, persisted migration flags, timers, or in-process retries. ## Validation - Full `just ci` passed at implementation head `e6c4bc640`, before the test-seam extraction and additional regression coverage. - Native patch head `8ab8cf6d6`: normal pre-commit/pre-push hooks passed, including macOS Tauri clippy (default and mesh-llm), complete Tauri test suite (3,208 passed, 19 ignored), and repository file-size gate. - Regression coverage: all five native authorization statuses × all three badge states; request exactly once only for Authorized + NotSupported; settings/request failure propagation; startup wiring; complete authorization options. - Mutation verification: removing the startup task and removing the Badge option each fail the corresponding regression. - Isolated bundled native app on macOS 26.6.2: requesting Alert | Sound produced Authorized + badge NotSupported; requesting Alert | Sound | Badge then produced badge Enabled with no error. A direct AppKit Dock label of 7 was visibly rendered in the isolated app. - Independent review found no remaining code blocker at final head. ## CI repair (`f9a6ff2b0`) - The original browser smoke failure was a 500ms startup assertion racing loaded-runner rendering. Hosted failure snapshots contained all 14 expected rows; the unchanged test also reproduced the failure locally with 6× CPU throttling. - Reuse the existing one-shot mock channel response gate in the affected test. Wait for startup settlement, defer a second real revalidation, mutate display state while pending, release, then assert after query settlement. No production changes in this follow-up. - All 10 sidebar snapshot E2Es pass at clean head `f9a6ff2b0`; the repaired case also passed 3/3 CPU-throttled runs before commit. Temporarily removing display preservation or persisting the display mutation each caused the corresponding assertion to fail; both mutants were restored. - Normal pre-commit/pre-push gates passed, including desktop checks/typechecking, all 6,508 desktop tests, and the file-size gate. Independent artifact/source review found no blocker in the repair. - New hosted CI is running: https://github.com/block/buzz/actions/runs/35633308957. This is not a claim that hosted CI is green. Exact-range security review still requires organization-member authorization. ## Runtime limitation This fixes a verified missing authorization registration and provides an automatic existing-install path. It is **not yet proof that the reported blank Dock badge in the released Buzz app is fully resolved**: patched bundled Buzz startup and unread-to-Dock recovery have not been exercised. The isolated app is native API evidence, not a replacement for that release smoke test. Installed Buzz and its read state were not modified. Before release, verify an existing Alert/Sound-only install gains the badge setting on launch and renders an unread Dock badge; verify a user-disabled badge remains disabled. --------- Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/macos_notifications.rs | 136 +++++++++++++++++-- desktop/tests/e2e/sidebar-snapshot.spec.ts | 43 +++++- 2 files changed, 163 insertions(+), 16 deletions(-) diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs index 5bcedd8975..dde4ae1d8e 100644 --- a/desktop/src-tauri/src/macos_notifications.rs +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -25,7 +25,7 @@ use objc2_foundation::{NSBundle, NSDictionary, NSError, NSObject, NSObjectProtoc use objc2_user_notifications::{ UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions, - UNNotificationRequest, UNNotificationResponse, UNNotificationSettings, + UNNotificationRequest, UNNotificationResponse, UNNotificationSetting, UNNotificationSettings, UNUserNotificationCenter, UNUserNotificationCenterDelegate, }; use tauri::{AppHandle, Emitter}; @@ -144,6 +144,18 @@ pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { // process-lifetime state, matching the application-lifetime delegate Apple // documents and avoiding mutable global or per-notification registrations. std::mem::forget(delegate); + + // Older releases registered alerts/sounds but omitted Badge. Repair only + // that unregistered interaction, never an explicit user opt-out. This runs + // once per bundled process without blocking startup or prompting new users. + tauri::async_runtime::spawn_blocking(|| { + let result = + register_missing_badge(notification_settings_sync, request_notification_access_sync); + if let Err(error) = result { + // Leave the native setting unchanged; a later launch can retry. + eprintln!("buzz-desktop: failed to register macOS badge authorization: {error}"); + } + }); Ok(()) } @@ -158,15 +170,15 @@ fn ensure_bundled_application() -> Result<(), String> { } } -fn notification_permission_state_sync() -> Result { +fn notification_settings_sync() -> Result<(UNAuthorizationStatus, UNNotificationSetting), String> { ensure_bundled_application()?; let (sender, receiver) = mpsc::sync_channel(1); let handler = RcBlock::new(move |settings: NonNull| { // SAFETY: Apple guarantees a live UNNotificationSettings object for // the duration of this completion handler. - let status = unsafe { settings.as_ref() }.authorizationStatus(); - let _ = sender.send(permission_state(status)); + let settings = unsafe { settings.as_ref() }; + let _ = sender.send((settings.authorizationStatus(), settings.badgeSetting())); }); UNUserNotificationCenter::currentNotificationCenter() .getNotificationSettingsWithCompletionHandler(&handler); @@ -176,6 +188,23 @@ fn notification_permission_state_sync() -> Result Result { + notification_settings_sync().map(|(status, _)| permission_state(status)) +} + +fn register_missing_badge( + settings: impl FnOnce() -> Result<(UNAuthorizationStatus, UNNotificationSetting), String>, + request: impl FnOnce() -> Result, +) -> Result<(), String> { + let (permission, badge) = settings()?; + if permission == UNAuthorizationStatus::Authorized + && badge == UNNotificationSetting::NotSupported + { + request()?; + } + Ok(()) +} + #[tauri::command] pub(crate) async fn notification_permission_state() -> Result { tokio::task::spawn_blocking(notification_permission_state_sync) @@ -183,6 +212,11 @@ pub(crate) async fn notification_permission_state() -> Result UNAuthorizationOptions { + // Register every interaction Buzz uses, including its Dock badge. + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound | UNAuthorizationOptions::Badge +} + fn request_notification_access_sync() -> Result { ensure_bundled_application()?; @@ -196,7 +230,7 @@ fn request_notification_access_sync() -> Result Option { #[cfg(test)] mod tests { use super::{ - is_application_bundle_layout, is_bundled_application, parse_target, permission_state, - queue_activation, take_pending_activations, NotificationPermissionState, - MAX_PENDING_ACTIVATIONS, + is_application_bundle_layout, is_bundled_application, notification_authorization_options, + parse_target, permission_state, queue_activation, register_missing_badge, + take_pending_activations, NotificationPermissionState, MAX_PENDING_ACTIVATIONS, + }; + use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNNotificationSetting, }; - use objc2_user_notifications::UNAuthorizationStatus; use std::path::Path; + #[test] + fn registers_only_unrequested_badges_for_already_authorized_installs() { + for permission in [ + UNAuthorizationStatus::NotDetermined, + UNAuthorizationStatus::Denied, + UNAuthorizationStatus::Authorized, + UNAuthorizationStatus::Provisional, + UNAuthorizationStatus::Ephemeral, + ] { + for badge in [ + UNNotificationSetting::NotSupported, + UNNotificationSetting::Disabled, + UNNotificationSetting::Enabled, + ] { + let mut requests = 0; + register_missing_badge( + || Ok((permission, badge)), + || { + requests += 1; + Ok(NotificationPermissionState::Granted) + }, + ) + .expect("registration succeeds"); + assert_eq!( + requests, + usize::from( + permission == UNAuthorizationStatus::Authorized + && badge == UNNotificationSetting::NotSupported + ), + "permission={permission:?}, badge={badge:?}", + ); + } + } + } + + #[test] + fn badge_registration_propagates_settings_and_request_failures() { + let error = register_missing_badge( + || Err("settings unavailable".into()), + || panic!("must not request when settings failed"), + ) + .expect_err("settings failure"); + assert_eq!(error, "settings unavailable"); + let error = register_missing_badge( + || { + Ok(( + UNAuthorizationStatus::Authorized, + UNNotificationSetting::NotSupported, + )) + }, + || Err("request failed".into()), + ) + .expect_err("request failure"); + assert_eq!(error, "request failed"); + } + + #[test] + fn bundled_startup_wires_native_badge_registration() { + // Complement behavioral tests with a wiring guard: removing the startup + // task must not leave the isolated operation tests green. + let source = include_str!("macos_notifications.rs"); + let init = source + .split("pub(crate) fn init(") + .nth(1) + .expect("init") + .split("fn ensure_bundled_application") + .next() + .expect("init body"); + assert!(init.contains("tauri::async_runtime::spawn_blocking")); + assert!(init.contains("register_missing_badge(")); + assert!(init.contains("notification_settings_sync,")); + assert!(init.contains("request_notification_access_sync")); + } + + #[test] + fn requests_all_interactions_used_by_buzz() { + let options = notification_authorization_options(); + assert!(options.contains(UNAuthorizationOptions::Alert)); + assert!(options.contains(UNAuthorizationOptions::Sound)); + assert!(options.contains(UNAuthorizationOptions::Badge)); + } + #[test] fn activation_queue_is_bounded_and_drained() { let _ = take_pending_activations(); diff --git a/desktop/tests/e2e/sidebar-snapshot.spec.ts b/desktop/tests/e2e/sidebar-snapshot.spec.ts index eaf8797f11..c8a3af4694 100644 --- a/desktop/tests/e2e/sidebar-snapshot.spec.ts +++ b/desktop/tests/e2e/sidebar-snapshot.spec.ts @@ -294,24 +294,53 @@ test("matching not-modified preserves display mutations without persisting them" }) => { const optimisticName = "optimistic-local-channel"; await seedSnapshot(page, { hash: MATCHING_HASH }); - await installMockBridge(page, { - channelsReadDelayMs: READ_DELAY_MS, - honorChannelsKnownHash: true, - }); + await installMockBridge(page, { honorChannelsKnownHash: true }); await page.goto("/"); await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount( FULL_SNAPSHOT.length, - { timeout: 500 }, ); + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["channels"]), + ), + ) + .toMatchObject({ fetchStatus: "idle", status: "success" }); + + // Exercise the mutation during a real revalidation, not a race between boot + // rendering and a fixed response delay. The cold-boot test covers first paint. + await page.evaluate(() => { + window.__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?.(); + void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + exact: true, + }); + }); + await expect + .poll(() => page.evaluate(() => window.__BUZZ_E2E_CHANNELS_READ_PENDING__)) + .toBe(1); await mutateDisplayedChannels(page, optimisticName); await expect( page.locator('[data-channel-id="optimistic-channel"]'), ).toBeVisible(); + expect( + await page.evaluate(() => window.__BUZZ_E2E_RELEASE_CHANNELS_READ__?.()), + ).toBe(1); + // Invocation logging only proves the request started. Wait for the query to + // consume its response before checking either display state or persistence. await expect - .poll(() => getChannelsPayloads(page)) - .toEqual([{ knownHash: MATCHING_HASH }]); + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["channels"]), + ), + ) + .toMatchObject({ fetchStatus: "idle", status: "success" }); + expect(await getChannelsPayloads(page)).toEqual([ + { knownHash: MATCHING_HASH }, + { knownHash: MATCHING_HASH }, + ]); await expect .poll(() => readPersistedSnapshot(page)) .toEqual({ From 6a86531ac9d27b5123cca14636ced581578975df Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 21 Sep 2026 11:30:03 -0700 Subject: [PATCH 20/22] feat(relay): add atomic complete read-state snapshots (#7572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opened by Brain on behalf of Wes (GitHub: wesbillman). ## Summary Add an opt-in, host-scoped completeness primitive for NIP-RS read-state clients. Ordinary query arrays and WebSocket behavior remain unchanged. - Advertise a version-1 `read_state_snapshot` capability in NIP-11 only when the request resolves a community. - Accept exactly one self-author kind-30078 snapshot filter on authenticated `POST /query`, after the existing admission, replay and membership gates. - Read all retained, nondeleted self-author kind-30078 coordinates from the writer at one SQL-statement MVCC cut—including unrelated application coordinates and events without a read-state tag. - Return a complete versioned envelope bound to community, signer and a content-identity hash. Reject malformed contracts, reconstruction/signature/storage failures and whole-request overflow; never return a truncated complete response. - Bound snapshots at 4,096 events and 8 MiB of compact encoded event-array bytes, advertised as `max_event_array_bytes`; envelope metadata is additional. Retain the separate stored-payload preflight budget. ## Safety and scope This is point-in-time enumeration, **not** a live freshness guarantee, cursor, CAS token, cross-relay transaction, or unread total. NIP-RS now explicitly requires fresh complete enumeration from every write relay before each override action, canonical publication, carry-forward or deletion. It does not enable synchronized manual-unread overrides in any client. No schema migration or deployment is included. ## Validation Executed against the source committed as `1fd5a5e0c0d893c1259386885517c0c44ae7630f`: - Four real-router/PostgreSQL snapshot cases: beyond-page-cap enumeration and overflow; corruption/storage failure; production payload-bound NIP-98, Redis replay guard and membership enforcement; host discovery/scope/strict request shape. - Two database cases: replacement during preflight preserves a single statement cut; encoded-byte overflow and writer-not-replica routing. The cluster-wide probe uses the required `cluster_global_` serialization prefix. - Production-call-site mutations killed for dispatch, strict filter validation, discovery tenant, snapshot tenant, encoded-byte guard, membership enforcement and replay enforcement; isolated mutation tree restored. - Both affected crates pass all-target Clippy; formatting, differential file-size and repository PostgreSQL test-discovery checks pass. - Normal pre-push gates pass without bypass: branch skew, push-head scope, file size, Rust unit lanes and desktop Tauri checks. - Independent read-only review by Pinky found no remaining relay blocker at this commit. The execution/mutation results above were run by Brain, not duplicated by the reviewer. Hosted CI and maintainer review remain separate gates. No production traffic or deployment was used for these fixture tests. Originating Buzz channel: `a394ecdb-9c67-4c4e-a3c6-4a95f69e8cc8` (unread-but-good), thread `2413d0ba7fa5b2ac5c777df6b3754e2099b042ec8fb96ca64302a0919a0550b3`. ## Review repair (2026-09-21) Carl updated this existing branch to `2f9c9b11266c24947412cf28cefae4e96e7167b7`, merging main `5079c770fe30bb3d8204822ce6c2431eacac6d4b` without rewriting published history. - Resolved the DB export conflict, preserving both snapshot and storage-accounting exports. - Addressed the blocking review using its accepted alternative: rename `max_bytes` to `max_event_array_bytes` and explicitly document that success-envelope metadata is outside the array budget. No auth, query, runtime-limit, or client-behavior redesign. - Added a PostgreSQL/router regression proving discovery, exact 8,388,608-byte array success with a larger envelope, and one-byte-over rejection (413, no partial complete response). Exact-head validation: all seven snapshot PostgreSQL/router tests passed in an isolated local database; both affected crates passed all-target Clippy; mandatory pre-commit/pre-push gates passed without bypass (Rust unit lanes, Tauri default/mesh Clippy and tests, differential file size, branch-skew/ref checks). Reverting only the descriptor rename made the new regression fail before the final commit. Independent bounded source-delta review by Mordecai found no blockers; execution was Carl's. A broader `cargo test -p buzz-db -p buzz-relay --all-targets` run on the merged candidate passed 131 DB tests and 1,057 relay tests, then encountered the same unrelated `mesh_demo::demo_join_forwarded_arm_round_trips_echo` 504 recorded in the prior review. Full repository `just ci` / `just test` were not rerun locally; hosted CI remains a separate gate. No deployment or production traffic was exercised. --------- Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- crates/buzz-db/src/error.rs | 4 + crates/buzz-db/src/lib.rs | 4 +- crates/buzz-db/src/store/mod.rs | 2 + crates/buzz-db/src/store/read_state.rs | 276 +++++++++++ crates/buzz-relay/src/api/bridge.rs | 8 +- .../src/api/bridge/read_state_snapshot.rs | 442 ++++++++++++++++++ crates/buzz-relay/src/nip11.rs | 12 + docs/nips/NIP-RS.md | 95 +++- 8 files changed, 838 insertions(+), 5 deletions(-) create mode 100644 crates/buzz-db/src/store/read_state.rs create mode 100644 crates/buzz-relay/src/api/bridge/read_state_snapshot.rs diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index 4f4e6b105c..79a371acb4 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -64,6 +64,10 @@ pub enum DbError { #[error("deletion safety error: {0}")] DeletionSafety(String), + /// A complete read-state snapshot exceeds its bounded resource budget. + #[error("read-state snapshot exceeds event or byte limit")] + ReadStateSnapshotTooLarge, + /// A stored timestamp value could not be interpreted. #[error("invalid timestamp: {0}")] InvalidTimestamp(i64), diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index d444d9ce52..f749d1a625 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -64,8 +64,8 @@ pub(crate) use runtime::{ pub use store::{ admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, - reaction, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, - replaceable, storage_accounting, thread, usage, user, workflow, + reaction, read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, + reminder, replaceable, storage_accounting, thread, usage, user, workflow, }; pub use allowlist::AllowlistEntry; diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 79739373d0..3aeb2257aa 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -34,6 +34,8 @@ pub mod product_feedback; pub mod push; /// Reaction persistence. pub mod reaction; +/// Complete own-author read-state snapshots. +pub mod read_state; /// HTTP report-resolution enforcement state machine persistence. pub mod relay_admin_actions; /// Use-limited relay invite persistence (v2 opaque tokens). diff --git a/crates/buzz-db/src/store/read_state.rs b/crates/buzz-db/src/store/read_state.rs new file mode 100644 index 0000000000..5a8a2a2c6c --- /dev/null +++ b/crates/buzz-db/src/store/read_state.rs @@ -0,0 +1,276 @@ +//! Atomic, bounded, writer-only snapshots of an author's kind-30078 state. + +use buzz_core::{kind::KIND_READ_STATE, CommunityId}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use sqlx::Row; + +use crate::{observability, Db, DbError, Result}; + +/// Maximum number of events in a complete snapshot; overflow is an error. +pub const MAX_SNAPSHOT_EVENTS: usize = 4096; +/// Maximum stored payload or encoded event-array bytes; overflow is an error. +pub const MAX_SNAPSHOT_BYTES: usize = 8 * 1024 * 1024; + +/// A complete view at one writer statement's MVCC cut, not a live-delivery fence. +#[derive(Debug)] +pub struct ReadStateSnapshot { + /// Content identifier bound to community, author, and ordered event ids. + /// Not a monotone revision, CAS token, or live-stream cursor. + pub snapshot_id: String, + /// Every current own kind-30078 event, including unrelated application data. + pub events: Vec, +} + +impl Db { + /// Load all current kind-30078 events for exactly one author and community. + /// + /// A single writer statement binds selection, resource preflight, and row + /// retrieval to the same MVCC snapshot. No tag, horizon, replica, or ordinary + /// query-page cap is involved. Any corrupt row or budget overflow fails the + /// whole operation before a caller can assert completeness. + pub async fn read_state_snapshot( + &self, + community: CommunityId, + author: &nostr::PublicKey, + ) -> Result { + let mut conn = observability::acquire_writer( + &self.pool, + observability::WriterOperation::SubscriptionHistory, + ) + .await?; + // Select only bounded metadata first. A left join retains the budget + // sentinel even for empty/oversized sets; oversized payloads never leave + // Postgres. Both CTEs and the payload join share ONE statement snapshot, + // including when a coordinate is hard-deleted/replaced concurrently. + let rows = sqlx::query( + "WITH candidates AS MATERIALIZED ( + SELECT id, created_at, + octet_length(content)::bigint + octet_length(tags::text)::bigint AS bytes + FROM events + WHERE community_id = $1 AND pubkey = $2 AND kind = $3 + AND deleted_at IS NULL + ORDER BY created_at DESC, id ASC LIMIT $4 + ), budget AS MATERIALIZED ( + SELECT count(*) AS n, COALESCE(sum(bytes), 0)::bigint AS bytes FROM candidates + ) + SELECT budget.n AS snapshot_count, budget.bytes AS snapshot_bytes, + e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, + e.received_at, e.channel_id + FROM budget + LEFT JOIN candidates c ON budget.n <= $5 AND budget.bytes <= $6 + LEFT JOIN events e ON e.community_id = $1 AND e.id = c.id + AND e.created_at = c.created_at + ORDER BY e.created_at DESC, e.id ASC", + ) + .bind(community.as_uuid()) + .bind(author.to_bytes().to_vec()) + .bind(KIND_READ_STATE as i32) + .bind((MAX_SNAPSHOT_EVENTS + 1) as i64) + .bind(MAX_SNAPSHOT_EVENTS as i64) + .bind(MAX_SNAPSHOT_BYTES as i64) + .fetch_all(&mut *conn) + .await?; + + let first = rows.first().ok_or_else(|| { + DbError::InvalidData("read-state snapshot missing budget sentinel".into()) + })?; + let count: i64 = first.try_get("snapshot_count")?; + let stored_bytes: i64 = first.try_get("snapshot_bytes")?; + if count > MAX_SNAPSHOT_EVENTS as i64 || stored_bytes > MAX_SNAPSHOT_BYTES as i64 { + return Err(DbError::ReadStateSnapshotTooLarge); + } + let mut events = Vec::with_capacity(count as usize); + let mut encoded_bytes = 2usize; // JSON array brackets + let mut hash = Sha256::new(); + hash.update(b"buzz-read-state-snapshot-v1\0"); + hash.update(community.as_uuid().as_bytes()); + hash.update(author.to_bytes()); + for row in rows { + if count == 0 { + break; + } + let stored = super::event::row_to_stored_event(row)?.ok_or_else(|| { + DbError::InvalidData("unreadable read-state snapshot event".into()) + })?; + let event = stored.event; + if event.pubkey != *author || event.kind.as_u16() as u32 != KIND_READ_STATE { + return Err(DbError::InvalidData( + "read-state snapshot scope mismatch".into(), + )); + } + event.verify().map_err(|_| { + DbError::InvalidData("invalid signed read-state snapshot event".into()) + })?; + let value = serde_json::to_value(&event)?; + encoded_bytes += serde_json::to_vec(&value)?.len() + usize::from(!events.is_empty()); + if encoded_bytes > MAX_SNAPSHOT_BYTES { + return Err(DbError::ReadStateSnapshotTooLarge); + } + hash.update(event.id.as_bytes()); + events.push(value); + } + if events.len() != count as usize { + return Err(DbError::InvalidData( + "read-state snapshot row count mismatch".into(), + )); + } + Ok(ReadStateSnapshot { + snapshot_id: hex::encode(hash.finalize()), + events, + }) + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + use sqlx::{postgres::PgPoolOptions, PgPool}; + use std::time::Duration; + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn cluster_global_read_state_snapshot_replacement_during_preflight_keeps_one_mvcc_cut() { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("mvcc-{}.local", uuid::Uuid::new_v4().simple())) + .await + .unwrap() + .id; + let key = Keys::generate(); + let slot = format!("read-state:{}", uuid::Uuid::new_v4().simple()); + let make = |ts, content| { + EventBuilder::new(Kind::Custom(30078), content) + .tags([Tag::identifier(&slot), Tag::hashtag("read-state")]) + .custom_created_at(Timestamp::from(ts)) + .sign_with_keys(&key) + .unwrap() + }; + let before = make(1789090000, "before"); + let after = make(1789090001, "after"); + db.replace_parameterized_event(community, &before, &slot, None) + .await + .unwrap(); + + // Gate the real production SQL at octet_length(content), after it has + // taken its statement snapshot. Only this connection's private schema + // shadows the builtin; the normal writer path is completely unchanged. + let schema = format!("snapshot_gate_{}", uuid::Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {schema}"))) + .execute(&pool) + .await + .unwrap(); + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE FUNCTION {schema}.octet_length(text) RETURNS integer + LANGUAGE plpgsql VOLATILE AS $$ BEGIN + PERFORM pg_catalog.pg_advisory_xact_lock(81193042); + RETURN pg_catalog.octet_length($1); END $$" + ))) + .execute(&pool) + .await + .unwrap(); + let application = schema.clone(); + let gate_schema = schema.clone(); + let snapshot_pool = PgPoolOptions::new().max_connections(1).after_connect(move |conn, _| { + let setup = format!("SET search_path TO {gate_schema},pg_catalog,public; SET application_name='{gate_schema}'"); + Box::pin(async move { sqlx::raw_sql(sqlx::AssertSqlSafe(setup)).execute(conn).await?; Ok(()) }) + }).connect(&crate::test_support::database_url()).await.unwrap(); + let snapshot_db = Db::from_pool(snapshot_pool); + let mut blocker = pool.acquire().await.unwrap(); + sqlx::query("SELECT pg_advisory_lock(81193042)") + .execute(&mut *blocker) + .await + .unwrap(); + let reader = snapshot_db.clone(); + let pubkey = key.public_key(); + let pending = + tokio::spawn(async move { reader.read_state_snapshot(community, &pubkey).await }); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity + WHERE application_name=$1 AND wait_event='advisory')", + ) + .bind(&application) + .fetch_one(&pool) + .await + .unwrap(); + if waiting { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("snapshot must reach the real metadata read"); + assert!( + db.replace_parameterized_event(community, &after, &slot, None) + .await + .unwrap() + .1 + ); + sqlx::query("SELECT pg_advisory_unlock(81193042)") + .execute(&mut *blocker) + .await + .unwrap(); + let cut = pending.await.unwrap().unwrap(); + assert_eq!(cut.events, vec![serde_json::to_value(&before).unwrap()]); + let next = snapshot_db + .read_state_snapshot(community, &key.public_key()) + .await + .unwrap(); + assert_eq!(next.events, vec![serde_json::to_value(&after).unwrap()]); + assert_ne!(cut.snapshot_id, next.snapshot_id); + sqlx::query(sqlx::AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE"))) + .execute(&pool) + .await + .unwrap(); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_state_snapshot_encoded_bytes_bound_and_writer_not_replica() { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("bytes-{}.local", uuid::Uuid::new_v4().simple())) + .await + .unwrap() + .id; + let key = Keys::generate(); + // Stored bytes fit but escaped JSON event bytes do not. + let event = EventBuilder::new(Kind::Custom(30078), "\u{0001}".repeat(2 * 1024 * 1024)) + .tags([Tag::identifier("escape-fixture")]) + .sign_with_keys(&key) + .unwrap(); + db.insert_event(community, &event, None).await.unwrap(); + let dead_replica = PgPoolOptions::new() + .connect_lazy("postgres://unused@127.0.0.1:1/absent") + .unwrap(); + dead_replica.close().await; + let routed = Db::from_pools(pool.clone(), dead_replica); + assert!(matches!( + routed + .read_state_snapshot(community, &key.public_key()) + .await, + Err(DbError::ReadStateSnapshotTooLarge) + )); + sqlx::query("DELETE FROM events WHERE community_id=$1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + let empty = routed + .read_state_snapshot(community, &key.public_key()) + .await + .unwrap(); + assert!(empty.events.is_empty()); + assert_eq!(empty.snapshot_id.len(), 64); + } +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 93e4f1769f..e7433d43b1 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -3,6 +3,8 @@ //! These endpoints provide HTTP access to the relay's Nostr protocol, //! authenticated via NIP-98 signed events. +mod read_state_snapshot; + use std::sync::Arc; use axum::{ @@ -1145,6 +1147,10 @@ async fn query_events_authed( )); } + if read_state_snapshot::requested(&raw_filters) { + return read_state_snapshot::query(state, tenant, &pubkey, &raw_filters).await; + } + // Get channels this user can access — same enforcement as WS REQ handler. let mut accessible_channels = state .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) @@ -3861,7 +3867,7 @@ mod postgres_tests { /// - Redis pool points at the local dev instance for the admission check. /// /// Returns `None` when local Postgres is not reachable. - async fn bridge_handler_test_state() -> Option> { + pub(super) async fn bridge_handler_test_state() -> Option> { let mut config = crate::config::Config::from_env().ok()?; config.database_url = crate::test_support::database_url(); // Use the real local Redis so enforce_http_admission can pass. diff --git a/crates/buzz-relay/src/api/bridge/read_state_snapshot.rs b/crates/buzz-relay/src/api/bridge/read_state_snapshot.rs new file mode 100644 index 0000000000..178c327eaa --- /dev/null +++ b/crates/buzz-relay/src/api/bridge/read_state_snapshot.rs @@ -0,0 +1,442 @@ +//! Opt-in atomic full-state query extension. Ordinary query arrays cannot +//! accidentally satisfy this envelope, even on relays that ignore extensions. + +use axum::{http::StatusCode, Json}; +use buzz_core::TenantContext; +use serde_json::{json, Value}; + +use crate::{api::api_error, state::AppState}; + +pub(super) fn requested(filters: &[Value]) -> bool { + filters + .iter() + .any(|f| f.get("read_state_snapshot").is_some()) +} + +pub(super) async fn query( + state: &AppState, + tenant: &TenantContext, + author: &nostr::PublicKey, + filters: &[Value], +) -> Result, (StatusCode, Json)> { + // Exact raw shape: never silently drop constraints or unknown versions. + let expected = json!({ + "kinds": [buzz_core::kind::KIND_READ_STATE], + "authors": [author.to_hex()], + "read_state_snapshot": 1, + }); + if filters != [expected] { + return Err(api_error( + StatusCode::BAD_REQUEST, + "read_state_snapshot requires one exact version-1 own-author kind-30078 filter", + )); + } + let snapshot = state + .db + .read_state_snapshot(tenant.community(), author) + .await + .map_err(|error| match error { + buzz_db::DbError::ReadStateSnapshotTooLarge => api_error( + StatusCode::PAYLOAD_TOO_LARGE, + "read_state_snapshot exceeds event or byte limit; cannot prove complete", + ), + _ => { + tracing::error!(%error, "read-state snapshot failed"); + api_error( + StatusCode::SERVICE_UNAVAILABLE, + "read_state_snapshot unavailable; cannot prove complete", + ) + } + })?; + Ok(Json(json!({ + "read_state_snapshot": 1, + "complete": true, + "community_id": tenant.community().as_uuid(), + "pubkey": author.to_hex(), + "snapshot_id": snapshot.snapshot_id, + "events": snapshot.events, + }))) +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use axum::{ + body::{to_bytes, Body}, + http::Request, + }; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use std::sync::Arc; + use tower::ServiceExt; + + async fn request( + state: Arc, + host: &str, + key: Option<&Keys>, + body: Value, + ) -> (StatusCode, Value) { + let mut req = Request::builder() + .method("POST") + .uri("/query") + .header("host", host); + if let Some(key) = key { + req = req.header("x-pubkey", key.public_key().to_hex()); + } + let response = crate::router::build_router(state) + .oneshot( + req.body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 10 * 1024 * 1024) + .await + .unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) + } + + fn filter(key: &Keys) -> Value { + json!([{"kinds":[30078], "authors":[key.public_key().to_hex()], "read_state_snapshot":1}]) + } + + async fn setup() -> (Arc, String, buzz_core::CommunityId, Keys) { + let state = super::super::postgres_tests::bridge_handler_test_state() + .await + .expect("isolated Postgres/Redis fixture"); + let host = format!("snapshot-{}.local", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + (state, host, community, Keys::generate()) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_state_snapshot_router_real_auth_membership_and_replay() { + use base64::Engine; + use sha2::{Digest, Sha256}; + + let (fixture, host, community, key) = setup().await; + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.require_auth_token = true; + config.require_relay_membership = true; + // Restore the production Redis guard; never accept every replay in this test. + state.nip98_replay = Arc::new(buzz_pubsub::RedisNip98ReplayGuard::new( + state.redis_pool.clone(), + )); + let state = Arc::new(state); + let body = serde_json::to_vec(&filter(&key)).unwrap(); + let proof = |signed_host: &str| { + let auth = EventBuilder::new(Kind::Custom(27235), "") + .tags([ + Tag::parse(["u", &format!("https://{signed_host}/query")]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["payload", &hex::encode(Sha256::digest(&body))]).unwrap(), + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(&key) + .unwrap(); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&auth).unwrap()) + ) + }; + let post = |authorization: String, bytes: Vec| { + let router = crate::router::build_router(state.clone()); + let req = Request::builder() + .method("POST") + .uri("/query") + .header("host", &host) + .header("authorization", authorization) + .body(Body::from(bytes)) + .unwrap(); + async move { + let response = router.oneshot(req).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + (status, serde_json::from_slice::(&bytes).unwrap()) + } + }; + let denied = post(proof(&host), body.clone()).await; + assert_eq!(denied.0, StatusCode::FORBIDDEN, "{}", denied.1); + assert_eq!(denied.1["error"], "relay_membership_required"); + assert_ne!(denied.1["complete"], true); + + state + .db + .add_relay_member(community, &key.public_key().to_hex(), "member", None) + .await + .unwrap(); + // Dev headers must not bypass real auth even for an admitted member. + assert_eq!( + request(state.clone(), &host, Some(&key), filter(&key)) + .await + .0, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + post(proof("wrong-host.invalid"), body.clone()).await.0, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + post(proof(&host), b"[]".to_vec()).await.0, + StatusCode::UNAUTHORIZED + ); + + let auth = proof(&host); + let accepted = post(auth.clone(), body.clone()).await; + assert_eq!(accepted.0, StatusCode::OK, "{}", accepted.1); + assert_eq!(accepted.1["complete"], true); + assert_eq!(accepted.1["community_id"], community.as_uuid().to_string()); + let replay = post(auth, body.clone()).await; + assert_eq!(replay.0, StatusCode::UNAUTHORIZED, "{}", replay.1); + assert_ne!(replay.1["complete"], true); + assert_eq!(post(proof(&host), body).await.0, StatusCode::OK); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_state_snapshot_router_scope_discovery_and_strict_contract() { + let (state, host, community, key) = setup().await; + let other = Keys::generate(); + let other_host = format!("other-{}.local", uuid::Uuid::new_v4().simple()); + let other_community = state + .db + .ensure_configured_community(&other_host) + .await + .unwrap() + .id; + let event = EventBuilder::new(Kind::Custom(30078), "encrypted") + .tags([Tag::identifier("unrelated-app-without-t-tag")]) + .sign_with_keys(&key) + .unwrap(); + state + .db + .replace_parameterized_event(community, &event, "unrelated-app-without-t-tag", None) + .await + .unwrap(); + let other_event = EventBuilder::new(Kind::Custom(30078), "other-author") + .tags([Tag::identifier("other")]) + .sign_with_keys(&other) + .unwrap(); + state + .db + .replace_parameterized_event(community, &other_event, "other", None) + .await + .unwrap(); + state + .db + .replace_parameterized_event( + other_community, + &event, + "unrelated-app-without-t-tag", + None, + ) + .await + .unwrap(); + + let (status, body) = request(state.clone(), &host, Some(&key), filter(&key)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["complete"], true); + assert_eq!(body["read_state_snapshot"], 1); + assert_eq!(body["community_id"], community.as_uuid().to_string()); + assert_eq!(body["pubkey"], key.public_key().to_hex()); + assert_eq!(body["events"], json!([event])); + let info = crate::nip11::nip11_document(&state, &host).await; + assert_eq!( + info.read_state_snapshot.as_ref().unwrap()["community_id"], + body["community_id"] + ); + assert!(crate::nip11::nip11_document(&state, "unmapped.invalid") + .await + .read_state_snapshot + .is_none()); + let (_, other_body) = request(state.clone(), &other_host, Some(&key), filter(&key)).await; + assert_eq!( + other_body["community_id"], + other_community.as_uuid().to_string() + ); + assert_ne!(other_body["snapshot_id"], body["snapshot_id"]); + let (status, _) = request(state.clone(), &host, None, filter(&key)).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, _) = + request(state.clone(), "unmapped.invalid", Some(&key), filter(&key)).await; + assert_eq!(status, StatusCode::NOT_FOUND); + let (status, _) = request(state.clone(), &host, Some(&other), filter(&key)).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + for (name, value) in [ + ("since", json!(0)), + ("limit", json!(1)), + ("#t", json!(["read-state"])), + ("read_state_snapshot", json!(2)), + ("read_state_snapshot", json!(false)), + ("read_state_snapshot", Value::Null), + ] { + let mut bad = filter(&key); + bad[0][name] = value; + let (status, body) = request(state.clone(), &host, Some(&key), bad).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{name}: {body}"); + assert_ne!(body["complete"], true); + } + let mut mixed = filter(&key); + mixed.as_array_mut().unwrap().push(json!({"kinds":[0]})); + assert_eq!( + request(state.clone(), &host, Some(&key), mixed).await.0, + StatusCode::BAD_REQUEST + ); + let ordinary = json!([{"kinds":[30078], "authors":[key.public_key().to_hex()]}]); + let (status, body) = request(state, &host, Some(&key), ordinary).await; + assert_eq!(status, StatusCode::OK); + assert!(body.is_array()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_state_snapshot_router_corruption_and_storage_failure_are_not_complete() { + let (state, host, community, key) = setup().await; + let event = EventBuilder::new(Kind::Custom(30078), "payload") + .tags([Tag::identifier("fixture")]) + .sign_with_keys(&key) + .unwrap(); + state + .db + .replace_parameterized_event(community, &event, "fixture", None) + .await + .unwrap(); + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + for malformed in [json!(42), json!([["d", "fixture"]])] { + sqlx::query( + "UPDATE events SET tags=$1, content='corrupt' WHERE community_id=$2 AND id=$3", + ) + .bind(malformed) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + let (status, body) = request(state.clone(), &host, Some(&key), filter(&key)).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}"); + assert_ne!(body["complete"], true); + assert!(body.get("events").is_none()); + } + // Real DB error, not a mocked empty query: lock out the relation in this + // isolated per-test DB, while tenant/auth lookups remain usable. + sqlx::query("ALTER TABLE events RENAME TO snapshot_unavailable_events") + .execute(&pool) + .await + .unwrap(); + let (status, body) = request(state, &host, Some(&key), filter(&key)).await; + sqlx::query("ALTER TABLE snapshot_unavailable_events RENAME TO events") + .execute(&pool) + .await + .unwrap(); + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}"); + assert_ne!(body["complete"], true); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_state_snapshot_router_advertised_event_array_byte_boundary() { + let (state, host, community, key) = setup().await; + let info = crate::nip11::nip11_document(&state, &host).await; + let descriptor = info.read_state_snapshot.unwrap(); + let budget = descriptor["max_event_array_bytes"].as_u64().unwrap() as usize; + assert_eq!(budget, buzz_db::read_state::MAX_SNAPSHOT_BYTES); + assert!(descriptor.get("max_bytes").is_none()); + + // ASCII content adds exactly one encoded byte per character; timestamp, + // event id and signature widths stay fixed across these signed events. + let make = |content: String, timestamp| { + EventBuilder::new(Kind::Custom(30078), content) + .tags([Tag::identifier("byte-boundary")]) + .custom_created_at(nostr::Timestamp::from(timestamp)) + .sign_with_keys(&key) + .unwrap() + }; + let overhead = serde_json::to_vec(&json!([make(String::new(), 1789090000)])) + .unwrap() + .len(); + for extra in 0..=1 { + let event = make( + "x".repeat(budget - overhead + extra), + 1789090001 + extra as u64, + ); + assert_eq!( + serde_json::to_vec(&json!([event])).unwrap().len(), + budget + extra + ); + state + .db + .replace_parameterized_event(community, &event, "byte-boundary", None) + .await + .unwrap(); + let (status, body) = request(state.clone(), &host, Some(&key), filter(&key)).await; + if extra == 0 { + assert_eq!(status, StatusCode::OK); + assert_eq!(body["complete"], true); + assert_eq!(serde_json::to_vec(&body["events"]).unwrap().len(), budget); + // Discovery bounds the array, not the larger success envelope. + assert!(serde_json::to_vec(&body).unwrap().len() > budget); + } else { + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + assert_ne!(body["complete"], true); + assert!(body.get("events").is_none()); + } + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_state_snapshot_router_beyond_page_cap_and_overflow() { + let (state, host, community, key) = setup().await; + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + // Same-second plateau > ordinary 1,000 cap, including unrelated data. + for n in 0..1001 { + let event = EventBuilder::new(Kind::Custom(30078), "payload") + .custom_created_at(nostr::Timestamp::from(1789090000)) + .tags([Tag::identifier(format!("fixture-{n}"))]) + .sign_with_keys(&key) + .unwrap(); + state + .db + .insert_event(community, &event, None) + .await + .unwrap(); + } + let (status, body) = request(state.clone(), &host, Some(&key), filter(&key)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["events"].as_array().unwrap().len(), 1001); + // Stored byte preflight must reject before decoding these corrupt rows. + sqlx::query("UPDATE events SET content=repeat('x', 9000) WHERE community_id=$1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + let (status, body) = request(state.clone(), &host, Some(&key), filter(&key)).await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE, "{body}"); + assert_ne!(body["complete"], true); + sqlx::query("DELETE FROM events WHERE community_id=$1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + // Count overflow must not masquerade as a capped complete response. + sqlx::query("INSERT INTO events (community_id,id,pubkey,created_at,kind,tags,content,sig) + SELECT $1, decode(md5(n::text)||md5(n::text),'hex'),$2,to_timestamp(1789090000),30078,'[]','',decode(repeat('00',64),'hex') + FROM generate_series(1,4097) n") + .bind(community.as_uuid()).bind(key.public_key().to_bytes().to_vec()).execute(&pool).await.unwrap(); + let (status, body) = request(state, &host, Some(&key), filter(&key)).await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE, "{body}"); + assert_ne!(body["complete"], true); + } +} diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index e6b18cdd0f..043458b4bc 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -31,6 +31,9 @@ pub struct RelayInfo { /// admins/owners via the kind:9033 command. Omitted when no icon is set. #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, + /// Host-bound atomic read-state snapshot capability; absent on unresolved hosts. + #[serde(skip_serializing_if = "Option::is_none")] + pub read_state_snapshot: Option, /// Relay operator's public key (hex), if published. pub pubkey: Option, /// Contact address for the relay operator. @@ -203,6 +206,7 @@ impl RelayInfo { name: "Buzz Relay".to_string(), description: "Buzz — private team communication relay".to_string(), icon: icon.filter(|s| !s.is_empty()).map(|s| s.to_string()), + read_state_snapshot: None, pubkey: None, contact: None, supported_nips, @@ -293,6 +297,14 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st admin_api.as_deref(), state.config.klipy.as_ref().map(|_| "klipy"), ); + if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { + info.read_state_snapshot = Some(serde_json::json!({ + "version": 1, + "community_id": tenant.community().as_uuid(), + "max_events": buzz_db::read_state::MAX_SNAPSHOT_EVENTS, + "max_event_array_bytes": buzz_db::read_state::MAX_SNAPSHOT_BYTES, + })); + } let tenant_host = if state.config.push_enabled { crate::tenant::bind_community(&state.db, raw_host) .await diff --git a/docs/nips/NIP-RS.md b/docs/nips/NIP-RS.md index 6a095df60a..071fc82f47 100644 --- a/docs/nips/NIP-RS.md +++ b/docs/nips/NIP-RS.md @@ -378,6 +378,97 @@ Until a complete load succeeds, the client MUST evaluate unread state from its o The number of coordinates a full-state load must retrieve is bounded by the number of installations that have ever used the override layer, plus their not-yet-deleted rotation predecessors. It grows with the user's device history, not with elapsed time, and — because a coordinate carrying `ov_*` entries may not be deleted until it has been carried forward (see Client-ID Rotation) — it does not shrink on its own. Clients SHOULD carry forward and delete rotation predecessors promptly so the count stays near one coordinate per live installation. +#### Buzz Atomic Snapshot Extension (version 1) + +A relay MAY offer a complete **point-in-time** alternative to the paginated +full-state load above through the existing authenticated HTTP Nostr query bridge. +This extension does not change kind 30078, encryption, register algebra, or the +ordinary `POST /query` array response. It does not assert the five WebSocket +conformance properties above. + +Discovery: obtain NIP-11 from the configured, trusted relay origin. Its optional +`read_state_snapshot` descriptor contains `version: 1`, `community_id` (canonical +UUID string), `max_events: 4096`, and `max_event_array_bytes: 8388608`. The byte +budget applies only to the compact JSON `events` array, not the complete HTTP +response; clients MUST allow additional space for the success envelope metadata. +The community identifier is resolved from that request's Host through the active +community map; it is not supplied by the client or derived from its key. An +unresolved host omits the descriptor. This is TLS/origin-bound discovery, not an +independently signed global community identity. Clients MUST bind discovery and +queries to the same trusted relay origin and community session, and fence +asynchronous results on identity or community changes. + +Request: NIP-98 authenticated `POST /query` with exactly one raw filter: + +```json +[{"kinds":[30078],"authors":[""],"read_state_snapshot":1}] +``` + +No extra keys, tags, time bounds, pagination, other kinds/authors, or mixed filters +are accepted. Admission, replay protection, and relay membership gates apply as +for ordinary bridge reads. The relay never substitutes an agent owner's identity +for the authenticated signing identity. + +Success is a versioned envelope, **not an event array**: + +```json +{ + "read_state_snapshot": 1, + "complete": true, + "community_id": "", + "pubkey": "", + "snapshot_id": "<64-lowercase-hex>", + "events": [] +} +``` + +`events` contains every non-deleted own-author kind-30078 event retained at one +writer-database MVCC statement cut, including unrelated application coordinates +and events without a `t` tag. There is no ordinary query cap, age window, replica +routing, or multi-request pagination. A coordinate replaced during the statement +is consistently represented by the version visible at its cut; a later snapshot +sees the replacement. This guarantee covers retained current state, not purged +history, uncommitted writes, or future commits. + +`snapshot_id` is SHA-256 of the concatenation of UTF-8 +`buzz-read-state-snapshot-v1` plus a zero byte, the 16 raw community UUID bytes, +the 32 author bytes, and each 32-byte event id in `created_at DESC, id ASC` order. +It is a content identifier, **not** a monotone write revision, CAS precondition, +or live-delivery cursor. An unchanged view may have the same identifier. + +Both event count and bytes are hard bounded. More than 4096 events, more than +8 MiB of stored content plus PostgreSQL JSON tag text, or more than 8 MiB of +compact encoded event-array bytes fails the whole request (HTTP 413). No partial +envelope is returned. Row reconstruction, signature verification, serialization, +or database failure produces an explicit non-success response (HTTP 503 after +authentication). Invalid extension requests produce HTTP 400; normal auth, +unknown-host, membership, replay and admission errors retain their bridge status. +Clients MUST treat **all** such errors as `cannot prove complete`. There is no +permission to delete old coordinates to get under the snapshot cap. + +Clients MUST reject absent discovery, version mismatch, ordinary arrays (including +an old relay silently ignoring the extension), missing/false completeness, +community/pubkey mismatch, invalid signatures, duplicate/conflicting coordinates, +or invalid event envelopes. Read-state selection/decryption is client-side. An +unreadable or unsupported recognized read-state coordinate MUST block override +actions/publication; it MUST NOT be interpreted as absent state. + +A validated snapshot substitutes for the paginated enumeration and mutation fence +**only for its point-in-time load**. Before **each** override action, canonical +publication, or carry-forward/deletion decision, obtain a fresh snapshot from +**every** write relay and merge those snapshots with durable local state. Failure +on any required relay blocks that operation. Do not publish own coordinates while +loading. Ordinary read-before-write, primary co-location, permanent clear floors, +uint32/budget exhaustion, and per-relay acceptance-before-deletion rules remain. +A failed/ambiguous publish does not report synchronized action success. + +Actions after different snapshot cuts are concurrent CRDT actions, not serialized +transactions. A snapshot does not reserve a coordinate, prevent another writer, +or make subsequent live delivery lossless. Clients MUST NOT cache `complete:true` +as a session-wide authority or use it to label live freshness. On reconnect, +identity changes, or later canonical publication, acquire a new snapshot; merge +arriving live state as evidence but do not infer a complete load from it. + ### Merge Rule After decrypting all fetched blobs, the effective read timestamp for each context is: @@ -783,9 +874,9 @@ that expose read activity to other users MUST require explicit user consent. ## Backwards Compatibility -This NIP introduces no changes to existing event kinds and adds no new kind, wire message, or relay-stored read-state logic. It uses only standard NIP-01 event storage, NIP-33 addressable event semantics, NIP-44 encryption, and NIP-78 application data conventions. Clients that do not implement this NIP are unaffected, as are clients that implement everything but the manual-unread override layer. +This NIP introduces no changes to existing event kinds or relay-stored read-state logic. The baseline uses only standard NIP-01 event storage, NIP-33 addressable event semantics, NIP-44 encryption, and NIP-78 application data conventions. The optional Buzz Atomic Snapshot Extension adds explicitly discovered, versioned query/discovery envelopes; ordinary query arrays and WebSocket messages remain unchanged. Clients that do not implement this NIP are unaffected, as are clients that implement everything but the manual-unread override layer. -The override layer is the exception, and it is a relay-compatibility one rather than a client one. Its full-state load carries the completeness guarantee only against a relay that satisfies the ordering, capacity, floor, push, and barrier requirements enumerated in Full-State Load. Against a relay known or evidenced not to conform, every load resolves to *cannot prove complete* and the actions that depend on a complete load report as failed; against an undetectably nonconforming relay, a load may still return *complete*, and the completeness guarantee does not apply to that verdict. In either case the layer still runs and still merges, and frontier sync is unaffected. +The override layer is the exception, and it is a relay-compatibility one rather than a client one. Its paginated full-state load carries the completeness guarantee only against a relay that satisfies the ordering, capacity, floor, push, and barrier requirements enumerated in Full-State Load. A validated atomic snapshot supplies the alternative point-in-time guarantee described in Buzz Atomic Snapshot Extension, without asserting those WebSocket properties. Without either proof, every load resolves to *cannot prove complete* and the actions that depend on a complete load report as failed; against an undetectably nonconforming relay, a load may still return *complete*, and the completeness guarantee does not apply to that verdict. In either case the layer still runs and still merges, and frontier sync is unaffected. ## References From ce41be9c4b1ca50dea0b04379333eb082826ba0f Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 21 Sep 2026 21:52:54 +0300 Subject: [PATCH 21/22] fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Ensures admin kick live side effects (subscription eviction + workflow disable) fire correctly on every path — fresh HTTP, crash recovery, and upgrade recovery from pre-migration rows — without a race window where a concurrent legitimate re-add can be revoked. ### Finding 1 — Lock held through effects Replace `verify_member_still_removed` (which acquired + released the advisory lock before returning) with `membership_removal_fence`, a RAII guard that keeps the `pg_advisory_xact_lock` alive until the caller drops it. Update `apply_kick_live_side_effects` to use the fence on every path (fresh and recovery), holding it through eviction and workflow-disable. Remove the `is_recovery` parameter entirely. **The bug:** the old code released the lock before returning a bool; a concurrent `add_member` could commit between the check and the effects, silently evicting a re-added member. **New guarantee:** any `add_member` either serializes before the fence (fence observes `removed_at IS NULL`, skips effects) or waits until after effects complete. ### Finding 2 — Persisted-context path scoped to kick only Gate the persisted-context branch in `admin_action_worker.rs` on `action == "kick"`. Non-kick actions (ban, timeout, delete) always re-derive from the report. **The bug:** any action with `enforcement_target_pubkey` set took the persisted-context branch, which hard-coded `target_event_id = None`. A stranded delete either failed pre-marker recovery ("delete requires target_event_id") or finalized post-marker without the tombstone outbox row. ### Finding 3 — Legacy kick row upgrade recovery (Carl's CHANGES_REQUESTED) Migration 0047 (renumbered from 0045 to avoid collision with two main merges: 0045_retain_push_revocation_tombstones and 0046_storage_accounting_snapshots) adds `enforcement_target_pubkey`/`enforcement_channel_id` without backfilling. **The bug:** the convergence gate read only the persisted columns and rejected NULL/NULL rows as corrupt, causing every recovery retry to fail forever. A cancellation cannot release a post-mutation action, so these rows remain permanently stuck. **Fix:** when persisted columns are absent, fall back to the function parameters (which the recovery worker re-derives from the report row). Error only when both sources are absent (genuinely unresolvable). This preserves new-writer safety while allowing pre-migration rows to converge. An old writer in a rolling deployment can also create NULL rows after the DDL applies; the same fallback handles these. ### Finding 4 — Test moved to PG lane `kick_live_side_effects_clears_membership_cache_and_evicts_subscription` was in the infra-free unit lane but called `membership_removal_fence`, which requires a real Postgres connection. Moved to the PG fixture lane (`#[ignore = "requires Postgres"]`) with a real member row so the fence query sees the expected `removed_at` state. ## Tests - **`crash_recovery_redrive_fires_kick_live_side_effects`** (existing): re-drive via real recovery worker path, asserts all three side effects. - **`crash_recovery_after_readd_preserves_membership_subscriptions_and_workflows`** (updated): uses real `add_member` (not direct SQL) — re-add completes before fence is acquired. - **`membership_removal_fence_blocks_concurrent_add_member`** (existing): holds the fence explicitly, spawns `add_member`, asserts it blocks until the fence is dropped. - **`stranded_delete_pre_marker_recovers_via_worker`** (existing): stranded delete (crash before mutation) converges via `recover_one` to succeeded. - **`legacy_kick_row_pre_marker_recovers_via_worker`** (new): old-writer shaped row (NULL columns, no step_marker) converges through the full state machine via recovery worker. Falsifiable: reverting the convergence-gate fallback leaves the action stuck in enforcing. - **`legacy_kick_row_post_marker_recovers_via_worker`** (new): same but with step_marker set (post-marker crash). Falsifiable: same revert. - **`kick_live_side_effects_clears_membership_cache_and_evicts_subscription`** (moved to PG lane): verifies cache eviction and subscription removal against a real member row. ## CI `Rust / Rust Lint` and `Windows Rust` failures in the prior run were main-inherited (same lanes fail on main at the prior base). The branch is now at main's current head. --------- Signed-off-by: Will Pfleger Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 6 +- crates/buzz-db/src/store/channel_members.rs | 206 ++ .../buzz-db/src/store/relay_admin_actions.rs | 21 +- crates/buzz-db/src/store/workflow.rs | 31 +- crates/buzz-relay/src/api/admin/mod.rs | 2093 ++++++++++++++++- .../src/handlers/admin_action_worker.rs | 67 +- .../src/handlers/report_resolution.rs | 67 +- .../buzz-relay/src/handlers/side_effects.rs | 142 +- migrations/0047_relay_admin_action_target.sql | 17 + schema/schema.sql | 8 + 10 files changed, 2613 insertions(+), 45 deletions(-) create mode 100644 migrations/0047_relay_admin_action_target.sql diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index aebf8b9092..97c619d6ed 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -703,7 +703,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 46); + assert_eq!(migrations.len(), 47); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -2259,9 +2259,9 @@ mod postgres_tests { .await .expect("connect migrated probe database"); MIGRATOR - .run_to(39, &migrated) + .run_to(47, &migrated) .await - .expect("apply migrations 1-39"); + .expect("apply migrations 1-47"); for table in [ "relay_admin_actions", diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 8280ca01f8..7a246e0469 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -687,6 +687,173 @@ pub async fn is_member( Ok(cnt > 0) } +/// Verify that a member is still removed under the channel-membership lock, +/// serializing with any concurrent [`add_member`] call on the same channel. +/// +/// Returns `true` when `removed_at IS NOT NULL` for `(community_id, channel_id, +/// pubkey)` — i.e., the removal that crash-recovery is re-applying is still the +/// current state and no re-add has reversed it. +/// +/// Uses the same `pg_advisory_xact_lock` key as [`add_member`] so that a +/// concurrent re-add either sees this check complete (and then sets `removed_at +/// = NULL` after recovery finishes) or serializes before it (in which case this +/// check observes the re-add and returns `false`, preventing stale eviction). +/// +/// Designed for use before firing subscription eviction and workflow +/// disablement: cache invalidation is always safe (it only drops a stale +/// positive), but eviction and workflow-disable must not target a member who +/// was legitimately re-added after the kick committed. +/// +/// **Use [`membership_removal_fence`] instead of this function** when the caller +/// needs to hold the advisory lock through the destructive effects themselves. +/// This function releases the lock immediately after the read; concurrent +/// `add_member` calls can commit between the return and the caller's effects. +pub async fn verify_member_still_removed( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result { + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + + let row = sqlx::query( + "SELECT removed_at FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(pubkey) + .fetch_optional(&mut *tx) + .await?; + + // Row absent → never joined or hard-deleted; treat as removed (safe to skip + // eviction for someone who isn't and wasn't a member). + let still_removed = match row { + None => true, + Some(r) => { + let removed_at: Option> = r.try_get("removed_at")?; + removed_at.is_some() + } + }; + tx.rollback().await?; + Ok(still_removed) +} + +/// A guard that holds the per-channel membership advisory lock for the +/// duration of kick live side effects. +/// +/// Acquired via [`membership_removal_fence`]. The lock is released when the +/// guard is dropped (the inner transaction rolls back). Callers must NOT drop +/// the guard until after subscription eviction and workflow-disable complete, +/// so that no concurrent [`add_member`] can commit between the `still_removed` +/// observation and the destructive effects. +/// +/// After firing all live side effects, call +/// [`commit_disabling_workflows`][Self::commit_disabling_workflows] to run +/// the durable workflow-disable UPDATE on this guard's own connection and +/// commit the transaction (releasing the lock). If the guard is dropped +/// without committing, the inner transaction rolls back — the advisory lock +/// is released, but no domain writes persist. +pub struct MembershipRemovalFence { + /// `true` when the member row still has `removed_at IS NOT NULL` — i.e., no + /// re-add has reversed the kick since it committed. When `false`, the caller + /// must NOT fire eviction or workflow-disable. + pub still_removed: bool, + // Holds the advisory transaction lock. The caller commits via + // `commit_disabling_workflows`; on drop without commit the tx rolls back. + tx: Transaction<'static, Postgres>, +} + +impl MembershipRemovalFence { + /// Run the workflow-disable UPDATE on this guard's own connection, then + /// commit the transaction (releasing the advisory lock). + /// + /// By running the UPDATE inside the same connection that holds the lock, + /// no additional pool connection is needed — preventing the self-deadlock + /// that would arise from a pool-size-N scenario where every kick holds one + /// connection while trying to acquire a second for the disable write. + /// + /// The commit makes the disable durable before the lock is released, so + /// there is no window between "workflows disabled" and "lock released." + /// + /// On failure the transaction is rolled back (the advisory lock is still + /// released), and the error is returned to the caller to handle (log/skip). + pub async fn commit_disabling_workflows( + mut self, + community_id: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], + ) -> crate::Result { + let affected = crate::workflow::disable_workflows_for_owner_in_channel_on_conn( + &mut self.tx, + community_id, + channel_id, + owner_pubkey, + ) + .await?; + self.tx.commit().await?; + Ok(affected) + } +} + +/// Acquire the per-channel membership advisory lock and check whether the +/// kicked member is still removed, returning a [`MembershipRemovalFence`] that +/// keeps the lock alive until the guard is dropped. +/// +/// By holding the lock from the moment of the `removed_at` check until after +/// the caller's subscription eviction and workflow-disable complete, this +/// prevents the window where a concurrent [`add_member`] could commit between +/// the check and the effects (the race that [`verify_member_still_removed`] +/// leaves open, since it releases the lock before returning). +/// +/// The guard is read-only before [`Self::commit_disabling_workflows`] is called. +/// Once the caller has finished its effects, dropping the guard releases the +/// lock (the transaction is implicitly rolled back if not committed). To +/// durably write the workflow-disable and release the lock in one step, call +/// [`Self::commit_disabling_workflows`]. +pub async fn membership_removal_fence( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result { + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + + let row = sqlx::query( + "SELECT removed_at FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(pubkey) + .fetch_optional(&mut *tx) + .await?; + + // Row absent → never joined or hard-deleted; treat as removed (safe to + // skip eviction for someone who isn't and wasn't a member). + let still_removed = match row { + None => true, + Some(r) => { + let removed_at: Option> = r.try_get("removed_at")?; + removed_at.is_some() + } + }; + + Ok(MembershipRemovalFence { still_removed, tx }) +} + /// Return which of the given (channel, pubkey) combinations are active /// memberships, restricted to non-deleted channels — one statement for any /// batch size (T2b). Semantics per pair match [`is_member`]. @@ -1364,6 +1531,45 @@ impl Db { is_member(&self.pool, community_id, channel_id, pubkey).await } + /// Returns `true` when the member row still has `removed_at IS NOT NULL`, + /// holding the channel-membership advisory lock so the check serializes + /// with concurrent [`add_member`] calls. + /// + /// **Note:** this function releases the lock before returning. Use + /// [`Db::membership_removal_fence`] when the advisory lock must remain + /// held through subscription eviction and workflow-disable. + #[datastore_span(name = "verify_member_still_removed", system = "postgresql")] + pub async fn verify_member_still_removed( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result { + verify_member_still_removed(&self.pool, community_id, channel_id, pubkey).await + } + + /// Acquire the per-channel membership advisory lock and return a + /// [`MembershipRemovalFence`] that keeps the lock alive until it is + /// dropped or committed via [`MembershipRemovalFence::commit_disabling_workflows`]. + /// + /// The guard's `still_removed` field indicates whether the kick is still + /// the current state (i.e., no re-add has reversed it). When `true`, the + /// caller fires eviction and then calls + /// [`MembershipRemovalFence::commit_disabling_workflows`] to durably + /// write the workflow-disable and commit the transaction (releasing the + /// lock). When `false`, the member was re-added and effects must be skipped. + /// Dropping the guard without committing releases the advisory lock (the + /// transaction is implicitly rolled back). + #[datastore_span(name = "membership_removal_fence", system = "postgresql")] + pub async fn membership_removal_fence( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result { + membership_removal_fence(&self.pool, community_id, channel_id, pubkey).await + } + /// Return the active (channel, pubkey) membership pairs among the given /// sets, in one statement. #[datastore_span(name = "membership_pairs", system = "postgresql")] diff --git a/crates/buzz-db/src/store/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs index 7662077911..f50ab385b4 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -47,6 +47,12 @@ pub struct AdminActionRecord { pub cancelled_by: Option>, /// Error from the last failure, if any. pub error_message: Option, + /// Authoritative target pubkey persisted at claim time (kick/ban/timeout pubkey targets). + /// `None` for event/blob targets or actions with no pubkey target. + pub enforcement_target_pubkey: Option>, + /// Authoritative channel persisted at claim time (kick actions). + /// `None` for community-wide actions. + pub enforcement_channel_id: Option, /// Row creation time. pub created_at: DateTime, /// Row last-updated time. @@ -241,6 +247,7 @@ pub async fn claim_report( r#" SELECT id, report_id, report_community_id, request_id, actor_pubkey, actor_role, action, reason, timeout_until, state, step_marker, cancelled_by, error_message, + enforcement_target_pubkey, enforcement_channel_id, created_at, updated_at FROM relay_admin_actions WHERE report_community_id = $1 AND report_id = $2 AND request_id = $3 @@ -269,10 +276,11 @@ pub async fn claim_report( r#" INSERT INTO relay_admin_actions ( report_id, report_community_id, request_id, actor_pubkey, actor_role, - action, reason, timeout_until, state - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending') + action, reason, timeout_until, state, enforcement_target_pubkey, enforcement_channel_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, $10) RETURNING id, report_id, report_community_id, request_id, actor_pubkey, actor_role, action, reason, timeout_until, state, step_marker, cancelled_by, error_message, + enforcement_target_pubkey, enforcement_channel_id, created_at, updated_at "#, ) @@ -284,6 +292,8 @@ pub async fn claim_report( .bind(action) .bind(reason) .bind(timeout_until) + .bind(target_pubkey) + .bind(channel_id) .fetch_one(&mut *tx) .await?; @@ -1242,6 +1252,7 @@ pub async fn get_action(pool: &PgPool, action_id: Uuid) -> Result Result { step_marker: row.try_get("step_marker")?, cancelled_by: row.try_get("cancelled_by")?, error_message: row.try_get("error_message")?, + enforcement_target_pubkey: row.try_get("enforcement_target_pubkey")?, + enforcement_channel_id: row.try_get("enforcement_channel_id")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, }) diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index 043ab112cf..da02c7f75d 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -15,7 +15,7 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; -use sqlx::{PgPool, Row}; +use sqlx::{PgConnection, PgPool, Row}; use uuid::Uuid; use buzz_core::CommunityId; @@ -746,6 +746,35 @@ pub async fn disable_workflows_for_owner_in_channel( Ok(affected) } +/// Connection-local variant: run the same workflow-disable UPDATE on the +/// provided connection rather than acquiring a new one from the pool. +/// +/// Used by [`buzz_db::channel_members::MembershipRemovalFence::commit_disabling_workflows`] +/// so the disable executes on the fence's own connection, avoiding a second +/// pool acquisition while the fence holds one connection. +pub async fn disable_workflows_for_owner_in_channel_on_conn( + conn: &mut PgConnection, + community_id: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], +) -> Result { + let affected = sqlx::query( + r#" + UPDATE workflows + SET enabled = FALSE + WHERE community_id = $1 AND channel_id = $2 AND owner_pubkey = $3 AND enabled = TRUE + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(owner_pubkey) + .execute(&mut *conn) + .await? + .rows_affected(); + + Ok(affected) +} + /// Delete a workflow and all its runs/approvals (CASCADE). /// /// NOTE: see the cache-invalidation note on [`update_workflow`]. The relay's diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 19f2153b95..e9175d92ae 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -5180,6 +5180,207 @@ mod postgres_tests { ); } + // ── 1c. pre-marker stranded delete: crash before mutation, recovery re-drives ── + + /// Stranded delete: crash BEFORE the mutation+marker committed. + /// + /// Recovery worker must re-run the delete mutation, commit the step marker, + /// and finalize — resulting in the event being soft-deleted and both tombstone + /// and reporter_notice outbox rows created. + /// + /// This test guards against the Finding-2 regression introduced at ba34fb292: + /// if the persisted-context branch (`enforcement_target_pubkey` / `channel_id` + /// set) forces `target_event_id = None` for non-kick actions, delete recovery + /// fails pre-mutation with "delete requires target_event_id". Gate on action=="kick" + /// ensures non-kick actions always re-derive from the report. + #[tokio::test] + #[ignore = "requires Postgres — pre-marker stranded delete recovers correctly"] + async fn stranded_delete_pre_marker_recovers_via_worker() { + let pool = e2e_pool().await; + let (community_id, _host) = e2e_community(&pool, "stranded-delete-pre-marker").await; + let target_event_id: Vec = { + let u = uuid::Uuid::new_v4(); + u.as_bytes() + .iter() + .chain(u.as_bytes().iter()) + .copied() + .collect() + }; + let actor = vec![6u8; 32]; + let author = vec![7u8; 32]; + let cid = buzz_core::CommunityId::from_uuid(community_id); + + // Create a channel and seed the target event. + let channel_id = uuid::Uuid::new_v4(); + sqlx::query( + r#"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'stranded-del-pre-ch', 'stream', 'open', $3)"#, + ) + .bind(channel_id) + .bind(community_id) + .bind(&actor) + .execute(&pool) + .await + .expect("create channel"); + + let sig = vec![0u8; 64]; + sqlx::query( + r#"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id) + VALUES ($1, $2, $3, now(), 1, '[]', 'test', $4, now(), $5)"#, + ) + .bind(community_id) + .bind(target_event_id.as_slice()) + .bind(&author) + .bind(sig.as_slice()) + .bind(channel_id) + .execute(&pool) + .await + .expect("insert event"); + + // Create an event report. + let reporter = vec![0u8; 32]; + let report_event_raw: Vec = { + let u = uuid::Uuid::new_v4(); + u.as_bytes() + .iter() + .chain(u.as_bytes().iter()) + .copied() + .collect() + }; + let report_id: uuid::Uuid = sqlx::query_scalar( + r#"INSERT INTO moderation_reports + (community_id, report_event_id, reporter_pubkey, target_kind, target_event_id, + channel_id, report_type) + VALUES ($1, $2, $3, 'event', $4, $5, 'spam') RETURNING id"#, + ) + .bind(community_id) + .bind(report_event_raw.as_slice()) + .bind(&reporter) + .bind(target_event_id.as_slice()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("insert event report"); + + // Claim DELETE action but do NOT run the mutation or commit the step marker. + // This simulates a crash immediately after claim — pre-mutation state. + let action_id = match buzz_db::relay_admin_actions::claim_report( + &pool, + cid, + report_id, + uuid::Uuid::new_v4(), + &actor, + "operator", + "delete", + None, + None, + "resolve:delete", + "relay_operator", + None, + Some(target_event_id.as_slice()), + Some(channel_id), + ) + .await + .expect("claim") + { + buzz_db::relay_admin_actions::ClaimResult::Claimed(a) => a.id, + other => panic!("expected Claimed, got {other:?}"), + }; + + // Advance to enforcing but do not run mutation (crash point = post-claim, pre-mutation). + let _ = buzz_db::relay_admin_actions::begin_enforcing(&pool, action_id) + .await + .expect("begin_enforcing"); + + // Action is in enforcing state with no step_marker — stranded pre-mutation. + let rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action") + .expect("action exists"); + assert_eq!(rec.state, "enforcing"); + assert!(rec.step_marker.is_none(), "must have no step_marker yet"); + + // Also verify the event is NOT yet deleted. + let deleted_before: Option>> = + sqlx::query_scalar("SELECT deleted_at FROM events WHERE community_id = $1 AND id = $2") + .bind(community_id) + .bind(target_event_id.as_slice()) + .fetch_optional(&pool) + .await + .expect("fetch deleted_at before"); + assert!( + deleted_before.flatten().is_none(), + "event must not be deleted before recovery" + ); + + // Expire the lease so the stranded batch can claim it. + sqlx::query( + "UPDATE relay_admin_actions SET action_lease_expires_at = $2, action_lease_token = NULL WHERE id = $1", + ) + .bind(action_id) + .bind(chrono::Utc::now() - chrono::Duration::seconds(300)) + .execute(&pool) + .await + .expect("expire lease"); + + // Re-drive via the real recovery worker. + let batch = buzz_db::relay_admin_actions::claim_stranded_action_batch( + &pool, + "e2e-stranded-del-pre", + chrono::Utc::now() + chrono::Duration::seconds(120), + 1000, + ) + .await + .expect("claim_stranded_action_batch"); + let claim = batch + .into_iter() + .find(|c| c.record.id == action_id) + .expect("stranded action must appear in batch"); + + let state = state_from_pool(pool.clone()).await; + crate::handlers::admin_action_worker::recover_one(&state, claim).await; + + // Action must have converged to succeeded. + let final_rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action") + .expect("exists"); + assert_eq!( + final_rec.state, "succeeded", + "stranded pre-marker delete must converge to succeeded" + ); + + // Event must now be soft-deleted. + let deleted_after: Option>> = + sqlx::query_scalar("SELECT deleted_at FROM events WHERE community_id = $1 AND id = $2") + .bind(community_id) + .bind(target_event_id.as_slice()) + .fetch_optional(&pool) + .await + .expect("fetch deleted_at after"); + assert!( + deleted_after.flatten().is_some(), + "event must be soft-deleted after stranded pre-marker delete recovery" + ); + + // Tombstone + reporter_notice outbox rows must exist. + let outbox_rows: Vec = sqlx::query_scalar( + "SELECT task_type FROM relay_admin_outbox WHERE action_id = $1 ORDER BY task_type", + ) + .bind(action_id) + .fetch_all(&pool) + .await + .expect("fetch outbox rows"); + assert!( + outbox_rows.iter().any(|t| t == "tombstone"), + "tombstone outbox row must exist after delete recovery; got: {outbox_rows:?}" + ); + assert!( + outbox_rows.iter().any(|t| t == "reporter_notice"), + "reporter_notice outbox row must exist; got: {outbox_rows:?}" + ); + } + // ── 1b. timeout affected-user notice: worker renders the authoritative term ─ #[tokio::test] @@ -6029,17 +6230,15 @@ mod postgres_tests { } #[tokio::test] - #[ignore = "requires Postgres — stranded kick re-drive converges after mid-flight event purge"] + #[ignore = "requires Postgres — stranded kick re-drive converges and fires live side effects after mid-flight event purge"] async fn worker_redrive_of_event_kick_converges_after_event_purged_mid_flight() { // Criterion 3 + Paul's mid-flight edge: a kick on an event report claims, // commits its mutation+marker, then the event row is HARD-purged before a - // stranded re-drive. The worker re-derives from the (now author-less) - // report row; because the target is not persisted, the pubkey re-derives - // to None. The action is already past `mutation_committed`, so the driver - // skips the mutation and finalizes: action → succeeded, report → resolved. - // The kick already landed (member removed at commit time); only the - // system_message artifact (which needs the target pubkey) is dropped — - // the action does NOT strand permanently. + // stranded re-drive. Because target_pubkey and channel_id are now persisted + // in relay_admin_actions at claim time (migration 0047), recovery no longer + // re-derives them from the mutable event/report rows. All three live side + // effects fire on re-drive: cache invalidation, subscription eviction, and + // workflow disablement. The action converges to succeeded and report → resolved. let pool = e2e_pool().await; let (community_id, _host) = e2e_community(&pool, "worker-midflight-purge").await; let author = vec![0x71u8; 32]; @@ -6118,6 +6317,63 @@ mod postgres_tests { "kick must commit its mutation + marker before the crash" ); + // Seed in-process state that simulates a live session after the crash: + // a stale membership cache entry and an active channel subscription. + // These are what the crash-recovery live side effects must clear. + let cid = buzz_core::CommunityId::from_uuid(community_id); + let _tenant = e2e_tenant(community_id, "worker-midflight-purge.example"); + state + .membership_cache + .insert((cid, channel_id, author.clone()), true); + let conn_id = uuid::Uuid::new_v4(); + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + state.conn_manager.register( + conn_id, + tx, + ctrl_tx, + None, + tokio_util::sync::CancellationToken::new(), + cid, + std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), + std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, + ); + state + .conn_manager + .set_authenticated_pubkey(conn_id, author.clone()); + state.sub_registry.register_channels_scoped( + cid, + conn_id, + "purge-test-sub".to_string(), + vec![nostr::Filter::new()], + vec![channel_id], + ); + + // Seed an enabled workflow owned by the kicked user so we can verify disable. + // Seed a user row first: required by the workflows FK + // (community_id, owner_pubkey) → users (community_id, pubkey). + sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(community_id) + .bind(&author) + .execute(&pool) + .await + .expect("seed user row for workflow owner"); + let workflow_id = state + .db + .create_workflow( + cid, + Some(channel_id), + &author, + "purge-test-workflow", + r#"{"kind":"workflow"}"#, + &[0u8; 32], + ) + .await + .expect("create workflow"); + // Mid-flight disappearance: HARD-purge the stored event (community purge), // then expire the lease so the recovery worker can re-claim. sqlx::query("DELETE FROM events WHERE community_id = $1 AND id = $2") @@ -6135,7 +6391,9 @@ mod postgres_tests { .await .expect("expire lease"); - // Re-derive now yields no author — the exact divergence Paul flagged. + // Verify: re-deriving from the now-author-less report yields None (event + // is purged), but recovery does NOT depend on re-derivation — it reads + // the persisted enforcement_target_pubkey / enforcement_channel_id columns. let report_after = state .db .admin_get_report(report_id) @@ -6145,7 +6403,10 @@ mod postgres_tests { let (target_after, _e) = crate::handlers::report_resolution::derive_enforcement_target(&report_after) .expect("derive after purge"); - assert_eq!(target_after, None, "author unresolvable after purge"); + assert_eq!( + target_after, None, + "confirm: re-derivation path yields None after purge (recovery must NOT use this path)" + ); // Re-drive through the REAL recovery worker entry point. let batch = buzz_db::relay_admin_actions::claim_stranded_action_batch( @@ -6162,8 +6423,41 @@ mod postgres_tests { .expect("stranded action must appear in batch"); crate::handlers::admin_action_worker::recover_one(&state, claim).await; + // ── Assertions: all three live side effects fired ───────────────────── // Convergence: action succeeded (marker was already committed), report - // resolved. No permanent strand despite the vanished target. + // resolved. Persisted target context means the event purge no longer + // prevents live side effects from firing. + + // 1. Membership cache cleared. + assert!( + state + .membership_cache + .get(&(cid, channel_id, author.clone())) + .is_none(), + "crash-recovery must clear membership cache after purged-event kick re-drive" + ); + + // 2. Channel subscription evicted. + assert!( + !state + .sub_registry + .channel_subscriber_conns_scoped(cid, channel_id) + .contains(&conn_id), + "crash-recovery must evict channel subscription after purged-event kick re-drive" + ); + + // 3. Workflow disabled. + let wf = state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get_workflow"); + assert!( + !wf.enabled, + "crash-recovery must disable target's workflows after purged-event kick re-drive" + ); + + // 4. DB state. let final_rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) .await .expect("get_action") @@ -7602,4 +7896,1781 @@ mod postgres_tests { "row claim token must belong to worker B (stale A token must not rewrite)" ); } + + // ── Kick live side effects: crash-recovery and re-add race ──────────────── + + /// Recovery seam: commit kick marker, simulate crash, re-drive via the real + /// recovery worker entry point and assert all three live side effects fire: + /// membership cache cleared, channel subscription evicted, workflow disabled. + /// + /// The test is falsifiable: deleting the convergence-point call to + /// `apply_kick_live_side_effects` in `drive_enforcement` would leave the + /// helper-direct test green but fail this test (recovery would finalize + /// without clearing cache, evicting, or disabling). + #[tokio::test] + #[ignore = "requires Postgres — crash recovery re-drive fires all three kick live side effects"] + async fn crash_recovery_redrive_fires_kick_live_side_effects() { + let pool = e2e_pool().await; + let (community_id, host) = e2e_community(&pool, "kick-crash-recovery").await; + let cid = buzz_core::CommunityId::from_uuid(community_id); + let target = vec![0xAAu8; 32]; + let actor = vec![0xBBu8; 32]; + + // Seed channel + member. + let channel_id = uuid::Uuid::new_v4(); + sqlx::query( + r#"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'recovery-test-ch', 'stream', 'open', $3)"#, + ) + .bind(channel_id) + .bind(community_id) + .bind(&actor) + .execute(&pool) + .await + .expect("create channel"); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role) VALUES ($1, $2, $3, 'member')", + ) + .bind(community_id) + .bind(channel_id) + .bind(&target) + .execute(&pool) + .await + .expect("add member"); + + let report_id = e2e_report_pubkey(&pool, community_id, &target).await; + + // Claim → enforcing → lease → kick + commit marker (simulates process + // that committed the kick but crashed before live side effects ran). + let action_id = match buzz_db::relay_admin_actions::claim_report( + &pool, + cid, + report_id, + uuid::Uuid::new_v4(), + &actor, + "operator", + "kick", + None, + None, + "resolve:kick", + "relay_operator", + Some(&target), + None, + Some(channel_id), + ) + .await + .expect("claim") + { + buzz_db::relay_admin_actions::ClaimResult::Claimed(a) => a.id, + other => panic!("expected Claimed, got {other:?}"), + }; + let _ = buzz_db::relay_admin_actions::begin_enforcing(&pool, action_id) + .await + .expect("begin_enforcing"); + let lease_until = chrono::Utc::now() + chrono::Duration::seconds(60); + let lease_token = + match buzz_db::relay_admin_actions::acquire_action_lease(&pool, action_id, lease_until) + .await + .expect("acquire lease") + { + buzz_db::relay_admin_actions::LeaseResult::Acquired(t) => t, + other => panic!("expected Acquired, got {other:?}"), + }; + let kick_result = buzz_db::relay_admin_actions::execute_kick_with_marker( + &pool, + action_id, + lease_token, + cid, + channel_id, + &target, + &actor, + ) + .await + .expect("execute_kick_with_marker"); + assert!( + matches!( + kick_result, + buzz_db::relay_admin_actions::KickWithMarkerResult::Removed + ), + "kick must commit before the simulated crash" + ); + + // Expire the lease so the recovery worker can re-claim. + sqlx::query( + "UPDATE relay_admin_actions SET action_lease_expires_at = $2, action_lease_token = NULL WHERE id = $1", + ) + .bind(action_id) + .bind(chrono::Utc::now() - chrono::Duration::seconds(300)) + .execute(&pool) + .await + .expect("expire lease"); + + // Build state with real DB. Seed stale in-process entries. + let state = state_from_pool(pool.clone()).await; + let _tenant = e2e_tenant(community_id, &host); + + state + .membership_cache + .insert((cid, channel_id, target.clone()), true); + + let conn_id = uuid::Uuid::new_v4(); + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + state.conn_manager.register( + conn_id, + tx, + ctrl_tx, + None, + tokio_util::sync::CancellationToken::new(), + cid, + std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), + std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, + ); + state + .conn_manager + .set_authenticated_pubkey(conn_id, target.clone()); + state.sub_registry.register_channels_scoped( + cid, + conn_id, + "recovery-seam-sub".to_string(), + vec![nostr::Filter::new()], + vec![channel_id], + ); + + // Seed a user row for the target: required by the workflows FK + // (community_id, owner_pubkey) → users (community_id, pubkey). + sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(community_id) + .bind(&target) + .execute(&pool) + .await + .expect("seed user row for workflow owner"); + + // Seed an enabled owned workflow; disable must be asserted after recovery. + let workflow_id = state + .db + .create_workflow( + cid, + Some(channel_id), + &target, + "recovery-test-workflow", + r#"{"kind":"workflow"}"#, + &[0u8; 32], + ) + .await + .expect("create workflow"); + + // Pre-conditions. + assert!( + state + .membership_cache + .get(&(cid, channel_id, target.clone())) + .is_some(), + "pre-condition: membership cache entry must exist before recovery" + ); + assert!( + state + .sub_registry + .channel_subscriber_conns_scoped(cid, channel_id) + .contains(&conn_id), + "pre-condition: subscription must be registered before recovery" + ); + assert!( + state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get workflow") + .enabled, + "pre-condition: workflow must be enabled before recovery" + ); + + // Re-drive via the real recovery worker path. + let batch = buzz_db::relay_admin_actions::claim_stranded_action_batch( + &pool, + "e2e-kick-recovery", + chrono::Utc::now() + chrono::Duration::seconds(120), + 1000, + ) + .await + .expect("claim_stranded_action_batch"); + let claim = batch + .into_iter() + .find(|c| c.record.id == action_id) + .expect("stranded action must appear in batch"); + crate::handlers::admin_action_worker::recover_one(&state, claim).await; + + // ── Assertions ─────────────────────────────────────────────────────── + + // 1. Membership cache cleared. + assert!( + state + .membership_cache + .get(&(cid, channel_id, target.clone())) + .is_none(), + "crash-recovery re-drive must clear the membership cache" + ); + + // 2. Channel subscription evicted. + assert!( + !state + .sub_registry + .channel_subscriber_conns_scoped(cid, channel_id) + .contains(&conn_id), + "crash-recovery re-drive must evict the kicked user's channel subscription" + ); + + // 3. Workflow disabled. + assert!( + !state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get workflow") + .enabled, + "crash-recovery re-drive must disable the kicked user's workflows" + ); + + // 4. Action reached succeeded. + let final_rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action") + .expect("record must still exist"); + assert_eq!( + final_rec.state, "succeeded", + "action must reach succeeded after crash-recovery re-drive" + ); + } + + /// Re-add race: kick commits → crash → `add_member` re-adds → recovery fires. + /// + /// Scenario: the member was legitimately re-added (via `add_member`) BEFORE + /// the recovery worker acquires the membership fence. When the fence is + /// acquired, `removed_at IS NULL` — the re-add already committed. Recovery + /// must NOT evict subscriptions or disable workflows for the now-valid member. + /// + /// This test is falsifiable: removing the `membership_removal_fence` check + /// from `apply_kick_live_side_effects` would cause the recovery to always + /// fire effects, wrongly evicting the re-added member's subscription and + /// disabling their workflow. + /// + /// Uses real `add_member` (not direct SQL) to exercise the advisory-lock + /// serialization: `add_member` sets `removed_at = NULL` inside the lock and + /// commits before the fence is acquired, so the fence observes the re-add. + #[tokio::test] + #[ignore = "requires Postgres — post-kick re-add survives crash recovery"] + async fn crash_recovery_after_readd_preserves_membership_subscriptions_and_workflows() { + let pool = e2e_pool().await; + let (community_id, host) = e2e_community(&pool, "kick-readd-race").await; + let cid = buzz_core::CommunityId::from_uuid(community_id); + let target = vec![0xCCu8; 32]; + let actor = vec![0xDDu8; 32]; + + // Seed channel + member. + let channel_id = uuid::Uuid::new_v4(); + sqlx::query( + r#"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'readd-race-ch', 'stream', 'open', $3)"#, + ) + .bind(channel_id) + .bind(community_id) + .bind(&actor) + .execute(&pool) + .await + .expect("create channel"); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role) VALUES ($1, $2, $3, 'member')", + ) + .bind(community_id) + .bind(channel_id) + .bind(&target) + .execute(&pool) + .await + .expect("add member"); + + let report_id = e2e_report_pubkey(&pool, community_id, &target).await; + + // Claim → enforcing → kick + commit marker. + let action_id = match buzz_db::relay_admin_actions::claim_report( + &pool, + cid, + report_id, + uuid::Uuid::new_v4(), + &actor, + "operator", + "kick", + None, + None, + "resolve:kick", + "relay_operator", + Some(&target), + None, + Some(channel_id), + ) + .await + .expect("claim") + { + buzz_db::relay_admin_actions::ClaimResult::Claimed(a) => a.id, + other => panic!("expected Claimed, got {other:?}"), + }; + let _ = buzz_db::relay_admin_actions::begin_enforcing(&pool, action_id) + .await + .expect("begin_enforcing"); + let lease_until = chrono::Utc::now() + chrono::Duration::seconds(60); + let lease_token = + match buzz_db::relay_admin_actions::acquire_action_lease(&pool, action_id, lease_until) + .await + .expect("acquire lease") + { + buzz_db::relay_admin_actions::LeaseResult::Acquired(t) => t, + other => panic!("expected Acquired, got {other:?}"), + }; + buzz_db::relay_admin_actions::execute_kick_with_marker( + &pool, + action_id, + lease_token, + cid, + channel_id, + &target, + &actor, + ) + .await + .expect("execute_kick_with_marker"); + + // Expire lease so recovery worker can reclaim. + sqlx::query( + "UPDATE relay_admin_actions SET action_lease_expires_at = $2, action_lease_token = NULL WHERE id = $1", + ) + .bind(action_id) + .bind(chrono::Utc::now() - chrono::Duration::seconds(300)) + .execute(&pool) + .await + .expect("expire lease"); + + // Re-add the member via the real `add_member` (not direct SQL). + // This acquires and releases the membership advisory lock, setting + // removed_at = NULL before the recovery worker's fence is acquired. + buzz_db::channel_members::add_member( + &pool, + cid, + channel_id, + &target, + buzz_db::channel::MemberRole::Member, + None, + ) + .await + .expect("re-add member via add_member"); + + // Verify pre-condition: member is active again. + let still_member = buzz_db::channel_members::is_member(&pool, cid, channel_id, &target) + .await + .expect("is_member"); + assert!( + still_member, + "pre-condition: member must be active after re-add" + ); + + // Build state with real DB. Register a live subscription and workflow + // for the re-added member — recovery must NOT revoke these. + let state = state_from_pool(pool.clone()).await; + let _tenant = e2e_tenant(community_id, &host); + + let conn_id = uuid::Uuid::new_v4(); + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + state.conn_manager.register( + conn_id, + tx, + ctrl_tx, + None, + tokio_util::sync::CancellationToken::new(), + cid, + std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), + std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, + ); + state + .conn_manager + .set_authenticated_pubkey(conn_id, target.clone()); + state.sub_registry.register_channels_scoped( + cid, + conn_id, + "readd-race-sub".to_string(), + vec![nostr::Filter::new()], + vec![channel_id], + ); + + // Seed a user row for the target: required by the workflows FK + // (community_id, owner_pubkey) → users (community_id, pubkey). + sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(community_id) + .bind(&target) + .execute(&pool) + .await + .expect("seed user row for workflow owner"); + + let workflow_id = state + .db + .create_workflow( + cid, + Some(channel_id), + &target, + "readd-race-workflow", + r#"{"kind":"workflow"}"#, + &[0u8; 32], + ) + .await + .expect("create workflow"); + + // Re-drive via the real recovery worker. + let batch = buzz_db::relay_admin_actions::claim_stranded_action_batch( + &pool, + "e2e-readd-race", + chrono::Utc::now() + chrono::Duration::seconds(120), + 1000, + ) + .await + .expect("claim_stranded_action_batch"); + let claim = batch + .into_iter() + .find(|c| c.record.id == action_id) + .expect("stranded action must appear in batch"); + crate::handlers::admin_action_worker::recover_one(&state, claim).await; + + // ── Assertions: re-added member's live session must survive ────────── + + // 1. Subscription NOT evicted: fence observed re-add, skipped eviction. + assert!( + state + .sub_registry + .channel_subscriber_conns_scoped(cid, channel_id) + .contains(&conn_id), + "recovery must NOT evict the subscription of a re-added member" + ); + + // 2. Workflow NOT disabled: fence observed re-add, skipped disable. + assert!( + state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get workflow") + .enabled, + "recovery must NOT disable workflows of a re-added member" + ); + + // 3. DB membership active after re-add (not further altered by recovery). + assert!( + state + .db + .is_member(cid, channel_id, &target) + .await + .expect("is_member"), + "recovery must NOT remove the re-added membership row" + ); + + // 4. Action still converges to succeeded (the kick itself already landed). + let final_rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action") + .expect("exists"); + assert_eq!( + final_rec.state, "succeeded", + "action must reach succeeded even when re-add fence suppresses eviction" + ); + } + + /// Fence ordering: the membership advisory lock is held through eviction and + /// workflow-disable, so `add_member` cannot commit between the removed-at + /// check and the destructive effects. + /// + /// This test verifies the other direction of the race: the fence is acquired + /// BEFORE any concurrent `add_member`. While the fence is held, an `add_member` + /// in a separate task must block and cannot commit until the fence is released. + /// + /// This is falsifiable: if `membership_removal_fence` released the transaction + /// lock before returning (as the old `verify_member_still_removed` did), the + /// `add_member` task would complete while effects are still running. + #[tokio::test] + #[ignore = "requires Postgres — membership_removal_fence holds advisory lock through effects"] + async fn membership_removal_fence_blocks_concurrent_add_member() { + let pool = e2e_pool().await; + let (community_id, _host) = e2e_community(&pool, "fence-ordering").await; + let cid = buzz_core::CommunityId::from_uuid(community_id); + let target = vec![0xEEu8; 32]; + let actor = vec![0xFFu8; 32]; + + // Seed channel + member. + let channel_id = uuid::Uuid::new_v4(); + sqlx::query( + r#"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'fence-ordering-ch', 'stream', 'open', $3)"#, + ) + .bind(channel_id) + .bind(community_id) + .bind(&actor) + .execute(&pool) + .await + .expect("create channel"); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role) VALUES ($1, $2, $3, 'member')", + ) + .bind(community_id) + .bind(channel_id) + .bind(&target) + .execute(&pool) + .await + .expect("add member"); + + // Kick: set removed_at (simulate kick committed). + sqlx::query( + "UPDATE channel_members SET removed_at = now(), removed_by = $4 \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community_id) + .bind(channel_id) + .bind(&target) + .bind(&actor) + .execute(&pool) + .await + .expect("kick member"); + + // Acquire the fence — this holds the membership advisory lock. + let fence = + buzz_db::channel_members::membership_removal_fence(&pool, cid, channel_id, &target) + .await + .expect("acquire fence"); + assert!(fence.still_removed, "pre-condition: member must be removed"); + + // Spawn `add_member` in a separate task. It must NOT complete while the + // fence is held — the advisory lock blocks it. + let pool2 = pool.clone(); + let target_clone = target.clone(); + let mut add_task = tokio::spawn(async move { + buzz_db::channel_members::add_member( + &pool2, + cid, + channel_id, + &target_clone, + buzz_db::channel::MemberRole::Member, + None, + ) + .await + }); + + // `add_member` must block: the fence holds the same advisory lock. + let blocked = + tokio::time::timeout(std::time::Duration::from_millis(500), &mut add_task).await; + assert!( + blocked.is_err(), + "add_member must not complete while the membership fence is held" + ); + + // Release the fence — the advisory lock is dropped when `fence` is dropped. + drop(fence); + + // Now `add_member` can proceed. + tokio::time::timeout(std::time::Duration::from_secs(5), add_task) + .await + .expect("add_member must proceed after fence is released") + .expect("add_member task panicked") + .expect("add_member must succeed after fence is released"); + + // Confirm member is active again. + assert!( + buzz_db::channel_members::is_member(&pool, cid, channel_id, &target) + .await + .expect("is_member"), + "member must be active after add_member succeeds" + ); + } + + /// Pool-size-1 regression: `commit_disabling_workflows` runs the UPDATE on + /// the fence's own connection so no second pool connection is needed. + /// + /// This test would deadlock (or time out and skip the disable) on the + /// previous two-connection implementation: the fence holds the one available + /// connection while `disable_workflows_for_owner_in_channel(&pool, …)` waits + /// for another. With the fix, only the fence's connection is used for both + /// the lock and the UPDATE, so the test must complete and the workflow row + /// must be durably disabled. + #[tokio::test] + #[ignore = "requires Postgres — pool-size-1 fence workflow-disable does not self-deadlock"] + async fn pool_size_1_fence_commit_disabling_workflows_completes_without_deadlock() { + // Single-connection pool: every additional acquire blocks until the + // current holder releases. This makes the old two-connection path + // deterministically self-deadlock. + let url = database_url(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .expect("connect with pool-size-1"); + + let (community_id, host) = e2e_community(&pool, "fence-pool1").await; + let cid = buzz_core::CommunityId::from_uuid(community_id); + let target = vec![0x11u8; 32]; + let actor = vec![0x22u8; 32]; + + // Seed channel + member. + let channel_id = uuid::Uuid::new_v4(); + sqlx::query( + r#"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'fence-pool1-ch', 'stream', 'open', $3)"#, + ) + .bind(channel_id) + .bind(community_id) + .bind(&actor) + .execute(&pool) + .await + .expect("create channel"); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role) VALUES ($1, $2, $3, 'member')", + ) + .bind(community_id) + .bind(channel_id) + .bind(&target) + .execute(&pool) + .await + .expect("add member"); + + // Simulate kick committed: set removed_at. + sqlx::query( + "UPDATE channel_members SET removed_at = now(), removed_by = $4 \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community_id) + .bind(channel_id) + .bind(&target) + .bind(&actor) + .execute(&pool) + .await + .expect("kick member"); + + // Seed a user row and an enabled workflow for the target. + sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(community_id) + .bind(&target) + .execute(&pool) + .await + .expect("seed user"); + let state = state_from_pool(pool.clone()).await; + let workflow_id = state + .db + .create_workflow( + cid, + Some(channel_id), + &target, + "pool1-fence-workflow", + r#"{"kind":"workflow"}"#, + &[0u8; 32], + ) + .await + .expect("create workflow"); + + // Pre-condition: workflow is enabled. + assert!( + state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get workflow") + .enabled, + "pre-condition: workflow must be enabled" + ); + + // The pool has max_connections=1. The fence acquires that one connection. + // On the old code, commit_disabling_workflows would try to acquire a + // SECOND connection from the pool here and deadlock (the fence still holds + // the first). With the fix, the UPDATE runs on the fence's own connection. + let tenant = buzz_core::tenant::TenantContext::resolved(cid, host.clone()); + tokio::time::timeout( + std::time::Duration::from_secs(10), + crate::handlers::side_effects::apply_kick_live_side_effects( + &tenant, &state, channel_id, &target, + ), + ) + .await + .expect("apply_kick_live_side_effects must complete without deadlock on pool-size-1") + .expect("apply_kick_live_side_effects must succeed on pool-size-1"); + + // Post-condition: workflow is disabled — the UPDATE committed. + assert!( + !state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get workflow after effects") + .enabled, + "workflow must be durably disabled after kick live effects" + ); + } + + /// Workflow-disable transaction failure regression: the specific seam that + /// SEC-006 protects. + /// + /// A `BEFORE UPDATE` trigger on `workflows` is installed to raise an + /// exception when the workflow-disable UPDATE fires (after fence acquisition + /// and member eviction have already succeeded). The trigger is scoped to + /// one workflow row so it cannot affect unrelated tests running concurrently. + /// + /// Phase 1 — fault injected: + /// `drive_enforcement_pub` must return `Err` and leave the action at + /// `enforcing / mutation_committed`. The workflow must remain enabled + /// (disable rolled back). + /// + /// Phase 2 — fault removed, lease expired, recovery worker re-drives: + /// `claim_stranded_action_batch` must surface the action, `recover_one` + /// must drive it to `succeeded`, and the workflow must be durably disabled. + /// + /// This test was introduced because the original failure-path test only + /// exercised the fence-acquisition arm (closed pool, pre-effects). The + /// transaction-failure arm — the actual finding — was structurally correct + /// but unbound. + #[tokio::test] + #[ignore = "requires Postgres — workflow-disable tx failure must not finalize kick as succeeded, and recovery worker must converge"] + async fn kick_live_effects_disable_tx_failure_does_not_finalize_and_retries_to_success() { + let pool = e2e_pool().await; + let (community_id, host) = e2e_community(&pool, "wf-tx-fail").await; + let cid = buzz_core::CommunityId::from_uuid(community_id); + let target = vec![0xEEu8; 32]; + let actor = vec![0xFFu8; 32]; + + // Seed channel + member. + let channel_id = uuid::Uuid::new_v4(); + sqlx::query( + r#"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'wf-tx-fail-ch', 'stream', 'open', $3)"#, + ) + .bind(channel_id) + .bind(community_id) + .bind(&actor) + .execute(&pool) + .await + .expect("create channel"); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role) VALUES ($1, $2, $3, 'member')", + ) + .bind(community_id) + .bind(channel_id) + .bind(&target) + .execute(&pool) + .await + .expect("add member"); + + // Seed user + enabled workflow owned by the kick target. + sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(community_id) + .bind(&target) + .execute(&pool) + .await + .expect("seed user"); + let state = state_from_pool(pool.clone()).await; + let workflow_id = state + .db + .create_workflow( + cid, + Some(channel_id), + &target, + "wf-tx-fail-workflow", + r#"{"kind":"workflow"}"#, + &[0u8; 32], + ) + .await + .expect("create workflow"); + + // Confirm pre-condition: workflow enabled. + assert!( + state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get workflow") + .enabled, + "pre-condition: workflow must be enabled" + ); + + // Claim → enforcing → lease → execute kick mutation + marker. + let report_id = e2e_report_pubkey(&pool, community_id, &target).await; + let action_id = match buzz_db::relay_admin_actions::claim_report( + &pool, + cid, + report_id, + uuid::Uuid::new_v4(), + &actor, + "operator", + "kick", + None, + None, + "resolve:kick", + "relay_operator", + Some(&target), + None, + Some(channel_id), + ) + .await + .expect("claim") + { + buzz_db::relay_admin_actions::ClaimResult::Claimed(a) => a.id, + other => panic!("expected Claimed, got {other:?}"), + }; + let _ = buzz_db::relay_admin_actions::begin_enforcing(&pool, action_id) + .await + .expect("begin_enforcing"); + let lease_until = chrono::Utc::now() + chrono::Duration::seconds(120); + let lease_token = + match buzz_db::relay_admin_actions::acquire_action_lease(&pool, action_id, lease_until) + .await + .expect("acquire lease") + { + buzz_db::relay_admin_actions::LeaseResult::Acquired(t) => t, + other => panic!("expected Acquired, got {other:?}"), + }; + buzz_db::relay_admin_actions::execute_kick_with_marker( + &pool, + action_id, + lease_token, + cid, + channel_id, + &target, + &actor, + ) + .await + .expect("execute_kick_with_marker"); + + // Pre-condition: mutation_committed set, action still enforcing. + let rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action") + .expect("action exists"); + assert_eq!(rec.step_marker.as_deref(), Some("mutation_committed")); + assert_eq!(rec.state, "enforcing"); + + // ── Phase 1: inject a trigger that raises on workflow-disable UPDATE ── + // + // The trigger function raises immediately for the seeded workflow row so + // the UPDATE inside `commit_disabling_workflows` fails after the fence + // acquires its connection and the member row is already removed. It is + // DROP-ped before the retry, so recovery sees a clean database. + let fn_name = format!("raise_for_wf_{}", workflow_id.simple()); + let trigger_name = format!("trg_raise_for_wf_{}", workflow_id.simple()); + + sqlx::query(sqlx::AssertSqlSafe(format!( + r#" + CREATE OR REPLACE FUNCTION {fn_name}() + RETURNS TRIGGER LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.id = '{workflow_id}' THEN + RAISE EXCEPTION 'injected fault: workflow-disable tx failure for test'; + END IF; + RETURN NEW; + END; + $$ + "# + ))) + .execute(&pool) + .await + .expect("create fault-injection function"); + + sqlx::query(sqlx::AssertSqlSafe(format!( + r#" + CREATE TRIGGER {trigger_name} + BEFORE UPDATE ON workflows + FOR EACH ROW EXECUTE FUNCTION {fn_name}() + "# + ))) + .execute(&pool) + .await + .expect("create fault-injection trigger"); + + // Drive enforcement — the workflow-disable UPDATE will raise, which + // means `commit_disabling_workflows` returns Err, which propagates + // through `apply_kick_live_side_effects` and out of `drive_enforcement` + // BEFORE `finalize_action_success`. + let tenant = e2e_tenant(community_id, &host); + let result = crate::handlers::report_resolution::drive_enforcement_pub( + &state, + &tenant, + cid, + report_id, + "kick", + None, + None, + &actor, + Some(&target), + None, + Some(channel_id), + &rec, + None, + ) + .await; + + assert!( + result.is_err(), + "drive_enforcement_pub must return Err when workflow-disable tx fails, got Ok" + ); + + // Action must remain enforcing / mutation_committed — not succeeded. + let after_fault = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action after fault") + .expect("record must still exist"); + assert_eq!( + after_fault.state, "enforcing", + "action must remain enforcing when disable tx fails, not finalize as succeeded" + ); + assert_eq!( + after_fault.step_marker.as_deref(), + Some("mutation_committed"), + "mutation_committed marker must be preserved for retry" + ); + + // Workflow must still be enabled — the UPDATE rolled back. + assert!( + state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get workflow after fault") + .enabled, + "workflow must remain enabled after rolled-back disable tx" + ); + + // ── Phase 2: remove fault, expire lease, recovery worker converges ──── + + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP TRIGGER IF EXISTS {trigger_name} ON workflows" + ))) + .execute(&pool) + .await + .expect("drop fault trigger"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP FUNCTION IF EXISTS {fn_name}()" + ))) + .execute(&pool) + .await + .expect("drop fault function"); + + // Expire the lease so claim_stranded_action_batch can reclaim it. + sqlx::query( + "UPDATE relay_admin_actions SET action_lease_expires_at = $2, action_lease_token = NULL WHERE id = $1", + ) + .bind(action_id) + .bind(chrono::Utc::now() - chrono::Duration::seconds(300)) + .execute(&pool) + .await + .expect("expire lease"); + + let batch = buzz_db::relay_admin_actions::claim_stranded_action_batch( + &pool, + "e2e-wf-tx-fail-worker", + chrono::Utc::now() + chrono::Duration::seconds(120), + 1000, + ) + .await + .expect("claim_stranded_action_batch"); + let claim = batch + .into_iter() + .find(|c| c.record.id == action_id) + .expect("stranded action must appear in batch after lease expiry"); + + crate::handlers::admin_action_worker::recover_one(&state, claim).await; + + // Recovery must have succeeded and disabled the workflow durably. + let final_rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action after recover_one") + .expect("record still exists"); + assert_eq!( + final_rec.state, "succeeded", + "action must be succeeded after recovery worker re-drives with fault removed" + ); + + let wf_after = state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get workflow after recovery"); + assert!( + !wf_after.enabled, + "workflow must be durably disabled after successful recovery re-drive" + ); + } + + /// Failure-path regression (fence-acquire arm): when `apply_kick_live_side_effects` + /// fails because fence acquisition itself fails, the action must NOT be + /// finalized as `succeeded`. + /// + /// This test closes the pool before `drive_enforcement_pub` so the fence + /// acquire fails immediately, binding the propagation arm for that failure + /// mode. For the disable-UPDATE/commit-failure arm, see + /// `kick_live_effects_disable_tx_failure_does_not_finalize_and_retries_to_success`. + #[tokio::test] + #[ignore = "requires Postgres — live-effects failure must not finalize action as succeeded"] + async fn kick_live_effects_failure_does_not_finalize_as_succeeded() { + // Use a separate pool to pre-stage the DB rows, then build the state + // from a pool that is immediately closed to force the fence to fail. + let pool = e2e_pool().await; + let (community_id, host) = e2e_community(&pool, "kick-effects-fail").await; + let cid = buzz_core::CommunityId::from_uuid(community_id); + let target = vec![0xCCu8; 32]; + let actor = vec![0xDDu8; 32]; + + // Seed channel + kicked member. + let channel_id = uuid::Uuid::new_v4(); + sqlx::query( + r#"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'effects-fail-ch', 'stream', 'open', $3)"#, + ) + .bind(channel_id) + .bind(community_id) + .bind(&actor) + .execute(&pool) + .await + .expect("create channel"); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role) VALUES ($1, $2, $3, 'member')", + ) + .bind(community_id) + .bind(channel_id) + .bind(&target) + .execute(&pool) + .await + .expect("add member"); + + let report_id = e2e_report_pubkey(&pool, community_id, &target).await; + + // Claim → enforcing → lease → execute kick (commits removal + marker). + let action_id = match buzz_db::relay_admin_actions::claim_report( + &pool, + cid, + report_id, + uuid::Uuid::new_v4(), + &actor, + "operator", + "kick", + None, + None, + "resolve:kick", + "relay_operator", + Some(&target), + None, + Some(channel_id), + ) + .await + .expect("claim") + { + buzz_db::relay_admin_actions::ClaimResult::Claimed(a) => a.id, + other => panic!("expected Claimed, got {other:?}"), + }; + let _ = buzz_db::relay_admin_actions::begin_enforcing(&pool, action_id) + .await + .expect("begin_enforcing"); + let lease_until = chrono::Utc::now() + chrono::Duration::seconds(120); + let lease_token = + match buzz_db::relay_admin_actions::acquire_action_lease(&pool, action_id, lease_until) + .await + .expect("acquire lease") + { + buzz_db::relay_admin_actions::LeaseResult::Acquired(t) => t, + other => panic!("expected Acquired, got {other:?}"), + }; + buzz_db::relay_admin_actions::execute_kick_with_marker( + &pool, + action_id, + lease_token, + cid, + channel_id, + &target, + &actor, + ) + .await + .expect("execute_kick_with_marker"); + + // Verify pre-condition: mutation_committed set, action still enforcing. + let rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action") + .expect("action exists"); + assert_eq!(rec.step_marker.as_deref(), Some("mutation_committed")); + assert_eq!(rec.state, "enforcing"); + + // Build state from a pool that is immediately closed so every DB + // operation in the live-effects path will fail — specifically the + // membership_removal_fence acquire. + let dying_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url()) + .await + .expect("connect dying pool"); + let state = state_from_pool(dying_pool.clone()).await; + dying_pool.close().await; + + let tenant = e2e_tenant(community_id, &host); + let result = crate::handlers::report_resolution::drive_enforcement_pub( + &state, + &tenant, + cid, + report_id, + "kick", + None, + None, + &actor, + Some(&target), + None, + Some(channel_id), + &rec, + None, + ) + .await; + + // drive_enforcement_pub must return Err, not Ok. + assert!( + result.is_err(), + "drive_enforcement_pub must return Err when live effects fail, got Ok" + ); + + // Action must NOT have been finalized as succeeded — it must remain + // in enforcing with mutation_committed so the worker can retry. + // (Use the original pool, which is still open, to check.) + let after_rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action after") + .expect("record must still exist"); + assert_eq!( + after_rec.state, "enforcing", + "action must remain in enforcing state, not succeeded, when live effects fail" + ); + assert_eq!( + after_rec.step_marker.as_deref(), + Some("mutation_committed"), + "mutation_committed marker must be preserved for retry when live effects fail" + ); + } + + // ── Pre-migration kick row upgrade-recovery (pre-marker) ───────────────── + + /// A kick action created by the old writer (before migration 0047 applied) + /// has NULL enforcement_target_pubkey and enforcement_channel_id. After the + /// migration the recovery worker finds this row pre-marker (no mutation + /// committed yet), re-derives the target from the report, and drives the full + /// state machine: kick executes, marker is set, live side effects fire, and + /// the action finalizes to succeeded. + /// + /// This test is falsifiable: reverting the convergence-gate fallback in + /// `drive_enforcement` (removing the `.or(target_pubkey)` / `.or(channel_id)` + /// lines) causes the gate to return the "unresolvable target" error instead + /// of calling `apply_kick_live_side_effects`, leaving the action stuck in + /// enforcing/mutation_committed. + #[tokio::test] + #[ignore = "requires Postgres — pre-migration NULL kick row converges via recovery worker (pre-marker path)"] + async fn legacy_kick_row_pre_marker_recovers_via_worker() { + let pool = e2e_pool().await; + let (community_id, _host) = e2e_community(&pool, "legacy-kick-pre-marker").await; + let cid = buzz_core::CommunityId::from_uuid(community_id); + let target = vec![0xE1u8; 32]; + let actor = vec![0xE2u8; 32]; + + // Seed an event report with author=target so: + // 1. report.report.channel_id is non-NULL (kick requires it) + // 2. derive_enforcement_target_pub returns Some(target) via the event + // author join, giving the convergence gate a real pubkey fallback + // 3. target is already a channel member (helper seeds it) + let (report_id, channel_id, _) = + e2e_event_report_with_author(&pool, community_id, &target).await; + + // Claim via the normal path (populates enforcement columns), then clear + // them to simulate the old writer that did not know about migration 0047. + let action_id = match buzz_db::relay_admin_actions::claim_report( + &pool, + cid, + report_id, + uuid::Uuid::new_v4(), + &actor, + "operator", + "kick", + None, + None, + "resolve:kick", + "relay_operator", + Some(&target), + None, + Some(channel_id), + ) + .await + .expect("claim") + { + buzz_db::relay_admin_actions::ClaimResult::Claimed(a) => a.id, + other => panic!("expected Claimed, got {other:?}"), + }; + + // Simulate old-writer shape: NULL out the persisted columns. + sqlx::query( + "UPDATE relay_admin_actions \ + SET enforcement_target_pubkey = NULL, enforcement_channel_id = NULL \ + WHERE id = $1", + ) + .bind(action_id) + .execute(&pool) + .await + .expect("null out enforcement columns (old-writer simulation)"); + + // No step_marker — pre-marker crash path. Expire the lease. + sqlx::query( + "UPDATE relay_admin_actions \ + SET action_lease_expires_at = $2, action_lease_token = NULL \ + WHERE id = $1", + ) + .bind(action_id) + .bind(chrono::Utc::now() - chrono::Duration::seconds(300)) + .execute(&pool) + .await + .expect("expire lease"); + + // Seed user row for workflow FK. + sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(community_id) + .bind(&target) + .execute(&pool) + .await + .expect("seed user row"); + + // Build state with real DB; seed stale in-process entries. + let state = state_from_pool(pool.clone()).await; + + state + .membership_cache + .insert((cid, channel_id, target.clone()), true); + + let conn_id = uuid::Uuid::new_v4(); + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + state.conn_manager.register( + conn_id, + tx, + ctrl_tx, + None, + tokio_util::sync::CancellationToken::new(), + cid, + std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), + std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, + ); + state + .conn_manager + .set_authenticated_pubkey(conn_id, target.clone()); + state.sub_registry.register_channels_scoped( + cid, + conn_id, + "legacy-pre-marker-sub".to_string(), + vec![nostr::Filter::new()], + vec![channel_id], + ); + + let workflow_id = state + .db + .create_workflow( + cid, + Some(channel_id), + &target, + "legacy-pre-marker-workflow", + r#"{"kind":"workflow"}"#, + &[0u8; 32], + ) + .await + .expect("create workflow"); + + // Pre-conditions. + assert!( + state + .membership_cache + .get(&(cid, channel_id, target.clone())) + .is_some(), + "pre-condition: membership cache entry must exist" + ); + assert!( + state + .sub_registry + .channel_subscriber_conns_scoped(cid, channel_id) + .contains(&conn_id), + "pre-condition: subscription must be registered" + ); + assert!( + state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get workflow") + .enabled, + "pre-condition: workflow must be enabled" + ); + + // Verify that the row has NULL enforcement columns before recovery. + let before = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action before") + .expect("row must exist"); + assert!( + before.enforcement_target_pubkey.is_none(), + "pre-condition: enforcement_target_pubkey must be NULL (old-writer simulation)" + ); + assert!( + before.enforcement_channel_id.is_none(), + "pre-condition: enforcement_channel_id must be NULL (old-writer simulation)" + ); + + // Re-drive via the recovery worker. + let batch = buzz_db::relay_admin_actions::claim_stranded_action_batch( + &pool, + "e2e-legacy-kick-pre-marker", + chrono::Utc::now() + chrono::Duration::seconds(120), + 1000, + ) + .await + .expect("claim_stranded_action_batch"); + let claim = batch + .into_iter() + .find(|c| c.record.id == action_id) + .expect("stranded action must appear in batch"); + crate::handlers::admin_action_worker::recover_one(&state, claim).await; + + // ── Assertions ─────────────────────────────────────────────────────── + + // 1. Action converged to succeeded. + let final_rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action") + .expect("record must still exist"); + assert_eq!( + final_rec.state, "succeeded", + "legacy kick row (pre-marker) must converge to succeeded via recovery worker" + ); + + // 2. Membership cache cleared. + assert!( + state + .membership_cache + .get(&(cid, channel_id, target.clone())) + .is_none(), + "recovery must clear the membership cache for legacy kick row" + ); + + // 3. Channel subscription evicted. + assert!( + !state + .sub_registry + .channel_subscriber_conns_scoped(cid, channel_id) + .contains(&conn_id), + "recovery must evict the kicked user's channel subscription for legacy kick row" + ); + + // 4. Workflow disabled. + assert!( + !state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get workflow") + .enabled, + "recovery must disable the kicked user's workflows for legacy kick row" + ); + } + + // ── Pre-migration kick row upgrade-recovery (post-marker) ──────────────── + + /// Same scenario as the pre-marker test, but the old writer committed both + /// the kick mutation AND the step_marker before crashing. After migration 0047 + /// the recovery worker picks up the stranded post-marker row, falls back to + /// the re-derived target, fires live side effects, and finalizes to succeeded. + /// + /// This test is falsifiable: reverting the convergence-gate fallback causes + /// the gate to see NULL persisted columns with no function-parameter fallback + /// and return the "unresolvable target" error, leaving the action stuck + /// forever in enforcing/mutation_committed. + #[tokio::test] + #[ignore = "requires Postgres — pre-migration NULL kick row converges via recovery worker (post-marker path)"] + async fn legacy_kick_row_post_marker_recovers_via_worker() { + let pool = e2e_pool().await; + let (community_id, _host) = e2e_community(&pool, "legacy-kick-post-marker").await; + let cid = buzz_core::CommunityId::from_uuid(community_id); + let target = vec![0xE3u8; 32]; + let actor = vec![0xE4u8; 32]; + + // Seed an event report with author=target so: + // 1. report.report.channel_id is non-NULL (kick requires it) + // 2. derive_enforcement_target_pub returns Some(target) via the event + // author join, giving the convergence gate a real pubkey fallback + // 3. target is already a channel member (helper seeds it) + let (report_id, channel_id, _) = + e2e_event_report_with_author(&pool, community_id, &target).await; + + // Claim and execute the kick (mutation + marker), then NULL out the + // enforcement columns to simulate the old-writer shape. + let action_id = match buzz_db::relay_admin_actions::claim_report( + &pool, + cid, + report_id, + uuid::Uuid::new_v4(), + &actor, + "operator", + "kick", + None, + None, + "resolve:kick", + "relay_operator", + Some(&target), + None, + Some(channel_id), + ) + .await + .expect("claim") + { + buzz_db::relay_admin_actions::ClaimResult::Claimed(a) => a.id, + other => panic!("expected Claimed, got {other:?}"), + }; + + let _ = buzz_db::relay_admin_actions::begin_enforcing(&pool, action_id) + .await + .expect("begin_enforcing"); + let lease_until = chrono::Utc::now() + chrono::Duration::seconds(60); + let lease_token = + match buzz_db::relay_admin_actions::acquire_action_lease(&pool, action_id, lease_until) + .await + .expect("acquire lease") + { + buzz_db::relay_admin_actions::LeaseResult::Acquired(t) => t, + other => panic!("expected Acquired, got {other:?}"), + }; + + // Commit kick + step_marker — the post-marker crash point. + let kick_result = buzz_db::relay_admin_actions::execute_kick_with_marker( + &pool, + action_id, + lease_token, + cid, + channel_id, + &target, + &actor, + ) + .await + .expect("execute_kick_with_marker"); + assert!( + matches!( + kick_result, + buzz_db::relay_admin_actions::KickWithMarkerResult::Removed + ), + "kick must commit before simulated crash" + ); + + // Simulate old-writer shape: NULL out the enforcement columns. + sqlx::query( + "UPDATE relay_admin_actions \ + SET enforcement_target_pubkey = NULL, enforcement_channel_id = NULL \ + WHERE id = $1", + ) + .bind(action_id) + .execute(&pool) + .await + .expect("null out enforcement columns (old-writer simulation)"); + + // Expire the lease so the recovery worker can re-claim. + sqlx::query( + "UPDATE relay_admin_actions \ + SET action_lease_expires_at = $2, action_lease_token = NULL \ + WHERE id = $1", + ) + .bind(action_id) + .bind(chrono::Utc::now() - chrono::Duration::seconds(300)) + .execute(&pool) + .await + .expect("expire lease"); + + // Seed user row for workflow FK. + sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(community_id) + .bind(&target) + .execute(&pool) + .await + .expect("seed user row"); + + // Build state with real DB; seed stale in-process entries. + let state = state_from_pool(pool.clone()).await; + + state + .membership_cache + .insert((cid, channel_id, target.clone()), true); + + let conn_id = uuid::Uuid::new_v4(); + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + state.conn_manager.register( + conn_id, + tx, + ctrl_tx, + None, + tokio_util::sync::CancellationToken::new(), + cid, + std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), + std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, + ); + state + .conn_manager + .set_authenticated_pubkey(conn_id, target.clone()); + state.sub_registry.register_channels_scoped( + cid, + conn_id, + "legacy-post-marker-sub".to_string(), + vec![nostr::Filter::new()], + vec![channel_id], + ); + + let workflow_id = state + .db + .create_workflow( + cid, + Some(channel_id), + &target, + "legacy-post-marker-workflow", + r#"{"kind":"workflow"}"#, + &[0u8; 32], + ) + .await + .expect("create workflow"); + + // Verify that the row is post-marker with NULL enforcement columns. + let before = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action before") + .expect("row must exist"); + assert_eq!( + before.step_marker.as_deref(), + Some("mutation_committed"), + "pre-condition: step_marker must be set (post-marker path)" + ); + assert!( + before.enforcement_target_pubkey.is_none(), + "pre-condition: enforcement_target_pubkey must be NULL (old-writer simulation)" + ); + assert!( + before.enforcement_channel_id.is_none(), + "pre-condition: enforcement_channel_id must be NULL (old-writer simulation)" + ); + + // Re-drive via the recovery worker. + let batch = buzz_db::relay_admin_actions::claim_stranded_action_batch( + &pool, + "e2e-legacy-kick-post-marker", + chrono::Utc::now() + chrono::Duration::seconds(120), + 1000, + ) + .await + .expect("claim_stranded_action_batch"); + let claim = batch + .into_iter() + .find(|c| c.record.id == action_id) + .expect("stranded action must appear in batch"); + crate::handlers::admin_action_worker::recover_one(&state, claim).await; + + // ── Assertions ─────────────────────────────────────────────────────── + + // 1. Action converged to succeeded. + let final_rec = buzz_db::relay_admin_actions::get_action(&pool, action_id) + .await + .expect("get_action") + .expect("record must still exist"); + assert_eq!( + final_rec.state, "succeeded", + "legacy kick row (post-marker) must converge to succeeded via recovery worker" + ); + + // 2. Membership cache cleared. + assert!( + state + .membership_cache + .get(&(cid, channel_id, target.clone())) + .is_none(), + "recovery must clear the membership cache for legacy kick row" + ); + + // 3. Channel subscription evicted. + assert!( + !state + .sub_registry + .channel_subscriber_conns_scoped(cid, channel_id) + .contains(&conn_id), + "recovery must evict the kicked user's channel subscription for legacy kick row" + ); + + // 4. Workflow disabled. + assert!( + !state + .db + .get_workflow(cid, workflow_id) + .await + .expect("get workflow") + .enabled, + "recovery must disable the kicked user's workflows for legacy kick row" + ); + } + + // ── kick_live_side_effects: membership cache + subscription eviction ────── + + /// Verify that `apply_kick_live_side_effects` clears the membership cache + /// entry and evicts the live channel subscription for the kicked user. + /// + /// Moved from `handlers::report_resolution` tests (which used `test_state()` + /// with a lazy PG pool) to the PG fixture lane, because + /// `apply_kick_live_side_effects` → `membership_removal_fence` requires a + /// real Postgres connection. + /// + /// Setup: + /// 1. Seed the membership cache with `true` so the cache claims the target + /// is still a member. + /// 2. Register a connection authenticated as the target pubkey and add a + /// channel-scoped subscription for them. + /// 3. Call `apply_kick_live_side_effects`. + /// + /// Assertions: + /// - The membership cache entry is gone (cache returns `None`). + /// - The channel subscription index no longer lists the connection. + /// + /// Redis-dependent work inside the helper (cross-pod cache invalidation + /// publish, pubsub topic release) hits an intentionally unreachable endpoint + /// and is silently dropped — this mirrors the production "best-effort" + /// contract and does not affect the in-process assertions. + /// + /// This test is falsifiable: replacing `membership_removal_fence` with an + /// always-fire eviction path (bypassing the `still_removed` gate) would + /// leave this test green, but + /// `crash_recovery_after_readd_preserves_membership_subscriptions_and_workflows` + /// covers the fence semantics. + #[tokio::test] + #[ignore = "requires Postgres — kick live side effects clear cache and evict subscription"] + async fn kick_live_side_effects_clears_membership_cache_and_evicts_subscription() { + let pool = e2e_pool().await; + let (community_id, host) = e2e_community(&pool, "kick-side-effects-unit").await; + let cid = buzz_core::CommunityId::from_uuid(community_id); + let channel_id = uuid::Uuid::new_v4(); + let target_pubkey: Vec = vec![0xABu8; 32]; + let actor: Vec = vec![0xACu8; 32]; + let tenant = buzz_core::tenant::TenantContext::resolved(cid, host); + + // Create channel and seed the target as a member so the fence query + // finds a removed_at IS NULL row (kick has already committed in DB but + // we need a member row for the fence to read). + sqlx::query( + r#"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'side-effects-unit-ch', 'stream', 'open', $3)"#, + ) + .bind(channel_id) + .bind(community_id) + .bind(&actor) + .execute(&pool) + .await + .expect("create channel"); + // Insert already-removed member row (removed_at set) — simulates state + // after a kick mutation committed but before side effects ran. + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, removed_at) \ + VALUES ($1, $2, $3, 'member', now())", + ) + .bind(community_id) + .bind(channel_id) + .bind(&target_pubkey) + .execute(&pool) + .await + .expect("insert removed member row"); + + let state = state_from_pool(pool.clone()).await; + + // 1. Seed the membership cache. + state + .membership_cache + .insert((cid, channel_id, target_pubkey.clone()), true); + + assert!( + state + .membership_cache + .get(&(cid, channel_id, target_pubkey.clone())) + .is_some(), + "pre-condition: membership cache entry must exist before kick side effects" + ); + + // 2. Register a connection and a channel-scoped subscription. + let conn_id = uuid::Uuid::new_v4(); + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + state.conn_manager.register( + conn_id, + tx, + ctrl_tx, + None, + tokio_util::sync::CancellationToken::new(), + cid, + std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), + std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, + ); + state + .conn_manager + .set_authenticated_pubkey(conn_id, target_pubkey.clone()); + + state.sub_registry.register_channels_scoped( + cid, + conn_id, + "kick-side-effects-sub".to_string(), + vec![nostr::Filter::new()], + vec![channel_id], + ); + + assert!( + state + .sub_registry + .channel_subscriber_conns_scoped(cid, channel_id) + .contains(&conn_id), + "pre-condition: subscription must be registered before kick side effects" + ); + + // 3. Fire kick live side effects. + crate::handlers::side_effects::apply_kick_live_side_effects( + &tenant, + &state, + channel_id, + &target_pubkey, + ) + .await + .expect("kick live side effects must succeed in test"); + + // Assert: membership cache entry is gone. + assert!( + state + .membership_cache + .get(&(cid, channel_id, target_pubkey.clone())) + .is_none(), + "membership cache must not contain a stale entry after kick side effects" + ); + + // Assert: channel subscription is no longer indexed for this connection. + assert!( + !state + .sub_registry + .channel_subscriber_conns_scoped(cid, channel_id) + .contains(&conn_id), + "kicked user's channel subscription must be evicted after kick side effects" + ); + } } diff --git a/crates/buzz-relay/src/handlers/admin_action_worker.rs b/crates/buzz-relay/src/handlers/admin_action_worker.rs index 75f98417b4..42c3647b2c 100644 --- a/crates/buzz-relay/src/handlers/admin_action_worker.rs +++ b/crates/buzz-relay/src/handlers/admin_action_worker.rs @@ -101,34 +101,65 @@ pub(crate) async fn recover_one(state: &Arc, claim: StrandedActionClai "Action recovery worker re-driving stranded action" ); - // Decode the target from the report row. - let report = match state.db.admin_get_report(rec.report_id).await { - Ok(Some(r)) => r, - Ok(None) => { - warn!(action_id = %action_id, "Action recovery: report not found"); - return; - } - Err(e) => { - warn!(action_id = %action_id, "Action recovery: report lookup failed: {e}"); - return; - } - }; - - let (target_pubkey_opt, target_event_id_opt) = - match crate::handlers::report_resolution::derive_enforcement_target_pub(&report) { - Ok(pair) => pair, + // Use the target context persisted at claim time for kick actions only. + // Migration 0047 added enforcement_target_pubkey/enforcement_channel_id + // for kicks; other actions (ban, timeout, delete) do not set these columns + // and must re-derive from the report row on every recovery. Applying the + // persisted-context branch to non-kick actions breaks delete recovery: + // delete requires target_event_id (not NULL'd here for kick only) and any + // persisted pubkey from a prior kick on the same report would force + // target_event_id=None, causing pre-marker delete recovery to fail + // ("delete requires target_event_id") or post-marker recovery to skip the + // tombstone outbox row. Gate strictly on action=="kick". + // + // For pre-migration kick rows (both columns NULL), we still re-derive and + // pass the result as function parameters; the convergence gate in + // drive_enforcement accepts those as a legacy-context fallback so these + // stranded kicks can finalize without the persisted columns being populated. + let (target_pubkey_opt, target_event_id_opt, channel_id) = if rec.action == "kick" + && (rec.enforcement_target_pubkey.is_some() || rec.enforcement_channel_id.is_some()) + { + // Persisted kick context available — use it unconditionally. + ( + rec.enforcement_target_pubkey.clone(), + None::>, + rec.enforcement_channel_id, + ) + } else { + // Non-kick action, or pre-migration kick row with both columns NULL. + // Non-kick actions: always re-derive from the report. + // Pre-migration kick rows: re-derive so the convergence gate can use the + // result as a legacy-context fallback (see report_resolution.rs). + let report = match state.db.admin_get_report(rec.report_id).await { + Ok(Some(r)) => r, + Ok(None) => { + warn!(action_id = %action_id, "Action recovery: report not found"); + return; + } Err(e) => { - warn!(action_id = %action_id, "Action recovery: target derive failed: {e:?}"); + warn!(action_id = %action_id, "Action recovery: report lookup failed: {e}"); return; } }; + let (pk, eid) = + match crate::handlers::report_resolution::derive_enforcement_target_pub(&report) { + Ok(pair) => pair, + Err(e) => { + warn!( + action_id = %action_id, + "Action recovery: target derive failed: {e:?}" + ); + return; + } + }; + (pk, eid, report.report.channel_id) + }; let timeout_until = rec.timeout_until; let action = rec.action.clone(); let reason = rec.reason.clone(); let actor_pubkey = rec.actor_pubkey.clone(); let report_id = rec.report_id; - let channel_id = report.report.channel_id; match crate::handlers::report_resolution::drive_enforcement_pub( state, diff --git a/crates/buzz-relay/src/handlers/report_resolution.rs b/crates/buzz-relay/src/handlers/report_resolution.rs index d004903bd8..8b77eb7746 100644 --- a/crates/buzz-relay/src/handlers/report_resolution.rs +++ b/crates/buzz-relay/src/handlers/report_resolution.rs @@ -379,7 +379,7 @@ struct EnforcementCtx<'a> { #[allow(clippy::too_many_arguments)] async fn drive_enforcement( state: &Arc, - _tenant: &TenantContext, + tenant: &TenantContext, community_id: buzz_core::tenant::CommunityId, report_id: Uuid, action: &str, @@ -564,8 +564,9 @@ async fn drive_enforcement( match mutation_result { Ok(MutationOutcome::AlreadyCommitted) => { - // step_marker already set by a concurrent driver; - // reload and advance to finalization. + // step_marker already set by a concurrent driver. Reload and + // advance to finalization; live side effects fire at the + // convergence point below (after the is_none block). rec = state .db .get_admin_action(action_id) @@ -589,7 +590,8 @@ async fn drive_enforcement( ))); } Ok(MutationOutcome::Committed) => { - // Marker committed. Fall through to finalization below. + // Marker committed. Fall through to the convergence point + // below for live side effects and finalization. } Err(e) => { if failure_lease_lost { @@ -607,6 +609,63 @@ async fn drive_enforcement( } } } + // ── Convergence point ──────────────────────────────────────────────── + // Reached on every path where the step marker is (or was just) committed: + // the fresh HTTP path (Committed above), a concurrent-driver path + // (AlreadyCommitted → reload → loop reaches here with marker set), and + // the crash-recovery path (process died after DB commit but before live + // effects; recovery worker re-enters here directly with marker set). + // + // Live side effects for kick use the target context persisted at claim + // time (enforcement_target_pubkey / enforcement_channel_id) when present. + // Migration 0047 added these columns without backfilling, so rows written + // before the migration have NULL/NULL. On the recovery path the worker + // re-derives the target from the report and passes it as the function + // parameters; we accept those as a legacy-context fallback so pre-migration + // stranded kicks can still converge. Both persisted context (preferred) + // and derived-parameter fallback must satisfy the non-NULL/non-NULL + // requirement; if neither can supply the target the row is genuinely + // unresolvable and we must not silently succeed. + // + // Eviction and workflow-disable are fenced behind membership_removal_fence + // (which holds the per-channel advisory lock through both effects) so a + // kick-commit → re-add → re-drive race does not revoke a legitimately + // restored membership. Cache invalidation is unconditional because + // stale-positive is always safe to drop. The fence applies on every path + // (fresh and recovery) for a single consistent ordering guarantee. + if action == "kick" { + // Prefer the persisted context (accurate at claim time, immune to + // later report/member mutations). Fall back to the function parameters + // when the row pre-dates migration 0047 and those columns are NULL. + let kick_target = rec.enforcement_target_pubkey.as_deref().or(target_pubkey); + let kick_channel = rec.enforcement_channel_id.or(channel_id); + + match (kick_target, kick_channel) { + (Some(target), Some(ch)) => { + crate::handlers::side_effects::apply_kick_live_side_effects( + tenant, state, ch, target, + ) + .await + .map_err(|e| { + ResolutionError::Internal(format!( + "kick action {action_id} live side effects failed \ + (mutation_committed marker is recoverable; worker will retry): {e}" + )) + })?; + } + _ => { + // Both persisted columns and function parameters are absent. + // The recovery worker could not resolve a target from the + // report, so this action cannot be finalized safely. + return Err(ResolutionError::Internal(format!( + "kick action {action_id} reached convergence with unresolvable \ + target: enforcement_target_pubkey and enforcement_channel_id are \ + absent from both the row and the derived function parameters — \ + cannot finalize; manual intervention required" + ))); + } + } + } // Finalize: action → succeeded, report → resolved, outbox rows created. // Requires step_marker = 'mutation_committed' AND active_action_id = this action. let finalized = state diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 702a0b62ca..f63c174d96 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -37,6 +37,131 @@ pub fn is_side_effect_kind(kind: u32) -> bool { matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | 41001..=41003 | 40099) } +/// Apply the three live side effects that must follow a successful admin kick: +/// +/// 1. `invalidate_membership` — drops the 10 s membership-cache entry on this pod +/// AND publishes a cross-pod `CacheInvalidation::Membership` message so every +/// other relay instance also drops the entry. Subsequent REQ / fan-out +/// `is_member_cached` calls on any pod see the removal immediately. +/// 2. `evict_live_channel_subscriptions` — closes open channel subscriptions for +/// the kicked pubkey **on this pod only**. A session connected to a different +/// pod keeps its inert subscription state, but will not receive messages from +/// this channel: the cross-pod membership invalidation (step 1) ensures that +/// `filter_fanout_by_access` re-checks membership before delivery and denies +/// the removed user on every pod. The same pod-local eviction primitive is +/// used by the remove (kind 9001) and leave (kind 9022) paths; cross-pod +/// remote `CLOSED` frame delivery is not yet implemented (tracked separately). +/// 3. `disable_departed_member_workflows` — durably disables any workflows the +/// kicked user owned in this channel (SEC-006). +/// +/// These are identical to what `handle_remove_user` (kind 9001 path) fires after +/// removing a member. Without them a kicked user's live WebSocket session retains +/// full channel access: the `channel_members.removed_at` write is invisible to +/// the running subscription and the membership cache until natural expiry, which +/// is exactly the "kick reported success but user still in channel" symptom. +/// +/// # Return value +/// +/// Returns `Ok(())` on successful convergence — both after effects committed +/// and after an intentional re-added skip (the kick is still correctly enforced; +/// the membership was legitimately restored). Returns `Err` when fence +/// acquisition, the workflow-disable UPDATE, or the transaction commit fail — +/// the caller must propagate the error rather than finalizing the action as +/// succeeded, so the `mutation_committed` marker remains recoverable for a later +/// retry. +/// +/// **Fencing:** eviction and workflow-disable are gated behind a +/// `membership_removal_fence` that acquires the same `pg_advisory_xact_lock` as +/// `add_member` and holds it through both destructive effects. This prevents the +/// window where a concurrent re-add could commit between the `removed_at` check +/// and the effects: any `add_member` either serializes before the fence (the +/// fence then observes `removed_at IS NULL` and skips effects) or waits until +/// after the effects complete (the re-add then succeeds cleanly). Cache +/// invalidation fires unconditionally before the fence — stale-positive is +/// always safe to drop. +/// +/// The workflow-disable UPDATE (SEC-006) runs on the fence's own connection +/// via [`buzz_db::channel_members::MembershipRemovalFence::commit_disabling_workflows`] +/// rather than acquiring a second pool connection. This prevents a self-deadlock +/// when N concurrent kicks ≥ pool size: each kick holds one connection while +/// the disable would otherwise wait for a second — a cycle the 3 s acquire +/// timeout would "resolve" by silently skipping the durable revocation. +pub(crate) async fn apply_kick_live_side_effects( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + target_pubkey: &[u8], +) -> Result<(), anyhow::Error> { + // Cache invalidation is unconditionally safe — drops a stale positive. + state.invalidate_membership(tenant, channel_id, target_pubkey); + + // Acquire the membership advisory lock and check that the member is still + // removed. The fence guard keeps the lock alive through eviction and + // workflow-disable so no concurrent add_member can commit in that window. + let fence = state + .db + .membership_removal_fence(tenant.community(), channel_id, target_pubkey) + .await + .map_err(|e| { + warn!( + channel = %channel_id, + target = %hex::encode(target_pubkey), + error = %e, + "kick live effects: membership_removal_fence failed; \ + skipping eviction and workflow-disable" + ); + e + })?; + + if !fence.still_removed { + // Member was re-added before this driver acquired the fence. + // Skip eviction and workflow-disable so the re-add is not undone. + // This is successful convergence: the kick is enforced; membership was + // legitimately restored. + tracing::info!( + channel = %channel_id, + target = %hex::encode(target_pubkey), + "kick live effects: member re-added before fence acquired; \ + skipping eviction and workflow-disable" + ); + return Ok(()); + } + + // Still removed — fire effects while the lock is held. + // Eviction first (no DB write needed), then commit the fence with the + // workflow-disable UPDATE on the same connection so no second pool + // connection is needed (pool-exhaustion deadlock prevention — see module doc). + evict_live_channel_subscriptions(tenant, state, channel_id, target_pubkey).await; + match fence + .commit_disabling_workflows(tenant.community(), channel_id, target_pubkey) + .await + { + Ok(0) => {} + Ok(n) => { + tracing::info!( + channel = %channel_id, + owner = %hex::encode(target_pubkey), + disabled = n, + "Disabled departed member's workflows" + ); + state + .workflow_engine + .invalidate_channel_workflows(tenant.community(), channel_id); + } + Err(e) => { + warn!( + channel = %channel_id, + owner = %hex::encode(target_pubkey), + error = %e, + "Failed to disable departed member's workflows; \ + kick live effects returning error so caller can retry" + ); + return Err(e.into()); + } + } + Ok(()) +} + async fn evict_live_channel_subscriptions( tenant: &TenantContext, state: &Arc, @@ -54,12 +179,19 @@ async fn evict_live_channel_subscriptions( /// Durably disable a departing member's workflows in the channel (SEC-006). /// +/// Used by the inline remove paths (kind 9001 / kind 9022) where the member +/// removal DB write has already committed and the caller does not hold a fence +/// connection. For the fenced kick path (`apply_kick_live_side_effects`) use +/// [`buzz_db::channel_members::MembershipRemovalFence::commit_disabling_workflows`] +/// instead, which runs the UPDATE on the fence's own connection to avoid the +/// pool-exhaustion self-deadlock. +/// /// A workflow runs with its owner's standing authority; once the owner is no -/// longer a member (removed via kind 9001 or left via kind 9022) their -/// workflows must stop firing on every path — event triggers, the scheduler, -/// manual triggers, and the webhook endpoint all honor `enabled = FALSE`. -/// The per-fire authority gate in `buzz-workflow` is the fail-closed backstop; -/// this makes the revocation durable and immediately visible. +/// longer a member their workflows must stop firing on every path — event +/// triggers, the scheduler, manual triggers, and the webhook endpoint all +/// honor `enabled = FALSE`. The per-fire authority gate in `buzz-workflow` is +/// the fail-closed backstop; this makes the revocation durable and immediately +/// visible. /// /// Failures are logged, not propagated: membership removal has already been /// committed, and the per-fire gate still denies a removed owner even if this diff --git a/migrations/0047_relay_admin_action_target.sql b/migrations/0047_relay_admin_action_target.sql new file mode 100644 index 0000000000..41ec7ce338 --- /dev/null +++ b/migrations/0047_relay_admin_action_target.sql @@ -0,0 +1,17 @@ +-- Persist the authoritative enforcement target with the action row so that +-- crash-recovery can fire live side effects without re-deriving from mutable +-- sources (event rows, report rows) that may have changed or been purged. +-- +-- enforcement_target_pubkey: the resolved target pubkey bytes at claim time, +-- when the action targets a pubkey (kick/ban/timeout). NULL for event/blob +-- targets where no pubkey is derived. +-- enforcement_channel_id: the channel the enforcement targets. Populated for +-- kick actions. NULL for community-wide actions. +-- +-- Both columns mirror the values passed to claim_report's target_pubkey and +-- channel_id parameters. They are written once at claim time and never updated. + +ALTER TABLE relay_admin_actions + ADD COLUMN enforcement_target_pubkey BYTEA + CHECK (enforcement_target_pubkey IS NULL OR length(enforcement_target_pubkey) = 32), + ADD COLUMN enforcement_channel_id UUID; diff --git a/schema/schema.sql b/schema/schema.sql index d7074f359d..21d0d2ea8e 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1806,6 +1806,14 @@ CREATE TABLE relay_admin_actions ( -- retries and lets the recovery worker claim/re-drive stranded actions. action_lease_token UUID, action_lease_expires_at TIMESTAMPTZ, + -- Authoritative enforcement target (migration 0047): persisted at claim time + -- so crash-recovery can fire live side effects without re-deriving from mutable + -- sources. enforcement_target_pubkey is the resolved target pubkey bytes for + -- kick/ban/timeout actions; NULL for event/blob targets. enforcement_channel_id + -- is the channel targeted by kick actions; NULL for community-wide actions. + enforcement_target_pubkey BYTEA + CHECK (enforcement_target_pubkey IS NULL OR length(enforcement_target_pubkey) = 32), + enforcement_channel_id UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- Report-scoped idempotency: one action per (report, request_id). From 77729abfb692b25a0f4ec4a69add86af2e32c0dd Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 21 Sep 2026 21:53:04 +0300 Subject: [PATCH 22/22] feat(relay): add admin HTTP routes for member restriction management (#7302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Three admin HTTP routes under `GET|DELETE /api/admin/v1/members/...`: - `GET /members/restrictions?communityId=[&limit={1-200}][&cursor=]` — lists active bans and timeouts with stable keyset pagination - `DELETE /members/{pubkey}/ban?communityId=` — lifts an active ban; 409 if no active restriction - `DELETE /members/{pubkey}/timeout?communityId=` — lifts an active timeout; 409 if no active restriction ## Auth All three routes pass through the existing NIP-98 authorization gate. Both DELETEs call `require_mutation_principal`; GET follows the established read-authorization pattern. ## Atomicity Both DELETE routes use transactional DB helpers (`unban_member_with_audit` / `untimeout_member_with_audit`) that perform the conditional lift UPDATE and the `moderation_actions` INSERT in one SQL transaction. If the audit INSERT fails the lift rolls back; the restriction stays active. ## Expired-ban predicate `unban_member` WHERE clause requires `banned AND (ban_expires_at IS NULL OR ban_expires_at > now())` — matches all read paths. An already-expired ban returns `false` → 409. ## Pagination (GET /members/restrictions) `GET /members/restrictions` returns `{ "items": [...], "nextCursor": ""|null }`. - `limit` — page size 1–200 (default 200), enforced as a SQL `LIMIT`. Returns 400 for values outside range. - `cursor` — opaque continuation token from a prior page's `nextCursor`. Omit for the first page. - Order: `ORDER BY updated_at DESC, pubkey DESC`. The `pubkey` tie-breaker ensures a deterministic cursor even when multiple rows share the same `updated_at`. - Cursor encoding: base64url of `{updated_at_micros}_{pubkey_hex}`. Treat as opaque. - `nextCursor` is `null` only when the returned page is smaller than `limit`. An exactly-full final page emits a non-null cursor; the subsequent request returns an empty page with `null`. - The existing `list_community_restrictions` DB method (unbounded) is kept intact for the embedded dashboard bridge consumer (`GET /moderation/restricted`), whose bare-array response contract must not change. ## Tests All Postgres-gated tests are in the `postgres_tests` module (`api/admin/mod.rs`): - `list_restrictions_returns_active_bans_and_timeouts` — seeds a ban + timeout, GET returns both; `nextCursor` is null when all records fit in one page - `list_restrictions_pagination_exhaustive` — seeds 201 rows: 199 with distinct timestamps (1s apart, newest first) plus a tied pair sharing an identical older timestamp (`now()-1000s`), pinned to sort positions 200/201 so the default-200 page boundary splits the tie. Asserts: (1) `limit=201` → 400; (2) a no-`limit` request returns exactly 200 items + non-null `nextCursor` (falsifiable binding of default=200 and SQL cap — reverting the route default to the shared helper's 50 fails this assertion); (3) continuation walk to exhaustion with exactly-once coverage over all 201 pubkeys, exercising the tie-breaker at the page boundary. - `unban_member_returns_204_clears_ban_and_inserts_audit` — 204, ban cleared, timeout survives (restriction independence), audit row verified, tenant isolation confirmed - `untimeout_member_returns_204_clears_timeout_and_inserts_audit` — 204, timeout cleared, ban survives (restriction independence), second-community timeout survives (community_id predicate), audit row verified - `unban_member_returns_409_for_expired_ban` — expired ban → 409, no audit row - `unban_with_audit_rolls_back_lift_when_audit_insert_fails` (buzz-db) — forced CHECK constraint failure proves Err + ban still active (rollback proof) --------- Signed-off-by: Will Pfleger Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/store/moderation.rs | 250 +++++- crates/buzz-relay/src/api/admin/mod.rs | 1128 +++++++++++++++++++++++- 2 files changed, 1376 insertions(+), 2 deletions(-) diff --git a/crates/buzz-db/src/store/moderation.rs b/crates/buzz-db/src/store/moderation.rs index b5cc4d3093..d580b5c46a 100644 --- a/crates/buzz-db/src/store/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -376,7 +376,8 @@ pub async fn unban_member( UPDATE community_bans SET banned = false, ban_expires_at = NULL, ban_reason = NULL, actor_pubkey = $3, updated_at = now() - WHERE community_id = $1 AND pubkey = $2 AND banned = true + WHERE community_id = $1 AND pubkey = $2 + AND (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) "#, ) .bind(community.as_uuid()) @@ -388,6 +389,112 @@ pub async fn unban_member( Ok(result.rows_affected() > 0) } +/// Lift an active ban and insert the audit row in a single transaction. +/// +/// Returns `true` when the ban was active and both the lift and the audit-insert +/// committed. Returns `false` (without inserting an audit row) when no active +/// unexpired ban exists for the member, leaving the caller free to return 409. +/// +/// The predicate matches the definition used by all read paths: +/// `banned AND (ban_expires_at IS NULL OR ban_expires_at > now())`. +pub async fn unban_member_with_audit( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + actor_authority: &str, +) -> Result { + let mut tx = pool.begin().await?; + + let result = sqlx::query( + r#" + UPDATE community_bans + SET banned = false, ban_expires_at = NULL, ban_reason = NULL, + actor_pubkey = $3, updated_at = now() + WHERE community_id = $1 AND pubkey = $2 + AND (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(actor) + .execute(&mut *tx) + .await?; + + if result.rows_affected() == 0 { + // No active ban — roll back (no-op) and signal 409 to the caller. + tx.rollback().await?; + return Ok(false); + } + + sqlx::query( + r#" + INSERT INTO moderation_actions ( + community_id, actor_pubkey, action, target_pubkey, actor_authority + ) VALUES ($1, $2, 'unban', $3, $4) + "#, + ) + .bind(community.as_uuid()) + .bind(actor) + .bind(pubkey) + .bind(actor_authority) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) +} + +/// Lift an active timeout and insert the audit row in a single transaction. +/// +/// Returns `true` when the timeout was active and both the lift and the +/// audit-insert committed. Returns `false` when no active timeout exists. +pub async fn untimeout_member_with_audit( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + actor_authority: &str, +) -> Result { + let mut tx = pool.begin().await?; + + let result = sqlx::query( + r#" + UPDATE community_bans + SET muted_until = NULL, mute_reason = NULL, + actor_pubkey = $3, updated_at = now() + WHERE community_id = $1 AND pubkey = $2 AND muted_until > now() + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(actor) + .execute(&mut *tx) + .await?; + + if result.rows_affected() == 0 { + tx.rollback().await?; + return Ok(false); + } + + sqlx::query( + r#" + INSERT INTO moderation_actions ( + community_id, actor_pubkey, action, target_pubkey, actor_authority + ) VALUES ($1, $2, 'untimeout', $3, $4) + "#, + ) + .bind(community.as_uuid()) + .bind(actor) + .bind(pubkey) + .bind(actor_authority) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) +} + /// Upsert a timeout: sets `muted_until` + reason. pub async fn timeout_member( pool: &PgPool, @@ -540,6 +647,49 @@ pub async fn list_restricted(pool: &PgPool, community: CommunityId) -> Result, Vec)>, +) -> Result> { + let (cursor_ts, cursor_pk) = cursor.unzip(); + let rows = sqlx::query( + r#" + SELECT pubkey, + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned, + ban_expires_at, ban_reason, muted_until, + mute_reason, actor_pubkey, updated_at + FROM community_bans + WHERE community_id = $1 + AND ( + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) + OR muted_until > now() + ) + AND ( + $2::timestamptz IS NULL + OR (updated_at, pubkey) < ($2, $3::bytea) + ) + ORDER BY updated_at DESC, pubkey DESC + LIMIT $4 + "#, + ) + .bind(community.as_uuid()) + .bind(cursor_ts) + .bind(cursor_pk) + .bind(limit) + .fetch_all(pool) + .await?; + + rows.into_iter().map(row_to_ban).collect() +} + /// Insert a moderation audit row, returning its id. pub async fn insert_action( pool: &PgPool, @@ -744,6 +894,37 @@ impl Db { unban_member(&self.pool, community, pubkey, actor).await } + /// Lift an active ban and insert the audit row atomically. + /// + /// Returns `false` when no active unexpired ban exists (409 signal); the + /// audit row is only inserted when the lift commits. Expired bans + /// (`banned AND ban_expires_at <= now()`) return `false` — read paths + /// already treat them as inactive. + #[datastore_span(name = "unban_community_member_with_audit", system = "postgresql")] + pub async fn unban_community_member_with_audit( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + actor_authority: &str, + ) -> Result { + unban_member_with_audit(&self.pool, community, pubkey, actor, actor_authority).await + } + + /// Lift an active timeout and insert the audit row atomically. + /// + /// Returns `false` when no active timeout exists (409 signal). + #[datastore_span(name = "untimeout_community_member_with_audit", system = "postgresql")] + pub async fn untimeout_community_member_with_audit( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + actor_authority: &str, + ) -> Result { + untimeout_member_with_audit(&self.pool, community, pubkey, actor, actor_authority).await + } + /// Upsert a community timeout/write-block for a member pubkey. #[datastore_span(name = "timeout_community_member", system = "postgresql")] pub async fn timeout_community_member( @@ -797,6 +978,20 @@ impl Db { list_restricted(&self.pool, community).await } + /// List currently restricted members with stable keyset pagination. + /// + /// Returns at most `limit` rows. Supply the `(updated_at, pubkey)` of the + /// last row as `cursor` to advance to the next page. + #[datastore_span(name = "list_community_restrictions_page", system = "postgresql")] + pub async fn list_community_restrictions_page( + &self, + community: CommunityId, + limit: i64, + cursor: Option<(DateTime, Vec)>, + ) -> Result> { + list_restricted_page(&self.pool, community, limit, cursor).await + } + /// Insert a moderation audit action row. #[datastore_span(name = "insert_moderation_action", system = "postgresql")] pub async fn insert_moderation_action( @@ -1141,6 +1336,59 @@ mod postgres_tests { ); } + /// Atomicity guard: `unban_member_with_audit` must roll back the ban lift + /// when the audit INSERT fails, leaving the restriction active. + /// + /// This exercises the transactional boundary: the UPDATE and the INSERT share + /// one SQL transaction; a CHECK violation on the INSERT must roll back both. + /// Passing `actor_authority = "invalid"` triggers the DB CHECK constraint + /// on `moderation_actions.actor_authority`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unban_with_audit_rolls_back_lift_when_audit_insert_fails() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let pubkey = random_32(); + let actor = random_32(); + + ban_member(&pool, community, &pubkey, &actor, None, None) + .await + .expect("insert ban fixture"); + + // Verify the ban is active before we attempt the lift. + let state_before = restriction_state(&pool, community, &pubkey) + .await + .expect("restriction_state before"); + assert!(state_before.banned, "pre-condition: pubkey must be banned"); + + // Use an invalid actor_authority that violates the DB CHECK constraint — + // this causes the audit INSERT to fail, which must roll back the UPDATE. + let result = + unban_member_with_audit(&pool, community, &pubkey, &actor, "invalid_authority").await; + assert!( + result.is_err(), + "unban_with_audit must return Err when audit INSERT violates a constraint" + ); + + // The ban must still be active — the rolled-back UPDATE must not have committed. + let state_after = restriction_state(&pool, community, &pubkey) + .await + .expect("restriction_state after"); + assert!( + state_after.banned, + "ban must remain active after a failed unban_with_audit (rollback)" + ); + + // No audit row must have been inserted. + let actions = list_actions(&pool, community, 10) + .await + .expect("list actions"); + assert!( + actions.is_empty(), + "no audit row must be committed when the transaction rolls back" + ); + } + /// Every non-`illegal` category still lands `open` for community triage; the /// auto-escalation branch must not widen to the ordinary report flow. #[tokio::test] diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index e9175d92ae..a891b282c3 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -60,6 +60,9 @@ pub fn router(state: Arc) -> Router { .route("/operators", get(list_operators)) .route("/operators/{pubkey}", put(upsert_operator)) .route("/operators/{pubkey}", delete(delete_operator)) + .route("/members/restrictions", get(list_member_restrictions)) + .route("/members/{pubkey}/ban", delete(unban_member)) + .route("/members/{pubkey}/timeout", delete(untimeout_member)) .layer(middleware::from_fn(security_headers)) // Mutation routes carry a JSON body (max ~4 KB); read-only routes have no body. .layer(RequestBodyLimitLayer::new(4096)) @@ -1108,7 +1111,263 @@ async fn delete_operator( Ok(Json(serde_json::json!({"deleted": canonical_hex}))) } -// ── Staffing helpers ────────────────────────────────────────────────────────── +// ── Member restriction routes ───────────────────────────────────────────────── + +/// JSON response shape for one restriction record. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MemberRestrictionRecord { + /// Target member pubkey as lowercase hex. + pubkey: String, + /// Whether the member is currently banned. + banned: bool, + /// Ban expiry timestamp; `null` while `banned` ⇒ permanent. + ban_expires_at: Option>, + /// Moderator-supplied ban reason (private to the admin plane). + ban_reason: Option, + /// Write-block until this timestamp; `null` or past ⇒ not timed out. + muted_until: Option>, + /// Moderator-supplied timeout reason (private to the admin plane). + mute_reason: Option, + /// Last-acting moderator pubkey as lowercase hex. + actor_pubkey: String, + /// Last modification time. + updated_at: DateTime, +} + +impl From for MemberRestrictionRecord { + fn from(r: buzz_db::moderation::BanRecord) -> Self { + Self { + pubkey: hex::encode(&r.pubkey), + banned: r.banned, + ban_expires_at: r.ban_expires_at, + ban_reason: r.ban_reason, + muted_until: r.muted_until, + mute_reason: r.mute_reason, + actor_pubkey: hex::encode(&r.actor_pubkey), + updated_at: r.updated_at, + } + } +} + +/// Paginated response for `GET /members/restrictions`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RestrictionsPage { + /// Restriction records for this page, newest first. + items: Vec, + /// Opaque cursor for the next page, or `null` when the returned page is + /// smaller than `limit` (i.e., all rows have been delivered). An exactly + /// full final page emits a non-null cursor; the subsequent request returns + /// an empty page with `null`. + /// + /// Encoding: `base64url(updated_at_micros_decimal + "_" + pubkey_hex)`. + /// Treat as opaque — the format may change across releases. + next_cursor: Option, +} + +/// Encode a keyset cursor as a base64url-safe opaque token. +/// +/// Format: `{updated_at_micros}_{pubkey_hex}` encoded with URL_SAFE_NO_PAD. +fn encode_cursor(updated_at: DateTime, pubkey: &[u8]) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; + let payload = format!("{}_{}", updated_at.timestamp_micros(), hex::encode(pubkey)); + URL_SAFE_NO_PAD.encode(payload.as_bytes()) +} + +/// Decode an opaque cursor token back to `(updated_at, pubkey_bytes)`. +fn decode_cursor(token: &str) -> Result<(DateTime, Vec), ApiError> { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; + let bytes = URL_SAFE_NO_PAD + .decode(token) + .map_err(|_| ApiError::bad_request("invalid_cursor", "cursor is not valid base64url"))?; + let s = std::str::from_utf8(&bytes) + .map_err(|_| ApiError::bad_request("invalid_cursor", "cursor is not valid UTF-8"))?; + let (ts_str, pk_hex) = s + .split_once('_') + .ok_or_else(|| ApiError::bad_request("invalid_cursor", "cursor format is invalid"))?; + let micros: i64 = ts_str + .parse() + .map_err(|_| ApiError::bad_request("invalid_cursor", "cursor timestamp is invalid"))?; + let dt = DateTime::from_timestamp_micros(micros) + .ok_or_else(|| ApiError::bad_request("invalid_cursor", "cursor timestamp out of range"))?; + let pubkey = hex::decode(pk_hex) + .map_err(|_| ApiError::bad_request("invalid_cursor", "cursor pubkey is invalid hex"))?; + if pubkey.len() != 32 { + return Err(ApiError::bad_request( + "invalid_cursor", + "cursor pubkey must be 32 bytes", + )); + } + Ok((dt, pubkey)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CommunityQuery { + community_id: Uuid, +} + +/// Query params for `GET /members/restrictions`. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RestrictionsQuery { + community_id: Uuid, + /// Maximum number of records to return (1–200, default 200). + limit: Option, + /// Opaque continuation cursor from a prior response's `nextCursor` field. + cursor: Option, +} + +/// GET /members/restrictions?communityId={uuid}[&limit={1-200}][&cursor={token}] +/// +/// List currently active bans and timeouts for the given community, newest +/// first, with stable keyset pagination. +/// +/// **Response shape:** `{ "items": [...], "nextCursor": ""|null }` +/// +/// - `limit` — page size, 1–200, default 200. Enforced as a SQL `LIMIT`. +/// - `cursor` — opaque token from a prior page's `nextCursor`. Omit for the +/// first page. Format: base64url of `{updated_at_micros}_{pubkey_hex}`. +/// +/// Returns 400 if `communityId` is absent / invalid, `limit` is out of range, +/// or `cursor` is malformed. Returns 401 without a valid admin credential. +async fn list_member_restrictions( + State(state): State>, + uri: Uri, + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + authorize( + &state, + &headers, + uri.path_and_query() + .map_or_else(|| uri.path(), |pq| pq.as_str()), + "GET", + None, + ) + .await?; + + let page_limit = limit(Some(query.limit.unwrap_or(200)))?; + let cursor = query.cursor.as_deref().map(decode_cursor).transpose()?; + + let community = buzz_core::CommunityId::from_uuid(query.community_id); + let records = state + .db + .list_community_restrictions_page(community, page_limit, cursor) + .await?; + + let next_cursor = if records.len() as i64 == page_limit { + records + .last() + .map(|r| encode_cursor(r.updated_at, &r.pubkey)) + } else { + None + }; + + Ok(Json(RestrictionsPage { + items: records.into_iter().map(Into::into).collect(), + next_cursor, + })) +} + +/// DELETE /members/{pubkey}/ban?communityId={uuid} +/// +/// Lift an active ban for the given member in the given community. +/// Returns 204 on success, 409 if no active ban exists. +/// Requires nip98 auth. +async fn unban_member( + State(state): State>, + uri: Uri, + headers: HeaderMap, + Path(pubkey_hex): Path, + Query(query): Query, +) -> Result { + let principal_opt = authorize( + &state, + &headers, + uri.path_and_query() + .map_or_else(|| uri.path(), |pq| pq.as_str()), + "DELETE", + None, + ) + .await?; + + let principal = require_mutation_principal(principal_opt)?; + + let target_bytes = decode_hex_pubkey(&pubkey_hex)?; + let community = buzz_core::CommunityId::from_uuid(query.community_id); + + let actor_authority = match principal.role { + AdminRole::Operator => "relay_operator", + AdminRole::Moderator => "relay_moderator", + }; + + let lifted = state + .db + .unban_community_member_with_audit( + community, + &target_bytes, + &principal.pubkey, + actor_authority, + ) + .await?; + if !lifted { + return Err(ApiError::conflict("no active ban for this member")); + } + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// DELETE /members/{pubkey}/timeout?communityId={uuid} +/// +/// Clear an active timeout/write-block for the given member in the given +/// community. Returns 204 on success, 409 if no active timeout exists. +/// Requires nip98 auth. +async fn untimeout_member( + State(state): State>, + uri: Uri, + headers: HeaderMap, + Path(pubkey_hex): Path, + Query(query): Query, +) -> Result { + let principal_opt = authorize( + &state, + &headers, + uri.path_and_query() + .map_or_else(|| uri.path(), |pq| pq.as_str()), + "DELETE", + None, + ) + .await?; + + let principal = require_mutation_principal(principal_opt)?; + + let target_bytes = decode_hex_pubkey(&pubkey_hex)?; + let community = buzz_core::CommunityId::from_uuid(query.community_id); + + let actor_authority = match principal.role { + AdminRole::Operator => "relay_operator", + AdminRole::Moderator => "relay_moderator", + }; + + let lifted = state + .db + .untimeout_community_member_with_audit( + community, + &target_bytes, + &principal.pubkey, + actor_authority, + ) + .await?; + if !lifted { + return Err(ApiError::conflict("no active timeout for this member")); + } + + Ok(axum::http::StatusCode::NO_CONTENT) +} /// Returns true if any config-backed operator is effective — a non-empty /// `RELAY_OPERATOR_PUBKEYS` (every entry is an operator) or, when that list is @@ -1724,6 +1983,873 @@ mod postgres_tests { ); } + // ── Member restriction tests ────────────────────────────────────────── + + #[test] + fn restriction_record_converts_ban_record_pubkeys_to_hex() { + // Pure unit test: BanRecord → MemberRestrictionRecord hex encodes the + // Vec pubkeys. No database or state needed. + let record = buzz_db::moderation::BanRecord { + pubkey: vec![0xAB; 32], + banned: true, + ban_expires_at: None, + ban_reason: Some("spam".to_string()), + muted_until: None, + mute_reason: None, + actor_pubkey: vec![0xCD; 32], + updated_at: chrono::Utc::now(), + }; + let response: MemberRestrictionRecord = record.into(); + assert_eq!(response.pubkey, "ab".repeat(32)); + assert_eq!(response.actor_pubkey, "cd".repeat(32)); + assert!(response.banned); + assert_eq!(response.ban_reason.as_deref(), Some("spam")); + } + + #[tokio::test] + async fn list_restrictions_rejects_missing_credential() { + let state = test_state().await; + let community_id = Uuid::nil(); + let response = status_for( + state, + Request::builder() + .uri(format!("/members/restrictions?communityId={community_id}")) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "GET /members/restrictions without credential must return 401" + ); + } + + #[tokio::test] + async fn unban_member_rejects_missing_credential() { + let state = test_state().await; + let pubkey_hex = "ab".repeat(32); + let community_id = Uuid::nil(); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(format!( + "/members/{pubkey_hex}/ban?communityId={community_id}" + )) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "DELETE /members/{{pubkey}}/ban without credential must return 401" + ); + } + + #[tokio::test] + async fn untimeout_member_rejects_missing_credential() { + let state = test_state().await; + let pubkey_hex = "ab".repeat(32); + let community_id = Uuid::nil(); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(format!( + "/members/{pubkey_hex}/timeout?communityId={community_id}" + )) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "DELETE /members/{{pubkey}}/timeout without credential must return 401" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unban_member_returns_409_when_no_active_ban() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + + // Create an isolated community so the test doesn't clash with other rows. + let community_uuid = Uuid::new_v4(); + let host = format!("unban-test-{}.example", community_uuid.simple()); + db.ensure_configured_community(&host) + .await + .expect("create test community"); + + let state = test_state().await; + let operator_keys = test_operator_keys(); + let pubkey_hex = "ab".repeat(32); + let community_id = community_uuid; + + let path = format!("/members/{pubkey_hex}/ban?communityId={community_id}"); + let auth = make_nostr_auth_delete(&operator_keys, &path); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "unban with no active ban must return 409" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn untimeout_member_returns_409_when_no_active_timeout() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + + let community_uuid = Uuid::new_v4(); + let host = format!("untimeout-test-{}.example", community_uuid.simple()); + db.ensure_configured_community(&host) + .await + .expect("create test community"); + + let state = test_state().await; + let operator_keys = test_operator_keys(); + let pubkey_hex = "ab".repeat(32); + let community_id = community_uuid; + + let path = format!("/members/{pubkey_hex}/timeout?communityId={community_id}"); + let auth = make_nostr_auth_delete(&operator_keys, &path); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "untimeout with no active timeout must return 409" + ); + } + + // ── Restriction management success tests (require Postgres) ────────── + + /// Build an AppState that uses a real Postgres connection pool so HTTP + /// routes that hit the DB can commit and read back results. + async fn nip98_state_with_real_pool(pool: sqlx::PgPool) -> Arc { + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.relay_operator_pubkeys = vec![test_operator_keys().public_key().to_hex()]; + config.relay_operator_api_origin = Some("https://admin.example".to_string()); + config.admin = Some(crate::config::AdminConfig { + host: "admin.example".to_string(), + auth: crate::config::AdminAuth::Nip98, + web_dir: None, + }); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Arc::new(state) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_restrictions_returns_active_bans_and_timeouts() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + + let community_uuid = Uuid::new_v4(); + let host = format!("list-restrictions-{}.example", community_uuid.simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("create test community") + .id; + let community_uuid = *community.as_uuid(); + + let banned_pubkey = vec![0xAAu8; 32]; + let timed_out_pubkey = vec![0xBBu8; 32]; + let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); + + // Insert a permanent ban and a timeout in this community. + db.ban_community_member(community, &banned_pubkey, &actor_pubkey, None, None) + .await + .expect("insert ban fixture"); + db.timeout_community_member( + community, + &timed_out_pubkey, + &actor_pubkey, + chrono::Utc::now() + chrono::Duration::hours(1), + None, + ) + .await + .expect("insert timeout fixture"); + + let state = nip98_state_with_real_pool(pool).await; + let path = format!("/members/restrictions?communityId={community_uuid}"); + let auth = make_nostr_auth(&test_operator_keys(), &path); + let response = status_for( + state, + Request::builder() + .method("GET") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + + assert_eq!( + response.status(), + StatusCode::OK, + "GET restrictions must return 200" + ); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body"); + let page: serde_json::Value = serde_json::from_slice(&body).expect("parse JSON"); + + let records = page["items"].as_array().expect("items must be an array"); + assert_eq!(records.len(), 2, "must return both the ban and the timeout"); + + let banned_hex = hex::encode(&banned_pubkey); + let timed_out_hex = hex::encode(&timed_out_pubkey); + let pubkeys: std::collections::HashSet = records + .iter() + .filter_map(|r| r["pubkey"].as_str().map(String::from)) + .collect(); + assert!( + pubkeys.contains(&banned_hex), + "banned pubkey must appear in the response" + ); + assert!( + pubkeys.contains(&timed_out_hex), + "timed-out pubkey must appear in the response" + ); + + // Verify the banned record has banned=true in JSON. + let banned_rec = records + .iter() + .find(|r| r["pubkey"].as_str() == Some(&banned_hex)) + .expect("banned record"); + assert_eq!( + banned_rec["banned"], + serde_json::Value::Bool(true), + "banned record must have banned=true" + ); + + // Two records returned with default limit=200 → no next page. + assert_eq!( + page["nextCursor"], + serde_json::Value::Null, + "nextCursor must be null when all records fit in one page" + ); + } + + /// Pagination regression: bind the default=200 cap, SQL LIMIT enforcement, + /// keyset continuation, exactly-once coverage, and tie-breaker correctness + /// in a single falsifiable route test. + /// + /// Seeding strategy: + /// - Rows 1–199: distinct `updated_at` values, newest first (positions 1–199). + /// - Rows 200–201: two distinct pubkeys forced to share an identical older + /// `updated_at` so they land at positions 200–201 in `ORDER BY updated_at + /// DESC, pubkey DESC`, straddling the default 200-row page boundary. + /// A broken tie-breaker will either duplicate or omit one of these rows. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_restrictions_pagination_exhaustive() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + + let community_uuid = Uuid::new_v4(); + let host = format!("list-restrict-pg-{}.example", community_uuid.simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("create test community") + .id; + let community_uuid = *community.as_uuid(); + + let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); + + // Build 201 distinct pubkeys. Pubkey layout: + // indices 0..=198 (199 rows) → [i+1, i+1, ..., i+1] where i ∈ 0..199 + // indices 199..=200 (2 rows) → [0xE1..] and [0xE2..] (the tied pair) + // The tied pair is pinned to positions 200/201 by their OLDER timestamp + // (now()-1000s vs. now()-1..199s), not by pubkey ordering. Under + // `pubkey DESC` 0xE2 sorts above 0xE1, so 0xE2 lands at position 200 + // and 0xE1 at position 201; the default-200 page boundary splits the tie. + let mut all_pubkeys: Vec> = Vec::new(); + for i in 1u8..=199 { + all_pubkeys.push(vec![i; 32]); + } + all_pubkeys.push(vec![0xE1u8; 32]); // tied pair member A — position 201 + all_pubkeys.push(vec![0xE2u8; 32]); // tied pair member B — position 200 + + // Batch-insert all 201 rows with explicit `updated_at` stamps: + // rows 0..198 → now() - (i+1) seconds (distinct, newer) + // rows 199-200 → now() - 1000 seconds (identical, oldest) + // This guarantees the tied pair occupies sort positions 200–201. + let actor_bytes = actor_pubkey; + + // Insert in two batches to keep parameter counts manageable. + // Batch 1: rows 0..=198 (distinct timestamps). + for (i, pk) in all_pubkeys[..199].iter().enumerate() { + let secs_ago = (i as i64) + 1; + sqlx::query( + r#" + INSERT INTO community_bans + (community_id, pubkey, banned, actor_pubkey, updated_at) + VALUES ($1, $2, true, $3, + now() - make_interval(secs => $4)) + ON CONFLICT (community_id, pubkey) DO UPDATE SET + banned = true, + actor_pubkey = EXCLUDED.actor_pubkey, + updated_at = EXCLUDED.updated_at + "#, + ) + .bind(community.as_uuid()) + .bind(pk.as_slice()) + .bind(actor_bytes.as_slice()) + .bind(secs_ago) + .execute(&pool) + .await + .expect("insert row"); + } + + // Batch 2: tied pair at an identical old timestamp. + let tied_ts = chrono::Utc::now() - chrono::Duration::seconds(1000); + for pk in &all_pubkeys[199..] { + sqlx::query( + r#" + INSERT INTO community_bans + (community_id, pubkey, banned, actor_pubkey, updated_at) + VALUES ($1, $2, true, $3, $4) + ON CONFLICT (community_id, pubkey) DO UPDATE SET + banned = true, + actor_pubkey = EXCLUDED.actor_pubkey, + updated_at = EXCLUDED.updated_at + "#, + ) + .bind(community.as_uuid()) + .bind(pk.as_slice()) + .bind(actor_bytes.as_slice()) + .bind(tied_ts) + .execute(&pool) + .await + .expect("insert tied row"); + } + + let state = nip98_state_with_real_pool(pool).await; + let operator_keys = test_operator_keys(); + + // ── assertion 1: limit=201 → 400 ───────────────────────────────── + let bad_path = format!("/members/restrictions?communityId={community_uuid}&limit=201"); + let bad_auth = make_nostr_auth(&operator_keys, &bad_path); + let bad_response = status_for( + Arc::clone(&state), + Request::builder() + .method("GET") + .uri(&bad_path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, bad_auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + bad_response.status(), + StatusCode::BAD_REQUEST, + "limit=201 must return 400" + ); + + // ── assertion 2: default limit → exactly 200 items + non-null cursor ─ + // (This is the falsifiable binding of default=200 and max=200.) + let first_path = format!("/members/restrictions?communityId={community_uuid}"); + let first_auth = make_nostr_auth(&operator_keys, &first_path); + let first_response = status_for( + Arc::clone(&state), + Request::builder() + .method("GET") + .uri(&first_path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, first_auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + first_response.status(), + StatusCode::OK, + "default-limit request must return 200" + ); + let first_body = axum::body::to_bytes(first_response.into_body(), usize::MAX) + .await + .expect("read body"); + let first_page: serde_json::Value = + serde_json::from_slice(&first_body).expect("parse JSON"); + let first_items = first_page["items"].as_array().expect("items array"); + assert_eq!( + first_items.len(), + 200, + "default limit must return exactly 200 items (binds default=200 and SQL cap)" + ); + assert!( + first_page["nextCursor"].is_string(), + "200 items from 201 seeded → nextCursor must be non-null" + ); + + // Collect pubkeys from the first page. + let mut seen_pubkeys: std::collections::HashSet = first_items + .iter() + .filter_map(|r| r["pubkey"].as_str().map(String::from)) + .collect(); + assert_eq!( + seen_pubkeys.len(), + 200, + "first page must have 200 distinct pubkeys" + ); + + // ── assertion 3: walk remaining pages to exhaustion ─────────────── + let mut cursor_token = first_page["nextCursor"].as_str().map(str::to_owned); + let mut page_count = 1usize; // already consumed first page above + + while let Some(tok) = cursor_token.clone() { + let path = format!("/members/restrictions?communityId={community_uuid}&cursor={tok}"); + let auth = make_nostr_auth(&operator_keys, &path); + let response = status_for( + Arc::clone(&state), + Request::builder() + .method("GET") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::OK, + "continuation page {page_count}: must return 200" + ); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body"); + let page: serde_json::Value = serde_json::from_slice(&body).expect("parse JSON"); + let items = page["items"].as_array().expect("items array"); + assert!( + items.len() <= 200, + "continuation page {page_count}: must not exceed cap; got {}", + items.len() + ); + for item in items { + let pk = item["pubkey"].as_str().expect("pubkey str").to_owned(); + assert!( + seen_pubkeys.insert(pk.clone()), + "continuation page {page_count}: pubkey {pk} appeared more than once" + ); + } + page_count += 1; + cursor_token = page["nextCursor"].as_str().map(str::to_owned); + assert!( + page_count <= 10, + "pagination must terminate within 10 pages for 201 rows" + ); + } + + // ── assertion 4: exactly-once coverage ─────────────────────────── + let expected: std::collections::HashSet = + all_pubkeys.iter().map(hex::encode).collect(); + assert_eq!( + seen_pubkeys, expected, + "every seeded pubkey must appear exactly once across all pages" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unban_member_returns_204_clears_ban_and_inserts_audit() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + + let community_uuid = Uuid::new_v4(); + let host = format!("unban-success-{}.example", community_uuid.simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("create other community") + .id; + let community_uuid = *community.as_uuid(); + + // Insert a permanent ban as the target member. + let target_pubkey = vec![0xCCu8; 32]; + let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); + db.ban_community_member(community, &target_pubkey, &actor_pubkey, None, None) + .await + .expect("insert ban fixture"); + + // Also give the same target an active timeout in the same community. + // Restriction independence: unban must NOT clear the timeout. + db.timeout_community_member( + community, + &target_pubkey, + &actor_pubkey, + chrono::Utc::now() + chrono::Duration::hours(2), + Some("test-timeout"), + ) + .await + .expect("insert timeout fixture alongside ban"); + + // Tenant isolation: ban a different pubkey in a *different* community so + // we can verify the DELETE only clears the intended restriction. + let other_community_uuid = Uuid::new_v4(); + let other_host = format!("unban-other-{}.example", other_community_uuid.simple()); + let other_community = db + .ensure_configured_community(&other_host) + .await + .expect("create other community") + .id; + db.ban_community_member(other_community, &target_pubkey, &actor_pubkey, None, None) + .await + .expect("insert ban fixture for other community"); + + let state = nip98_state_with_real_pool(pool.clone()).await; + let target_hex = hex::encode(&target_pubkey); + let path = format!("/members/{target_hex}/ban?communityId={community_uuid}"); + let auth = make_nostr_auth_delete(&test_operator_keys(), &path); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "unban of an active ban must return 204" + ); + + // Ban must be cleared in the target community. + let ban = db + .get_community_ban(community, &target_pubkey) + .await + .expect("read ban after unban"); + assert!( + ban.as_ref().is_none_or(|r| !r.banned), + "ban must be cleared after successful unban" + ); + + // Timeout must survive: unban must not clear the co-existing timeout. + // A regression that widened the unban UPDATE to also clear muted_until + // would fail this assertion. + assert!( + ban.as_ref() + .and_then(|r| r.muted_until) + .is_some_and(|t| t > chrono::Utc::now()), + "unban must not clear the co-existing active timeout" + ); + + // Audit row must exist in the target community. + let actions = db + .list_moderation_actions(community, 10) + .await + .expect("list moderation actions"); + let unban_action = actions.iter().find(|a| a.action == "unban"); + assert!(unban_action.is_some(), "audit row for unban must exist"); + let action = unban_action.unwrap(); + assert_eq!( + action.actor_pubkey, actor_pubkey, + "audit actor must be the operator" + ); + assert_eq!( + action.target_pubkey.as_deref(), + Some(target_pubkey.as_slice()), + "audit target_pubkey must match" + ); + assert_eq!( + action.actor_authority.as_str(), + "relay_operator", + "audit actor_authority must be relay_operator" + ); + + // Other community's ban must be untouched (tenant isolation). + let other_ban = db + .get_community_ban(other_community, &target_pubkey) + .await + .expect("read other community ban"); + assert!( + other_ban.is_some_and(|r| r.banned), + "unban must not affect the same pubkey in another community" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn untimeout_member_returns_204_clears_timeout_and_inserts_audit() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + + let community_uuid = Uuid::new_v4(); + let host = format!("untimeout-success-{}.example", community_uuid.simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("create test community") + .id; + let community_uuid = *community.as_uuid(); + + let target_pubkey = vec![0xDDu8; 32]; + let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); + db.timeout_community_member( + community, + &target_pubkey, + &actor_pubkey, + chrono::Utc::now() + chrono::Duration::hours(1), + Some("test reason"), + ) + .await + .expect("insert timeout fixture"); + + // Also give the same target an active ban in the same community. + // Restriction independence: untimeout must NOT clear the ban. + db.ban_community_member(community, &target_pubkey, &actor_pubkey, None, None) + .await + .expect("insert ban fixture alongside timeout"); + + // Cross-community isolation: give the same target a timeout in a second + // community. The untimeout must NOT clear it (binds community_id = $1). + let other_community_uuid = Uuid::new_v4(); + let other_host = format!("untimeout-other-{}.example", other_community_uuid.simple()); + let other_community = db + .ensure_configured_community(&other_host) + .await + .expect("create other community") + .id; + db.timeout_community_member( + other_community, + &target_pubkey, + &actor_pubkey, + chrono::Utc::now() + chrono::Duration::hours(1), + Some("test reason other community"), + ) + .await + .expect("insert timeout fixture for other community"); + + let state = nip98_state_with_real_pool(pool.clone()).await; + let target_hex = hex::encode(&target_pubkey); + let path = format!("/members/{target_hex}/timeout?communityId={community_uuid}"); + let auth = make_nostr_auth_delete(&test_operator_keys(), &path); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "untimeout of an active timeout must return 204" + ); + + // Timeout must be cleared in the target community. + let restriction = db + .get_community_ban(community, &target_pubkey) + .await + .expect("read ban after untimeout"); + assert!( + restriction + .as_ref() + .is_none_or(|r| r.muted_until.is_none_or(|t| t <= chrono::Utc::now())), + "timeout must be cleared after successful untimeout" + ); + + // Ban must survive: untimeout must not clear the co-existing ban. + // A regression that widened the untimeout UPDATE to also clear banned + // would fail this assertion. + assert!( + restriction.as_ref().is_some_and(|r| r.banned), + "untimeout must not clear the co-existing active ban" + ); + + // Other community's timeout must be untouched (community_id predicate). + // A regression that dropped the community_id = $1 WHERE clause would + // clear this timeout and fail this assertion. + let other_restriction = db + .get_community_ban(other_community, &target_pubkey) + .await + .expect("read other community restriction"); + assert!( + other_restriction + .as_ref() + .and_then(|r| r.muted_until) + .is_some_and(|t| t > chrono::Utc::now()), + "untimeout must not affect the same pubkey's timeout in another community" + ); + + // Audit row must exist. + let actions = db + .list_moderation_actions(community, 10) + .await + .expect("list moderation actions"); + let untimeout_action = actions.iter().find(|a| a.action == "untimeout"); + assert!( + untimeout_action.is_some(), + "audit row for untimeout must exist" + ); + let action = untimeout_action.unwrap(); + assert_eq!(action.actor_pubkey, actor_pubkey, "audit actor must match"); + assert_eq!( + action.target_pubkey.as_deref(), + Some(target_pubkey.as_slice()), + "audit target_pubkey must match" + ); + assert_eq!( + action.actor_authority.as_str(), + "relay_operator", + "audit actor_authority must be relay_operator" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unban_member_returns_409_for_expired_ban() { + // An expired ban (banned=true, ban_expires_at <= now()) is treated as + // inactive by all read paths; the DELETE must also return 409 rather + // than 204 for an expired ban. + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + + let community_uuid = Uuid::new_v4(); + let host = format!("unban-expired-{}.example", community_uuid.simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("create test community") + .id; + let community_uuid = *community.as_uuid(); + + // Insert a ban that already expired. + let target_pubkey = vec![0xEEu8; 32]; + let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); + db.ban_community_member( + community, + &target_pubkey, + &actor_pubkey, + None, + // Expired 1 hour ago. + Some(chrono::Utc::now() - chrono::Duration::hours(1)), + ) + .await + .expect("insert expired ban fixture"); + + let state = nip98_state_with_real_pool(pool.clone()).await; + let target_hex = hex::encode(&target_pubkey); + let path = format!("/members/{target_hex}/ban?communityId={community_uuid}"); + let auth = make_nostr_auth_delete(&test_operator_keys(), &path); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "unban of an expired ban must return 409" + ); + + // No audit row should have been inserted (transaction rolled back). + let actions = db + .list_moderation_actions(community, 10) + .await + .expect("list moderation actions"); + assert!( + actions.is_empty(), + "no audit row must be inserted when unban returns 409" + ); + } + // ── NIP-98 mode helpers and tests ───────────────────────────────────── /// Replay guard that always returns `true` — every event is "fresh".