diff --git a/.env.example b/.env.example index 6d12738247..683bceb4cc 100644 --- a/.env.example +++ b/.env.example @@ -75,10 +75,9 @@ RELAY_URL=ws://localhost:3000 # BUZZ_WEB_DIR=./web/dist # NIP-PL mobile push is an explicit deployment opt-in. A gateway URL alone -# never enables it. When enabled and the URL is absent, the canonical -# https://push.buzz.xyz/v1/deliveries/apns endpoint is used. +# never enables it. When enabled, the exact HTTPS delivery URL is required. BUZZ_PUSH_ENABLED=false -# BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns +BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # ----------------------------------------------------------------------------- # Admin Dashboard (private moderation surface) diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 1d4f0ab50d..8238769df2 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -111,7 +111,11 @@ jobs: - name: Analyze run: cd mobile && flutter analyze - name: Test - run: cd mobile && flutter test + run: cd mobile && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example + - name: Test mobile without push configuration + run: cd mobile && flutter test test/shared/push/push_unconfigured_build_test.dart + - name: Test mobile recipe argument forwarding + run: /bin/bash scripts/test-mobile-gateway-recipes.sh - name: Build Android debug APK run: just mobile-build-android @@ -134,7 +138,9 @@ jobs: - name: Build complete iOS simulator app run: cd mobile && flutter build ios --simulator --debug --no-pub - name: Build complete unsigned iOS release - run: cd mobile && flutter build ios --release --no-codesign --no-pub + run: | + /bin/bash scripts/test-mobile-gateway-recipes.sh + cd mobile && flutter build ios --release --no-codesign --no-pub results: name: Results 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/AGENTS.md b/AGENTS.md index 2679499012..7afdc429fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -691,7 +691,7 @@ The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks. cd mobile dart format --output=none --set-exit-if-changed . flutter analyze -flutter test +flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example ``` Or from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt check), `just mobile-test` (tests). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2ef80719ae..bf435c270a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -229,15 +229,14 @@ When the relay receives `["EVENT", ]`, the handler in `handlers/event.rs` 4. EPHEMERAL ROUTE — kind 20000–29999 → ephemeral sub-pipeline (see below) 5. VERIFY — spawn_blocking(verify_event) — Schnorr sig + ID hash 6. MEMBERSHIP — channel_id in event tags? → check_channel_membership -7. DB INSERT — db.insert_event (ON CONFLICT DO NOTHING — idempotent) +7. DB INSERT — db.insert_event (idempotent; search_tsv generated synchronously) 8. REDIS PUBLISH — pubsub.publish_event (if channel-scoped) 9. FAN-OUT — sub_registry.fan_out → conn_manager.send_to -10. SEARCH INDEX — search_index_tx.send (bounded worker queue, non-blocking) -11. AUDIT LOG — audit.log (spawned async, non-blocking) -12. WORKFLOW TRIGGER — wf.on_event (spawned async, excludes kinds 46001–46012) +10. AUDIT LOG — audit.log (spawned async, non-blocking) +11. WORKFLOW TRIGGER — wf.on_event (spawned async, excludes kinds 46001–46012) ``` -Steps 10–12 are fire-and-forget. Search indexing is sent to a bounded worker queue (`search_index_tx`, capacity 1000); audit and workflow triggers are spawned as independent async tasks. A failure in any of these does not fail the event submission. The client receives `["OK", , true, ""]` at the end of the pipeline, not immediately after DB insert. +Steps 10–11 are fire-and-forget: audit and workflow triggers are spawned as independent async tasks. A failure in either does not fail the event submission. Search has no asynchronous indexing step: Postgres maintains the generated `events.search_tsv` column as part of step 7, and `buzz-search` only queries it. The client receives `["OK", , true, ""]` at the end of the pipeline, not immediately after DB insert. Step 9 (fan-out) explicitly **excludes** global subscriptions (no `channel_id` constraint) from channel-scoped events — global subscriptions do NOT receive events from private channels, regardless of filter match. This is a deliberate security boundary: only subscriptions scoped to an accessible `channel_id` receive those events. @@ -600,11 +599,22 @@ pub struct AppState { pub handler_semaphore: Arc, // 1024 concurrent handlers pub relay_keypair: nostr::Keys, // relay identity pub local_event_ids: moka::sync::Cache, // local-echo dedup - pub search_index_tx: mpsc::Sender, // bounded search worker queue // + config, redis_pool, membership_cache, media_storage, shutdown state } ``` +Postgres pools use the closed physical role vocabulary `writer`, `reader`, +`audit`, and `search`. The periodic sampler retains cheap SQLx pool handles and +exports `buzz_db_pool_connections{pool_role,state}` for the bounded states +`idle` and `active`, `buzz_db_pool_max_connections{pool_role}` for capacity, +and `buzz_db_pool_configured{pool_role}`. All four roles are always present (16 +raw gauge series total); absent optional pools report zero. Current pool size is +exactly the sum of its idle and active connection gauges. The legacy +`buzz_db_pool_*` writer gauges and `buzz_db_read_pool_*` reader gauges remain +for dashboard compatibility. Pool sizing and aggregate deployment connection +budgets remain configuration/deployment concerns rather than a relay pool +manager. + **`ConnectionState`** (per-connection): ```rust diff --git a/Justfile b/Justfile index 00c4fecc2f..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. @@ -829,7 +835,9 @@ mobile-check: # Run mobile tests mobile-test: - unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test + /bin/bash ./scripts/test-mobile-gateway-recipes.sh + unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example + unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test test/shared/push/push_unconfigured_build_test.dart # Regenerate the emoji dataset asset from desktop's emoji-mart install. # Output is committed — rerun after bumping @emoji-mart/data. @@ -838,8 +846,16 @@ mobile-emoji-data: # Compile an unsigned Android debug APK (worktree-aware debug identity) mobile-build-android: + #!/usr/bin/env bash + set -euo pipefail ./scripts/mobile-worktree-overrides.sh - unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub + set -- build apk --debug --no-pub + if [[ -n "${BUZZ_PUSH_GATEWAY_URL:-}" ]]; then + set -- "$@" --dart-define="BUZZ_PUSH_GATEWAY_URL=${BUZZ_PUSH_GATEWAY_URL}" + fi + unset GIT_DIR GIT_WORK_TREE + cd {{mobile_dir}} + flutter "$@" # Run the mobile app on iOS simulator (worktree-aware debug identity) mobile-dev: @@ -850,9 +866,18 @@ mobile-dev: sleep 3 fi ./scripts/mobile-worktree-overrides.sh + gateway_url="${BUZZ_PUSH_GATEWAY_URL:-}" + overrides_file="{{mobile_dir}}/ios/Flutter/AppOverrides.xcconfig" + if [[ -z "$gateway_url" && -f "$overrides_file" ]]; then + gateway_url="$(sed -nE 's/^[[:space:]]*BUZZ_PUSH_GATEWAY_URL[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$/\1/p' "$overrides_file" | tail -n 1 | sed 's/\$()//g')" + fi + set -- run + if [[ -n "$gateway_url" ]]; then + set -- "$@" --dart-define="BUZZ_PUSH_GATEWAY_URL=${gateway_url}" + fi cd {{mobile_dir}} unset GIT_DIR GIT_WORK_TREE - flutter run + flutter "$@" # Uninstall stale worktree-suffixed Buzz debug installs (production apps kept) mobile-clean: 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 6f8d0ffb3d..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, 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, @@ -50,7 +65,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/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/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 5901512504..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(), 44); + 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/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 5f608cb78f..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}; @@ -427,6 +434,52 @@ pub struct DbPoolStats { pub max: u32, } +impl DbPoolStats { + /// Read a utilization snapshot from a physical SQLx pool handle. + pub fn from_pool(pool: &sqlx::PgPool) -> Self { + Self { + size: pool.size(), + idle: pool.num_idle() as u32, + max: pool.options().get_max_connections(), + } + } + + /// Connections currently checked out from the pool. + pub const fn active(self) -> u32 { + self.size.saturating_sub(self.idle) + } +} + +/// Physical role of a Postgres connection pool owned by the relay. +/// +/// This vocabulary is intentionally closed so metrics labels remain bounded. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum DbPoolRole { + /// Primary pool used for authoritative writes and consistency-sensitive reads. + Writer, + /// Optional read-replica pool used for eligible lag-tolerant reads. + Reader, + /// Optional independent pool used by the hash-chain audit service. + Audit, + /// Independent pool used for Postgres full-text search queries. + Search, +} + +impl DbPoolRole { + /// Every physical pool role, in stable metrics-contract order. + pub const ALL: [Self; 4] = [Self::Writer, Self::Reader, Self::Audit, Self::Search]; + + /// Stable low-cardinality metrics label for this role. + pub const fn as_str(self) -> &'static str { + match self { + Self::Writer => "writer", + Self::Reader => "reader", + Self::Audit => "audit", + Self::Search => "search", + } + } +} + /// Bounded outcome of the Postgres portion of a relay readiness check. /// /// The variants deliberately separate waiting for a pooled connection from @@ -584,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; @@ -595,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, @@ -608,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)", @@ -617,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}" @@ -629,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 @@ -1073,9 +1187,9 @@ impl Db { /// exactly the ratio of the two pool sizes — in the direction that hides /// the problem. pub fn read_pool_stats(&self) -> Option { - self.read_pool.as_ref().map(|p| DbPoolStats { - size: p.size(), - idle: p.num_idle() as u32, + self.read_pool.as_ref().map(|pool| DbPoolStats { + size: pool.size(), + idle: pool.num_idle() as u32, max: self.read_max_connections, }) } @@ -1245,6 +1359,57 @@ impl Db { } } +#[cfg(test)] +mod pool_role_tests { + use super::{DbPoolRole, DbPoolStats}; + + #[test] + fn database_pool_role_vocabulary_is_exact() { + assert_eq!( + DbPoolRole::ALL.map(DbPoolRole::as_str), + ["writer", "reader", "audit", "search"] + ); + } + + #[test] + fn database_pool_active_connections_use_saturating_arithmetic() { + assert_eq!( + DbPoolStats { + size: 12, + idle: 5, + max: 20, + } + .active(), + 7 + ); + assert_eq!( + DbPoolStats { + size: 2, + idle: 3, + max: 20, + } + .active(), + 0 + ); + } + + /// `from_pool` must read the live SQLx handle rather than a cached copy. + /// A lazy pool never opens a connection, so this stays infrastructure-free. + #[tokio::test] + async fn database_pool_stats_are_read_from_the_physical_pool_handle() { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(7) + .connect_lazy(&crate::test_support::database_url()) + .expect("construct lazy pool"); + + let stats = DbPoolStats::from_pool(&pool); + assert_eq!(stats.size, 0); + assert_eq!(stats.idle, 0); + assert_eq!(stats.active(), 0); + assert_eq!(stats.max, 7); + } +} + #[cfg(test)] #[path = "tests.rs"] mod postgres_tests; diff --git a/crates/buzz-db/src/runtime/observability.rs b/crates/buzz-db/src/runtime/observability.rs index 4d2e5f7ad6..079c43f98a 100644 --- a/crates/buzz-db/src/runtime/observability.rs +++ b/crates/buzz-db/src/runtime/observability.rs @@ -7,6 +7,8 @@ use std::future::Future; use std::sync::Mutex; use std::time::{Duration, Instant}; +use super::DbPoolRole; + /// One valid pool/operation acquisition family. /// /// Keeping role and operation in one enum makes invalid combinations @@ -111,9 +113,9 @@ impl PoolOperation { pub(crate) const fn pool_role(self) -> &'static str { match self { Self::ReaderBootstrap | Self::ReaderAuthorization | Self::ReaderSubscriptionHistory => { - "reader" + DbPoolRole::Reader.as_str() } - _ => "writer", + _ => DbPoolRole::Writer.as_str(), } } @@ -138,21 +140,21 @@ impl PoolOperation { } pub(crate) const POOL_ACQUIRE_VALID_PAIRS: [(&str, &str); 11] = [ - ("writer", "bootstrap"), - ("reader", "bootstrap"), - ("writer", "readiness"), - ("writer", "tenant_resolution"), - ("writer", "authentication"), - ("writer", "authorization"), - ("reader", "authorization"), - ("writer", "subscription_history"), - ("reader", "subscription_history"), - ("writer", "event_write"), - ("writer", "maintenance"), + (DbPoolRole::Writer.as_str(), "bootstrap"), + (DbPoolRole::Reader.as_str(), "bootstrap"), + (DbPoolRole::Writer.as_str(), "readiness"), + (DbPoolRole::Writer.as_str(), "tenant_resolution"), + (DbPoolRole::Writer.as_str(), "authentication"), + (DbPoolRole::Writer.as_str(), "authorization"), + (DbPoolRole::Reader.as_str(), "authorization"), + (DbPoolRole::Writer.as_str(), "subscription_history"), + (DbPoolRole::Reader.as_str(), "subscription_history"), + (DbPoolRole::Writer.as_str(), "event_write"), + (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 { @@ -355,7 +357,7 @@ fn publish_waiters(pair: PoolOperation, value: u64) { /// idle eviction cannot turn an expected zero into ambiguous missing data. pub(crate) fn refresh_pool_waiters(include_reader: bool) { for pair in PoolOperation::ALL { - if pair.pool_role() == "reader" && !include_reader { + if pair.pool_role() == DbPoolRole::Reader.as_str() && !include_reader { continue; } let waiters = POOL_WAITERS[pair.index()] @@ -380,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() @@ -604,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), [ @@ -861,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-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/push.rs b/crates/buzz-db/src/store/push.rs index 59c44fc5a8..b8e62df833 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -328,7 +328,8 @@ pub async fn accept_lease_event( // uniqueness forever. The author lock makes this cleanup atomic with the // subsequent author-wide checks and replacement. sqlx::query( - "UPDATE push_leases SET active=false, endpoint_enabled=false, updated_at=now() \ + "UPDATE push_leases SET active=false, endpoint_enabled=false, app_profile=NULL, \ + endpoint_hash=NULL, endpoint_grant=NULL, max_class=NULL, subscriptions=NULL, updated_at=now() \ WHERE community_id=$1 AND author=$2 AND active \ AND expires_at <= EXTRACT(EPOCH FROM now())::bigint", ) @@ -1665,6 +1666,82 @@ mod postgres_tests { assert_eq!((event_count, lease_count), (0, 0)); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn replacing_naturally_expired_lease_replaces_old_state() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let keys = nostr::Keys::generate(); + let endpoint = [42; 32]; + let subscriptions = serde_json::json!([]); + + let expired_event = lease_event(&keys, "install", 100); + assert_eq!( + accept_lease_event( + &pool, + community, + &expired_event, + "install", + LeaseVersion { + source_event_id: expired_event.id.as_bytes(), + source_created_at: 100, + generation: 1, + expires_at: 1, + }, + Some(ActiveLease { + app_profile: "ios-production", + endpoint_hash: &endpoint, + endpoint_grant: "opaque-grant", + max_class: "default", + subscriptions: &subscriptions, + }), + 16, + ) + .await + .expect("insert expired lease"), + AcceptLeaseOutcome::Accepted + ); + + let replacement_event = lease_event(&keys, "install", 200); + let outcome = accept_lease_event( + &pool, + community, + &replacement_event, + "install", + LeaseVersion { + source_event_id: replacement_event.id.as_bytes(), + source_created_at: 200, + generation: 2, + expires_at: i64::MAX / 2, + }, + Some(ActiveLease { + app_profile: "ios-production", + endpoint_hash: &[43; 32], + endpoint_grant: "replacement-grant", + max_class: "default", + subscriptions: &subscriptions, + }), + 16, + ) + .await + .expect("replace expired lease"); + assert_eq!(outcome, AcceptLeaseOutcome::Accepted); + + let persisted: (bool, Vec, i64) = sqlx::query_as( + "SELECT active, source_event_id, generation FROM push_leases \ + WHERE community_id=$1 AND author=$2 AND installation_id=$3", + ) + .bind(community.as_uuid()) + .bind(expired_event.pubkey.as_bytes()) + .bind("install") + .fetch_one(&pool) + .await + .expect("read original lease"); + assert!(persisted.0, "replacement lease must be active"); + assert_eq!(persisted.1, replacement_event.id.as_bytes()); + assert_eq!(persisted.2, 2); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn source_event_collision_is_protocol_outcome_without_event_insert() { 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-push-gateway/migrations/0005_retain_revocation_tombstones.sql b/crates/buzz-push-gateway/migrations/0005_retain_revocation_tombstones.sql new file mode 100644 index 0000000000..bb00d1db13 --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0005_retain_revocation_tombstones.sql @@ -0,0 +1,15 @@ +-- Revoked installations remain as retry tombstones until authority expiry. +-- Restrict ownership uniqueness to live rows so a replacement can enroll +-- without deleting the old installation's idempotency state. +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_attest_key_id_key; +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_profile_token_fingerprint_key; + +CREATE UNIQUE INDEX push_gateway_installations_active_app_attest_key + ON push_gateway_installations (app_attest_key_id) + WHERE revoked_at IS NULL; + +CREATE UNIQUE INDEX push_gateway_installations_active_profile_token + ON push_gateway_installations (app_profile, token_fingerprint) + WHERE revoked_at IS NULL; diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 1a7ef4b2a6..9f71f6ee69 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -103,6 +103,8 @@ pub enum DeliveryDisposition { pub enum AuthorityError { #[error("authority state rejected the request")] Rejected, + #[error("installation authority is already live")] + Conflict, #[error("authority request rate exceeded")] RateLimited, #[error("authority store unavailable")] @@ -140,6 +142,13 @@ pub trait AuthorityStore: Send + Sync { now: i64, ) -> Result, AuthorityError>; async fn installation(&self, id: Uuid, now: i64) -> Result; + /// Load an unexpired installation for authenticated revocation retry, + /// including its terminal tombstone. + async fn installation_for_revocation( + &self, + id: Uuid, + now: i64, + ) -> Result; async fn advance_assertion_counter( &self, installation_id: Uuid, @@ -155,8 +164,9 @@ pub trait AuthorityStore: Send + Sync { token_ciphertext: Vec, token_fingerprint: [u8; 32], ) -> Result<(), AuthorityError>; - /// Revoke an active delegation only when `expected_generation` is current, - /// retaining that generation as the replacement watermark. + /// Revoke a delegation only when `expected_generation` is current, + /// retaining that generation as the replacement watermark. Repeating the + /// exact revocation succeeds so clients can recover from a lost response. async fn revoke_delegation( &self, installation_id: Uuid, @@ -267,7 +277,7 @@ impl AuthorityStore for MemoryAuthorityStore { if s.installations.contains_key(&n.id) { return Err(AuthorityError::Rejected); } - let replaced = s + let matching = s .installations .values() .filter(|installation| { @@ -277,14 +287,22 @@ impl AuthorityStore for MemoryAuthorityStore { }) .map(|installation| installation.id) .collect::>(); - if replaced.iter().any(|id| { + if matching.iter().any(|id| { s.installations .get(id) .is_some_and(|installation| !installation.revoked && installation.expires_at >= now) }) { // App identity and token possession never supersede a live installation. - return Err(AuthorityError::Rejected); + return Err(AuthorityError::Conflict); } + let replaced = matching + .into_iter() + .filter(|id| { + s.installations + .get(id) + .is_some_and(|installation| installation.expires_at < now) + }) + .collect::>(); for id in replaced { if let Some(old) = s.installations.remove(&id) { s.token_owners.remove(&(old.profile, old.token_fingerprint)); @@ -323,6 +341,20 @@ impl AuthorityStore for MemoryAuthorityStore { Ok(i.clone()) } + async fn installation_for_revocation( + &self, + id: Uuid, + now: i64, + ) -> Result { + let s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let i = s + .installations + .get(&id) + .filter(|i| i.expires_at >= now) + .ok_or(AuthorityError::Rejected)?; + Ok(i.clone()) + } + async fn matching_installation( &self, key_id: &[u8], @@ -361,7 +393,7 @@ impl AuthorityStore for MemoryAuthorityStore { .installations .get_mut(&id) .ok_or(AuthorityError::Rejected)?; - if i.revoked || i.assertion_counter != previous { + if i.assertion_counter != previous { return Err(AuthorityError::Rejected); } i.assertion_counter = next; @@ -449,9 +481,12 @@ impl AuthorityStore for MemoryAuthorityStore { .delegations .get_mut(&key) .ok_or(AuthorityError::Rejected)?; - if old.revoked || expected_generation != old.generation { + if expected_generation != old.generation { return Err(AuthorityError::Rejected); } + if old.revoked { + return Ok(()); + } old.revoked = true; Ok(()) } @@ -470,6 +505,9 @@ impl AuthorityStore for MemoryAuthorityStore { .installations .get_mut(&id) .ok_or(AuthorityError::Rejected)?; + if i.revoked && i.endpoint_epoch == new { + return Ok(()); + } if i.revoked || i.endpoint_epoch != expected { return Err(AuthorityError::Rejected); } @@ -758,7 +796,7 @@ mod tests { store .create_installation(replacement(Uuid::from_u128(5)), 1_999) .await, - Err(AuthorityError::Rejected) + Err(AuthorityError::Conflict) ); store .create_installation(replacement(Uuid::from_u128(5)), 2_001) @@ -838,6 +876,10 @@ mod tests { .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1) .await .expect("the current generation can be revoked"); + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1) + .await + .expect("the exact revocation is idempotent after response loss"); assert!(admitted(&store, &"55".repeat(32), Uuid::new_v4()) .await .is_err()); @@ -878,4 +920,57 @@ mod tests { .await .expect("generation 2 authority is active"); } + + #[tokio::test] + async fn installation_revocation_is_authenticated_and_idempotent() { + let store = store().await; + let id = Uuid::from_u128(1); + let before = store.installation(id, 1_000).await.unwrap(); + + store + .revoke_installation(id, 1, 2) + .await + .expect("the current endpoint epoch can be revoked"); + let tombstone = store + .installation_for_revocation(id, 1_000) + .await + .expect("revocation retry can authenticate against the tombstone"); + assert!(tombstone.revoked); + store + .advance_assertion_counter(id, before.assertion_counter, before.assertion_counter + 1) + .await + .expect("an authenticated tombstone retry can advance its assertion counter"); + store + .revoke_installation(id, 1, 2) + .await + .expect("the exact installation revocation is idempotent"); + store + .create_installation( + NewInstallation { + id: Uuid::from_u128(5), + app_attest_key_id: vec![1], + app_attest_public_key: vec![5; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![6], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at: 3_000, + }, + 1_001, + ) + .await + .expect("a replacement can reuse ownership without deleting the tombstone"); + assert!( + store + .installation_for_revocation(id, 1_001) + .await + .expect("replacement preserves the prior revocation tombstone") + .revoked + ); + assert_eq!( + store.revoke_installation(id, 2, 3).await, + Err(AuthorityError::Rejected) + ); + } } diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index f8485a628d..1835468fee 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -26,7 +26,8 @@ pub struct KeyConfig { pub struct Config { pub bind_addr: SocketAddr, pub health_addr: SocketAddr, - pub public_delivery_url: url::Url, + /// External gateway origin, delivery URL, and registered transcript audiences. + pub gateway_urls: GatewayUrls, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, @@ -41,6 +42,54 @@ pub struct Config { /// externally presented delivery capabilities. pub token_keys: Vec, } + +/// Gateway transport URLs and registered NIP-PL v1 transcript audiences. +#[derive(Debug, Clone)] +pub struct GatewayUrls { + /// External HTTPS origin serving the gateway. + pub origin: url::Url, + /// Exact NIP-98 delivery endpoint used by relays. + pub delivery: url::Url, + /// App Attest audience for installation enrollment. + pub enroll_audience: String, + /// App Attest audience for relay delegation. + pub delegate_audience: String, + /// App Attest audience for endpoint rotation. + pub rotate_endpoint_audience: String, + /// App Attest audience for delegation revocation. + pub revoke_delegation_audience: String, + /// App Attest audience for installation revocation. + pub revoke_installation_audience: String, +} + +impl GatewayUrls { + pub(crate) fn from_origin(origin: url::Url) -> Result { + let derive = |path: &str| { + origin + .join(path) + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN")) + }; + let delivery = derive("v1/deliveries/apns")?; + // NIP-PL v1 registers these exact audience strings. The configurable + // origin controls transport only; changing transcript bytes requires + // a separately versioned protocol profile. + let enroll_audience = "https://push.buzz.xyz/v1/installations".to_owned(); + let delegate_audience = "https://push.buzz.xyz/v1/delegations".to_owned(); + let rotate_endpoint_audience = "https://push.buzz.xyz/v1/installations/endpoint".to_owned(); + let revoke_delegation_audience = "https://push.buzz.xyz/v1/delegations/revoke".to_owned(); + let revoke_installation_audience = + "https://push.buzz.xyz/v1/installations/revoke".to_owned(); + Ok(Self { + origin, + delivery, + enroll_audience, + delegate_audience, + rotate_endpoint_audience, + revoke_delegation_audience, + revoke_installation_audience, + }) + } +} #[derive(Debug, Error)] pub enum ConfigError { #[error("missing required environment variable {0}")] @@ -132,20 +181,21 @@ impl Config { }) { return Err(ConfigError::Invalid("BUZZ_PUSH_TOKEN_KEYS")); } - let public_delivery_url = req(e, "BUZZ_PUSH_PUBLIC_DELIVERY_URL")? + let gateway_origin = req(e, "BUZZ_PUSH_GATEWAY_ORIGIN")? .parse::() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_PUBLIC_DELIVERY_URL"))?; - if public_delivery_url.scheme() != "https" - || public_delivery_url.host_str() != Some("push.buzz.xyz") - || public_delivery_url.port().is_some() - || public_delivery_url.path() != "/v1/deliveries/apns" - || public_delivery_url.query().is_some() - || public_delivery_url.fragment().is_some() - || !public_delivery_url.username().is_empty() - || public_delivery_url.password().is_some() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN"))?; + if gateway_origin.scheme() != "https" + || gateway_origin.host().is_none() + || gateway_origin.port().is_some() + || gateway_origin.path() != "/" + || gateway_origin.query().is_some() + || gateway_origin.fragment().is_some() + || !gateway_origin.username().is_empty() + || gateway_origin.password().is_some() { - return Err(ConfigError::Invalid("BUZZ_PUSH_PUBLIC_DELIVERY_URL")); + return Err(ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN")); } + let gateway_urls = GatewayUrls::from_origin(gateway_origin)?; let max_grant_lifetime_seconds = req(e, "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS")? .parse::() .ok() @@ -191,7 +241,7 @@ impl Config { Ok(Self { bind_addr, health_addr, - public_delivery_url, + gateway_urls, max_grant_lifetime_seconds, max_installation_lifetime_seconds, endpoint_quota_window_seconds, @@ -227,8 +277,8 @@ mod tests { ), ), ( - "BUZZ_PUSH_PUBLIC_DELIVERY_URL".into(), - "https://push.buzz.xyz/v1/deliveries/apns".into(), + "BUZZ_PUSH_GATEWAY_ORIGIN".into(), + "https://push.example".into(), ), ( "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS".into(), @@ -285,6 +335,36 @@ mod tests { } } + #[test] + fn gateway_transport_uses_configured_origin_and_transcript_audiences_stay_registered() { + let config = Config::from_map(&base()).unwrap(); + assert_eq!(config.gateway_urls.origin.as_str(), "https://push.example/"); + assert_eq!( + config.gateway_urls.delivery.as_str(), + "https://push.example/v1/deliveries/apns" + ); + assert_eq!( + config.gateway_urls.enroll_audience, + "https://push.buzz.xyz/v1/installations" + ); + assert_eq!( + config.gateway_urls.delegate_audience, + "https://push.buzz.xyz/v1/delegations" + ); + assert_eq!( + config.gateway_urls.rotate_endpoint_audience, + "https://push.buzz.xyz/v1/installations/endpoint" + ); + assert_eq!( + config.gateway_urls.revoke_delegation_audience, + "https://push.buzz.xyz/v1/delegations/revoke" + ); + assert_eq!( + config.gateway_urls.revoke_installation_audience, + "https://push.buzz.xyz/v1/installations/revoke" + ); + } + #[test] fn keyrings_preserve_current_then_predecessor_order_and_are_independent() { let config = Config::from_map(&base()).unwrap(); @@ -298,14 +378,10 @@ mod tests { #[test] fn malformed_security_configuration_fails_startup() { for (key, value) in [ - ( - "BUZZ_PUSH_PUBLIC_DELIVERY_URL", - "http://push.example/v1/deliveries/apns", - ), - ( - "BUZZ_PUSH_PUBLIC_DELIVERY_URL", - "https://push.example/v1/deliveries/apns", - ), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "http://push.example"), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example/path"), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example?token=x"), + ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://user@push.example"), ("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""), ("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 9a6c66a519..657c549b1e 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -5,6 +5,7 @@ use crate::{ authority::{ AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation, }, + config::GatewayUrls, grant::GrantKeyring, model::*, token::TokenKeyring, @@ -46,7 +47,8 @@ pub struct AppState { /// Server-owned dogfood application identity and APNs transport. The wire /// profile selector is fixed and App Attest verifies the configured app ID. pub profile: Arc, - pub delivery_url: url::Url, + /// Security-sensitive endpoints and audiences derived from one gateway origin. + pub gateway_urls: Arc, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, @@ -88,12 +90,16 @@ fn decode_challenge(value: &str) -> Option<[u8; 32]> { fn authority_error(e: AuthorityError) -> Response { match e { AuthorityError::Rejected => error(StatusCode::NOT_FOUND, "not_authorized"), + AuthorityError::Conflict => installation_conflict(), AuthorityError::RateLimited => error(StatusCode::TOO_MANY_REQUESTS, "rate_limited"), AuthorityError::Unavailable => { error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable") } } } +fn installation_conflict() -> Response { + error(StatusCode::CONFLICT, "installation_conflict") +} fn endpoint_bytes(endpoint: &str) -> Option> { valid_endpoint(endpoint) .then(|| hex::decode(endpoint).ok()) @@ -151,7 +157,7 @@ async fn challenge(State(s): State, body: Bytes) -> Response { #[derive(serde::Serialize)] struct EnrollTranscript<'a> { v: u8, - audience: &'static str, + audience: &'a str, challenge_id: uuid::Uuid, challenge: &'a str, key_id: &'a str, @@ -186,7 +192,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { }; let t = EnrollTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/installations", + audience: &s.gateway_urls.enroll_audience, challenge_id: r.challenge_id, challenge: &r.challenge, key_id: &r.key_id, @@ -232,7 +238,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { ) .into_response(); } - Ok(Some(_)) => return error(StatusCode::NOT_FOUND, "not_authorized"), + Ok(Some(_)) => return installation_conflict(), Ok(None) => {} Err(e) => return authority_error(e), } @@ -273,23 +279,31 @@ async fn enroll(State(s): State, body: Bytes) -> Response { .into_response() } +struct AssertionChallenge<'a> { + id: uuid::Uuid, + text: &'a str, +} + async fn verify_installation_assertion( s: &AppState, installation_id: uuid::Uuid, - challenge_id: uuid::Uuid, - challenge_text: &str, + challenge: AssertionChallenge<'_>, assertion: &str, domain: &str, signed: &T, + include_revoked: bool, ) -> Result<(), Response> { let now = (s.now)(); - let challenge = decode_challenge(challenge_text) + let challenge_bytes = decode_challenge(challenge.text) .ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?; - let installation = s - .authority - .installation(installation_id, now) - .await - .map_err(authority_error)?; + let installation = if include_revoked { + s.authority + .installation_for_revocation(installation_id, now) + .await + } else { + s.authority.installation(installation_id, now).await + } + .map_err(authority_error)?; if installation.profile != AppProfile::BuzzIosDogfood { return Err(error(StatusCode::NOT_FOUND, "not_authorized")); } @@ -303,12 +317,12 @@ async fn verify_installation_assertion( transcript.as_bytes(), &installation.app_attest_public_key, installation.assertion_counter, - challenge_text, - challenge_text, + challenge.text, + challenge.text, ) .map_err(|_| error(StatusCode::UNAUTHORIZED, "invalid_attestation"))?; s.authority - .consume_challenge(challenge_id, challenge, now) + .consume_challenge(challenge.id, challenge_bytes, now) .await .map_err(authority_error)?; s.authority @@ -324,7 +338,7 @@ async fn verify_installation_assertion( #[derive(serde::Serialize)] struct DelegateTranscript<'a> { v: u8, - audience: &'static str, + audience: &'a str, challenge_id: uuid::Uuid, challenge: &'a str, installation_handle: uuid::Uuid, @@ -352,7 +366,7 @@ async fn delegate(State(s): State, body: Bytes) -> Response { } let t = DelegateTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/delegations", + audience: &s.gateway_urls.delegate_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -365,11 +379,14 @@ async fn delegate(State(s): State, body: Bytes) -> Response { if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.delegate.v1", &t, + false, ) .await { @@ -413,7 +430,7 @@ async fn delegate(State(s): State, body: Bytes) -> Response { #[derive(serde::Serialize)] struct RotateTranscript<'a> { v: u8, - audience: &'static str, + audience: &'a str, challenge_id: uuid::Uuid, challenge: &'a str, installation_handle: uuid::Uuid, @@ -446,7 +463,7 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { }; let t = RotateTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/installations/endpoint", + audience: &s.gateway_urls.rotate_endpoint_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -457,11 +474,14 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.rotate-endpoint.v1", &t, + false, ) .await { @@ -489,7 +509,7 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { #[derive(serde::Serialize)] struct RevokeDelegationTranscript<'a> { v: u8, - audience: &'static str, + audience: &'a str, challenge_id: uuid::Uuid, challenge: &'a str, installation_handle: uuid::Uuid, @@ -506,7 +526,7 @@ async fn revoke_delegation(State(s): State, body: Bytes) -> Response { } let t = RevokeDelegationTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/delegations/revoke", + audience: &s.gateway_urls.revoke_delegation_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -516,11 +536,14 @@ async fn revoke_delegation(State(s): State, body: Bytes) -> Response { if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.revoke-delegation.v1", &t, + false, ) .await { @@ -538,7 +561,7 @@ async fn revoke_delegation(State(s): State, body: Bytes) -> Response { #[derive(serde::Serialize)] struct RevokeInstallationTranscript<'a> { v: u8, - audience: &'static str, + audience: &'a str, challenge_id: uuid::Uuid, challenge: &'a str, installation_handle: uuid::Uuid, @@ -558,7 +581,7 @@ async fn revoke_installation(State(s): State, body: Bytes) -> Response } let t = RevokeInstallationTranscript { v: r.v, - audience: "https://push.buzz.xyz/v1/installations/revoke", + audience: &s.gateway_urls.revoke_installation_audience, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -568,11 +591,14 @@ async fn revoke_installation(State(s): State, body: Bytes) -> Response if let Err(e) = verify_installation_assertion( &s, r.installation_handle, - r.challenge_id, - &r.challenge, + AssertionChallenge { + id: r.challenge_id, + text: &r.challenge, + }, &r.assertion, "buzz.push.revoke-installation.v1", &t, + true, ) .await { @@ -613,7 +639,7 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> }; let relay = match verify_auth_header( auth, - &s.delivery_url, + &s.gateway_urls.delivery, HttpMethod::POST, Timestamp::now(), Some(&body), @@ -662,6 +688,11 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> crate::metrics::record_delivery_error("invalid_grant"); return error(StatusCode::NOT_FOUND, "invalid_grant"); } + Err(AuthorityError::Conflict) => { + crate::metrics::record_admission(crate::metrics::Admission::Rejected); + crate::metrics::record_delivery_error("invalid_grant"); + return error(StatusCode::NOT_FOUND, "invalid_grant"); + } Err(AuthorityError::RateLimited) => { crate::metrics::record_admission(crate::metrics::Admission::Rejected); crate::metrics::record_delivery_error("rate_limited"); @@ -875,7 +906,9 @@ mod request_limit_tests { app_attest: Arc::new(app_attest), transport: Arc::new(NeverTransport), }), - delivery_url: "https://push.buzz.xyz/v1/deliveries/apns".parse().unwrap(), + gateway_urls: Arc::new( + GatewayUrls::from_origin("https://push.example".parse().unwrap()).unwrap(), + ), max_grant_lifetime_seconds: 86_400, max_installation_lifetime_seconds: 86_400, endpoint_quota_window_seconds: 60, @@ -934,6 +967,15 @@ mod request_limit_tests { assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); } + #[test] + fn live_installation_conflict_has_an_unambiguous_status() { + assert_eq!( + authority_error(AuthorityError::Conflict).status(), + StatusCode::CONFLICT + ); + assert_eq!(installation_conflict().status(), StatusCode::CONFLICT); + } + #[test] fn ambiguous_apns_profile_failures_remain_retryable_at_the_relay_boundary() { for reason in ["BadDeviceToken", "DeviceTokenNotForTopic"] { diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index db35b25110..f71bf21d6b 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -95,7 +95,7 @@ async fn main() -> Result<(), Box> { authority, token_keyring: Arc::new(token_keyring), profile: Arc::new(profile), - delivery_url: c.public_delivery_url, + gateway_urls: Arc::new(c.gateway_urls), max_grant_lifetime_seconds: c.max_grant_lifetime_seconds, max_installation_lifetime_seconds: c.max_installation_lifetime_seconds, endpoint_quota_window_seconds: c.endpoint_quota_window_seconds, diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 17fff2a242..2459cefe11 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -7,6 +7,12 @@ use chrono::{DateTime, Utc}; use sqlx::{AssertSqlSafe, PgPool, Row}; use uuid::Uuid; +#[cfg(test)] +#[path = "postgres/bootstrap_tests.rs"] +mod bootstrap_postgres_tests; + +static GATEWAY_MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); + #[derive(Clone)] pub struct PostgresAuthorityStore { pool: PgPool, @@ -20,7 +26,10 @@ impl PostgresAuthorityStore { pool: &PgPool, runtime_role: &str, ) -> Result<(), Box> { - sqlx::migrate!("./migrations").run(pool).await?; + sqlx::raw_sql(include_str!("postgres/bootstrap_guard.sql")) + .execute(pool) + .await?; + GATEWAY_MIGRATOR.run(pool).await?; if runtime_role.is_empty() || runtime_role.len() > 63 || !runtime_role @@ -187,20 +196,29 @@ impl AuthorityStore for PostgresAuthorityStore { _ => true, } }) { - return Err(AuthorityError::Rejected); + return Err(AuthorityError::Conflict); } let replaced = existing .iter() - .map(|row| row.try_get::("id").map_err(db)) + .map(|row| { + Ok(( + row.try_get::("id").map_err(db)?, + row.try_get::, _>("expires_at").map_err(db)?, + )) + }) .collect::, _>>()?; - if !replaced.is_empty() { + let expired = replaced + .into_iter() + .filter_map(|(id, expires_at)| (expires_at < now_at).then_some(id)) + .collect::>(); + if !expired.is_empty() { sqlx::query("DELETE FROM push_gateway_delegations WHERE installation_id = ANY($1)") - .bind(&replaced) + .bind(&expired) .execute(&mut *tx) .await .map_err(db)?; sqlx::query("DELETE FROM push_gateway_installations WHERE id = ANY($1)") - .bind(&replaced) + .bind(&expired) .execute(&mut *tx) .await .map_err(db)?; @@ -208,7 +226,7 @@ impl AuthorityStore for PostgresAuthorityStore { let result = sqlx::query("INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING") .bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&mut *tx).await.map_err(db)?; if result.rows_affected() != 1 { - return Err(AuthorityError::Rejected); + return Err(AuthorityError::Conflict); } tx.commit().await.map_err(db)?; Ok(()) @@ -230,6 +248,37 @@ impl AuthorityStore for PostgresAuthorityStore { revoked: false, }) } + async fn installation_for_revocation( + &self, + id: Uuid, + now: i64, + ) -> Result { + let r = sqlx::query( + "SELECT * FROM push_gateway_installations WHERE id=$1 AND expires_at >= $2", + ) + .bind(id) + .bind(at(now)?) + .fetch_optional(&self.pool) + .await + .map_err(db)? + .ok_or(AuthorityError::Rejected)?; + Ok(Installation { + id, + app_attest_key_id: r.try_get("app_attest_key_id").map_err(db)?, + app_attest_public_key: r.try_get("app_attest_public_key").map_err(db)?, + assertion_counter: u32::try_from(r.try_get::("assertion_counter").map_err(db)?) + .map_err(|_| AuthorityError::Unavailable)?, + profile: profile(r.try_get("app_profile").map_err(db)?)?, + token_ciphertext: r.try_get("token_ciphertext").map_err(db)?, + token_fingerprint: bytes32(r.try_get("token_fingerprint").map_err(db)?)?, + endpoint_epoch: r.try_get("endpoint_epoch").map_err(db)?, + expires_at: ts(r.try_get("expires_at").map_err(db)?), + revoked: r + .try_get::>, _>("revoked_at") + .map_err(db)? + .is_some(), + }) + } async fn matching_installation( &self, key_id: &[u8], @@ -278,7 +327,7 @@ impl AuthorityStore for PostgresAuthorityStore { if next <= previous { return Err(AuthorityError::Rejected); } - let result=sqlx::query("UPDATE push_gateway_installations SET assertion_counter=$3,updated_at=now() WHERE id=$1 AND assertion_counter=$2 AND revoked_at IS NULL") + let result=sqlx::query("UPDATE push_gateway_installations SET assertion_counter=$3,updated_at=now() WHERE id=$1 AND assertion_counter=$2") .bind(id).bind(i64::from(previous)).bind(i64::from(next)).execute(&self.pool).await.map_err(db)?; if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); @@ -334,9 +383,12 @@ impl AuthorityStore for PostgresAuthorityStore { expected_generation: i64, ) -> Result<(), AuthorityError> { let relay = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?; - let result=sqlx::query("UPDATE push_gateway_delegations SET revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation=$3 AND revoked_at IS NULL").bind(id).bind(relay).bind(expected_generation).execute(&self.pool).await.map_err(db)?; + let result=sqlx::query("UPDATE push_gateway_delegations SET revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation=$3 AND revoked_at IS NULL").bind(id).bind(&relay).bind(expected_generation).execute(&self.pool).await.map_err(db)?; if result.rows_affected() != 1 { - return Err(AuthorityError::Rejected); + let already_revoked: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM push_gateway_delegations WHERE installation_id=$1 AND relay_pubkey=$2 AND generation=$3 AND revoked_at IS NOT NULL)").bind(id).bind(&relay).bind(expected_generation).fetch_one(&self.pool).await.map_err(db)?; + if !already_revoked { + return Err(AuthorityError::Rejected); + } } Ok(()) } @@ -351,7 +403,10 @@ impl AuthorityStore for PostgresAuthorityStore { } let result=sqlx::query("UPDATE push_gateway_installations SET endpoint_epoch=$3,revoked_at=now(),updated_at=now() WHERE id=$1 AND endpoint_epoch=$2 AND revoked_at IS NULL").bind(id).bind(expected).bind(new).execute(&self.pool).await.map_err(db)?; if result.rows_affected() != 1 { - return Err(AuthorityError::Rejected); + let already_revoked: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM push_gateway_installations WHERE id=$1 AND endpoint_epoch=$2 AND revoked_at IS NOT NULL)").bind(id).bind(new).fetch_one(&self.pool).await.map_err(db)?; + if !already_revoked { + return Err(AuthorityError::Rejected); + } } Ok(()) } @@ -484,26 +539,28 @@ impl AuthorityStore for PostgresAuthorityStore { .execute(&mut *tx) .await .map_err(db)?; - // A parent may become retention-eligible before an otherwise-active - // child. Parent eligibility must therefore reap every child first; - // otherwise the installation delete violates the delegation FK and - // rolls back all cleanup in this transaction. + // Revoked rows are idempotency tombstones for cleanup retries, so keep + // them for the full authority lifetime. A parent may expire before an + // otherwise-active child; reap every child of an expired parent first + // so the installation delete cannot violate the delegation FK. sqlx::query( "DELETE FROM push_gateway_delegations d WHERE d.expires_at < $1 - OR d.revoked_at < $1 - interval '1 day' OR EXISTS ( SELECT 1 FROM push_gateway_installations i WHERE i.id = d.installation_id - AND (i.expires_at < $1 OR i.revoked_at < $1 - interval '1 day') + AND i.expires_at < $1 )", ) .bind(at(now)?) .execute(&mut *tx) .await .map_err(db)?; - sqlx::query("DELETE FROM push_gateway_installations WHERE expires_at < $1 OR revoked_at < $1 - interval '1 day'") - .bind(at(now)?).execute(&mut *tx).await.map_err(db)?; + sqlx::query("DELETE FROM push_gateway_installations WHERE expires_at < $1") + .bind(at(now)?) + .execute(&mut *tx) + .await + .map_err(db)?; tx.commit().await.map_err(db)?; Ok(()) } @@ -573,6 +630,30 @@ mod postgres_tests { runtime.ready().await.is_ok(), "migrated least-privilege runtime is ready" ); + let legacy_uniqueness_constraints: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_constraint + WHERE conrelid='push_gateway_installations'::regclass + AND conname IN ( + 'push_gateway_installations_app_attest_key_id_key', + 'push_gateway_installations_app_profile_token_fingerprint_key')", + ) + .fetch_one(&migration_pool) + .await + .expect("inspect retired unconditional uniqueness constraints"); + assert_eq!(legacy_uniqueness_constraints, 0); + let active_uniqueness_indexes: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_indexes + WHERE schemaname=current_schema() + AND tablename='push_gateway_installations' + AND indexname IN ( + 'push_gateway_installations_active_app_attest_key', + 'push_gateway_installations_active_profile_token') + AND indexdef LIKE '%WHERE (revoked_at IS NULL)%'", + ) + .fetch_one(&migration_pool) + .await + .expect("inspect active-only uniqueness indexes"); + assert_eq!(active_uniqueness_indexes, 2); assert!( sqlx::query("CREATE TABLE forbidden_runtime_ddl(id INT)") .execute(&runtime_pool) @@ -606,7 +687,7 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires PostgreSQL"] - async fn reaper_deletes_active_child_of_retention_eligible_revoked_installation() { + async fn reaper_retains_revocation_tombstones_until_authority_expiry() { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| TEST_DB_URL.to_owned()); @@ -646,32 +727,54 @@ mod postgres_tests { .expect("create authority retention tables"); let now = Utc::now(); - let installation_id = Uuid::new_v4(); + let revoked_installation_id = Uuid::new_v4(); sqlx::query( "INSERT INTO push_gateway_installations(id, expires_at, revoked_at) VALUES ($1, $2, $3)", ) - .bind(installation_id) + .bind(revoked_installation_id) .bind(now + chrono::Duration::days(30)) .bind(now - chrono::Duration::days(2)) .execute(&pool) .await - .expect("insert retention-eligible revoked installation"); + .expect("insert revoked installation tombstone"); sqlx::query( "INSERT INTO push_gateway_delegations(id, installation_id, expires_at, revoked_at) VALUES ($1, $2, $3, NULL)", ) .bind(Uuid::new_v4()) - .bind(installation_id) + .bind(revoked_installation_id) .bind(now + chrono::Duration::days(7)) .execute(&pool) .await .expect("insert active future-expiring child delegation"); + let active_installation_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO push_gateway_installations(id, expires_at, revoked_at) + VALUES ($1, $2, NULL)", + ) + .bind(active_installation_id) + .bind(now + chrono::Duration::days(30)) + .execute(&pool) + .await + .expect("insert active installation"); + sqlx::query( + "INSERT INTO push_gateway_delegations(id, installation_id, expires_at, revoked_at) + VALUES ($1, $2, $3, $4)", + ) + .bind(Uuid::new_v4()) + .bind(active_installation_id) + .bind(now + chrono::Duration::days(30)) + .bind(now - chrono::Duration::days(2)) + .execute(&pool) + .await + .expect("insert revoked delegation tombstone"); + PostgresAuthorityStore::new(pool.clone()) .reap_expired(now.timestamp()) .await - .expect("reaper must delete the child before its revoked parent"); + .expect("reap before authority expiry"); let delegations: i64 = sqlx::query_scalar("SELECT count(*) FROM push_gateway_delegations") .fetch_one(&pool) .await @@ -681,6 +784,22 @@ mod postgres_tests { .fetch_one(&pool) .await .expect("count installations"); + assert_eq!(delegations, 2); + assert_eq!(installations, 2); + + PostgresAuthorityStore::new(pool.clone()) + .reap_expired((now + chrono::Duration::days(31)).timestamp()) + .await + .expect("reap after authority expiry"); + let delegations: i64 = sqlx::query_scalar("SELECT count(*) FROM push_gateway_delegations") + .fetch_one(&pool) + .await + .expect("count expired delegations"); + let installations: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations") + .fetch_one(&pool) + .await + .expect("count expired installations"); assert_eq!(delegations, 0); assert_eq!(installations, 0); @@ -726,63 +845,13 @@ mod postgres_tests { .connect(&database_url) .await .expect("connect isolated-schema pool"); - // Real DDL from migration 0010 (minus the _operator_global_tables audit - // insert, which lives outside the isolated schema). - sqlx::raw_sql( - "CREATE TABLE push_gateway_challenges ( - id UUID PRIMARY KEY, - challenge_hash BYTEA NOT NULL CHECK (length(challenge_hash) = 32), - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL - ); - CREATE TABLE push_gateway_installations ( - id UUID PRIMARY KEY, - app_attest_key_id BYTEA NOT NULL UNIQUE, - app_attest_public_key BYTEA NOT NULL, - assertion_counter BIGINT NOT NULL, - app_profile TEXT NOT NULL, - token_ciphertext BYTEA NOT NULL, - token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32), - endpoint_epoch BIGINT NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - revoked_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (app_profile, token_fingerprint) - ); - CREATE TABLE push_gateway_delegations ( - id UUID PRIMARY KEY, - installation_id UUID NOT NULL REFERENCES push_gateway_installations(id), - relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), - endpoint_epoch BIGINT NOT NULL, - generation BIGINT NOT NULL, - not_before TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - revoked_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (installation_id, relay_pubkey) - ); - CREATE TABLE push_gateway_endpoint_quotas ( - token_fingerprint BYTEA PRIMARY KEY CHECK (length(token_fingerprint) = 32), - window_started_at TIMESTAMPTZ NOT NULL, - admitted BIGINT NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ); - CREATE TABLE push_gateway_delivery_auth_replays ( - relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), - auth_event_id BYTEA NOT NULL CHECK (length(auth_event_id) = 32), - expires_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (relay_pubkey, auth_event_id) - ); - CREATE TABLE push_gateway_delivery_request_replays ( - relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), - request_id UUID NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (relay_pubkey, request_id) - );", - ) - .execute(&pool) - .await - .expect("create authority admission tables"); + // Exercise the same scoped migrations as production, including active-only + // uniqueness and future schema changes. The isolated search_path keeps + // these tables and SQLx's history separate from other tests. + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("apply gateway migrations"); (pool, schema) } @@ -909,7 +978,7 @@ mod postgres_tests { store .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 999,) .await, - Err(AuthorityError::Rejected) + Err(AuthorityError::Conflict) ); store .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 1_001) @@ -932,6 +1001,56 @@ mod postgres_tests { drop_schema(&schema).await; } + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn replacement_installation_preserves_unexpired_revocation_tombstone() { + let (pool, schema) = full_schema(1).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let now = Utc::now().timestamp(); + let installation = |id| NewInstallation { + id, + app_attest_key_id: vec![1], + app_attest_public_key: vec![2; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at: now + 2_592_000, + }; + let original_id = Uuid::from_u128(1); + + store + .create_installation(installation(original_id), now) + .await + .expect("create original installation"); + store + .revoke_installation(original_id, 1, 2) + .await + .expect("revoke original installation"); + store + .create_installation(installation(Uuid::from_u128(2)), now + 1) + .await + .expect("create replacement with the same key and token"); + + assert!( + store + .installation_for_revocation(original_id, now + 1) + .await + .expect("the original tombstone remains retryable") + .revoked + ); + let installations: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations") + .fetch_one(&pool) + .await + .expect("count original tombstone and replacement"); + assert_eq!(installations, 2); + + pool.close().await; + drop_schema(&schema).await; + } + fn admit<'a>( store: &'a PostgresAuthorityStore, event_hex: &'a str, diff --git a/crates/buzz-push-gateway/src/postgres/bootstrap_guard.sql b/crates/buzz-push-gateway/src/postgres/bootstrap_guard.sql new file mode 100644 index 0000000000..cf21b9bbf9 --- /dev/null +++ b/crates/buzz-push-gateway/src/postgres/bootstrap_guard.sql @@ -0,0 +1,29 @@ +-- This release supports fresh gateway initialization, not populated legacy +-- development databases. Run before migrations that could alter legacy data. +DO $$ +DECLARE + initialized boolean := false; + populated boolean; + candidate record; +BEGIN + IF to_regclass('_sqlx_migrations') IS NOT NULL THEN + EXECUTE 'SELECT EXISTS (SELECT 1 FROM _sqlx_migrations WHERE version = 5 AND success)' + INTO initialized; + END IF; + -- Ordinary subsequent deployments of the initialized gateway retain data. + IF initialized THEN + RETURN; + END IF; + + FOR candidate IN + SELECT schemaname, tablename FROM pg_tables + WHERE schemaname = current_schema() AND tablename <> '_sqlx_migrations' + LOOP + EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I.%I)', + candidate.schemaname, candidate.tablename) INTO populated; + IF populated THEN + RAISE EXCEPTION 'Cannot initialize push gateway: non-empty pre-launch database (table %). Legacy upgrades are unsupported. Stop the development gateway and provision a fresh dedicated database. No migrations have run.', candidate.tablename; + END IF; + END LOOP; +END +$$; diff --git a/crates/buzz-push-gateway/src/postgres/bootstrap_tests.rs b/crates/buzz-push-gateway/src/postgres/bootstrap_tests.rs new file mode 100644 index 0000000000..117cc798c2 --- /dev/null +++ b/crates/buzz-push-gateway/src/postgres/bootstrap_tests.rs @@ -0,0 +1,78 @@ +use super::*; +use sqlx::postgres::PgPoolOptions; + +#[tokio::test] +#[ignore = "requires PostgreSQL"] +async fn bootstrap_refuses_legacy_data_before_migrations_and_allows_initialized_data() { + let url = std::env::var("BUZZ_TEST_DATABASE_URL").expect("test database URL"); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .expect("connect test database"); + let schema = format!("bootstrap_{}", Uuid::new_v4().simple()); + sqlx::raw_sql(AssertSqlSafe(format!( + "CREATE SCHEMA {schema}; SET search_path TO {schema}" + ))) + .execute(&pool) + .await + .expect("isolated schema"); + + // Legacy schema plus real authority data. The production entry point must + // refuse before SQLx can create its history or run destructive old steps. + for migration in GATEWAY_MIGRATOR.iter().take(3) { + sqlx::raw_sql(migration.sql.as_ref()) + .execute(&pool) + .await + .expect("legacy schema"); + } + sqlx::query("INSERT INTO push_gateway_challenges (id, challenge_hash, expires_at) VALUES ($1, $2, now() + interval '1 hour')") + .bind(Uuid::new_v4()) + .bind(vec![1_u8; 32]) + .execute(&pool) + .await + .expect("legacy challenge"); + let error = PostgresAuthorityStore::apply_migrations_and_grants(&pool, "") + .await + .expect_err("populated legacy database must be refused"); + assert!(error.to_string().contains("non-empty pre-launch database")); + let (count, history_missing): (i64, bool) = sqlx::query_as( + "SELECT count(*), to_regclass('_sqlx_migrations') IS NULL FROM push_gateway_challenges", + ) + .fetch_one(&pool) + .await + .expect("unchanged legacy state"); + assert_eq!(count, 1); + assert!(history_missing, "refusal must precede all SQLx migrations"); + + // Only the test discards its isolated fixture, never the gateway. + sqlx::raw_sql(AssertSqlSafe(format!( + "DROP SCHEMA {schema} CASCADE; CREATE SCHEMA {schema}; SET search_path TO {schema}" + ))) + .execute(&pool) + .await + .expect("fresh fixture"); + for populated in [false, true] { + if populated { + sqlx::query("INSERT INTO push_gateway_challenges (id, challenge_hash, expires_at) VALUES ($1, $2, now() + interval '1 hour')") + .bind(Uuid::new_v4()) + .bind(vec![2_u8; 32]) + .execute(&pool) + .await + .expect("initialized gateway data"); + } + // An invalid role deliberately stops after migrations, avoiding grants + // against the shared test database's public schema. + let error = PostgresAuthorityStore::apply_migrations_and_grants(&pool, "") + .await + .expect_err("invalid role after successful initialization"); + assert!(error.to_string().contains("runtime database role")); + } + sqlx::raw_sql(AssertSqlSafe(format!( + "SET search_path TO public; DROP SCHEMA {schema} CASCADE" + ))) + .execute(&pool) + .await + .expect("remove isolated test fixture"); + pool.close().await; +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e035752ec3..5d831b3f65 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -347,8 +347,8 @@ pub struct Config { /// Descriptor key identifier accepted in kind:30350 `exec` tags. pub push_executor_key_id: String, /// Exact HTTPS gateway endpoint used to submit client-authorized APNs delivery capabilities. - /// An absent setting selects the canonical Buzz gateway. An explicitly - /// empty setting is allowed only while push is disabled. + /// Required while push is enabled. An explicitly empty setting is allowed + /// only while push is disabled. pub push_gateway_delivery_url: Option, /// Hard timeout for one gateway delivery request. pub push_gateway_timeout: Duration, @@ -447,8 +447,6 @@ fn parse_operator_api_origin(raw: &str) -> Result { Ok(raw.trim_end_matches('/').to_string()) } -const DEFAULT_PUSH_GATEWAY_DELIVERY_URL: &str = "https://push.buzz.xyz/v1/deliveries/apns"; - fn parse_push_gateway_delivery_url(raw: &str) -> Result { let url = url::Url::parse(raw.trim()).map_err(|e| { ConfigError::InvalidValue(format!( @@ -457,6 +455,7 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result { })?; if url.scheme() != "https" || url.host().is_none() + || url.port().is_some() || !url.username().is_empty() || url.password().is_some() || url.path() != "/v1/deliveries/apns" @@ -464,7 +463,7 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result { || url.fragment().is_some() { return Err(ConfigError::InvalidValue( - "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTPS /v1/deliveries/apns URL without credentials, query, or fragment" + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTPS /v1/deliveries/apns URL without an explicit port, credentials, query, or fragment" .to_string(), )); } @@ -978,9 +977,18 @@ impl Config { } Ok(raw) if raw.trim().is_empty() => None, Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?), - Err(_) => Some(parse_push_gateway_delivery_url( - DEFAULT_PUSH_GATEWAY_DELIVERY_URL, - )?), + Err(std::env::VarError::NotPresent) if push_enabled => { + return Err(ConfigError::InvalidValue( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be configured when BUZZ_PUSH_ENABLED=true" + .to_string(), + )); + } + Err(std::env::VarError::NotPresent) => None, + Err(error) => { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be valid UTF-8: {error}" + ))); + } }; let push_gateway_timeout_millis = match std::env::var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS") { Ok(raw) => raw @@ -2174,7 +2182,7 @@ mod tests { } #[test] - fn push_is_opt_in_and_gateway_defaults_to_buzz() { + fn push_is_opt_in_and_gateway_is_required_when_enabled() { let _guard = ENV_MUTEX.lock().unwrap(); let previous_enabled = std::env::var_os("BUZZ_PUSH_ENABLED"); let previous = std::env::var_os("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); @@ -2182,15 +2190,20 @@ mod tests { std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); let config = Config::from_env().expect("default config"); assert!(!config.push_enabled); - assert_eq!( - config - .push_gateway_delivery_url - .as_ref() - .map(url::Url::as_str), - Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL) - ); + assert!(config.push_gateway_delivery_url.is_none()); std::env::set_var("BUZZ_PUSH_ENABLED", "true"); + let result = Config::from_env(); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("must be configured") + )); + + std::env::set_var( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL", + "https://push.example/v1/deliveries/apns", + ); let config = Config::from_env().expect("enabled push config"); assert!(config.push_enabled); assert_eq!( @@ -2198,7 +2211,7 @@ mod tests { .push_gateway_delivery_url .as_ref() .map(url::Url::as_str), - Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL) + Some("https://push.example/v1/deliveries/apns") ); std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", ""); @@ -2248,6 +2261,7 @@ mod tests { assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok()); for invalid in [ "http://push.example/v1/deliveries/apns", + "https://push.example:8443/v1/deliveries/apns", "https://push.example/v1/deliveries/apns/", "https://push.example/v1/deliveries/apns?token=x", "https://user@push.example/v1/deliveries/apns", diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index e284e7fa6a..12a450861e 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -3,12 +3,12 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::sync::atomic::{AtomicU8, Ordering}; -use std::sync::Arc; -use std::time::Duration; +use std::sync::{Arc, Mutex as StdMutex, PoisonError}; +use std::time::{Duration, Instant}; use axum::extract::ws::{Message as WsMessage, WebSocket}; use futures_util::{Sink, SinkExt, StreamExt}; -use tokio::sync::{mpsc, watch, Mutex, RwLock}; +use tokio::sync::{mpsc, watch, Mutex}; use tokio_util::sync::CancellationToken; use tracing::Instrument as _; use tracing::{debug, info, trace, warn}; @@ -19,6 +19,7 @@ use buzz_core::tenant::TenantContext; use nostr::Filter; use crate::handlers; +use crate::metrics::AuthOutcome; use crate::protocol::{ClientMessage, RelayMessage}; use crate::rejection::{enforce_ws_admission, request_rejection_message, RejectionTarget}; use crate::state::{ @@ -29,6 +30,10 @@ use crate::state::{ /// Maximum time a new socket may hold a connection slot without completing NIP-42 auth. const AUTH_TIMEOUT: Duration = Duration::from_secs(5); +/// Maximum time the writer may spend flushing terminal frames after cancellation. +/// This stays well inside the process-wide 30-second hard drain. +const WS_TERMINAL_FLUSH_TIMEOUT: Duration = Duration::from_secs(1); + /// Shared mutable subscription map for a single WebSocket connection. pub(crate) type ConnectionSubscriptions = Arc>>>; @@ -47,6 +52,8 @@ pub enum AuthState { Pending { /// The random challenge string sent to the client. challenge: String, + /// When the challenge was delivered and this attempt began. + started_at: Instant, }, /// Client has successfully authenticated. Authenticated(AuthContext), @@ -55,7 +62,7 @@ pub enum AuthState { } /// Per-connection state split by access pattern: -/// - `auth_state`: RwLock (read-heavy after initial auth) +/// - `auth_state`: synchronous mutex (short, non-awaiting transitions; drop-safe cleanup) /// - `subscriptions`: Mutex (write-heavy during REQ/CLOSE) /// - `send_tx`, `ctrl_tx`, `cancel`: outside any lock (Clone+Send, no coordination needed) pub struct ConnectionState { @@ -68,7 +75,7 @@ pub struct ConnectionState { /// Remote socket address of the client. pub remote_addr: SocketAddr, /// Current NIP-42 authentication state. - pub auth_state: RwLock, + pub auth_state: StdMutex, /// Active subscriptions keyed by subscription ID. pub subscriptions: ConnectionSubscriptions, /// Sender for outbound data messages (EVENT, NOTICE, OK, etc.). @@ -88,6 +95,104 @@ pub struct ConnectionState { } impl ConnectionState { + fn lock_auth_state(&self) -> std::sync::MutexGuard<'_, AuthState> { + self.auth_state + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + + /// Snapshot the current authentication state without holding its lock over an await. + pub(crate) fn auth_state_snapshot(&self) -> AuthState { + self.lock_auth_state().clone() + } + + fn transition_pending_auth(&self, next: AuthState, outcome: AuthOutcome) -> bool { + let mut auth = self.lock_auth_state(); + let AuthState::Pending { started_at, .. } = &*auth else { + return false; + }; + let duration = started_at.elapsed(); + let became_authenticated = matches!(next, AuthState::Authenticated(_)); + *auth = next; + crate::metrics::record_auth_outcome(outcome, duration); + if became_authenticated { + // Keep the gauge update under the same state lock as the + // Pending -> Authenticated transition. Cleanup must never observe + // Authenticated before its increment and decrement first. + metrics::gauge!("buzz_ws_authenticated_connections_active").increment(1.0); + } + true + } + + /// Atomically finish the initial challenge as authenticated. + pub(crate) fn authenticate(&self, auth_context: AuthContext) -> bool { + self.transition_pending_auth(AuthState::Authenticated(auth_context), AuthOutcome::Success) + } + + /// Atomically finish the initial challenge with a bounded denial. + pub(crate) fn reject_auth(&self, outcome: AuthOutcome) -> bool { + debug_assert!(!matches!(outcome, AuthOutcome::Success)); + self.transition_pending_auth(AuthState::Failed, outcome) + } + + /// Finish a pending challenge on timeout and preserve the historical rule + /// that an already-failed connection is closed when its timeout expires. + fn expire_auth(&self) -> bool { + let mut auth = self.lock_auth_state(); + match &*auth { + AuthState::Pending { started_at, .. } => { + let duration = started_at.elapsed(); + *auth = AuthState::Failed; + crate::metrics::record_auth_outcome(AuthOutcome::Timeout, duration); + true + } + AuthState::Failed => true, + AuthState::Authenticated(_) => false, + } + } + + /// Finalize authentication accounting when a connection closes. + /// + /// Replacing the state with `Failed` makes cleanup idempotent: an + /// authenticated gauge can be decremented at most once, and a pending + /// attempt can receive at most one disconnect/shutdown terminal. + fn finish_auth_on_close(&self, outcome: AuthOutcome) -> Option { + debug_assert!(matches!( + outcome, + AuthOutcome::Disconnect | AuthOutcome::Shutdown + )); + let mut auth = self.lock_auth_state(); + match std::mem::replace(&mut *auth, AuthState::Failed) { + AuthState::Pending { started_at, .. } => { + crate::metrics::record_auth_outcome(outcome, started_at.elapsed()); + None + } + AuthState::Authenticated(auth_context) => { + metrics::gauge!("buzz_ws_authenticated_connections_active").decrement(1.0); + Some(auth_context) + } + AuthState::Failed => None, + } + } + + /// Let the cancellation watcher claim only a still-pending attempt. + /// Authenticated cleanup remains owned by `AuthLifecycleGuard`, which must + /// retain the authentication context for presence cleanup after task joins. + fn finish_pending_auth_on_cancel(&self, outcome: AuthOutcome) -> bool { + debug_assert!(matches!( + outcome, + AuthOutcome::Disconnect | AuthOutcome::Shutdown + )); + let mut auth = self.lock_auth_state(); + let AuthState::Pending { started_at, .. } = &*auth else { + return false; + }; + let duration = started_at.elapsed(); + *auth = AuthState::Failed; + crate::metrics::record_auth_outcome(outcome, duration); + true + } + /// Sends a data message to this connection's outbound channel. /// /// On a full buffer, increments the backpressure counter. The first @@ -119,6 +224,37 @@ impl ConnectionState { } } +/// Owns authentication accounting for exactly the lifetime of the production +/// connection future. Explicit teardown selects the precise close outcome; +/// aborts and panics fall back to `disconnect` and cancel child tasks. +struct AuthLifecycleGuard { + conn: Arc, + finished: bool, +} + +impl AuthLifecycleGuard { + fn new(conn: Arc) -> Self { + Self { + conn, + finished: false, + } + } + + fn finish(&mut self, outcome: AuthOutcome) -> Option { + self.finished = true; + self.conn.finish_auth_on_close(outcome) + } +} + +impl Drop for AuthLifecycleGuard { + fn drop(&mut self) { + if !self.finished { + self.conn.cancel.cancel(); + self.conn.finish_auth_on_close(AuthOutcome::Disconnect); + } + } +} + /// Entry point for a new WebSocket connection. /// /// Acquires a connection semaphore permit, sends the NIP-42 AUTH challenge, @@ -184,8 +320,9 @@ async fn handle_active_connection( conn_id, tenant, remote_addr: addr, - auth_state: RwLock::new(AuthState::Pending { + auth_state: StdMutex::new(AuthState::Pending { challenge: challenge.clone(), + started_at: Instant::now(), }), subscriptions: Arc::clone(&subscriptions), send_tx: tx.clone(), @@ -215,6 +352,8 @@ async fn handle_active_connection( // Gauge incremented AFTER challenge send succeeds — early disconnects // don't leak. Decremented in the cleanup path below. metrics::gauge!("buzz_ws_connections_active").increment(1.0); + crate::metrics::record_auth_attempt_started(); + let mut auth_lifecycle = AuthLifecycleGuard::new(Arc::clone(&conn)); // Register after challenge succeeds — avoids leaked entries on early disconnect. state.conn_manager.register( @@ -254,11 +393,7 @@ async fn handle_active_connection( let auth_timeout_task = tokio::spawn(async move { tokio::select! { _ = tokio::time::sleep(AUTH_TIMEOUT) => { - let authenticated = matches!( - *auth_timeout_conn.auth_state.read().await, - AuthState::Authenticated(_) - ); - if !authenticated { + if auth_timeout_conn.expire_auth() { warn!( conn_id = %auth_timeout_conn.conn_id, timeout_secs = AUTH_TIMEOUT.as_secs(), @@ -272,6 +407,22 @@ async fn handle_active_connection( } }); + // Cancellation races database-backed AUTH work. This watcher claims the + // pending lifecycle under the same lock as success/denial transitions, so + // whichever terminal happens first wins and a late handler cannot overwrite it. + let auth_cancel_conn = Arc::clone(&conn); + let auth_cancel_state = Arc::clone(&state); + let auth_cancel_token = cancel.clone(); + let auth_cancel_task = tokio::spawn(async move { + auth_cancel_token.cancelled().await; + let outcome = if auth_cancel_state.shutting_down.load(Ordering::Acquire) { + AuthOutcome::Shutdown + } else { + AuthOutcome::Disconnect + }; + auth_cancel_conn.finish_pending_auth_on_cancel(outcome); + }); + recv_loop( ws_recv, Arc::clone(&conn), @@ -282,9 +433,19 @@ async fn handle_active_connection( .await; cancel.cancel(); + let close_outcome = if state.shutting_down.load(Ordering::Acquire) { + AuthOutcome::Shutdown + } else { + AuthOutcome::Disconnect + }; + // Terminalize before joining writer/heartbeat tasks. A blocked socket sink + // must not keep the authenticated gauge high during shutdown. + let authenticated = auth_lifecycle.finish(close_outcome); + let _ = send_task.await; let _ = heartbeat_task.await; let _ = auth_timeout_task.await; + let _ = auth_cancel_task.await; for removed in state.sub_registry.remove_connection(conn.conn_id) { if removed.scope.is_global() { @@ -301,7 +462,7 @@ async fn handle_active_connection( } } state.conn_manager.deregister(conn.conn_id); - if let AuthState::Authenticated(ref auth_ctx) = *conn.auth_state.read().await { + if let Some(auth_ctx) = authenticated { let remaining = state.conn_manager.connection_ids_for_pubkey_in_community( conn.tenant.community(), auth_ctx.pubkey.to_bytes().as_slice(), @@ -344,6 +505,93 @@ async fn send_loop( .await; } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WriterStep { + Completed, + Cancelled, + Failed, +} + +async fn send_or_cancel( + sink: &mut S, + message: WsMessage, + cancel: &CancellationToken, +) -> WriterStep +where + S: Sink + Unpin, +{ + tokio::select! { + biased; + _ = cancel.cancelled() => WriterStep::Cancelled, + result = sink.send(message) => { + if result.is_ok() { WriterStep::Completed } else { WriterStep::Failed } + } + } +} + +async fn feed_or_cancel( + sink: &mut S, + message: WsMessage, + cancel: &CancellationToken, +) -> WriterStep +where + S: Sink + Unpin, +{ + tokio::select! { + biased; + _ = cancel.cancelled() => WriterStep::Cancelled, + result = sink.feed(message) => { + if result.is_ok() { WriterStep::Completed } else { WriterStep::Failed } + } + } +} + +async fn flush_or_cancel(sink: &mut S, cancel: &CancellationToken) -> WriterStep +where + S: Sink + Unpin, +{ + tokio::select! { + biased; + _ = cancel.cancelled() => WriterStep::Cancelled, + result = sink.flush() => { + if result.is_ok() { WriterStep::Completed } else { WriterStep::Failed } + } + } +} + +/// Best-effort terminal delivery with one shared deadline. A socket that never +/// becomes writable cannot retain its connection task or semaphore permit. +async fn flush_terminal_frames( + sink: &mut S, + ctrl_rx: &mut mpsc::Receiver, + disconnect_reason: &watch::Receiver>, + first_ctrl: Option, +) where + S: Sink + Unpin, +{ + let deadline = tokio::time::Instant::now() + WS_TERMINAL_FLUSH_TIMEOUT; + if let Some(ctrl_msg) = first_ctrl { + if !matches!( + tokio::time::timeout_at(deadline, sink.send(ctrl_msg)).await, + Ok(Ok(())) + ) { + return; + } + } + while let Ok(ctrl_msg) = ctrl_rx.try_recv() { + if !matches!( + tokio::time::timeout_at(deadline, sink.send(ctrl_msg)).await, + Ok(Ok(())) + ) { + return; + } + } + let close = disconnect_reason + .borrow() + .map_or(WsMessage::Close(None), |reason| reason.close_message()); + let _ = tokio::time::timeout_at(deadline, sink.send(close)).await; +} + async fn send_loop_inner( mut ws_send: S, mut data_rx: mpsc::Receiver, @@ -357,8 +605,19 @@ async fn send_loop_inner( loop { // Priority: drain all pending control frames before data. while let Ok(ctrl_msg) = ctrl_rx.try_recv() { - if ws_send.send(ctrl_msg).await.is_err() { - return; + match send_or_cancel(&mut ws_send, ctrl_msg.clone(), &cancel).await { + WriterStep::Completed => {} + WriterStep::Cancelled => { + flush_terminal_frames( + &mut ws_send, + &mut ctrl_rx, + &disconnect_reason, + Some(ctrl_msg), + ) + .await; + return; + } + WriterStep::Failed => return, } } @@ -368,13 +627,16 @@ async fn send_loop_inner( // cancellation can fall back to an unacknowledged close. biased; Some(restart) = restart_rx.recv() => { - let sent = ws_send - .send(WsMessage::Close(Some(axum::extract::ws::CloseFrame { + let sent = matches!( + tokio::time::timeout( + WS_TERMINAL_FLUSH_TIMEOUT, + ws_send.send(WsMessage::Close(Some(axum::extract::ws::CloseFrame { code: axum::extract::ws::close_code::RESTART, reason: axum::extract::ws::Utf8Bytes::from_static("relay restarting"), - }))) - .await - .is_ok(); + }))), + ).await, + Ok(Ok(())) + ); let _ = restart.flushed.send(sent); break; } @@ -385,33 +647,58 @@ async fn send_loop_inner( // would send Close first and the client would never learn why // (the top-of-loop drain does not run again after we break). // This makes "queue frame on ctrl, then cancel" a safe idiom. - while let Ok(ctrl_msg) = ctrl_rx.try_recv() { - if ws_send.send(ctrl_msg).await.is_err() { - break; - } - } - let close = disconnect_reason - .borrow() - .map_or(WsMessage::Close(None), |reason| reason.close_message()); - let _ = ws_send.send(close).await; + flush_terminal_frames(&mut ws_send, &mut ctrl_rx, &disconnect_reason, None).await; break; } Some(ctrl_msg) = ctrl_rx.recv() => { - if ws_send.send(ctrl_msg).await.is_err() { - break; + match send_or_cancel(&mut ws_send, ctrl_msg.clone(), &cancel).await { + WriterStep::Completed => {} + WriterStep::Cancelled => { + flush_terminal_frames( + &mut ws_send, + &mut ctrl_rx, + &disconnect_reason, + Some(ctrl_msg), + ) + .await; + break; + } + WriterStep::Failed => break, } } Some(msg) = data_rx.recv() => { let mut batched = 1usize; - if ws_send.feed(msg).await.is_err() { - break; + match feed_or_cancel(&mut ws_send, msg, &cancel).await { + WriterStep::Completed => {} + WriterStep::Cancelled => { + flush_terminal_frames( + &mut ws_send, + &mut ctrl_rx, + &disconnect_reason, + None, + ) + .await; + break; + } + WriterStep::Failed => break, } while batched < MAX_WS_SEND_BATCH { match data_rx.try_recv() { Ok(next) => { - if ws_send.feed(next).await.is_err() { - return; + match feed_or_cancel(&mut ws_send, next, &cancel).await { + WriterStep::Completed => {} + WriterStep::Cancelled => { + flush_terminal_frames( + &mut ws_send, + &mut ctrl_rx, + &disconnect_reason, + None, + ) + .await; + return; + } + WriterStep::Failed => return, } batched += 1; } @@ -420,8 +707,19 @@ async fn send_loop_inner( } } - if ws_send.flush().await.is_err() { - break; + match flush_or_cancel(&mut ws_send, &cancel).await { + WriterStep::Completed => {} + WriterStep::Cancelled => { + flush_terminal_frames( + &mut ws_send, + &mut ctrl_rx, + &disconnect_reason, + None, + ) + .await; + break; + } + WriterStep::Failed => break, } metrics::histogram!("buzz_ws_send_batch_size").record(batched as f64); } @@ -560,11 +858,15 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar match msg { ClientMessage::Auth(event) => { - // Auth is synchronous in the WS loop — no span context is lost. + // AUTH remains inline so only one frame can race the connection's + // pending lifecycle, but cancellation can preempt dependency waits. let span = tracing::info_span!("ws.auth", conn_id = %conn.conn_id); - handlers::auth::handle_auth(event, Arc::clone(&conn), Arc::clone(&state)) - .instrument(span) - .await; + tokio::select! { + biased; + _ = conn.cancel.cancelled() => {} + _ = handlers::auth::handle_auth(event, Arc::clone(&conn), Arc::clone(&state)) + .instrument(span) => {} + } } ClientMessage::Event(event) => { let conn = Arc::clone(&conn); @@ -654,10 +956,15 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar #[cfg(test)] pub(crate) mod tests { use super::*; + use axum::{extract::ws::WebSocketUpgrade, routing::get, Router}; + use metrics_util::debugging::DebugValue; use std::sync::{Arc, Mutex}; use buzz_auth::AuthMethod; - use nostr::{EventBuilder, Keys, Kind}; + use nostr::{EventBuilder, Keys, Kind, RelayUrl}; + use tokio::net::TcpListener; + use tokio::sync::Notify; + use tokio_tungstenite::{connect_async, tungstenite::Message}; /// A connection whose outbound frames a test can read back. /// @@ -675,7 +982,7 @@ pub(crate) mod tests { "test.local".to_string(), ), remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), - auth_state: RwLock::new(auth), + auth_state: StdMutex::new(auth), subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), send_tx, ctrl_tx, @@ -688,13 +995,109 @@ pub(crate) mod tests { /// An authenticated connection — the only state admission quotas apply to. pub(crate) fn authenticated_state() -> AuthState { - AuthState::Authenticated(AuthContext { + AuthState::Authenticated(auth_context()) + } + + fn auth_context() -> AuthContext { + AuthContext { pubkey: Keys::generate().public_key(), scopes: Vec::new(), channel_ids: None, auth_method: AuthMethod::Nip42, agent_owner_pubkey: None, - }) + } + } + + fn pending_state() -> AuthState { + AuthState::Pending { + challenge: "test-challenge".to_owned(), + started_at: Instant::now(), + } + } + + type MetricSnapshot = Vec<( + metrics_util::CompositeKey, + Option, + Option, + DebugValue, + )>; + + fn counter_value(snapshot: &MetricSnapshot, name: &str, outcome: Option<&str>) -> u64 { + snapshot + .iter() + .find_map(|(key, _, _, value)| { + if key.key().name() != name { + return None; + } + let labels = key.key().labels().collect::>(); + if outcome.is_some_and(|expected| { + !labels + .iter() + .any(|label| label.key() == "outcome" && label.value() == expected) + }) { + return None; + } + let DebugValue::Counter(value) = value else { + panic!("{name} must be a counter"); + }; + Some(*value) + }) + .unwrap_or_default() + } + + fn labeled_counter_value( + snapshot: &MetricSnapshot, + name: &str, + label_key: &str, + label_value: &str, + ) -> u64 { + snapshot + .iter() + .find_map(|(key, _, _, value)| { + (key.key().name() == name + && key + .key() + .labels() + .any(|label| label.key() == label_key && label.value() == label_value)) + .then(|| match value { + DebugValue::Counter(value) => *value, + _ => panic!("{name} must be a counter"), + }) + }) + .unwrap_or_default() + } + + fn labeled_gauge_value(snapshot: &MetricSnapshot, name: &str, labels: &[(&str, &str)]) -> f64 { + snapshot + .iter() + .find_map(|(key, _, _, value)| { + let matches = key.key().name() == name + && labels.iter().all(|(expected_key, expected_value)| { + key.key().labels().any(|label| { + label.key() == *expected_key && label.value() == *expected_value + }) + }); + matches.then(|| match value { + DebugValue::Gauge(value) => value.into_inner(), + _ => panic!("{name} must be a gauge"), + }) + }) + .unwrap_or_default() + } + + fn authenticated_gauge(snapshot: &MetricSnapshot) -> f64 { + snapshot + .iter() + .find_map(|(key, _, _, value)| { + if key.key().name() != "buzz_ws_authenticated_connections_active" { + return None; + } + let DebugValue::Gauge(value) = value else { + panic!("authenticated connections must be a gauge"); + }; + Some(value.into_inner()) + }) + .unwrap_or_default() } pub(crate) fn read_frame(rx: &mut mpsc::Receiver) -> serde_json::Value { @@ -704,6 +1107,387 @@ pub(crate) mod tests { } } + /// Exercise the real state-transition methods for every terminal. The + /// attempt counter must reconcile with exactly one terminal per completed + /// attempt, and repeated/racing terminal calls must not drive the active + /// authenticated gauge below zero. + #[tokio::test(flavor = "current_thread")] + async fn auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge() { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _recorder_guard = metrics::set_default_local_recorder(&recorder); + + crate::metrics::record_auth_attempt_started(); + let (success, _rx) = test_conn_with_auth(pending_state()); + assert!(success.authenticate(auth_context())); + assert!( + !success.authenticate(auth_context()), + "a terminal attempt cannot authenticate twice" + ); + assert!( + !success.finish_pending_auth_on_cancel(AuthOutcome::Disconnect), + "the cancellation watcher must leave authenticated context for lifecycle cleanup" + ); + assert!(success + .finish_auth_on_close(AuthOutcome::Disconnect) + .is_some()); + assert!( + success + .finish_auth_on_close(AuthOutcome::Shutdown) + .is_none(), + "cleanup must be idempotent" + ); + + for outcome in [ + AuthOutcome::Invalid, + AuthOutcome::Banned, + AuthOutcome::BanCheckError, + AuthOutcome::AllowlistCheckError, + AuthOutcome::AllowlistDenied, + AuthOutcome::RelayMembershipCheckError, + AuthOutcome::NotRelayMember, + ] { + crate::metrics::record_auth_attempt_started(); + let (denied, _rx) = test_conn_with_auth(pending_state()); + assert!(denied.reject_auth(outcome)); + assert!(!denied.reject_auth(outcome), "a denial cannot record twice"); + assert!(denied + .finish_auth_on_close(AuthOutcome::Disconnect) + .is_none()); + } + + crate::metrics::record_auth_attempt_started(); + let (timed_out, _rx) = test_conn_with_auth(pending_state()); + assert!(timed_out.expire_auth()); + assert!(timed_out + .finish_auth_on_close(AuthOutcome::Disconnect) + .is_none()); + + for outcome in [AuthOutcome::Disconnect, AuthOutcome::Shutdown] { + crate::metrics::record_auth_attempt_started(); + let (closed, _rx) = test_conn_with_auth(pending_state()); + assert!(closed.finish_auth_on_close(outcome).is_none()); + assert!(closed.finish_auth_on_close(outcome).is_none()); + } + + let snapshot = snapshotter.snapshot().into_vec(); + let attempts = counter_value(&snapshot, "buzz_auth_attempts_total", None); + let outcomes = AuthOutcome::ALL + .iter() + .map(|outcome| { + let value = counter_value( + &snapshot, + "buzz_auth_outcomes_total", + Some(outcome.as_str()), + ); + assert_eq!( + value, + 1, + "{} terminal must be recorded exactly once", + outcome.as_str() + ); + value + }) + .sum::(); + + assert_eq!(attempts, AuthOutcome::ALL.len() as u64); + assert_eq!(attempts, outcomes); + assert_eq!(authenticated_gauge(&snapshot), 0.0); + } + + /// Post-terminal AUTH floods traverse the production frame dispatcher but + /// cannot mint rollout-gating challenge lifecycles or outcomes. + #[tokio::test(flavor = "current_thread")] + async fn auth_flood_after_terminal_only_increments_protocol_noise_metric() { + const FRAMES_PER_STATE: u64 = 64; + + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _recorder_guard = metrics::set_default_local_recorder(&recorder); + let state = crate::state::tests::test_state().await; + let mut authoritative_attempts = 0; + let mut authoritative_outcomes = 0; + + for (auth_state, expected_state) in [ + ( + authenticated_state(), + crate::metrics::AuthPostTerminalState::Authenticated, + ), + ( + AuthState::Failed, + crate::metrics::AuthPostTerminalState::Failed, + ), + ] { + let (conn, mut rx) = test_conn_with_auth(auth_state); + for sequence in 0..FRAMES_PER_STATE { + let event = EventBuilder::new(Kind::Authentication, format!("noise-{sequence}")) + .sign_with_keys(&Keys::generate()) + .expect("sign AUTH noise event"); + let raw = serde_json::json!(["AUTH", event]).to_string(); + handle_text_message(raw, Arc::clone(&conn), Arc::clone(&state)).await; + let frame = read_frame(&mut rx); + assert_eq!(frame[2], false); + } + assert!(!conn.cancel.is_cancelled()); + let snapshot = snapshotter.snapshot().into_vec(); + authoritative_attempts += counter_value(&snapshot, "buzz_auth_attempts_total", None); + authoritative_outcomes += counter_value(&snapshot, "buzz_auth_outcomes_total", None); + assert_eq!( + labeled_counter_value( + &snapshot, + "buzz_auth_post_terminal_frames_total", + "state", + expected_state.as_str(), + ), + FRAMES_PER_STATE + ); + } + + let snapshot = snapshotter.snapshot().into_vec(); + authoritative_attempts += counter_value(&snapshot, "buzz_auth_attempts_total", None); + authoritative_outcomes += counter_value(&snapshot, "buzz_auth_outcomes_total", None); + assert_eq!( + authoritative_attempts, 0, + "post-terminal protocol noise cannot create authoritative attempts" + ); + assert_eq!( + authoritative_outcomes, 0, + "post-terminal protocol noise cannot create authoritative outcomes" + ); + } + + /// Aborting the production lifecycle owner must synchronously terminalize + /// pending accounting and release an authenticated gauge exactly once. + #[tokio::test(flavor = "current_thread")] + async fn aborting_lifecycle_owner_is_drop_safe() { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _recorder_guard = metrics::set_default_local_recorder(&recorder); + + crate::metrics::record_auth_attempt_started(); + let (authenticated, _rx) = test_conn_with_auth(pending_state()); + assert!(authenticated.authenticate(auth_context())); + let authenticated_started = Arc::new(Notify::new()); + let task = { + let conn = Arc::clone(&authenticated); + let started = Arc::clone(&authenticated_started); + tokio::spawn(async move { + let _guard = AuthLifecycleGuard::new(conn); + started.notify_one(); + std::future::pending::<()>().await; + }) + }; + authenticated_started.notified().await; + task.abort(); + assert!(task.await.expect_err("task was aborted").is_cancelled()); + + crate::metrics::record_auth_attempt_started(); + let (pending, _rx) = test_conn_with_auth(pending_state()); + let pending_started = Arc::new(Notify::new()); + let task = { + let conn = Arc::clone(&pending); + let started = Arc::clone(&pending_started); + tokio::spawn(async move { + let _guard = AuthLifecycleGuard::new(conn); + started.notify_one(); + std::future::pending::<()>().await; + }) + }; + pending_started.notified().await; + task.abort(); + assert!(task.await.expect_err("task was aborted").is_cancelled()); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!( + counter_value(&snapshot, "buzz_auth_attempts_total", None), + 2 + ); + assert_eq!( + counter_value( + &snapshot, + "buzz_auth_outcomes_total", + Some(AuthOutcome::Success.as_str()), + ), + 1 + ); + assert_eq!( + counter_value( + &snapshot, + "buzz_auth_outcomes_total", + Some(AuthOutcome::Disconnect.as_str()), + ), + 1 + ); + assert_eq!(authenticated_gauge(&snapshot), 0.0); + assert!(matches!(pending.auth_state_snapshot(), AuthState::Failed)); + } + + /// Drive the real upgraded WebSocket lifecycle until AUTH is waiting for + /// the sole database connection. Production shutdown must claim the + /// pending attempt before that dependency is released, and the late DB + /// result must not turn the terminal into success. + #[tokio::test(flavor = "current_thread")] + async fn shutdown_terminalizes_auth_stalled_on_database_before_late_success() { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _recorder_guard = metrics::set_default_local_recorder(&recorder); + + // Occupy the pool's only connection attempt with a fake PostgreSQL + // endpoint that accepts TCP and never completes the startup handshake. + // The production AUTH query then waits for the pool permit without + // requiring a developer database or relying on timing alone. + let fake_database = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fake PostgreSQL endpoint"); + let database_url = format!( + "postgres://buzz@{}/buzz", + fake_database.local_addr().expect("fake database address") + ); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(30)) + .connect_lazy(&database_url) + .expect("create lifecycle test pool"); + let blocker_pool = pool.clone(); + let blocker = tokio::spawn(async move { blocker_pool.acquire().await }); + let (blocked_database_stream, _) = fake_database + .accept() + .await + .expect("pool reached fake PostgreSQL endpoint"); + let state = crate::state::tests::test_state_with_database_pool(pool.clone()).await; + let tenant = TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "test.local".to_owned(), + ); + let expected_relay_url: RelayUrl = + crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant) + .parse() + .expect("expected NIP-42 relay URL"); + + let connection_finished = Arc::new(Notify::new()); + let route_state = Arc::clone(&state); + let route_tenant = tenant.clone(); + let route_finished = Arc::clone(&connection_finished); + let app = Router::new().route( + "/", + get(move |ws: WebSocketUpgrade| { + let state = Arc::clone(&route_state); + let tenant = route_tenant.clone(); + let finished = Arc::clone(&route_finished); + async move { + ws.on_upgrade(move |socket| async move { + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel); + handle_active_connection( + socket, + state, + "127.0.0.1:1234".parse().expect("client address"), + tenant, + Uuid::new_v4(), + control, + ) + .await; + finished.notify_one(); + }) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind lifecycle WebSocket listener"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve lifecycle WebSocket"); + }); + + let (mut client, _) = connect_async(format!("ws://{address}/")) + .await + .expect("connect lifecycle WebSocket"); + let challenge_frame = client + .next() + .await + .expect("challenge frame") + .expect("read challenge"); + let Message::Text(challenge_text) = challenge_frame else { + panic!("expected text challenge") + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("parse challenge"); + assert_eq!(challenge_json[0], "AUTH"); + let challenge = challenge_json[1].as_str().expect("challenge string"); + let auth_event = EventBuilder::auth(challenge, expected_relay_url) + .sign_with_keys(&Keys::generate()) + .expect("sign NIP-42 AUTH"); + client + .send(Message::Text( + serde_json::json!(["AUTH", auth_event]).to_string().into(), + )) + .await + .expect("send NIP-42 AUTH"); + + let attempts_before_shutdown = tokio::time::timeout(Duration::from_secs(2), async { + let mut attempts = 0; + loop { + let snapshot = snapshotter.snapshot().into_vec(); + attempts += counter_value(&snapshot, "buzz_auth_attempts_total", None); + if labeled_gauge_value( + &snapshot, + "buzz_db_pool_waiters", + &[("pool_role", "writer"), ("operation", "authorization")], + ) >= 1.0 + { + break attempts; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("AUTH reached the blocked production DB acquisition"); + assert_eq!( + attempts_before_shutdown, 1, + "the production-issued challenge must own the attempt start" + ); + + state.begin_shutdown(); + assert_eq!(state.conn_manager.drain_all(), 1); + tokio::time::timeout(Duration::from_secs(2), connection_finished.notified()) + .await + .expect("production connection lifecycle finishes after shutdown"); + + // Release the dependency only after production shutdown has claimed + // the pending lifecycle, then prove no late handler result can win. + drop(blocked_database_stream); + blocker.abort(); + let _ = blocker.await; + pool.close().await; + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!( + counter_value( + &snapshot, + "buzz_auth_outcomes_total", + Some(AuthOutcome::Shutdown.as_str()), + ), + 1 + ); + assert_eq!( + counter_value( + &snapshot, + "buzz_auth_outcomes_total", + Some(AuthOutcome::Success.as_str()), + ), + 0, + "releasing the dependency after shutdown must not record late success" + ); + assert_eq!(authenticated_gauge(&snapshot), 0.0); + + drop(client); + server.abort(); + let _ = server.await; + } + /// Drives the real `handle_text_message` with every handler permit held, so /// the EVENT saturation branch is reached through production dispatch rather /// than by calling its helpers directly. @@ -857,6 +1641,41 @@ pub(crate) mod tests { } } + #[derive(Debug)] + struct NeverReadySink { + ready_polled: Arc, + } + + impl Sink for NeverReadySink { + type Error = std::io::Error; + + fn poll_ready( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.ready_polled.notify_one(); + std::task::Poll::Pending + } + + fn start_send(self: std::pin::Pin<&mut Self>, _item: WsMessage) -> Result<(), Self::Error> { + panic!("a never-ready sink must not accept a frame") + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + + fn poll_close( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + } + fn ordinary_disconnect_reason() -> watch::Receiver> { let (_tx, rx) = watch::channel(None); rx @@ -878,6 +1697,40 @@ pub(crate) mod tests { .collect() } + /// Cancellation must break a writer blocked in `poll_ready`, and terminal + /// close delivery gets one bounded best-effort window before teardown wins. + #[tokio::test(start_paused = true)] + async fn cancelled_never_ready_sink_cannot_retain_writer_task() { + let (data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (_restart_tx, restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + let ready_polled = Arc::new(Notify::new()); + data_tx + .send(WsMessage::Text("blocked".into())) + .await + .expect("queue blocked frame"); + + let writer = tokio::spawn(send_loop_inner( + NeverReadySink { + ready_polled: Arc::clone(&ready_polled), + }, + data_rx, + ctrl_rx, + restart_rx, + cancel.clone(), + ordinary_disconnect_reason(), + )); + + ready_polled.notified().await; + cancel.cancel(); + tokio::task::yield_now().await; + tokio::time::advance(WS_TERMINAL_FLUSH_TIMEOUT + Duration::from_millis(1)).await; + writer + .await + .expect("writer exits after bounded terminal flush"); + } + #[tokio::test] async fn send_loop_batches_queued_data_frames_into_one_flush() { let (data_tx, data_rx) = mpsc::channel(MAX_WS_SEND_BATCH); diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 02e2cc03a6..930fa7af79 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -15,9 +15,63 @@ use axum::extract::ws::Message as WsMessage; use tracing::{debug, info, warn}; use crate::connection::{AuthState, ConnectionState}; +use crate::metrics::{AuthOutcome, AuthPostTerminalState}; use crate::protocol::RelayMessage; use crate::state::AppState; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BanOutcome { + Clear, + Banned, + DbError, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PolicyCheck { + Allowed(T), + Denied, + DependencyError, +} + +fn classify_allowlist(result: Result) -> PolicyCheck<()> { + match result { + Ok(true) => PolicyCheck::Allowed(()), + Ok(false) => PolicyCheck::Denied, + Err(_) => PolicyCheck::DependencyError, + } +} + +fn classify_relay_membership( + result: Result, +) -> PolicyCheck> { + use crate::api::relay_members::MembershipDecision; + + match result { + Ok(MembershipDecision::OpenRelay | MembershipDecision::Member) => { + PolicyCheck::Allowed(None) + } + Ok(MembershipDecision::ViaOwner(owner)) => PolicyCheck::Allowed(Some(owner)), + Ok(MembershipDecision::Denied) => PolicyCheck::Denied, + Err(_) => PolicyCheck::DependencyError, + } +} + +fn ban_denial(outcome: BanOutcome) -> Option<(&'static str, &'static str, AuthOutcome)> { + match outcome { + BanOutcome::Clear => None, + BanOutcome::Banned => Some(( + "banned", + "blocked: you are banned from this community", + AuthOutcome::Banned, + )), + BanOutcome::DbError => Some(( + "ban_check_error", + "error: internal error checking restriction state", + AuthOutcome::BanCheckError, + )), + } +} + /// Extract a NIP-OA `auth` tag from a verified AUTH event and serialize it as /// the JSON-array string that [`buzz_sdk::nip_oa::verify_auth_tag`] expects. /// @@ -43,11 +97,13 @@ pub fn extract_auth_tag_json(event: &nostr::Event) -> Option { pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Arc) { let event_id_hex = event.id.to_hex(); let (challenge, conn_id) = { - let auth = conn.auth_state.read().await; - match &*auth { - AuthState::Pending { challenge } => (challenge.clone(), conn.conn_id), + match conn.auth_state_snapshot() { + AuthState::Pending { challenge, .. } => (challenge, conn.conn_id), AuthState::Authenticated(_) => { debug!(conn_id = %conn.conn_id, "AUTH received but already authenticated"); + crate::metrics::record_post_terminal_auth_frame( + AuthPostTerminalState::Authenticated, + ); conn.send(RelayMessage::ok( &event_id_hex, false, @@ -57,6 +113,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } AuthState::Failed => { debug!(conn_id = %conn.conn_id, "AUTH received after failed auth"); + crate::metrics::record_post_terminal_auth_frame(AuthPostTerminalState::Failed); conn.send(RelayMessage::ok( &event_id_hex, false, @@ -82,8 +139,6 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &conn.tenant); let auth_svc = Arc::clone(&state.auth); - metrics::counter!("buzz_auth_attempts_total", "method" => "nip42").increment(1); - // Pure NIP-42 verification — crypto only, no DB lookups. match auth_svc .verify_auth_event(event, &challenge, &relay_url) @@ -111,12 +166,6 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // pinning `Failed` for the connection's life on a false premise. // `Banned` claims the ban; `DbError` denies with `error: internal` // (mirrors the ingest write-path gate). - enum BanOutcome { - Clear, - Banned, - DbError, - } - let mut outcome = match state .db .moderation_restriction_state(conn.tenant.community(), pubkey.as_bytes()) @@ -156,22 +205,13 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } - let denial: Option<(&str, &str)> = match outcome { - BanOutcome::Clear => None, - BanOutcome::Banned => { - Some(("banned", "blocked: you are banned from this community")) - } - BanOutcome::DbError => Some(( - "ban_check_error", - "error: internal error checking restriction state", - )), - }; - - if let Some((metric_reason, deny_reason)) = denial { + if let Some((metric_reason, deny_reason, auth_outcome)) = ban_denial(outcome) { warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), reason = deny_reason, "principal denied at ban seam"); metrics::counter!("buzz_auth_failures_total", "reason" => metric_reason) .increment(1); - *conn.auth_state.write().await = AuthState::Failed; + if !conn.reject_auth(auth_outcome) { + return; + } // Decision 4: banned ⇒ OK false + immediate WebSocket close. // Route the reason frame on the control channel (not `send`, // which uses the data channel and would race the cancel), so @@ -189,48 +229,68 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: if state.config.pubkey_allowlist_enabled && auth_ctx.auth_method == buzz_auth::AuthMethod::Nip42 { - let allowed = match state + let allowlist = state .db .is_pubkey_allowed(conn.tenant.community(), pubkey.as_bytes()) - .await - { - Ok(v) => v, - Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, + .await; + if let Err(e) = &allowlist { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, "allowlist DB lookup failed, denying (fail-closed)"); - false + } + match classify_allowlist(allowlist) { + PolicyCheck::Allowed(()) => {} + PolicyCheck::Denied => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "pubkey not in allowlist"); + metrics::counter!("buzz_auth_failures_total", "reason" => "allowlist_denied") + .increment(1); + if !conn.reject_auth(AuthOutcome::AllowlistDenied) { + return; + } + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: verification failed", + )); + return; + } + PolicyCheck::DependencyError => { + metrics::counter!("buzz_auth_failures_total", "reason" => "allowlist_check_error") + .increment(1); + if !conn.reject_auth(AuthOutcome::AllowlistCheckError) { + return; + } + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "error: internal error checking allowlist", + )); + return; } - }; - if !allowed { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "pubkey not in allowlist"); - metrics::counter!("buzz_auth_failures_total", "reason" => "allowlist_denied") - .increment(1); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - "auth-required: verification failed", - )); - return; } } // Relay membership gate — uses the shared helper with NIP-OA fallback. - let nip_oa_owner = match crate::api::relay_members::enforce_relay_membership( + let membership = crate::api::relay_members::check_relay_membership( &state, conn.tenant.community(), pubkey.as_bytes(), auth_tag_json.as_deref(), Some(signed_auth_created_at), ) - .await - { - Ok(owner) => owner, - Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = ?e, "not a relay member"); + .await; + if let Err(e) = &membership { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, + "relay membership DB lookup failed, denying (fail-closed)"); + } + let nip_oa_owner = match classify_relay_membership(membership) { + PolicyCheck::Allowed(owner) => owner, + PolicyCheck::Denied => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "not a relay member"); metrics::counter!("buzz_auth_failures_total", "reason" => "not_relay_member") .increment(1); - *conn.auth_state.write().await = AuthState::Failed; + if !conn.reject_auth(AuthOutcome::NotRelayMember) { + return; + } conn.send(RelayMessage::ok( &event_id_hex, false, @@ -238,6 +298,19 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: )); return; } + PolicyCheck::DependencyError => { + metrics::counter!("buzz_auth_failures_total", "reason" => "relay_membership_check_error") + .increment(1); + if !conn.reject_auth(AuthOutcome::RelayMembershipCheckError) { + return; + } + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "error: internal error checking relay membership", + )); + return; + } }; // Open relay NIP-OA backfill: extract owner for agent→owner DB mapping @@ -279,7 +352,9 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); - *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); + if !conn.authenticate(auth_ctx) { + return; + } state .conn_manager .set_authenticated_pubkey(conn_id, pubkey.to_bytes().to_vec()); @@ -288,7 +363,9 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Err(e) => { warn!(conn_id = %conn_id, error = %e, "NIP-42 auth failed"); metrics::counter!("buzz_auth_failures_total", "reason" => "nip42_invalid").increment(1); - *conn.auth_state.write().await = AuthState::Failed; + if !conn.reject_auth(AuthOutcome::Invalid) { + return; + } conn.send(RelayMessage::ok( &event_id_hex, false, @@ -300,8 +377,53 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: #[cfg(test)] mod tests { - use super::extract_auth_tag_json; - use nostr::{EventBuilder, Keys, Kind, Tag}; + use super::{ + ban_denial, classify_allowlist, classify_relay_membership, extract_auth_tag_json, + handle_auth, BanOutcome, PolicyCheck, + }; + use crate::api::relay_members::MembershipDecision; + use crate::connection::{tests::test_conn_with_auth, AuthState}; + use crate::metrics::{AuthOutcome, AuthPostTerminalState}; + use metrics_util::debugging::DebugValue; + use nostr::{EventBuilder, Keys, Kind, RelayUrl, Tag}; + use std::time::Instant; + + type MetricSnapshot = Vec<( + metrics_util::CompositeKey, + Option, + Option, + DebugValue, + )>; + + fn metric_counter(snapshot: &MetricSnapshot, name: &str, outcome: Option<&str>) -> u64 { + snapshot + .iter() + .find_map(|(key, _, _, value)| { + if key.key().name() != name { + return None; + } + let labels = key.key().labels().collect::>(); + if outcome.is_some_and(|expected| { + !labels + .iter() + .any(|label| label.key() == "outcome" && label.value() == expected) + }) { + return None; + } + let DebugValue::Counter(value) = value else { + panic!("{name} must be a counter"); + }; + Some(*value) + }) + .unwrap_or_default() + } + + fn pending(challenge: &str) -> AuthState { + AuthState::Pending { + challenge: challenge.to_owned(), + started_at: Instant::now(), + } + } /// Build a signed NIP-98 (kind 27235) event carrying the given tags. The /// `auth` tag lives inside the signed event exactly as the git and @@ -351,4 +473,194 @@ mod tests { ]); assert_eq!(extract_auth_tag_json(&event), None); } + + #[test] + fn ban_decisions_map_to_bounded_public_outcomes() { + assert_eq!(ban_denial(BanOutcome::Clear), None); + assert_eq!( + ban_denial(BanOutcome::Banned), + Some(( + "banned", + "blocked: you are banned from this community", + AuthOutcome::Banned, + )) + ); + assert_eq!( + ban_denial(BanOutcome::DbError), + Some(( + "ban_check_error", + "error: internal error checking restriction state", + AuthOutcome::BanCheckError, + )) + ); + } + + #[test] + fn dependency_failures_are_distinct_from_policy_denials() { + assert_eq!( + classify_allowlist(Ok::<_, &str>(true)), + PolicyCheck::Allowed(()) + ); + assert_eq!( + classify_allowlist(Ok::<_, &str>(false)), + PolicyCheck::Denied + ); + assert_eq!( + classify_allowlist(Err::("database unavailable")), + PolicyCheck::DependencyError + ); + + let owner = Keys::generate().public_key(); + assert_eq!( + classify_relay_membership(Ok(MembershipDecision::OpenRelay)), + PolicyCheck::Allowed(None) + ); + assert_eq!( + classify_relay_membership(Ok(MembershipDecision::Member)), + PolicyCheck::Allowed(None) + ); + assert_eq!( + classify_relay_membership(Ok(MembershipDecision::ViaOwner(owner))), + PolicyCheck::Allowed(Some(owner)) + ); + assert_eq!( + classify_relay_membership(Ok(MembershipDecision::Denied)), + PolicyCheck::Denied + ); + assert_eq!( + classify_relay_membership(Err("database unavailable".to_owned())), + PolicyCheck::DependencyError + ); + } + + /// The handler owns retry classification, so drive its real terminal-state + /// branches rather than calling the metric helper directly. A malformed + /// signature also traverses the real NIP-42 verifier before terminalizing. + #[tokio::test(flavor = "current_thread")] + async fn handler_separates_post_terminal_frames_from_invalid_attempts() { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _recorder_guard = metrics::set_default_local_recorder(&recorder); + let state = crate::state::tests::test_state().await; + + let (authenticated, mut authenticated_rx) = + test_conn_with_auth(crate::connection::tests::authenticated_state()); + let duplicate = signed_event_with_tags(Vec::new()); + handle_auth(duplicate, authenticated, state.clone()).await; + let duplicate_frame = crate::connection::tests::read_frame(&mut authenticated_rx); + assert_eq!(duplicate_frame[2], false); + assert_eq!(duplicate_frame[3], "auth-required: already authenticated"); + + let (failed, mut failed_rx) = test_conn_with_auth(AuthState::Failed); + let after_failure = signed_event_with_tags(Vec::new()); + handle_auth(after_failure, failed, state.clone()).await; + let failed_frame = crate::connection::tests::read_frame(&mut failed_rx); + assert_eq!(failed_frame[2], false); + assert_eq!( + failed_frame[3], + "auth-required: authentication already failed" + ); + + let challenge = "invalid-signature-challenge"; + let (invalid_conn, mut invalid_rx) = test_conn_with_auth(pending(challenge)); + crate::metrics::record_auth_attempt_started(); + let relay_url: RelayUrl = crate::api::bridge::nip42_expected_relay_url( + &state.config.relay_url, + &invalid_conn.tenant, + ) + .parse() + .expect("test relay URL"); + let mut invalid = EventBuilder::auth(challenge, relay_url) + .sign_with_keys(&Keys::generate()) + .expect("sign auth event"); + invalid.content.push('x'); + handle_auth(invalid, invalid_conn.clone(), state).await; + let invalid_frame = crate::connection::tests::read_frame(&mut invalid_rx); + assert_eq!(invalid_frame[2], false); + assert_eq!(invalid_frame[3], "auth-required: verification failed"); + assert!(matches!( + invalid_conn.auth_state_snapshot(), + AuthState::Failed + )); + + let snapshot = snapshotter.snapshot().into_vec(); + let attempts = metric_counter(&snapshot, "buzz_auth_attempts_total", None); + assert_eq!(attempts, 1); + assert_eq!( + metric_counter( + &snapshot, + "buzz_auth_outcomes_total", + Some(AuthOutcome::Invalid.as_str()), + ), + 1 + ); + for state in AuthPostTerminalState::ALL { + let count = snapshot + .iter() + .find_map(|(key, _, _, value)| { + (key.key().name() == "buzz_auth_post_terminal_frames_total" + && key + .key() + .labels() + .any(|label| label.key() == "state" && label.value() == state.as_str())) + .then(|| match value { + DebugValue::Counter(value) => *value, + _ => panic!("post-terminal frame metric must be a counter"), + }) + }) + .unwrap_or_default(); + assert_eq!(count, 1, "{} post-terminal frame", state.as_str()); + } + } + + /// A verified signature followed by an unavailable restriction database + /// must deny fail-closed and expose a dependency error, not mislabel the + /// principal as banned or let the attempt disappear. + #[tokio::test(flavor = "current_thread")] + async fn handler_accounts_ban_check_database_error() { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _recorder_guard = metrics::set_default_local_recorder(&recorder); + let state = crate::state::tests::test_state_with_database_url( + "postgres://buzz:buzz_dev@127.0.0.1:1/buzz", + ) + .await; + + let challenge = "ban-check-error-challenge"; + let (conn, _rx) = test_conn_with_auth(pending(challenge)); + crate::metrics::record_auth_attempt_started(); + let relay_url: RelayUrl = + crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &conn.tenant) + .parse() + .expect("test relay URL"); + let event = EventBuilder::auth(challenge, relay_url) + .sign_with_keys(&Keys::generate()) + .expect("sign auth event"); + + handle_auth(event, conn.clone(), state).await; + + assert!(matches!(conn.auth_state_snapshot(), AuthState::Failed)); + assert!(conn.cancel.is_cancelled()); + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!( + metric_counter( + &snapshot, + "buzz_auth_outcomes_total", + Some(AuthOutcome::BanCheckError.as_str()), + ), + 1 + ); + assert_eq!( + metric_counter( + &snapshot, + "buzz_auth_outcomes_total", + Some(AuthOutcome::Banned.as_str()), + ), + 0 + ); + assert_eq!( + metric_counter(&snapshot, "buzz_auth_attempts_total", None), + 1 + ); + } } diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 938674301e..5ea30b238b 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -23,8 +23,7 @@ pub async fn handle_count( ) { // Require auth let (pubkey_bytes, token_channel_ids) = { - let auth = conn.auth_state.read().await; - match &*auth { + match conn.auth_state_snapshot() { AuthState::Authenticated(ctx) => { (ctx.pubkey.to_bytes().to_vec(), ctx.channel_ids.clone()) } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 082522b123..3f767c1874 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -632,8 +632,7 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc ( conn.conn_id, ctx.pubkey.to_bytes().to_vec(), @@ -1059,8 +1058,7 @@ async fn handle_agent_observer_event( // Fast path: if this connection authenticated via NIP-OA and the verified // owner matches the observer frame's target owner, skip the DB lookup entirely. let session_owner_match = { - let auth = conn.auth_state.read().await; - if let crate::connection::AuthState::Authenticated(ctx) = &*auth { + if let crate::connection::AuthState::Authenticated(ctx) = conn.auth_state_snapshot() { ctx.agent_owner_pubkey.as_ref() == Some(&route.owner) } else { false @@ -1230,7 +1228,7 @@ mod tests { OBSERVER_FRAME_TELEMETRY, }; use nostr::{EventBuilder, Keys, Kind, Tag}; - use tokio::sync::{mpsc, Mutex, RwLock}; + use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -1447,7 +1445,7 @@ mod tests { conn_id: Uuid::new_v4(), tenant: buzz_core::TenantContext::resolved(community_b, "b.example"), remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), - auth_state: RwLock::new(crate::connection::AuthState::Authenticated( + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( buzz_auth::AuthContext { pubkey: agent.public_key(), scopes: vec![], @@ -1525,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![], @@ -1670,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/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 9a141e86ff..97d252cb7e 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -57,8 +57,7 @@ pub async fn handle_req( state: Arc, ) { let (conn_id, pubkey_bytes, token_channel_ids) = { - let auth = conn.auth_state.read().await; - match &*auth { + match conn.auth_state_snapshot() { AuthState::Authenticated(ctx) => { if !ctx.scopes.is_empty() && !ctx.scopes.contains(&Scope::MessagesRead) { conn.send(RelayMessage::notice("restricted: insufficient scope")); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 206f0329c0..532defbd8d 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -437,15 +437,16 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { Err(e) => error!("Failed to backfill d_tags: {e}"), } - let audit = if config.audit_enabled { + let (audit, audit_metrics_pool) = if config.audit_enabled { let audit_pool = connect_audit_pool(&db_config) .await .map_err(|e| anyhow::anyhow!("Audit DB connection failed: {e}"))?; info!("Audit service ready"); - Some(AuditService::new(audit_pool)) + let metrics_pool = audit_pool.clone(); + (Some(AuditService::new(audit_pool)), Some(metrics_pool)) } else { info!("Audit logging disabled by BUZZ_AUDIT_ENABLED"); - None + (None, None) }; let redis_pool = { @@ -495,6 +496,7 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { .connect(search_db_url) .await .map_err(|e| anyhow::anyhow!("Search DB connection failed: {e}"))?; + let search_metrics_pool = search_pool.clone(); let search = SearchService::new(search_pool); info!( replica = config.read_database_url.is_some(), @@ -1101,20 +1103,18 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { loop { interval.tick().await; let db_stats = pool_state.db.pool_stats(); - let active = db_stats.size.saturating_sub(db_stats.idle); - metrics::gauge!("buzz_db_pool_size").set(db_stats.size as f64); - metrics::gauge!("buzz_db_pool_idle").set(db_stats.idle as f64); - metrics::gauge!("buzz_db_pool_active").set(active as f64); - metrics::gauge!("buzz_db_pool_max").set(db_stats.max as f64); + let read_stats = pool_state.db.read_pool_stats(); + relay_metrics::record_db_pool_metrics(relay_metrics::DbPoolMetricsInput { + writer: db_stats, + reader: read_stats, + audit: audit_metrics_pool + .as_ref() + .map(buzz_db::DbPoolStats::from_pool), + search: buzz_db::DbPoolStats::from_pool(&search_metrics_pool), + }); pool_state.db.refresh_pool_waiter_metrics(); - if let Some(read_stats) = pool_state.db.read_pool_stats() { - let read_active = read_stats.size.saturating_sub(read_stats.idle); - metrics::gauge!("buzz_db_read_pool_size").set(read_stats.size as f64); - metrics::gauge!("buzz_db_read_pool_idle").set(read_stats.idle as f64); - metrics::gauge!("buzz_db_read_pool_active").set(read_active as f64); - metrics::gauge!("buzz_db_read_pool_max").set(read_stats.max as f64); - + if read_stats.is_some() { // Fence observability: 1 when replica routing is // eligible, and the verified-freshness lag in seconds. // Closed/stale fence reports open=0 with lag untouched. @@ -1609,6 +1609,7 @@ impl InMemoryMetricKey { /// racing the lifecycle-relative increments and decrements. fn refresh_legacy_active_gauge_recency() { metrics::gauge!("buzz_ws_connections_active").increment(0.0); + metrics::gauge!("buzz_ws_authenticated_connections_active").increment(0.0); metrics::gauge!("buzz_subscriptions_active").increment(0.0); } @@ -1763,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; @@ -2291,13 +2327,16 @@ mod tests { metrics::with_local_recorder(&recorder, || { let connections = metrics::gauge!("buzz_ws_connections_active"); + let authenticated = metrics::gauge!("buzz_ws_authenticated_connections_active"); let subscriptions = metrics::gauge!("buzz_subscriptions_active"); connections.increment(1.0); + authenticated.increment(1.0); subscriptions.increment(1.0); refresh_legacy_active_gauge_recency(); connections.decrement(1.0); + authenticated.decrement(1.0); subscriptions.increment(1.0); }); @@ -2314,6 +2353,10 @@ mod tests { .collect::>(); assert_eq!(values.get("buzz_ws_connections_active"), Some(&0.0)); + assert_eq!( + values.get("buzz_ws_authenticated_connections_active"), + Some(&0.0) + ); assert_eq!(values.get("buzz_subscriptions_active"), Some(&2.0)); } diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index f71894116c..c1e72f7b75 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -24,6 +24,80 @@ use axum::{ use metrics_exporter_prometheus::{BuildError, Matcher, PrometheusBuilder}; use metrics_util::MetricKindMask; +/// The fixed method label for the WebSocket NIP-42 challenge lifecycle. +pub(crate) const AUTH_METHOD_NIP42: &str = "nip42"; + +/// Bounded terminal results for one WebSocket authentication attempt. +/// +/// Keep this enum free of user-controlled or error-string values. Rollout +/// analysis relies on these labels having a small, stable cardinality. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AuthOutcome { + Success, + Invalid, + Banned, + BanCheckError, + AllowlistCheckError, + AllowlistDenied, + RelayMembershipCheckError, + NotRelayMember, + Timeout, + Disconnect, + Shutdown, +} + +impl AuthOutcome { + pub(crate) const ALL: [Self; 11] = [ + Self::Success, + Self::Invalid, + Self::Banned, + Self::BanCheckError, + Self::AllowlistCheckError, + Self::AllowlistDenied, + Self::RelayMembershipCheckError, + Self::NotRelayMember, + Self::Timeout, + Self::Disconnect, + Self::Shutdown, + ]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Success => "success", + Self::Invalid => "invalid", + Self::Banned => "banned", + Self::BanCheckError => "ban_check_error", + Self::AllowlistCheckError => "allowlist_check_error", + Self::AllowlistDenied => "allowlist_denied", + Self::RelayMembershipCheckError => "relay_membership_check_error", + Self::NotRelayMember => "not_relay_member", + Self::Timeout => "timeout", + Self::Disconnect => "disconnect", + Self::Shutdown => "shutdown", + } + } +} + +/// Bounded state of an AUTH frame received after the issued challenge had +/// already reached a terminal. These frames are protocol misuse, not recovery +/// lifecycles, and therefore stay outside rollout-gating attempts/outcomes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AuthPostTerminalState { + Authenticated, + Failed, +} + +impl AuthPostTerminalState { + pub(crate) const ALL: [Self; 2] = [Self::Authenticated, Self::Failed]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Authenticated => "authenticated", + Self::Failed => "failed", + } + } +} + /// HTTP latency buckets (milliseconds) — only for `http_request_latency_ms`. const LATENCY_BUCKETS_MS: [f64; 11] = [ 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0, @@ -43,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, @@ -115,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, @@ -206,6 +291,8 @@ pub fn try_install(port: u16, gauge_idle_timeout_secs: u64) -> Result<(), Metric .map_err(|_error| MetricsInstallError::RecorderConflict)?; describe_readiness_metrics(); describe_db_pool_metrics(); + describe_auth_metrics(); + initialize_auth_metric_series(); tokio::spawn(exporter); Ok(()) } @@ -242,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, @@ -255,6 +346,194 @@ 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" + ); + metrics::describe_gauge!( + "buzz_db_pool_max_connections", + "Maximum Postgres pool connections by physical pool role" + ); + metrics::describe_gauge!( + "buzz_db_pool_configured", + "Whether a physical Postgres pool role is configured, where 1 is configured and 0 is not configured" + ); +} + +/// Named utilization snapshots for every physical Postgres pool role. +pub struct DbPoolMetricsInput { + /// Primary writer pool utilization. + pub writer: buzz_db::DbPoolStats, + /// Optional read-replica pool utilization. + pub reader: Option, + /// Optional audit pool utilization. + pub audit: Option, + /// Search pool utilization. + pub search: buzz_db::DbPoolStats, +} + +/// Record fixed-cardinality utilization for every physical Postgres pool role. +/// +/// Optional roles emit zero-valued utilization when absent so the unified +/// scrape always contains four roles by two states, four maximum-capacity +/// series, and four configured series. The original writer and reader gauges +/// are also emitted unchanged for dashboard compatibility. +pub fn record_db_pool_metrics(input: DbPoolMetricsInput) { + let DbPoolMetricsInput { + writer, + reader, + audit, + search, + } = input; + let pools = [Some(writer), reader, audit, Some(search)]; + for (role, stats) in buzz_db::DbPoolRole::ALL.into_iter().zip(pools) { + let role = role.as_str(); + let configured = stats.is_some(); + let stats = stats.unwrap_or(buzz_db::DbPoolStats { + size: 0, + idle: 0, + max: 0, + }); + metrics::gauge!("buzz_db_pool_configured", "pool_role" => role).set(if configured { + 1.0 + } else { + 0.0 + }); + for (state, value) in [("idle", stats.idle), ("active", stats.active())] { + metrics::gauge!( + "buzz_db_pool_connections", + "pool_role" => role, + "state" => state, + ) + .set(value as f64); + } + metrics::gauge!("buzz_db_pool_max_connections", "pool_role" => role).set(stats.max as f64); + } + + metrics::gauge!("buzz_db_pool_size").set(writer.size as f64); + metrics::gauge!("buzz_db_pool_idle").set(writer.idle as f64); + metrics::gauge!("buzz_db_pool_active").set(writer.active() as f64); + metrics::gauge!("buzz_db_pool_max").set(writer.max as f64); + + if let Some(reader) = reader { + metrics::gauge!("buzz_db_read_pool_size").set(reader.size as f64); + metrics::gauge!("buzz_db_read_pool_idle").set(reader.idle as f64); + metrics::gauge!("buzz_db_read_pool_active").set(reader.active() as f64); + metrics::gauge!("buzz_db_read_pool_max").set(reader.max as f64); + } +} + +/// Register the bounded WebSocket authentication metric contract. +pub(crate) fn describe_auth_metrics() { + metrics::describe_counter!( + "buzz_auth_attempts_total", + "NIP-42 challenge lifecycles issued to WebSocket clients by bounded method" + ); + metrics::describe_counter!( + "buzz_auth_outcomes_total", + "WebSocket authentication attempts by bounded method and terminal outcome" + ); + metrics::describe_histogram!( + "buzz_auth_duration_seconds", + metrics::Unit::Seconds, + "WebSocket authentication attempt duration by bounded method and terminal outcome" + ); + metrics::describe_gauge!( + "buzz_ws_authenticated_connections_active", + "Current WebSocket connections that completed authentication on this pod" + ); + metrics::describe_counter!( + "buzz_auth_post_terminal_frames_total", + "AUTH frames received after the issued challenge reached a terminal, by bounded state" + ); +} + +/// Publish zero-valued authentication series at process start. +/// +/// Counters are never idle-evicted by the configured recorder. The active +/// gauge is refreshed alongside the other lifecycle-relative gauges in +/// `main.rs`, so a quiet healthy pod remains distinguishable from a missing +/// scrape target without racing its increments and decrements. +pub(crate) fn initialize_auth_metric_series() { + metrics::counter!( + "buzz_auth_attempts_total", + "method" => AUTH_METHOD_NIP42 + ) + .increment(0); + for outcome in AuthOutcome::ALL { + metrics::counter!( + "buzz_auth_outcomes_total", + "method" => AUTH_METHOD_NIP42, + "outcome" => outcome.as_str() + ) + .increment(0); + // Register the fixed-label histogram without recording a fake sample. + // Prometheus then exports zero buckets, `_sum`, and `_count` at boot. + let _ = metrics::histogram!( + "buzz_auth_duration_seconds", + "method" => AUTH_METHOD_NIP42, + "outcome" => outcome.as_str() + ); + } + for state in AuthPostTerminalState::ALL { + metrics::counter!( + "buzz_auth_post_terminal_frames_total", + "state" => state.as_str() + ) + .increment(0); + } + metrics::gauge!("buzz_ws_authenticated_connections_active").set(0.0); +} + +/// Record one issued NIP-42 challenge after it enters the connection writer. +/// +/// Before this lifecycle contract, `buzz_auth_attempts_total` incremented only +/// when an AUTH frame reached the pending handler. It now uses one consistent, +/// recovery-oriented unit: challenges successfully queued to the connection. +pub(crate) fn record_auth_attempt_started() { + metrics::counter!( + "buzz_auth_attempts_total", + "method" => AUTH_METHOD_NIP42 + ) + .increment(1); +} + +/// Record exactly one bounded terminal for an authentication attempt. +pub(crate) fn record_auth_outcome(outcome: AuthOutcome, duration: Duration) { + metrics::counter!( + "buzz_auth_outcomes_total", + "method" => AUTH_METHOD_NIP42, + "outcome" => outcome.as_str() + ) + .increment(1); + metrics::histogram!( + "buzz_auth_duration_seconds", + "method" => AUTH_METHOD_NIP42, + "outcome" => outcome.as_str() + ) + .record(duration.as_secs_f64()); +} + +/// Record protocol noise after the issued challenge already terminalized. +pub(crate) fn record_post_terminal_auth_frame(state: AuthPostTerminalState) { + metrics::counter!( + "buzz_auth_post_terminal_frames_total", + "state" => state.as_str() + ) + .increment(1); } #[cfg(test)] @@ -329,6 +608,7 @@ pub async fn track_metrics(req: Request, next: Next) -> Response { #[cfg(test)] mod contract_tests { use std::collections::BTreeSet; + use std::time::Duration; const OUTCOMES: [&str; 4] = ["success", "timeout", "error", "cancelled"]; @@ -344,12 +624,231 @@ mod contract_tests { .unwrap_or_default() } + fn sample_value<'a>(scrape: &'a str, metric: &str, labels: &[(&str, &str)]) -> &'a str { + scrape + .lines() + .find(|line| { + line.starts_with(metric) + && labels + .iter() + .all(|(key, value)| line.contains(&format!(r#"{key}="{value}""#))) + }) + .and_then(|line| line.rsplit_once(' ').map(|(_, value)| value)) + .unwrap_or_else(|| panic!("missing {metric} with {labels:?} in scrape:\n{scrape}")) + } + #[test] - fn production_builder_exports_frozen_db_pool_contract_and_187_series_budget() { + fn production_pool_sampler_has_fixed_roles_utilization_and_compatibility_metrics() { + let (recorder, handle) = super::readiness_test_recorder(); + metrics::with_local_recorder(&recorder, || { + super::describe_db_pool_metrics(); + super::record_db_pool_metrics(super::DbPoolMetricsInput { + writer: buzz_db::DbPoolStats { + size: 12, + idle: 5, + max: 20, + }, + reader: None, + audit: None, + search: buzz_db::DbPoolStats { + size: 4, + idle: 1, + max: 10, + }, + }); + }); + + let scrape = handle.render(); + let connections = scrape + .lines() + .filter(|line| line.starts_with("buzz_db_pool_connections{")) + .collect::>(); + let max_connections = scrape + .lines() + .filter(|line| line.starts_with("buzz_db_pool_max_connections{")) + .collect::>(); + let configured = scrape + .lines() + .filter(|line| line.starts_with("buzz_db_pool_configured{")) + .collect::>(); + assert_eq!(connections.len(), 8, "unexpected scrape:\n{scrape}"); + assert_eq!(max_connections.len(), 4, "unexpected scrape:\n{scrape}"); + assert_eq!(configured.len(), 4, "unexpected scrape:\n{scrape}"); + assert_eq!( + connections.len() + max_connections.len() + configured.len(), + 16, + "physical pool contract must stay fixed at 16 raw series:\n{scrape}" + ); + + let roles = connections + .iter() + .filter_map(|line| { + ["writer", "reader", "audit", "search"] + .into_iter() + .find(|role| line.contains(&format!(r#"pool_role="{role}""#))) + }) + .collect::>(); + assert_eq!( + roles, + BTreeSet::from(["writer", "reader", "audit", "search"]) + ); + assert!(connections + .iter() + .all(|line| label_keys(line) == BTreeSet::from(["pool_role", "state"]))); + assert!(max_connections + .iter() + .all(|line| label_keys(line) == BTreeSet::from(["pool_role"]))); + assert!(connections.iter().all(|line| { + line.contains(r#"state="idle""#) || line.contains(r#"state="active""#) + })); + assert!(!connections + .iter() + .any(|line| line.contains(r#"state="size""#) || line.contains(r#"state="max""#))); + + assert_eq!( + sample_value( + &scrape, + "buzz_db_pool_connections{", + &[("pool_role", "writer"), ("state", "active")] + ), + "7" + ); + for state in ["idle", "active"] { + assert_eq!( + sample_value( + &scrape, + "buzz_db_pool_connections{", + &[("pool_role", "reader"), ("state", state)] + ), + "0" + ); + } + assert_eq!( + sample_value( + &scrape, + "buzz_db_pool_max_connections{", + &[("pool_role", "writer")] + ), + "20" + ); + assert_eq!( + sample_value( + &scrape, + "buzz_db_pool_max_connections{", + &[("pool_role", "reader")] + ), + "0" + ); + assert_eq!( + sample_value( + &scrape, + "buzz_db_pool_configured{", + &[("pool_role", "reader")] + ), + "0" + ); + assert_eq!(sample_value(&scrape, "buzz_db_pool_size ", &[]), "12"); + assert_eq!(sample_value(&scrape, "buzz_db_pool_idle ", &[]), "5"); + assert_eq!(sample_value(&scrape, "buzz_db_pool_active ", &[]), "7"); + assert_eq!(sample_value(&scrape, "buzz_db_pool_max ", &[]), "20"); + assert!(!scrape.contains("buzz_db_read_pool_size")); + } + + #[test] + fn production_pool_sampler_reports_configured_reader_and_legacy_reader_family() { + let (recorder, handle) = super::readiness_test_recorder(); + metrics::with_local_recorder(&recorder, || { + super::record_db_pool_metrics(super::DbPoolMetricsInput { + writer: buzz_db::DbPoolStats { + size: 1, + idle: 1, + max: 50, + }, + reader: Some(buzz_db::DbPoolStats { + size: 9, + idle: 2, + max: 15, + }), + audit: None, + search: buzz_db::DbPoolStats { + size: 1, + idle: 1, + max: 10, + }, + }); + }); + + let scrape = handle.render(); + assert_eq!( + sample_value( + &scrape, + "buzz_db_pool_configured{", + &[("pool_role", "reader")] + ), + "1" + ); + assert_eq!(sample_value(&scrape, "buzz_db_read_pool_size ", &[]), "9"); + assert_eq!(sample_value(&scrape, "buzz_db_read_pool_idle ", &[]), "2"); + assert_eq!(sample_value(&scrape, "buzz_db_read_pool_active ", &[]), "7"); + assert_eq!(sample_value(&scrape, "buzz_db_read_pool_max ", &[]), "15"); + } + + #[tokio::test] + async fn production_pool_sampler_preserves_audit_and_search_pool_provenance() { + let audit_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect_lazy("postgres://localhost/buzz") + .expect("construct lazy audit pool"); + let search_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(10) + .connect_lazy("postgres://localhost/buzz") + .expect("construct lazy search pool"); + let (recorder, handle) = super::readiness_test_recorder(); + + metrics::with_local_recorder(&recorder, || { + super::record_db_pool_metrics(super::DbPoolMetricsInput { + writer: buzz_db::DbPoolStats { + size: 1, + idle: 1, + max: 50, + }, + reader: None, + audit: Some(buzz_db::DbPoolStats::from_pool(&audit_pool)), + search: buzz_db::DbPoolStats::from_pool(&search_pool), + }); + }); + + let scrape = handle.render(); + assert_eq!( + sample_value( + &scrape, + "buzz_db_pool_max_connections{", + &[("pool_role", "audit")] + ), + "5" + ); + assert_eq!( + sample_value( + &scrape, + "buzz_db_pool_max_connections{", + &[("pool_role", "search")] + ), + "10" + ); + } + + #[test] + 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, @@ -372,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() @@ -405,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{") }) @@ -420,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"])); @@ -430,6 +961,135 @@ 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] + fn production_builder_exports_bounded_auth_contract_and_stable_zeros() { + let (recorder, handle) = super::readiness_test_recorder(); + metrics::with_local_recorder(&recorder, || { + super::describe_auth_metrics(); + super::initialize_auth_metric_series(); + }); + + let stable_zero_scrape = handle.render(); + assert!(stable_zero_scrape.contains("buzz_auth_attempts_total{method=\"nip42\"} 0")); + assert!(stable_zero_scrape.contains("buzz_ws_authenticated_connections_active 0")); + let zero_outcomes = stable_zero_scrape + .lines() + .filter(|line| line.starts_with("buzz_auth_outcomes_total{") && line.ends_with(" 0")) + .collect::>(); + assert_eq!( + zero_outcomes.len(), + super::AuthOutcome::ALL.len(), + "every bounded outcome must exist before the first attempt:\n{stable_zero_scrape}" + ); + let zero_duration_counts = stable_zero_scrape + .lines() + .filter(|line| { + line.starts_with("buzz_auth_duration_seconds_count{") && line.ends_with(" 0") + }) + .count(); + let zero_duration_sums = stable_zero_scrape + .lines() + .filter(|line| { + line.starts_with("buzz_auth_duration_seconds_sum{") && line.ends_with(" 0") + }) + .count(); + let zero_duration_buckets = stable_zero_scrape + .lines() + .filter(|line| { + line.starts_with("buzz_auth_duration_seconds_bucket{") && line.ends_with(" 0") + }) + .count(); + assert_eq!(zero_duration_counts, super::AuthOutcome::ALL.len()); + assert_eq!(zero_duration_sums, super::AuthOutcome::ALL.len()); + assert_eq!( + zero_duration_buckets, + super::AuthOutcome::ALL.len() * 11, + "every fixed outcome must export all zero buckets before traffic:\n{stable_zero_scrape}" + ); + + metrics::with_local_recorder(&recorder, || { + super::record_auth_attempt_started(); + for outcome in super::AuthOutcome::ALL { + super::record_auth_outcome(outcome, Duration::from_millis(20)); + } + for state in super::AuthPostTerminalState::ALL { + super::record_post_terminal_auth_frame(state); + } + let active = metrics::gauge!("buzz_ws_authenticated_connections_active"); + active.increment(1.0); + active.decrement(1.0); + }); + + let scrape = handle.render(); + assert!(scrape.contains("# TYPE buzz_auth_attempts_total counter")); + assert!(scrape.contains("# TYPE buzz_auth_outcomes_total counter")); + assert!(scrape.contains("# TYPE buzz_auth_duration_seconds histogram")); + assert!(scrape.contains("# TYPE buzz_ws_authenticated_connections_active gauge")); + + let raw_series = scrape + .lines() + .filter(|line| { + line.starts_with("buzz_auth_attempts_total{") + || line.starts_with("buzz_auth_outcomes_total{") + || line.starts_with("buzz_auth_duration_seconds") + || line.starts_with("buzz_auth_post_terminal_frames_total{") + || line.starts_with("buzz_ws_authenticated_connections_active ") + }) + .collect::>(); + // 1 challenge + 11 outcomes + 2 post-terminal states + 1 active gauge +, for each outcome, + // 11 histogram buckets (including +Inf), sum, and count. + assert_eq!(raw_series.len(), 158, "unexpected raw scrape:\n{scrape}"); + + for line in raw_series { + let keys = label_keys(line); + if line.starts_with("buzz_auth_attempts_total{") { + assert_eq!(keys, BTreeSet::from(["method"])); + } else if line.starts_with("buzz_auth_outcomes_total{") { + assert_eq!(keys, BTreeSet::from(["method", "outcome"])); + } else if line.starts_with("buzz_auth_post_terminal_frames_total{") { + assert_eq!(keys, BTreeSet::from(["state"])); + } else if line.starts_with("buzz_auth_duration_seconds_bucket{") { + assert_eq!(keys, BTreeSet::from(["le", "method", "outcome"])); + } else if line.starts_with("buzz_auth_duration_seconds") { + assert_eq!(keys, BTreeSet::from(["method", "outcome"])); + } else { + assert!(keys.is_empty(), "active gauge must not add labels: {line}"); + } + assert!(!line.contains("pubkey=")); + assert!(!line.contains("community=")); + assert!(!line.contains("connection=")); + assert!(!line.contains("pod=")); + assert!(!line.contains("version=")); + assert!(!line.contains("error=")); + } } } diff --git a/crates/buzz-relay/src/rejection.rs b/crates/buzz-relay/src/rejection.rs index 96b8074e55..733c427f95 100644 --- a/crates/buzz-relay/src/rejection.rs +++ b/crates/buzz-relay/src/rejection.rs @@ -57,8 +57,7 @@ pub(crate) async fn enforce_ws_admission( } let (pubkey, is_agent) = { - let auth = conn.auth_state.read().await; - match &*auth { + match conn.auth_state_snapshot() { AuthState::Authenticated(ctx) => (ctx.pubkey, ctx.agent_owner_pubkey.is_some()), _ => return true, } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 95372d5bc3..98864df49e 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1417,7 +1417,7 @@ pub(crate) mod tests { use super::*; use crate::connection::{AuthState, ConnectionState}; use std::collections::HashMap; - use tokio::sync::{Mutex, RwLock}; + use tokio::sync::Mutex; /// Helper: create a ConnectionManager with one registered connection. /// Returns (manager, conn_id, receiver, ctrl_receiver, cancel, @@ -1460,6 +1460,37 @@ pub(crate) mod tests { config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + build_test_state(config, pool).await + } + + /// The same test state with an explicit database target. This lets handler + /// tests deterministically exercise fail-closed database seams without + /// depending on whether a developer has the normal test database running. + pub(crate) async fn test_state_with_database_url(database_url: &str) -> 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.database_url = database_url.to_owned(); + config.read_database_url = None; + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_millis(100)) + .connect_lazy(&config.database_url) + .expect("lazy pg pool"); + build_test_state(config, pool).await + } + + /// Build test state around a caller-owned writer pool. Production-path + /// lifecycle tests use this to hold the sole connection as a deterministic + /// barrier while AUTH waits in the real database acquisition path. + pub(crate) async fn test_state_with_database_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.read_database_url = None; + build_test_state(config, pool).await + } + + async fn build_test_state(config: crate::config::Config, pool: sqlx::PgPool) -> Arc { 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)) @@ -1688,7 +1719,7 @@ pub(crate) mod tests { "test.local".to_string(), ), remote_addr: "127.0.0.1:1234".parse().unwrap(), - auth_state: RwLock::new(AuthState::Failed), + auth_state: std::sync::Mutex::new(AuthState::Failed), subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx: tx.clone(), ctrl_tx, 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/crates/buzz-relay/tests/boot_lifecycle.rs b/crates/buzz-relay/tests/boot_lifecycle.rs index 29fcf991f8..17762bbdea 100644 --- a/crates/buzz-relay/tests/boot_lifecycle.rs +++ b/crates/buzz-relay/tests/boot_lifecycle.rs @@ -196,6 +196,50 @@ fn assert_no_startup_lifecycle_metrics(scrape: &str) { } } +fn assert_auth_metrics_have_stable_boot_zeros(scrape: &str) { + const OUTCOMES: [&str; 11] = [ + "success", + "invalid", + "banned", + "ban_check_error", + "allowlist_check_error", + "allowlist_denied", + "relay_membership_check_error", + "not_relay_member", + "timeout", + "disconnect", + "shutdown", + ]; + + assert!(scrape.contains("# TYPE buzz_auth_attempts_total counter")); + assert!(scrape.contains("buzz_auth_attempts_total{method=\"nip42\"} 0")); + assert!(scrape.contains("# TYPE buzz_auth_outcomes_total counter")); + assert!(scrape.contains("# TYPE buzz_auth_duration_seconds histogram")); + assert!(scrape.contains("# TYPE buzz_auth_post_terminal_frames_total counter")); + assert!(scrape.contains("buzz_auth_post_terminal_frames_total{state=\"authenticated\"} 0")); + assert!(scrape.contains("buzz_auth_post_terminal_frames_total{state=\"failed\"} 0")); + assert!(scrape.contains("buzz_ws_authenticated_connections_active 0")); + + for outcome in OUTCOMES { + assert!(scrape.contains(&format!( + "buzz_auth_outcomes_total{{method=\"nip42\",outcome=\"{outcome}\"}} 0" + ))); + assert!(scrape.contains(&format!( + "buzz_auth_duration_seconds_count{{method=\"nip42\",outcome=\"{outcome}\"}} 0" + ))); + assert!(scrape.contains(&format!( + "buzz_auth_duration_seconds_sum{{method=\"nip42\",outcome=\"{outcome}\"}} 0" + ))); + assert!(scrape.lines().any(|line| { + line.starts_with("buzz_auth_duration_seconds_bucket{") + && line.contains("le=\"+Inf\"") + && line.contains("method=\"nip42\"") + && line.contains(&format!("outcome=\"{outcome}\"")) + && line.ends_with(" 0") + })); + } +} + fn lifecycle_events(output: &Output) -> Vec { let mut events: Vec = output .stdout @@ -444,6 +488,7 @@ fn successful_main_emits_complete_lifecycle_without_startup_metrics() { ]); let scrape = wait_for_relay_metrics(&mut process, port); assert_no_startup_lifecycle_metrics(&scrape); + assert_auth_metrics_have_stable_boot_zeros(&scrape); let output = process.terminate(); let events = lifecycle_events(&output); diff --git a/deploy/charts/buzz-push-gateway/Chart.yaml b/deploy/charts/buzz-push-gateway/Chart.yaml index fe302e58c2..c466034ce1 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.2.0 +version: 0.3.0 appVersion: "0.1.0" type: application diff --git a/deploy/charts/buzz-push-gateway/ci/ci-values.yaml b/deploy/charts/buzz-push-gateway/ci/ci-values.yaml new file mode 100644 index 0000000000..f429ceef93 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/ci/ci-values.yaml @@ -0,0 +1,2 @@ +# Chart-testing input only. Real deployments must inject their own origin. +gatewayOrigin: https://push.example diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index ecdc97582a..faefb1e917 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -32,7 +32,7 @@ spec: env: - { name: BUZZ_PUSH_BIND_ADDR, value: "0.0.0.0:8080" } - { name: BUZZ_PUSH_HEALTH_ADDR, value: "0.0.0.0:8081" } - - { name: BUZZ_PUSH_PUBLIC_DELIVERY_URL, value: {{ .Values.publicDeliveryUrl | quote }} } + - { name: BUZZ_PUSH_GATEWAY_ORIGIN, value: {{ required "gatewayOrigin is required" .Values.gatewayOrigin | quote }} } - { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | quote }} } - { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem } - { name: BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID, value: {{ .Values.profiles.dogfood.appAttestAppId | quote }} } diff --git a/deploy/charts/buzz-push-gateway/templates/httproute.yaml b/deploy/charts/buzz-push-gateway/templates/httproute.yaml index 88f4d89dbc..f94afe174d 100644 --- a/deploy/charts/buzz-push-gateway/templates/httproute.yaml +++ b/deploy/charts/buzz-push-gateway/templates/httproute.yaml @@ -1,11 +1,13 @@ {{- if .Values.httpRoute.enabled }} +{{- $gatewayURL := urlParse (required "gatewayOrigin is required" .Values.gatewayOrigin) }} apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: {{ include "push.name" . }} spec: parentRefs: {{- toYaml .Values.httpRoute.parentRefs | nindent 4 }} - hostnames: {{- toYaml .Values.httpRoute.hostnames | nindent 4 }} + hostnames: + - {{ required "gatewayOrigin must include a hostname" $gatewayURL.host | quote }} rules: - matches: - path: { type: PathPrefix, value: / } diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index 97568ba2d0..675fea6fde 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -2,15 +2,17 @@ set -euo pipefail out=$(mktemp); production_out=$(mktemp); route_out=$(mktemp); datadog_out=$(mktemp) trap 'rm -f "$out" "$production_out" "$route_out" "$datadog_out" "${monitoring_out:-}"' EXIT +gateway_origin_arg=(--set 'gatewayOrigin=https://push.example') -# Defaults must lint and render without parameter injection. -helm lint deploy/charts/buzz-push-gateway >/dev/null -helm template push deploy/charts/buzz-push-gateway >"$out" +# Generic values require the deployment-owned gateway origin. +helm lint deploy/charts/buzz-push-gateway "${gateway_origin_arg[@]}" >/dev/null +helm template push deploy/charts/buzz-push-gateway "${gateway_origin_arg[@]}" >"$out" # Production values support a platform-owned ingress without rendering an # HTTPRoute. The environment-owned inputs remain mandatory. production_args=( -f deploy/charts/buzz-push-gateway/values-production.yaml --set 'image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + --set 'gatewayOrigin=https://push.example' --set 'profiles.dogfood.appAttestAppId=REALTEAM.xyz.block.buzz.dogfood.mobile' --set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16' ) @@ -20,6 +22,7 @@ helm template push deploy/charts/buzz-push-gateway "${production_args[@]}" >"$pr # Gateway API remains an explicit supported ingress mode when an operator opts # in and supplies the environment-owned parent. helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --set httpRoute.enabled=true \ --set 'httpRoute.parentRefs[0].name=production-gateway' \ --set 'httpRoute.parentRefs[0].namespace=gateway-system' \ @@ -63,8 +66,12 @@ required = Set.new(%w[ DATABASE_URL BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH BUZZ_PUSH_DOGFOOD_APNS_TOPIC BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID BUZZ_PUSH_GRANT_KEYS BUZZ_PUSH_TOKEN_KEYS BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS + BUZZ_PUSH_GATEWAY_ORIGIN ]) assert!(required.subset?(env_names)) +gateway_origin = d.dig("spec", "template", "spec", "containers", 0, "env") + .find { |entry| entry["name"] == "BUZZ_PUSH_GATEWAY_ORIGIN" } +assert!(gateway_origin["value"] == "https://push.example") assert!(!env_names.any? { |name| name.include?("APP_STORE") }) apns_volume = d.dig("spec", "template", "spec", "volumes").find { |volume| volume["name"] == "apns-dogfood" } assert!(apns_volume.dig("secret", "defaultMode") == 0o400, apns_volume.inspect) @@ -100,12 +107,26 @@ production_image = production_deployment.dig("spec", "template", "spec", "contai assert!(production_image == "ghcr.io/block/buzz-push-gateway@sha256:#{"a" * 64}", production_image.inspect) route = YAML.load_stream(File.read(ARGV[2])).compact.find { |x| x["kind"] == "HTTPRoute" } assert!(!route.dig("spec", "parentRefs").empty?) -assert!(route.dig("spec", "hostnames").include?("push.buzz.xyz")) +assert!(route.dig("spec", "hostnames") == ["push.example"]) RUBY +# A gateway origin is a required deployment input, even when HTTPRoute is off. +if helm template push deploy/charts/buzz-push-gateway >/dev/null 2>&1; then + echo 'expected missing gatewayOrigin to fail' >&2 + exit 1 +fi +for invalid_origin in 'http://push.example' 'https://push.example:8443' 'https://push.example/base'; do + if helm template push deploy/charts/buzz-push-gateway \ + --set "gatewayOrigin=$invalid_origin" >/dev/null 2>&1; then + echo "expected malformed gatewayOrigin $invalid_origin to fail" >&2 + exit 1 + fi +done + # Legacy token-auth values must fail rather than silently selecting the default # certificate Secret. if helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --set apnsKey.secretName=legacy-apns-secret \ --set apnsKey.secretKey=legacy-provider.p8 >/dev/null 2>&1; then echo 'expected legacy apnsKey values to fail schema validation' >&2 @@ -113,7 +134,7 @@ if helm template push deploy/charts/buzz-push-gateway \ fi # Enabling a route without a Gateway attachment must fail schema validation. -if helm template push deploy/charts/buzz-push-gateway --set httpRoute.enabled=true >/dev/null 2>&1; then +if helm template push deploy/charts/buzz-push-gateway "${gateway_origin_arg[@]}" --set httpRoute.enabled=true >/dev/null 2>&1; then echo 'expected httpRoute.enabled=true without parentRefs to fail' >&2 exit 1 fi @@ -129,6 +150,7 @@ fi # keyed to the named monitoring source — never a blanket 8081 rule. monitoring_out=$(mktemp) helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --set podMonitor.enabled=true \ --set prometheusRule.enabled=true \ --set networkPolicy.monitoring.enabled=true \ @@ -162,9 +184,11 @@ RUBY # Datadog discovers the same private endpoint from pod annotations and needs no # prometheus-operator CRDs. Its agent ingress remains selector-scoped. helm lint deploy/charts/buzz-push-gateway \ - -f deploy/charts/buzz-push-gateway/tests/datadog-values.yaml >/dev/null + -f deploy/charts/buzz-push-gateway/tests/datadog-values.yaml \ + "${gateway_origin_arg[@]}" >/dev/null helm template push deploy/charts/buzz-push-gateway \ -f deploy/charts/buzz-push-gateway/tests/datadog-values.yaml \ + "${gateway_origin_arg[@]}" \ >"$datadog_out" env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -rjson -ryaml -rset \ @@ -200,6 +224,7 @@ RUBY # Negative: monitoring enabled with default empty selectors must fail (would # otherwise render a blanket 8081 rule matching all namespaces/pods). if helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --set podMonitor.enabled=true \ --set networkPolicy.monitoring.enabled=true >/dev/null 2>&1; then echo 'expected monitoring.enabled with empty selectors to fail' >&2 @@ -209,6 +234,7 @@ fi # Negative: PodMonitor without ingress is an unreachable scraper and must fail. # Scoped ingress without PodMonitor is valid for annotation-discovered agents. if helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --set podMonitor.enabled=true \ --set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \ --set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \ @@ -219,6 +245,7 @@ fi # Negative: retry-ratio threshold is a fraction; a value > 1 must fail schema. if helm template push deploy/charts/buzz-push-gateway \ + "${gateway_origin_arg[@]}" \ --set prometheusRule.enabled=true \ --set prometheusRule.apnsRetryRatioThreshold=2 >/dev/null 2>&1; then echo 'expected apnsRetryRatioThreshold=2 to fail' >&2 diff --git a/deploy/charts/buzz-push-gateway/values-production.yaml b/deploy/charts/buzz-push-gateway/values-production.yaml index 85dd8af1a8..3882e935e6 100644 --- a/deploy/charts/buzz-push-gateway/values-production.yaml +++ b/deploy/charts/buzz-push-gateway/values-production.yaml @@ -3,16 +3,15 @@ image: tag: "" digest: "" +gatewayOrigin: "" profiles: dogfood: appAttestAppId: "" httpRoute: - # Keep disabled when the platform already routes push.buzz.xyz to this - # Service. Gateway API users enable it and inject an explicit parentRef. + # Keep disabled when the platform already routes the configured gateway + # origin to this Service. Gateway API users enable it and inject a parentRef. enabled: false parentRefs: [] - hostnames: - - push.buzz.xyz networkPolicy: apnsEgressCidrs: - 0.0.0.0/0 diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json index 1339b777e5..5f83965368 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -11,8 +11,10 @@ "type": "string", "minLength": 1 }, - "publicDeliveryUrl": { - "const": "https://push.buzz.xyz/v1/deliveries/apns" + "gatewayOrigin": { + "type": "string", + "format": "uri", + "pattern": "^https://[^/?#@:]+/?$" }, "maxGrantLifetimeSeconds": { "type": "integer", @@ -40,10 +42,10 @@ }, "httpRoute": { "type": "object", + "additionalProperties": false, "required": [ "enabled", - "parentRefs", - "hostnames" + "parentRefs" ], "properties": { "enabled": { @@ -51,12 +53,6 @@ }, "parentRefs": { "type": "array" - }, - "hostnames": { - "type": "array", - "contains": { - "const": "push.buzz.xyz" - } } }, "allOf": [ @@ -310,7 +306,7 @@ "required": [ "replicaCount", "existingSecret", - "publicDeliveryUrl", + "gatewayOrigin", "maxGrantLifetimeSeconds", "profiles", "httpRoute", diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index 245d1a682e..904cfbe788 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -18,7 +18,9 @@ migration: resources: requests: {cpu: 50m, memory: 64Mi} limits: {cpu: 250m, memory: 128Mi} -publicDeliveryUrl: https://push.buzz.xyz/v1/deliveries/apns +# Exact externally reachable HTTPS origin. The gateway derives transport routes +# from this value; NIP-PL v1 App Attest audiences remain registered constants. +gatewayOrigin: "" maxGrantLifetimeSeconds: 2592000 profiles: dogfood: @@ -41,7 +43,6 @@ httpRoute: # existing ingress or service mesh route should keep this disabled. enabled: false parentRefs: [] - hostnames: [push.buzz.xyz] resources: requests: {cpu: 100m, memory: 128Mi} limits: {cpu: "1", memory: 512Mi} 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/README.md b/deploy/charts/buzz/README.md index 5e77827913..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,82 @@ 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, +capacity, and configuration under role-labelled families. The role vocabulary +is closed — `writer`, `reader`, `audit`, `search` — and all four roles are always +present, so a series never appears or disappears when an optional pool is +enabled or disabled. An unconfigured optional pool reports +`buzz_db_pool_configured` `0` with zero utilization and capacity rather than +going missing. + +| Metric | Type | Labels | +|--------|------|--------| +| `buzz_db_pool_connections` | gauge | `pool_role`, `state` | +| `buzz_db_pool_max_connections` | gauge | `pool_role` | +| `buzz_db_pool_configured` | gauge | `pool_role`; `1` configured, `0` not configured | + +States are `idle` and `active`; their sum is the pool's current size. +`buzz_db_pool_max_connections` reports capacity separately, so summing the +connections family counts each current connection exactly once. The three +families are fixed at 16 raw Prometheus series per pod (`4 × 2` current +utilization plus `4` maximum capacity plus `4` configured). The `audit` and +`search` roles are statistics-only: pool ownership, capacities, timeouts, and +query routing are unchanged, and these roles emit no acquisition telemetry. + +The pre-existing unlabelled `buzz_db_pool_*` (writer) and `buzz_db_read_pool_*` +(reader) gauges are still exported unchanged for dashboard compatibility. The +reader family remains absent when no read replica is configured; use +`buzz_db_pool_configured{pool_role="reader"}` for the explicit signal. + +Pool sizing and the aggregate connection budget across all four roles remain +deployment configuration concerns. The relay does not enforce a combined +connection ceiling. + ## Relay Pod extensions The chart exposes narrow extension points for init containers, volumes, relay 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/deploy/compose/.env.example b/deploy/compose/.env.example index ab410b932f..100ec893c3 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -18,6 +18,10 @@ BUZZ_REQUIRE_RELAY_MEMBERSHIP=true BUZZ_ALLOW_NIP_OA_AUTH=true BUZZ_AUTO_MIGRATE=true BUZZ_GIT_CONFORMANCE_PROBE=true +# Push stays disabled unless explicitly enabled. Keep the delivery endpoint +# explicit so enabling it never depends on an in-code deployment fallback. +BUZZ_PUSH_ENABLED=false +BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns RUST_LOG=buzz_relay=info,buzz_db=info,buzz_auth=info,buzz_pubsub=info,tower_http=info # Owner identity. Set to a 64-character hex Nostr pubkey. diff --git a/deploy/compose/README.md b/deploy/compose/README.md index bb0e63fe15..b12f9ff48c 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -38,6 +38,10 @@ keypair. migrations. - The stack uses Postgres, Redis, MinIO, and a git data volume because those are real Buzz dependencies today. Minimal mode can simplify this later. +- Mobile push remains off by default. To use the public gateway, keep the + template's explicit `BUZZ_PUSH_GATEWAY_DELIVERY_URL` and set + `BUZZ_PUSH_ENABLED=true`. To use another gateway, replace the exact HTTPS + `/v1/deliveries/apns` URL before enabling push. - The bundled Compose stack fixes the relay endpoint to `http://minio:9000` and `BUZZ_S3_ADDRESSING_STYLE=path`: Docker DNS resolves `minio`, not `.minio`. It is not configurable for an external S3 provider through 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 a116c23d07..b9da81177b 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 2484fc7d14..e11c33637f 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -1,6 +1,6 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; -use reqwest::Method; +use reqwest::{Method, RequestBuilder}; use serde::de::DeserializeOwned; use serde::Deserialize; use sha2::{Digest, Sha256}; @@ -409,6 +409,37 @@ pub async fn query_relay_at_with_keys( .await } +/// Build an authenticated relay HTTP request using already-signed NIP-98 auth. +/// +/// The caller owns request ordering around this helper: rate-limit admission, +/// egress checks, URL/body construction, send, error classification, and response +/// parsing remain outside. `body` is accepted as final bytes so this helper never +/// reserializes, normalizes, or changes the payload that was signed. +fn build_authenticated_relay_request( + client: &reqwest::Client, + method: Method, + url: &str, + auth: &str, + body: Option>, + auth_tag: Option<&str>, + timeout: Option, +) -> RequestBuilder { + let mut request = client.request(method, url).header("Authorization", auth); + if body.is_some() { + request = request.header("Content-Type", "application/json"); + } + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } + if let Some(timeout) = timeout { + request = request.timeout(timeout); + } + if let Some(body) = body { + request = request.body(body); + } + request +} + /// Issue an authenticated `POST /query` and parse the response, applying the /// per-request `timeout` that bounds a stalled or half-open relay connection. /// @@ -425,19 +456,18 @@ async fn send_query_request( body_bytes: Vec, timeout: std::time::Duration, ) -> Result, String> { - let mut request = http_client - .post(url) - .header("Authorization", auth) - .header("Content-Type", "application/json") - .timeout(timeout); - if let Some(tag) = auth_tag { - request = request.header("x-auth-tag", tag); - } - let response = request - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; + let response = build_authenticated_relay_request( + http_client, + Method::POST, + url, + auth, + Some(body_bytes), + auth_tag, + Some(timeout), + ) + .send() + .await + .map_err(|e| classify_request_error(&e))?; if !response.status().is_success() { return Err(relay_error_message(response).await); } @@ -505,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. @@ -514,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, @@ -524,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")?; @@ -534,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 { @@ -554,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/get.rs b/desktop/src-tauri/src/relay/get.rs index 7d0855f463..95226d3bfc 100644 --- a/desktop/src-tauri/src/relay/get.rs +++ b/desktop/src-tauri/src/relay/get.rs @@ -4,8 +4,8 @@ use serde::de::DeserializeOwned; use crate::app_state::AppState; use super::{ - build_nip98_auth_header, classify_request_error, parse_json_response, - relay_api_base_url_with_override, relay_error_message, + build_authenticated_relay_request, build_nip98_auth_header, classify_request_error, + parse_json_response, relay_api_base_url_with_override, relay_error_message, }; /// Execute an authenticated GET against the active relay and decode its JSON body. @@ -23,13 +23,18 @@ pub async fn get_relay_json( path_with_query ); let auth = build_nip98_auth_header(&Method::GET, &url, &[], state)?; - let response = state - .http_client - .get(&url) - .header("Authorization", auth) - .send() - .await - .map_err(|error| classify_request_error(&error))?; + let response = build_authenticated_relay_request( + &state.http_client, + Method::GET, + &url, + &auth, + None, + None, + None, + ) + .send() + .await + .map_err(|error| classify_request_error(&error))?; if !response.status().is_success() { return Err(relay_error_message(response).await); } 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-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index b6a5703fd9..a6b79a6cd2 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -28,15 +28,18 @@ pub async fn submit_signed_event_at_with_keys( crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - let response = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; + let response = build_authenticated_relay_request( + &state.http_client, + Method::POST, + &url, + &auth_header, + Some(body_bytes), + None, + None, + ) + .send() + .await + .map_err(|e| classify_request_error(&e))?; if !response.status().is_success() { return Err(relay_error_message(response).await); diff --git a/desktop/src-tauri/src/relay/tests.rs b/desktop/src-tauri/src/relay/tests.rs index f2928cbf61..251845e431 100644 --- a/desktop/src-tauri/src/relay/tests.rs +++ b/desktop/src-tauri/src/relay/tests.rs @@ -2,12 +2,162 @@ //! Extracted from `relay.rs` to keep that module under the file-size ratchet. use super::{ - build_profile_event, classify_intercepted_response, effective_agent_relay_url, - extract_retry_in_hint, parse_command_response, relay_http_base_url, MALFORMED_RESPONSE_MESSAGE, + build_authenticated_relay_request, build_profile_event, classify_intercepted_response, + effective_agent_relay_url, extract_retry_in_hint, parse_command_response, relay_http_base_url, + MALFORMED_RESPONSE_MESSAGE, }; use serde::Deserialize; -// ── extract_retry_in_hint ──────────────────────────────────────────────── +const AUTH_HEADER: reqwest::header::HeaderName = reqwest::header::AUTHORIZATION; +const CONTENT_TYPE_HEADER: reqwest::header::HeaderName = reqwest::header::CONTENT_TYPE; +const X_AUTH_TAG_HEADER: reqwest::header::HeaderName = + reqwest::header::HeaderName::from_static("x-auth-tag"); + +fn assert_header_values( + request: &reqwest::Request, + expected: &[(&reqwest::header::HeaderName, &[&str])], +) { + let headers = request.headers(); + let actual_names: std::collections::BTreeSet = headers + .keys() + .map(|name| name.as_str().to_ascii_lowercase()) + .collect(); + let expected_names: std::collections::BTreeSet = expected + .iter() + .map(|(name, _)| name.as_str().to_ascii_lowercase()) + .collect(); + assert_eq!(actual_names, expected_names, "unexpected header set"); + + for (name, values) in expected { + let actual: Vec<&str> = headers + .get_all(*name) + .iter() + .map(|value| value.to_str().expect("test header must be visible text")) + .collect(); + assert_eq!(actual, *values, "unexpected values for {name}"); + } +} + +fn request_body_bytes(request: &reqwest::Request) -> Option<&[u8]> { + request.body().and_then(reqwest::Body::as_bytes) +} + +#[test] +fn authenticated_get_request_matches_existing_shape() { + let client = reqwest::Client::new(); + let request = build_authenticated_relay_request( + &client, + reqwest::Method::GET, + "https://relay.example/info?verbose=1", + "Nostr signed-get", + None, + None, + None, + ) + .build() + .expect("request should build"); + + assert_eq!(request.method(), reqwest::Method::GET); + assert_eq!( + request.url().as_str(), + "https://relay.example/info?verbose=1" + ); + assert_header_values(&request, &[(&AUTH_HEADER, &["Nostr signed-get"])]); + assert!(request.timeout().is_none()); + assert!(request_body_bytes(&request).is_none()); +} + +#[test] +fn authenticated_json_post_request_preserves_final_body_bytes() { + let client = reqwest::Client::new(); + let body = br#"[{"kinds":[9],"limit":1}]"#.to_vec(); + let request = build_authenticated_relay_request( + &client, + reqwest::Method::POST, + "https://relay.example/query", + "Nostr signed-post", + Some(body.clone()), + None, + None, + ) + .build() + .expect("request should build"); + + assert_eq!(request.method(), reqwest::Method::POST); + assert_eq!(request.url().as_str(), "https://relay.example/query"); + assert_header_values( + &request, + &[ + (&AUTH_HEADER, &["Nostr signed-post"]), + (&CONTENT_TYPE_HEADER, &["application/json"]), + ], + ); + assert!( + request.headers().get("x-auth-tag").is_none(), + "absence of x-auth-tag must remain absence, not an empty header" + ); + assert!(request.timeout().is_none()); + assert_eq!(request_body_bytes(&request), Some(body.as_slice())); +} + +#[test] +fn authenticated_json_post_request_keeps_auth_tag_when_present() { + let client = reqwest::Client::new(); + let body = b"{}".to_vec(); + let request = build_authenticated_relay_request( + &client, + reqwest::Method::POST, + "https://relay.example/event-submit", + "Nostr signed-event", + Some(body.clone()), + Some("owner-auth-tag"), + None, + ) + .build() + .expect("request should build"); + + assert_eq!(request.method(), reqwest::Method::POST); + assert_eq!(request.url().as_str(), "https://relay.example/event-submit"); + assert_header_values( + &request, + &[ + (&AUTH_HEADER, &["Nostr signed-event"]), + (&CONTENT_TYPE_HEADER, &["application/json"]), + (&X_AUTH_TAG_HEADER, &["owner-auth-tag"]), + ], + ); + assert!(request.timeout().is_none()); + assert_eq!(request_body_bytes(&request), Some(body.as_slice())); +} + +#[test] +fn authenticated_query_request_preserves_timeout() { + let client = reqwest::Client::new(); + let timeout = std::time::Duration::from_millis(250); + let request = build_authenticated_relay_request( + &client, + reqwest::Method::POST, + "https://relay.example/query", + "Nostr signed-query", + Some(b"[]".to_vec()), + None, + Some(timeout), + ) + .build() + .expect("request should build"); + + assert_eq!(request.method(), reqwest::Method::POST); + assert_eq!(request.url().as_str(), "https://relay.example/query"); + assert_header_values( + &request, + &[ + (&AUTH_HEADER, &["Nostr signed-query"]), + (&CONTENT_TYPE_HEADER, &["application/json"]), + ], + ); + assert_eq!(request.timeout(), Some(&timeout)); + assert_eq!(request_body_bytes(&request), Some(&b"[]"[..])); +} #[test] fn extracts_hint_from_429_body() { diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index ffcf34acfd..9f9cd48848 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -424,6 +424,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 3c767af3a9..37fb8d2c58 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 4ad87dce0b..bc3e487daf 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -14111,6 +14111,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( diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 6575c98bfb..c45f900e82 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -279,7 +279,7 @@ The opaque string returned as `endpoint_grant` by `POST /v1/delegations` is the All routes below accept only `POST`. Clients MUST send `Content-Type: application/json`; bodies are UTF-8 JSON and MUST be at most 8192 bytes, except `POST /v1/installations`, whose body MUST be at most 23896 bytes. That installation-only ceiling is derived from the maximum permitted base64-encoded 16384-byte App Attest object, the maximum 512-byte APNs endpoint encoded as hex, and 1024 bytes for the remaining closed envelope. A body over its applicable limit is rejected with HTTP `413` before JSON parsing. Every request object is closed: unknown members, duplicate members at any depth, missing or incorrectly typed members, trailing non-whitespace data, or a `v` other than integer `1` are `400 {"error":"invalid_request"}`. Integers are signed JSON integers in the ranges stated below. Unix times are integer seconds. UUIDs use the canonical lowercase hyphenated representation. Relay pubkeys are exactly 64 lowercase hexadecimal characters. APNs endpoints are non-empty, even-length lowercase hexadecimal strings encoding at most 512 bytes. Challenges are exactly 32 bytes encoded as unpadded URL-safe base64. `key_id`, `attestation`, and `assertion` use padded or unpadded standard base64 as accepted by Apple's App Attest API; decoded key ids are exactly 32 bytes, attestations are 1..16384 bytes, and assertions are 1..1024 bytes. An `endpoint_grant`, including its key-id prefix, MUST be at most 4096 bytes. -Handler responses are UTF-8 `application/json`. Closed error bodies are `{"error":"invalid_request"}`, `{"error":"invalid_attestation"}`, `{"error":"not_authorized"}`, `{"error":"invalid_auth"}`, `{"error":"invalid_grant"}`, `{"error":"rate_limited"}`, `{"error":"temporarily_unavailable"}`, `{"error":"configuration_fault"}`, or `{"error":"not_ready"}`. Authority, custody, and quota rejection MUST NOT reveal whether an installation, delegation, or endpoint exists. Delivery grant, authority, and replay failures collapse to `404 invalid_grant`; endpoint quota exhaustion uses `429 rate_limited`; storage failures use `503 temporarily_unavailable`. +Handler responses are UTF-8 `application/json`. Closed error bodies are `{"error":"invalid_request"}`, `{"error":"invalid_attestation"}`, `{"error":"not_authorized"}`, `{"error":"invalid_auth"}`, `{"error":"invalid_grant"}`, `{"error":"installation_conflict"}`, `{"error":"rate_limited"}`, `{"error":"temporarily_unavailable"}`, `{"error":"configuration_fault"}`, or `{"error":"not_ready"}`. Authority, custody, and quota rejection MUST NOT reveal whether an installation, delegation, or endpoint exists. Delivery grant, authority, and replay failures collapse to `404 invalid_grant`; endpoint quota exhaustion uses `429 rate_limited`; storage failures use `503 temporarily_unavailable`. ### Exact App Attest transcript construction @@ -329,7 +329,7 @@ The gateway verifies Apple's attestation chain, configured application identifie The client MUST durably journal the exact attested enrollment request before its first send and retain it until delegation state is durable. If that exact request is replayed after the installation commit, the gateway MUST return the same success response after re-verifying the attestation, even though the challenge was already consumed. Idempotency requires exact equality of attested key, profile, endpoint fingerprint, epoch, and expiration, and the recovered public key MUST equal the committed key; any mismatch remains indistinguishable from other authority rejection. This recovery rule grants no authority beyond replaying the already authenticated request. -Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge or a key/token owned by a live installation is `404 not_authorized`. A fresh verified enrollment may replace expired or revoked ownership so an app that missed its renewal window can recover. +Invalid attestation is `401 invalid_attestation`; a consumed or expired challenge is `404 not_authorized`. After successful attestation verification, a key or token owned by a different live installation is `409 installation_conflict`; the conflicting request does not replace existing live authority. A fresh verified enrollment may replace expired or revoked ownership so an app that missed its renewal window can recover. ### Relay delegation and capability issuance diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index c4151a677c..d3aebe1f7d 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -1,10 +1,10 @@ # Buzz Push Gateway deployment -`buzz-push-gateway` is the standalone public APNs last hop intended for `push.buzz.xyz`. Build it with `Dockerfile.push-gateway`; do not run it in the relay image or give relays APNs credentials. +`buzz-push-gateway` is a standalone APNs last hop. Build it with `Dockerfile.push-gateway`; do not run it in the relay image or give relays APNs credentials. ## Network and health -- Public listener: `BUZZ_PUSH_BIND_ADDR` (default `0.0.0.0:8080`). Route `https://push.buzz.xyz` to this port. +- Public listener: `BUZZ_PUSH_BIND_ADDR` (default `0.0.0.0:8080`). Route the configured `BUZZ_PUSH_GATEWAY_ORIGIN` to this port. - Private health listener: `BUZZ_PUSH_HEALTH_ADDR` (default `0.0.0.0:8081`). Probe `/_liveness` and `/_readiness`; do not expose this port publicly. The chart has no pod-ingress allowance for 8081; Kubernetes node/kubelet-origin probe traffic is exempt from NetworkPolicy. Add a narrowly selected monitoring source only if the target CNI requires pod-origin health scraping. - Readiness fails when PostgreSQL authority is unavailable. Graceful shutdown stops accepting new requests before draining in-flight APNs calls. @@ -13,7 +13,7 @@ | Variable | Purpose | |---|---| | `DATABASE_URL` | PostgreSQL authority/admission store. Runtime credentials need DML on the six gateway tables, not DDL. | -| `BUZZ_PUSH_PUBLIC_DELIVERY_URL` | Exact externally signed URL, normally `https://push.buzz.xyz/v1/deliveries/apns`. | +| `BUZZ_PUSH_GATEWAY_ORIGIN` | Exact externally reachable HTTPS origin. No credentials, port, path, query, or fragment. The gateway derives its transport routes from it; NIP-PL v1 App Attest audiences remain the registered `https://push.buzz.xyz/v1/...` constants. | | `BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS` | Maximum delegation capability lifetime (`1..=31536000`). | | `BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS` | Maximum encrypted-token installation lifetime (default 90 days, max one year). Clients must renew before expiry. | | `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. | @@ -24,7 +24,7 @@ | `BUZZ_PUSH_GRANT_KEYS` | Capability AEAD keyring, `id:base64-32-bytes[,predecessor...]`; current key first. | | `BUZZ_PUSH_TOKEN_KEYS` | Independent token-custody AEAD keyring in the same format. Never reuse grant keys. | -The canonical `push.buzz.xyz` MVP serves the dogfood application identity +The current MVP serves the dogfood application identity (`xyz.block.buzz.dogfood.mobile`). App Attest must cryptographically validate the configured application ID before enrollment. Assertions and delivery use the server-owned APNs topic, certificate-backed connection pool, and @@ -53,6 +53,25 @@ The gateway stores APNs tokens encrypted in PostgreSQL. Database backups therefo ## PostgreSQL and replicas +### First deployment, not a legacy upgrade + +This gateway has never been deployed outside personal development infrastructure. +This release supports a fresh dedicated database, not migration of existing +development authority. Before running any schema migrations, `--migrate-only` +refuses a non-empty database that has not completed gateway initialization +(migration 0005). The error names the populated table and tells the operator to +stop the development gateway and provision a fresh database. It does not delete +or migrate that data. Normal deployments of an already initialized gateway may +retain their data. + +Do not run a rolling upgrade from a pre-launch development binary. Stop that +deployment before initialization, use the fresh database, and do not roll back +to a pre-launch binary against the new database. The rolling strategy is for +compatible versions after initial deployment; no legacy writer compatibility or +mobile configured-to-unconfigured migration is supported. Push has no existing +enabled users. After enabling push, retain gateway configuration in updates to +that app identity; omitting it is not a push shutdown mechanism. + The gateway's dedicated pool does not consume the relay-oriented `BUZZ_DB_LOCK_TIMEOUT_MS`, `BUZZ_DB_IDLE_TXN_TIMEOUT_MS`, or `BUZZ_DB_STATEMENT_TIMEOUT_MS` settings. Its session-timeout policy remains separate from the `buzz-db` writer policy and must be designed and rolled out independently. All replicas must share one PostgreSQL database. Delivery authority, replay admission, and endpoint quota reservation are transactional there, so replica count does not multiply the abuse ceiling. The gateway owns a scoped migration history under `crates/buzz-push-gateway/migrations`; it creates only the six `push_gateway_*` authority tables plus SQLx's migration-history table and never runs relay migrations. @@ -122,10 +141,9 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe Relay push is an explicit deployment opt-in through `BUZZ_PUSH_ENABLED=true`; the established strict boolean parser rejects unknown values and the default is -false. When enabled, an absent `BUZZ_PUSH_GATEWAY_DELIVERY_URL` selects the exact -canonical URL `https://push.buzz.xyz/v1/deliveries/apns`; operators can provide -another exact HTTPS `/v1/deliveries/apns` URL as an advanced override. An -explicitly empty URL while enabled is a startup error. Only an enabled relay +false. When enabled, `BUZZ_PUSH_GATEWAY_DELIVERY_URL` is required and must be an +exact HTTPS `/v1/deliveries/apns` URL. An absent or explicitly empty URL while +enabled is a startup error. Only an enabled relay advertises its host-scoped NIP-PL descriptor, accepts leases, and starts the matcher and delivery worker. Relays retain lease matching, authorization, durable jobs/retries, and generation checks; they receive only opaque capabilities and @@ -157,7 +175,7 @@ capability—not a raw APNs token—into the encrypted relay lease. ## Internal dogfood evaluation and rollback -The MVP is ready to enable only when the canonical gateway's sole dogfood +The MVP is ready to enable only when the configured gateway's sole dogfood profile is configured with its server-owned App Attest app ID, APNs topic, production certificate identity, and production APNs environment, and only the selected internal relay deployments set `BUZZ_PUSH_ENABLED=true`. Every iOS @@ -178,7 +196,7 @@ publish the next immutable `mobile-vX.Y.Z-rc.N` candidate from the exact current and wait for the signed `xyz.block.buzz.dogfood.mobile` artifact to appear in Mobile Releases/Comp Portal before installing it on a physical device. Verify APNs delivery, fetched and signature-verified notification content, and -exact-message tap routing against the canonical gateway and a push-enabled +exact-message tap routing against the configured gateway and a push-enabled internal relay before widening the internal evaluation. Before that first candidate, the private dogfood builder's manual signing and @@ -229,7 +247,7 @@ the environment's GitOps values; the chart then renders `ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. `values-production.yaml` remains an intentionally invalid production-input contract: deployment CI must inject the verified image digest, the provisioned -dogfood Apple application identifier, and the actual PostgreSQL network. In an +dogfood Apple application identifier, `gatewayOrigin`, and the actual PostgreSQL network. In an environment with an existing ingress or service mesh route, keep `httpRoute.enabled=false`. If this chart owns a Gateway API route, enable it and inject an environment-owned `parentRef`; schema validation rejects an enabled diff --git a/migrations/0045_retain_push_revocation_tombstones.sql b/migrations/0045_retain_push_revocation_tombstones.sql new file mode 100644 index 0000000000..0f480f574b --- /dev/null +++ b/migrations/0045_retain_push_revocation_tombstones.sql @@ -0,0 +1,11 @@ +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_attest_key_id_key; +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_profile_token_fingerprint_key; + +CREATE UNIQUE INDEX push_gateway_installations_active_app_attest_key + ON push_gateway_installations (app_attest_key_id) + WHERE revoked_at IS NULL; +CREATE UNIQUE INDEX push_gateway_installations_active_profile_token + ON push_gateway_installations (app_profile, token_fingerprint) + WHERE revoked_at IS NULL; 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/mobile/README.md b/mobile/README.md index c108dcece2..e58a6387aa 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -25,7 +25,7 @@ engine all come from the same Flutter version. just mobile-dev # Direct (uses the app's configured community; apply worktree overrides first): -cd mobile && flutter run +cd mobile && flutter run --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example ``` ### Worktree-aware debug identity @@ -70,13 +70,18 @@ For direct Xcode / Android Studio / `flutter run` development, run switch to refresh the display label (the install identity never changes); the persisted files are then picked up by any subsequent build. In the main checkout the script is a no-op that removes stale override files, restoring -the plain `Buzz` identity. +the plain `Buzz` identity. To enable push in direct Xcode builds and Runner tests, supply a +`BUZZ_PUSH_GATEWAY_URL` build setting in the gitignored +`mobile/ios/Flutter/AppOverrides.xcconfig`; the build phase validates and +passes it through as a Flutter Dart define. Since `//` begins an xcconfig +comment, spell the origin as `BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example`. For an Android debug build that must remain installed alongside other Buzz worktree builds, set an explicit launcher name and package suffix when invoking the generator or a recipe that invokes it: ```bash +BUZZ_PUSH_GATEWAY_URL="https://push.example" \ BUZZ_ANDROID_DEBUG_APP_NAME="Buzz Huddles" \ BUZZ_ANDROID_DEBUG_ID_SUFFIX=".huddles_829c" \ ./bin/just mobile-build-android @@ -104,6 +109,25 @@ enrollment, or lease publication, so a later user opt-in can display pushes without rebuilding transport authority. An absent, malformed, or unreachable descriptor leaves push inactive without partial enrollment. +Mobile builds without a gateway origin succeed with push unavailable. To enable +push, supply the gateway origin explicitly: + +```bash +flutter build ios --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example +flutter build apk --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example +``` + +The iOS and Android build gates validate any supplied define, rejecting empty or +malformed values. Release/profile builds require an HTTPS origin without an +explicit port. An absent define disables permission requests, APNs registration, +gateway enrollment, and lease publication; Settings shows push as unavailable. +No production gateway is selected implicitly. Enrollment +grants and crash-recovery journals are scoped to this origin. Push has not +shipped to existing users, so there is no legacy-state or cross-gateway +migration. Changing gateways requires fresh enrollment; old installations +expire under their original gateway's lease policy. Current-gateway response +loss is still retried from the exact journaled request. + Relay rollout remains an explicit deployment opt-in. Only deployments with `BUZZ_PUSH_ENABLED=true` advertise the descriptor and process push. See `docs/push-gateway-deployment.md` for the canonical gateway profile contract, @@ -117,6 +141,7 @@ BUNDLE_IDENTIFIER = xyz.block.buzz.mobile BUZZ_DEVELOPMENT_TEAM = EYF346PHUG BUZZ_IOS_PUSH_ENVIRONMENT = development BUZZ_APP_ATTEST_ENVIRONMENT = development +BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example ``` This exercises the client, extension, relay, and gateway integration without @@ -148,7 +173,7 @@ short sender pubkey, community subtitle, and no image. ```bash dart format --output=none --set-exit-if-changed . flutter analyze -flutter test +flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example ``` Or from the repo root: `just mobile-check` and `just mobile-test`. diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 8ba678d1dd..0cd277da39 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.net.URI +import java.util.Base64 import java.util.Properties plugins { @@ -20,6 +22,44 @@ val uploadSigningValues = ) val missingUploadSigningValues = uploadSigningValues.filterValues { it.isNullOrBlank() }.keys val hasUploadSigning = missingUploadSigningValues.isEmpty() +val dartDefines = providers.gradleProperty("dart-defines").orNull.orEmpty() +val pushGatewayDefinePrefix = "BUZZ_PUSH_GATEWAY_URL=" +val pushGatewayOrigins = + dartDefines.split(',').mapNotNull { encoded -> + val define = runCatching { String(Base64.getDecoder().decode(encoded)) }.getOrNull() + define + ?.takeIf { it.startsWith(pushGatewayDefinePrefix) } + ?.removePrefix(pushGatewayDefinePrefix) + } +fun isValidPushGatewayOrigin(value: String, requireHttps: Boolean): Boolean { + val uri = runCatching { URI(value) }.getOrNull() ?: return false + val scheme = uri.scheme?.lowercase() + return (scheme == "https" || (!requireHttps && scheme == "http")) && + !uri.host.isNullOrBlank() && + uri.rawUserInfo == null && + (uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") && + uri.rawQuery == null && + uri.rawFragment == null && + (if (requireHttps) uri.port == -1 else uri.port == -1 || uri.port in 1..65535) +} + +tasks.matching { it.name.startsWith("compileFlutterBuild") }.configureEach { + doFirst { + val requireHttps = !name.endsWith("Debug", ignoreCase = true) + val hasValidPushGatewayOrigin = + pushGatewayOrigins.isEmpty() || + (pushGatewayOrigins.size == 1 && + isValidPushGatewayOrigin(pushGatewayOrigins.single(), requireHttps)) + if (!hasValidPushGatewayOrigin) { + throw GradleException( + "When supplied, BUZZ_PUSH_GATEWAY_URL must be an " + + (if (requireHttps) "HTTPS" else "HTTP(S)") + + " origin without " + (if (requireHttps) "an explicit port, " else "") + + "credentials, path, query, or fragment.", + ) + } + } +} // Worktree-aware debug identity (gitignored, written by // scripts/mobile-worktree-overrides.sh): debug builds from a git worktree get a 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/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 8003193da3..dabed2175f 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -12,6 +12,9 @@ import Foundation /// The opaque gateway capability and binding metadata needed by a later lease publisher. public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { + /// Gateway authority that issued this opaque capability. + public let gatewayOrigin: String + /// Canonical WebSocket relay origin that owns the client lease. public let relayOrigin: String /// NIP-PL delegation key selected from the relay push descriptor. public let relayPubkey: String @@ -20,19 +23,31 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { /// Gateway installation authority. This is distinct from [installationId], /// which is the unlinkable per-relay-origin NIP-PL lease address. public let gatewayInstallationHandle: String? + /// App Attest key that authenticates mutations for the gateway installation. + public let appAttestKeyId: String + /// Unlinkable per-relay lease address, distinct from the gateway handle. public let installationId: String + /// Opaque capability issued by the gateway for relay delivery. public let endpointGrant: String + /// Lowercase SHA-256 digest of the binary APNs device token. public let endpointHash: String + /// Server-owned application identity used for enrollment. public let appProfile: String + /// Monotonic endpoint version bound into the gateway grant. public let endpointEpoch: Int64 + /// Monotonic delegation version bound into the gateway grant. public let generation: Int64 + /// Unix expiration time of the installation or delegation authority. public let expiresAt: Int64 + /// Creates a gateway-scoped durable record from authenticated enrollment state. public init( + gatewayOrigin: String, relayOrigin: String, relayPubkey: String, relayMetadataPubkey: String? = nil, gatewayInstallationHandle: String? = nil, + appAttestKeyId: String, installationId: String, endpointGrant: String, endpointHash: String, @@ -42,10 +57,12 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { expiresAt: Int64 ) { precondition(generation > 0, "Endpoint grant generation must be positive") + self.gatewayOrigin = gatewayOrigin self.relayOrigin = relayOrigin self.relayPubkey = relayPubkey self.relayMetadataPubkey = relayMetadataPubkey self.gatewayInstallationHandle = gatewayInstallationHandle + self.appAttestKeyId = appAttestKeyId self.installationId = installationId self.endpointGrant = endpointGrant self.endpointHash = endpointHash @@ -59,14 +76,24 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { /// Persistence boundary for endpoint grants. The Runner implementation stores /// records in its Keychain access group and exposes them over the Flutter bridge. public protocol BuzzPushEndpointGrantStore { + /// Returns durable grants across configured origins without migrating them. func records() throws -> [BuzzPushEndpointGrantRecord] + /// Atomically replaces the grant for its gateway, relay origin, and app profile. func save(_ record: BuzzPushEndpointGrantRecord) throws + /// Loads the exact retry journal for a gateway, relay origin, and app profile. func pendingEnrollment( + gatewayOrigin: String, relayOrigin: String, appProfile: String ) throws -> BuzzPushPendingEnrollmentRecord? + /// Persists the retry journal before any enrollment or delegation mutation. func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws - func removePendingEnrollment(relayOrigin: String, appProfile: String) throws + /// Removes a journal only after success or authoritative terminal failure. + func removePendingEnrollment( + gatewayOrigin: String, + relayOrigin: String, + appProfile: String + ) throws } public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { @@ -108,7 +135,7 @@ protocol BuzzDevAppAttesting { func prepareAttestation() async throws -> BuzzDevAttestation func attestation(_ prepared: BuzzDevAttestation, clientData: Data) async throws -> BuzzDevAttestation - func assertion(clientData: Data) async throws -> String + func assertion(keyId: String, clientData: Data) async throws -> String } struct BuzzDevAttestation: Equatable { @@ -282,12 +309,10 @@ struct BuzzDCAppAttestProvider: BuzzDevAppAttesting { ) } - func assertion(clientData: Data) async throws -> String { + func assertion(keyId: String, clientData: Data) async throws -> String { precondition(!clientData.isEmpty, "Delegation client data must not be empty") try requireSupportedService() - guard let keyId = try keyIdStore.keyId(), - BuzzAppAttestKeyId.isValid(keyId) - else { + guard BuzzAppAttestKeyId.isValid(keyId) else { throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId } let object = try await service.generateAssertion( @@ -310,6 +335,7 @@ public final class BuzzDevPushEnrollmentDriver { public static let endpointEpoch: Int64 = 1 private let gatewayBaseURL: URL + private let gatewayOrigin: String private let store: BuzzPushEndpointGrantStore private let session: URLSession private let appAttest: BuzzDevAppAttesting @@ -351,10 +377,17 @@ public final class BuzzDevPushEnrollmentDriver { try BuzzSecureRandom.bytes(count: 16) } ) throws { - guard Self.isHTTPOrigin(gatewayBaseURL), lifetimeSeconds > 0 else { + guard lifetimeSeconds > 0 else { throw BuzzDevPushEnrollmentError.invalidGatewayURL } - self.gatewayBaseURL = gatewayBaseURL + let canonical: (url: URL, text: String) + do { + canonical = try BuzzPushTranscript.canonicalGatewayOrigin(gatewayBaseURL) + } catch { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + self.gatewayBaseURL = canonical.url + self.gatewayOrigin = canonical.text self.store = store self.session = session self.appAttest = appAttest @@ -363,8 +396,9 @@ public final class BuzzDevPushEnrollmentDriver { self.installationIdBytes = installationIdBytes } + /// Returns the persisted opaque grants available to the lease publisher. public func endpointGrants() throws -> [BuzzPushEndpointGrantRecord] { - try store.records() + try store.records().filter { $0.gatewayOrigin == gatewayOrigin } } /// Fetches the relay's current NIP-11 push key, enrolls the APNs endpoint, @@ -383,9 +417,11 @@ public final class BuzzDevPushEnrollmentDriver { let storedRecords = try store.records() let storedForOrigin = storedRecords.first { - $0.relayOrigin == relayOrigin.text && $0.appProfile == Self.appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin.text + && $0.appProfile == Self.appProfile } - var pendingEnrollment = try store.pendingEnrollment( + let pendingEnrollment = try store.pendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) @@ -393,71 +429,50 @@ public final class BuzzDevPushEnrollmentDriver { pending.relayPubkey != relayPubkey || pending.endpointHash != endpointHash || pending.expiresAt <= nowSeconds { - try store.removePendingEnrollment( - relayOrigin: relayOrigin.text, - appProfile: Self.appProfile - ) - pendingEnrollment = nil - } - if let current = storedForOrigin, - current.relayPubkey == relayPubkey, - current.endpointHash == endpointHash, - current.endpointEpoch == Self.endpointEpoch, - current.expiresAt > nowSeconds + 300 - { - guard current.relayMetadataPubkey != relayKeys.metadataPubkey else { + // Finish an unexpired request using its exact journaled endpoint before + // replacing it. A token/key change does not make a response-lost request + // safe to discard, nor does it require revoking sibling installations. + if pending.expiresAt > nowSeconds { + _ = try await resumeEnrollment( + pending, + storedRecords: storedRecords, + relayMetadataPubkey: nil + ) + } else { try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) - return current } - let refreshed = BuzzPushEndpointGrantRecord( - relayOrigin: current.relayOrigin, - relayPubkey: current.relayPubkey, - relayMetadataPubkey: relayKeys.metadataPubkey, - gatewayInstallationHandle: current.gatewayInstallationHandle, - installationId: current.installationId, - endpointGrant: current.endpointGrant, - endpointHash: current.endpointHash, - appProfile: current.appProfile, - endpointEpoch: current.endpointEpoch, - generation: current.generation, - expiresAt: current.expiresAt - ) - try store.save(refreshed) - try store.removePendingEnrollment( - relayOrigin: relayOrigin.text, - appProfile: Self.appProfile - ) - return refreshed + return try await enroll(deviceToken: deviceToken, relayURL: relayURL) } - - // One gateway delegation is scoped to an installation and relay key, not - // to a Buzz community. A second origin served by the same relay therefore - // gets a fresh unlinkable NIP-PL address while reusing the opaque grant. - if storedForOrigin == nil, - let sharedGrant = storedRecords.first(where: { - $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile - && $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch - && $0.expiresAt > nowSeconds + 300 - }) - { + let newestSharedGrant = storedRecords.filter { + $0.gatewayOrigin == gatewayOrigin && $0.relayPubkey == relayPubkey + && $0.appProfile == Self.appProfile + && $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch + && $0.expiresAt > nowSeconds + 300 + }.max { $0.generation < $1.generation } + if pendingEnrollment == nil, let reusableGrant = newestSharedGrant { + // Reuse the newest delegation without invalidating sibling communities. let record = BuzzPushEndpointGrantRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, relayMetadataPubkey: relayKeys.metadataPubkey, - gatewayInstallationHandle: sharedGrant.gatewayInstallationHandle, - installationId: try makeInstallationId(), - endpointGrant: sharedGrant.endpointGrant, + gatewayInstallationHandle: reusableGrant.gatewayInstallationHandle, + appAttestKeyId: reusableGrant.appAttestKeyId, + installationId: try storedForOrigin?.installationId ?? makeInstallationId(), + endpointGrant: reusableGrant.endpointGrant, endpointHash: endpointHash, appProfile: Self.appProfile, - endpointEpoch: sharedGrant.endpointEpoch, - generation: sharedGrant.generation, - expiresAt: sharedGrant.expiresAt + endpointEpoch: reusableGrant.endpointEpoch, + generation: reusableGrant.generation, + expiresAt: reusableGrant.expiresAt ) try store.save(record) try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, appProfile: Self.appProfile ) @@ -469,7 +484,8 @@ public final class BuzzDevPushEnrollmentDriver { // without attempting duplicate APNs-token enrollment. An installation in // its final five minutes is renewed by the authenticated delegation. let reusableInstallation = storedRecords.first { record in - guard record.appProfile == Self.appProfile, + guard record.gatewayOrigin == gatewayOrigin, + record.appProfile == Self.appProfile, record.endpointHash == endpointHash, record.endpointEpoch == Self.endpointEpoch, record.expiresAt > nowSeconds, @@ -496,13 +512,16 @@ public final class BuzzDevPushEnrollmentDriver { ? reusableInstallation.expiresAt : renewedExpiration pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, + endpoint: endpoint, endpointHash: endpointHash, appProfile: Self.appProfile, expiresAt: expiresAt, installationId: try storedForOrigin?.installationId ?? makeInstallationId(), - gatewayInstallationHandle: existing.uuidString.lowercased() + gatewayInstallationHandle: existing.uuidString.lowercased(), + keyId: reusableInstallation.appAttestKeyId ) try store.savePendingEnrollment(pending) } else { @@ -510,6 +529,7 @@ public final class BuzzDevPushEnrollmentDriver { let enrollmentChallenge = try await challenge() let preparedAttestation = try await appAttest.prepareAttestation() let enrollmentClientData = try BuzzPushTranscript.enroll( + gatewayOrigin: gatewayBaseURL, challengeId: enrollmentChallenge.id, challenge: enrollmentChallenge.value, keyId: preparedAttestation.keyId, @@ -526,8 +546,10 @@ public final class BuzzDevPushEnrollmentDriver { throw BuzzDevPushEnrollmentError.invalidResponse(route: "development attestation") } pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, + endpoint: endpoint, endpointHash: endpointHash, appProfile: Self.appProfile, expiresAt: expiresAt, @@ -541,6 +563,62 @@ public final class BuzzDevPushEnrollmentDriver { try store.savePendingEnrollment(pending) } + if pendingEnrollment != nil { + if let record = try await resumeEnrollment( + pending, + storedRecords: storedRecords, + relayMetadataPubkey: relayKeys.metadataPubkey + ) { + return record + } + return try await enroll(deviceToken: deviceToken, relayURL: relayURL) + } + return try await completeEnrollment( + pending, + storedRecords: storedRecords, + relayMetadataPubkey: relayKeys.metadataPubkey + ) + } + + private func resumeEnrollment( + _ pending: BuzzPushPendingEnrollmentRecord, + storedRecords: [BuzzPushEndpointGrantRecord], + relayMetadataPubkey: String? + ) async throws -> BuzzPushEndpointGrantRecord? { + do { + return try await completeEnrollment( + pending, + storedRecords: storedRecords, + relayMetadataPubkey: relayMetadataPubkey + ) + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations", _, actual: 404, _ + ) where pending.gatewayInstallationHandle == nil { + // Exact replay found no committed installation and its challenge expired. + // Only this terminal response permits replacing the old journal. A fresh + // request's failure propagates instead of recursively retrying forever. + try store.removePendingEnrollment( + gatewayOrigin: gatewayOrigin, + relayOrigin: pending.relayOrigin, + appProfile: pending.appProfile + ) + return nil + } + } + + private func completeEnrollment( + _ journal: BuzzPushPendingEnrollmentRecord, + storedRecords: [BuzzPushEndpointGrantRecord], + relayMetadataPubkey: String? + ) async throws -> BuzzPushEndpointGrantRecord { + var pending = journal + let endpoint = pending.endpoint + guard Self.endpointHash(endpoint) == pending.endpointHash + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "pending enrollment endpoint") + } + let nowSeconds = Int64(now().timeIntervalSince1970) + let relayPubkey = pending.relayPubkey let installation: UUID if let handle = pending.gatewayInstallationHandle, let existing = UUID(uuidString: handle), @@ -559,27 +637,17 @@ public final class BuzzDevPushEnrollmentDriver { route: "pending development enrollment" ) } - do { - installation = try await enrollInstallation( - challenge: Challenge(id: challengeUUID, value: challengeValue), - endpoint: endpoint, - expiresAt: pending.expiresAt, - attestation: BuzzDevAttestation(keyId: keyId, attestation: attestation) - ) - } catch BuzzDevPushEnrollmentError.unexpectedStatus( - route: "v1/installations", _, actual: 404, _ - ) where pendingEnrollment != nil { - // No installation was committed and the original challenge expired. - // Discard the prepared request and start once with a fresh App Attest key. - try store.removePendingEnrollment( - relayOrigin: relayOrigin.text, - appProfile: Self.appProfile - ) - return try await enroll(deviceToken: deviceToken, relayURL: relayURL) - } + installation = try await enrollInstallation( + challenge: Challenge(id: challengeUUID, value: challengeValue), + endpoint: endpoint, + expiresAt: pending.expiresAt, + attestation: BuzzDevAttestation(keyId: keyId, attestation: attestation) + ) pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: pending.relayOrigin, relayPubkey: pending.relayPubkey, + endpoint: pending.endpoint, endpointHash: pending.endpointHash, appProfile: pending.appProfile, expiresAt: pending.expiresAt, @@ -598,7 +666,7 @@ public final class BuzzDevPushEnrollmentDriver { let currentGeneration = storedRecords .filter { - $0.gatewayInstallationHandle == installationHandle + $0.gatewayOrigin == gatewayOrigin && $0.gatewayInstallationHandle == installationHandle && $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile } .map(\.generation) @@ -615,8 +683,10 @@ public final class BuzzDevPushEnrollmentDriver { generation = 1 } pending = BuzzPushPendingEnrollmentRecord( + gatewayOrigin: gatewayOrigin, relayOrigin: pending.relayOrigin, relayPubkey: pending.relayPubkey, + endpoint: pending.endpoint, endpointHash: pending.endpointHash, appProfile: pending.appProfile, expiresAt: pending.expiresAt, @@ -634,6 +704,7 @@ public final class BuzzDevPushEnrollmentDriver { let delegationChallenge = try await challenge() let delegationClientData = try BuzzPushTranscript.delegate( + gatewayOrigin: gatewayBaseURL, challengeId: delegationChallenge.id, challenge: delegationChallenge.value, installationHandle: installation, @@ -643,7 +714,13 @@ public final class BuzzDevPushEnrollmentDriver { notBefore: nowSeconds, expiresAt: pending.expiresAt ) - let assertion = try await appAttest.assertion(clientData: delegationClientData) + guard let appAttestKeyId = pending.keyId else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let assertion = try await appAttest.assertion( + keyId: appAttestKeyId, + clientData: delegationClientData + ) let endpointGrant = try await delegate( challenge: delegationChallenge, installationHandle: installation, @@ -655,13 +732,15 @@ public final class BuzzDevPushEnrollmentDriver { ) let record = BuzzPushEndpointGrantRecord( - relayOrigin: relayOrigin.text, + gatewayOrigin: gatewayOrigin, + relayOrigin: pending.relayOrigin, relayPubkey: relayPubkey, - relayMetadataPubkey: relayKeys.metadataPubkey, + relayMetadataPubkey: relayMetadataPubkey, gatewayInstallationHandle: installationHandle, + appAttestKeyId: appAttestKeyId, installationId: pending.installationId, endpointGrant: endpointGrant, - endpointHash: endpointHash, + endpointHash: pending.endpointHash, appProfile: Self.appProfile, endpointEpoch: Self.endpointEpoch, generation: generation, @@ -669,7 +748,8 @@ public final class BuzzDevPushEnrollmentDriver { ) try store.save(record) try store.removePendingEnrollment( - relayOrigin: relayOrigin.text, + gatewayOrigin: gatewayOrigin, + relayOrigin: pending.relayOrigin, appProfile: Self.appProfile ) return record @@ -828,16 +908,6 @@ public final class BuzzDevPushEnrollmentDriver { } } - private static func isHTTPOrigin(_ url: URL) -> Bool { - (url.scheme == "http" || url.scheme == "https") - && url.host != nil - && (url.path.isEmpty || url.path == "/") - && url.user == nil - && url.password == nil - && url.query == nil - && url.fragment == nil - } - private static func relayOrigin(_ url: URL) throws -> (url: URL, text: String) { guard url.scheme == "ws" || url.scheme == "wss", url.host != nil, @@ -889,6 +959,30 @@ public final class BuzzDevPushEnrollmentDriver { private static func lowercaseHex(_ data: Data) -> String { data.map { String(format: "%02x", $0) }.joined() } + + private static func endpointHash(_ endpoint: String) -> String? { + let utf8 = Array(endpoint.utf8) + guard !utf8.isEmpty, utf8.count <= 512, utf8.count.isMultiple(of: 2) else { + return nil + } + var bytes = [UInt8]() + bytes.reserveCapacity(utf8.count / 2) + for index in stride(from: 0, to: utf8.count, by: 2) { + guard let high = hexNibble(utf8[index]), let low = hexNibble(utf8[index + 1]) else { + return nil + } + bytes.append(high << 4 | low) + } + return lowercaseHex(Data(SHA256.hash(data: Data(bytes)))) + } + + private static func hexNibble(_ byte: UInt8) -> UInt8? { + switch byte { + case 48...57: byte - 48 + case 97...102: byte - 87 + default: nil + } + } } private struct VersionRequest: Encodable { let v: Int } 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/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift index 402f90be30..8143b4385d 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift @@ -1,23 +1,42 @@ /// Crash-recovery journal written before installation or delegation requests. -/// It contains no APNs endpoint, only its hash and the exact authenticated -/// enrollment material needed to replay a committed request idempotently. +/// The Keychain-backed journal retains the APNs endpoint only when an enrollment +/// request may need exact replay after response loss. public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { + /// Gateway authority for which this retry journal remains valid. + public let gatewayOrigin: String + /// Canonical WebSocket relay origin that owns the client lease. public let relayOrigin: String + /// Relay delegation key captured when the request was prepared. public let relayPubkey: String + /// Protected APNs endpoint needed to replay an enrollment without the current token. + public let endpoint: String + /// Lowercase SHA-256 digest of the binary APNs device token. public let endpointHash: String + /// Server-owned application identity used for enrollment. public let appProfile: String + /// Unix expiration time of the installation or delegation authority. public let expiresAt: Int64 + /// Unlinkable per-relay lease address, distinct from the gateway handle. public let installationId: String + /// Gateway installation identifier, available after enrollment succeeds. public let gatewayInstallationHandle: String? + /// Identifier of the journaled enrollment challenge. public let challengeId: String? + /// Exact challenge bytes encoded as unpadded base64url. public let challenge: String? + /// App Attest key that signed the journaled request. public let keyId: String? + /// Exact attestation object retained for idempotent enrollment replay. public let attestation: String? + /// Highest delegation generation reserved before a network request. public let delegationGeneration: Int64 + /// Creates a gateway-scoped durable record from authenticated enrollment state. public init( + gatewayOrigin: String, relayOrigin: String, relayPubkey: String, + endpoint: String, endpointHash: String, appProfile: String, expiresAt: Int64, @@ -29,8 +48,10 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { attestation: String? = nil, delegationGeneration: Int64 = 0 ) { + self.gatewayOrigin = gatewayOrigin self.relayOrigin = relayOrigin self.relayPubkey = relayPubkey + self.endpoint = endpoint self.endpointHash = endpointHash self.appProfile = appProfile self.expiresAt = expiresAt @@ -42,4 +63,5 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { self.attestation = attestation self.delegationGeneration = delegationGeneration } + } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift index fd0bc34d69..c33c5fad28 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift @@ -6,6 +6,8 @@ public enum BuzzPushTranscriptError: Error, Equatable { /// authority-bearing strings; rather than guess at UTF-8-vs-escaping /// behavior we fail closed. case nonASCIIInput(field: String) + /// The configured gateway was not an HTTP or HTTPS origin. + case invalidGatewayOrigin } /// Canonical NIP-PL App Attest transcript encoder. @@ -27,11 +29,9 @@ public enum BuzzPushTranscriptError: Error, Equatable { /// generated and asserted by the gateway's own encoder. The tests in this /// package replay those vectors byte-for-byte. /// -/// The `audience` member of each transcript is a **fixed protocol constant** -/// defined by NIP-PL (`https://push.buzz.xyz/v1/...`). It is a cross-route -/// domain-separation string, not a deployment URL: the gateway hardcodes it -/// regardless of where it is hosted, so clients must never derive it from a -/// discovered gateway base URL or relay host. +/// The `audience` member uses the fixed origin and route registered by NIP-PL +/// v1. A configurable gateway origin changes transport routing, not these +/// protocol bytes. public enum BuzzPushTranscript { // MARK: Domains @@ -41,22 +41,18 @@ public enum BuzzPushTranscript { public static let revokeDelegationDomain = "buzz.push.revoke-delegation.v1" public static let revokeInstallationDomain = "buzz.push.revoke-installation.v1" - // MARK: Fixed audiences (protocol constants, see type docs) - - public static let enrollAudience = "https://push.buzz.xyz/v1/installations" - public static let delegateAudience = "https://push.buzz.xyz/v1/delegations" - public static let rotateEndpointAudience = "https://push.buzz.xyz/v1/installations/endpoint" - public static let revokeDelegationAudience = "https://push.buzz.xyz/v1/delegations/revoke" - public static let revokeInstallationAudience = "https://push.buzz.xyz/v1/installations/revoke" - /// Wire version pinned by NIP-PL. Every transcript carries `"v":1`. public static let wireVersion: Int64 = 1 + /// Origin registered by NIP-PL v1 for every App Attest transcript audience. + private static let registeredAudienceOrigin = "https://push.buzz.xyz" + // MARK: Transcripts /// `buzz.push.enroll.v1` — these exact bytes are the App Attest /// `clientData` supplied to attestation verification. public static func enroll( + gatewayOrigin: URL, challengeId: UUID, challenge: String, keyId: String, @@ -67,7 +63,7 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.enrollAudience) + try o.string("audience", audience(gatewayOrigin, route: "v1/installations")) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") try o.string("key_id", keyId, field: "key_id") @@ -81,6 +77,7 @@ public enum BuzzPushTranscript { /// `buzz.push.delegate.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func delegate( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -92,7 +89,7 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.delegateAudience) + try o.string("audience", audience(gatewayOrigin, route: "v1/delegations")) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -107,6 +104,7 @@ public enum BuzzPushTranscript { /// `buzz.push.rotate-endpoint.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func rotateEndpoint( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -116,7 +114,10 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.rotateEndpointAudience) + try o.string( + "audience", + audience(gatewayOrigin, route: "v1/installations/endpoint") + ) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -129,6 +130,7 @@ public enum BuzzPushTranscript { /// `buzz.push.revoke-delegation.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func revokeDelegation( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -137,7 +139,10 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.revokeDelegationAudience) + try o.string( + "audience", + audience(gatewayOrigin, route: "v1/delegations/revoke") + ) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -149,6 +154,7 @@ public enum BuzzPushTranscript { /// `buzz.push.revoke-installation.v1` — `SHA-256(bytes)` is the assertion /// `clientDataHash`. public static func revokeInstallation( + gatewayOrigin: URL, challengeId: UUID, challenge: String, installationHandle: UUID, @@ -157,7 +163,10 @@ public enum BuzzPushTranscript { ) throws -> Data { var o = CanonicalObject() o.int("v", wireVersion) - try o.string("audience", Self.revokeInstallationAudience) + try o.string( + "audience", + audience(gatewayOrigin, route: "v1/installations/revoke") + ) o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") o.uuid("installation_handle", installationHandle) @@ -172,6 +181,34 @@ public enum BuzzPushTranscript { Data((domain + "\n" + object.encoded()).utf8) } + /// Validates and canonicalizes a configured gateway origin. + public static func canonicalGatewayOrigin(_ value: URL) throws -> (url: URL, text: String) { + guard let scheme = value.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = value.host?.lowercased(), + value.path.isEmpty || value.path == "/", + value.user == nil, + value.password == nil, + value.query == nil, + value.fragment == nil + else { + throw BuzzPushTranscriptError.invalidGatewayOrigin + } + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = value.port + guard let url = components.url, let text = components.string else { + throw BuzzPushTranscriptError.invalidGatewayOrigin + } + return (url, text) + } + + private static func audience(_ gatewayOrigin: URL, route: String) throws -> String { + _ = try canonicalGatewayOrigin(gatewayOrigin) + return "\(registeredAudienceOrigin)/\(route)" + } + /// Ordered compact JSON object writer. Emission order == call order; /// there is deliberately no sorting, no whitespace, and no `Encodable` /// round-trip anywhere near these bytes. 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/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 2345bc46c6..2edc513ae3 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -11,6 +11,7 @@ import XCTest final class BuzzDevPushEnrollmentDriverTests: XCTestCase { private static let gatewayURL = URL(string: "http://push.example/")! + private static let gatewayOrigin = "http://push.example" private static let relayURL = URL(string: "wss://relay.example/")! private static let relayPubkey = String(repeating: "a", count: 64) private static let firstChallengeId = "11111111-1111-4111-8111-111111111111" @@ -121,12 +122,16 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) XCTAssertEqual(appAttest.clientData.count, 2) + XCTAssertEqual(record.gatewayOrigin, Self.gatewayOrigin) XCTAssertEqual(record.relayOrigin, "wss://relay.example") try assertMatchesVector( "enroll", actual: appAttest.clientData[0], expectedSHA256: "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270", - fixture: makeFixtureTranscript(name: "enroll", replacements: []) + fixture: makeFixtureTranscript( + name: "enroll", + replacements: [] + ) ) try assertMatchesVector( "delegate", @@ -142,10 +147,12 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual( record, BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "opaque-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -316,6 +323,76 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertTrue(store.pending.isEmpty) } + func testResponseLostEnrollmentSurvivesTokenRotationAndAnotherReplayFailure() async throws { + let store = MemoryGrantStore() + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + let rotatedToken = Data(repeating: 42, count: 32) + let rotatedHandle = "44444444-4444-4444-8444-444444444444" + var oldRequests = 0 + var newRequests = 0 + var firstBody: [String: Any]? + var delegatedHandles: [String] = [] + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response(request, status: 200, json: [ + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]] + ]) + case ("POST", "http://push.example/v1/installations/challenges"): + return Self.response(request, status: 200, json: [ + "challenge_id": Self.firstChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ]) + case ("POST", "http://push.example/v1/installations"): + let body = try Self.body(request) + let oldEndpoint = body["endpoint"] as? String == Self.endpoint + if oldEndpoint { + oldRequests += 1 + if let firstBody { + XCTAssertTrue(NSDictionary(dictionary: body).isEqual(to: firstBody)) + } else { + firstBody = body + } + if oldRequests <= 2 { throw URLError(.networkConnectionLost) } + } else { + newRequests += 1 + XCTAssertEqual(body["endpoint"] as? String, Self.hex(rotatedToken)) + XCTAssertEqual(delegatedHandles, [Self.installationHandle]) + } + return Self.response(request, status: 201, json: [ + "installation_handle": oldEndpoint ? Self.installationHandle : rotatedHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ]) + case ("POST", "http://push.example/v1/delegations"): + let handle = try XCTUnwrap(Self.body(request)["installation_handle"] as? String) + delegatedHandles.append(handle) + return Self.response(request, status: 201, json: ["endpoint_grant": "grant-\(handle)"]) + default: + XCTFail("Unexpected request, including any migration/revocation route: \(request.url!)") + return Self.response(request, status: 500, json: [:]) + } + } + + for token in [Data((1...32).map(UInt8.init)), rotatedToken] { + do { + _ = try await driver.enroll(deviceToken: token, relayURL: Self.relayURL) + XCTFail("Expected response loss") + } catch { + XCTAssertEqual((error as NSError).domain, NSURLErrorDomain) + } + XCTAssertEqual(store.pending.first?.endpoint, Self.endpoint) + XCTAssertTrue(store.saved.isEmpty) + } + let grant = try await driver.enroll(deviceToken: rotatedToken, relayURL: Self.relayURL) + XCTAssertEqual(oldRequests, 3) + XCTAssertEqual(newRequests, 1) + XCTAssertEqual(delegatedHandles, [Self.installationHandle, rotatedHandle]) + XCTAssertEqual(grant.endpointHash, Self.hex(SHA256.hash(data: rotatedToken))) + XCTAssertTrue(store.pending.isEmpty) + } + func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws { let relayURL = URL(string: "wss://relay.example:8443/")! let store = MemoryGrantStore() @@ -373,16 +450,44 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443") } - func testLegacyGrantDecodesWithoutMetadataAuthority() throws { + func testLegacyGrantWithoutGatewayOriginIsRejected() throws { let data = Data( #"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"# .utf8 ) - let record = try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data) + XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) + } - XCTAssertEqual(record.relayPubkey, Self.relayPubkey) - XCTAssertNil(record.relayMetadataPubkey) + func testGrantWithoutAppAttestKeyIsRejected() throws { + let data = Data( + #"{"gatewayOrigin":"https://push.example","relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"# + .utf8 + ) + + XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) + } + + func testOtherGatewayGrantIsNeitherExportedNorMigrated() throws { + let other = BuzzPushEndpointGrantRecord( + gatewayOrigin: "https://other-push.example", + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + appAttestKeyId: Self.keyId, + installationId: Self.installationId, + endpointGrant: "other-gateway-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [other]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + + XCTAssertEqual(try driver.endpointGrants(), []) + XCTAssertEqual(store.saved, [other]) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) } func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { @@ -424,13 +529,13 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) } - func testRealAppAttestAssertionReusesStoredKeyAndMapsObject() async throws { + func testRealAppAttestAssertionUsesRequestedKeyAndMapsObject() async throws { let service = RecordingDCAppAttestService(assertionObject: Data([0x04, 0x05, 0x06])) let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) let clientData = Data("delegation transcript".utf8) - let assertion = try await provider.assertion(clientData: clientData) + let assertion = try await provider.assertion(keyId: Self.keyId, clientData: clientData) XCTAssertEqual(assertion, Data([0x04, 0x05, 0x06]).base64EncodedString()) XCTAssertEqual(service.assertedKeyIds, [Self.keyId]) @@ -441,6 +546,22 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(service.generateKeyCallCount, 0) } + func testRealAppAttestAssertionUsesInstallationKeyInsteadOfLatestStoredKey() async throws { + let installationKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString() + let service = RecordingDCAppAttestService(assertionObject: Data([0x07])) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + _ = try await provider.assertion( + keyId: installationKeyId, + clientData: Data("retired installation transcript".utf8) + ) + + XCTAssertEqual(service.assertedKeyIds, [installationKeyId]) + } + func testRealAppAttestRejectsInvalidGeneratedKeyBeforePersistence() async throws { for invalidKeyId in [ "not-a-key-id", @@ -489,7 +610,10 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { ) do { - _ = try await provider.assertion(clientData: Data("delegation transcript".utf8)) + _ = try await provider.assertion( + keyId: Self.keyId, + clientData: Data("delegation transcript".utf8) + ) XCTFail("Expected the DeviceCheck error") } catch { XCTAssertEqual((error as NSError).domain, expected.domain) @@ -616,52 +740,14 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } } - func testReusesPersistedUnexpiredGrant() async throws { - let existing = BuzzPushEndpointGrantRecord( - relayOrigin: "wss://relay.example", - relayPubkey: Self.relayPubkey, - relayMetadataPubkey: Self.relayPubkey, - installationId: Self.installationId, - endpointGrant: "existing-grant", - endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), - appProfile: "buzz-ios-dogfood", - endpointEpoch: 1, - generation: 1, - expiresAt: Self.expiresAt - ) - let store = MemoryGrantStore(records: [existing]) - let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) - URLProtocolStub.handler = { request in - guard request.httpMethod == "GET" else { - XCTFail("Persisted grant reuse must not call the gateway") - return Self.response(request, status: 500, json: [:]) - } - return Self.response( - request, - status: 200, - json: [ - "self": Self.relayPubkey, - "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], - ] - ) - } - - let record = try await driver.enroll( - deviceToken: Data((1...32).map(UInt8.init)), - relayURL: Self.relayURL - ) - - XCTAssertEqual(record, existing) - XCTAssertEqual(store.saved, [existing]) - XCTAssertEqual(URLProtocolStub.requests.count, 1) - } - func testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://first.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: String(repeating: "f", count: 32), endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -703,10 +789,12 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testSecondRelayKeyReusesAttestedInstallationAndCreatesOnlyDelegation() async throws { let secondRelayPubkey = String(repeating: "b", count: 64) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://first.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: String(repeating: "f", count: 32), endpointGrant: "first-relay-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -773,10 +861,12 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testExpiringGrantRenewsExistingInstallationAndReusesRelayLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), @@ -878,9 +968,11 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let oldMetadataPubkey = String(repeating: "c", count: 64) let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: pushPubkey, relayMetadataPubkey: oldMetadataPubkey, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), @@ -919,9 +1011,11 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testMissingRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), @@ -956,9 +1050,11 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testMalformedRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( + gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, + appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), @@ -1024,6 +1120,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { } private func makeDriver( + gatewayBaseURL: URL = BuzzDevPushEnrollmentDriverTests.gatewayURL, store: BuzzPushEndpointGrantStore, appAttest: BuzzDevAppAttesting, installationIdBytes: @escaping () throws -> Data = { @@ -1033,7 +1130,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [URLProtocolStub.self] return try BuzzDevPushEnrollmentDriver( - gatewayBaseURL: Self.gatewayURL, + gatewayBaseURL: gatewayBaseURL, store: store, session: URLSession(configuration: configuration), appAttest: appAttest, @@ -1148,12 +1245,17 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { var saved: [BuzzPushEndpointGrantRecord] var pending: [BuzzPushPendingEnrollmentRecord] = [] var grantSaveFailuresRemaining: Int + var pendingRemoveFailuresRemaining: Int init( records: [BuzzPushEndpointGrantRecord] = [], - grantSaveFailuresRemaining: Int = 0 + pending: [BuzzPushPendingEnrollmentRecord] = [], + grantSaveFailuresRemaining: Int = 0, + pendingRemoveFailuresRemaining: Int = 0 ) { saved = records + self.pending = pending self.grantSaveFailuresRemaining = grantSaveFailuresRemaining + self.pendingRemoveFailuresRemaining = pendingRemoveFailuresRemaining } func records() throws -> [BuzzPushEndpointGrantRecord] { saved } func save(_ record: BuzzPushEndpointGrantRecord) throws { @@ -1162,39 +1264,56 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { throw NSError(domain: "MemoryGrantStore", code: 1) } saved.removeAll { - $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin + && $0.appProfile == record.appProfile } saved.append(record) } func pendingEnrollment( + gatewayOrigin: String, relayOrigin: String, appProfile: String ) throws -> BuzzPushPendingEnrollmentRecord? { pending.first { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile } } func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws { pending.removeAll { - $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin + && $0.appProfile == record.appProfile } pending.append(record) } - func removePendingEnrollment(relayOrigin: String, appProfile: String) throws { + func removePendingEnrollment( + gatewayOrigin: String, + relayOrigin: String, + appProfile: String + ) throws { + if pendingRemoveFailuresRemaining > 0 { + pendingRemoveFailuresRemaining -= 1 + throw NSError(domain: "MemoryGrantStore", code: 3) + } pending.removeAll { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile } } } private final class RecordingAppAttest: BuzzDevAppAttesting { var clientData: [Data] = [] + var preparedAttestations: [BuzzDevAttestation] = [] + var assertionKeyIds: [String] = [] func prepareAttestation() async throws -> BuzzDevAttestation { - BuzzDevAttestation( + let prepared = BuzzDevAttestation( keyId: BuzzDevPushEnrollmentDriverTests.keyId, attestation: BuzzDevPushEnrollmentDriverTests.attestation ) + preparedAttestations.append(prepared) + return prepared } func attestation( @@ -1205,8 +1324,9 @@ private final class RecordingAppAttest: BuzzDevAppAttesting { return prepared } - func assertion(clientData: Data) async throws -> String { + func assertion(keyId: String, clientData: Data) async throws -> String { self.clientData.append(clientData) + assertionKeyIds.append(keyId) return BuzzDevPushEnrollmentDriverTests.assertion } } 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 bc6c62e013..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() @@ -574,6 +327,130 @@ struct BuzzPushPresentationCacheTests { ) } + @Test("Retrying a partial batch prefix preserves newer state and original scope") + func partialBatchPrefixReplayIsIdempotent() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore( + containerURL: directory, + now: { Date(timeIntervalSince1970: 1_700_000_100) } + ) + let relayPubkey = try pubkey(for: relayKey) + let member = try pubkey(for: profileKey) + let addedMember = try pubkey(for: otherRelayKey) + let profiles = try [profileKey, otherRelayKey].map { key in + try signedEvent(privateKey: key, createdAt: 100, kind: 0, content: #"{"name":"Original"}"#) + } + let metadata = try ["prefix", "suffix"].map { channel in + try signedEvent( + privateKey: relayKey, createdAt: 100, kind: 39_000, + tags: [["d", channel], ["name", "Original"], ["t", "stream"]] + ) + } + let memberships = try ["prefix", "suffix"].map { channel in + try signedEvent( + privateKey: relayKey, createdAt: 100, kind: 39_002, + tags: [["d", channel], ["p", member]] + ) + } + func deliver( + _ index: Int, community: String = "original", origin: String = "https://relay.example" + ) throws { + try store.updateProfiles( + communityID: community, relayOrigin: origin, + updates: [BuzzPushProfileCacheUpdate(event: profiles[index])] + ) + try store.updateChannels( + communityID: community, relayOrigin: origin, relayMetadataPubkey: relayPubkey, + metadataEvents: [metadata[index]], membershipEvents: [memberships[index]] + ) + } + + // Native storage accepts the prefix, but the caller loses its acknowledgement. + try deliver(0) + try deliver(0, community: "other-community") + try deliver(0, origin: "https://other-relay.example") + let beforeRetry = try loadSnapshot(directory) + let untouchedProfiles = beforeRetry.profiles.filter { + $0.communityID != "original" || $0.relayOrigin != "https://relay.example" + } + let untouchedChannels = beforeRetry.channels.filter { + $0.communityID != "original" || $0.relayOrigin != "https://relay.example" + } + let newerProfile = try signedEvent( + privateKey: profileKey, createdAt: 101, kind: 0, content: #"{"name":"Newest"}"# + ) + let newerMetadata = try signedEvent( + privateKey: relayKey, createdAt: 101, kind: 39_000, + tags: [["d", "prefix"], ["name", "Newest"], ["t", "forum"]] + ) + let newerMembership = try signedEvent( + privateKey: relayKey, createdAt: 101, kind: 39_002, + tags: [["d", "prefix"], ["p", member], ["p", addedMember]] + ) + try store.updateProfiles( + communityID: "original", relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: newerProfile)] + ) + try store.updateChannels( + communityID: "original", relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [newerMetadata], membershipEvents: [newerMembership] + ) + + // Replay exact events from the accepted prefix, then deliver the missing suffix. + try deliver(0) + try deliver(1) + let completed = try loadSnapshot(directory) + try deliver(0) + try deliver(1) + let replayed = try loadSnapshot(directory) + #expect(replayed.profiles == completed.profiles) + #expect(replayed.channels == completed.channels) + #expect(replayed.profiles.count == 4) + #expect(replayed.channels.count == 4) + #expect( + replayed.profiles.filter { + $0.communityID != "original" || $0.relayOrigin != "https://relay.example" + } == untouchedProfiles) + #expect( + replayed.channels.filter { + $0.communityID != "original" || $0.relayOrigin != "https://relay.example" + } == untouchedChannels) + let profile = try #require( + replayed.profile( + communityID: "original", relayOrigin: "https://relay.example", pubkey: member + )) + #expect(profile.eventID == newerProfile.id) + #expect(profile.displayName == "Newest") + let channel = try #require( + replayed.channel( + communityID: "original", relayOrigin: "https://relay.example", channelID: "prefix" + )) + #expect(channel.eventID == newerMetadata.id) + #expect(channel.displayName == "Newest") + #expect(channel.channelType == "forum") + #expect(channel.membershipEventID == newerMembership.id) + #expect(channel.memberCount == 2) + #expect( + channel.memberDigests + == [member, addedMember].map { + BuzzPushPresentationIdentity.channelMember( + communityID: "original", channelID: "prefix", pubkey: $0 + ) + }.sorted()) + #expect( + replayed.profile( + communityID: "original", relayOrigin: "https://relay.example", pubkey: addedMember + )?.eventID == profiles[1].id) + let suffix = try #require( + replayed.channel( + communityID: "original", relayOrigin: "https://relay.example", channelID: "suffix" + )) + #expect(suffix.eventID == metadata[1].id) + #expect(suffix.membershipEventID == memberships[1].id) + } + @Test("Channel authority rotation clears membership signed by the old authority") func channelAuthorityRotationClearsMembership() throws { let directory = try temporaryDirectory() @@ -993,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/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift index 3cb242bc4c..c08fe9c82c 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift @@ -42,6 +42,7 @@ final class BuzzPushTranscriptTests: XCTestCase { // Deterministic inputs mirroring the fixture's `inputs` block. static let challengeId = UUID(uuidString: "11111111-1111-4111-8111-111111111111")! + static let gatewayOrigin = URL(string: "https://push.buzz.xyz")! static let installationHandle = UUID(uuidString: "22222222-2222-4222-8222-222222222222")! static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" @@ -70,6 +71,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testEnrollVector() throws { try assertMatchesVector("enroll", BuzzPushTranscript.enroll( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, keyId: Self.keyId, @@ -82,6 +84,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testDelegateVector() throws { try assertMatchesVector("delegate", BuzzPushTranscript.delegate( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -95,6 +98,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testRotateEndpointVector() throws { try assertMatchesVector("rotate_endpoint", BuzzPushTranscript.rotateEndpoint( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -106,6 +110,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testRevokeDelegationVector() throws { try assertMatchesVector("revoke_delegation", BuzzPushTranscript.revokeDelegation( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -116,6 +121,7 @@ final class BuzzPushTranscriptTests: XCTestCase { func testRevokeInstallationVector() throws { try assertMatchesVector("revoke_installation", BuzzPushTranscript.revokeInstallation( + gatewayOrigin: Self.gatewayOrigin, challengeId: Self.challengeId, challenge: Self.challenge, installationHandle: Self.installationHandle, @@ -132,6 +138,41 @@ final class BuzzPushTranscriptTests: XCTestCase { ) } + func testConfiguredTransportOriginKeepsRegisteredTranscriptAudience() throws { + let bytes = try BuzzPushTranscript.delegate( + gatewayOrigin: URL(string: "https://push.example")!, + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + generation: 1, + relayPubkey: Self.relayPubkey, + notBefore: Self.notBefore, + expiresAt: Self.expiresAt + ) + + XCTAssertTrue( + String(decoding: bytes, as: UTF8.self) + .contains("\"audience\":\"https://push.buzz.xyz/v1/delegations\"") + ) + } + + func testInvalidGatewayOriginRejected() { + XCTAssertThrowsError(try BuzzPushTranscript.delegate( + gatewayOrigin: URL(string: "https://push.example/path")!, + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + generation: 1, + relayPubkey: Self.relayPubkey, + notBefore: Self.notBefore, + expiresAt: Self.expiresAt + )) { + XCTAssertEqual($0 as? BuzzPushTranscriptError, .invalidGatewayOrigin) + } + } + // MARK: Escaping edges (the exact JSONSerialization failure modes) func testSolidusIsNotEscaped() throws { 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.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 21946f4077..755923548e 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -512,7 +512,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "set -e\n. \"$SRCROOT/../scripts/require-push-gateway-origin.sh\"\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index b5b21445c6..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 @@ -509,7 +479,16 @@ import os.log openNotificationSettings(result: result) case "endpointGrants": do { - result(try endpointGrantStore.records().map(\.flutterArguments)) + guard let arguments = call.arguments as? [String: Any], + let gatewayText = arguments["gatewayUrl"] as? String, + let gatewayURL = URL(string: gatewayText) + else { throw BuzzDevPushEnrollmentError.invalidGatewayURL } + let driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore, + appAttestKeychainAccessGroup: pushKeychainAccessGroup + ) + result(try driver.endpointGrants().map(\.flutterArguments)) } catch { result( FlutterError( diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index ffedbedd19..89112e7470 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -6,8 +6,8 @@ import Security /// UserDefaults or logs. Dart can read the closed record through the push bridge. final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { private static let service = "buzz.push.endpoint-grants" - private static let recordsAccount = "v1" - private static let pendingAccount = "pending-v1" + private static let recordsAccount = "v2" + private static let pendingAccount = "pending-v2" private let accessGroup: String? @@ -39,36 +39,45 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { func save(_ record: BuzzPushEndpointGrantRecord) throws { var all = try records() all.removeAll { - $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin + && $0.appProfile == record.appProfile } all.append(record) - try replace(all, account: Self.recordsAccount) + try replaceValue(all, account: Self.recordsAccount) } func pendingEnrollment( + gatewayOrigin: String, relayOrigin: String, appProfile: String ) throws -> BuzzPushPendingEnrollmentRecord? { try pendingEnrollments().first { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile } } func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws { var all = try pendingEnrollments() all.removeAll { - $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin + && $0.appProfile == record.appProfile } all.append(record) - try replace(all, account: Self.pendingAccount) + try replaceValue(all, account: Self.pendingAccount) } - func removePendingEnrollment(relayOrigin: String, appProfile: String) throws { + func removePendingEnrollment( + gatewayOrigin: String, + relayOrigin: String, + appProfile: String + ) throws { var all = try pendingEnrollments() all.removeAll { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin + && $0.appProfile == appProfile } - try replace(all, account: Self.pendingAccount) + try replaceValue(all, account: Self.pendingAccount) } private func pendingEnrollments() throws -> [BuzzPushPendingEnrollmentRecord] { @@ -92,8 +101,8 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { } } - private func replace(_ values: [T], account: String) throws { - let data = try JSONEncoder().encode(values) + private func replaceValue(_ value: T, account: String) throws { + let data = try JSONEncoder().encode(value) let updateStatus = SecItemUpdate( baseQuery(account: account) as CFDictionary, [kSecValueData as String: data] as CFDictionary 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 cb8f453c14..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, @@ -390,9 +440,11 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { metadataPubkey: String ) -> BuzzPushEndpointGrantRecord { BuzzPushEndpointGrantRecord( + gatewayOrigin: "https://push.example", relayOrigin: "https://relay.example", relayPubkey: String(repeating: "c", count: 64), relayMetadataPubkey: metadataPubkey, + appAttestKeyId: Data(repeating: 0xAA, count: 32).base64EncodedString(), installationId: "installation", endpointGrant: "opaque-grant", endpointHash: String(repeating: "d", count: 64), 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 2577e342ce..09b8ea47b5 100644 --- a/mobile/lib/features/age_gate/age_signal_provider.dart +++ b/mobile/lib/features/age_gate/age_signal_provider.dart @@ -1,256 +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( - AgeSignalNotifier.new, + ageGatingEnabled ? AgeSignalNotifier.new : _DisabledAgeSignalNotifier.new, ); + +// Remains disabled until the fail-open implementation is validated in dogfood. +class _DisabledAgeSignalNotifier extends AgeSignalNotifier { + @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/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index 1282e5f1e1..dcff0a3569 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -82,22 +82,26 @@ final channelDirectoryLoadStatusProvider = Future> _fetchChannelMemberships( RelaySessionNotifier session, - String pubkey, -) => _fetchPaginatedChannelEvents( + String pubkey, { + void Function()? ensureCurrent, +}) => _fetchPaginatedChannelEvents( session, kind: 39002, tags: { '#p': [pubkey], }, operation: 'Channel memberships', + ensureCurrent: ensureCurrent, ); Future> _fetchChannelDirectoryMetas( - RelaySessionNotifier session, -) => _fetchPaginatedChannelEvents( + RelaySessionNotifier session, { + void Function()? ensureCurrent, +}) => _fetchPaginatedChannelEvents( session, kind: 39000, operation: 'Channel directory', + ensureCurrent: ensureCurrent, ); /// Thrown when a channel-list request is retired before it settles. @@ -391,12 +395,14 @@ Future> _fetchPaginatedChannelEvents( required int kind, required String operation, Map> tags = const {}, + void Function()? ensureCurrent, }) async { final events = []; final seenEventIds = {}; int? until; String? beforeId; for (var pageIndex = 0; pageIndex < _maxChannelDirectoryPages; pageIndex++) { + ensureCurrent?.call(); final page = await session.queryRelay([ NostrFilter( kinds: [kind], @@ -406,6 +412,7 @@ Future> _fetchPaginatedChannelEvents( extensions: {'before_id': ?beforeId}, ), ]); + ensureCurrent?.call(); if (page.isEmpty) break; var madeProgress = false; for (final event in page) { diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index e910f565b4..32f4824df2 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -229,9 +229,12 @@ class ChannelMessagesNotifier extends Notifier>> { void _handleWindowLiveEvent(NostrEvent event) { if (!_mergeWindowEventIntoStore(event)) return; - final flattened = _withDeepLinkEvents( - flattenChannelWindowEvents(_windowStore), - ); + final windowEvents = flattenChannelWindowEvents(_windowStore); + // Flattening already orders the window. Only merge and sort again when + // there are retained deep-link events to include. + final flattened = _deepLinkEvents.isEmpty + ? windowEvents + : _withDeepLinkEvents(windowEvents); _lastKnownMessages = flattened; state = AsyncData(flattened); } diff --git a/mobile/lib/features/channels/channel_push_cache.dart b/mobile/lib/features/channels/channel_push_cache.dart new file mode 100644 index 0000000000..4576f1b3d9 --- /dev/null +++ b/mobile/lib/features/channels/channel_push_cache.dart @@ -0,0 +1,106 @@ +part of 'channels_provider.dart'; + +extension _ChannelPushCache on ChannelsNotifier { + Future _exportPushCache( + String? communityID, + List metadata, + List membership, + ) async { + if (defaultTargetPlatform != TargetPlatform.iOS || communityID == null) { + return; + } + if (_pushCacheExporting) { + // A refresh can contain different channels. Do not replace its predecessor + // or retain another raw batch: refetch the complete scope after it drains. + _pushCacheDirty = true; + return; + } + _pushCacheExporting = true; + try { + await _pushExport.export( + () => cacheBuzzPushChannelEvents(communityID, metadata, membership), + ); + // A failed operation is terminal only for its own input. A refresh that + // arrived meanwhile still owns an independent dirty obligation. + while (_lifecycleRef.mounted && _pushCacheDirty) { + _pushCacheDirty = false; + final session = _lifecycleRef.read(relaySessionProvider.notifier); + final community = _lifecycleRef.read(activeCommunityProvider).value?.id; + final relay = _lifecycleRef.read(relayConfigProvider).baseUrl; + final pubkey = _lifecycleRef.read(myPubkeyProvider); + if (community == null || pubkey == null) return; + bool current() => + _lifecycleRef.mounted && + _lifecycleRef.read(activeCommunityProvider).value?.id == + community && + _lifecycleRef.read(relayConfigProvider).baseUrl == relay && + _lifecycleRef.read(myPubkeyProvider) == pubkey && + identical( + _lifecycleRef.read(relaySessionProvider.notifier), + session, + ); + + void ensureCurrent() { + if (!current()) throw const _StaleChannelRefresh(); + } + + // This fetch has no UI writes or channel-refresh generation changes. + // New ordinary refreshes can proceed and mark this snapshot dirty again. + // Start once. Admission retries await this same snapshot rather than + // repeating relay reads while the native export queue remains full. + final snapshot = () async { + try { + if (!current()) return null; + final members = await _fetchChannelMemberships( + session, + pubkey, + ensureCurrent: ensureCurrent, + ); + if (!current()) return null; + final ids = members + .map((event) => event.getTagValue('d')) + .whereType() + .toSet() + .toList(); + final memberMetadata = ids.isEmpty + ? const [] + : await session.fetchHistory(NostrFilters.channelMetadata(ids)); + if (!current()) return null; + final memberSnapshots = ids.isEmpty + ? const [] + : await session.fetchHistory( + NostrFilter( + kinds: const [39002], + tags: {'#d': ids}, + limit: ids.length, + ), + ); + if (!current()) return null; + return ( + metadata: memberMetadata, + membership: [...members, ...memberSnapshots], + ); + } catch (_) { + if (current()) rethrow; + // Retired fetches must neither write nor report an error in the + // replacement scope. Its ordinary refresh owns any new dirty work. + } + return null; + }(); + await _pushExport.export(() async { + final events = await snapshot; + if (events == null || !current()) return; + await cacheBuzzPushChannelEvents( + community, + events.metadata, + events.membership, + ); + }); + // Only new producer input can set dirty again; failure alone never + // schedules another attempt after recovery's bounded retries. + } + } finally { + _pushCacheExporting = false; + } + } +} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 8e724cc3a8..c5a77ba930 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -3,10 +3,12 @@ import 'dart:convert'; import 'dart:math'; import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/community/community_provider.dart'; import '../../shared/push/push_presentation_cache.dart'; +import '../../shared/push/push_presentation_export_recovery.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme_provider.dart'; import '../../shared/utils/string_utils.dart'; @@ -22,6 +24,7 @@ import 'unread_badge/observed_unread_event.dart'; import 'unread_badge/should_notify_for_event.dart'; part 'channel_directory.dart'; +part 'channel_push_cache.dart'; part 'channel_member_snapshots.dart'; part 'channels_provider_lifecycle.dart'; @@ -41,6 +44,10 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; /// for any visible channel event kind. Chunks stay within the relay's explicit /// channel cap and incoming events bump `lastMessageAt` for their channel. class ChannelsNotifier extends AsyncNotifier> { + final _pushExport = PushPresentationExportRecovery(); + bool _pushCacheExporting = false; + bool _pushCacheDirty = false; + static const _backstopInterval = Duration(seconds: 60); final Map _liveSubscriptionsByChunk = {}; @@ -300,7 +307,7 @@ class ChannelsNotifier extends AsyncNotifier> { // linkage validation and member-count hydration. if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); unawaited( - cacheBuzzPushChannelEvents(communityID, dedupedMetas, [ + _exportPushCache(communityID, dedupedMetas, [ ...memberships, ...memberEvents, ]), diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index f2cccbaa23..07d4f86a14 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -33,6 +34,7 @@ import 'message_media.dart'; import 'voice_note_attachment.dart'; part 'message_content/media_carousel.dart'; +part 'message_content/inline_components.dart'; part 'message_content/token_pill.dart'; part 'message_content/video_preview.dart'; @@ -176,13 +178,33 @@ class MessageContent extends HookConsumerWidget { in ref.watch(channelsProvider).asData?.value ?? const []) channel.name.toLowerCase(): channel.id, }; - final resolvedChannelTap = - onChannelTap ?? - (String channelId) { + final channelHandler = useRef(onChannelTap)..value = onChannelTap; + final resolvedChannelTap = useMemoized( + () => (String channelId) { + final handler = channelHandler.value; + if (handler != null) { + handler(channelId); + } else { ref .read(pendingDeepLinkProvider.notifier) .open(Uri(scheme: 'buzz', host: 'channel', path: channelId)); - }; + } + }, + const [], + ); + final replyHandler = useRef(onMediaReply)..value = onMediaReply; + final moreHandler = useRef(onMediaMore)..value = onMediaMore; + final mediaReply = useMemoized( + () => + () => replyHandler.value?.call(), + const [], + ); + final mediaMore = useMemoized( + () => + (BuildContext context, String url) => + moreHandler.value?.call(context, url), + const [], + ); final channelPresentationKey = [ for (final entry in (resolvedChannelNames.entries.toList() @@ -265,7 +287,23 @@ class MessageContent extends HookConsumerWidget { result = '\u200B$result'; } return result; - }, [linkNormalizedContent, resolvedMentionNames]); + }, [linkNormalizedContent, mentionPresentationKey]); + + final inlineComponents = _useMessageInlineComponents( + content: content, + finalContent: finalContent, + mentionNames: resolvedMentionNames, + bindings: mentionBindings, + agentPubkeys: resolvedAgentMentionPubkeys, + channelNames: resolvedChannelNames, + customEmoji: customEmoji, + emojiSize: inlineCustomEmojiSize, + tags: tags, + hasMediaReply: onMediaReply != null, + hasMediaMore: onMediaMore != null, + onMentionTap: onMentionTap, + onChannelTap: resolvedChannelTap, + ); final markdown = KeyedSubtree( key: ValueKey( @@ -291,35 +329,16 @@ class MessageContent extends HookConsumerWidget { resolvedChannelTap, resolvedChannelNames, ), - imageBuilder: (context, imageUrl, _, _) => - _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), + imageBuilder: (context, imageUrl, _, _) => _buildMedia( + context, + imageUrl, + imetaByUrl[imageUrl], + onReply: onMediaReply == null ? null : mediaReply, + onMore: onMediaMore == null ? null : mediaMore, + ), textAlign: textAlign, maxLines: maxLines, - inlineComponents: [ - _MentionMd( - mentionNames: resolvedMentionNames, - bindings: mentionBindings, - displayLabels: { - for (final range in mentionOccurrences( - content, - mentionBindings.keys, - )) - range.label: content.substring(range.start + 1, range.end), - }, - agentMentionPubkeys: resolvedAgentMentionPubkeys, - onMentionTap: onMentionTap, - ), - CustomEmojiMd( - customEmoji, - content: finalContent, - size: inlineCustomEmojiSize, - ), - _ChannelLinkMd( - channelNames: resolvedChannelNames, - onChannelTap: resolvedChannelTap, - ), - ...MarkdownComponent.inlineComponents, - ], + inlineComponents: inlineComponents, ), ); if (trailingGallery == null) return markdown; @@ -343,7 +362,13 @@ class MessageContent extends HookConsumerWidget { ); } - Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) { + Widget _buildMedia( + BuildContext context, + String imageUrl, + ImetaEntry? imeta, { + VoidCallback? onReply, + MediaViewerMoreAction? onMore, + }) { final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta); if (mediaKind == MessageMediaKind.audio) { return Padding( @@ -360,15 +385,15 @@ class MessageContent extends HookConsumerWidget { return _MessageVideoPreview( url: imageUrl, imeta: imeta, - onReply: onMediaReply, + onReply: onReply, ); } return _MessageImagePreview( url: imageUrl, imeta: imeta, semanticLabel: imeta?.alt ?? 'Message image', - onReply: onMediaReply, - onMore: onMediaMore, + onReply: onReply, + onMore: onMore, ); } diff --git a/mobile/lib/features/channels/message_content/inline_components.dart b/mobile/lib/features/channels/message_content/inline_components.dart new file mode 100644 index 0000000000..6e17db769e --- /dev/null +++ b/mobile/lib/features/channels/message_content/inline_components.dart @@ -0,0 +1,136 @@ +part of '../message_content.dart'; + +List _useMessageInlineComponents({ + required String content, + required String finalContent, + required Map mentionNames, + required Map> bindings, + required Set agentPubkeys, + required Map channelNames, + required List customEmoji, + required double emojiSize, + required List> tags, + required bool hasMediaReply, + required bool hasMediaMore, + required void Function(String)? onMentionTap, + required void Function(String) onChannelTap, +}) { + // Parsed spans retain these callbacks. Forward to the current handlers without + // invalidating the parser for a new closure from an otherwise unchanged row. + final mentionHandler = useRef(onMentionTap)..value = onMentionTap; + final channelHandler = useRef(onChannelTap)..value = onChannelTap; + final mentionTap = useMemoized( + () => + (String pubkey) => mentionHandler.value?.call(pubkey), + const [], + ); + final channelTap = useMemoized( + () => + (String channelId) => channelHandler.value(channelId), + const [], + ); + final inputs = _InlineComponentInputs( + content: content, + finalContent: finalContent, + mentionNames: mentionNames, + bindings: bindings, + agentPubkeys: agentPubkeys, + channelNames: channelNames, + customEmoji: customEmoji, + emojiSize: emojiSize, + tags: tags, + hasMediaReply: hasMediaReply, + hasMediaMore: hasMediaMore, + hasMentionHandler: onMentionTap != null, + ); + + // gpt_markdown compares components by identity. Recreating them on every + // parent rebuild reparses unchanged message bodies on the UI isolate. + return useMemoized( + () => [ + _MentionMd( + mentionNames: inputs.mentionNames, + bindings: inputs.bindings, + displayLabels: { + for (final range in mentionOccurrences(content, bindings.keys)) + range.label: content.substring(range.start + 1, range.end), + }, + agentMentionPubkeys: inputs.agentPubkeys, + onMentionTap: inputs.hasMentionHandler ? mentionTap : null, + ), + CustomEmojiMd(inputs.customEmoji, content: finalContent, size: emojiSize), + _ChannelLinkMd( + channelNames: inputs.channelNames, + onChannelTap: channelTap, + ), + ...MarkdownComponent.inlineComponents, + ], + [inputs], + ); +} + +class _InlineComponentInputs { + final String content; + final String finalContent; + final Map mentionNames; + final Map> bindings; + final Set agentPubkeys; + final Map channelNames; + final List customEmoji; + final double emojiSize; + final bool hasMentionHandler; + final List> tags; + final bool hasMediaReply; + final bool hasMediaMore; + + _InlineComponentInputs({ + required this.content, + required this.finalContent, + required Map mentionNames, + required Map> bindings, + required Set agentPubkeys, + required Map channelNames, + required List customEmoji, + required this.emojiSize, + required this.hasMentionHandler, + required List> tags, + required this.hasMediaReply, + required this.hasMediaMore, + }) : tags = List.unmodifiable( + tags.map((tag) => List.unmodifiable(tag)), + ), + mentionNames = Map.unmodifiable(mentionNames), + bindings = Map.unmodifiable({ + for (final entry in bindings.entries) + entry.key: Set.unmodifiable(entry.value), + }), + agentPubkeys = Set.unmodifiable(agentPubkeys), + channelNames = Map.unmodifiable(channelNames), + customEmoji = List.unmodifiable(customEmoji); + + @override + bool operator ==(Object other) => + other is _InlineComponentInputs && + content == other.content && + finalContent == other.finalContent && + emojiSize == other.emojiSize && + hasMentionHandler == other.hasMentionHandler && + hasMediaReply == other.hasMediaReply && + hasMediaMore == other.hasMediaMore && + tags.length == other.tags.length && + tags.indexed.every( + (entry) => listEquals(entry.$2, other.tags[entry.$1]), + ) && + mapEquals(mentionNames, other.mentionNames) && + mapEquals(channelNames, other.channelNames) && + setEquals(agentPubkeys, other.agentPubkeys) && + listEquals(customEmoji, other.customEmoji) && + bindings.length == other.bindings.length && + bindings.entries.every( + (entry) => setEquals(entry.value, other.bindings[entry.key]), + ); + + @override + int get hashCode => + Object.hash(content, finalContent, emojiSize, hasMentionHandler); +} 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/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 93621f7608..cedda77005 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -236,7 +236,9 @@ class ProfileNotifier extends AsyncNotifier { ownerPubkey: verifiedOaOwnerPubkey(submittedEvent.tags, pubkey), ); state = AsyncData(profile); - ref.read(userCacheProvider.notifier).put(profile); + // Record the confirmed event order with the profile so an older batch + // completing after this save cannot replace it. + ref.read(userCacheProvider.notifier).cacheProfileEvent(submittedEvent); } void _requireCurrentWriteContext(_ProfileWriteContext context) { 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/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart index 8560846b0e..bcb140a44f 100644 --- a/mobile/lib/features/settings/settings_page/notifications_section.dart +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -10,6 +10,20 @@ class _NotificationsSection extends ConsumerWidget { } final community = ref.watch(activeCommunityProvider).value; if (community == null) return const SizedBox.shrink(); + if (!Env.pushGatewayConfigured) { + return AppListCard( + label: 'Notifications', + verticalPadding: Grid.twelve, + children: [ + AppListRow( + key: const ValueKey('push-notifications-unavailable'), + icon: LucideIcons.bell, + title: 'Push notifications', + subtitle: 'Unavailable in this build', + ), + ], + ); + } final authorization = ref.watch(buzzPushAuthorizationStatusProvider); final status = authorization.value; final permissionUnavailable = authorization.hasError; diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 8deb39a0b6..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 = {}; @@ -549,7 +423,7 @@ class CommunityListNotifier extends AsyncNotifier> { }); } - Future markPushLeaseAccepted( + Future markPushLeaseAccepted( String id, { required List subscriptions, required int generation, @@ -557,14 +431,20 @@ class CommunityListNotifier extends AsyncNotifier> { final storage = ref.read(communityStorageProvider); final current = state.value ?? await storage.loadAll(); final index = current.indexWhere((community) => community.id == id); - if (index < 0) return; + if (index < 0) return false; final community = current[index]; final acceptedGeneration = community.pushSubscriptionState.acceptedGeneration ?? 0; final generationCursor = community.pushSubscriptionState.generationCursor ?? 0; - if (generation < max(acceptedGeneration, generationCursor)) return; + if (generation < max(acceptedGeneration, generationCursor)) return false; + if (buzzPushSubscriptionsFingerprint( + community.pushSubscriptionState.desired, + ) != + buzzPushSubscriptionsFingerprint(subscriptions)) { + return false; + } final updated = community.copyWith( pushSubscriptionState: community.pushSubscriptionState.withAccepted( subscriptions: subscriptions, @@ -575,6 +455,7 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]..[index] = updated; state = AsyncData(updatedList); await syncCommunitySnapshot(ref, updatedList); + return true; }); Future setPushNotificationsEnabled(String id, bool enabled) async { @@ -616,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/profile/profile_event_parser.dart b/mobile/lib/shared/profile/profile_event_parser.dart new file mode 100644 index 0000000000..eec12bf9a1 --- /dev/null +++ b/mobile/lib/shared/profile/profile_event_parser.dart @@ -0,0 +1,53 @@ +import 'dart:isolate'; + +import '../crypto/nip_oa.dart'; +import '../relay/nostr_models.dart'; +import 'user_profile.dart'; + +/// A verified profile together with its NIP-01 replacement order. +typedef ParsedProfileEvent = ({ + UserProfile profile, + int createdAt, + String eventId, +}); + +/// Parses and verifies profile history without blocking the UI isolate. +/// +/// Capture only the event batch here, never a provider or its surrounding +/// context. NIP-OA verification performs synchronous elliptic-curve work. +Future> parseProfileEventBatch( + List events, +) => Isolate.run(() => _parseBatch(events)); + +List _parseBatch(List events) { + final latest = {}; + for (final event in events) { + if (event.kind != 0) continue; + final pubkey = event.pubkey.toLowerCase(); + final current = latest[pubkey]; + if (current == null || + event.createdAt > current.createdAt || + (event.createdAt == current.createdAt && + event.id.compareTo(current.id) < 0)) { + latest[pubkey] = event; + } + } + return [for (final event in latest.values) parseProfileEvent(event)]; +} + +/// Parses a single live profile, including its verified owner attribution. +ParsedProfileEvent parseProfileEvent(NostrEvent event) { + final data = ProfileData.fromEvent(event); + return ( + profile: UserProfile( + pubkey: data.pubkey.toLowerCase(), + displayName: data.displayName, + avatarUrl: data.avatarUrl, + about: data.about, + nip05Handle: data.nip05, + ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey), + ), + createdAt: event.createdAt, + eventId: event.id, + ); +} diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index c974b4a055..4a034315d0 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -3,26 +3,38 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../community/community_provider.dart'; -import '../crypto/nip_oa.dart'; import '../push/push_presentation_cache.dart'; +import '../push/push_presentation_export_recovery.dart'; import '../relay/relay.dart'; import 'user_profile.dart'; +import 'profile_event_parser.dart'; /// In-memory cache of user profiles, fetched in batches from the relay. /// /// Lookups requested via [get] or [preload] are coalesced into a single -/// kind:0 batch query (NIP-01 `authors` filter) every 50ms. +/// kind:0 batch query (NIP-01 `authors` filter) every 50ms. Larger requests +/// drain sequentially in pages bounded by the relay response limit. class UserCacheNotifier extends Notifier> { + // The relay clamps each history response to DEFAULT_MAX_PAGE_LIMIT (1000). + static const _maximumProfilesPerQuery = 1000; + final Set _pending = {}; + final _pushExport = PushPresentationExportRecovery(); final Map _profileEventOrders = {}; + int _generation = 0; + bool _flushInFlight = false; + Future _verificationQueue = Future.value(); Timer? _batchTimer; Completer? _batchCompleter; @override Map build() { ref.watch(relayConfigProvider); + _generation++; _profileEventOrders.clear(); ref.onDispose(() { + _generation++; + _pending.clear(); _batchTimer?.cancel(); _batchTimer = null; _batchCompleter?.complete(false); @@ -57,7 +69,7 @@ class UserCacheNotifier extends Notifier> { if (uncached.isEmpty && !alreadyPending) return Future.value(true); _pending.addAll(uncached); final completer = _batchCompleter ??= Completer(); - _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); + _scheduleBatch(); return completer.future; } @@ -72,22 +84,17 @@ class UserCacheNotifier extends Notifier> { .toSet() .toList(); if (normalized.isEmpty) return true; + final generation = _generation; try { final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.profilesBatch(normalized), - ); - final updated = Map.from(state); - final updatedOrders = Map.from( - _profileEventOrders, - ); - for (final event in events) { - _cacheProfileEvent(event, updated, updatedOrders); + for (final batch in _profileQueryBatches(normalized)) { + if (!_isCurrent(generation)) return false; + final events = await session.fetchHistory( + NostrFilters.profilesBatch(batch), + ); + if (!_isCurrent(generation)) return false; + if (!await _verifyAndMerge(events, generation)) return false; } - _profileEventOrders - ..clear() - ..addAll(updatedOrders); - state = updated; return true; } catch (_) { return false; @@ -108,7 +115,7 @@ class UserCacheNotifier extends Notifier> { if (state.containsKey(pubkey) || _pending.contains(pubkey)) return; _pending.add(pubkey); _batchCompleter ??= Completer(); - _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); + _scheduleBatch(); } Future _flushPending() async { @@ -120,72 +127,138 @@ class UserCacheNotifier extends Notifier> { final completer = _batchCompleter; _batchCompleter = null; + _flushInFlight = true; + final generation = _generation; var succeeded = false; try { final communityID = ref.read(activeCommunityProvider).value?.id; final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.profilesBatch(pubkeys), - ); - - final updated = Map.from(state); - final updatedOrders = Map.from( - _profileEventOrders, - ); - for (final event in events) { - _cacheProfileEvent(event, updated, updatedOrders); + var remaining = pubkeys.length; + for (final batch in _profileQueryBatches(pubkeys)) { + if (!_isCurrent(generation)) return; + final events = await session.fetchHistory( + NostrFilters.profilesBatch(batch), + ); + if (!_isCurrent(generation)) return; + if (!await _verifyAndMerge(events, generation)) return; + remaining -= batch.length; + if (remaining == 0) { + // All requested pages are ready. Native export does not gate readers. + succeeded = true; + completer?.complete(true); + } + if (communityID != null) { + // Drain each page before fetching another, retaining at most one raw + // response while later requests coalesce as pubkeys in _pending. + await _pushExport.export( + () => cacheBuzzPushProfileEvents(communityID, events), + ); + } } - - _profileEventOrders - ..clear() - ..addAll(updatedOrders); - state = updated; - if (communityID != null) { - unawaited(cacheBuzzPushProfileEvents(communityID, events)); - } - succeeded = true; } catch (_) { // Silently fail — non-gating callers will just show pubkeys. } finally { - completer?.complete(succeeded); + if (completer != null && !completer.isCompleted) { + completer.complete(succeeded); + } + _flushInFlight = false; + if (ref.mounted && _pending.isNotEmpty) _scheduleBatch(); } } - bool _cacheProfileEvent( - NostrEvent event, - Map profiles, [ - Map? orders, - ]) { - if (event.kind != 0) return false; - final eventOrders = orders ?? _profileEventOrders; - final pubkey = event.pubkey.toLowerCase(); - final current = eventOrders[pubkey]; - final isNewer = - current == null || - event.createdAt > current.createdAt || - (event.createdAt == current.createdAt && - event.id.compareTo(current.eventId) < 0); - if (!isNewer) return false; + Iterable> _profileQueryBatches(List pubkeys) sync* { + for ( + var start = 0; + start < pubkeys.length; + start += _maximumProfilesPerQuery + ) { + final end = start + _maximumProfilesPerQuery; + yield pubkeys.sublist(start, end < pubkeys.length ? end : pubkeys.length); + } + } - profiles[pubkey] = _profileFromEvent(event); - eventOrders[pubkey] = (createdAt: event.createdAt, eventId: event.id); + void _scheduleBatch() { + if (_flushInFlight) return; + _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); + } + + Future _verifyAndMerge(List events, int generation) { + // Both refresh and preload share one worker slot. Keep this queue across + // community changes so an old worker cannot overlap a new community's job. + final result = _verificationQueue.then((_) async { + if (!_isCurrent(generation)) return false; + // Match live-cache admission before parsing. In particular, a malformed + // stale profile must not fail a refresh that previously ignored it. + final pending = [ + for (final event in events) + if (event.kind == 0 && + _isNewer(event.pubkey.toLowerCase(), event.createdAt, event.id)) + event, + ]; + if (pending.isEmpty) return true; + final profiles = await ref.read(profileEventBatchParserProvider)(pending); + if (!_isCurrent(generation)) return false; + _mergeParsedProfiles(profiles); + return true; + }); + // Callers receive the original error; only the sequencing tail recovers so + // one rejected batch does not poison all future refreshes. + _verificationQueue = result.then( + (_) {}, + onError: (Object error, StackTrace stack) {}, + ); + return result; + } + + bool _isCurrent(int generation) => ref.mounted && generation == _generation; + + void _mergeParsedProfiles(List profiles) { + // Read the current state after the isolate completes: live events and other + // batches may have installed newer profiles while this batch was parsing. + final updated = Map.from(state); + var changed = false; + for (final parsed in profiles) { + if (!_isNewer(parsed.profile.pubkey, parsed.createdAt, parsed.eventId)) { + continue; + } + updated[parsed.profile.pubkey] = parsed.profile; + _profileEventOrders[parsed.profile.pubkey] = ( + createdAt: parsed.createdAt, + eventId: parsed.eventId, + ); + changed = true; + } + if (changed) state = updated; + } + + bool _isNewer(String pubkey, int createdAt, String eventId) { + final current = _profileEventOrders[pubkey]; + return current == null || + createdAt > current.createdAt || + (createdAt == current.createdAt && + eventId.compareTo(current.eventId) < 0); + } + + bool _cacheProfileEvent(NostrEvent event, Map profiles) { + if (event.kind != 0) return false; + final pubkey = event.pubkey.toLowerCase(); + if (!_isNewer(pubkey, event.createdAt, event.id)) return false; + profiles[pubkey] = parseProfileEvent(event).profile; + _profileEventOrders[pubkey] = ( + createdAt: event.createdAt, + eventId: event.id, + ); return true; } - - UserProfile _profileFromEvent(NostrEvent event) { - final data = ProfileData.fromEvent(event); - final pubkey = data.pubkey.toLowerCase(); - return UserProfile( - pubkey: pubkey, - displayName: data.displayName, - avatarUrl: data.avatarUrl, - about: data.about, - nip05Handle: data.nip05, - ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey), - ); - } } +/// Profile history parser, isolated from provider state so batch completion can +/// be delayed independently of relay delivery. +final profileEventBatchParserProvider = + Provider> Function(List)>( + (ref) => parseProfileEventBatch, + ); + final userCacheProvider = NotifierProvider>( UserCacheNotifier.new, diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 0e86745f92..847668fefe 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -88,17 +88,65 @@ String buzzPushPublicationAttemptKey({ bool buzzPushLifecycleEnabled({ required Community? community, required BuzzPushLeaseDescriptor? descriptor, -}) => community?.pushNotificationsEnabled == true && descriptor != null; +}) => + Env.pushGatewayConfigured && + community?.pushNotificationsEnabled == true && + descriptor != null; + +/// Starts APNs registration when the active community opts in. +@visibleForTesting +class BuzzPushRegistrationBootstrap extends HookWidget { + const BuzzPushRegistrationBootstrap({ + required this.shouldRegister, + required this.attemptKey, + required this.child, + this.startRegistration = startBuzzPushRegistration, + super.key, + }); + + final bool shouldRegister; + final String attemptKey; + final Widget child; + final Future Function() startRegistration; + + @override + Widget build(BuildContext context) { + final attemptGate = useMemoized(BuzzPushAttemptGate.new); + final retry = useState(0); + useEffect(() => attemptGate.dispose, const []); + useEffect(() { + if (!shouldRegister || !attemptGate.tryBegin(attemptKey)) return null; + unawaited(() async { + try { + await startRegistration(); + } catch (error, stack) { + attemptGate.failed( + attemptKey, + retry: () { + if (context.mounted) retry.value += 1; + }, + ); + debugPrint('Push registration bootstrap failed: $error'); + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, [shouldRegister, attemptKey, retry.value]); + return child; + } +} @visibleForTesting Future publishBuzzPushLeaseRecoverably({ required Future Function() reserveGeneration, required Future Function(int generation) publish, - required Future Function(int generation) markAccepted, + required Future Function(int generation) markAccepted, }) async { final generation = await reserveGeneration(); await publish(generation); - await markAccepted(generation); + if (!await markAccepted(generation)) { + throw StateError('A newer push lease superseded the published generation.'); + } return generation; } @@ -112,10 +160,8 @@ class BuzzPushBootstrap extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { useListenable(apnsDeviceToken); - final registrationAttempt = useMemoized(BuzzPushAttemptGate.new); final publicationAttempt = useMemoized(BuzzPushAttemptGate.new); final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new); - final registrationRetry = useState(0); final publicationRetry = useState(0); final tombstoneRetry = useState(0); final revocationOutbox = ref.watch(buzzPushLeaseRevocationOutboxProvider); @@ -143,7 +189,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { useEffect( () => () { - registrationAttempt.dispose(); publicationAttempt.dispose(); tombstoneAttempt.dispose(); }, @@ -209,48 +254,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { ], ); - useEffect( - () { - if (!_ready(session, config, community, memberPubkey) || - !buzzPushLifecycleEnabled( - community: community, - descriptor: descriptor, - )) { - return null; - } - final activeCommunity = community!; - final activeDescriptor = descriptor!; - final attempt = '${activeCommunity.id}|${config.baseUrl}'; - if (!registrationAttempt.tryBegin(attempt)) return null; - unawaited(() async { - try { - await startBuzzPushRegistrationIfCapable( - activeDescriptor, - startRegistration: startBuzzPushRegistration, - ); - } catch (error, stack) { - registrationAttempt.failed( - attempt, - retry: () { - if (context.mounted) registrationRetry.value += 1; - }, - ); - debugPrint('Push registration bootstrap failed: $error'); - debugPrintStack(stackTrace: stack); - } - }()); - return null; - }, - [ - session.status, - config.baseUrl, - community?.id, - memberPubkey, - descriptor, - registrationRetry.value, - ], - ); - final token = apnsDeviceToken.value; useEffect( () { @@ -319,6 +322,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { session.status, config.baseUrl, community?.id, + community?.pushNotificationsEnabled, community?.pushSubscriptionState, memberPubkey, descriptor, @@ -327,7 +331,16 @@ class BuzzPushBootstrap extends HookConsumerWidget { ], ); - return child; + return BuzzPushRegistrationBootstrap( + shouldRegister: + _ready(session, config, community, memberPubkey) && + buzzPushLifecycleEnabled( + community: community, + descriptor: descriptor, + ), + attemptKey: '${community?.id}|${config.baseUrl}', + child: child, + ); } static bool _ready( diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 6f91e5625d..6500301162 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -140,7 +140,10 @@ Future syncPendingBuzzPushNotificationResponse() async { /// requests. Display authorization is intentionally not returned or persisted: /// APNs registration and enrollment remain valid while display is denied. Future startBuzzPushRegistration() async { - if (defaultTargetPlatform != TargetPlatform.iOS) return; + if (!Env.pushGatewayConfigured || + defaultTargetPlatform != TargetPlatform.iOS) { + return; + } try { await _channel.invokeMethod('startRegistration'); } on MissingPluginException { @@ -188,9 +191,14 @@ class BuzzPushEndpointGrant { } Future> readBuzzPushEndpointGrants() async { - if (defaultTargetPlatform != TargetPlatform.iOS) return const []; + if (!Env.pushGatewayConfigured || + defaultTargetPlatform != TargetPlatform.iOS) { + return const []; + } try { - final raw = await _channel.invokeListMethod('endpointGrants'); + final raw = await _channel.invokeListMethod('endpointGrants', { + 'gatewayUrl': Env.pushGatewayUrl, + }); final grants = [ for (final value in raw ?? const []) BuzzPushEndpointGrant.fromMap(value as Map), @@ -265,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/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart index 82cc78a89d..99af19d864 100644 --- a/mobile/lib/shared/push/push_presentation_cache.dart +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -11,6 +11,19 @@ const _pushPresentationChannel = MethodChannel('buzz/push'); const _maximumAvatarSourceBytes = 512 * 1024; const _maximumAvatarPNGBytes = 64 * 1024; Future _avatarEncodeTail = Future.value(); +Future _presentationExportTail = Future.value(); +const _maximumPresentationExports = 8; +// Match BuzzPushPresentationCacheStore's per-write admission limits. +const _maximumProfilesPerWrite = 256; +const _maximumChannelsPerWrite = 512; +int _outstandingPresentationExports = 0; + +/// Temporary admission failure; detached producers must retain and retry input. +class PushPresentationExportQueueFull extends StateError { + /// Creates a saturation error for the shared eight-export budget. + PushPresentationExportQueueFull() + : super('Push presentation export queue is full (8 outstanding exports)'); +} /// The latest best-effort App Group presentation-cache failure. final pushPresentationCacheError = ValueNotifier(null); @@ -34,6 +47,9 @@ bool isVerifiedPushPresentationEvent(NostrEvent event) { } /// Exports raw verified kind-0 events. Native code verifies them again before storage. +/// Fails with [StateError] when eight exports are already outstanding. +/// Native persistence failures retry with bounded backoff, then propagate and +/// stop any remaining chunks. Other native errors propagate immediately. Future cacheBuzzPushProfileEvents( String communityID, Iterable events, @@ -41,20 +57,30 @@ Future cacheBuzzPushProfileEvents( if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { return; } - final verified = _newestVerifiedEvents( - events, - kind: 0, - scope: (event) => event.pubkey.toLowerCase(), - ).values.toList(); - if (verified.isEmpty) return; - await _invokeBestEffort({ - 'section': 'profiles', - 'communityId': communityID, - 'events': [for (final event in verified) event.toJson()], + final batch = events.toList(growable: false); + if (batch.isEmpty) return; + await _serializePresentationExport(() async { + final verified = await compute( + _selectPushProfileEvents, + batch, + debugLabel: 'buzz-push-profile-cache', + ); + if (verified.isEmpty) return; + final retryBudget = _NativeWriteRetryBudget(); + for (final chunk in _boundedChunks(verified, _maximumProfilesPerWrite)) { + await _invokeVerifiedChunk({ + 'section': 'profiles', + 'communityId': communityID, + 'events': [for (final event in chunk) event.toJson()], + }, retryBudget); + } }); } /// Exports verified channel metadata and membership for native authority checks. +/// Fails with [StateError] when eight exports are already outstanding. +/// Native persistence failures retry with bounded backoff, then propagate and +/// stop any remaining chunks. Other native errors propagate immediately. Future cacheBuzzPushChannelEvents( String? communityID, Iterable metadataEvents, @@ -65,20 +91,89 @@ Future cacheBuzzPushChannelEvents( communityID.isEmpty) { return; } - final batch = selectPushChannelEvents(metadataEvents, membershipEvents); - final verifiedMetadata = batch.metadata; - final verifiedMembership = batch.membership; - if (verifiedMetadata.isEmpty && verifiedMembership.isEmpty) return; - await _invokeBestEffort({ - 'section': 'channels', - 'communityId': communityID, - 'metadataEvents': [for (final event in verifiedMetadata) event.toJson()], - 'membershipEvents': [ - for (final event in verifiedMembership) event.toJson(), - ], + final batch = ( + metadata: metadataEvents.toList(growable: false), + membership: membershipEvents.toList(growable: false), + ); + if (batch.metadata.isEmpty && batch.membership.isEmpty) return; + await _serializePresentationExport(() async { + final verified = await compute( + _selectPushChannelBatch, + batch, + debugLabel: 'buzz-push-channel-cache', + ); + if (verified.metadata.isEmpty && verified.membership.isEmpty) return; + final metadata = { + for (final event in verified.metadata) event.getTagValue('d')!: event, + }; + final membership = { + for (final event in verified.membership) event.getTagValue('d')!: event, + }; + final channelIDs = {...metadata.keys, ...membership.keys}.toList(); + final retryBudget = _NativeWriteRetryBudget(); + // Keep each channel's metadata and membership in the same native write. + // All chunks retain this export's FIFO slot until handoff is complete. + for (final ids in _boundedChunks(channelIDs, _maximumChannelsPerWrite)) { + await _invokeVerifiedChunk({ + 'section': 'channels', + 'communityId': communityID, + 'metadataEvents': [ + for (final id in ids) + if (metadata[id] case final event?) event.toJson(), + ], + 'membershipEvents': [ + for (final id in ids) + if (membership[id] case final event?) event.toJson(), + ], + }, retryBudget); + } }); } +Iterable> _boundedChunks(List values, int maximum) sync* { + for (var start = 0; start < values.length; start += maximum) { + final end = start + maximum; + yield values.sublist(start, end < values.length ? end : values.length); + } +} + +// Share one worker slot across profile/channel exports and retain FIFO native +// handoff, even across community changes. The two producers are coalesced profile +// fetches and channel refreshes. Eight outstanding batches allow a short burst +// while bounding retained batch count; individual batch sizes remain caller-owned. +// Saturation fails explicitly rather than acknowledging an export we cannot retain. +Future _serializePresentationExport( + Future Function() export, +) async { + if (_outstandingPresentationExports >= _maximumPresentationExports) { + throw PushPresentationExportQueueFull(); + } + _outstandingPresentationExports++; + final previous = _presentationExportTail; + final release = Completer(); + _presentationExportTail = release.future; + await previous; + try { + await export(); + } finally { + // Keep the queue usable while propagating a worker failure to its caller. + _outstandingPresentationExports--; + release.complete(); + } +} + +List _selectPushProfileEvents(List events) => + _newestVerifiedEvents( + events, + kind: 0, + scope: (event) => event.pubkey.toLowerCase(), + ).values.toList(); + +({List metadata, List membership}) +_selectPushChannelBatch( + ({List metadata, List membership}) batch, +) => selectPushChannelEvents(batch.metadata, batch.membership); + /// Selects the newest paired verified channel metadata and membership events. @visibleForTesting ({List metadata, List membership}) @@ -162,18 +257,52 @@ Future cacheBuzzPushAvatarFromLoadedBytes( try { final png = await _boundedAvatarPNG(sourceBytes); if (png == null) return; - await _invokeBestEffort({ + await _invokeSnapshot({ 'section': 'avatar', 'communityId': communityID, 'sourceUrl': sourceURL, 'png': png, - }); + }, bestEffort: true); } finally { release.complete(); } } -Future _invokeBestEffort(Map arguments) async { +// One bounded backoff budget for the entire export, not one per chunk. +class _NativeWriteRetryBudget { + static const _delays = [250, 500, 1000, 2000, 4000]; + int _used = 0; + + Duration? takeDelay() => + _used == _delays.length ? null : Duration(milliseconds: _delays[_used++]); +} + +// Retry only native persistence failures, retaining the verified payload and +// the surrounding export's FIFO slot. Never repeat relay reads or verification. +Future _invokeVerifiedChunk( + Map arguments, + _NativeWriteRetryBudget budget, +) async { + final retryableCode = arguments['section'] == 'profiles' + ? 'profile_cache_failed' + : 'channel_cache_failed'; + while (true) { + try { + await _invokeSnapshot(arguments); + return; + } on PlatformException catch (error) { + if (error.code != retryableCode) rethrow; + final delay = budget.takeDelay(); + if (delay == null) rethrow; + await Future.delayed(delay); + } + } +} + +Future _invokeSnapshot( + Map arguments, { + bool bestEffort = false, +}) async { try { await _pushPresentationChannel.invokeMethod( 'syncPushSnapshot', @@ -186,6 +315,9 @@ Future _invokeBestEffort(Map arguments) async { pushPresentationCacheError.value = error.toString(); debugPrint('Push presentation cache update failed: $error'); debugPrintStack(stackTrace: stackTrace); + // Event exports must stop at the failed chunk and report failure to their + // owner. Avatar updates preserve their existing detached best-effort policy. + if (!bestEffort) rethrow; } } diff --git a/mobile/lib/shared/push/push_presentation_export_recovery.dart b/mobile/lib/shared/push/push_presentation_export_recovery.dart new file mode 100644 index 0000000000..764697c39c --- /dev/null +++ b/mobile/lib/shared/push/push_presentation_export_recovery.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; + +import 'push_presentation_cache.dart'; + +/// Latest terminal export failure, retained across unrelated successful writes. +/// +/// Unlike [pushPresentationCacheError], this records an export that could not +/// reach native storage. Retries are bounded and do not guarantee delivery. +final pushPresentationExportError = ValueNotifier(null); + +/// Retries one export while its producer applies backpressure to new input. +/// +/// Queue saturation retries the exact operation five times over 7.75 seconds. +/// Exhausted retries or a worker failure return false and record a terminal +/// error. Producers must serialize calls and retain dirty input upstream; this +/// helper does not queue, replace, or merge unverified event batches. +class PushPresentationExportRecovery { + /// Exports once, recovering transient saturation without detached errors. + Future export(Future Function() operation) async { + try { + await operation(); + return true; + } on PushPresentationExportQueueFull { + // The producer retains this operation until recovery completes. + } catch (error, stack) { + return _failed(error, stack); + } + + for (final milliseconds in [250, 500, 1000, 2000, 4000]) { + await Future.delayed(Duration(milliseconds: milliseconds)); + try { + await operation(); + return true; + } on PushPresentationExportQueueFull catch (error, stack) { + if (milliseconds == 4000) return _failed(error, stack); + } catch (error, stack) { + return _failed(error, stack); + } + } + return false; + } + + bool _failed(Object error, StackTrace stack) { + pushPresentationExportError.value = error.toString(); + debugPrint('Push presentation export could not be delivered: $error'); + debugPrintStack(stackTrace: stack); + return false; + } +} diff --git a/mobile/lib/shared/push/push_relay_capability_provider.dart b/mobile/lib/shared/push/push_relay_capability_provider.dart index f2e8eebd8b..bcfaf06fa4 100644 --- a/mobile/lib/shared/push/push_relay_capability_provider.dart +++ b/mobile/lib/shared/push/push_relay_capability_provider.dart @@ -15,11 +15,13 @@ final buzzPushDescriptorFetcherProvider = Provider( /// The fully validated push capability advertised by the current relay. /// +/// An unconfigured artifact has no capability and never starts discovery. /// Discovery fails closed. An absent, malformed, or unreachable NIP-11 push /// descriptor is represented as no capability, so no notification permission, /// APNs registration, gateway enrollment, or relay lease can begin. final currentRelayPushDescriptorProvider = FutureProvider.autoDispose((ref) async { + if (!Env.pushGatewayConfigured) return null; final session = ref.watch(relaySessionProvider); final config = ref.watch(relayConfigProvider); final community = ref.watch(activeCommunityProvider).value; diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 00fcf65b71..73643ff020 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -71,10 +71,12 @@ class Env { 'BUZZ_RELAY_URL', defaultValue: 'http://localhost:3000', ); - static const pushGatewayUrl = String.fromEnvironment( - 'BUZZ_PUSH_GATEWAY_URL', - defaultValue: 'https://push.buzz.xyz', - ); + + /// Optional gateway origin. Without it this artifact has no push capability. + static const pushGatewayUrl = String.fromEnvironment('BUZZ_PUSH_GATEWAY_URL'); + + /// Whether this artifact can offer push notification enrollment. + static const pushGatewayConfigured = pushGatewayUrl != ''; } class RelayConfigNotifier extends Notifier { 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/scripts/require-push-gateway-origin.sh b/mobile/scripts/require-push-gateway-origin.sh new file mode 100644 index 0000000000..d473bb1466 --- /dev/null +++ b/mobile/scripts/require-push-gateway-origin.sh @@ -0,0 +1,55 @@ +#!/bin/sh +set -eu + +gateway_origin= +has_dart_define=false +old_ifs=$IFS +IFS=',' +for encoded in ${DART_DEFINES:-}; do + decoded=$(printf '%s' "$encoded" | base64 --decode 2>/dev/null || printf '%s' "$encoded" | base64 -D 2>/dev/null || true) + case "$decoded" in + BUZZ_PUSH_GATEWAY_URL=*) + if [ "$has_dart_define" = true ]; then + echo "error: BUZZ_PUSH_GATEWAY_URL must be supplied at most once." >&2 + exit 1 + fi + has_dart_define=true + gateway_origin=${decoded#BUZZ_PUSH_GATEWAY_URL=} + ;; + esac +done +IFS=$old_ifs + +if [ "$has_dart_define" = false ] && [ -n "${BUZZ_PUSH_GATEWAY_URL:-}" ]; then + gateway_origin=$BUZZ_PUSH_GATEWAY_URL + encoded=$(printf '%s' "BUZZ_PUSH_GATEWAY_URL=$gateway_origin" | base64 | tr -d '\n') + DART_DEFINES=${DART_DEFINES:+$DART_DEFINES,}$encoded + export DART_DEFINES +fi + +if [ "$has_dart_define" = false ] && [ -z "$gateway_origin" ]; then + # An unconfigured artifact deliberately has no push capability. + return 0 2>/dev/null || exit 0 +fi + +if [ -n "${SRCROOT:-}" ]; then + script_dir=$SRCROOT/../scripts +else + script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +fi +dart_bin=${FLUTTER_ROOT:+$FLUTTER_ROOT/bin/dart} +if [ -z "$dart_bin" ] || [ ! -x "$dart_bin" ]; then + dart_bin=$(command -v dart || true) +fi +if [ -z "$dart_bin" ]; then + echo "error: Dart is required to validate BUZZ_PUSH_GATEWAY_URL." >&2 + exit 1 +fi +case ${CONFIGURATION:-Release} in + Debug*) + "$dart_bin" "$script_dir/validate_push_gateway_origin.dart" "$gateway_origin" + ;; + *) + "$dart_bin" "$script_dir/validate_push_gateway_origin.dart" --require-https "$gateway_origin" + ;; +esac diff --git a/mobile/scripts/validate_push_gateway_origin.dart b/mobile/scripts/validate_push_gateway_origin.dart new file mode 100644 index 0000000000..97a8ed7aa4 --- /dev/null +++ b/mobile/scripts/validate_push_gateway_origin.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +bool isValidPushGatewayOrigin(String value, {bool requireHttps = false}) { + try { + final uri = Uri.parse(value); + if (requireHttps + ? uri.scheme != 'https' + : uri.scheme != 'http' && uri.scheme != 'https') { + return false; + } + if (!uri.hasAuthority || uri.host.isEmpty || uri.userInfo.isNotEmpty) { + return false; + } + if (uri.path.isNotEmpty && uri.path != '/') return false; + if (uri.hasQuery || uri.hasFragment) return false; + if (requireHttps && uri.hasPort) return false; + final port = uri.port; + return port >= 1 && port <= 65535; + } on FormatException { + return false; + } +} + +void main(List arguments) { + final requireHttps = + arguments.isNotEmpty && arguments.first == '--require-https'; + final values = requireHttps ? arguments.skip(1).toList() : arguments; + if (values.length == 1 && + isValidPushGatewayOrigin(values.single, requireHttps: requireHttps)) { + return; + } + final requirement = requireHttps + ? 'an HTTPS origin without an explicit port, credentials, path, query, or fragment' + : 'an HTTP(S) origin without credentials, path, query, or fragment'; + stderr.writeln('error: BUZZ_PUSH_GATEWAY_URL must be $requirement.'); + exitCode = 1; +} diff --git a/mobile/test/benchmark/README.md b/mobile/test/benchmark/README.md new file mode 100644 index 0000000000..8b9dad3788 --- /dev/null +++ b/mobile/test/benchmark/README.md @@ -0,0 +1,30 @@ +# Mobile performance diagnostics + +With the repository's Hermit environment active, run from `mobile/`: + +```sh +flutter test --dart-define=BUZZ_RUN_BENCHMARKS=true test/benchmark/channel_live_burst_test.dart +``` + +The diagnostic is skipped by default. It does not use a relay, credentials, or +community content. + +The buffered-channel diagnostic sends synthetic events through the real +`RelaySessionNotifier` event buffer and `ChannelMessagesNotifier` live callback. +Only connection state and history responses are replaced. It measures one +synchronous flush and a zero-delay timer queued just before that flush. The timer +shows how long other event-loop work waits behind the consumer callbacks. + +It varies retained history (50, 500, 2000 events) and live burst size (50, 200, +1000 events). Each case warms up twice before emitting four JSON measurement +records in microseconds. Compare medians on the same host under similar load. +No wall-clock threshold is asserted because debug/JIT compilation and host load +make it unsuitable for a stable CI performance gate. Final event counts are +asserted to ensure the measured work actually completes. + +This is a bounded stress diagnostic, not a representative release benchmark. +Retained history is modeled as one synthetic page instead of real pagination. +It excludes widgets, media, network traffic, signature verification, and other +subscriptions. It can identify synchronous consumer costs, but cannot establish +that a measured burst occurred in a real community or predict release frame +latency. diff --git a/mobile/test/benchmark/channel_live_burst_test.dart b/mobile/test/benchmark/channel_live_burst_test.dart new file mode 100644 index 0000000000..c70890ab8c --- /dev/null +++ b/mobile/test/benchmark/channel_live_burst_test.dart @@ -0,0 +1,134 @@ +// Opt-in synthetic diagnostic; see README.md for invocation and limitations. +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/channels/channel_messages_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + test( + 'buffered live channel delivery', + () async { + for (final historySize in [50, 500, 2000]) { + for (final burstSize in [50, 200, 1000]) { + // Discard the first two runs to reduce debug/JIT warm-up effects. + for (var run = 0; run < 6; run++) { + await _measureBurst(historySize, burstSize, run); + } + } + } + }, + skip: !const bool.fromEnvironment('BUZZ_RUN_BENCHMARKS'), + timeout: const Timeout(Duration(minutes: 3)), + ); +} + +Future _measureBurst(int historySize, int burstSize, int run) async { + final session = _SyntheticSession(historySize); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + try { + final loaded = Completer(); + container.listen(channelMessagesProvider(_channelId), (_, next) { + if (!loaded.isCompleted && next.value?.length == historySize) { + loaded.complete(); + } + }); + await loaded.future.timeout(const Duration(seconds: 5)); + + for (var index = 0; index < burstSize; index++) { + session.debugHandleMessage([ + 'EVENT', + 'l-1', + _event('live-$index', historySize + index).toJson(), + ]); + } + final tick = Completer(); + final watch = Stopwatch()..start(); + Timer.run(() => tick.complete(watch.elapsedMicroseconds)); + session.debugFlushEventBuffer(); + final synchronousUs = watch.elapsedMicroseconds; + final eventLoopDelayUs = await tick.future; + + expect( + container.read(channelMessagesProvider(_channelId)).value!.length, + historySize + burstSize, + ); + if (run >= 2) { + debugPrint( + jsonEncode({ + 'history': historySize, + 'burst': burstSize, + 'run': run - 2, + 'synchronous_us': synchronousUs, + 'event_loop_delay_us': eventLoopDelayUs, + }), + ); + } + } finally { + container.dispose(); + // The original 16 ms buffer timer remains armed after a debug flush. + await Future.delayed(const Duration(milliseconds: 20)); + } +} + +const _channelId = '11111111-1111-4111-8111-111111111111'; + +NostrEvent _event(String id, int createdAt) => NostrEvent( + id: id, + pubkey: 'synthetic-author', + createdAt: createdAt, + kind: EventKind.streamMessageV2, + tags: const [ + ['h', _channelId], + ], + content: 'Synthetic message', + sig: 'synthetic-signature', +); + +class _SyntheticSession extends RelaySessionNotifier { + final int historySize; + + _SyntheticSession(this.historySize); + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) { + // Preserve the real subscription and buffer delivery implementation. This + // fixture creates exactly one subscription, whose generated id is l-1. + final ready = super.subscribe(filter, onEvent, onClosed: onClosed); + debugHandleMessage(['EOSE', 'l-1']); + return ready; + } + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async => [ + // Model retained history in one synthetic page; real windows page by 50. + for (var index = historySize - 1; index >= 0; index--) + _event('history-$index', index), + NostrEvent( + id: 'bounds', + pubkey: 'synthetic-relay', + createdAt: 0, + kind: EventKind.channelWindowBounds, + tags: const [ + ['d', '$_channelId:head'], + ], + content: jsonEncode({'has_more': false, 'next_cursor': null}), + sig: 'synthetic-signature', + ), + ]; +} 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 728505a512..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,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:buzz/app.dart'; import 'package:buzz/features/age_gate/age_restriction_page.dart'; @@ -6,7 +7,13 @@ 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'; @@ -26,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)); @@ -34,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(); @@ -54,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 = []; @@ -87,191 +229,136 @@ 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); }); - 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 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 { + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), + relaySessionProvider.overrideWith(() => relaySession), + ageAllowedNotificationRestorerProvider.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); - - 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); - }); - - 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 {}, + }), + ageSignalPushSnapshotRetryWaitProvider.overrideWithValue( + (_) async {}, ), - ), - suspendCommunitySnapshotForAgeCheckProvider.overrideWithValue( - () async => snapshotSuspensions += 1, - ), - resumeCommunitySnapshotAfterAgeCheckProvider.overrideWithValue( - () async => snapshotRestorations += 1, - ), - savedPrefsProvider.overrideWithValue(prefs), - ], - child: const AgeSignalPushBootstrap(child: App()), - ), - ); - await tester.pump(); - await tester.pump(); + savedPrefsProvider.overrideWithValue(prefs), + ], + child: const AgeSignalPushBootstrap(child: App()), + ), + ); - expect(find.text('Try again'), findsOneWidget); - expect(find.byType(HomePage), findsNothing); - expect(snapshotSuspensions, greaterThanOrEqualTo(1)); - expect(snapshotRestorations, 0); + await tester.pump(); + await tester.pump(); - await tester.tap(find.text('Try again')); - 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, ageGatingEnabled ? 1 : 0); + await tester.pump(); + await tester.pump(); + expect(snapshotRestorations, 2); + }, + ); - expect(requests, 3); - expect(find.text('Try again'), findsNothing); - expect(find.byType(HomePage), findsOneWidget); - expect(snapshotRestorations, 1); - }); + 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)), + ), + 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); - 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', @@ -307,7 +394,7 @@ void main() { overrides: [ ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()), communityListProvider.overrideWith( - () => _RetryingCleanupCommunityListNotifier(), + () => _UnavailableCommunityListNotifier(), ), ageRestrictedNotificationPurgerProvider.overrideWithValue(() async { purges += 1; @@ -339,7 +426,7 @@ void main() { overrides: [ ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()), communityListProvider.overrideWith( - () => _SuccessfulCleanupCommunityListNotifier(), + () => _UnavailableCommunityListNotifier(), ), ageRestrictedNotificationPurgerProvider.overrideWithValue(() async { purges += 1; @@ -389,7 +476,7 @@ class _UnauthenticatedAuthNotifier extends AuthNotifier { class _MutableAgeSignalNotifier extends AgeSignalNotifier { @override - AgeSignalState build() => AgeSignalState.checking; + AgeSignalState build() => AgeSignalState.allowed; @override Future request() async {} @@ -415,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 3846e651a1..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,528 +7,194 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(ageSignalChannel, null); }); - Future requestWithResponse(Object? response) async { + ProviderContainer enabledContainer({AgeSignalNotifier Function()? create}) { + final container = ProviderContainer( + overrides: [ + ageSignalProvider.overrideWith(create ?? AgeSignalNotifier.new), + ], + ); + addTearDown(container.dispose); + return container; + } + + test('production provider respects the explicit dogfood opt-in', () async { + var calls = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(ageSignalChannel, (call) async { - expect(call.method, 'requestAgeSignal'); - expect(call.arguments, isNull); - return response; + .setMockMethodCallHandler(ageSignalChannel, (_) async { + calls += 1; + return {'status': 'signal', 'ageUpper': 17}; }); final container = ProviderContainer(); addTearDown(container.dispose); - await container.read(ageSignalProvider.notifier).request(); - return container.read(ageSignalProvider); - } + expect( + container.read(ageSignalProvider), + ageGatingEnabled ? AgeSignalState.restricted : AgeSignalState.allowed, + ); + expect(calls, ageGatingEnabled ? 1 : 0); + }); - test( - 'notification protection failure stays gated until a successful retry', - () async { - var protected = false; + 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, (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); - - await notifier.request(); - expect( - container.read(ageSignalProvider), - AgeSignalState.retryableFailure, - ); - protected = true; - await notifier.request(); + .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(); - 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/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index 80eefc5b98..9e2d93d2a7 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -11,6 +11,105 @@ import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { + test('live window without deep links does not rescan flattened ids', () async { + var historyIdReads = 0; + final history = _IdReadTrackingEvent( + _event(id: 'history', createdAt: 10), + onIdRead: () => historyIdReads++, + ); + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [history, _bounds()], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + + historyIdReads = 0; + relaySession.emit(_event(id: 'live', createdAt: 20)); + + // One read checks page membership; one builds the flattened window. The + // distinct timestamps need no id tie-break when sorting. A redundant + // deep-link merge would read the historical id a third time to build its + // dedup set. Allow fewer reads if either required pass is optimized later. + expect(historyIdReads, lessThanOrEqualTo(2)); + expect( + container.read(channelMessagesProvider(_channelId)).value!.length, + 2, + ); + }); + + for (final retainDeepLink in [false, true]) { + test( + 'live window keeps chronological order with retained deep link: $retainDeepLink', + () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'newer-history', createdAt: 20), + _event(id: 'older-history', 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, + ); + if (retainDeepLink) { + final load = notifier.loadEventsById(['deep-link']); + relaySession.completeTargetHistory([ + _event(id: 'deep-link', createdAt: 5), + ]); + await load; + } + + // Older live rows must insert in order, including the descending-id + // tie-break within a second, on both sides of the deep-link fast path. + relaySession.emit(_event(id: 'a-live', createdAt: 15)); + relaySession.emit(_event(id: 'z-live', createdAt: 15)); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value! + .map((event) => event.id), + [ + if (retainDeepLink) 'deep-link', + 'older-history', + 'z-live', + 'a-live', + 'newer-history', + ], + ); + + if (retainDeepLink) { + notifier.releaseDeepLinkEvents(['deep-link']); + relaySession.emit(_event(id: 'newest-live', createdAt: 30)); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value! + .map((event) => event.id), + [ + 'older-history', + 'z-live', + 'a-live', + 'newer-history', + 'newest-live', + ], + ); + } + }, + ); + } + test( 'keeps live events that arrive while initial history is loading', () async { @@ -967,6 +1066,27 @@ void main() { const _channelId = '11111111-1111-4111-8111-111111111111'; +class _IdReadTrackingEvent extends NostrEvent { + final void Function() onIdRead; + + _IdReadTrackingEvent(NostrEvent event, {required this.onIdRead}) + : super( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + ); + + @override + String get id { + onIdRead(); + return super.id; + } +} + ProviderContainer _buildContainer(_RecordingRelaySessionNotifier relaySession) { return ProviderContainer( overrides: [relaySessionProvider.overrideWith(() => relaySession)], diff --git a/mobile/test/features/channels/message_content_stability_test.dart b/mobile/test/features/channels/message_content_stability_test.dart new file mode 100644 index 0000000000..3499cc18ca --- /dev/null +++ b/mobile/test/features/channels/message_content_stability_test.dart @@ -0,0 +1,291 @@ +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart'; +import 'package:buzz/shared/emoji/emoji_only.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +class _Fixture { + final revision = ValueNotifier(0); + String content = 'Hello @Alice, visit #sample.'; + Map mentions = {'alice-key': 'Alice'}; + final channels = {'sample': 'channel-one'}; + Set agents = {}; + List> tags = []; + List palette = []; + bool scaleEmoji = false; + bool mentionHandler = true; + bool mediaHandlers = false; + int? tappedRevision; + String? tappedId; + + Widget build() => ProviderScope( + overrides: [customEmojiListProvider.overrideWith((ref) => palette)], + child: MaterialApp( + theme: AppTheme.light(), + home: AppMarkdownTheme( + child: Scaffold( + body: ValueListenableBuilder( + valueListenable: revision, + builder: (_, generation, _) => MessageContent( + content: content, + mentionNames: Map.of(mentions), + channelNames: Map.of(channels), + agentMentionPubkeys: Set.of(agents), + tags: tags, + scaleEmojiOnly: scaleEmoji, + onMediaReply: mediaHandlers + ? () { + tappedRevision = generation; + tappedId = 'reply'; + } + : null, + onMediaMore: mediaHandlers + ? (_, url) { + tappedRevision = generation; + tappedId = url; + } + : null, + onMentionTap: mentionHandler + ? (id) { + tappedRevision = generation; + tappedId = id; + } + : null, + onChannelTap: (id) { + tappedRevision = generation; + tappedId = id; + }, + ), + ), + ), + ), + ), + ); + + Future refresh(WidgetTester tester) async { + revision.value++; + await tester.pump(); + } +} + +List _components(WidgetTester tester) => tester + .widget(find.byType(GptMarkdown).first) + .inlineComponents!; + +void main() { + testWidgets('explicit channel links retain the current callback', ( + tester, + ) async { + const id = '580ca78b-9dae-46f3-8854-bd671853ba32'; + final fixture = _Fixture()..content = '[Open channel](buzz://channel/$id)'; + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + final before = _components(tester); + await fixture.refresh(tester); + expect(_components(tester), same(before)); + await tester.tap(find.text('Open channel', findRichText: true)); + expect(fixture.tappedId, id); + expect(fixture.tappedRevision, fixture.revision.value); + }); + + testWidgets( + 'inline media retains current actions and invalidates metadata and action presence', + (tester) async { + const url = 'https://relay.example/image.png'; + final fixture = _Fixture() + ..content = '![image]($url)\n\nAfter image' + ..mediaHandlers = true + ..tags = [ + ['imeta', 'url $url', 'm image/png', 'alt First label'], + ]; + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + final before = _components(tester); + await fixture.refresh(tester); + expect(_components(tester), same(before)); + await tester.tap( + find.byKey(const ValueKey('message-media-image-preview:$url')), + ); + await tester.pumpAndSettle(); + var viewer = tester.widget( + find.byType(MediaImageViewerPage), + ); + viewer.onReply!(); + expect(fixture.tappedId, 'reply'); + expect(fixture.tappedRevision, fixture.revision.value); + viewer.onMore!(tester.element(find.byType(MediaImageViewerPage)), url); + expect(fixture.tappedId, url); + expect(fixture.tappedRevision, fixture.revision.value); + Navigator.of(tester.element(find.byType(MediaImageViewerPage))).pop(); + await tester.pumpAndSettle(); + fixture.tags[0][3] = 'alt Second label'; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + await tester.tap( + find.byKey(const ValueKey('message-media-image-preview:$url')), + ); + await tester.pumpAndSettle(); + viewer = tester.widget( + find.byType(MediaImageViewerPage), + ); + expect(viewer.semanticLabel, 'Second label'); + Navigator.of(tester.element(find.byType(MediaImageViewerPage))).pop(); + await tester.pumpAndSettle(); + final beforeRemoval = _components(tester); + fixture.mediaHandlers = false; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(beforeRemoval))); + await tester.tap( + find.byKey(const ValueKey('message-media-image-preview:$url')), + ); + await tester.pumpAndSettle(); + viewer = tester.widget( + find.byType(MediaImageViewerPage), + ); + expect(viewer.onReply, isNull); + expect(viewer.onMore, isNull); + }, + ); + + testWidgets( + 'equal presentation retains parser components and current callbacks', + (tester) async { + final fixture = _Fixture(); + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + final before = _components(tester); + for (var i = 0; i < 3; i++) { + await fixture.refresh(tester); + // This is the production dependency seam: gpt_markdown's config.isSame + // compares these component identities before deciding whether to parse. + expect(_components(tester), same(before)); + } + await tester.tap(find.text('Alice')); + expect(fixture.tappedRevision, fixture.revision.value); + expect(fixture.tappedId, 'alice-key'); + await tester.tap(find.text('sample')); + expect(fixture.tappedRevision, fixture.revision.value); + expect(fixture.tappedId, 'channel-one'); + + fixture.channels['sample'] = 'channel-two'; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + await tester.tap(find.text('sample')); + expect(fixture.tappedId, 'channel-two'); + }, + ); + + testWidgets( + 'mention labels, bindings, agent status and handler presence invalidate', + (tester) async { + final fixture = _Fixture(); + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + var before = _components(tester); + fixture.mentions['alice-key'] = 'ALICE'; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect(find.text('ALICE'), findsOneWidget); + before = _components(tester); + fixture.agents.add('alice-key'); + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + before = _components(tester); + fixture.mentionHandler = false; + fixture.tappedId = null; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + await tester.tap(find.text('ALICE')); + expect(fixture.tappedId, isNull); + fixture.mentionHandler = true; + fixture.mentions = {'second-key': 'ALICE'}; + await fixture.refresh(tester); + await tester.tap(find.text('ALICE')); + expect(fixture.tappedId, 'second-key'); + expect(find.byIcon(LucideIcons.bot), findsNothing); + }, + ); + + testWidgets('signed qualified mention changes cannot retain an old binding', ( + tester, + ) async { + final key = 'a' * 64; + final fixture = _Fixture() + ..content = '@Alice ($key)' + ..mentions = {} + ..tags = [ + ['p', key], + ]; + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + final before = _components(tester); + await tester.tap(find.text('Alice (aaaaaaaa…aaaa)')); + expect(fixture.tappedId, key); + fixture.tags = []; + fixture.tappedId = null; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect(find.text('Alice (aaaaaaaa…aaaa)'), findsNothing); + }); + + testWidgets('community emoji palette and emoji-only size invalidate', ( + tester, + ) async { + final fixture = _Fixture() + ..content = ':sample:' + ..palette = [ + const CustomEmoji( + shortcode: 'sample', + url: 'https://relay.example/one.png', + ), + ]; + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + var before = _components(tester); + expect( + tester.widget(find.byType(CustomEmojiImage)).url, + 'https://relay.example/one.png', + ); + fixture.palette = [ + const CustomEmoji( + shortcode: 'sample', + url: 'https://relay.example/two.png', + ), + ]; + ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ).invalidate(customEmojiListProvider); + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect( + tester.widget(find.byType(CustomEmojiImage)).url, + 'https://relay.example/two.png', + ); + before = _components(tester); + fixture.scaleEmoji = true; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect( + tester.widget(find.byType(CustomEmojiImage)).size, + kEmojiOnlyCustomEmojiSize, + ); + before = _components(tester); + fixture.tags = [ + ['emoji', 'sample', 'https://relay.example/tag.png'], + ]; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect( + tester.widget(find.byType(CustomEmojiImage)).url, + 'https://relay.example/tag.png', + ); + }); +} diff --git a/mobile/test/features/channels/voice_note_recording_test.dart b/mobile/test/features/channels/voice_note_recording_test.dart index 6669cc620c..4eed60907a 100644 --- a/mobile/test/features/channels/voice_note_recording_test.dart +++ b/mobile/test/features/channels/voice_note_recording_test.dart @@ -391,9 +391,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-toggle-cancel-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -402,6 +399,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); var authGeneration = 0; await player.loadRemote( 'https://example.com/voice-note.mp4', @@ -464,9 +463,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-activation-cancel-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: coordinator, client: client, @@ -475,6 +471,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); addTearDown(previous.dispose); var authGeneration = 0; await player.loadRemote( @@ -525,9 +523,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-source-load-cancel-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -536,6 +531,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); var authGeneration = 0; await player.loadRemote( 'https://example.com/voice-note.mp4', @@ -591,9 +588,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-source-load-retry-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -602,6 +596,9 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Teardowns run in reverse order. Remove the fake player's files before + // dispose starts its unawaited file cleanup, avoiding competing deletes. + addTearDown(() => directory.delete(recursive: true)); var authGeneration = 0; await player.loadRemote( 'https://example.com/voice-note.mp4', @@ -655,9 +652,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-download-limit-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -667,6 +661,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); await player.loadRemote( 'https://example.com/voice-note.mp4', headers: () => const {}, @@ -726,9 +722,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-retry-test', ); - addTearDown(() async { - if (await directory.exists()) await directory.delete(recursive: true); - }); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -737,6 +730,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Delete before dispose starts its unawaited file cleanup (reverse order). + addTearDown(() => directory.delete(recursive: true)); var authGeneration = 0; await player.loadRemote( 'https://example.com/voice-note.mp4', @@ -781,7 +776,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-pause-test', ); - addTearDown(() => directory.delete(recursive: true)); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -790,6 +784,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); await player.loadRemote( 'https://example.com/voice-note.mp4', headers: () => const {}, @@ -823,7 +819,6 @@ void main() { final directory = await Directory.systemTemp.createTemp( 'voice-note-source-replacement-test', ); - addTearDown(() => directory.delete(recursive: true)); final player = DeviceVoiceNotePlayerController( coordinator: VoiceNotePlaybackCoordinator(), client: client, @@ -832,6 +827,8 @@ void main() { player: audioPlayer, ); addTearDown(player.dispose); + // Reverse teardown order keeps recursive deletion ahead of disposal. + addTearDown(() => directory.delete(recursive: true)); await player.loadRemote( 'https://example.com/first.mp4', headers: () => const {}, 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/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 93c68cfa20..daa6c88cab 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/shared/profile/profile_event_parser.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -78,6 +79,76 @@ void main() { ); }); + test('confirmed profile save survives an older in-flight batch', () async { + final keys = nostr.Keys.generate(); + final owner = nostr.Keys.generate(); + NostrEvent event(String id, int createdAt, {bool owned = false}) => + NostrEvent( + id: id, + pubkey: keys.public, + createdAt: createdAt, + kind: EventKind.profile, + tags: owned ? [_authTag(owner, keys.public)] : const [], + content: jsonEncode({ + 'display_name': owned ? 'Current' : 'Old relay profile', + 'picture': owned ? 'https://example.com/new.png' : 'old.png', + 'about': owned ? 'Current about' : 'Old about', + 'nip05': owned ? 'agent@example.com' : 'old@example.com', + }), + sig: 'sig', + ); + var history = [event('old', 1)]; + final relaySession = _ControlledProfileRelaySession( + fetch: () async => history, + ); + final parserStarted = Completer(); + final releaseParser = Completer(); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith( + () => _FixedRelayConfigNotifier(keys.nsec), + ), + relaySessionProvider.overrideWith(() => relaySession), + profileEventBatchParserProvider.overrideWithValue((events) async { + final parsed = await parseProfileEventBatch(events); + parserStarted.complete(); + await releaseParser.future; + return parsed; + }), + ], + ); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + final cache = container.read(userCacheProvider.notifier); + final refresh = cache.refresh([keys.public]); + await parserStarted.future; + history = [event('current', 2, owned: true)]; + await container + .read(profileProvider.notifier) + .updateDisplayName('Saved locally'); + final saved = container.read(userCacheProvider)[keys.public]!; + expect(saved.displayName, 'Saved locally'); + expect(saved.avatarUrl, 'https://example.com/new.png'); + expect(saved.about, 'Current about'); + expect(saved.nip05Handle, 'agent@example.com'); + expect(saved.ownerPubkey, owner.public.toLowerCase()); + expect(relaySession.published, hasLength(1)); + + releaseParser.complete(); + expect(await refresh, isTrue); + final afterBatch = container.read(userCacheProvider)[keys.public]!; + expect(afterBatch.displayName, 'Saved locally'); + expect(afterBatch.avatarUrl, saved.avatarUrl); + expect(afterBatch.about, saved.about); + expect(afterBatch.nip05Handle, saved.nip05Handle); + expect(afterBatch.ownerPubkey, owner.public.toLowerCase()); + expect( + container.read(profileProvider).requireValue?.ownerPubkey, + owner.public.toLowerCase(), + ); + }); + test('clearing a display name restores the pubkey label fallback', () async { final keys = nostr.Keys.generate(); final relaySession = _ProfileRelaySession( 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 { diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index fb49b64d40..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', @@ -304,10 +206,13 @@ void main() { final notifier = container.read(communityListProvider.notifier); await notifier.addCommunity(community); - await notifier.markPushLeaseAccepted( - community.id, - subscriptions: const [], - generation: 5, + expect( + await notifier.markPushLeaseAccepted( + community.id, + subscriptions: const [], + generation: 5, + ), + isFalse, ); final stored = (await communityStorage.loadAll()).single; @@ -318,6 +223,54 @@ void main() { ); }); + test('lease success cannot accept stale desired subscriptions', () async { + container = createContainer(); + await container.read(communityListProvider.future); + final original = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final replacement = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['b' * 64]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [original], + ), + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + final generation = await notifier.reservePushLeaseGeneration( + community.id, + ); + await notifier.updateDesiredPushSubscriptions(community.id, [ + replacement, + ]); + + expect( + await notifier.markPushLeaseAccepted( + community.id, + subscriptions: [original], + generation: generation, + ), + isFalse, + ); + + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushSubscriptionState.accepted, isNull); + expect( + stored.pushSubscriptionState.desired.single.toJson(), + replacement.toJson(), + ); + }); + test('opt-out tombstones an in-flight first publication', () async { container = createContainer(); await container.read(communityListProvider.future); @@ -455,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); @@ -1081,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/profile/user_cache_batch_limit_test.dart b/mobile/test/shared/profile/user_cache_batch_limit_test.dart new file mode 100644 index 0000000000..452a8df656 --- /dev/null +++ b/mobile/test/shared/profile/user_cache_batch_limit_test.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const bridge = MethodChannel('buzz/push'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + test( + 'pending profiles drain in bounded queries after delayed export', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final entered = Completer(); + final release = Completer(); + messenger.setMockMethodCallHandler(bridge, (_) async { + if (!entered.isCompleted) { + entered.complete(); + await release.future; + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(bridge, null); + debugDefaultTargetPlatformOverride = null; + }); + final signed = NostrEvent.fromJson( + nostr.Event.from( + kind: 0, + createdAt: 1, + content: '{"name":"First"}', + tags: [], + secretKey: '1'.padLeft(64, '0'), + ).toMap(), + ); + final session = _ClampingSession(first: signed); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + activeCommunityProvider.overrideWith( + (ref) async => Community( + id: 'original', + name: 'Synthetic', + relayUrl: 'https://example.invalid', + addedAt: DateTime(2026), + ), + ), + ], + ); + addTearDown(container.dispose); + await container.read(activeCommunityProvider.future); + final cache = container.read(userCacheProvider.notifier); + expect(await cache.preload([signed.pubkey]), isTrue); + await entered.future.timeout(const Duration(seconds: 5)); + final keys = List.generate(1001, (i) => 'profile-$i'); + var completed = false; + final pending = cache.preload(keys).then((result) { + completed = true; + return result; + }); + final duplicate = cache.preload(keys); + try { + await Future.delayed(const Duration(milliseconds: 60)); + expect(session.filters, hasLength(1)); + expect(completed, isFalse); + } finally { + release.complete(); + } + expect(await pending, isTrue); + expect(await duplicate, isTrue); + expect(container.read(userCacheProvider).keys, containsAll(keys)); + expect(session.filters.map((f) => f.authors!.length), [1, 1000, 1]); + expect(session.filters.map((f) => f.limit), [1, 1000, 1]); + expect(session.maxRunning, 1); + }, + ); + + for (final preload in [true, false]) { + for (final outcome in ['success', 'failure', 'community change']) { + test( + '${preload ? "preload" : "refresh"} waits for final bounded query: $outcome', + () async { + final started = Completer(); + final release = Completer(); + final session = _ClampingSession( + beforeFetch: (index) async { + if (index != 1) return; + started.complete(); + await release.future; + if (outcome == 'failure') throw StateError('relay unavailable'); + }, + ); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + final keys = List.generate(1001, (i) => 'profile-$i'); + bool? result; + final done = (preload ? cache.preload(keys) : cache.refresh(keys)) + .then((value) => result = value); + await started.future.timeout(const Duration(seconds: 5)); + expect(result, isNull); + expect(container.read(userCacheProvider), hasLength(1000)); + if (outcome == 'community change') { + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://next.invalid'); + container.read(userCacheProvider); + } + release.complete(); + await done; + expect(result, outcome == 'success'); + expect(session.filters.map((f) => f.authors!.length), [1000, 1]); + expect(session.maxRunning, 1); + expect( + container.read(userCacheProvider), + hasLength( + outcome == 'community change' + ? 0 + : outcome == 'failure' + ? 1000 + : 1001, + ), + ); + }, + ); + } + } +} + +class _ClampingSession extends RelaySessionNotifier { + _ClampingSession({this.first, this.beforeFetch}); + final NostrEvent? first; + final Future Function(int)? beforeFetch; + final filters = []; + int running = 0; + int maxRunning = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + final index = filters.length; + filters.add(filter); + running++; + if (running > maxRunning) maxRunning = running; + try { + await beforeFetch?.call(index); + return [ + for (final key in filter.authors!.take(1000)) + if (key == first?.pubkey) + first! + else + NostrEvent( + id: key, + pubkey: key, + createdAt: 1, + kind: 0, + tags: [], + content: jsonEncode({'name': key}), + sig: 'synthetic', + ), + ]; + } finally { + running--; + } + } +} diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart index 9a27086107..2871514ad9 100644 --- a/mobile/test/shared/profile/user_cache_provider_test.dart +++ b/mobile/test/shared/profile/user_cache_provider_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/profile_event_parser.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -140,6 +141,297 @@ void main() { expect(cache.state['agent']?.displayName, 'Valid'); }); + for (final preload in [false, true]) { + test( + '${preload ? 'preload' : 'refresh'} merges after parsing against newer live state', + () async { + final started = Completer(); + final release = Completer(); + final session = _RecordingProfileSession( + result: Future.value([ + _profileEvent(id: 'old', createdAt: 1, name: 'Old'), + ]), + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + profileEventBatchParserProvider.overrideWithValue((events) async { + final parsed = await parseProfileEventBatch(events); + started.complete(); + await release.future; + return parsed; + }), + ], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + final result = preload + ? cache.preload(['agent']) + : cache.refresh(['agent']); + await started.future; + cache.cacheProfileEvent( + _profileEvent(id: 'new', createdAt: 2, name: 'Live'), + ); + cache.cacheProfileEvent( + _profileEvent( + id: 'other', + pubkey: 'other', + createdAt: 1, + name: 'Other', + ), + ); + release.complete(); + expect(await result, isTrue); + expect(cache.state['agent']?.displayName, 'Live'); + expect(cache.state['other']?.displayName, 'Other'); + }, + ); + + test( + '${preload ? 'preload' : 'refresh'} rejects a retired parsing completion', + () async { + final started = Completer(); + final release = Completer(); + final session = _RecordingProfileSession( + result: Future.value([ + _profileEvent(id: 'old', createdAt: 1, name: 'Previous community'), + ]), + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + profileEventBatchParserProvider.overrideWithValue((events) async { + final parsed = await parseProfileEventBatch(events); + started.complete(); + await release.future; + return parsed; + }), + ], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + final result = preload + ? cache.preload(['agent']) + : cache.refresh(['agent']); + await started.future; + // This is the same dependency invalidation caused by community config. + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://next-community.invalid'); + container.read(userCacheProvider); + release.complete(); + expect(await result, isFalse); + expect(container.read(userCacheProvider), isEmpty); + }, + ); + } + + test( + 'community change rejects an old network response before parsing', + () async { + final response = Completer>(); + var parsed = false; + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith( + () => _RecordingProfileSession(result: response.future), + ), + profileEventBatchParserProvider.overrideWithValue((events) async { + parsed = true; + return parseProfileEventBatch(events); + }), + ], + ); + addTearDown(container.dispose); + final result = container.read(userCacheProvider.notifier).refresh([ + 'agent', + ]); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://next-community.invalid'); + container.read(userCacheProvider); + response.complete([_profileEvent(id: 'old', createdAt: 1, name: 'Old')]); + expect(await result, isFalse); + expect(parsed, isFalse); + expect(container.read(userCacheProvider), isEmpty); + }, + ); + + test( + 'parser failure reports refresh failure without modifying cache', + () async { + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith( + () => _RecordingProfileSession( + result: Future.value([ + _profileEvent(id: 'next', createdAt: 2, name: 'Next'), + ]), + ), + ), + profileEventBatchParserProvider.overrideWithValue( + (_) => Future.error(StateError('parse failed')), + ), + ], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + cache.cacheProfileEvent( + _profileEvent(id: 'cached', createdAt: 1, name: 'Cached'), + ); + expect(await cache.refresh(['agent']), isFalse); + expect(cache.state['agent']?.displayName, 'Cached'); + }, + ); + + test('later refresh completion cannot roll back a newer refresh', () async { + final oldResponse = Completer>(); + final session = _RecordingProfileSession( + results: [ + oldResponse.future, + Future.value([_profileEvent(id: 'new', createdAt: 2, name: 'New')]), + ], + ); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + final old = cache.refresh(['agent']); + expect(await cache.refresh(['agent']), isTrue); + oldResponse.complete([_profileEvent(id: 'old', createdAt: 1, name: 'Old')]); + expect(await old, isTrue); + expect(cache.state['agent']?.displayName, 'New'); + }); + + test('stale malformed profile is skipped before parsing', () async { + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith( + () => _RecordingProfileSession( + result: Future.value([ + NostrEvent( + id: 'old', + pubkey: 'agent', + createdAt: 1, + kind: 0, + tags: [], + content: '{"name":42}', + sig: 'sig', + ), + ]), + ), + ), + ], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + cache.cacheProfileEvent( + _profileEvent(id: 'new', createdAt: 2, name: 'New'), + ); + expect(await cache.refresh(['agent']), isTrue); + expect(cache.state['agent']?.displayName, 'New'); + }); + + test( + 'refresh and preload share one verification worker and coalesce pending lookups', + () async { + final started = Completer(); + final release = Completer(); + var running = 0; + var maxRunning = 0; + var calls = 0; + final session = _RecordingProfileSession( + results: [ + Future.value([ + _profileEvent(id: 'a', pubkey: 'a', createdAt: 1, name: 'A'), + ]), + Future.value([ + _profileEvent(id: 'b', pubkey: 'b', createdAt: 1, name: 'B'), + ]), + Future.value([ + _profileEvent(id: 'c', pubkey: 'c', createdAt: 1, name: 'C'), + ]), + ], + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + profileEventBatchParserProvider.overrideWithValue((events) async { + running++; + if (running > maxRunning) maxRunning = running; + if (calls++ == 0) { + started.complete(); + await release.future; + } + final parsed = await parseProfileEventBatch(events); + running--; + return parsed; + }), + ], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + final first = cache.preload(['a']); + await started.future; + final refresh = cache.refresh(['b']); + final next = cache.preload(['c']); + final duplicate = cache.preload(['c']); + // Let the refresh reach the worker queue before releasing its predecessor. + await Future.delayed(Duration.zero); + expect(calls, 1); + release.complete(); + expect( + await Future.wait([first, refresh, next, duplicate]), + everyElement(isTrue), + ); + expect(maxRunning, 1); + expect(calls, 3); + expect(session.requestCount, 3); + }, + ); + + test( + 'refresh verifies real OA batch while allowing main-isolate timers', + () async { + final owner = nostr.Keys.generate(); + final agents = List.generate(16, (_) => nostr.Keys.generate()); + final events = [ + for (final agent in agents) + _profileEvent( + id: agent.public, + pubkey: agent.public, + createdAt: 1, + name: 'Synthetic', + tags: [_authTag(owner, agent.public)], + ), + ]; + var timerRanBeforeParsingCompleted = false; + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith( + () => _RecordingProfileSession(result: Future.value(events)), + ), + profileEventBatchParserProvider.overrideWithValue((events) async { + var timerRan = false; + final timer = Timer(Duration.zero, () => timerRan = true); + final parsed = await parseProfileEventBatch(events); + timerRanBeforeParsingCompleted = timerRan; + timer.cancel(); + return parsed; + }), + ], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + expect(await cache.refresh(agents.map((a) => a.public).toList()), isTrue); + expect(timerRanBeforeParsingCompleted, isTrue); + for (final agent in agents) { + expect(cache.state[agent.public]?.ownerPubkey, owner.public); + } + }, + ); + test('same-second profile tie keeps the lowest event id', () { final container = ProviderContainer(); addTearDown(container.dispose); @@ -200,6 +492,7 @@ class _RecordingProfileSession extends RelaySessionNotifier { final List>> _results; NostrFilter? requestedFilter; + int requestCount = 0; @override SessionState build() => const SessionState(status: SessionStatus.connected); @@ -209,6 +502,7 @@ class _RecordingProfileSession extends RelaySessionNotifier { NostrFilter filter, { Duration timeout = const Duration(seconds: 8), }) async { + requestCount++; requestedFilter = filter; return _results.isEmpty ? const [] : _results.removeAt(0); } diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 6380837bf6..2a3752e9f8 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -3,8 +3,43 @@ import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/push/push_bootstrap.dart'; import 'package:buzz/shared/push/push_subscription.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; void main() { + testWidgets('first opt-in starts registration without switching community', ( + tester, + ) async { + var registrations = 0; + Widget bootstrap(bool enabled) => BuzzPushRegistrationBootstrap( + shouldRegister: enabled, + attemptKey: 'same-community|same-relay', + startRegistration: () async { + registrations += 1; + }, + child: const SizedBox(), + ); + await tester.pumpWidget(bootstrap(false)); + expect(registrations, 0); + await tester.pumpWidget(bootstrap(true)); + expect(registrations, 1); + await tester.pumpWidget(bootstrap(true)); + expect(registrations, 1); + }); + + test( + 'superseded lease acceptance is a retryable publication failure', + () async { + await expectLater( + publishBuzzPushLeaseRecoverably( + reserveGeneration: () async => 1, + publish: (_) async {}, + markAccepted: (_) async => false, + ), + throwsStateError, + ); + }, + ); + test('failed bootstrap attempt becomes retryable after the delay', () async { final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); addTearDown(gate.dispose); @@ -156,12 +191,13 @@ void main() { relayGeneration = generation; } - Future markAccepted(int generation) async { + Future markAccepted(int generation) async { if (failLocalSave) { failLocalSave = false; throw StateError('injected local persistence failure'); } acceptedGeneration = generation; + return true; } await expectLater( diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index ce25c93bd2..9e87b7bf28 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -191,6 +191,7 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; messenger.setMockMethodCallHandler(_channel, (call) async { expect(call.method, 'endpointGrants'); + expect(call.arguments, {'gatewayUrl': Env.pushGatewayUrl}); return [_grantMap('opaque-grant')]; }); diff --git a/mobile/test/shared/push/push_gateway_origin_validator_test.dart b/mobile/test/shared/push/push_gateway_origin_validator_test.dart new file mode 100644 index 0000000000..3463a31b47 --- /dev/null +++ b/mobile/test/shared/push/push_gateway_origin_validator_test.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../../scripts/validate_push_gateway_origin.dart'; + +void main() { + test( + 'iOS build phase allows omission and validates supplied origins', + () async { + Future buildPhase( + Map environment, + ) => Process.run( + '/bin/sh', + [ + '-c', + '. ./scripts/require-push-gateway-origin.sh; printf "continued:%s" "\${DART_DEFINES:-}"', + ], + includeParentEnvironment: false, + environment: { + 'PATH': Platform.environment['PATH']!, + 'SRCROOT': '${Directory.current.path}/ios', + 'CONFIGURATION': 'Release', + ...environment, + }, + ); + String define(String origin) => + base64.encode(utf8.encode('BUZZ_PUSH_GATEWAY_URL=$origin')); + final absent = await buildPhase({}); + expect(absent.exitCode, 0, reason: '${absent.stderr}'); + expect(absent.stdout, 'continued:'); + final configured = await buildPhase({ + 'BUZZ_PUSH_GATEWAY_URL': 'https://push.example', + }); + expect(configured.exitCode, 0, reason: '${configured.stderr}'); + expect(configured.stdout, 'continued:${define('https://push.example')}'); + for (final origin in [ + '', + 'not-a-url', + 'https://push.example/path', + 'http://localhost:8080', + ]) { + final invalid = await buildPhase({'DART_DEFINES': define(origin)}); + expect(invalid.exitCode, isNot(0), reason: origin); + } + final debug = await buildPhase({ + 'CONFIGURATION': 'Debug', + 'DART_DEFINES': define('http://localhost:8080'), + }); + expect(debug.exitCode, 0, reason: '${debug.stderr}'); + }, + skip: Platform.isWindows, + ); + + test('accepts origin-only HTTP and HTTPS gateway URLs', () { + for (final value in [ + 'https://push.example', + 'https://push.example/', + 'http://localhost:8080', + ]) { + expect(isValidPushGatewayOrigin(value), isTrue, reason: value); + } + }); + + test('requires HTTPS for release and profile builds', () { + expect( + isValidPushGatewayOrigin('https://push.example', requireHttps: true), + isTrue, + ); + expect( + isValidPushGatewayOrigin('http://localhost:8080', requireHttps: true), + isFalse, + ); + expect( + isValidPushGatewayOrigin('https://push.example:8443', requireHttps: true), + isFalse, + ); + }); + + test('rejects malformed or non-origin gateway URLs', () { + for (final value in [ + '', + 'push.example', + 'ftp://push.example', + 'https://push.example/path', + 'https://push.example?token=x', + 'https://push.example#fragment', + 'https://user@push.example', + 'https://push.example:70000', + ]) { + expect(isValidPushGatewayOrigin(value), isFalse, reason: value); + } + }); +} diff --git a/mobile/test/shared/push/push_presentation_export_test.dart b/mobile/test/shared/push/push_presentation_export_test.dart new file mode 100644 index 0000000000..3d4baf63af --- /dev/null +++ b/mobile/test/shared/push/push_presentation_export_test.dart @@ -0,0 +1,608 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:buzz/shared/push/push_presentation_cache.dart'; +import 'package:buzz/shared/push/push_presentation_export_recovery.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +const _channel = MethodChannel('buzz/push'); +const _secret = + '0000000000000000000000000000000000000000000000000000000000000001'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + setUp(() { + calls.clear(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + pushPresentationCacheError.value = null; + pushPresentationExportError.value = null; + messenger.setMockMethodCallHandler(_channel, (call) async { + calls.add(call); + return null; + }); + }); + tearDown(() { + messenger.setMockMethodCallHandler(_channel, null); + debugDefaultTargetPlatformOverride = null; + pushPresentationCacheError.value = null; + pushPresentationExportError.value = null; + }); + + for (final profiles in [true, false]) { + for (final failedChunk in [0, 1]) { + test( + 'native chunk transient failure retries ${profiles ? "profiles" : "channels"} chunk $failedChunk in FIFO slot', + () async { + final limit = profiles ? 256 : 512; + final observations = ReceivePort(); + addTearDown(observations.close); + var workerReads = 0; + final caller = Isolate.current.debugName; + observations.listen((name) { + if (name != caller) workerReads++; + }); + final events = [ + for (var i = 0; i <= limit; i++) + _signed( + profiles ? 0 : 39000, + 100, + channelID: 'channel-$i', + secretKey: profiles + ? (i + 1).toRadixString(16).padLeft(64, '0') + : _secret, + ), + ]; + events[0] = _ObservedVerificationEvent( + events[0], + observations.sendPort, + ); + final memberships = profiles + ? [] + : [ + for (var i = 0; i <= limit; i++) + _signed(39002, 100, channelID: 'channel-$i'), + ]; + var failed = false; + final successfulChunks = []; + messenger.setMockMethodCallHandler(_channel, (call) async { + calls.add(call); + final args = call.arguments as Map; + if (args['communityId'] == 'following-community') return null; + final chunk = + (args[profiles ? 'events' : 'metadataEvents'] as List).length == + limit + ? 0 + : 1; + if (chunk == failedChunk && !failed) { + failed = true; + throw PlatformException( + code: profiles + ? 'profile_cache_failed' + : 'channel_cache_failed', + ); + } + successfulChunks.add(args); + return null; + }); + final exported = PushPresentationExportRecovery().export( + () => profiles + ? cacheBuzzPushProfileEvents('original-community', events) + : cacheBuzzPushChannelEvents( + 'original-community', + events, + memberships, + ), + ); + final following = cacheBuzzPushProfileEvents('following-community', [ + _signed(0, 200), + ]); + expect(await exported, isTrue); + await following; + expect(calls.map((call) => call.arguments['communityId']), [ + 'original-community', + 'original-community', + 'original-community', + 'following-community', + ]); + expect( + calls[failedChunk + 1].arguments, + equals(calls[failedChunk].arguments), + reason: 'retry must reuse the exact failed verified chunk', + ); + expect(successfulChunks, hasLength(2)); + expect([ + for (final chunk in successfulChunks) + ...chunk[profiles ? 'events' : 'metadataEvents'] as List, + ], unorderedEquals(events.map((event) => event.toJson()))); + if (!profiles) { + expect([ + for (final chunk in successfulChunks) + ...chunk['membershipEvents'] as List, + ], unorderedEquals(memberships.map((event) => event.toJson()))); + } + await Future.delayed(Duration.zero); + expect( + workerReads, + 1, + reason: 'native retries must not repeat signature verification', + ); + expect(pushPresentationExportError.value, isNull); + }, + ); + } + } + + for (final profiles in [true, false]) { + test( + 'permanent first ${profiles ? "profile" : "channel"} chunk failure exhausts retries and stops export', + () async { + final limit = profiles ? 256 : 512; + final events = [ + for (var i = 0; i <= limit; i++) + _signed( + profiles ? 0 : 39000, + 100, + channelID: 'channel-$i', + secretKey: profiles + ? (i + 1).toRadixString(16).padLeft(64, '0') + : _secret, + ), + ]; + final memberships = profiles + ? [] + : [ + for (var i = 0; i <= limit; i++) + _signed(39002, 100, channelID: 'channel-$i'), + ]; + messenger.setMockMethodCallHandler(_channel, (call) async { + calls.add(call); + if (call.arguments['communityId'] == 'failed-community') { + throw PlatformException( + code: profiles ? 'profile_cache_failed' : 'channel_cache_failed', + ); + } + return null; + }); + final recovery = PushPresentationExportRecovery(); + final succeeded = await recovery.export( + () => profiles + ? cacheBuzzPushProfileEvents('failed-community', events) + : cacheBuzzPushChannelEvents( + 'failed-community', + events, + memberships, + ), + ); + expect(succeeded, isFalse); + expect( + calls, + hasLength(6), + reason: 'initial attempt plus five retries, with no later chunk', + ); + expect( + pushPresentationCacheError.value, + contains(profiles ? 'profile_cache_failed' : 'channel_cache_failed'), + ); + final terminal = pushPresentationExportError.value; + expect( + terminal, + contains(profiles ? 'profile_cache_failed' : 'channel_cache_failed'), + ); + await cacheBuzzPushProfileEvents('following-community', [ + _signed(0, 200), + ]); + expect(calls, hasLength(7)); + for (final retry in calls.take(6)) { + expect(retry.arguments, equals(calls.first.arguments)); + } + expect(calls.last.arguments['communityId'], 'following-community'); + expect(pushPresentationExportError.value, terminal); + }, + ); + } + + test('native retry budget is shared across all chunks', () async { + final events = [ + for (var i = 1; i <= 257; i++) + _signed(0, 100, secretKey: i.toRadixString(16).padLeft(64, '0')), + ]; + messenger.setMockMethodCallHandler(_channel, (call) async { + calls.add(call); + final rows = call.arguments['events'] as List; + if (calls.length == 1 || rows.length == 1) { + throw PlatformException(code: 'profile_cache_failed'); + } + return null; + }); + expect( + await PushPresentationExportRecovery().export( + () => cacheBuzzPushProfileEvents('community', events), + ), + isFalse, + ); + expect(calls, hasLength(7)); + expect(calls.map((call) => (call.arguments['events'] as List).length), [ + 256, + 256, + 1, + 1, + 1, + 1, + 1, + ]); + expect(pushPresentationExportError.value, contains('profile_cache_failed')); + }); + + test( + 'native argument errors are terminal without persistence retries', + () async { + messenger.setMockMethodCallHandler(_channel, (call) async { + calls.add(call); + throw PlatformException(code: 'invalid_arguments'); + }); + expect( + await PushPresentationExportRecovery().export( + () => cacheBuzzPushProfileEvents('community', [_signed(0, 100)]), + ), + isFalse, + ); + expect(calls, hasLength(1)); + expect(pushPresentationExportError.value, contains('invalid_arguments')); + }, + ); + + test('unavailable native bridge remains an intentional no-op', () async { + messenger.setMockMethodCallHandler(_channel, (call) async { + calls.add(call); + throw MissingPluginException('synthetic non-Runner embedding'); + }); + expect( + await PushPresentationExportRecovery().export( + () => cacheBuzzPushProfileEvents('community', [_signed(0, 100)]), + ), + isTrue, + ); + expect(calls, hasLength(1)); + expect(pushPresentationCacheError.value, isNull); + expect(pushPresentationExportError.value, isNull); + }); + + for (final profiles in [true, false]) { + test( + 'large ${profiles ? "profile" : "channel"} exports fit native bounds', + () async { + final limit = profiles ? 256 : 512; + final events = [ + for (var i = 0; i <= limit; i++) + _signed( + profiles ? 0 : 39000, + 100, + channelID: 'channel-$i', + secretKey: profiles + ? (i + 1).toRadixString(16).padLeft(64, '0') + : _secret, + ), + ]; + // Deliberately reverse roster order: each chunk must pair by channel ID. + final memberships = profiles + ? [] + : [ + for (var i = limit; i >= 0; i--) + _signed(39002, 100, channelID: 'channel-$i'), + ]; + final received = []; + final receivedMemberships = []; + messenger.setMockMethodCallHandler(_channel, (call) async { + final args = call.arguments as Map; + if (args['communityId'] == 'following-community') { + calls.add(call); + return null; + } + final metadata = args[profiles ? 'events' : 'metadataEvents'] as List; + final rosters = profiles + ? [] + : args['membershipEvents'] as List; + if (metadata.length > limit || rosters.length > limit) { + throw PlatformException(code: 'invalid_arguments'); + } + expect(args['communityId'], 'bounded-community'); + if (!profiles) { + String id(dynamic event) => + (event['tags'] as List).firstWhere((tag) => tag[0] == 'd')[1] + as String; + expect(rosters.map(id).toSet(), metadata.map(id).toSet()); + } + received.addAll(metadata); + receivedMemberships.addAll(rosters); + calls.add(call); + return null; + }); + final largeExport = profiles + ? cacheBuzzPushProfileEvents('bounded-community', events) + : cacheBuzzPushChannelEvents( + 'bounded-community', + events, + memberships, + ); + final followingExport = cacheBuzzPushProfileEvents( + 'following-community', + [_signed(0, 200)], + ); + await Future.wait([largeExport, followingExport]); + expect(pushPresentationCacheError.value, isNull); + expect(calls.map((call) => call.arguments['communityId']), [ + 'bounded-community', + 'bounded-community', + 'following-community', + ]); + expect( + received, + unorderedEquals(events.map((event) => event.toJson())), + ); + expect( + receivedMemberships, + unorderedEquals(memberships.map((event) => event.toJson())), + ); + }, + ); + } + + for (final kind in [0, 39000]) { + test( + 'kind $kind verifies off the calling isolate before native export', + () async { + final verification = Completer(); + final observations = ReceivePort(); + addTearDown(observations.close); + final callerIsolateName = Isolate.current.debugName; + observations.listen((name) { + if (name != callerIsolateName && !verification.isCompleted) { + verification.complete(); + } + }); + final event = _ObservedVerificationEvent( + _signed(kind, 100), + observations.sendPort, + ); + var timerRan = false; + Timer.run(() => timerRan = true); + if (kind == 0) { + await cacheBuzzPushProfileEvents('community', [event]); + } else { + await cacheBuzzPushChannelEvents('community', [event], []); + } + // The signal originates from reading the real signature in the worker. + // Restoring synchronous verification cannot produce it. This is a + // liveness timeout, not a device-dependent elapsed-time performance gate. + await verification.future.timeout(const Duration(seconds: 5)); + expect(timerRan, isTrue); + expect(calls.single.method, 'syncPushSnapshot'); + expect(calls.single.arguments['communityId'], 'community'); + final key = kind == 0 ? 'events' : 'metadataEvents'; + expect(calls.single.arguments[key], [event.toJson()]); + }, + ); + } + + test( + 'profile export retains older valid candidates and signed payloads', + () async { + final older = _signed(0, 100); + final newestValid = _signed(0, 200); + await cacheBuzzPushProfileEvents('profiles-community', [ + older, + _tampered(_signed(0, 300)), + newestValid, + _signed(39000, 400), + ]); + expect(calls.single.arguments, { + 'section': 'profiles', + 'communityId': 'profiles-community', + 'events': [newestValid.toJson()], + }); + }, + ); + + test( + 'channel export preserves valid fallback and membership scope pairing', + () async { + final metadata = _signed(39000, 200); + final membership = _signed(39002, 200); + await cacheBuzzPushChannelEvents( + 'channels-community', + [ + _signed(39000, 100), + _tampered(_signed(39000, 300)), + metadata, + _signed(39000, 400, channelID: 'unpaired'), + ], + [_tampered(_signed(39002, 300)), membership, _signed(39002, 100)], + ); + expect(calls.single.arguments, { + 'section': 'channels', + 'communityId': 'channels-community', + 'metadataEvents': [metadata.toJson()], + 'membershipEvents': [membership.toJson()], + }); + }, + ); + + test( + 'native handoff and completion retain FIFO across sections and communities', + () async { + final firstEntered = Completer(); + final releaseFirst = Completer(); + var firstReleased = false; + var overlappingHandoff = false; + messenger.setMockMethodCallHandler(_channel, (call) async { + calls.add(call); + if (calls.length == 1) { + firstEntered.complete(); + await releaseFirst.future; + firstReleased = true; + } else { + overlappingHandoff |= !firstReleased; + } + return null; + }); + final completed = []; + final first = cacheBuzzPushProfileEvents('first', [ + _signed(0, 100), + ]).then((_) => completed.add('first')); + await firstEntered.future; + final second = cacheBuzzPushChannelEvents('second', [ + _signed(39000, 100), + ], []).then((_) => completed.add('second')); + final third = cacheBuzzPushProfileEvents('third', [ + _signed(0, 200), + ]).then((_) => completed.add('third')); + try { + await Future.delayed(Duration.zero); + expect(completed, isEmpty); + expect(calls.length, 1); + } finally { + releaseFirst.complete(); + await Future.wait([first, second, third]); + } + expect(overlappingHandoff, isFalse); + expect(completed, ['first', 'second', 'third']); + expect(calls.map((call) => call.arguments['communityId']), [ + 'first', + 'second', + 'third', + ]); + }, + ); + + test( + 'saturation rejects explicitly and admitted exports drain before recovery', + () async { + final entered = Completer(); + final release = Completer(); + messenger.setMockMethodCallHandler(_channel, (call) async { + calls.add(call); + if (calls.length == 1) { + entered.complete(); + await release.future; + } + return null; + }); + final event = _signed(0, 100); + final admitted = [ + cacheBuzzPushProfileEvents('0', [event]), + ]; + await entered.future; + try { + for (var i = 1; i < 8; i++) { + admitted.add(cacheBuzzPushProfileEvents('$i', [event])); + } + await expectLater( + cacheBuzzPushProfileEvents('rejected', [ + event, + ]).timeout(const Duration(seconds: 5)), + throwsStateError, + ); + expect(calls.length, 1); + } finally { + release.complete(); + await Future.wait(admitted); + } + expect(calls.map((call) => call.arguments['communityId']), [ + for (var i = 0; i < 8; i++) '$i', + ]); + await cacheBuzzPushProfileEvents('recovered', [event]); + expect(calls.last.arguments['communityId'], 'recovered'); + expect(calls.length, 9); + }, + ); + + test( + 'worker submission failure propagates and releases the next export', + () async { + final unsendable = ReceivePort(); + addTearDown(unsendable.close); + final failed = cacheBuzzPushProfileEvents('failed', [ + _UnsendableEvent(_signed(0, 100), unsendable), + ]); + final failure = expectLater(failed, throwsArgumentError); + final next = cacheBuzzPushProfileEvents('next', [_signed(0, 200)]); + await failure; + await next.timeout(const Duration(seconds: 5)); + expect(calls.length, 1); + expect(calls.single.arguments['communityId'], 'next'); + }, + ); +} + +NostrEvent _signed( + int kind, + int createdAt, { + String channelID = 'channel', + String secretKey = _secret, +}) => NostrEvent.fromJson( + nostr.Event.from( + kind: kind, + createdAt: createdAt, + content: kind == 0 ? '{"name":"Synthetic profile"}' : '', + tags: kind == 0 + ? [] + : [ + ['d', channelID], + ], + secretKey: secretKey, + ).toMap(), +); + +NostrEvent _tampered(NostrEvent event) => NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: 'Tampered', + sig: event.sig, +); + +class _ObservedVerificationEvent extends NostrEvent { + final SendPort observations; + + _ObservedVerificationEvent(NostrEvent event, this.observations) + : super( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + ); + + @override + String get sig { + observations.send(Isolate.current.debugName); + return super.sig; + } +} + +class _UnsendableEvent extends NostrEvent { + final ReceivePort port; + + _UnsendableEvent(NostrEvent event, this.port) + : super( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + ); +} diff --git a/mobile/test/shared/push/push_presentation_recovery_test.dart b/mobile/test/shared/push/push_presentation_recovery_test.dart new file mode 100644 index 0000000000..9d58cebe36 --- /dev/null +++ b/mobile/test/shared/push/push_presentation_recovery_test.dart @@ -0,0 +1,707 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/profile_event_parser.dart'; +import 'package:buzz/shared/push/push_presentation_cache.dart'; +import 'package:buzz/shared/push/push_presentation_export_recovery.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +const _bridge = MethodChannel('buzz/push'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + pushPresentationExportError.value = null; + }); + tearDown(() { + debugDefaultTargetPlatformOverride = null; + pushPresentationExportError.value = null; + messenger.setMockMethodCallHandler(_bridge, null); + }); + + for (final profiles in [true, false]) { + final section = profiles ? 'profiles' : 'channels'; + test( + '$section producer recovers its saturated detached snapshot', + () async { + final blocked = Completer(); + final entered = Completer(); + final delivered = Completer>(); + messenger.setMockMethodCallHandler(_bridge, (call) async { + final args = call.arguments as Map; + if (args['communityId'] == 'blocker' && !entered.isCompleted) { + entered.complete(); + await blocked.future; + } + if (args['communityId'] == 'original' && !delivered.isCompleted) { + delivered.complete(args); + } + return null; + }); + final filler = _signed(0); + final queued = [ + cacheBuzzPushProfileEvents('blocker', [filler]), + ]; + await entered.future; + for (var i = 1; i < 8; i++) { + queued.add(cacheBuzzPushProfileEvents('queued-$i', [filler])); + } + final event = _signed(profiles ? 0 : 39000); + final membership = _signed(39002); + var communityID = 'original'; + final container = _container( + _Session(event, membership), + communityID: () => communityID, + ); + addTearDown(container.dispose); + Future? preload; + try { + await container.read(activeCommunityProvider.future); + if (profiles) { + final ready = Completer(); + final subscription = container.listen(userCacheProvider, (_, next) { + if (next.containsKey(event.pubkey) && !ready.isCompleted) { + ready.complete(); + } + }); + addTearDown(subscription.close); + preload = container.read(userCacheProvider.notifier).preload([ + event.pubkey, + ]); + await ready.future.timeout(const Duration(seconds: 5)); + } else { + await container.read(channelsProvider.future); + } + expect(delivered.isCompleted, isFalse); + communityID = 'replacement'; + container.invalidate(activeCommunityProvider); + await container.read(activeCommunityProvider.future); + } finally { + blocked.complete(); + await Future.wait(queued); + if (preload != null) expect(await preload, isTrue); + } + final payload = await delivered.future.timeout( + const Duration(seconds: 5), + ); + expect(payload['section'], section); + expect(payload['communityId'], 'original'); + expect(payload[profiles ? 'events' : 'metadataEvents'], [ + event.toJson(), + ]); + if (!profiles) { + expect(payload['membershipEvents'], [membership.toJson()]); + } + expect(pushPresentationExportError.value, isNull); + }, + ); + + test( + '$section producer preserves distinct input beyond eight arrivals', + () async { + final entered = Completer(); + final release = Completer(); + final payloads = >[]; + final delivered = Completer(); + final expected = {}; + messenger.setMockMethodCallHandler(_bridge, (call) async { + final args = call.arguments as Map; + if (args['communityId'] == 'blocker' && !entered.isCompleted) { + entered.complete(); + await release.future; + } + if (args['communityId'] == 'original') { + payloads.add(args); + final ids = { + for (final payload in payloads) + for (final event + in payload[profiles ? 'events' : 'metadataEvents'] as List) + event['id'] as String, + }; + if (expected.length == 13 && + ids.containsAll(expected) && + !delivered.isCompleted) { + delivered.complete(); + } + } + return null; + }); + final queued = [ + cacheBuzzPushProfileEvents('blocker', [_signed(0)]), + ]; + await entered.future; + for (var i = 1; i < 8; i++) { + queued.add(cacheBuzzPushProfileEvents('queued-$i', [_signed(0)])); + } + final first = _signed(profiles ? 0 : 39000); + expected.add(first.id); + final session = _Session(first, _signed(39002)); + final container = _container(session); + addTearDown(container.dispose); + final loads = >[]; + List? visibleBefore; + var subscriptionsBefore = 0; + try { + await container.read(activeCommunityProvider.future); + if (profiles) { + final ready = Completer(); + final subscription = container.listen(userCacheProvider, (_, next) { + if (next.containsKey(first.pubkey) && !ready.isCompleted) { + ready.complete(); + } + }); + addTearDown(subscription.close); + loads.add( + container.read(userCacheProvider.notifier).preload([ + first.pubkey, + ]), + ); + await ready.future.timeout(const Duration(seconds: 5)); + expect(await loads.single, isTrue); + } else { + await container.read(channelsProvider.future); + } + for (var i = 2; i <= 13; i++) { + final event = _signed( + profiles ? 0 : 39000, + key: i, + channel: 'channel-$i', + ); + session.extra.add(event); + expected.add(event.id); + if (profiles) { + loads.add( + container.read(userCacheProvider.notifier).preload([ + event.pubkey, + ]), + ); + } else { + session.extra.add(_signed(39002, channel: 'channel-$i')); + await container.read(channelsProvider.notifier).refresh(); + } + } + // Include a newer valid event and an invalid newest candidate for an + // entity in a pending partial batch. Selection must keep the valid one. + final newer = _signed( + profiles ? 0 : 39000, + key: 2, + channel: 'channel-2', + createdAt: 200, + ); + expected.remove( + _signed(profiles ? 0 : 39000, key: 2, channel: 'channel-2').id, + ); + expected.add(newer.id); + session.extra.addAll([ + newer, + NostrEvent( + id: 'invalid-newest', + pubkey: newer.pubkey, + createdAt: 300, + kind: newer.kind, + tags: newer.tags, + content: 'tampered', + sig: newer.sig, + ), + ]); + if (profiles) { + // Let the 50 ms fetch-coalescing timer fire if backpressure is + // removed. This is scheduling coverage, not a latency threshold. + await Future.delayed(const Duration(milliseconds: 60)); + expect( + session.profileFetches, + 1, + reason: 'pending keys must remain upstream while export waits', + ); + } else { + for (var turn = 0; turn < 20; turn++) { + await Future.delayed(Duration.zero); + } + visibleBefore = container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id) + .toList(); + subscriptionsBefore = session.subscriptions; + } + } finally { + release.complete(); + await Future.wait(queued); + } + await Future.wait(loads); + await delivered.future.timeout(const Duration(seconds: 5)); + expect(pushPresentationExportError.value, isNull); + if (!profiles) { + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + visibleBefore, + ); + expect(session.subscriptions, subscriptionsBefore); + expect( + session.directoryLoads, + 0, + reason: 'notification recovery must not fetch the open directory', + ); + expect( + payloads.last['membershipEvents'], + unorderedEquals([ + session.membership.toJson(), + for (final event in session.extra) + if (event.kind == 39002) event.toJson(), + ]), + ); + } + }, + ); + + test( + '$section producer handles detached worker submission failure', + () async { + final port = ReceivePort(); + addTearDown(port.close); + final event = _Unsendable(_signed(profiles ? 0 : 39000), port); + final failure = Completer(); + void onError() { + if (pushPresentationExportError.value != null && + !failure.isCompleted) { + failure.complete(); + } + } + + pushPresentationExportError.addListener(onError); + addTearDown(() => pushPresentationExportError.removeListener(onError)); + var nativeCalls = 0; + messenger.setMockMethodCallHandler(_bridge, (_) async { + nativeCalls++; + return null; + }); + final container = _container( + _Session(event, _signed(39002)), + bypassProfileWorker: true, + ); + addTearDown(container.dispose); + await container.read(activeCommunityProvider.future); + if (profiles) { + await container.read(userCacheProvider.notifier).preload([ + event.pubkey, + ]); + } else { + await container.read(channelsProvider.future); + } + await failure.future.timeout(const Duration(seconds: 5)); + expect(nativeCalls, 0); + final terminalError = pushPresentationExportError.value; + await cacheBuzzPushProfileEvents('unrelated-success', [_signed(0)]); + expect(nativeCalls, 1); + expect(pushPresentationExportError.value, terminalError); + }, + ); + } + + for (final mode in ['reentrant', 'retired', 'failure']) { + test('channel dirty refetch handles $mode work', () async { + final nativeEntered = Completer(); + final releaseNative = Completer(); + final refetchEntered = Completer(); + final releaseRefetch = Completer(); + final complete = Completer(); + final payloads = >[]; + messenger.setMockMethodCallHandler(_bridge, (call) async { + final args = call.arguments as Map; + payloads.add(args); + if (payloads.length == 1) { + nativeEntered.complete(); + await releaseNative.future; + } else if ((mode == 'reentrant' && payloads.length == 3) || + (mode == 'retired' && args['communityId'] == 'replacement')) { + if (!complete.isCompleted) complete.complete(); + } + return null; + }); + void errorChanged() { + if (mode == 'failure' && + pushPresentationExportError.value != null && + !complete.isCompleted) { + complete.complete(); + } + } + + pushPresentationExportError.addListener(errorChanged); + addTearDown( + () => pushPresentationExportError.removeListener(errorChanged), + ); + final session = _Session(_signed(39000), _signed(39002)); + var community = 'original'; + final container = _container(session, communityID: () => community); + addTearDown(container.dispose); + await container.read(activeCommunityProvider.future); + await container.read(channelsProvider.future); + await nativeEntered.future; + await container.read(channelsProvider.notifier).refresh(); + session.beforeMetadata = () async { + refetchEntered.complete(); + await releaseRefetch.future; + if (mode == 'failure') throw StateError('synthetic refetch failure'); + }; + releaseNative.complete(); + await refetchEntered.future.timeout(const Duration(seconds: 5)); + try { + if (mode == 'reentrant') { + session.extra.addAll([ + _signed(39000, channel: 'later'), + _signed(39002, channel: 'later'), + ]); + await container.read(channelsProvider.notifier).refresh(); + } else if (mode == 'retired') { + community = 'replacement'; + session.event = _signed(39000, channel: 'replacement-channel'); + session.membership = _signed(39002, channel: 'replacement-channel'); + container.invalidate(activeCommunityProvider); + await container.read(activeCommunityProvider.future); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://replacement.invalid'); + await container.read(channelsProvider.future); + await container.read(channelsProvider.notifier).refresh(); + } + } finally { + releaseRefetch.complete(); + } + await complete.future.timeout(const Duration(seconds: 5)); + for (var turn = 0; turn < 20; turn++) { + await Future.delayed(Duration.zero); + } + if (mode == 'failure') { + expect(session.metadataLoads, 3); + expect(session.directoryLoads, 0); + expect(payloads, hasLength(1)); + expect( + pushPresentationExportError.value, + contains('synthetic refetch failure'), + ); + } else if (mode == 'retired') { + expect( + payloads.where((payload) => payload['communityId'] == 'original'), + hasLength(1), + ); + expect(payloads.last['metadataEvents'], [session.event.toJson()]); + expect(payloads.last['membershipEvents'], [ + session.membership.toJson(), + ]); + expect(pushPresentationExportError.value, isNull); + } else { + expect(session.metadataLoads, 5); + expect(session.directoryLoads, 0); + expect(payloads.last['metadataEvents'], hasLength(2)); + expect(payloads.last['membershipEvents'], hasLength(2)); + expect(pushPresentationExportError.value, isNull); + } + }); + } + + for (final failedOperation in ['initial export', 'dirty refetch']) { + test( + 'pending channel input drains after exhausted $failedOperation', + () async { + final blockerEntered = Completer(); + final releaseBlocker = Completer(); + final initialEntered = Completer(); + final releaseInitial = Completer(); + final refetchEntered = Completer(); + final releaseRefetch = Completer(); + final terminal = Completer(); + late _Session session; + List? readsBeforeAdmissionRetries; + List? readsAtTerminal; + final latestDelivered = Completer>(); + final latest = _signed(39000, channel: 'latest'); + final latestMembership = _signed(39002, channel: 'latest'); + messenger.setMockMethodCallHandler(_bridge, (call) async { + final args = call.arguments as Map; + if (args['communityId'] == 'blocker' && !blockerEntered.isCompleted) { + blockerEntered.complete(); + await releaseBlocker.future; + } + if (args['communityId'] == 'original') { + if (failedOperation == 'dirty refetch' && + !initialEntered.isCompleted) { + initialEntered.complete(); + await releaseInitial.future; + } + if ((args['metadataEvents'] as List).any( + (event) => event['id'] == latest.id, + ) && + !latestDelivered.isCompleted) { + latestDelivered.complete(args); + } + } + return null; + }); + void onFailure() { + if (pushPresentationExportError.value != null && + !terminal.isCompleted) { + readsAtTerminal = session.snapshotReadCounts; + terminal.complete(); + } + } + + pushPresentationExportError.addListener(onFailure); + addTearDown( + () => pushPresentationExportError.removeListener(onFailure), + ); + final queued = >[]; + Future saturate() async { + queued.add(cacheBuzzPushProfileEvents('blocker', [_signed(0)])); + await blockerEntered.future; + for (var i = 1; i < 8; i++) { + queued.add(cacheBuzzPushProfileEvents('queued-$i', [_signed(0)])); + } + } + + session = _Session(_signed(39000), _signed(39002)); + final container = _container(session); + addTearDown(container.dispose); + try { + if (failedOperation == 'initial export') await saturate(); + await container.read(activeCommunityProvider.future); + await container.read(channelsProvider.future); + if (failedOperation == 'dirty refetch') { + await initialEntered.future; + await container.read(channelsProvider.notifier).refresh(); + session.beforeMetadata = () async { + refetchEntered.complete(); + await releaseRefetch.future; + }; + releaseInitial.complete(); + await refetchEntered.future.timeout(const Duration(seconds: 5)); + await saturate(); + } + session.extra.addAll([latest, latestMembership]); + await container.read(channelsProvider.notifier).refresh(); + readsBeforeAdmissionRetries = session.snapshotReadCounts; + if (!releaseRefetch.isCompleted) releaseRefetch.complete(); + await terminal.future.timeout(const Duration(seconds: 12)); + expect(pushPresentationExportError.value, contains('queue is full')); + if (failedOperation == 'dirty refetch') { + // Membership pages and metadata were fetched before the paused + // metadata response. Only the remaining member snapshot is read; + // all five admission retries must reuse those same raw events. + expect( + [ + for (var i = 0; i < 3; i++) + readsAtTerminal![i] - readsBeforeAdmissionRetries[i], + ], + [0, 0, 1], + ); + } + // Capacity returns only after the predecessor has exhausted every retry. + releaseBlocker.complete(); + await Future.wait(queued); + final payload = await latestDelivered.future.timeout( + const Duration(seconds: 5), + ); + expect(payload['communityId'], 'original'); + expect(payload['metadataEvents'], contains(equals(latest.toJson()))); + expect( + payload['membershipEvents'], + contains(equals(latestMembership.toJson())), + ); + expect(session.directoryLoads, 0); + expect(pushPresentationExportError.value, contains('queue is full')); + } finally { + if (!releaseBlocker.isCompleted) releaseBlocker.complete(); + if (!releaseInitial.isCompleted) releaseInitial.complete(); + if (!releaseRefetch.isCompleted) releaseRefetch.complete(); + await Future.wait(queued); + } + }, + ); + } + + test('bounded retries terminate and a later operation can succeed', () { + fakeAsync((clock) { + final recovery = PushPresentationExportRecovery(); + var attempts = 0; + bool? result; + recovery + .export(() async { + attempts++; + throw PushPresentationExportQueueFull(); + }) + .then((value) => result = value); + clock.flushMicrotasks(); + clock.elapse(const Duration(seconds: 8)); + expect(result, isFalse); + expect(attempts, 6); + final terminal = pushPresentationExportError.value; + expect(terminal, isNotNull); + clock.elapse(const Duration(days: 1)); + expect(attempts, 6); + bool? recovered; + recovery.export(() async {}).then((value) => recovered = value); + clock.flushMicrotasks(); + expect(recovered, isTrue); + expect(pushPresentationExportError.value, terminal); + }); + }); +} + +ProviderContainer _container( + _Session session, { + String Function()? communityID, + bool bypassProfileWorker = false, +}) => ProviderContainer( + retry: (_, _) => null, + overrides: [ + if (bypassProfileWorker) + profileEventBatchParserProvider.overrideWithValue( + (events) async => events.map(parseProfileEvent).toList(), + ), + relaySessionProvider.overrideWith(() => session), + appLifecycleProvider.overrideWith(_Lifecycle.new), + myPubkeyProvider.overrideWith((ref) => 'me'), + activeCommunityProvider.overrideWith( + (ref) async => Community( + id: communityID?.call() ?? 'original', + name: 'Synthetic', + relayUrl: 'https://example.invalid', + addedAt: DateTime(2026), + ), + ), + ], +); + +class _Session extends RelaySessionNotifier { + _Session(this.event, this.membership); + NostrEvent event; + NostrEvent membership; + final extra = []; + int profileFetches = 0; + int subscriptions = 0; + int directoryLoads = 0; + int metadataLoads = 0; + int membershipPages = 0; + int memberSnapshotLoads = 0; + List get snapshotReadCounts => [ + membershipPages, + metadataLoads, + memberSnapshotLoads, + ]; + Future Function()? beforeMetadata; + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filter.kinds.contains(0)) profileFetches++; + if (filter.kinds.contains(39002) && filter.tags['#d'] != null) { + memberSnapshotLoads++; + } + if (filter.kinds.contains(39000)) { + metadataLoads++; + final hook = beforeMetadata; + beforeMetadata = null; + if (hook != null) await hook(); + } + return [ + for (final candidate in [event, membership, ...extra]) + if (filter.kinds.contains(candidate.kind) && + (filter.authors == null || + filter.authors!.contains(candidate.pubkey))) + candidate, + ]; + } + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filters.any( + (filter) => filter.kinds.contains(39002) && filter.tags['#p'] != null, + )) { + membershipPages++; + } + if (filters.any( + (filter) => + filter.kinds.contains(39000) && + filter.tags['#d'] == null && + filter.extensions['before_id'] == null, + )) { + directoryLoads++; + } + return [ + for (final filter in filters) + ...await fetchHistory(filter, timeout: timeout), + ]; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String)? onClosed, + }) async { + subscriptions++; + return () {}; + } +} + +class _Lifecycle extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} + +NostrEvent _signed( + int kind, { + int key = 1, + String channel = 'channel', + int createdAt = 100, +}) => NostrEvent.fromJson( + nostr.Event.from( + kind: kind, + createdAt: createdAt, + content: kind == 0 ? '{"name":"Synthetic"}' : '', + tags: kind == 0 + ? [] + : [ + ['d', channel], + ['name', 'Synthetic'], + if (kind == 39002) ['p', 'me'], + ], + secretKey: key.toRadixString(16).padLeft(64, '0'), + ).toMap(), +); + +class _Unsendable extends NostrEvent { + final ReceivePort port; + _Unsendable(NostrEvent event, this.port) + : super( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + ); +} diff --git a/mobile/test/shared/push/push_unconfigured_build_test.dart b/mobile/test/shared/push/push_unconfigured_build_test.dart new file mode 100644 index 0000000000..fc626e50ea --- /dev/null +++ b/mobile/test/shared/push/push_unconfigured_build_test.dart @@ -0,0 +1,116 @@ +import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_bootstrap.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/push/push_relay_capability_provider.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:buzz/shared/relay/relay_session.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/foundation.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() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('artifact without a gateway define', () { + test( + 'does not discover push capability or enter the opted-in lifecycle', + () async { + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith( + () => throw StateError('must not start relay discovery'), + ), + ], + ); + addTearDown(container.dispose); + expect( + await container.read(currentRelayPushDescriptorProvider.future), + isNull, + ); + expect( + buzzPushLifecycleEnabled( + community: Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ).copyWith(pushNotificationsEnabled: true), + descriptor: const BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: 'key', + executorPubkey: 'pubkey', + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, + ), + ), + isFalse, + ); + }, + ); + + test('never requests native registration or gateway grants', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/push'); + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + await startBuzzPushRegistration(); + expect(await readBuzzPushEndpointGrants(), isEmpty); + expect(calls, isEmpty); + }); + + testWidgets( + 'Settings explains push is unavailable without offering opt-in', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + activeCommunityProvider.overrideWith( + (ref) async => Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ), + ), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => + throw StateError('must not query notification permission'), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Unavailable in this build'), findsOneWidget); + expect(find.byType(Switch), findsNothing); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }, + ); + }, skip: Env.pushGatewayConfigured); +} 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; +} diff --git a/schema/schema.sql b/schema/schema.sql index 0950812562..d7074f359d 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1131,7 +1131,7 @@ CREATE INDEX push_gateway_challenges_expiry ON push_gateway_challenges (expires_ CREATE TABLE push_gateway_installations ( id UUID PRIMARY KEY, - app_attest_key_id BYTEA NOT NULL UNIQUE CHECK (octet_length(app_attest_key_id) BETWEEN 1 AND 128), + app_attest_key_id BYTEA NOT NULL CHECK (octet_length(app_attest_key_id) BETWEEN 1 AND 128), app_attest_public_key BYTEA NOT NULL CHECK (octet_length(app_attest_public_key) BETWEEN 33 AND 256), assertion_counter BIGINT NOT NULL CHECK (assertion_counter BETWEEN 0 AND 4294967295), app_profile TEXT NOT NULL CHECK (app_profile = 'buzz-ios-dogfood'), @@ -1141,9 +1141,12 @@ CREATE TABLE push_gateway_installations ( expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (app_profile, token_fingerprint) + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +CREATE UNIQUE INDEX push_gateway_installations_active_app_attest_key + ON push_gateway_installations (app_attest_key_id) WHERE revoked_at IS NULL; +CREATE UNIQUE INDEX push_gateway_installations_active_profile_token + ON push_gateway_installations (app_profile, token_fingerprint) WHERE revoked_at IS NULL; CREATE INDEX push_gateway_installations_expiry ON push_gateway_installations (expires_at) WHERE revoked_at IS NULL; CREATE TABLE push_gateway_delegations ( @@ -1893,3 +1896,17 @@ 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/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(), + ); + }); +} 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) -------------------------------- diff --git a/scripts/test-mobile-gateway-recipes.sh b/scripts/test-mobile-gateway-recipes.sh new file mode 100644 index 0000000000..ae6741eb1a --- /dev/null +++ b/scripts/test-mobile-gateway-recipes.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Exercise the production recipes with macOS's system Bash, not a rewritten copy. +set -euo pipefail +repo_root=$(cd "$(dirname "$0")/.." && pwd) +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +mkdir -p "$fixture/bin" "$fixture/scripts" "$fixture/mobile/ios/Flutter" +for recipe in mobile-dev mobile-build-android; do + # Render with the real task runner before installing stubs: a Hermit just + # shim can otherwise prepend the real Flutter to PATH ahead of our stub. + just --justfile "$repo_root/Justfile" --dry-run "$recipe" > "$fixture/$recipe" 2>&1 +done +cat > "$fixture/bin/flutter" <<'STUB' +#!/bin/bash +set -eu +printf '%s\n' "$@" > "$CALL_LOG" +STUB +cat > "$fixture/bin/pgrep" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat > "$fixture/scripts/mobile-worktree-overrides.sh" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod +x "$fixture/bin/flutter" "$fixture/bin/pgrep" "$fixture/scripts/mobile-worktree-overrides.sh" +export PATH="$fixture/bin:$PATH" +export CALL_LOG="$fixture/arguments" +unset BUZZ_PUSH_GATEWAY_URL + +for recipe in mobile-dev mobile-build-android; do + for mode in omitted supplied; do + unset BUZZ_PUSH_GATEWAY_URL + if [[ "$mode" == supplied ]]; then + # A space catches accidental splitting. The build gate validates URLs; + # this test checks exact argument forwarding. + export BUZZ_PUSH_GATEWAY_URL='https://push.example/value with space' + fi + (cd "$fixture" && /bin/bash "$fixture/$recipe") + if [[ "$recipe" == mobile-dev ]]; then + printf '%s\n' run > "$fixture/expected" + else + printf '%s\n' build apk --debug --no-pub > "$fixture/expected" + fi + if [[ "$mode" == supplied ]]; then + printf '%s\n' "--dart-define=BUZZ_PUSH_GATEWAY_URL=$BUZZ_PUSH_GATEWAY_URL" >> "$fixture/expected" + fi + diff -u "$fixture/expected" "$CALL_LOG" + printf 'PASS %s %s\n' "$recipe" "$mode" + done +done + +unset BUZZ_PUSH_GATEWAY_URL +printf '%s\n' 'BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example' > "$fixture/mobile/ios/Flutter/AppOverrides.xcconfig" +(cd "$fixture" && /bin/bash "$fixture/mobile-dev") +printf '%s\n' run '--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example' > "$fixture/expected" +diff -u "$fixture/expected" "$CALL_LOG" +printf 'PASS mobile-dev Xcode override\n' 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(); } });