diff --git a/.env.example b/.env.example index 8e0c9c28fd..1216730968 100644 --- a/.env.example +++ b/.env.example @@ -120,6 +120,12 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # and provisioning requests fail closed until it is set. # RELAY_OPERATOR_API_ORIGIN=http://127.0.0.1:3000 +# Deployment-global operator-listener routes. Each configured Nostr identity +# may register target pubkeys and receives mention notifications at its URL. +# Format: pubkey:deliveryUrl;pubkey:deliveryUrl +# BUZZ_OPERATOR_LISTENER_TIMEOUT_MS=5000 +# BUZZ_OPERATOR_LISTENERS=<64-char hex pubkey>:https://listener.example/mentions + # Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and # authenticated desktop clients use this relay as the metadata/search proxy. # Keep the real value in your deployment's secret manager; never commit it. diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index b616b8e7f3..cf87d7a6ca 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -198,6 +198,8 @@ jobs: with: name: desktop-e2e-relay path: target/ci + - name: Restore relay executable permission + run: chmod +x ./target/ci/buzz-relay - name: PostgreSQL-backed tests env: BUZZ_POSTGRES_ADMIN_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/postgres diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ae82c131ec..8aecbd61cc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,6 +14,43 @@ EVENT, REQ, REST, media, git, search, workflow, or pub/sub handling. Unknown hosts fail closed, and NIP-98/API-token stamps must agree with the host-derived community rather than overriding it. +Deployment-root community management uses operator-signed NIP-98 HTTP requests. +`POST /operator/communities/delete` accepts only an exact normalized, archived +community whose asserted pubkey is still its owner. The caller supplies the +request UUID as the stable correlation/idempotency identity; the durable row +records owner intent, mediating operator, and acknowledgement version. Admission +returns `202` at the `submitted` stage and performs no inventory, approval, +quiescing, object-store access, or deletion execution synchronously. While that +non-aborted request exists, unarchive and ownership transfer conflict and owner +management lists suppress the archived row. Replaying the same UUID converges +to its current stage; a different UUID conflicts with the existing one-active- +request invariant until that request is aborted. + +Owner consent on this path is asserted, not proven. The mediating operator +authenticates the owner and collects the deletion acknowledgement out of band, +upstream of the relay; the request itself carries only the operator's NIP-98 +signature. The relay verifies operator authority and that the asserted pubkey +is still the community's owner, then records the owner pubkey, mediating +operator pubkey, and acknowledgement version as durable provenance for that +upstream ceremony. No owner-signed attestation is required or checked, and +owners have no self-service cancellation. Recovery is a privileged abort, +which stays open across the reversible `submitted`, `inventoried`, `approved`, +and `fenced` stages — releasing the request fence while leaving the community +archived — and closes from `drained` onward, when tenant-state destruction may +have begun. + +Manual operator handoff converges on an admitted owner request only when +`buzz-admin deletions submit --requested-by` repeats the owner pubkey recorded +on the row. Owner provenance pins `requested_by` to `owner_pubkey`, so passing +the operator's own pubkey does not converge — it conflicts with the existing +one-active-request invariant instead. + +Ownership is mutable only while a community is active. Archiving freezes the +current owner. Normal transfer and deployment-root legacy convergence take the +same community-row lock as owner-deletion admission, then reject archived, +quiescing, deleted, or deletion-pending rotation without changing membership. +Initial owner bootstrap for a newly created community remains supported. + Buzz is a Rust monorepo, licensed Apache 2.0 under Block, Inc. --- @@ -644,7 +681,7 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), | GET | `/.well-known/nostr.json` | NIP-05 identity | | GET | `/health` | Health check | | GET | `/_liveness` | Liveness probe | -| GET | `/_readiness` | Readiness probe | +| GET | `/_readiness` | Readiness probe — local process lifecycle only | | POST | `/events` | Submit a signed Nostr event over HTTP (same ingest path as WebSocket `EVENT`) | | POST | `/query` | Query Nostr events over HTTP with NIP-01 filters | | POST | `/count` | Count Nostr events over HTTP with NIP-45 filters | diff --git a/Justfile b/Justfile index d66e0b641c..0e67e7a099 100644 --- a/Justfile +++ b/Justfile @@ -461,7 +461,8 @@ test-unit: # `handlers::`: the wider set is mostly Postgres-backed, and five of its # non-postgres_tests cases only "pass" without a database by waiting out # the ~30s sqlx acquire timeout, so they do not belong in the infra-free - # unit job either. + # unit job either. The REQ subscription-lifecycle tests are picked by + # exact name for the same reason: the rest of handlers::req needs a DB. # The third clause adds the NIP-FI HTTP ingress and its router/config # neighbours: nip_fi_http, nip_fi_config, router, api::parse_query_tests, # and the Git transport off_mode_precedence_tests. All are infra-free @@ -472,7 +473,7 @@ test-unit: # because they live in the binary target; the nested # `tests::postgres_tests::` stays in the PostgreSQL lane. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/)) - test(/^tests::postgres_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. @@ -565,6 +566,10 @@ relay: bootstrap _ensure-migrations set +o allexport cargo run -p buzz-relay +# Post one root + N replies (and a few reactions) to the local dev relay, for eyeballing long threads +seed-long-thread replies="187": + ./scripts/seed-long-thread.sh {{replies}} + # Start the relay with the built web UI served from it relay-web: bootstrap _ensure-migrations #!/usr/bin/env bash diff --git a/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md b/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md index b6442d4f6d..c64d7e048b 100644 --- a/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md +++ b/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md @@ -12,8 +12,10 @@ created: 2026-08-28 routing**: the read may be served from a read replica when `BUZZ_REPLICA_READ_MAX_AGE_MS` is set, subject to the soundness predicate `RoutePredicate::for_query` derives from the query shape. The seam fails closed -to the writer at every error and is a genuine no-op until the budget is -configured (`crates/buzz-db/src/lib.rs`). +to the writer on replica errors, with one deliberate exception: a statement +cancelled by `statement_timeout` (SQLSTATE `57014`) is surfaced rather than +re-run, because the writer would run the same slow statement. It is a genuine +no-op until the budget is configured (`crates/buzz-db/src/lib.rs`). ## Routing rule diff --git a/RELEASING.md b/RELEASING.md index 8d1fad7480..34fc9b49da 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -89,6 +89,45 @@ prior release's recorded squash commit; tag ancestry is deliberately irrelevant. Every push to `main` continues to publish the rolling relay `:main` and `:sha-<7>` tags, plus matching `:debug-main` and `:debug-sha-<7>` variants. +#### NIP-FI upload proof freshness (relay floor) + +Desktop, CLI, and mobile clients mint Blossom upload authorization proofs with +a **60-second expiry** (`expiration = now + 60`). **Older** relays (≤ `v0.2.1`) +re-run the full auth verifier — including the expiry check — after receiving the +full upload body, so a 60-second proof that was valid when admitted can expire +during a large or slow transfer, causing a spurious rejection. + +The repair (commit `75e9bef748d2149ce459b14da842e706a51a5f78`, landed in this +PR) replaces the post-body full-verifier call with a hash-only check +(`verify_upload_hash_only`): the relay verifies the freshness and all other +proof fields before reading the body; the only check after transfer is that the +body's SHA-256 matches what was declared in the signed proof. + +Clients cannot lengthen proofs beyond 60 seconds (NIP-FI §Strict limits the +max proof window to 60 s). This is a relay-side concern: + +- **The relay release that includes this PR's changes** (the first relay + tag > `v0.2.1`, containing commit `75e9bef748d2149ce459b14da842e706a51a5f78`) + performs a hash-only post-body check, eliminating the expiry window during + transfer. **Update this line with the concrete relay tag once it is cut.** +- **Older self-hosted relays** (≤ `v0.2.1`) re-verify the full proof expiry + after transfer and may reject large uploads over slow connections. + Upgrading the relay is the fix; no client workaround exists within Strict + constraints. + +**Prerequisite before distributing desktop, CLI, or mobile builds that mint +60-second upload proofs:** verify that all supported relay environments have +been upgraded to a relay release containing commit +`75e9bef748d2149ce459b14da842e706a51a5f78`. An unknown or unverified deployment +does not satisfy this prerequisite. Deployments running ≤ `v0.2.1` must be +upgraded before receiving these clients. + +When releasing a new **relay** version that carries the post-body hash-only +check, record the concrete tag here so release operators can verify the +prerequisite. When releasing **desktop, CLI, or mobile** for environments where +self-hosted relays may be running pre-floor versions, document the floor in +release notes and require operators to upgrade before distributing these clients. + ### Mobile 1. **Publish a candidate.** From a clean checkout whose `origin` is the diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 6ade19f1ca..54441f55cc 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -327,7 +327,7 @@ fn sign_blossom_get(keys: &Keys, media_url: &str) -> Result { use nostr::Timestamp; let now = Timestamp::now().as_secs(); - let exp_str = (now + 600).to_string(); + let exp_str = (now + 60).to_string(); let domain = relay_server_tag(media_url) .ok_or_else(|| CliError::Usage(format!("invalid media URL: {media_url}")))?; let tags = vec![ @@ -350,28 +350,23 @@ fn sign_blossom_get(keys: &Keys, media_url: &str) -> Result { fn sign_blossom_upload( keys: &Keys, sha256: &str, - mime: &str, + _mime: &str, relay_url: &str, ) -> Result { use base64::engine::general_purpose::URL_SAFE_NO_PAD; use nostr::Timestamp; let now = Timestamp::now().as_secs(); - let expiry: u64 = if mime.starts_with("video/") { - 3600 - } else { - 600 - }; - let exp_str = (now + expiry).to_string(); + let exp_str = (now + 60).to_string(); + let domain = relay_server_tag(relay_url) + .ok_or_else(|| CliError::Usage(format!("invalid relay URL: {relay_url}")))?; - let mut tags = vec![ + let tags = vec![ Tag::parse(["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?, Tag::parse(["x", sha256]).map_err(|e| CliError::Other(e.to_string()))?, Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, + Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?, ]; - if let Some(domain) = relay_server_tag(relay_url) { - tags.push(Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?); - } let auth_event = EventBuilder::new(Kind::from(24242), "Upload file") .tags(tags) diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index e7d7f2d324..4f15cc2b24 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -84,5 +84,17 @@ pub enum DbError { LastOperator, } +impl DbError { + /// Whether Postgres cancelled the statement (SQLSTATE 57014: + /// `statement_timeout` or an explicit cancel). Re-running such a query on + /// the writer would only repeat the same expensive work. + pub fn is_statement_cancelled(&self) -> bool { + matches!( + self, + DbError::Sqlx(sqlx::Error::Database(db)) if db.code().as_deref() == Some("57014") + ) + } +} + /// Convenience alias for `Result`. pub type Result = std::result::Result; diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index e70c5dd85b..3dac0b1ba2 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -63,16 +63,17 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, - community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, - reaction, read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, - reminder, replaceable, storage_accounting, thread, thread_window, usage, user, workflow, + community, deletion, dm, event, feed, git_repo, moderation, operator_listener, partition, + product_feedback, push, reaction, read_state, relay_admin_actions, relay_invite, relay_members, + relay_operators, reminder, replaceable, storage_accounting, thread, thread_window, usage, user, + workflow, }; pub use allowlist::AllowlistEntry; pub use api_token::{ApiTokenRecord, TokenSummary}; pub use community::{ ArchivedCommunityRecord, CommunityRecord, CreateCommunityWithOwnerResult, - CreatedCommunityRecord, EnsuredCommunityRecord, OwnedCommunityRecord, + CreatedCommunityRecord, EnsuredCommunityRecord, OwnedCommunityRecord, UnarchiveCommunityResult, UnarchivedCommunityRecord, }; pub use error::{DbError, Result}; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 20496ac1cf..9eae1dc72b 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -490,6 +490,8 @@ mod postgres_tests { "relay_admin_outbox", "relay_operator_audit", "storage_accounting_snapshots", + "operator_listener_pubkeys", + "operator_listener_outbox", ] { if normalized[insert_pos..].contains(&format!("'{value}'")) { globals.insert(value.to_owned()); @@ -703,12 +705,17 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 49); + assert_eq!(migrations.len(), 51); assert_eq!(migrations[48].version, 49); + assert_eq!(migrations[50].version, 51); assert!(migrations[48] .sql .as_str() .contains("idx_thread_metadata_window")); + assert!(migrations[50] + .sql + .as_str() + .contains("community_deletion_owner_provenance")); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -760,6 +767,11 @@ mod postgres_tests { .as_str() .contains("CREATE INDEX idx_events_tags_gin")); assert!(!migrations[0].sql.as_str().contains("idx_events_tags_gin")); + // schema.sql (CI / isolated relay bootstrap) must carry the same index, + // or e-tag reads there run on plans prod never sees. + assert!(include_str!("../../../../schema/schema.sql").contains( + "CREATE INDEX idx_events_tags_gin ON events USING GIN (tags jsonb_path_ops)" + )); // NIP-AM (kind 44200) FTS exclusion: additive migration, never folded // into 0001 — folding would change 0001's checksum and break brownfield @@ -1293,10 +1305,63 @@ mod postgres_tests { .sql .as_str() .contains("CREATE TABLE storage_accounting_snapshots")); - // schema.sql exclusion list must match the restored (pre-0041) body. + assert_eq!(migrations[47].version, 48); + assert!(migrations[47] + .sql + .as_str() + .contains("ADD COLUMN hash_version SMALLINT")); + assert_eq!(migrations[49].version, 50); + assert!(migrations[49] + .sql + .as_str() + .contains("CREATE TABLE operator_listener_pubkeys")); + assert!(migrations[49] + .sql + .as_str() + .contains("CREATE TABLE operator_listener_outbox")); + assert!(migrations[49] + .sql + .as_str() + .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); + assert!(migrations[49] + .sql + .as_str() + .contains("'rate_limit_violations', 'operator_listener_outbox'")); + for index in [ + "operator_listener_pubkeys_target", + "operator_listener_pubkeys_created_at", + "operator_listener_outbox_due", + "operator_listener_outbox_recovery", + "operator_listener_outbox_created_at", + ] { + assert!( + migrations[49].sql.as_str().contains(index), + "migration 0050 must declare {index}" + ); + assert!( + desired_schema.contains(index), + "schema.sql must declare {index}" + ); + } + for index_shape in [ + "ON operator_listener_outbox (next_attempt_at, created_at, id)", + "ON operator_listener_outbox (lease_until, created_at, id)", + ] { + assert!( + migrations[49].sql.as_str().contains(index_shape), + "migration 0050 must declare {index_shape}" + ); + assert!( + desired_schema.contains(index_shape), + "schema.sql must declare {index_shape}" + ); + } + // schema.sql keeps the restored (pre-0041) deletion exclusions and also + // leaves the deployment-global listener outbox outside tenant fencing. assert!( - desired_schema.contains("'rate_limit_violations'\n ]::TEXT[])"), - "schema.sql exclusion list must match the pre-0041 body after ledger removal" + desired_schema + .contains("'rate_limit_violations', 'operator_listener_outbox'\n ]::TEXT[])"), + "schema.sql must exclude the deployment-global listener outbox from tenant fencing" ); } @@ -1765,6 +1830,12 @@ mod postgres_tests { .expect("embedded migration 0029") .sql .as_ref(); + let migration_0051: &str = MIGRATOR + .iter() + .find(|migration| migration.version == 51) + .expect("embedded migration 0051") + .sql + .as_ref(); let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .and_then(std::path::Path::parent) @@ -1773,6 +1844,7 @@ mod postgres_tests { .expect("read schema/schema.sql"); let migration = surface(migration_0029); + let owner_admission_migration = surface(migration_0051); let schema = surface(&schema_sql); assert_eq!( @@ -1801,13 +1873,42 @@ mod postgres_tests { .functions .get(function) .unwrap_or_else(|| panic!("schema.sql is missing deletion function {function}")); - if function != "community_write_fence_excluded_table" { + if function != "community_write_fence_excluded_table" + && function != "prevent_community_deletion_request_retargeting" + { assert_eq!( in_schema, definition, "schema.sql definition of {function}() drifted from migration 0029" ); } } + assert_eq!( + schema + .functions + .get("prevent_community_deletion_request_retargeting") + .expect("schema.sql deletion retargeting guard"), + owner_admission_migration + .functions + .get("prevent_community_deletion_request_retargeting") + .expect("0051 deletion retargeting guard"), + "schema.sql must carry the latest immutable owner-provenance guard" + ); + let request_table = schema + .tables + .get("community_deletion_requests") + .expect("schema.sql deletion request table"); + for owner_provenance_fragment in [ + "request_origin text not null default 'operator'", + "owner_pubkey text", + "mediating_operator_pubkey text", + "acknowledgement_version integer", + "constraint community_deletion_owner_provenance check", + ] { + assert!( + request_table.contains(owner_provenance_fragment), + "schema.sql deletion requests are missing {owner_provenance_fragment}" + ); + } for (trigger, definition) in &migration.triggers { let in_schema = schema .triggers @@ -2457,6 +2558,22 @@ mod postgres_tests { assert_eq!(after, vec![(1, Some(true)), (30_179, None), (30_350, None)]); } + /// Migration-upgrade half of the owner-provenance contract. + /// + /// The desired-state bootstrap half lives in + /// `store::deletion::postgres_tests` and asserts the same shared case + /// table, so `schema/schema.sql` cannot admit owner rows the migration + /// path refuses (or the reverse). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migrated_schema_enforces_owner_provenance_contract() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + run_migrations(&pool).await.expect("run migrations"); + + crate::store::deletion::owner_provenance_contract::assert_contract(&pool).await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn run_migrations_applies_consolidated_initial_schema_on_fresh_database() { diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 150c8f4768..bc1e0374c7 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -201,6 +201,8 @@ impl ReadSession { /// /// If the proved replica transaction fails mid-request, the session /// permanently degrades to the writer and the query is re-run there. + /// Exception: a statement cancelled by `statement_timeout` (57014) is + /// returned as-is, since the writer would run the same slow statement. /// The writer is always at or ahead of any replica replay position, so /// the degraded follow-up can only observe *more* than the proof-time /// snapshot, never less — fresher aux rows, the same failure semantics @@ -211,6 +213,9 @@ impl ReadSession { ReadSessionInner::Replica { tx, writer } => { match event::query_events_on(tx, q).await { Ok(rows) => return Ok(rows), + // A cancelled statement (timeout) would be just as slow on + // the writer; surface it instead of doubling the cost. + Err(e) if e.is_statement_cancelled() => return Err(e), Err(e) => { tracing::warn!( error = %e, diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index 7d70d0a643..3aa8e90685 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -638,6 +638,26 @@ async fn migration_schema_database_guard_covers_legacy_writer_and_nip09_deletion assert_eq!(watermark.1, c.id.as_bytes().as_slice()); } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_operator_listener_outbox_is_not_tenant_scoped() { + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let inventory = db + .deletion_store() + .inventory_schema(community) + .await + .expect("migrated deletion catalog should validate"); + + assert!( + !inventory + .scoped_tables + .iter() + .any(|table| table == "operator_listener_outbox"), + "operator-listener outbox is deployment-global, not tenant-scoped" + ); +} + // ---- Read-replica routing ------------------------------------------------ // // These tests pin the routing contract of `Db::read()` and the two routed @@ -3293,5 +3313,302 @@ async fn floor_guard_blocks_updates_that_move_rows_below_the_fence() { drop_scratch_db(&admin, pool, &name).await; } +// ---- Statement-deadline cancellation vs. replica fallback ---------------- +// +// A replica read cancelled by `statement_timeout` (SQLSTATE 57014) must be +// surfaced, not re-run on the writer: the writer would be just as slow and +// the relay maps it to a distinct non-retried "query timed out". Every other +// replica failure keeps failing closed to the writer. + +/// Writer and replica scratch DBs holding one root plus an `e`-tagged reply +/// that mentions `mentioned` — the writer copy says "writer-reply" so a +/// writer re-run is observable. Returns the reply-targeted query shape. +struct EtagFixture { + admin: PgPool, + writer: PgPool, + wname: String, + replica: PgPool, + rname: String, + cid: CommunityId, + root_hex: String, + mentioned_hex: String, +} + +async fn e_tag_fixture(prefix: &str) -> EtagFixture { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, &format!("{prefix}_w")).await; + let (replica, rname) = create_scratch_db(&admin, &format!("{prefix}_r")).await; + let author = nostr::Keys::generate(); + let mentioned = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + let base = 1_700_000_000u64; + let root = signed_event_at(&author, "root", base); + for (pool, content) in [(&writer, "writer-reply"), (&replica, "replica-reply")] { + seed_community_channel(pool, community, channel, &author).await; + insert_top_level(pool, community, channel, &root).await; + let reply = nostr::EventBuilder::new(nostr::Kind::Custom(9), content) + .tags([ + nostr::Tag::event(root.id), + nostr::Tag::public_key(mentioned.public_key()), + ]) + .custom_created_at(nostr::Timestamp::from(base + 10)) + .sign_with_keys(&author) + .expect("sign reply"); + insert_top_level(pool, community, channel, &reply).await; + crate::insert_mentions( + pool, + CommunityId::from_uuid(community), + &reply, + Some(channel), + ) + .await + .expect("insert mentions"); + } + EtagFixture { + admin, + writer, + wname, + replica, + rname, + cid: CommunityId::from_uuid(community), + root_hex: root.id.to_hex(), + mentioned_hex: mentioned.public_key().to_hex(), + } +} + +impl EtagFixture { + fn e_tag_query(&self) -> EventQuery { + let mut q = EventQuery::for_community(self.cid); + q.e_tags = Some(vec![self.root_hex.clone(), "00".repeat(32)]); + q + } + + /// A routed `Db` whose reader pool connects fresh (so it picks up any + /// `ALTER DATABASE` settings made on the replica), with the fence open + /// and a bounded budget so unpinned reads route to the replica. + async fn routed_db(&self) -> Db { + let base = admin_url().await; + let url = format!("{}/{}", &base[..base.rfind('/').expect("path")], self.rname); + let reader = PgPool::connect(&url).await.expect("connect reader"); + let mut db = Db::from_pools(self.writer.clone(), reader); + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + db + } + + async fn drop(self) { + drop_scratch_db(&self.admin, self.replica, &self.rname).await; + drop_scratch_db(&self.admin, self.writer, &self.wname).await; + } +} + +fn contents(rows: &[StoredEvent]) -> Vec<&str> { + rows.iter().map(|e| e.event.content.as_str()).collect() +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn replica_statement_cancel_propagates_without_writer_rerun() { + let fx = e_tag_fixture("cxl").await; + // A short operator cap on the replica (kept, not raised, by the e-tag + // deadline), plus a lock that parks every `events` read past it: the + // heartbeat proof still succeeds, then the routed read is cancelled. + sqlx::query(sqlx::AssertSqlSafe(format!( + "ALTER DATABASE {} SET statement_timeout = '300ms'", + fx.rname + ))) + .execute(&fx.admin) + .await + .expect("set replica statement_timeout"); + let db = fx.routed_db().await; + let q = fx.e_tag_query(); + + // Healthy control: the replica serves before it is locked. + let rows = db.query_events_routed("t_cxl", &q).await.expect("healthy"); + assert!( + contents(&rows).contains(&"replica-reply"), + "fixture must route to replica" + ); + + let mut locker = fx.replica.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock replica events"); + + let cancelled = |r: crate::Result<()>, what: &str| match r { + Err(e) => assert!(e.is_statement_cancelled(), "{what}: want 57014, got {e}"), + Ok(()) => panic!("{what}: replica cancel was re-run on the writer"), + }; + cancelled( + db.query_events_routed("t_cxl", &q).await.map(drop), + "query_events_routed", + ); + cancelled( + db.query_events_routed_bounded("t_cxl", &q).await.map(drop), + "query_events_routed_bounded", + ); + cancelled( + db.count_events_routed("t_cxl", &q).await.map(drop), + "count_events_routed", + ); + let root_id = hex::decode(&fx.root_hex).expect("hex"); + cancelled( + db.get_events_by_ids_routed("t_cxl", fx.cid, &[root_id.as_slice()]) + .await + .map(drop), + "get_events_by_ids_routed", + ); + + locker.rollback().await.expect("unlock"); + drop(db); + fx.drop().await; +} + +/// [`replica_statement_cancel_propagates_without_writer_rerun`] for the +/// request-scoped [`ReadSession`]: a cancelled aux read inside the proved +/// replica transaction surfaces 57014 and does not degrade to the writer. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn read_session_statement_cancel_propagates_without_writer_rerun() { + let fx = e_tag_fixture("scxl").await; + let db = fx.routed_db().await; + let reader_tx = db + .read_pool + .as_ref() + .expect("reader pool") + .begin() + .await + .expect("begin reader tx"); + let mut session = ReadSession { + inner: ReadSessionInner::Replica { + tx: reader_tx, + writer: fx.writer.clone(), + }, + }; + let rows = session + .query_events(&fx.e_tag_query()) + .await + .expect("healthy"); + assert_eq!(contents(&rows), vec!["replica-reply"]); + + // Force the next statement in the session over a short cap. + let ReadSessionInner::Replica { tx, .. } = &mut session.inner else { + unreachable!() + }; + sqlx::query("SET LOCAL statement_timeout = '50ms'") + .execute(&mut **tx) + .await + .expect("cap session"); + let mut locker = fx.replica.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock replica events"); + + match session.query_events(&fx.e_tag_query()).await { + Err(e) => assert!(e.is_statement_cancelled(), "want 57014, got {e}"), + Ok(rows) => panic!("cancel re-ran on the writer: {:?}", contents(&rows)), + } + assert!( + session.is_replica(), + "a cancel must not degrade the session" + ); + + locker.rollback().await.expect("unlock"); + drop(session); + drop(db); + fx.drop().await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn replica_non_cancel_failure_still_falls_back_to_writer() { + let fx = e_tag_fixture("nfb").await; + let db = fx.routed_db().await; + let q = fx.e_tag_query(); + let rows = db.query_events_routed("t_nfb", &q).await.expect("healthy"); + assert!( + contents(&rows).contains(&"replica-reply"), + "fixture must route to replica" + ); + + // Break the replica after the proof point (heartbeat table intact). + sqlx::query("DROP TABLE events CASCADE") + .execute(&fx.replica) + .await + .expect("drop replica events"); + + for rows in [ + db.query_events_routed("t_nfb", &q).await, + db.query_events_routed_bounded("t_nfb", &q).await, + ] { + let rows = rows.expect("non-cancel failure must fall back"); + assert!( + contents(&rows).contains(&"writer-reply"), + "{:?}", + contents(&rows) + ); + } + assert_eq!( + db.count_events_routed("t_nfb", &q) + .await + .expect("count falls back"), + 1 + ); + + drop(db); + fx.drop().await; +} + +/// The `#p`-joined SELECT (fenced, `e.` column prefix) and the COUNT `ANY` +/// path, executed with real bound values rather than only rendered SQL. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn e_tag_any_runs_with_real_binds_on_p_join_and_count() { + let fx = e_tag_fixture("pjn").await; + let mut q = fx.e_tag_query(); + + let rows = event::query_events(&fx.writer, &q) + .await + .expect("e-tag select"); + assert_eq!(contents(&rows), vec!["writer-reply"]); + assert_eq!( + event::count_events(&fx.writer, &q) + .await + .expect("e-tag count"), + 1 + ); + + q.p_tag_hex = Some(fx.mentioned_hex.clone()); + let rows = event::query_events(&fx.writer, &q) + .await + .expect("#p + e-tag select"); + assert_eq!(contents(&rows), vec!["writer-reply"]); + assert_eq!( + event::count_events(&fx.writer, &q) + .await + .expect("#p + e-tag count"), + 1 + ); + + q.p_tag_hex = Some("11".repeat(32)); + assert!(event::query_events(&fx.writer, &q) + .await + .expect("miss") + .is_empty()); + assert_eq!( + event::count_events(&fx.writer, &q) + .await + .expect("miss count"), + 0 + ); + + fx.drop().await; +} + #[path = "tests/thread_window_postgres_tests.rs"] mod thread_window_postgres_tests; diff --git a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs index 6062528d55..5d51f4348d 100644 --- a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs +++ b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs @@ -311,7 +311,7 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() production_result.is_ok(), "production migrator must preserve ingestion progress: {production_result:?}" ); - assert_eq!(version, 49); + assert_eq!(version, 51); assert_eq!(final_oid, oid, "prebuild must not be replaced"); assert_eq!(count, 4, "all writer witnesses must persist"); } diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index bcd38f4e5c..461348a5cd 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -81,6 +81,17 @@ pub struct UnarchivedCommunityRecord { pub host: String, } +/// Result of an owner-authorized unarchive attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UnarchiveCommunityResult { + /// The community is active, with archive state cleared idempotently. + Unarchived(UnarchivedCommunityRecord), + /// Durable deletion intent exists and wins over restoration. + DeletionPending, + /// The host is absent, unavailable, or not owned by the asserted pubkey. + NotFound, +} + impl Db { /// Returns the community mapped to a normalized request host, if one exists. /// @@ -209,6 +220,10 @@ impl Db { JOIN relay_members rm ON rm.community_id = c.id WHERE rm.pubkey = $1 AND rm.role = 'owner' + AND NOT EXISTS ( + SELECT 1 FROM community_deletion_requests request + WHERE request.community_id = c.id AND request.stage <> 'aborted' + ) ORDER BY c.created_at ASC, c.host ASC "#, ) @@ -531,40 +546,69 @@ impl Db { } /// Idempotently restores a community when the asserted pubkey is its current owner. + /// + /// Locks the community row so owner-deletion admission and restoration have + /// one serial order. A non-aborted deletion request returns + /// [`UnarchiveCommunityResult::DeletionPending`] without clearing archive state. #[datastore_span(name = "unarchive_community_owned_by", system = "postgresql")] pub async fn unarchive_community_owned_by( &self, normalized_host: &str, owner_pubkey: &str, - ) -> Result> { - let mut connection = crate::observability::acquire_writer( + ) -> Result { + let connection = crate::observability::acquire_writer( &self.pool, crate::observability::WriterOperation::Authorization, ) .await?; - let row = sqlx::query( - r#"UPDATE communities c - SET archived_at = NULL - FROM relay_members rm - WHERE lower(c.host) = lower($1) - AND rm.community_id = c.id - AND lower(rm.pubkey) = lower($2) - AND rm.role = 'owner' - AND c.deletion_state = 'active' - AND c.deleted_at IS NULL - RETURNING c.id, c.host"#, + let mut tx = sqlx::Transaction::begin(connection, None).await?; + let target = sqlx::query( + "SELECT id, host FROM communities \ + WHERE lower(host) = lower($1) AND deletion_state = 'active' \ + AND deleted_at IS NULL FOR UPDATE", ) .bind(normalized_host) - .bind(owner_pubkey) - .fetch_optional(&mut *connection) + .fetch_optional(&mut *tx) .await?; - row.map(|row| { - Ok(UnarchivedCommunityRecord { - id: CommunityId::from_uuid(row.try_get("id")?), - host: row.try_get("host")?, - }) - }) - .transpose() + let Some(target) = target else { + tx.rollback().await?; + return Ok(UnarchiveCommunityResult::NotFound); + }; + let community_id: Uuid = target.try_get("id")?; + let is_owner: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM relay_members \ + WHERE community_id = $1 AND lower(pubkey) = lower($2) AND role = 'owner')", + ) + .bind(community_id) + .bind(owner_pubkey) + .fetch_one(&mut *tx) + .await?; + if !is_owner { + tx.rollback().await?; + return Ok(UnarchiveCommunityResult::NotFound); + } + let deletion_pending: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM community_deletion_requests \ + WHERE community_id = $1 AND stage <> 'aborted')", + ) + .bind(community_id) + .fetch_one(&mut *tx) + .await?; + if deletion_pending { + tx.rollback().await?; + return Ok(UnarchiveCommunityResult::DeletionPending); + } + sqlx::query("UPDATE communities SET archived_at = NULL WHERE id = $1") + .bind(community_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(UnarchiveCommunityResult::Unarchived( + UnarchivedCommunityRecord { + id: CommunityId::from_uuid(community_id), + host: target.try_get("host")?, + }, + )) } /// Returns the community that owns a channel, if the channel exists. @@ -900,22 +944,26 @@ mod postgres_tests { .is_none(), "archived communities must fail admission" ); - assert!(db - .unarchive_community_owned_by(&host, &outsider) - .await - .expect("wrong-owner unarchive") - .is_none()); - assert!(db - .unarchive_community_owned_by("missing.example", &owner) - .await - .expect("unknown-host unarchive") - .is_none()); + assert_eq!( + db.unarchive_community_owned_by(&host, &outsider) + .await + .expect("wrong-owner unarchive"), + UnarchiveCommunityResult::NotFound + ); + assert_eq!( + db.unarchive_community_owned_by("missing.example", &owner) + .await + .expect("unknown-host unarchive"), + UnarchiveCommunityResult::NotFound + ); let restored = db .unarchive_community_owned_by(&host.to_ascii_uppercase(), &owner) .await - .expect("unarchive community") - .expect("owned community"); + .expect("unarchive community"); + let UnarchiveCommunityResult::Unarchived(restored) = restored else { + panic!("expected owned community") + }; assert_eq!(restored.id, created.id); assert_eq!(restored.host, host); assert_eq!( @@ -938,9 +986,8 @@ mod postgres_tests { let retry = db .unarchive_community_owned_by(&host, &owner) .await - .expect("idempotent retry") - .expect("owned community"); - assert_eq!(retry, restored); + .expect("idempotent retry"); + assert_eq!(retry, UnarchiveCommunityResult::Unarchived(restored)); } #[tokio::test] diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 0e184e00d8..cdd13d94c8 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -27,6 +27,8 @@ pub const POSTGRES_STORE_NAME: &str = "postgres"; pub const OBJECT_STORE_NAME: &str = "object_store"; /// Durable name of the Redis/cache manifest component. pub const REDIS_STORE_NAME: &str = "redis"; +/// Owner acknowledgement contract accepted by the first self-serve deletion API. +pub const OWNER_DELETION_ACKNOWLEDGEMENT_VERSION: i32 = 1; /// Deployment-global advisory-lock key serializing schema migration with /// destructive deletion. @@ -220,7 +222,7 @@ impl FromStr for DeletionStage { } /// Durable community deletion request. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct DeletionRequest { /// Request identifier. pub id: Uuid, @@ -233,8 +235,16 @@ pub struct DeletionRequest { pub stage: DeletionStage, /// Stage at which the current consecutive retry streak started. pub retry_stage: Option, - /// Operator identity that submitted the request. + /// Legacy display identity that submitted the request. pub requested_by: String, + /// Whether the request originated from an operator or authenticated owner intent. + pub request_origin: DeletionRequestOrigin, + /// Current owner identity authenticated at owner-request admission. + pub owner_pubkey: Option, + /// Deployment operator that mediated the authenticated owner intent. + pub mediating_operator_pubkey: Option, + /// Owner-facing destructive-action acknowledgement contract version. + pub acknowledgement_version: Option, /// Optional request reason. pub reason: Option, /// Frozen catalog manifest. @@ -283,6 +293,47 @@ pub struct DeletionRequest { pub completed_at: Option>, } +/// Durable provenance class for a community deletion request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DeletionRequestOrigin { + /// Request was submitted directly by a deployment operator. + Operator, + /// Request records authenticated owner intent mediated by an operator. + Owner, +} + +impl FromStr for DeletionRequestOrigin { + type Err = DbError; + + fn from_str(value: &str) -> std::result::Result { + match value { + "operator" => Ok(Self::Operator), + "owner" => Ok(Self::Owner), + other => Err(DbError::InvalidData(format!( + "unknown community deletion request origin: {other}" + ))), + } + } +} + +/// Result of atomically admitting authenticated owner deletion intent. +#[derive(Debug, Clone, PartialEq)] +pub enum OwnerDeletionAdmission { + /// A new request was created or the stable request UUID converged to its row. + Accepted(Box), + /// The exact host is absent or the asserted owner is no longer current. + NotFoundOrNotOwner, + /// The community exists and is current-owner controlled, but is not archived. + NotArchived, + /// The community is already quiescing, fenced, or deleted. + LifecycleConflict, + /// The request UUID targets different intent, or another active request exists. + RequestConflict, + /// The owner acknowledgement contract is not supported. + UnsupportedAcknowledgementVersion, +} + /// Frozen PostgreSQL catalog inventory. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SchemaManifest { @@ -697,6 +748,12 @@ impl DeletionStore { } /// Persist a request. Only active non-tombstone communities may be submitted. + /// + /// `requested_by` is recorded on a new row and is also the convergence key + /// when a `submitted` request already exists. Owner provenance pins an + /// owner-origin request's `requested_by` to its `owner_pubkey`, so taking + /// one over manually means passing that owner pubkey; the operator's own + /// pubkey conflicts with the existing request instead of converging. pub async fn submit( &self, community_host: &str, @@ -742,6 +799,137 @@ impl DeletionStore { } } + /// Atomically admit an archived current owner's deletion intent. + /// + /// `request_id` is both the durable request id and the caller's stable + /// correlation/idempotency identity. Replays return the existing request at + /// its current stage. This operation only persists intent; it never + /// inventories, approves, quiesces, or executes deletion. + /// + /// The owner's consent arrives as the calling operator's assertion: the + /// operator authenticated the owner and collected the acknowledgement + /// upstream. This layer records that provenance and checks that + /// `owner_pubkey` is still the community's owner; it never verifies an + /// owner-signed attestation. + pub async fn admit_owner_request( + &self, + normalized_community_host: &str, + owner_pubkey: &str, + mediating_operator_pubkey: &str, + acknowledgement_version: i32, + request_id: Uuid, + ) -> Result { + if acknowledgement_version != OWNER_DELETION_ACKNOWLEDGEMENT_VERSION { + return Ok(OwnerDeletionAdmission::UnsupportedAcknowledgementVersion); + } + let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let mediating_operator_pubkey = mediating_operator_pubkey.to_ascii_lowercase(); + let mut tx = self.pool.begin().await?; + + sqlx::query( + "SELECT pg_advisory_xact_lock(hashtextextended('buzz-owner-deletion-intent:' || $1::text, 0))", + ) + .bind(request_id) + .execute(&mut *tx) + .await?; + + if let Some(row) = sqlx::query("SELECT * FROM community_deletion_requests WHERE id = $1") + .bind(request_id) + .fetch_optional(&mut *tx) + .await? + { + let existing = row_to_request(row)?; + let converges = existing.community_host == normalized_community_host + && existing.request_origin == DeletionRequestOrigin::Owner + && existing.owner_pubkey.as_deref() == Some(owner_pubkey.as_str()) + && existing.acknowledgement_version == Some(acknowledgement_version); + tx.rollback().await?; + return Ok(if converges { + OwnerDeletionAdmission::Accepted(Box::new(existing)) + } else { + OwnerDeletionAdmission::RequestConflict + }); + } + + let target = sqlx::query( + "SELECT id, host, archived_at, deletion_state, deleted_at \ + FROM communities WHERE host = $1 FOR UPDATE", + ) + .bind(normalized_community_host) + .fetch_optional(&mut *tx) + .await?; + let Some(target) = target else { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::NotFoundOrNotOwner); + }; + let community_id: Uuid = target.try_get("id")?; + let canonical_host: String = target.try_get("host")?; + let deletion_state: String = target.try_get("deletion_state")?; + let deleted_at: Option> = target.try_get("deleted_at")?; + if deletion_state != "active" || deleted_at.is_some() { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::LifecycleConflict); + } + + let owner_exists = sqlx::query_scalar::<_, String>( + "SELECT pubkey FROM relay_members \ + WHERE community_id = $1 AND pubkey = $2 AND role = 'owner' FOR UPDATE", + ) + .bind(community_id) + .bind(&owner_pubkey) + .fetch_optional(&mut *tx) + .await? + .is_some(); + if !owner_exists { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::NotFoundOrNotOwner); + } + if target + .try_get::>, _>("archived_at")? + .is_none() + { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::NotArchived); + } + let active_request_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM community_deletion_requests \ + WHERE community_id = $1 AND stage <> 'aborted')", + ) + .bind(community_id) + .fetch_one(&mut *tx) + .await?; + if active_request_exists { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::RequestConflict); + } + + let row = sqlx::query( + r#" + INSERT INTO community_deletion_requests ( + id, community_id, community_host, requested_by, request_origin, + owner_pubkey, mediating_operator_pubkey, acknowledgement_version + ) VALUES ($1, $2, $3, $4, 'owner', $4, $5, $6) + ON CONFLICT (community_id) WHERE stage <> 'aborted' DO NOTHING + RETURNING * + "#, + ) + .bind(request_id) + .bind(community_id) + .bind(canonical_host) + .bind(owner_pubkey) + .bind(mediating_operator_pubkey) + .bind(acknowledgement_version) + .fetch_optional(&mut *tx) + .await?; + let Some(row) = row else { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::RequestConflict); + }; + let request = row_to_request(row)?; + tx.commit().await?; + Ok(OwnerDeletionAdmission::Accepted(Box::new(request))) + } + /// List requests newest first with a hard bound. pub async fn list(&self, limit: i64) -> Result> { let rows = sqlx::query( @@ -2010,7 +2198,17 @@ impl DeletionStore { Ok(()) } - /// Terminally abort an approved or fenced request before object deletion begins. + /// Terminally abort a request at the reversible pre-destruction boundary. + /// + /// `submitted`, `inventoried`, `approved`, and `fenced` are reversible: + /// nothing tenant-visible has been destroyed, so abort releases the durable + /// request fence over owner listing, unarchive, and owner rotation. Owner + /// admission deliberately has no owner-facing cancellation, so this + /// privileged path is the only recovery when preparation cannot continue. + /// Abort reverses deletion intent, not the owner's archive decision: the + /// community stays archived and the owner restores it explicitly. + /// Stages from `drained` onward stay closed because tenant-state destruction + /// may have begun. pub async fn abort( &self, request_id: Uuid, @@ -2049,7 +2247,10 @@ impl DeletionStore { } if !matches!( request.stage, - DeletionStage::Approved | DeletionStage::Fenced + DeletionStage::Submitted + | DeletionStage::Inventoried + | DeletionStage::Approved + | DeletionStage::Fenced ) { return Err(DbError::DeletionSafety(format!( "deletion {request_id} at stage {} cannot be aborted", @@ -2674,7 +2875,7 @@ async fn lock_community_deletion( Ok(()) } -async fn lock_community_deletion_shared( +pub(crate) async fn lock_community_deletion_shared( tx: &mut Transaction<'_, Postgres>, community: CommunityId, ) -> Result<()> { @@ -3112,6 +3313,10 @@ fn row_to_request(row: sqlx::postgres::PgRow) -> Result { .map(|stage| stage.parse()) .transpose()?, requested_by: row.try_get("requested_by")?, + request_origin: row.try_get::("request_origin")?.parse()?, + owner_pubkey: row.try_get("owner_pubkey")?, + mediating_operator_pubkey: row.try_get("mediating_operator_pubkey")?, + acknowledgement_version: row.try_get("acknowledgement_version")?, reason: row.try_get("reason")?, schema_manifest: row.try_get("schema_manifest")?, storage_manifest: row.try_get("storage_manifest")?, @@ -3413,10 +3618,216 @@ mod tests { } } +/// Shared `community_deletion_owner_provenance` contract cases. +/// +/// The same table is asserted against the migration-upgrade schema +/// (`runtime::migration::postgres_tests`) and the desired-state bootstrap +/// schema (`postgres_tests` below), so the two schema sources cannot drift +/// into different owner-provenance guarantees. +#[cfg(test)] +pub(crate) mod owner_provenance_contract { + use sqlx::{PgPool, Row}; + use uuid::Uuid; + + /// Constraint that must reject every malformed owner-provenance row. + pub(crate) const CONSTRAINT: &str = "community_deletion_owner_provenance"; + + const VALID_OWNER: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const VALID_OPERATOR: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + + /// One rejected owner-origin row: `(case name, owner, mediator, ack, requested_by)`. + type Case = ( + &'static str, + Option<&'static str>, + Option<&'static str>, + Option, + &'static str, + ); + + /// Every owner-origin row the constraint must refuse. + pub(crate) fn rejected_cases() -> Vec { + vec![ + ( + "missing owner_pubkey", + None, + Some(VALID_OPERATOR), + Some(1), + VALID_OWNER, + ), + ( + "missing mediating_operator_pubkey", + Some(VALID_OWNER), + None, + Some(1), + VALID_OWNER, + ), + ( + "missing acknowledgement_version", + Some(VALID_OWNER), + Some(VALID_OPERATOR), + None, + VALID_OWNER, + ), + ( + "owner_pubkey too short", + Some("abc"), + Some(VALID_OPERATOR), + Some(1), + "abc", + ), + ( + "owner_pubkey uppercase hex", + Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + Some(VALID_OPERATOR), + Some(1), + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ), + ( + "owner_pubkey non-hex", + Some("zzzz111111111111111111111111111111111111111111111111111111111111"), + Some(VALID_OPERATOR), + Some(1), + "zzzz111111111111111111111111111111111111111111111111111111111111", + ), + ( + "mediating_operator_pubkey too short", + Some(VALID_OWNER), + Some("abc"), + Some(1), + VALID_OWNER, + ), + ( + "mediating_operator_pubkey uppercase hex", + Some(VALID_OWNER), + Some("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + Some(1), + VALID_OWNER, + ), + ( + "acknowledgement_version zero", + Some(VALID_OWNER), + Some(VALID_OPERATOR), + Some(0), + VALID_OWNER, + ), + ( + "acknowledgement_version negative", + Some(VALID_OWNER), + Some(VALID_OPERATOR), + Some(-1), + VALID_OWNER, + ), + ( + "requested_by is not the owner", + Some(VALID_OWNER), + Some(VALID_OPERATOR), + Some(1), + VALID_OPERATOR, + ), + ] + } + + async fn seed_community(pool: &PgPool) -> Uuid { + let host = format!("owner-provenance-{}.example", Uuid::new_v4().simple()); + sqlx::query_scalar::<_, Uuid>("INSERT INTO communities (host) VALUES ($1) RETURNING id") + .bind(&host) + .fetch_one(pool) + .await + .expect("seed owner-provenance community") + } + + async fn insert_owner_row( + pool: &PgPool, + owner: Option<&str>, + mediator: Option<&str>, + acknowledgement: Option, + requested_by: &str, + ) -> Result<(), sqlx::Error> { + let community_id = seed_community(pool).await; + let host: String = sqlx::query("SELECT host FROM communities WHERE id = $1") + .bind(community_id) + .fetch_one(pool) + .await + .expect("seeded host") + .try_get("host") + .expect("host column"); + sqlx::query( + "INSERT INTO community_deletion_requests \ + (id, community_id, community_host, requested_by, request_origin, \ + owner_pubkey, mediating_operator_pubkey, acknowledgement_version) \ + VALUES ($1, $2, $3, $4, 'owner', $5, $6, $7)", + ) + .bind(Uuid::new_v4()) + .bind(community_id) + .bind(host) + .bind(requested_by) + .bind(owner) + .bind(mediator) + .bind(acknowledgement) + .execute(pool) + .await + .map(|_| ()) + } + + /// Assert the live schema refuses every malformed owner row and still + /// admits a well-formed one. + pub(crate) async fn assert_contract(pool: &PgPool) { + for (name, owner, mediator, acknowledgement, requested_by) in rejected_cases() { + let error = insert_owner_row(pool, owner, mediator, acknowledgement, requested_by) + .await + .expect_err(&format!("owner-provenance case must be rejected: {name}")); + let constraint = error + .as_database_error() + .and_then(sqlx::error::DatabaseError::constraint); + assert_eq!( + constraint, + Some(CONSTRAINT), + "case {name:?} must fail {CONSTRAINT}, got: {error}" + ); + } + + // Falsifiability: the constraint must still admit well-formed intent. + insert_owner_row( + pool, + Some(VALID_OWNER), + Some(VALID_OPERATOR), + Some(1), + VALID_OWNER, + ) + .await + .expect("well-formed owner provenance must be accepted"); + + // The operator branch stays exclusive of owner columns. + let community_id = seed_community(pool).await; + let operator_with_owner_columns = sqlx::query( + "INSERT INTO community_deletion_requests \ + (id, community_id, community_host, requested_by, request_origin, owner_pubkey) \ + VALUES ($1, $2, 'operator.example', 'operator', 'operator', $3)", + ) + .bind(Uuid::new_v4()) + .bind(community_id) + .bind(VALID_OWNER) + .execute(pool) + .await; + assert_eq!( + operator_with_owner_columns + .expect_err("operator-origin rows must not carry owner provenance") + .as_database_error() + .and_then(sqlx::error::DatabaseError::constraint), + Some(CONSTRAINT) + ); + } +} + #[cfg(test)] mod postgres_tests { use super::*; - use crate::{CreateCommunityWithOwnerResult, Db, DbConfig}; + use crate::{ + relay_members::{ProvisionOwnerResult, TransferResult}, + CreateCommunityWithOwnerResult, Db, DbConfig, UnarchiveCommunityResult, + }; + use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + use std::str::FromStr; async fn store() -> (Db, DeletionStore) { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") @@ -3485,6 +3896,914 @@ mod postgres_tests { (request, inventory) } + async fn archived_owned_community(db: &Db) -> (String, String, CommunityId) { + let host = format!("owner-delete-{}.example", Uuid::new_v4().simple()); + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let CreateCommunityWithOwnerResult::Created(created) = db + .create_community_with_owner(&host, &owner) + .await + .expect("create owned community") + else { + panic!("expected a fresh community") + }; + db.archive_community_owned_by(&host, &owner, "protected.example") + .await + .expect("archive community") + .expect("owned community"); + (host, owner, created.id) + } + + struct AdmissionInsertGate { + connection: sqlx::pool::PoolConnection, + trigger_name: String, + function_name: String, + first_key: i32, + second_key: i32, + } + + async fn install_admission_insert_gate(db: &Db, request_id: Uuid) -> AdmissionInsertGate { + let suffix = Uuid::new_v4().simple().to_string(); + let trigger_name = format!("owner_admission_gate_{suffix}"); + let function_name = format!("owner_admission_gate_fn_{suffix}"); + let first_key = (request_id.as_u128() as u32 & 0x7fff_ffff) as i32; + let second_key = ((request_id.as_u128() >> 32) as u32 & 0x7fff_ffff) as i32; + let mut connection = db.pool.acquire().await.expect("acquire gate connection"); + sqlx::query("SELECT pg_advisory_lock(712345, 193847)") + .execute(&mut *connection) + .await + .expect("serialize admission gate fixtures"); + sqlx::query("SELECT pg_advisory_lock($1, $2)") + .bind(first_key) + .bind(second_key) + .execute(&mut *connection) + .await + .expect("hold admission gate"); + sqlx::query(AssertSqlSafe(format!( + "CREATE FUNCTION {function_name}() RETURNS trigger LANGUAGE plpgsql AS $$ \ + BEGIN \ + IF NEW.id = '{request_id}'::uuid THEN \ + PERFORM pg_advisory_xact_lock({first_key}, {second_key}); \ + END IF; \ + RETURN NEW; \ + END $$" + ))) + .execute(&db.pool) + .await + .expect("install admission gate function"); + sqlx::query(AssertSqlSafe(format!( + "CREATE TRIGGER {trigger_name} BEFORE INSERT ON community_deletion_requests \ + FOR EACH ROW EXECUTE FUNCTION {function_name}()" + ))) + .execute(&db.pool) + .await + .expect("install admission gate trigger"); + AdmissionInsertGate { + connection, + trigger_name, + function_name, + first_key, + second_key, + } + } + + async fn wait_for_admission_gate(db: &Db, gate: &AdmissionInsertGate) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_locks \ + WHERE locktype = 'advisory' AND classid = $1::oid AND objid = $2::oid \ + AND objsubid = 2 AND NOT granted)", + ) + .bind(gate.first_key) + .bind(gate.second_key) + .fetch_one(&db.pool) + .await + .expect("inspect admission gate"); + if waiting { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("owner admission reached gated insert"); + } + + async fn release_admission_insert_gate(gate: &mut AdmissionInsertGate) { + sqlx::query("SELECT pg_advisory_unlock($1, $2)") + .bind(gate.first_key) + .bind(gate.second_key) + .execute(&mut *gate.connection) + .await + .expect("release admission gate"); + } + + async fn remove_admission_insert_gate(db: &Db, mut gate: AdmissionInsertGate) { + sqlx::query(AssertSqlSafe(format!( + "DROP TRIGGER {} ON community_deletion_requests", + gate.trigger_name + ))) + .execute(&db.pool) + .await + .expect("remove admission gate trigger"); + sqlx::query(AssertSqlSafe(format!( + "DROP FUNCTION {}()", + gate.function_name + ))) + .execute(&db.pool) + .await + .expect("remove admission gate function"); + sqlx::query("SELECT pg_advisory_unlock(712345, 193847)") + .execute(&mut *gate.connection) + .await + .expect("release admission fixture serialization"); + } + + struct OwnerConvergenceGate { + connection: sqlx::pool::PoolConnection, + trigger_name: String, + function_name: String, + first_key: i32, + second_key: i32, + } + + async fn install_owner_convergence_gate( + db: &Db, + community: CommunityId, + owner: &str, + ) -> OwnerConvergenceGate { + let suffix = Uuid::new_v4().simple().to_string(); + let trigger_name = format!("a_owner_convergence_gate_{suffix}"); + let function_name = format!("owner_convergence_gate_fn_{suffix}"); + let gate_id = Uuid::new_v4(); + let first_key = (gate_id.as_u128() as u32 & 0x7fff_ffff) as i32; + let second_key = ((gate_id.as_u128() >> 32) as u32 & 0x7fff_ffff) as i32; + let mut connection = db.pool.acquire().await.expect("acquire gate connection"); + sqlx::query("SELECT pg_advisory_lock($1, $2)") + .bind(first_key) + .bind(second_key) + .execute(&mut *connection) + .await + .expect("hold owner convergence gate"); + sqlx::query(AssertSqlSafe(format!( + "CREATE FUNCTION {function_name}() RETURNS trigger LANGUAGE plpgsql AS $$ \ + BEGIN \ + IF NEW.community_id = '{}'::uuid AND NEW.pubkey = '{}' THEN \ + PERFORM pg_advisory_xact_lock({first_key}, {second_key}); \ + END IF; \ + RETURN NEW; \ + END $$", + community.as_uuid(), + owner + ))) + .execute(&db.pool) + .await + .expect("install owner convergence gate function"); + sqlx::query(AssertSqlSafe(format!( + "CREATE TRIGGER {trigger_name} BEFORE INSERT ON relay_members \ + FOR EACH ROW EXECUTE FUNCTION {function_name}()" + ))) + .execute(&db.pool) + .await + .expect("install owner convergence gate trigger"); + OwnerConvergenceGate { + connection, + trigger_name, + function_name, + first_key, + second_key, + } + } + + async fn wait_for_owner_convergence_gate(db: &Db, gate: &OwnerConvergenceGate) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_locks \ + WHERE locktype = 'advisory' AND classid = $1::oid AND objid = $2::oid \ + AND objsubid = 2 AND NOT granted)", + ) + .bind(gate.first_key) + .bind(gate.second_key) + .fetch_one(&db.pool) + .await + .expect("inspect owner convergence gate"); + if waiting { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("owner convergence reached gated member update"); + } + + async fn release_owner_convergence_gate(gate: &mut OwnerConvergenceGate) { + sqlx::query("SELECT pg_advisory_unlock($1, $2)") + .bind(gate.first_key) + .bind(gate.second_key) + .execute(&mut *gate.connection) + .await + .expect("release owner convergence gate"); + } + + async fn remove_owner_convergence_gate(db: &Db, gate: OwnerConvergenceGate) { + sqlx::query(AssertSqlSafe(format!( + "DROP TRIGGER {} ON relay_members", + gate.trigger_name + ))) + .execute(&db.pool) + .await + .expect("remove owner convergence gate trigger"); + sqlx::query(AssertSqlSafe(format!( + "DROP FUNCTION {}()", + gate.function_name + ))) + .execute(&db.pool) + .await + .expect("remove owner convergence gate function"); + } + + async fn contender_db(application_name: &str) -> Db { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let options = PgConnectOptions::from_str(&database_url) + .expect("parse test database URL") + .application_name(application_name); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("connect contender DB"); + Db::from_pool(pool) + } + + async fn wait_for_contender_lock(db: &Db, application_name: &str) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity \ + WHERE datname = current_database() AND application_name = $1 \ + AND wait_event_type = 'Lock')", + ) + .bind(application_name) + .fetch_one(&db.pool) + .await + .expect("inspect contender lock"); + if waiting { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("contender reached community row lock"); + } + + async fn membership_roles(db: &Db, community: CommunityId) -> Vec<(String, String)> { + sqlx::query_as( + "SELECT pubkey, role FROM relay_members WHERE community_id = $1 ORDER BY pubkey", + ) + .bind(community.as_uuid()) + .fetch_all(&db.pool) + .await + .expect("read membership roles") + } + + async fn assert_owner_admission_is_only_committed_mutation( + db: &Db, + community: CommunityId, + request_id: Uuid, + ) { + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM community_deletion_requests \ + WHERE id = $1 AND community_id = $2 AND stage = 'submitted'", + ) + .bind(request_id) + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("count admitted request"), + 1 + ); + assert!( + sqlx::query_scalar::<_, bool>( + "SELECT archived_at IS NOT NULL FROM communities WHERE id = $1", + ) + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("read archive state"), + "the losing mutation must not clear archive state" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_admission_requires_current_owner_and_archived_active_target() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let request_id = Uuid::new_v4(); + + let admitted = store + .admit_owner_request(&host, &owner, operator, 1, request_id) + .await + .expect("admit archived owner request"); + let OwnerDeletionAdmission::Accepted(request) = admitted else { + panic!("expected accepted owner request") + }; + assert_eq!(request.id, request_id); + assert_eq!(request.community_id, community); + assert_eq!(request.community_host, host); + assert_eq!(request.stage, DeletionStage::Submitted); + assert_eq!(request.request_origin, DeletionRequestOrigin::Owner); + assert_eq!(request.owner_pubkey.as_deref(), Some(owner.as_str())); + assert_eq!(request.mediating_operator_pubkey.as_deref(), Some(operator)); + assert_eq!(request.acknowledgement_version, Some(1)); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT deletion_state FROM communities WHERE id = $1") + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("community lifecycle"), + "active", + "admission must not prematurely quiesce the community" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_admission_rejects_non_archived_non_owner_and_stale_owner() { + let (db, store) = store().await; + let host = format!("owner-delete-active-{}.example", Uuid::new_v4().simple()); + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let replacement = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let outsider = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let CreateCommunityWithOwnerResult::Created(created) = db + .create_community_with_owner(&host, &owner) + .await + .expect("create community") + else { + panic!("expected fresh community") + }; + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + assert_eq!( + store + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .await + .expect("non-archived admission result"), + OwnerDeletionAdmission::NotArchived + ); + db.archive_community_owned_by(&host, &owner, "protected.example") + .await + .expect("archive") + .expect("owned community"); + assert_eq!( + store + .admit_owner_request(&host, &outsider, operator, 1, Uuid::new_v4()) + .await + .expect("non-owner admission result"), + OwnerDeletionAdmission::NotFoundOrNotOwner + ); + assert_eq!( + db.transfer_ownership(created.id, &replacement, &owner) + .await + .expect("reject archived owner rotation"), + TransferResult::LifecycleConflict + ); + assert!(matches!( + store + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .await + .expect("current-owner admission result"), + OwnerDeletionAdmission::Accepted(_) + )); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn accepted_owner_deletion_blocks_legacy_owner_convergence_without_membership_change() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let replacement = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let before = membership_roles(&db, community).await; + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + assert!(matches!( + store + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .await + .expect("admit owner request"), + OwnerDeletionAdmission::Accepted(_) + )); + + assert_eq!( + db.provision_owner(community, &replacement) + .await + .expect("legacy convergence result"), + ProvisionOwnerResult::DeletionPending + ); + assert_eq!(membership_roles(&db, community).await, before); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn same_owner_convergence_serializes_with_abort_without_deadlock() { + let mut deadlocks = Vec::new(); + for stage in [DeletionStage::Submitted, DeletionStage::Inventoried] { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let OwnerDeletionAdmission::Accepted(request) = store + .admit_owner_request( + &host, + &owner, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 1, + Uuid::new_v4(), + ) + .await + .expect("admit owner deletion") + else { + panic!("expected accepted owner deletion") + }; + if stage == DeletionStage::Inventoried { + let inventory = FrozenInventory { + schema: store + .inventory_schema(community) + .await + .expect("inventory schema"), + storage: empty_storage_manifest(community), + }; + let inventoried = store + .freeze_inventory(request.id, &inventory) + .await + .expect("freeze inventory"); + assert_eq!(inventoried.stage, DeletionStage::Inventoried); + } + + let mut gate = install_owner_convergence_gate(&db, community, &owner).await; + let convergence_db = + contender_db(&format!("owner-convergence-{}", Uuid::new_v4().simple())).await; + let converging = tokio::spawn({ + let owner = owner.clone(); + async move { convergence_db.provision_owner(community, &owner).await } + }); + wait_for_owner_convergence_gate(&db, &gate).await; + + let abort_application = format!("owner-abort-{}", Uuid::new_v4().simple()); + let abort_store = contender_db(&abort_application).await.deletion_store(); + let aborting = tokio::spawn(async move { + abort_store + .abort(request.id, "operator", "same-owner convergence race") + .await + }); + wait_for_contender_lock(&db, &abort_application).await; + release_owner_convergence_gate(&mut gate).await; + + let (convergence_join, abort_join) = tokio::join!( + tokio::time::timeout(Duration::from_secs(5), converging), + tokio::time::timeout(Duration::from_secs(5), aborting), + ); + remove_owner_convergence_gate(&db, gate).await; + let convergence_result = convergence_join + .expect("same-owner convergence must not hang") + .expect("join same-owner convergence"); + let abort_result = abort_join + .expect("abort must not hang") + .expect("join abort"); + if matches!( + &convergence_result, + Err(DbError::Sqlx(sqlx::Error::Database(error))) + if error.code().as_deref() == Some("40P01") + ) || matches!( + &abort_result, + Err(DbError::Sqlx(sqlx::Error::Database(error))) + if error.code().as_deref() == Some("40P01") + ) { + deadlocks.push(format!( + "{stage}: convergence={convergence_result:?}, abort={abort_result:?}" + )); + continue; + } + assert_eq!( + convergence_result.expect("same-owner convergence"), + ProvisionOwnerResult::Applied + ); + assert_eq!( + abort_result.expect("abort request").stage, + DeletionStage::Aborted + ); + } + assert!( + deadlocks.is_empty(), + "same-owner convergence and abort must serialize without 40P01:\n{}", + deadlocks.join("\n") + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_owner_admission_serializes_before_normal_transfer() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let replacement = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let before = membership_roles(&db, community).await; + let request_id = Uuid::new_v4(); + let mut gate = install_admission_insert_gate(&db, request_id).await; + let admission = tokio::spawn({ + let store = store.clone(); + let host = host.clone(); + let owner = owner.clone(); + async move { + store + .admit_owner_request( + &host, + &owner, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 1, + request_id, + ) + .await + } + }); + wait_for_admission_gate(&db, &gate).await; + + let application_name = format!("owner-transfer-{}", Uuid::new_v4().simple()); + let contender = contender_db(&application_name).await; + let transfer = tokio::spawn({ + let owner = owner.clone(); + let replacement = replacement.clone(); + async move { + contender + .transfer_ownership(community, &replacement, &owner) + .await + } + }); + wait_for_contender_lock(&db, &application_name).await; + release_admission_insert_gate(&mut gate).await; + + let admission_result = admission.await.expect("join admission").expect("admission"); + let transfer_result = transfer.await.expect("join transfer").expect("transfer"); + remove_admission_insert_gate(&db, gate).await; + assert!(matches!( + admission_result, + OwnerDeletionAdmission::Accepted(_) + )); + assert_eq!(transfer_result, TransferResult::DeletionPending); + assert_eq!(membership_roles(&db, community).await, before); + assert_owner_admission_is_only_committed_mutation(&db, community, request_id).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_owner_admission_serializes_before_unarchive() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let before = membership_roles(&db, community).await; + let request_id = Uuid::new_v4(); + let mut gate = install_admission_insert_gate(&db, request_id).await; + let admission = tokio::spawn({ + let store = store.clone(); + let host = host.clone(); + let owner = owner.clone(); + async move { + store + .admit_owner_request( + &host, + &owner, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 1, + request_id, + ) + .await + } + }); + wait_for_admission_gate(&db, &gate).await; + + let application_name = format!("owner-unarchive-{}", Uuid::new_v4().simple()); + let contender = contender_db(&application_name).await; + let unarchive = tokio::spawn({ + let host = host.clone(); + let owner = owner.clone(); + async move { contender.unarchive_community_owned_by(&host, &owner).await } + }); + wait_for_contender_lock(&db, &application_name).await; + release_admission_insert_gate(&mut gate).await; + + let admission_result = admission.await.expect("join admission").expect("admission"); + let unarchive_result = unarchive.await.expect("join unarchive").expect("unarchive"); + remove_admission_insert_gate(&db, gate).await; + assert!(matches!( + admission_result, + OwnerDeletionAdmission::Accepted(_) + )); + assert_eq!(unarchive_result, UnarchiveCommunityResult::DeletionPending); + assert_eq!(membership_roles(&db, community).await, before); + assert_owner_admission_is_only_committed_mutation(&db, community, request_id).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_owner_admission_serializes_before_legacy_owner_rotation() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let replacement = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let before = membership_roles(&db, community).await; + let request_id = Uuid::new_v4(); + let mut gate = install_admission_insert_gate(&db, request_id).await; + let admission = tokio::spawn({ + let store = store.clone(); + let host = host.clone(); + let owner = owner.clone(); + async move { + store + .admit_owner_request( + &host, + &owner, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 1, + request_id, + ) + .await + } + }); + wait_for_admission_gate(&db, &gate).await; + + let application_name = format!("owner-legacy-{}", Uuid::new_v4().simple()); + let contender = contender_db(&application_name).await; + let provision = tokio::spawn({ + let replacement = replacement.clone(); + async move { contender.provision_owner(community, &replacement).await } + }); + wait_for_contender_lock(&db, &application_name).await; + release_admission_insert_gate(&mut gate).await; + + let admission_result = admission.await.expect("join admission").expect("admission"); + let provision_result = provision + .await + .expect("join legacy convergence") + .expect("legacy convergence"); + remove_admission_insert_gate(&db, gate).await; + assert!(matches!( + admission_result, + OwnerDeletionAdmission::Accepted(_) + )); + assert_eq!(provision_result, ProvisionOwnerResult::DeletionPending); + assert_eq!(membership_roles(&db, community).await, before); + assert_owner_admission_is_only_committed_mutation(&db, community, request_id).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_admission_replay_converges_after_stage_advancement_and_rejects_retargeting() { + let (db, store) = store().await; + let (host, owner, _) = archived_owned_community(&db).await; + let (other_host, other_owner, _) = archived_owned_community(&db).await; + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let request_id = Uuid::new_v4(); + let OwnerDeletionAdmission::Accepted(first) = store + .admit_owner_request(&host, &owner, operator, 1, request_id) + .await + .expect("first admission") + else { + panic!("expected accepted request") + }; + let inventory = FrozenInventory { + schema: store + .inventory_schema(first.community_id) + .await + .expect("schema inventory"), + storage: empty_storage_manifest(first.community_id), + }; + store + .freeze_inventory(first.id, &inventory) + .await + .expect("advance request"); + + let OwnerDeletionAdmission::Accepted(replayed) = store + .admit_owner_request(&host, &owner, operator, 1, request_id) + .await + .expect("replay admission") + else { + panic!("expected converged replay") + }; + assert_eq!(replayed.id, first.id); + assert_eq!(replayed.stage, DeletionStage::Inventoried); + assert_eq!( + store + .admit_owner_request(&other_host, &other_owner, operator, 1, request_id) + .await + .expect("retargeting result"), + OwnerDeletionAdmission::RequestConflict + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_owner_admission_duplicates_return_one_stable_request() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let request_id = Uuid::new_v4(); + let (first, second) = tokio::join!( + store.admit_owner_request(&host, &owner, operator, 1, request_id), + store.admit_owner_request(&host, &owner, operator, 1, request_id), + ); + let accepted_id = |result: Result| { + let OwnerDeletionAdmission::Accepted(request) = result.expect("admission") else { + panic!("expected accepted request") + }; + request.id + }; + assert_eq!(accepted_id(first), request_id); + assert_eq!(accepted_id(second), request_id); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM community_deletion_requests WHERE community_id = $1 AND stage <> 'aborted'" + ) + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("active request count"), + 1 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn accepted_owner_request_blocks_unarchive_and_transfer_and_suppresses_owner_list_row() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let new_owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let OwnerDeletionAdmission::Accepted(_) = store + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .await + .expect("admit owner request") + else { + panic!("expected accepted request") + }; + + assert_eq!( + db.unarchive_community_owned_by(&host, &owner) + .await + .expect("unarchive result"), + UnarchiveCommunityResult::DeletionPending + ); + assert_eq!( + db.transfer_ownership(community, &new_owner, &owner) + .await + .expect("transfer result"), + TransferResult::DeletionPending + ); + assert!( + db.list_communities_owned_by(&owner) + .await + .expect("owner list") + .iter() + .all(|row| row.id != community), + "accepted deletion requests must not remain actionable archived rows" + ); + } + + /// Desired-state bootstrap half of the owner-provenance contract. + /// + /// The migration-upgrade half lives in `runtime::migration::postgres_tests` + /// and asserts the same shared case table. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn desired_state_schema_enforces_owner_provenance_contract() { + let (db, _) = store().await; + owner_provenance_contract::assert_contract(&db.pool).await; + } + + /// Privileged recovery abort at the reversible pre-approval boundary. + /// + /// Owner admission has no owner-facing cancellation. When preparation + /// cannot safely continue, an operator aborts the request; that must + /// release the durable request fence over listing, unarchive, and owner + /// rotation without silently unarchiving the community behind the owner. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn privileged_abort_of_submitted_owner_request_releases_the_request_fence() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let new_owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let request_id = Uuid::new_v4(); + let OwnerDeletionAdmission::Accepted(request) = store + .admit_owner_request(&host, &owner, operator, 1, request_id) + .await + .expect("admit owner request") + else { + panic!("expected accepted request") + }; + assert_eq!(request.stage, DeletionStage::Submitted); + assert_eq!( + db.transfer_ownership(community, &new_owner, &owner) + .await + .expect("fenced transfer"), + TransferResult::DeletionPending + ); + + let aborted = store + .abort( + request_id, + "recovery-operator", + "owner preparation cannot continue", + ) + .await + .expect("privileged abort at the reversible submitted boundary"); + assert_eq!(aborted.stage, DeletionStage::Aborted); + assert_eq!(aborted.aborted_by.as_deref(), Some("recovery-operator")); + + // Abort reverses deletion intent, not the owner's archive decision. + let (deletion_state, archived_at): (String, Option>) = + sqlx::query_as("SELECT deletion_state, archived_at FROM communities WHERE id = $1") + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("community lifecycle after abort"); + assert_eq!(deletion_state, "active"); + assert!( + archived_at.is_some(), + "abort must not unarchive the community on the owner's behalf" + ); + + assert!( + db.list_communities_owned_by(&owner) + .await + .expect("owner list after abort") + .iter() + .any(|row| row.id == community), + "aborting the request must restore the owner's actionable archived row" + ); + let UnarchiveCommunityResult::Unarchived(restored) = db + .unarchive_community_owned_by(&host, &owner) + .await + .expect("unarchive after abort") + else { + panic!("aborting the request must restore owner-authorized unarchive") + }; + assert_eq!(restored.id, community); + assert_eq!( + db.transfer_ownership(community, &new_owner, &owner) + .await + .expect("transfer after abort"), + TransferResult::Transferred { + previous_owner: Some(owner.clone()), + } + ); + } + + /// The reversible boundary extends through `fenced`. From `drained` + /// onward, destruction may have begun, so abort must stay closed. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn privileged_abort_spans_only_the_reversible_pre_destruction_boundary() { + let (db, store) = store().await; + let (request, _) = inventoried_request(&db, &store).await; + assert_eq!(request.stage, DeletionStage::Inventoried); + let aborted = store + .abort(request.id, "recovery-operator", "inventory needs recovery") + .await + .expect("privileged abort at the inventoried boundary"); + assert_eq!(aborted.stage, DeletionStage::Aborted); + + for irreversible in [ + DeletionStage::Drained, + DeletionStage::BindingsRemoved, + DeletionStage::PostgresPurged, + DeletionStage::CachePurged, + DeletionStage::LogicallyVerified, + DeletionStage::RetentionPending, + ] { + let (later, _) = inventoried_request(&db, &store).await; + sqlx::query("UPDATE community_deletion_requests SET stage = $2 WHERE id = $1") + .bind(later.id) + .bind(irreversible.to_string()) + .execute(&db.pool) + .await + .expect("advance stage"); + let error = store + .abort(later.id, "recovery-operator", "too late") + .await + .expect_err("abort must stay closed after destruction begins"); + assert!( + error.to_string().contains("cannot be aborted"), + "unexpected error at {irreversible}: {error}" + ); + assert_eq!( + store.get(later.id).await.expect("unchanged request").stage, + irreversible, + "a refused abort must not move the request" + ); + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn approval_boundary_blocks_claim_until_exact_inventory_is_approved() { diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 411c518240..23082102e9 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -360,7 +360,14 @@ pub async fn insert_event( crate::observability::WriterOperation::EventWrite, ) .await?; - insert_event_on(&mut connection, community_id, event, channel_id).await + if crate::operator_listener::is_listener_mention_kind(u32::from(event.kind.as_u16())) { + let mut tx = Transaction::begin(connection, None).await?; + let result = insert_event_in_transaction(&mut tx, community_id, event, channel_id).await?; + tx.commit().await?; + Ok(result) + } else { + insert_event_on(&mut connection, community_id, event, channel_id).await + } } /// Insert a Nostr event in a caller-owned PostgreSQL transaction. @@ -374,7 +381,11 @@ pub async fn insert_event_in_transaction( event: &Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - insert_event_on(tx.as_mut(), community_id, event, channel_id).await + let result = insert_event_on(tx.as_mut(), community_id, event, channel_id).await?; + if result.1 { + crate::operator_listener::enqueue_mentions_in_transaction(tx, community_id, event).await?; + } + Ok(result) } async fn insert_event_on( @@ -492,13 +503,42 @@ pub(crate) async fn query_events_on( return Ok(vec![]); } + let mut qb = build_query_events_sql(q); + let rows = if q.e_tags.is_some() { + fetch_with_e_tag_deadline(conn, &mut qb).await? + } else { + qb.build().fetch_all(&mut *conn).await? + }; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + if let Some(ev) = row_to_stored_event(row)? { + out.push(ev); + } + } + Ok(out) +} + +/// SQL for [`query_events_on`], after its match-nothing early returns. +fn build_query_events_sql(q: &EventQuery) -> QueryBuilder { let clamp = q.max_limit.unwrap_or(DEFAULT_MAX_PAGE_LIMIT); let limit_val = q.limit.unwrap_or(100).min(clamp); let offset_val = q.offset.unwrap_or(0); + // e-tag reads filter inside a MATERIALIZED CTE and sort/limit outside it. + // With `ORDER BY created_at LIMIT n` in the same scope, the planner + // overestimates matches and walks each partition's pkey backward, testing + // every community row against the id set; the fence keeps it on the GIN + // tags index. + let e_tag_fence = q.e_tags.is_some(); let mut qb: QueryBuilder = if let Some(ref p_hex) = q.p_tag_hex { // Join against event_mentions for #p-filtered queries (indexed). - let mut b = QueryBuilder::new( + let mut b = QueryBuilder::new(if e_tag_fence { + "WITH m AS MATERIALIZED (" + } else { + "" + }); + b.push( "SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, \ e.sig, e.received_at, e.channel_id \ FROM events e \ @@ -513,7 +553,12 @@ pub(crate) async fn query_events_on( b.push_bind(p_hex.to_ascii_lowercase()); b } else { - let mut b = QueryBuilder::new( + let mut b = QueryBuilder::new(if e_tag_fence { + "WITH m AS MATERIALIZED (" + } else { + "" + }); + b.push( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ FROM events WHERE community_id = ", ); @@ -603,17 +648,7 @@ pub(crate) async fn query_events_on( // the dominant scroll-back cost (~1.7s/page on staging). if let Some(ref e_tags) = q.e_tags { if !e_tags.is_empty() { - qb.push(" AND ("); - for (i, hex_id) in e_tags.iter().enumerate() { - if i > 0 { - qb.push(" OR "); - } - // Build the JSONB literal: [["e",""]] - let containment = serde_json::json!([["e", hex_id]]); - qb.push(format!("{col_prefix}tags @> ")); - qb.push_bind(containment); - } - qb.push(")"); + push_e_tag_filter(&mut qb, col_prefix, e_tags); } } @@ -693,21 +728,58 @@ pub(crate) async fn query_events_on( // same second. No existing index covers this trailing column — Postgres // sorts in memory, which is fine at current scale. If query performance // degrades, add a composite index like `(pubkey, kind, created_at DESC, id ASC)`. - qb.push(format!( - " ORDER BY {col_prefix}created_at DESC, {col_prefix}id ASC LIMIT " - )); + if e_tag_fence { + qb.push(") SELECT * FROM m ORDER BY created_at DESC, id ASC LIMIT "); + } else { + qb.push(format!( + " ORDER BY {col_prefix}created_at DESC, {col_prefix}id ASC LIMIT " + )); + } qb.push_bind(limit_val); qb.push(" OFFSET ").push_bind(offset_val); + qb +} - let rows = qb.build().fetch_all(&mut *conn).await?; +/// Server-side deadline for e-tag (aux-closure / `#e`) reads, kept under the +/// desktop's 30 s request timeout so a runaway query is cancelled once no +/// client can still be waiting for it. It only ever tightens the effective +/// `statement_timeout`: a shorter operator cap (`BUZZ_DB_STATEMENT_TIMEOUT_MS`) +/// is kept, and a disabled (`0`) or longer one is lowered to 20 s. The limit is +/// per statement, not per request. +const E_TAG_STATEMENT_TIMEOUT_SQL: &str = "SELECT set_config('statement_timeout', \ + CASE WHEN current_setting('statement_timeout')::interval = interval '0' \ + OR current_setting('statement_timeout')::interval > interval '20s' \ + THEN '20s' ELSE current_setting('statement_timeout') END, true)"; - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - if let Some(ev) = row_to_stored_event(row)? { - out.push(ev); - } - } - Ok(out) +/// Run an e-tag read under [`E_TAG_STATEMENT_TIMEOUT_SQL`] without changing the +/// deadline for anything else on `conn`. `begin()` opens a transaction on an +/// autocommit writer connection or a savepoint inside the replica's read +/// transaction; rolling it back (the read has no writes to keep) reverts the +/// transaction-local `set_config` in both cases. +async fn fetch_with_e_tag_deadline( + conn: &mut sqlx::PgConnection, + qb: &mut QueryBuilder, +) -> Result> { + let mut tx = sqlx::Connection::begin(&mut *conn).await?; + sqlx::query(E_TAG_STATEMENT_TIMEOUT_SQL) + .execute(&mut *tx) + .await?; + let rows = qb.build().fetch_all(&mut *tx).await?; + tx.rollback().await?; + Ok(rows) +} + +/// e-tag pushdown as one array-bound containment test instead of an N-way +/// `OR` chain, so planner cost does not scale with the number of referenced +/// ids (the thread aux hop sends one id per reply). +fn push_e_tag_filter(qb: &mut QueryBuilder, col_prefix: &str, e_tags: &[String]) { + let containments: Vec = e_tags + .iter() + .map(|hex_id| serde_json::json!([["e", hex_id]])) + .collect(); + qb.push(format!(" AND {col_prefix}tags @> ANY(")) + .push_bind(containments) + .push("::jsonb[])"); } pub(crate) fn row_to_stored_event(row: sqlx::postgres::PgRow) -> Result> { @@ -873,16 +945,7 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer if let Some(ref e_tags) = q.e_tags { if !e_tags.is_empty() { - qb.push(" AND ("); - for (i, hex_id) in e_tags.iter().enumerate() { - if i > 0 { - qb.push(" OR "); - } - let containment = serde_json::json!([["e", hex_id]]); - qb.push(format!("{col_prefix}tags @> ")); - qb.push_bind(containment); - } - qb.push(")"); + push_e_tag_filter(&mut qb, col_prefix, e_tags); } } @@ -909,7 +972,16 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer } } - let row = qb.build().fetch_one(&mut *conn).await?; + let row = if q.e_tags.as_deref().is_some_and(|e| !e.is_empty()) { + // Run under the same transaction-local deadline as `query_events_on` + // so a COUNT over a long thread cannot stall indefinitely either. + let rows = fetch_with_e_tag_deadline(conn, &mut qb).await?; + rows.into_iter() + .next() + .ok_or_else(|| DbError::InvalidData("COUNT returned no rows".to_string()))? + } else { + qb.build().fetch_one(&mut *conn).await? + }; let cnt: i64 = row.try_get("cnt")?; Ok(cnt) @@ -1528,6 +1600,8 @@ pub(crate) async fn insert_event_with_thread_metadata_tx( } } } + + crate::operator_listener::enqueue_mentions_in_transaction(tx, community_id, event).await?; } Ok(( @@ -1829,7 +1903,8 @@ impl Db { /// seam is gated on `BUZZ_REPLICA_READ_MAX_AGE_MS` (default off): when /// unset, even covered-eligible queries stay on the writer, so merging /// this seam is a true no-op until the budget is configured. Every - /// failure fails closed to the writer. + /// failure fails closed to the writer, except a statement-timeout cancel + /// (57014), which is surfaced rather than re-run. #[datastore_span(name = "query_events_routed", system = "postgresql")] pub async fn query_events_routed( &self, @@ -1851,6 +1926,12 @@ impl Db { Self::record_route(path, "replica", reason); Ok(events) } + // A cancelled statement (timeout) would be just as slow on the + // writer; surface it instead of doubling the cost. + Err(e) if e.is_statement_cancelled() => { + Self::record_route(path, "replica", "statement_cancelled"); + Err(e) + } Err(e) => { // Mid-query replica failure: fail closed to the // writer rather than surfacing a routed error. @@ -1904,6 +1985,12 @@ impl Db { Self::record_route(path, "replica", reason); Ok(events) } + // A cancelled statement (timeout) would be just as slow on the + // writer; surface it instead of doubling the cost. + Err(e) if e.is_statement_cancelled() => { + Self::record_route(path, "replica", "statement_cancelled"); + Err(e) + } Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); @@ -1962,6 +2049,12 @@ impl Db { Self::record_route(path, "replica", reason); Ok(count) } + // A cancelled statement (timeout) would be just as slow on the + // writer; surface it instead of doubling the cost. + Err(e) if e.is_statement_cancelled() => { + Self::record_route(path, "replica", "statement_cancelled"); + Err(e) + } Err(e) => { tracing::warn!(path, "replica count failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); @@ -2215,6 +2308,12 @@ impl Db { Self::record_route(path, "replica", reason); Ok(events) } + // A cancelled statement (timeout) would be just as slow on the + // writer; surface it instead of doubling the cost. + Err(e) if e.is_statement_cancelled() => { + Self::record_route(path, "replica", "statement_cancelled"); + Err(e) + } Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); @@ -2335,6 +2434,41 @@ impl Db { } } +#[cfg(test)] +mod e_tag_filter_shape_tests { + use super::*; + + /// The aux closure sends one e-tag per reply. The id set must be one + /// array-bound predicate (never an N-way `OR`), and it must be filtered + /// inside a MATERIALIZED CTE with ORDER BY/LIMIT outside: in the same + /// scope the planner flips to a backward pkey walk over the whole + /// community (the long-thread timeout). + #[test] + fn e_tag_query_filters_in_materialized_fence_before_order_limit() { + let mut q = EventQuery::for_community(CommunityId::from_uuid(uuid::Uuid::nil())); + q.kinds = Some(vec![7]); + q.e_tags = Some((0..188).map(|i| format!("{i:064x}")).collect()); + let qb = build_query_events_sql(&q); + let sql = qb.sql().as_str().to_owned(); + + assert!(sql.starts_with("WITH m AS MATERIALIZED (SELECT ")); + assert_eq!(sql.matches("tags @> ANY(").count(), 1); + assert!(!sql.contains(" OR ")); + let fence_end = sql.find(") SELECT * FROM m ").expect("fence closes"); + assert!(sql.contains("::jsonb[])")); + assert!(!sql[..fence_end].contains("ORDER BY")); + assert!(!sql[..fence_end].contains("LIMIT")); + assert!(sql[fence_end..].contains("ORDER BY created_at DESC, id ASC LIMIT ")); + } + + #[test] + fn non_e_tag_query_is_not_fenced() { + let q = EventQuery::for_community(CommunityId::from_uuid(uuid::Uuid::nil())); + let qb = build_query_events_sql(&q); + assert!(!qb.sql().as_str().contains("MATERIALIZED")); + } +} + #[cfg(test)] mod postgres_tests { use super::*; @@ -2388,6 +2522,180 @@ mod postgres_tests { id } + /// Effective `statement_timeout` observed *inside* the e-tag deadline, and + /// the value restored on `conn` afterwards. + async fn deadline_inside_and_after(conn: &mut sqlx::PgConnection) -> (String, String) { + let mut qb = QueryBuilder::new("SELECT current_setting('statement_timeout')"); + let rows = fetch_with_e_tag_deadline(conn, &mut qb) + .await + .expect("deadline read"); + let inside: String = rows[0].get(0); + let after: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *conn) + .await + .expect("show timeout"); + (inside, after) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn e_tag_deadline_only_tightens_and_restores_timeout() { + let pool = setup_pool().await; + // (operator setting, expected effective value inside the read) + let cases = [("0", "20s"), ("5s", "5s"), ("1min", "20s")]; + for (setting, expected) in cases { + // Autocommit connection (writer path). + let mut conn = pool.acquire().await.expect("acquire"); + sqlx::query("SELECT set_config('statement_timeout', $1, false)") + .bind(setting) + .execute(&mut *conn) + .await + .expect("set session timeout"); + let restored: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *conn) + .await + .expect("show timeout"); + let (inside, after) = deadline_inside_and_after(&mut conn).await; + assert_eq!(inside, expected, "autocommit, operator {setting}"); + assert_eq!(after, restored, "autocommit restore, operator {setting}"); + + // Nested in an outer read transaction (replica path -> savepoint). + let mut tx = sqlx::Connection::begin(&mut *conn).await.expect("begin"); + let (inside, after) = deadline_inside_and_after(&mut tx).await; + assert_eq!(inside, expected, "savepoint, operator {setting}"); + assert_eq!(after, restored, "savepoint restore, operator {setting}"); + tx.rollback().await.expect("rollback outer"); + + sqlx::query("RESET statement_timeout") + .execute(&mut *conn) + .await + .expect("reset"); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn e_tag_deadline_restores_timeout_after_error() { + let pool = setup_pool().await; + let mut conn = pool.acquire().await.expect("acquire"); + sqlx::query("SET statement_timeout = '1min'") + .execute(&mut *conn) + .await + .expect("set session timeout"); + // Autocommit connection (writer path). + let mut qb = QueryBuilder::new("SELECT 1/0"); + let err = fetch_with_e_tag_deadline(&mut conn, &mut qb).await; + assert!(err.is_err(), "division by zero must fail"); + let after: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *conn) + .await + .expect("connection usable after error"); + assert_eq!(after, "1min", "autocommit restore"); + + // Nested in an outer read transaction (replica path -> savepoint): + // the failed savepoint must roll back and leave the outer tx usable. + let mut tx = sqlx::Connection::begin(&mut *conn).await.expect("begin"); + let mut qb = QueryBuilder::new("SELECT 1/0"); + let err = fetch_with_e_tag_deadline(&mut tx, &mut qb).await; + assert!(err.is_err(), "division by zero must fail"); + let after: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *tx) + .await + .expect("outer transaction usable after savepoint error"); + assert_eq!(after, "1min", "savepoint restore"); + tx.rollback().await.expect("rollback outer"); + sqlx::query("RESET statement_timeout") + .execute(&mut *conn) + .await + .expect("reset"); + } + + /// Pins the dispatch: an e-tag read through the production + /// `query_events_on` is cancelled at the 20 s deadline even with the + /// session timeout disabled, while `events` is locked. (Slow by design.) + #[tokio::test] + #[ignore = "requires Postgres"] + async fn e_tag_read_is_cancelled_at_deadline_when_timeout_disabled() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let mut locker = pool.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock events"); + + let mut conn = pool.acquire().await.expect("acquire"); + sqlx::query("SET statement_timeout = 0") + .execute(&mut *conn) + .await + .expect("disable timeout"); + let mut q = EventQuery::for_community(community); + q.e_tags = Some(vec!["00".repeat(32)]); + let started = std::time::Instant::now(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(40), + query_events_on(&mut conn, &q), + ) + .await + .expect("deadline must fire before the 40 s guard"); + let err = result.expect_err("locked read must be cancelled"); + assert!(err.is_statement_cancelled(), "want 57014, got {err}"); + assert!(started.elapsed() >= std::time::Duration::from_secs(19)); + + locker.rollback().await.expect("unlock"); + sqlx::query("RESET statement_timeout") + .execute(&mut *conn) + .await + .expect("reset"); + } + + /// Pins the dispatch: a COUNT with an e-tag filter through the production + /// `count_events_on` is cancelled at the 20 s deadline even with the + /// session timeout disabled, while `events` is locked. Connection and + /// setting are restored afterwards. (Slow by design.) + #[tokio::test] + #[ignore = "requires Postgres"] + async fn count_events_cancels_at_e_tag_deadline_when_timeout_disabled() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let mut locker = pool.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock events"); + + let mut conn = pool.acquire().await.expect("acquire"); + sqlx::query("SET statement_timeout = 0") + .execute(&mut *conn) + .await + .expect("disable timeout"); + let mut q = EventQuery::for_community(community); + q.e_tags = Some(vec!["00".repeat(32)]); + let started = std::time::Instant::now(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(40), + count_events_on(&mut conn, &q), + ) + .await + .expect("deadline must fire before the 40 s guard"); + let err = result.expect_err("locked COUNT must be cancelled"); + assert!(err.is_statement_cancelled(), "want 57014, got {err}"); + assert!(started.elapsed() >= std::time::Duration::from_secs(19)); + + // Connection and session timeout are restored after the error. + let after: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *conn) + .await + .expect("connection usable after cancellation"); + assert_eq!(after, "0", "timeout must be restored to disabled"); + + locker.rollback().await.expect("unlock"); + sqlx::query("RESET statement_timeout") + .execute(&mut *conn) + .await + .expect("reset"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn event_insert_in_existing_transaction_rolls_back_with_caller() { diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 559460e15b..51b8717db1 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -26,6 +26,8 @@ pub mod feed; pub mod git_repo; /// Community moderation: reports, bans/timeouts, audit actions. pub mod moderation; +/// Deployment-global operator-listener mention registrations and delivery queues. +pub mod operator_listener; /// Monthly table partition management. pub mod partition; /// Buzz product-feedback sidecar persistence. diff --git a/crates/buzz-db/src/store/operator_listener.rs b/crates/buzz-db/src/store/operator_listener.rs new file mode 100644 index 0000000000..7b08a34572 --- /dev/null +++ b/crates/buzz-db/src/store/operator_listener.rs @@ -0,0 +1,1036 @@ +//! Deployment-global operator-listener mention registrations and delivery outbox. + +use std::collections::HashSet; + +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, TimeDelta, Utc}; +use nostr::Event; +use sqlx::{PgPool, Postgres, QueryBuilder, Row as _, Transaction}; +use uuid::Uuid; + +use crate::error::Result; +use crate::{observability, CommunityId, Db}; + +/// Event kinds whose `p` tags represent user-visible message mentions. +pub const OPERATOR_LISTENER_MENTION_KINDS: [u32; 4] = [9, 40002, 45001, 45003]; +/// Maximum notification attempts before a delivery becomes terminally failed. +pub const MAX_DELIVERY_ATTEMPTS: i32 = 9; +/// Maximum time a queued delivery remains useful when no worker can route it. +pub const OUTBOX_RETENTION: TimeDelta = TimeDelta::minutes(15); +/// Registration lifetime before the daily cleanup removes it. +pub const PUBKEY_RETENTION: TimeDelta = TimeDelta::days(30); + +async fn acquire_maintenance_writer( + pool: &PgPool, +) -> sqlx::Result> { + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await +} + +/// A claimed operator-listener notification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimedDelivery { + /// Durable delivery identifier and claim fence key. + pub id: Uuid, + /// Claim fencing token. + pub claim_id: Uuid, + /// Configured operator-listener identity. + pub listener_pubkey: Vec, + /// Registered target identity mentioned by the event. + pub target_pubkey: Vec, + /// Community containing the event. + pub community: CommunityId, + /// Community host used in the notification payload. + pub community_host: String, + /// Mentioning event id. + pub event_id: Vec, + /// Mentioning event kind. + pub event_kind: i32, + /// Author-controlled event timestamp. + pub event_created_at: DateTime, + /// Attempt number, starting at one. + pub attempt: i32, +} + +/// Return whether an event kind should generate operator-listener notifications. +#[must_use] +pub const fn is_listener_mention_kind(kind: u32) -> bool { + let mut index = 0; + while index < OPERATOR_LISTENER_MENTION_KINDS.len() { + if OPERATOR_LISTENER_MENTION_KINDS[index] == kind { + return true; + } + index += 1; + } + false +} + +fn event_targets(event: &Event) -> Vec> { + let mut targets = Vec::new(); + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("p") { + continue; + } + let Some(value) = parts.get(1) else { + continue; + }; + let Ok(target) = hex::decode(value) else { + continue; + }; + if target.len() == 32 && !targets.iter().any(|known| known == &target) { + targets.push(target); + } + } + targets +} + +/// Insert one outbox row for each registered listener matching the event's +/// `p` tags. The caller owns the transaction and must commit it with the event. +pub(crate) async fn enqueue_mentions_in_transaction( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + event: &Event, +) -> Result { + let kind = u32::from(event.kind.as_u16()); + if !is_listener_mention_kind(kind) { + return Ok(0); + } + let targets = event_targets(event); + if targets.is_empty() { + return Ok(0); + } + let event_created_at = DateTime::from_timestamp(event.created_at.as_secs() as i64, 0).ok_or( + crate::error::DbError::InvalidTimestamp(event.created_at.as_secs() as i64), + )?; + + let mut query = QueryBuilder::::new( + "INSERT INTO operator_listener_outbox \ + (listener_pubkey, target_pubkey, community_id, event_id, event_kind, event_created_at) \ + SELECT r.listener_pubkey, r.target_pubkey, ", + ); + query + .push_bind(community_id.as_uuid()) + .push(", ") + .push_bind(event.id.as_bytes().as_slice()) + .push(", ") + .push_bind(kind as i32) + .push(", ") + .push_bind(event_created_at) + .push(" FROM operator_listener_pubkeys r WHERE r.target_pubkey IN ("); + let mut separated = query.separated(", "); + for target in &targets { + separated.push_bind(target.as_slice()); + } + separated.push_unseparated(") ON CONFLICT DO NOTHING"); + + Ok(query.build().execute(&mut **tx).await?.rows_affected()) +} + +/// Register target pubkeys for one deployment-global listener. +pub async fn register_pubkeys( + pool: &PgPool, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], +) -> Result { + if target_pubkeys.is_empty() { + return Ok(0); + } + let mut seen = HashSet::with_capacity(target_pubkeys.len()); + let unique_target_pubkeys: Vec<&[u8]> = target_pubkeys + .iter() + .filter_map(|target| seen.insert(target.as_slice()).then_some(target.as_slice())) + .collect(); + let mut connection = acquire_maintenance_writer(pool).await?; + let mut query = QueryBuilder::::new( + "INSERT INTO operator_listener_pubkeys (listener_pubkey, target_pubkey) ", + ); + query.push_values(unique_target_pubkeys, |mut bind, target| { + bind.push_bind(listener_pubkey).push_bind(target); + }); + query.push( + " ON CONFLICT (listener_pubkey, target_pubkey) DO UPDATE SET created_at = EXCLUDED.created_at", + ); + Ok(query + .build() + .execute(&mut *connection) + .await? + .rows_affected()) +} + +/// Remove target pubkeys for one deployment-global listener. +pub async fn remove_pubkeys( + pool: &PgPool, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], +) -> Result { + if target_pubkeys.is_empty() { + return Ok(0); + } + let mut connection = acquire_maintenance_writer(pool).await?; + let mut query = QueryBuilder::::new( + "DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = ", + ); + query + .push_bind(listener_pubkey) + .push(" AND target_pubkey IN ("); + let mut separated = query.separated(", "); + for target in target_pubkeys { + separated.push_bind(target.as_slice()); + } + separated.push_unseparated(")"); + Ok(query + .build() + .execute(&mut *connection) + .await? + .rows_affected()) +} + +/// Delete registrations older than the operator-listener retention period. +pub async fn delete_expired_pubkeys(pool: &PgPool) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + let cutoff = Utc::now() - PUBKEY_RETENTION; + Ok( + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE created_at < $1") + .bind(cutoff) + .execute(&mut *connection) + .await? + .rows_affected(), + ) +} + +/// Claim due notification deliveries and recover claims whose visibility timeout expired. +pub async fn claim_deliveries( + pool: &PgPool, + limit: i64, + lease_until: DateTime, +) -> Result> { + let claim_id = Uuid::new_v4(); + let cutoff = Utc::now() - OUTBOX_RETENTION; + let mut connection = acquire_maintenance_writer(pool).await?; + let rows = sqlx::query( + "WITH candidates AS ( \ + SELECT o.id, o.community_id \ + FROM operator_listener_outbox o \ + WHERE o.attempts < $2 \ + AND o.next_attempt_at <= now() \ + AND o.created_at >= $4 \ + AND (o.state = 'pending' OR (o.state = 'sending' AND o.lease_until < now())) \ + ORDER BY o.next_attempt_at, o.created_at, o.id \ + FOR UPDATE OF o SKIP LOCKED \ + LIMIT $3 \ + ) \ + UPDATE operator_listener_outbox o \ + SET state = 'sending', claim_id = $1, lease_until = $5, attempts = o.attempts + 1 \ + FROM candidates c \ + JOIN communities community ON community.id = c.community_id \ + WHERE o.id = c.id \ + RETURNING o.id, o.claim_id, o.listener_pubkey, o.target_pubkey, o.community_id, \ + community.host, o.event_id, o.event_kind, o.event_created_at, o.attempts", + ) + .bind(claim_id) + .bind(MAX_DELIVERY_ATTEMPTS) + .bind(limit) + .bind(cutoff) + .bind(lease_until) + .fetch_all(&mut *connection) + .await?; + + rows.into_iter() + .map(|row| { + Ok(ClaimedDelivery { + id: row.try_get("id")?, + claim_id: row.try_get("claim_id")?, + listener_pubkey: row.try_get("listener_pubkey")?, + target_pubkey: row.try_get("target_pubkey")?, + community: CommunityId::from_uuid(row.try_get("community_id")?), + community_host: row.try_get("host")?, + event_id: row.try_get("event_id")?, + event_kind: row.try_get("event_kind")?, + event_created_at: row.try_get("event_created_at")?, + attempt: row.try_get("attempts")?, + }) + }) + .collect() +} + +/// Release a delivery claim that this pod cannot route. +pub async fn release_unroutable_delivery( + pool: &PgPool, + id: Uuid, + claim_id: Uuid, + next: DateTime, +) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + Ok(sqlx::query( + "UPDATE operator_listener_outbox \ + SET state = 'pending', claim_id = NULL, lease_until = NULL, \ + next_attempt_at = $3, attempts = GREATEST(attempts - 1, 0) \ + WHERE id = $1 AND claim_id = $2 AND state = 'sending'", + ) + .bind(id) + .bind(claim_id) + .bind(next) + .execute(&mut *connection) + .await? + .rows_affected() + == 1) +} + +/// Mark one fenced delivery successful. +pub async fn complete_delivery(pool: &PgPool, id: Uuid, claim_id: Uuid) -> Result { + delete_claimed_delivery(pool, id, claim_id).await +} + +/// Retry one fenced delivery after a transient failure. +pub async fn retry_delivery( + pool: &PgPool, + id: Uuid, + claim_id: Uuid, + next: DateTime, +) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + Ok(sqlx::query( + "UPDATE operator_listener_outbox \ + SET state = 'pending', claim_id = NULL, lease_until = NULL, next_attempt_at = $3 \ + WHERE id = $1 AND claim_id = $2 AND state = 'sending' AND attempts < $4", + ) + .bind(id) + .bind(claim_id) + .bind(next) + .bind(MAX_DELIVERY_ATTEMPTS) + .execute(&mut *connection) + .await? + .rows_affected() + == 1) +} + +/// Delete one fenced delivery after all delivery attempts fail. +pub async fn fail_delivery(pool: &PgPool, id: Uuid, claim_id: Uuid) -> Result { + delete_claimed_delivery(pool, id, claim_id).await +} + +async fn delete_claimed_delivery(pool: &PgPool, id: Uuid, claim_id: Uuid) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + Ok(sqlx::query( + "DELETE FROM operator_listener_outbox \ + WHERE id = $1 AND claim_id = $2 AND state = 'sending'", + ) + .bind(id) + .bind(claim_id) + .execute(&mut *connection) + .await? + .rows_affected() + == 1) +} + +/// Delete delivery rows that have exceeded the outbox retention window. +pub async fn reap_deliveries(pool: &PgPool) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + let cutoff = Utc::now() - OUTBOX_RETENTION; + Ok( + sqlx::query("DELETE FROM operator_listener_outbox WHERE created_at < $1") + .bind(cutoff) + .execute(&mut *connection) + .await? + .rows_affected(), + ) +} + +impl Db { + /// Register target pubkeys for one configured operator listener. + #[datastore_span(name = "register_operator_listener_pubkeys", system = "postgresql")] + pub async fn register_operator_listener_pubkeys( + &self, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], + ) -> Result { + register_pubkeys(&self.pool, listener_pubkey, target_pubkeys).await + } + + /// Remove target pubkeys for one configured operator listener. + #[datastore_span(name = "remove_operator_listener_pubkeys", system = "postgresql")] + pub async fn remove_operator_listener_pubkeys( + &self, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], + ) -> Result { + remove_pubkeys(&self.pool, listener_pubkey, target_pubkeys).await + } + + /// Claim due operator-listener deliveries. + #[datastore_span(name = "claim_operator_listener_deliveries", system = "postgresql")] + pub async fn claim_operator_listener_deliveries( + &self, + limit: i64, + lease_until: DateTime, + ) -> Result> { + claim_deliveries(&self.pool, limit, lease_until).await + } + + /// Mark one operator-listener delivery successful. + #[datastore_span(name = "complete_operator_listener_delivery", system = "postgresql")] + pub async fn complete_operator_listener_delivery( + &self, + id: Uuid, + claim_id: Uuid, + ) -> Result { + complete_delivery(&self.pool, id, claim_id).await + } + + /// Retry one operator-listener delivery. + #[datastore_span(name = "retry_operator_listener_delivery", system = "postgresql")] + pub async fn retry_operator_listener_delivery( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + retry_delivery(&self.pool, id, claim_id, next).await + } + + /// Release a delivery claim that this pod cannot route. + #[datastore_span(name = "release_operator_listener_delivery", system = "postgresql")] + pub async fn release_operator_listener_delivery( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + release_unroutable_delivery(&self.pool, id, claim_id, next).await + } + + /// Delete one operator-listener delivery after terminal failure. + #[datastore_span(name = "fail_operator_listener_delivery", system = "postgresql")] + pub async fn fail_operator_listener_delivery(&self, id: Uuid, claim_id: Uuid) -> Result { + fail_delivery(&self.pool, id, claim_id).await + } + + /// Reap stale deliveries. + #[datastore_span(name = "reap_operator_listener_deliveries", system = "postgresql")] + pub async fn reap_operator_listener_deliveries(&self) -> Result { + reap_deliveries(&self.pool).await + } + + /// Delete operator-listener registrations older than thirty days. + #[datastore_span( + name = "delete_expired_operator_listener_pubkeys", + system = "postgresql" + )] + pub async fn delete_expired_operator_listener_pubkeys(&self) -> Result { + delete_expired_pubkeys(&self.pool).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_message_kinds_are_listener_mentions() { + for kind in OPERATOR_LISTENER_MENTION_KINDS { + assert!(is_listener_mention_kind(kind)); + } + assert!(!is_listener_mention_kind(1)); + assert!(!is_listener_mention_kind(40003)); + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use sqlx::PgPool; + + async fn setup_pool() -> PgPool { + PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn event_insert_enqueues_registered_listener_in_same_transaction() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let target_pubkey = target.public_key().to_bytes().to_vec(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + register_pubkeys( + &pool, + listener.public_key().as_bytes(), + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register listener target"); + + let event = EventBuilder::new(Kind::Custom(9), "mention") + .tag(Tag::public_key(target.public_key())) + .sign_with_keys(&Keys::generate()) + .expect("sign test event"); + let (_, inserted) = + crate::event::insert_event(&pool, CommunityId::from_uuid(community_id), &event, None) + .await + .expect("insert event"); + assert!(inserted); + + let outbox_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count listener deliveries"); + assert_eq!(outbox_count, 1); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test outbox rows"); + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1") + .bind(listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test registration"); + sqlx::query("DELETE FROM event_mentions WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test mentions"); + sqlx::query("DELETE FROM events WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test event"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn thread_metadata_insert_fans_out_once_to_matching_listeners() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let first_listener = Keys::generate(); + let second_listener = Keys::generate(); + let unrelated_listener = Keys::generate(); + let target = Keys::generate(); + let unrelated_target = Keys::generate(); + let target_pubkey = target.public_key().to_bytes().to_vec(); + let unrelated_pubkey = unrelated_target.public_key().to_bytes().to_vec(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-thread-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + for listener in [&first_listener, &second_listener] { + register_pubkeys( + &pool, + listener.public_key().as_bytes(), + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register matching listener target"); + } + register_pubkeys( + &pool, + unrelated_listener.public_key().as_bytes(), + std::slice::from_ref(&unrelated_pubkey), + ) + .await + .expect("register unrelated listener target"); + + let event = EventBuilder::new(Kind::Custom(9), "thread mention") + .tags([ + Tag::public_key(target.public_key()), + Tag::public_key(target.public_key()), + ]) + .sign_with_keys(&Keys::generate()) + .expect("sign test event"); + let community = CommunityId::from_uuid(community_id); + let (_, inserted) = + crate::event::insert_event_with_thread_metadata(&pool, community, &event, None, None) + .await + .expect("insert event through thread-metadata path"); + assert!(inserted); + + let delivered_listeners: Vec> = sqlx::query_scalar( + "SELECT listener_pubkey FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2 ORDER BY listener_pubkey", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_all(&pool) + .await + .expect("read listener fanout"); + let mut expected_listeners = vec![ + first_listener.public_key().to_bytes().to_vec(), + second_listener.public_key().to_bytes().to_vec(), + ]; + expected_listeners.sort(); + assert_eq!(delivered_listeners, expected_listeners); + + let (_, duplicate_inserted) = + crate::event::insert_event_with_thread_metadata(&pool, community, &event, None, None) + .await + .expect("retry duplicate event through thread-metadata path"); + assert!(!duplicate_inserted); + let outbox_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count listener deliveries after duplicate event"); + assert_eq!(outbox_count, 2); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test outbox rows"); + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey IN ($1, $2, $3)") + .bind(first_listener.public_key().as_bytes().as_slice()) + .bind(second_listener.public_key().as_bytes().as_slice()) + .bind(unrelated_listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test registrations"); + sqlx::query("DELETE FROM events WHERE community_id = $1 AND id = $2") + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test event"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn event_and_listener_outbox_roll_back_together() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let target_pubkey = target.public_key().to_bytes().to_vec(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-rollback-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + register_pubkeys( + &pool, + listener.public_key().as_bytes(), + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register listener target"); + let event = EventBuilder::new(Kind::Custom(9), "mention") + .tag(Tag::public_key(target.public_key())) + .sign_with_keys(&Keys::generate()) + .expect("sign test event"); + + let mut tx = pool.begin().await.expect("begin event transaction"); + let (_, inserted) = crate::event::insert_event_in_transaction( + &mut tx, + CommunityId::from_uuid(community_id), + &event, + None, + ) + .await + .expect("insert event and enqueue mention"); + assert!(inserted); + tx.rollback().await.expect("roll back event transaction"); + + let event_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rolled-back event"); + let outbox_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rolled-back delivery"); + assert_eq!(event_count, 0); + assert_eq!(outbox_count, 0); + + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1") + .bind(listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test registration"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn repeated_registration_renews_created_at() { + let pool = setup_pool().await; + let listener = Keys::generate(); + let target = Keys::generate(); + let listener_pubkey = listener.public_key().as_bytes().to_vec(); + let target_pubkey = target.public_key().as_bytes().to_vec(); + + register_pubkeys( + &pool, + &listener_pubkey, + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register listener target"); + + let expired_at = Utc::now() - PUBKEY_RETENTION - TimeDelta::hours(1); + sqlx::query( + "UPDATE operator_listener_pubkeys SET created_at = $3 \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&target_pubkey) + .bind(expired_at) + .execute(&pool) + .await + .expect("age test registration"); + + register_pubkeys( + &pool, + &listener_pubkey, + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("renew listener target"); + + let renewed_at: DateTime = sqlx::query_scalar( + "SELECT created_at FROM operator_listener_pubkeys \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&target_pubkey) + .fetch_one(&pool) + .await + .expect("read renewed registration"); + assert!(renewed_at > expired_at); + + sqlx::query( + "DELETE FROM operator_listener_pubkeys \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&target_pubkey) + .execute(&pool) + .await + .expect("delete test registration"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_deliveries_skips_rows_past_retention() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let stale_id = Uuid::new_v4(); + let fresh_id = Uuid::new_v4(); + let stale_at = Utc::now() - OUTBOX_RETENTION - TimeDelta::seconds(1); + let event_created_at = Utc::now(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-claim-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + for (id, event_marker, created_at) in [ + (stale_id, 1_u8, stale_at), + (fresh_id, 2_u8, event_created_at), + ] { + sqlx::query( + "INSERT INTO operator_listener_outbox \ + (id, listener_pubkey, target_pubkey, community_id, event_id, event_kind, \ + event_created_at, created_at) \ + VALUES ($1, $2, $3, $4, $5, 9, $6, $7)", + ) + .bind(id) + .bind(listener.public_key().as_bytes().as_slice()) + .bind(target.public_key().as_bytes().as_slice()) + .bind(community_id) + .bind(vec![event_marker; 32]) + .bind(event_created_at) + .bind(created_at) + .execute(&pool) + .await + .expect("insert test delivery"); + } + + let claimed = claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("claim deliveries"); + assert_eq!(claimed.len(), 1); + assert_eq!(claimed[0].id, fresh_id); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test deliveries"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expired_claim_is_recovered_and_old_claim_token_is_fenced() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let delivery_id = Uuid::new_v4(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-lease-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + sqlx::query( + "INSERT INTO operator_listener_outbox \ + (id, listener_pubkey, target_pubkey, community_id, event_id, event_kind, event_created_at) \ + VALUES ($1, $2, $3, $4, $5, 9, $6)", + ) + .bind(delivery_id) + .bind(listener.public_key().as_bytes().as_slice()) + .bind(target.public_key().as_bytes().as_slice()) + .bind(community_id) + .bind(vec![0x33_u8; 32]) + .bind(Utc::now()) + .execute(&pool) + .await + .expect("insert test delivery"); + + let first = claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("claim delivery"); + assert_eq!(first.len(), 1); + assert_eq!(first[0].id, delivery_id); + assert!( + claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("don't claim active lease") + .is_empty() + ); + + sqlx::query("UPDATE operator_listener_outbox SET lease_until = $2 WHERE id = $1") + .bind(delivery_id) + .bind(Utc::now() - TimeDelta::seconds(1)) + .execute(&pool) + .await + .expect("expire first lease"); + let recovered = claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("recover expired claim"); + assert_eq!(recovered.len(), 1); + assert_eq!(recovered[0].id, delivery_id); + assert_ne!(recovered[0].claim_id, first[0].claim_id); + assert_eq!(recovered[0].attempt, first[0].attempt + 1); + assert!(!complete_delivery(&pool, delivery_id, first[0].claim_id) + .await + .expect("old claim cannot complete delivery")); + assert!(!retry_delivery( + &pool, + delivery_id, + first[0].claim_id, + Utc::now() + TimeDelta::seconds(10), + ) + .await + .expect("old claim cannot retry delivery")); + assert!(!release_unroutable_delivery( + &pool, + delivery_id, + first[0].claim_id, + Utc::now() + TimeDelta::seconds(10), + ) + .await + .expect("old claim cannot release delivery")); + assert!(!fail_delivery(&pool, delivery_id, first[0].claim_id) + .await + .expect("old claim cannot delete delivery")); + assert!(complete_delivery(&pool, delivery_id, recovered[0].claim_id) + .await + .expect("current claim completes delivery")); + + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reapers_delete_expired_rows_and_preserve_recent_rows() { + let pool = setup_pool().await; + let listener = Keys::generate(); + let old_target = Keys::generate(); + let recent_target = Keys::generate(); + let listener_pubkey = listener.public_key().as_bytes().to_vec(); + let old_target_pubkey = old_target.public_key().as_bytes().to_vec(); + let recent_target_pubkey = recent_target.public_key().as_bytes().to_vec(); + + register_pubkeys( + &pool, + &listener_pubkey, + &[old_target_pubkey.clone(), recent_target_pubkey.clone()], + ) + .await + .expect("register test targets"); + let expired_at = Utc::now() - PUBKEY_RETENTION - TimeDelta::seconds(1); + sqlx::query( + "UPDATE operator_listener_pubkeys SET created_at = $3 \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&old_target_pubkey) + .bind(expired_at) + .execute(&pool) + .await + .expect("age test registration"); + + let community_id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-reaper-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + let old_delivery = Uuid::new_v4(); + let recent_delivery = Uuid::new_v4(); + for (id, marker, created_at) in [ + ( + old_delivery, + 0x44_u8, + Utc::now() - OUTBOX_RETENTION - TimeDelta::seconds(1), + ), + (recent_delivery, 0x55_u8, Utc::now()), + ] { + sqlx::query( + "INSERT INTO operator_listener_outbox \ + (id, listener_pubkey, target_pubkey, community_id, event_id, event_kind, \ + event_created_at, created_at) \ + VALUES ($1, $2, $3, $4, $5, 9, $6, $7)", + ) + .bind(id) + .bind(&listener_pubkey) + .bind(&recent_target_pubkey) + .bind(community_id) + .bind(vec![marker; 32]) + .bind(Utc::now()) + .bind(created_at) + .execute(&pool) + .await + .expect("insert test delivery"); + } + + assert!( + delete_expired_pubkeys(&pool) + .await + .expect("reap expired registrations") + >= 1 + ); + assert!( + reap_deliveries(&pool) + .await + .expect("reap expired deliveries") + >= 1 + ); + + let registration_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_pubkeys \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&recent_target_pubkey) + .fetch_one(&pool) + .await + .expect("count recent registration"); + let old_delivery_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM operator_listener_outbox WHERE id = $1") + .bind(old_delivery) + .fetch_one(&pool) + .await + .expect("count expired delivery"); + let recent_delivery_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM operator_listener_outbox WHERE id = $1") + .bind(recent_delivery) + .fetch_one(&pool) + .await + .expect("count recent delivery"); + assert_eq!(registration_count, 1); + assert_eq!(old_delivery_count, 0); + assert_eq!(recent_delivery_count, 1); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test deliveries"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1") + .bind(&listener_pubkey) + .execute(&pool) + .await + .expect("delete test registrations"); + } +} diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index ecde3924fe..20309022cb 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -366,8 +366,10 @@ pub async fn update_relay_member_role( /// Ensures the configured owner pubkey holds the `"owner"` role *in /// `community`*, and demotes any other owners in that community to `"admin"`. /// This handles owner rotation: if `RELAY_OWNER_PUBKEY` changes, the old owner -/// is automatically demoted. Scoped to one community — an owner of community A -/// is never bootstrapped into community B. +/// is automatically demoted only while the community is active and has no +/// durable deletion intent. Scoped to one community — an owner of community A +/// is never bootstrapped into community B. Initial insertion and exact-owner +/// convergence remain allowed because neither rotates existing ownership. /// /// Runs in a single transaction. Safe to call at every startup — idempotent. /// @@ -383,13 +385,93 @@ pub async fn bootstrap_owner( community: CommunityId, owner_pubkey: &str, ) -> Result<()> { - bootstrap_owner_with_operation( + match bootstrap_owner_with_operation( pool, community, owner_pubkey, observability::WriterOperation::Bootstrap, ) - .await + .await? + { + ProvisionOwnerResult::Applied => Ok(()), + ProvisionOwnerResult::LifecycleConflict => Err(DbError::AccessDenied( + "community lifecycle freezes owner rotation".to_string(), + )), + ProvisionOwnerResult::DeletionPending => Err(DbError::AccessDenied( + "community deletion is pending".to_string(), + )), + } +} + +#[derive(Clone, Copy)] +enum OwnerMutationMode { + Transfer, + Converge, +} + +enum OwnerMutationAdmission { + Allowed(Vec), + NotFound, + LifecycleConflict, + DeletionPending, +} + +async fn lock_owner_mutation_admission( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community: CommunityId, + proposed_owner: &str, + mode: OwnerMutationMode, +) -> Result { + crate::deletion::lock_community_deletion_shared(tx, community).await?; + let target = sqlx::query( + "SELECT archived_at, deletion_state, deleted_at FROM communities \ + WHERE id = $1 FOR UPDATE", + ) + .bind(community.as_uuid()) + .fetch_optional(&mut **tx) + .await?; + let Some(target) = target else { + return Ok(OwnerMutationAdmission::NotFound); + }; + let existing_owners: Vec = sqlx::query_scalar( + "SELECT pubkey FROM relay_members \ + WHERE community_id = $1 AND role = 'owner' \ + ORDER BY pubkey FOR UPDATE", + ) + .bind(community.as_uuid()) + .fetch_all(&mut **tx) + .await?; + + let changes_existing_owner = match mode { + OwnerMutationMode::Transfer => true, + OwnerMutationMode::Converge => { + !(existing_owners.is_empty() + || existing_owners.len() == 1 && existing_owners[0] == proposed_owner) + } + }; + if !changes_existing_owner { + return Ok(OwnerMutationAdmission::Allowed(existing_owners)); + } + + let deletion_pending: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM community_deletion_requests \ + WHERE community_id = $1 AND stage <> 'aborted')", + ) + .bind(community.as_uuid()) + .fetch_one(&mut **tx) + .await?; + if deletion_pending { + return Ok(OwnerMutationAdmission::DeletionPending); + } + + let archived_at: Option> = target.try_get("archived_at")?; + let deletion_state: String = target.try_get("deletion_state")?; + let deleted_at: Option> = target.try_get("deleted_at")?; + if archived_at.is_some() || deletion_state != "active" || deleted_at.is_some() { + return Ok(OwnerMutationAdmission::LifecycleConflict); + } + + Ok(OwnerMutationAdmission::Allowed(existing_owners)) } async fn bootstrap_owner_with_operation( @@ -397,11 +479,25 @@ async fn bootstrap_owner_with_operation( community: CommunityId, owner_pubkey: &str, operation: observability::WriterOperation, -) -> Result<()> { +) -> Result { let pubkey = owner_pubkey.to_ascii_lowercase(); let connection = observability::acquire_writer(pool, operation).await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; + match lock_owner_mutation_admission(&mut tx, community, &pubkey, OwnerMutationMode::Converge) + .await? + { + OwnerMutationAdmission::Allowed(_) => {} + OwnerMutationAdmission::NotFound | OwnerMutationAdmission::LifecycleConflict => { + tx.rollback().await?; + return Ok(ProvisionOwnerResult::LifecycleConflict); + } + OwnerMutationAdmission::DeletionPending => { + tx.rollback().await?; + return Ok(ProvisionOwnerResult::DeletionPending); + } + } + // 1. Upsert the configured owner for this community. sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ @@ -424,7 +520,7 @@ async fn bootstrap_owner_with_operation( .await?; tx.commit().await?; - Ok(()) + Ok(ProvisionOwnerResult::Applied) } /// The result of a transfer-ownership attempt. @@ -444,12 +540,27 @@ pub enum TransferResult { /// concurrent transfer or owner rotation has already changed ownership. /// The caller must NOT retry blindly — re-read ownership and re-evaluate. OwnerConflict, + /// The community is archived, quiescing, or deleted; ownership is frozen. + LifecycleConflict, + /// Durable deletion intent exists and wins over ownership mutation. + DeletionPending, /// The transferee already owns the maximum number of communities. /// Enforced atomically inside the transfer transaction so concurrent /// transfers to the same recipient cannot both pass the limit. LimitReached, } +/// Result of converging an owner through deployment-root provisioning. +#[derive(Debug, PartialEq, Eq)] +pub enum ProvisionOwnerResult { + /// The initial owner was inserted or existing ownership converged. + Applied, + /// The community lifecycle freezes ownership mutation. + LifecycleConflict, + /// Durable deletion intent exists and wins over owner convergence. + DeletionPending, +} + /// Default maximum number of communities a single pubkey can own. Enforced at /// the relay layer — the authoritative layer — so that concurrent transfers or /// transfer-vs-create races cannot both pass a preflight count. @@ -500,13 +611,17 @@ pub fn owner_count_advisory_lock_key(pubkey_hex: &str) -> i64 { /// so that concurrent transfers to the same recipient serialize. The same /// lock key is also used by `Db::create_community_with_owner` to prevent /// transfer-vs-create races. -/// 2. Locks the current owner row `FOR UPDATE` and verifies +/// 2. Locks the community row and rejects archive/non-active lifecycle or any +/// non-aborted deletion request. +/// Owner-deletion admission takes the same row lock, so whichever operation +/// commits first makes the other re-evaluate and conflict. +/// 3. Locks the current owner row `FOR UPDATE` and verifies /// `expected_owner_pubkey` matches. This prevents a stale-owner race where /// a delayed/retried request overwrites a completed transfer. -/// 3. Enforces the [`MAX_COMMUNITIES_PER_OWNER`] limit on the transferee by +/// 4. Enforces the [`MAX_COMMUNITIES_PER_OWNER`] limit on the transferee by /// counting owned communities inside the same transaction. -/// 4. Upserts `new_owner_pubkey` as `owner` (insert or promote). -/// 5. Demotes every other owner in this community to `member` — **not** +/// 5. Upserts `new_owner_pubkey` as `owner` (insert or promote). +/// 6. Demotes every other owner in this community to `member` — **not** /// `admin`, per product decision: the former owner retains no management /// capabilities. /// @@ -533,18 +648,28 @@ pub async fn transfer_ownership( ) .await?; - // 2. Lock the current owner row FOR UPDATE and verify the expected owner. - // FOR UPDATE prevents the stale-owner race: a concurrent transfer that - // already changed the owner will block on this lock until our txn - // completes (or vice versa), and the expected_owner check will fail. - let existing_owners: Vec = sqlx::query_scalar( - "SELECT pubkey FROM relay_members \ - WHERE community_id = $1 AND role = 'owner' \ - FOR UPDATE", + let existing_owners = match lock_owner_mutation_admission( + &mut tx, + community, + &pubkey, + OwnerMutationMode::Transfer, ) - .bind(community.as_uuid()) - .fetch_all(&mut *tx) - .await?; + .await? + { + OwnerMutationAdmission::Allowed(existing_owners) => existing_owners, + OwnerMutationAdmission::NotFound => { + tx.rollback().await?; + return Ok(TransferResult::NoOwner); + } + OwnerMutationAdmission::LifecycleConflict => { + tx.rollback().await?; + return Ok(TransferResult::LifecycleConflict); + } + OwnerMutationAdmission::DeletionPending => { + tx.rollback().await?; + return Ok(TransferResult::DeletionPending); + } + }; if existing_owners.is_empty() { tx.rollback().await?; @@ -570,7 +695,7 @@ pub async fn transfer_ownership( existing_owners.iter().find(|p| **p != pubkey).cloned() }; - // 3. Enforce the transferee's community ownership limit inside the same + // 4. Enforce the transferee's community ownership limit inside the same // transaction that holds the advisory lock. This is the authoritative // check — kgoose's preflight count is advisory only. let owned_count: i64 = sqlx::query_scalar( @@ -585,7 +710,7 @@ pub async fn transfer_ownership( return Ok(TransferResult::LimitReached); } - // 4. Upsert the new owner. + // 5. Upsert the new owner. sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, 'owner', NULL) \ @@ -596,7 +721,7 @@ pub async fn transfer_ownership( .execute(&mut *tx) .await?; - // 5. Demote all other owners to member (not admin). + // 6. Demote all other owners to member (not admin). sqlx::query( "UPDATE relay_members SET role = 'member', updated_at = now() \ WHERE community_id = $1 AND role = 'owner' AND pubkey <> $2", @@ -798,7 +923,11 @@ impl Db { /// Ensure an owner during operator-driven community provisioning. #[datastore_span(name = "provision_owner", system = "postgresql")] - pub async fn provision_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { + pub async fn provision_owner( + &self, + community: CommunityId, + owner_pubkey: &str, + ) -> Result { bootstrap_owner_with_operation( &self.pool, community, @@ -1341,6 +1470,50 @@ mod postgres_tests { assert_role(&pool, community, &owner, "owner").await; } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_rejects_archived_community_without_membership_change() { + let pool = setup_pool().await; + let (community, owner) = owned_community(&pool).await; + let replacement = test_pubkey(); + sqlx::query("UPDATE communities SET archived_at = now() WHERE id = $1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .expect("archive community"); + + let result = transfer_ownership(&pool, community, &replacement, &owner) + .await + .expect("transfer archived community"); + + assert_eq!(result, TransferResult::LifecycleConflict); + assert_role(&pool, community, &owner, "owner").await; + assert!( + get_relay_member(&pool, community, &replacement) + .await + .expect("get replacement") + .is_none(), + "archived transfer must not add the replacement owner" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn provision_owner_still_bootstraps_initial_owner() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let owner = test_pubkey(); + let db = Db::from_pool(pool.clone()); + + assert_eq!( + db.provision_owner(community, &owner) + .await + .expect("provision initial owner"), + ProvisionOwnerResult::Applied + ); + assert_role(&pool, community, &owner, "owner").await; + } + /// Transferring a community with no owner row returns `NoOwner`. #[tokio::test] #[ignore = "requires Postgres"] diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index 714cf7eaff..2814c48691 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -227,7 +227,11 @@ pub enum Command { /// Canonical community host. Defaults to RELAY_URL's authority. #[arg(long)] host: Option, - /// Operator identity recorded on the request. + /// Identity recorded on the request. + /// + /// Also the convergence key for an existing `submitted` request: to + /// take over an owner-origin request, pass its owner pubkey. The + /// operator's own pubkey conflicts with it instead of converging. #[arg(long)] requested_by: String, /// Optional reason for the request. diff --git a/crates/buzz-dev-mcp/src/view_image.rs b/crates/buzz-dev-mcp/src/view_image.rs index 441338ab12..8f8ea0ede7 100644 --- a/crates/buzz-dev-mcp/src/view_image.rs +++ b/crates/buzz-dev-mcp/src/view_image.rs @@ -49,8 +49,8 @@ pub(crate) const MAX_DECODER_ALLOC: u64 = 256 * 1024 * 1024; /// Connect + read timeout for URL fetches. const FETCH_TIMEOUT: Duration = Duration::from_secs(10); /// Lifetime of a Blossom `t=get` read token for relay media fetches. -/// Matches the desktop client's `MEDIA_GET_AUTH_EXPIRY_SECS`. -const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 600; +/// 60 seconds — matches the NIP-FI strict proof window. +const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 60; /// Build the decoder allocation cap. Centralised so the resize path uses the /// same value tests can reason about. diff --git a/crates/buzz-media/src/auth.rs b/crates/buzz-media/src/auth.rs index c6fff2be47..96fd1f92c6 100644 --- a/crates/buzz-media/src/auth.rs +++ b/crates/buzz-media/src/auth.rs @@ -1,4 +1,4 @@ -//! Blossom kind:24242 auth verification (BUD-11 compliant). +//! Blossom kind:24242 auth verification (BUD-11 + NIP-FI compliant). use crate::error::MediaError; @@ -18,13 +18,47 @@ impl BlossomVerb { } } -/// Verify common kind:24242 Blossom auth event validity: +/// Verification strictness derived from the NIP-FI mode. +/// +/// `Strict` applies the full NIP-FI kind-24242 rules: 60-second proof window, +/// `expiration <= created_at + 60s`, mandatory `server` tag on all proofs, and +/// exact cardinality (exactly one each of `t`, `expiration`, `server`; at most +/// one `x`). +/// +/// `Permissive` preserves the pre-NIP-FI behavior for Off-mode deployments: +/// 3600-second proof window, `server` tag optional, and duplicate tags accepted. +/// This keeps a deployed desktop that mints old-shape tokens working against an +/// Off-mode relay [FI-INV-15]. +/// +/// The relay call sites derive strictness from `config.nip_fi.mode`; library +/// callers without mode access may pass `Strict` directly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlossomStrictness { + /// Full NIP-FI compliance: 60s window, mandatory `server`, exact cardinality. + Strict, + /// Pre-NIP-FI compatibility: 3600s window, optional `server`, tolerant cardinality. + Permissive, +} + +/// Verify common kind:24242 Blossom auth event validity. +/// +/// Checks in order: /// 1. Schnorr signature -/// 2. kind == 24242 -/// 3. `t` tag matches `verb` -/// 4. `expiration` tag in the future -/// 5. `created_at` in the past (with 5s clock-skew tolerance) -/// 6. If `server` tags present, our domain must appear in at least one +/// 2. kind == 24242 and non-empty content +/// 3. `t` tag matches `verb`: +/// - `Strict`: count every tag named `t` by field name; require exactly +/// one occurrence; require its content to be non-empty and equal to +/// `verb` (NIP-FI.md:658-666 — malformed/empty/duplicate instances MUST +/// be rejected as `evidence_rejected`). +/// - `Permissive`: at least one `t` tag equal to `verb`; any other valued +/// `t` (including `""`) rejects and valueless tags are ignored +/// (origin/main `found_t` semantics). +/// 4. `expiration` tag present, strictly future, and within the freshness window +/// 5. `created_at` bounded: not more than 5s in the future; not older than the +/// mode-selected window (60s `Strict`, 3600s `Permissive`) +/// 6. `server` tag enforcement: in `Strict` mode, exactly one `server` tag is +/// required and MUST match the bound tenant host; in `Permissive`, optional +/// when-present behavior is preserved /// /// Does NOT check verb-specific scope tags (`x` for upload, `x` OR `server` /// for get). Call this BEFORE trusting the event's pubkey for scope resolution. @@ -32,8 +66,10 @@ pub fn verify_blossom_auth_event_for_verb( auth_event: &nostr::Event, verb: BlossomVerb, server_domain: Option<&str>, - max_age_secs: u64, + strictness: BlossomStrictness, ) -> Result<(), MediaError> { + let strict = strictness == BlossomStrictness::Strict; + // 1. Verify Schnorr signature auth_event .verify() @@ -49,64 +85,138 @@ pub fn verify_blossom_auth_event_for_verb( return Err(MediaError::InvalidAuthEvent); } - let mut found_t = false; - let mut found_exp = false; - let mut server_tags: Vec<&str> = Vec::new(); + // Tag accumulation with strict cardinality tracking. + // In Strict mode: each of t/expiration/server must appear exactly once; + // x is counted separately for upload scope checking (at most one). + // In Permissive mode: boolean-style "found at least one" semantics, + // matching the pre-NIP-FI behavior. + let mut t_count: u8 = 0; + let mut exp_count: u8 = 0; + let mut server_count: u8 = 0; + let mut x_count: u8 = 0; + let mut exp_value: u64 = 0; + // All server tag values, collected for Permissive any-match semantics. + // Strict mode returns early on duplicate-server before reaching the + // validation block, so server_values[0] is always safe to use there. + let mut server_values: Vec = Vec::new(); for tag in auth_event.tags.iter() { let kind = tag.kind().to_string(); match kind.as_str() { "t" => { - if let Some(v) = tag.content() { - if v != verb.as_str() { - return Err(MediaError::InvalidAuthVerb); + if strict { + // Strict (NIP-FI): count EVERY tag named "t" by field name, + // regardless of its content. NIP-FI.md:658-666 requires + // that malformed, empty, duplicate, or conflicting instances + // be rejected as `evidence_rejected`. We count first, gate + // on cardinality, then validate content. + t_count = t_count.saturating_add(1); + if t_count > 1 { + return Err(MediaError::DuplicateTag("t")); } - found_t = true; + // Exactly one t tag: its content must be non-empty and + // equal to the requested verb. + match tag.content() { + Some(v) if !v.is_empty() && v == verb.as_str() => {} + _ => return Err(MediaError::InvalidAuthVerb), + } + } else { + // Permissive: origin/main's `found_t` semantics exactly. + // Any valued tag (including empty) that is not the verb + // rejects; valueless tags are ignored [FI-INV-15]. + if let Some(v) = tag.content() { + if v != verb.as_str() { + return Err(MediaError::InvalidAuthVerb); + } + t_count = t_count.saturating_add(1); + } + // No content: tag is ignored (not counted, not rejected). } } "expiration" => { + exp_count = exp_count.saturating_add(1); + if strict && exp_count > 1 { + return Err(MediaError::DuplicateTag("expiration")); + } if let Some(v) = tag.content() { - exp_value = v.parse().unwrap_or(0); - found_exp = true; + if strict { + // Strict: first-wins (duplicate already rejected above). + if exp_count == 1 { + exp_value = v.parse().unwrap_or(0); + } + } else { + // Permissive: last-wins, matching pre-NIP-FI base + // behavior (base auth.rs processed each valued tag + // unconditionally, so a later tag overwrote earlier + // ones). A valueless tag followed by a future-valued + // tag must not be treated as expired [FI-INV-15]. + exp_value = v.parse().unwrap_or(0); + } } } "server" => { + server_count = server_count.saturating_add(1); + if strict && server_count > 1 { + return Err(MediaError::DuplicateTag("server")); + } + // Collect all valued server tags for Permissive any-match; Strict + // will only ever have at most one here (duplicate check above). if let Some(v) = tag.content() { - server_tags.push(v); + server_values.push(v.to_owned()); + } + } + "x" => { + x_count = x_count.saturating_add(1); + if strict && x_count > 1 { + return Err(MediaError::DuplicateTag("x")); } } _ => {} } } - // 3. t tag required - if !found_t { + // 3. t tag required (exactly one in Strict, at least one in Permissive) + if t_count == 0 { return Err(MediaError::MissingTag("t")); } - // 4. Expiration must exist and be in the future - if !found_exp { + // 4a. Expiration must exist + if exp_count == 0 { return Err(MediaError::MissingTag("expiration")); } let now = nostr::Timestamp::now().as_secs(); + + // 4b. Expiration must be strictly in the future if exp_value <= now { return Err(MediaError::TokenExpired); } - // 5. created_at must be recent: not in the future (5s tolerance) and not - // older than 10 minutes. This bounds the replay window — even if the - // expiration tag allows a longer lifetime, the token must have been - // freshly minted. + // 5. created_at freshness bounds. + // + // Strict (NIP-FI active modes): + // created_at <= now + 5s — bounded future skew + // now - created_at <= 60s — 60-second replay window + // expiration <= created_at + 60s — token cannot outlive its window + // + // Permissive (Off mode): + // created_at <= now + 5s — future skew only + // now - created_at <= 3600s — 1-hour replay window (pre-NIP-FI) + // expiration window not enforced let created = auth_event.created_at.as_secs(); if created > now + 5 { return Err(MediaError::TimestampOutOfWindow); } - if now > created + max_age_secs { + let max_age = if strict { 60u64 } else { 3600u64 }; + if now > created + max_age { + return Err(MediaError::TimestampOutOfWindow); + } + if strict && exp_value > created + 60 { + // expiration tag must satisfy expiration <= created_at + 60s return Err(MediaError::TimestampOutOfWindow); } - // 6. Server tag enforcement (BUD-11 §5): if server tags present, our host must appear. + // 6. Server tag enforcement. // // `server_domain` is the host this request was bound to — the per-request // tenant host (`TenantContext::host()`), NOT a single process-global domain. @@ -117,21 +227,59 @@ pub fn verify_blossom_auth_event_for_verb( // construction across case, trailing dot, default ports, and an optional // URL scheme/path — exactly as every other host seam resolves tenants. // - // Fail closed: if the bound host is unknown, reject tokens that carry server - // tags rather than silently accepting them. - if !server_tags.is_empty() { - match server_domain { - Some(domain) => { - let want = normalize_server_host(domain); - let matches = server_tags - .iter() - .any(|tag| normalize_server_host(tag) == want); - if !matches { + // Strict (NIP-FI active): exactly one server tag MUST be present and MUST + // match the bound tenant host. Absent or mismatched → evidence_rejected. + // + // Permissive (Off mode): if ANY server tag is present, at least one MUST + // match our host (any-match semantics, preserving origin/main behavior); + // absent server tags are accepted [FI-INV-15]. + if strict { + // Strict: server_values has at most one entry (duplicate rejected above). + match ( + server_count, + server_values.first().map(String::as_str), + server_domain, + ) { + (0, _, _) => { + // Strict: server tag mandatory + return Err(MediaError::ServerMismatch); + } + (_, Some(tag_host), Some(domain)) => { + if normalize_server_host(tag_host) != normalize_server_host(domain) { return Err(MediaError::ServerMismatch); } } - None => { - // Server tags present but we don't know our own host — reject. + (_, _, None) => { + // Strict: bound host unknown — fail closed + return Err(MediaError::ServerMismatch); + } + (_, None, _) => { + // server tag present but valueless — treat as mismatch + return Err(MediaError::ServerMismatch); + } + } + } else { + // Permissive: validate only when VALUED server tags are present. + // A valueless `["server"]` tag does not constitute a server-tag binding + // and must not change the admission decision (FI-INV-15: Permissive/Off + // must preserve pre-NIP-FI base behavior, which gated on the collected + // valued-tag list being nonempty). Using `server_values.is_empty()` + // (not `server_count > 0`) ensures a lone valueless server tag is + // treated the same as no server tag — the proof is admitted without a + // server check, matching origin/main behavior. + // + // Any-match semantics: a proof with multiple VALUED server tags is + // accepted if at least one matches our host. + if !server_values.is_empty() { + let Some(domain) = server_domain else { + // Server tags present but our host is unknown — fail closed. + return Err(MediaError::ServerMismatch); + }; + let want = normalize_server_host(domain); + let any_match = server_values + .iter() + .any(|tag_host| normalize_server_host(tag_host) == want); + if !any_match { return Err(MediaError::ServerMismatch); } } @@ -140,6 +288,28 @@ pub fn verify_blossom_auth_event_for_verb( Ok(()) } +/// Verify only the `x` tag hash match on an already-admitted upload auth event. +/// +/// This is the post-body hash check: the full auth event verification +/// (signature, kind, freshness, cardinality, server) was already performed at +/// the pre-body admission gate. Re-running the full verifier after streaming +/// a potentially large body would fail for any upload that takes longer than +/// the minted token's `expiration` window (typically 60 s in Strict mode). +/// +/// The ONLY thing that is unknown before the body is transferred is the +/// content hash (`x` tag). This function confirms that the body's SHA-256 +/// matches what was declared in the signed proof. +pub fn verify_upload_hash_only(auth_event: &nostr::Event, sha256: &str) -> Result<(), MediaError> { + let has_matching_x = auth_event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "x" && tag.content() == Some(sha256)); + if !has_matching_x { + return Err(MediaError::HashMismatch); + } + Ok(()) +} + /// Verify common upload auth event validity. /// /// Kept as the upload-shaped public wrapper for existing callers; new verb-aware @@ -147,9 +317,9 @@ pub fn verify_blossom_auth_event_for_verb( pub fn verify_blossom_auth_event( auth_event: &nostr::Event, server_domain: Option<&str>, - max_age_secs: u64, + strictness: BlossomStrictness, ) -> Result<(), MediaError> { - verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, max_age_secs) + verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, strictness) } /// Normalize a Blossom `server` tag value (or a bound tenant host) into the @@ -170,22 +340,19 @@ fn normalize_server_host(value: &str) -> String { /// Verify a kind:24242 Blossom upload auth event, including the x tag hash check. /// -/// Calls [`verify_blossom_auth_event`] first, then verifies that at least one -/// `x` tag matches `sha256` (BUD-11 §6: "at least one x tag matches"). +/// In `Strict` mode (NIP-FI active): exactly one `x` tag MUST be present and +/// MUST match `sha256`. In `Permissive` mode: at least one `x` tag must match. pub fn verify_blossom_upload_auth( auth_event: &nostr::Event, sha256: &str, server_domain: Option<&str>, - max_age_secs: u64, + strictness: BlossomStrictness, ) -> Result<(), MediaError> { - verify_blossom_auth_event_for_verb( - auth_event, - BlossomVerb::Upload, - server_domain, - max_age_secs, - )?; + verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, strictness)?; - // At least one x tag must match the body sha256 (BUD-11 §6) + // Upload: x tag must match the body sha256. + // Strict: exactly one x tag and it must match (duplicate x caught above). + // Permissive: at least one x tag must match. let has_matching_x = auth_event .tags .iter() @@ -204,18 +371,39 @@ pub fn verify_blossom_upload_auth( /// or server-scoped authorization (`server` tag matches this relay host). The /// latter intentionally grants reads for all blobs on the host until expiration; /// callers must still apply relay membership after this verifier returns. +/// +/// In `Strict` mode the `server` tag is mandatory (enforced by the base verifier); +/// `x` is optional on reads. An `x` tag, when present, must be exactly one and +/// must match `sha256`. pub fn verify_blossom_get_auth( auth_event: &nostr::Event, sha256: &str, server_domain: Option<&str>, - max_age_secs: u64, + strictness: BlossomStrictness, ) -> Result<(), MediaError> { - verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Get, server_domain, max_age_secs)?; + verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Get, server_domain, strictness)?; - let has_matching_x = auth_event + // x tag scope check for get: if an x tag is present it must match sha256. + // In Strict mode duplicate x is already rejected above; here we check the value. + // In Permissive mode we use the original "any matching x OR matching server" logic. + + // Count ALL x tags (including valueless) — presence of a valueless x in Strict + // mode is a malformed proof and must not become absent scope. + let x_tag_count: usize = auth_event .tags .iter() - .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(sha256))); + .filter(|tag| tag.kind().to_string() == "x") + .count(); + + // Collect only valued x tags for hash comparison. + let x_tags: Vec<&str> = auth_event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == "x") + .filter_map(|tag| tag.content()) + .collect(); + + let has_matching_x = x_tags.contains(&sha256); let has_matching_server = match server_domain { Some(domain) => { @@ -231,8 +419,20 @@ pub fn verify_blossom_get_auth( None => false, }; - if !has_matching_x && !has_matching_server { - return Err(MediaError::InsufficientScope); + if strictness == BlossomStrictness::Strict { + // Strict: server is already validated as present+matching by the base verifier. + // If any x tag is present (including a valueless one), it MUST contain the + // exact requested sha256 — a valueless or mismatched x is malformed evidence + // (evidence_rejected). No x tags → server-scoped read, admitted here. + if x_tag_count > 0 && !has_matching_x { + return Err(MediaError::ServerMismatch); + } + // Server-scoped read (no x) is always admitted here — server was validated above. + } else { + // Permissive: original BUD-01 semantics — matching x OR matching server. + if !has_matching_x && !has_matching_server { + return Err(MediaError::InsufficientScope); + } } Ok(()) @@ -242,8 +442,25 @@ pub fn verify_blossom_get_auth( mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + use sha2::Digest as _; fn build_valid_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(keys) + .unwrap() + } + + fn build_permissive_auth(keys: &Keys, sha256: &str) -> nostr::Event { + // Old-shape token: no server tag, 300s expiry — valid in Permissive, rejected in Strict. let now = Timestamp::now().as_secs(); let exp_str = (now + 300).to_string(); let tags = vec![ @@ -257,22 +474,349 @@ mod tests { .unwrap() } + // ── Strict mode ────────────────────────────────────────────────────────── + #[test] - fn test_verify_valid() { + fn test_verify_valid_strict() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let event = build_valid_auth(&keys, &sha256); - assert!(verify_blossom_upload_auth(&event, &sha256, None, 600).is_ok()); + assert!(verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok()); } #[test] - fn test_verify_auth_event_valid() { + fn test_verify_auth_event_valid_strict() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let event = build_valid_auth(&keys, &sha256); - assert!(verify_blossom_auth_event(&event, None, 600).is_ok()); + assert!(verify_blossom_auth_event( + &event, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok()); } + // ── Permissive mode (Off-mode regression guard [FI-INV-15]) ────────────── + + #[test] + fn test_verify_permissive_old_shape_token_admitted() { + // Old-shape token (no server tag, 300s expiry) MUST be admitted in Permissive mode. + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let event = build_permissive_auth(&keys, &sha256); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Off-mode must admit old-shape tokens without server tag [FI-INV-15]" + ); + } + + #[test] + fn test_verify_permissive_long_expiry_admitted() { + // 600s expiry token (pre-NIP-FI desktop default) MUST be admitted in Permissive mode. + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 600).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Off-mode must admit 600s expiry tokens [FI-INV-15]" + ); + } + + // ── Cardinality: Strict rejects duplicates ──────────────────────────────── + + #[test] + fn test_strict_rejects_duplicate_t_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["t", "upload"]).unwrap(), // duplicate + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::DuplicateTag("t")) + )); + } + + #[test] + fn test_strict_rejects_duplicate_expiration_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), // duplicate + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::DuplicateTag("expiration")) + )); + } + + #[test] + fn test_strict_rejects_duplicate_server_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), // duplicate + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::DuplicateTag("server")) + )); + } + + #[test] + fn test_strict_rejects_duplicate_x_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), // duplicate + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::DuplicateTag("x")) + )); + } + + // ── Permissive mode: duplicate tags still admitted ──────────────────────── + + #[test] + fn test_permissive_admits_duplicate_x_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), // duplicate — permitted in Permissive + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok()); + } + + // ── Strict: server tag mandatory ───────────────────────────────────────── + + #[test] + fn test_strict_rejects_absent_server_on_upload() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + // no server tag + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::ServerMismatch) + ), + "Strict mode must reject upload proof without server tag" + ); + } + + #[test] + fn test_strict_rejects_absent_server_on_read() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + // no server tag + ]; + let event = EventBuilder::new(Kind::from(24242), "Get buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::ServerMismatch) + ), + "Strict mode must reject read proof without server tag" + ); + } + + // ── Strict: freshness window ────────────────────────────────────────────── + + #[test] + fn test_strict_rejects_expiration_exceeding_60s_window() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 61).to_string(); // 61s > 60s limit + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::TimestampOutOfWindow) + ), + "Strict mode must reject expiration > created_at + 60s" + ); + } + + #[test] + fn test_strict_admits_60s_expiration() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 60).to_string(); // exactly 60s — allowed + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok(), + "Strict mode must admit expiration == created_at + 60s" + ); + } + + // ── Get auth ───────────────────────────────────────────────────────────── + fn build_get_auth(keys: &Keys, tags: Vec) -> nostr::Event { EventBuilder::new(Kind::from(24242), "Get buzz-media") .tags(tags) @@ -281,7 +825,82 @@ mod tests { } #[test] - fn test_verify_get_accepts_matching_x_without_server_tag() { + fn test_verify_get_accepts_matching_server_strict() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let event = build_get_auth( + &keys, + vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["server", "https://Relay.Example./media/ignored"]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ], + ); + assert!(verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok()); + } + + #[test] + fn test_verify_get_accepts_matching_x_and_server_strict() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let event = build_get_auth( + &keys, + vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ], + ); + assert!(verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok()); + } + + #[test] + fn test_verify_get_strict_rejects_mismatched_x_with_valid_server() { + // x present but wrong hash → evidence_rejected even if server matches + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let other = "b".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let event = build_get_auth( + &keys, + vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", &other]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ], + ); + assert!(matches!( + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::ServerMismatch) + )); + } + + #[test] + fn test_verify_get_accepts_matching_x_without_server_permissive() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); @@ -294,12 +913,17 @@ mod tests { Tag::parse(["expiration", &exp_str]).unwrap(), ], ); - - assert!(verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600).is_ok()); + assert!(verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok()); } #[test] - fn test_verify_get_accepts_matching_server_without_x_tag() { + fn test_verify_get_accepts_matching_server_without_x_permissive() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); @@ -312,8 +936,13 @@ mod tests { Tag::parse(["expiration", &exp_str]).unwrap(), ], ); - - assert!(verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600).is_ok()); + assert!(verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok()); } #[test] @@ -321,15 +950,19 @@ mod tests { let keys = Keys::generate(); let sha256 = "a".repeat(64); let event = build_valid_auth(&keys, &sha256); - assert!(matches!( - verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600), + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ), Err(MediaError::InvalidAuthVerb) )); } #[test] - fn test_verify_get_requires_x_or_server_scope() { + fn test_verify_get_requires_x_or_server_scope_permissive() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let other_hash = "b".repeat(64); @@ -343,9 +976,13 @@ mod tests { Tag::parse(["expiration", &exp_str]).unwrap(), ], ); - assert!(matches!( - verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600), + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ), Err(MediaError::InsufficientScope) )); } @@ -364,9 +1001,13 @@ mod tests { Tag::parse(["expiration", &exp_str]).unwrap(), ], ); - assert!(matches!( - verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600), + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ), Err(MediaError::ServerMismatch) )); } @@ -378,7 +1019,12 @@ mod tests { let event = build_valid_auth(&keys, &sha256); let wrong_hash = "b".repeat(64); assert!(matches!( - verify_blossom_upload_auth(&event, &wrong_hash, None, 600), + verify_blossom_upload_auth( + &event, + &wrong_hash, + Some("relay.example"), + BlossomStrictness::Strict + ), Err(MediaError::HashMismatch) )); } @@ -388,45 +1034,30 @@ mod tests { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); let tags = vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), ]; let event = EventBuilder::new(Kind::from(27235), "wrong kind") .tags(tags) .sign_with_keys(&keys) .unwrap(); assert!(matches!( - verify_blossom_upload_auth(&event, &sha256, None, 600), + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), Err(MediaError::InvalidAuthKind) )); } #[test] - fn test_verify_multi_x_tags() { - let keys = Keys::generate(); - let sha256 = "a".repeat(64); - let other_hash = "b".repeat(64); - let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); - let tags = vec![ - Tag::parse(["t", "upload"]).unwrap(), - Tag::parse(["x", &other_hash]).unwrap(), - Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &exp_str]).unwrap(), - ]; - let event = EventBuilder::new(Kind::from(24242), "Upload multi-x") - .tags(tags) - .sign_with_keys(&keys) - .unwrap(); - // Should pass because at least one x tag matches - assert!(verify_blossom_upload_auth(&event, &sha256, None, 600).is_ok()); - } - - #[test] - fn test_server_tag_enforcement() { + fn test_server_tag_enforcement_permissive() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); @@ -443,41 +1074,52 @@ mod tests { .unwrap(); // Should fail — server tag present but doesn't match our domain assert!(matches!( - verify_blossom_upload_auth(&event, &sha256, Some("buzz.example.com"), 600), + verify_blossom_upload_auth( + &event, + &sha256, + Some("buzz.example.com"), + BlossomStrictness::Permissive + ), Err(MediaError::ServerMismatch) )); // Should pass when our domain matches - assert!( - verify_blossom_upload_auth(&event, &sha256, Some("other.example.com"), 600).is_ok() - ); + assert!(verify_blossom_upload_auth( + &event, + &sha256, + Some("other.example.com"), + BlossomStrictness::Permissive + ) + .is_ok()); // Should fail when server_domain is None — fail closed assert!(matches!( - verify_blossom_upload_auth(&event, &sha256, None, 600), + verify_blossom_upload_auth(&event, &sha256, None, BlossomStrictness::Permissive), Err(MediaError::ServerMismatch) )); } #[test] - fn test_no_server_tags_always_passes() { + fn test_permissive_no_server_tags_always_passes() { let keys = Keys::generate(); let sha256 = "a".repeat(64); - let event = build_valid_auth(&keys, &sha256); - // No server tags → passes regardless of our domain - assert!(verify_blossom_upload_auth(&event, &sha256, Some("any.domain.com"), 600).is_ok()); + let event = build_permissive_auth(&keys, &sha256); + // No server tags → passes regardless of our domain in Permissive mode + assert!(verify_blossom_upload_auth( + &event, + &sha256, + Some("any.domain.com"), + BlossomStrictness::Permissive + ) + .is_ok()); } /// A `server` tag is matched against the *bound tenant host* under the - /// shared `normalize_host` rule, so equivalent host spellings agree — the - /// stock CLI's bare `host:port`, an explicit default port, a trailing dot, - /// mixed case, and a full URL all match the same bound host. This is the - /// regression guard for the multi-tenant media blocker: a non-primary - /// tenant must accept its own server-tagged client. + /// shared `normalize_host` rule, so equivalent host spellings agree. #[test] fn test_server_tag_normalized_against_bound_host() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); let build = |server: &str| { let tags = vec![ Tag::parse(["t", "upload"]).unwrap(), @@ -491,18 +1133,16 @@ mod tests { .unwrap() }; - // Non-primary tenant host with explicit non-default port (the live - // repro: tenant B on 127.0.0.1:3100). Stock CLI tags `host:port`. + // Non-primary tenant host with explicit non-default port. assert!(verify_blossom_upload_auth( &build("127.0.0.1:3100"), &sha256, Some("127.0.0.1:3100"), - 600 + BlossomStrictness::Strict ) .is_ok()); - // Equivalence under normalize_host: explicit default port, trailing - // dot, mixed case, and a full URL all collapse to the bound host. + // Equivalence under normalize_host for tag in [ "Relay.Example:443", "relay.example.", @@ -510,8 +1150,13 @@ mod tests { "https://relay.example/", ] { assert!( - verify_blossom_upload_auth(&build(tag), &sha256, Some("relay.example"), 600) - .is_ok(), + verify_blossom_upload_auth( + &build(tag), + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok(), "server tag {tag:?} should match bound host relay.example" ); } @@ -522,7 +1167,7 @@ mod tests { &build("127.0.0.1:3100"), &sha256, Some("127.0.0.1:3200"), - 600 + BlossomStrictness::Strict ), Err(MediaError::ServerMismatch) )); @@ -533,11 +1178,12 @@ mod tests { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); let tags = vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), ]; // Empty content — BUD-11 requires a human-readable string let event = EventBuilder::new(Kind::from(24242), "") @@ -545,8 +1191,682 @@ mod tests { .sign_with_keys(&keys) .unwrap(); assert!(matches!( - verify_blossom_auth_event(&event, None, 600), + verify_blossom_auth_event(&event, Some("relay.example"), BlossomStrictness::Strict), Err(MediaError::InvalidAuthEvent) )); } + + // ── Finding 3: valueless / empty-string t tag must not satisfy verb binding ── + + /// A `t` tag with no content (`["t"]`) in Strict mode: counted as one occurrence, + /// content check fires → `InvalidAuthVerb` (malformed instance, NIP-FI §cardinality). + #[test] + fn test_strict_rejects_valueless_t_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + // ["t"] with no second element — no content, must be rejected + let tags = vec![ + Tag::parse(["t"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::InvalidAuthVerb) + ), + "valueless t tag must be rejected in Strict mode (counted once, content invalid)" + ); + } + + #[test] + fn test_valueless_t_tag_is_not_counted_permissive() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ), + Err(MediaError::MissingTag("t")) + ), + "valueless t tag must not satisfy t requirement in Permissive mode" + ); + } + + /// A `t` tag with an empty-string value (`["t", ""]`) in Strict mode: counted + /// as one occurrence, content check fires → `InvalidAuthVerb`. + #[test] + fn test_strict_rejects_empty_string_t_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", ""]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::InvalidAuthVerb) + ), + "empty-string t tag must be rejected in Strict mode (counted once, content invalid)" + ); + } + + /// A valueless `x` tag on upload (`["x"]`) does not match any sha256. + #[test] + fn test_valueless_x_tag_does_not_match_hash() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x"]).unwrap(), // no value + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::HashMismatch) + ), + "valueless x tag must not satisfy hash requirement" + ); + } + + // ── Finding 3 R2: mixed malformed+valid t combos must reject in Strict ──── + + /// `["t"]` (valueless) + `["t","upload"]` in Strict: the malformed tag is counted + /// and content-validated on first encounter; the exact rejection error depends on + /// tag ordering but any error is correct — no combination may be admitted. + /// (If malformed comes first: `InvalidAuthVerb`; if valid first: `DuplicateTag("t")`.) + #[test] + fn test_strict_rejects_valueless_plus_valid_t_combo() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t"]).unwrap(), // valueless — counted in Strict + Tag::parse(["t", "upload"]).unwrap(), // valid — but two t tags total + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload bypass attempt") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let result = verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict, + ); + assert!( + result.is_err(), + "valueless+valid t combo must be rejected in Strict mode, got Ok" + ); + // The first tag is valueless → InvalidAuthVerb fires before the second tag + // is seen; if ordering were reversed it would be DuplicateTag("t"). Both are + // valid evidence_rejected-class outcomes — what matters is admission is denied. + assert!( + matches!( + result, + Err(MediaError::InvalidAuthVerb) | Err(MediaError::DuplicateTag("t")) + ), + "expected InvalidAuthVerb or DuplicateTag(t), got unexpected error variant" + ); + } + + /// `["t",""]` (empty-string) + `["t","upload"]` in Strict: same reasoning — + /// any error is correct; admission must be denied. + #[test] + fn test_strict_rejects_empty_plus_valid_t_combo() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", ""]).unwrap(), // empty-string — counted in Strict + Tag::parse(["t", "upload"]).unwrap(), // valid — but two t tags total + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload bypass attempt") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let result = verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict, + ); + assert!( + result.is_err(), + "empty-string+valid t combo must be rejected in Strict mode, got Ok" + ); + assert!( + matches!( + result, + Err(MediaError::InvalidAuthVerb) | Err(MediaError::DuplicateTag("t")) + ), + "expected InvalidAuthVerb or DuplicateTag(t), got unexpected error variant" + ); + } + + /// Permissive must match origin/main's predicate exactly [FI-INV-15]: any + /// valued `t` unequal to the verb — including `""` — rejects, in either + /// order; only a valueless `["t"]` is ignored. + fn permissive_get_with_t_tags(t_tags: &[&[&str]]) -> Result<(), MediaError> { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let exp_str = (Timestamp::now().as_secs() + 300).to_string(); + let mut tags: Vec = t_tags + .iter() + .map(|t| Tag::parse(t.iter().copied()).unwrap()) + .collect(); + tags.push(Tag::parse(["x", &sha256]).unwrap()); + tags.push(Tag::parse(["expiration", &exp_str]).unwrap()); + let event = build_get_auth(&keys, tags); + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive, + ) + } + + #[test] + fn test_permissive_rejects_empty_plus_valid_t_either_order() { + for order in [ + &[&["t", ""][..], &["t", "get"][..]][..], + &[&["t", "get"][..], &["t", ""][..]][..], + ] { + assert!( + matches!( + permissive_get_with_t_tags(order), + Err(MediaError::InvalidAuthVerb) + ), + "Permissive must reject empty-valued t like main: {order:?}" + ); + } + } + + #[test] + fn test_permissive_admits_valueless_plus_valid_t_either_order() { + for order in [ + &[&["t"][..], &["t", "get"][..]][..], + &[&["t", "get"][..], &["t"][..]][..], + ] { + assert!( + permissive_get_with_t_tags(order).is_ok(), + "Permissive must ignore valueless t like main: {order:?}" + ); + } + } + + /// `["x"]` (valueless) + `["x", sha256]` (valid) in Strict: `x_count` increments + /// unconditionally for both occurrences → `DuplicateTag("x")`. This confirms + /// the x arm already handles mixed malformed+valid correctly. + #[test] + fn test_strict_rejects_valueless_plus_valid_x_combo() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x"]).unwrap(), // valueless — counted unconditionally + Tag::parse(["x", &sha256]).unwrap(), // valid — but makes x_count = 2 + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload bypass attempt") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::DuplicateTag("x")) + ), + "valueless+valid x combo must be rejected as DuplicateTag in Strict mode" + ); + } + + // ── Finding 1 (F1): Permissive multi-server any-match regression ───────── + + /// Permissive: `[[\"server\",\"other.example\"],[\"server\",\"relay.example\"]]` — + /// relay.example is second; must be admitted (any-match). Restores origin/main semantics. + #[test] + fn test_permissive_multi_server_second_matches() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "other.example"]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload multi-server") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Permissive must accept multi-server proof when any server matches (relay.example second)" + ); + } + + /// Permissive: `[[\"server\",\"relay.example\"],[\"server\",\"other.example\"]]` — + /// relay.example is first; must also be admitted. + #[test] + fn test_permissive_multi_server_first_matches() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + Tag::parse(["server", "other.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload multi-server") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Permissive must accept multi-server proof when any server matches (relay.example first)" + ); + } + + /// Permissive multi-server: neither server matches our host → still rejected. + #[test] + fn test_permissive_multi_server_none_match() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "other.example"]).unwrap(), + Tag::parse(["server", "another.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload multi-server") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ), + Err(MediaError::ServerMismatch) + ), + "Permissive must reject multi-server proof when no server matches" + ); + } + + // ── Finding 4 (F4): valueless x tag on Strict get must not become host-wide scope ─ + + /// Strict get: proof with exactly one `[\"x\"]` (valueless) and a valid server — + /// the valueless x tag must not become absent scope (host-wide authorization). + /// Before the fix, `filter_map(tag.content())` dropped it silently and the read passed. + #[test] + fn test_strict_get_valueless_x_tag_does_not_grant_host_wide_scope() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x"]).unwrap(), // valueless — must not become absent scope + Tag::parse(["server", "relay.example"]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Get buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // Must be rejected: valueless x is present but matches no sha256. + assert!( + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_err(), + "valueless x tag in Strict get must not grant host-wide read scope" + ); + } + + // ── Finding 1 compatibility edge (F1): valueless server tag must not change Permissive/Off behavior ─ + + /// Permissive: a proof with a valid matching `x`, a valid expiration, and a + /// lone VALUELESS `["server"]` tag (no content) was admitted by the pre-NIP-FI + /// base verifier, which gated server validation on the collected valued-tag list + /// being nonempty. The fix gates on `!server_values.is_empty()` (not + /// `server_count > 0`) so a valueless server tag is treated as absent. + /// + /// Before the fix, `server_count > 0` was true (count includes valueless tags), + /// entering the branch, and `server_values.iter().any(...)` over an empty vec + /// always returned false → `ServerMismatch` — a regression vs. base behavior. + #[test] + fn test_permissive_valueless_server_tag_does_not_change_admission() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + // Valid proof: matching x, valid expiration, lone valueless server tag. + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server"]).unwrap(), // valueless — must be ignored in Permissive + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // Permissive: valueless server tag must not trigger ServerMismatch. + // The proof has a matching x tag so it must be admitted on the x-scope path. + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Permissive must admit a proof whose only server tag is valueless — \ + valueless server tag must not change admission (FI-INV-15)" + ); + } + + /// Permissive (used for both Permissive and Off NIP-FI modes on the Blossom path): + /// same invariant as above, tested on its own to confirm the fix is not + /// order-dependent. Permissive is the only non-Strict variant; Off mode + /// selects Permissive strictness at the API layer. + #[test] + fn test_off_valueless_server_tag_does_not_change_admission() { + let keys = Keys::generate(); + let sha256 = "b".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server"]).unwrap(), // valueless + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // BlossomStrictness::Off is represented as Permissive on the Blossom path. + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Off must admit a proof whose only server tag is valueless (FI-INV-15)" + ); + } + + // ── Upload expiry-boundary regression (commit 75e9bef748d2149ce459b14da842e706a51a5f78) ── + // + // Demonstrates the split between pre-body admission (freshness + full auth) + // and post-body verification (hash only via verify_upload_hash_only). + // + // Scenario: a 60-second Strict upload proof is valid at admit time. After a + // slow transfer, the proof's expiry has passed. The OLD full-verifier + // (verify_blossom_upload_auth called post-body) would reject the expired + // token, failing a legitimate large upload. The NEW post-body gate + // (verify_upload_hash_only) ignores freshness and accepts only the hash. + // + // Three cases exercise the production upload path contract: + // A. Admitted when fresh → pre-body gate (verify_blossom_auth_event_for_verb) accepts. + // B. Old full-verifier post-body → rejects even a valid hash if the token expired. + // C. New hash-only post-body → accepts the matching hash regardless of expiry. + // D. Hash-mismatch control → verify_upload_hash_only rejects a wrong hash. + + #[test] + fn test_upload_expiry_boundary_admission_before_expiry() { + // Case A: the pre-body gate must accept a fresh 60-second proof. + let keys = Keys::generate(); + let body = b"hello world"; + let sha256 = hex::encode(sha2::Sha256::digest(body)); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 60).to_string(); // exactly at the Strict ceiling + let server = "relay.example"; + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_auth_event_for_verb( + &event, + BlossomVerb::Upload, + Some(server), + BlossomStrictness::Strict, + ) + .is_ok(), + "pre-body gate must admit a fresh 60-second Strict upload proof" + ); + } + + #[test] + fn test_upload_expiry_boundary_old_verifier_rejects_expired_post_body() { + // Case B: the OLD full-verifier rejects an expired token post-body, even + // when the hash matches. This is the failure the hash-only split fixes. + let keys = Keys::generate(); + let body = b"hello world"; + let sha256 = hex::encode(sha2::Sha256::digest(body)); + let server = "relay.example"; + // Token expired in the past — simulates a proof that was valid at admit + // time but whose expiry passed during a slow body transfer. + let past = Timestamp::now().as_secs().saturating_sub(10); + let exp_str = past.to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // Full verifier (old post-body path) rejects the expired token. + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some(server), + BlossomStrictness::Strict + ), + Err(MediaError::TokenExpired) + ), + "old full-verifier must reject an expired token even when the hash matches" + ); + } + + #[test] + fn test_upload_expiry_boundary_hash_only_accepts_expired_after_transfer() { + // Case C: the NEW hash-only post-body gate accepts the matching hash + // regardless of expiry. A slow-transfer large upload survives. + let keys = Keys::generate(); + let body = b"hello world"; + let sha256 = hex::encode(sha2::Sha256::digest(body)); + let server = "relay.example"; + let past = Timestamp::now().as_secs().saturating_sub(10); + let exp_str = past.to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // Hash-only post-body gate (new path): ignores freshness, accepts matching hash. + assert!( + verify_upload_hash_only(&event, &sha256).is_ok(), + "hash-only post-body gate must accept a matching hash regardless of expiry" + ); + } + + #[test] + fn test_upload_expiry_boundary_hash_mismatch_rejected() { + // Case D: verify_upload_hash_only rejects a wrong hash (security control). + let keys = Keys::generate(); + let body = b"hello world"; + let correct_sha256 = hex::encode(sha2::Sha256::digest(body)); + let wrong_sha256 = "a".repeat(64); + let server = "relay.example"; + let now = Timestamp::now().as_secs(); + let exp_str = (now + 60).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &correct_sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // Presenting the WRONG body hash — the proof is for a different blob. + assert!( + matches!( + verify_upload_hash_only(&event, &wrong_sha256), + Err(MediaError::HashMismatch) + ), + "hash-only gate must reject a body whose SHA-256 does not match the proof's x tag" + ); + } + + #[test] + fn test_permissive_valueless_expiration_then_valued_uses_last_wins() { + // FI-INV-15: Permissive mode must use last-wins expiration semantics + // (matching pre-NIP-FI base behavior). A valueless expiration tag + // followed by a future-valued expiration tag must admit, not expire. + let keys = Keys::generate(); + let sha256 = "c".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration"]).unwrap(), // valueless first + Tag::parse(["expiration", &exp_str]).unwrap(), // valued second + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + None, + BlossomStrictness::Permissive, + ) + .is_ok(), + "Permissive must use last-wins expiration: valueless-first must not cause expiry rejection" + ); + } } diff --git a/crates/buzz-media/src/error.rs b/crates/buzz-media/src/error.rs index 5abbea6f58..357920e640 100644 --- a/crates/buzz-media/src/error.rs +++ b/crates/buzz-media/src/error.rs @@ -3,6 +3,25 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; +/// Coarse Blossom denial kind for NIP-FI response shaping. +/// +/// Callers that know the active [`crate::auth::BlossomStrictness`] use this to +/// choose the correct HTTP response shape — NIP-FI fixed text/plain in Strict +/// mode, legacy JSON in Permissive mode — without requiring `buzz-media` to +/// depend on `buzz-auth`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlossomDenialKind { + /// `Authorization` header was absent. Maps to HTTP 401 + `WWW-Authenticate: Nostr`. + MissingEvidence, + /// Authorization header or proof was present but structurally invalid, + /// malformed, expired, or otherwise rejected. Maps to HTTP 403. + EvidenceRejected, + /// A local-policy denial: relay membership required, community write fenced, + /// or similar authorization refusal that is not an auth-credential failure. + /// Maps to HTTP 403 with fixed `authorization denied\n` body [NIP-FI.md:764-775]. + AuthorizationDenied, +} + /// Errors from media operations. #[derive(Debug, thiserror::Error)] pub enum MediaError { @@ -26,6 +45,9 @@ pub enum MediaError { InvalidAuthVerb, #[error("missing required tag: {0}")] MissingTag(&'static str), + /// A tag that must appear exactly once appeared more than once. + #[error("duplicate tag: {0}")] + DuplicateTag(&'static str), #[error("hash mismatch")] HashMismatch, #[error("server mismatch")] @@ -109,6 +131,46 @@ impl From for MediaError { } } +impl MediaError { + /// Classify this error as a Blossom denial kind for response-shape selection. + /// + /// Returns `Some(BlossomDenialKind::MissingEvidence)` when the + /// `Authorization` header was absent, `Some(BlossomDenialKind::EvidenceRejected)` + /// for any structurally present but invalid/malformed/expired proof, + /// `Some(BlossomDenialKind::AuthorizationDenied)` for local-policy denials + /// (relay membership, community write fence), and `None` for non-auth errors. + /// + /// Relay call sites that know the active `BlossomStrictness` use this to + /// select the appropriate response shape: NIP-FI fixed text/plain in Strict + /// mode, legacy JSON 401 in Permissive mode. + pub fn blossom_denial_kind(&self) -> Option { + match self { + Self::MissingAuth => Some(BlossomDenialKind::MissingEvidence), + Self::InvalidAuthScheme + | Self::InvalidBase64 + | Self::InvalidAuthEvent + | Self::InvalidAuthKind + | Self::InvalidAuthVerb + | Self::DuplicateTag(_) + | Self::InvalidSignature + | Self::TokenExpired + | Self::TimestampOutOfWindow + | Self::Unauthorized + | Self::TokenRevoked + | Self::PubkeyMismatch + | Self::HashMismatch + | Self::ServerMismatch + | Self::MissingTag(_) => Some(BlossomDenialKind::EvidenceRejected), + // Local-policy denials: identity proved, but access refused by relay policy. + // Maps to AuthorizationDenied per NIP-FI.md:764-775. + Self::RelayMembershipRequired | Self::CommunityWriteFenced => { + Some(BlossomDenialKind::AuthorizationDenied) + } + _ => None, + } + } +} + impl IntoResponse for MediaError { fn into_response(self) -> Response { let (status, msg) = match &self { @@ -119,9 +181,15 @@ impl IntoResponse for MediaError { Self::FileTooLarge { .. } | Self::ImageTooLarge => { (StatusCode::PAYLOAD_TOO_LARGE, self.to_string()) } - // All authentication failures return the same generic 401 to prevent oracle enumeration. - // InsufficientScope is intentionally 403 — it's an authorization (not authentication) - // failure and is safe to distinguish since it requires a valid identity first. + // All authentication failures return the same generic 401 to prevent oracle + // enumeration in the legacy/Permissive path [FI-INV-15]. Off-mode deployments + // preserve this pre-NIP-FI behavior. InsufficientScope is intentionally 403 + // below — it is an authorization (not authentication) failure and is safe to + // distinguish because it requires a valid identity first. + // + // Strict mode routes through MediaDenial (buzz-relay) and applies the full + // NIP-FI rejection table (missing_evidence → 401, evidence_rejected → 403) + // independently of this path. Self::MissingAuth | Self::InvalidAuthScheme | Self::InvalidBase64 @@ -129,6 +197,7 @@ impl IntoResponse for MediaError { | Self::InvalidSignature | Self::InvalidAuthKind | Self::InvalidAuthVerb + | Self::DuplicateTag(_) | Self::TokenExpired | Self::TimestampOutOfWindow | Self::Unauthorized @@ -173,6 +242,136 @@ impl IntoResponse for MediaError { mod tests { use super::*; + // ── BlossomDenialKind classification ───────────────────────────────────── + + #[test] + fn missing_auth_is_missing_evidence() { + assert_eq!( + MediaError::MissingAuth.blossom_denial_kind(), + Some(BlossomDenialKind::MissingEvidence) + ); + } + + #[test] + fn structural_proof_errors_are_evidence_rejected() { + for error in [ + MediaError::InvalidAuthScheme, + MediaError::InvalidBase64, + MediaError::InvalidAuthEvent, + MediaError::InvalidAuthKind, + MediaError::InvalidAuthVerb, + MediaError::DuplicateTag("Authorization"), + MediaError::InvalidSignature, + MediaError::TokenExpired, + MediaError::TimestampOutOfWindow, + MediaError::Unauthorized, + MediaError::TokenRevoked, + MediaError::PubkeyMismatch, + MediaError::HashMismatch, + MediaError::ServerMismatch, + MediaError::MissingTag("t"), + ] { + assert_eq!( + error.blossom_denial_kind(), + Some(BlossomDenialKind::EvidenceRejected), + "expected EvidenceRejected for {error:?}" + ); + } + } + + #[test] + fn non_auth_errors_have_no_denial_kind() { + for error in [ + MediaError::NotFound, + MediaError::FileTooLarge { size: 1, max: 0 }, + MediaError::Internal, + MediaError::ServiceUnavailable, + MediaError::InsufficientScope, + ] { + assert_eq!( + error.blossom_denial_kind(), + None, + "expected None for {error:?}" + ); + } + } + + #[test] + fn local_policy_denials_are_authorization_denied() { + for error in [ + MediaError::RelayMembershipRequired, + MediaError::CommunityWriteFenced, + ] { + assert_eq!( + error.blossom_denial_kind(), + Some(BlossomDenialKind::AuthorizationDenied), + "expected AuthorizationDenied for {error:?}" + ); + } + } + + // ── IntoResponse status code pins ────────────────────────────────────── + // These pins cover the legacy/Permissive path (MediaError::into_response). + // Strict mode overrides this via MediaDenial in buzz-relay [FI-INV-15]. + + #[tokio::test] + async fn all_auth_failures_return_json_401_in_permissive_path() { + // In the legacy/Permissive path all auth failures collapse to a single + // JSON 401 to prevent oracle enumeration [FI-INV-15]. Strict mode + // (MediaDenial in buzz-relay) applies the NIP-FI 401/403 split instead. + for error in [ + MediaError::MissingAuth, + MediaError::InvalidAuthScheme, + MediaError::InvalidBase64, + MediaError::InvalidAuthEvent, + MediaError::InvalidAuthKind, + MediaError::InvalidAuthVerb, + MediaError::DuplicateTag("Authorization"), + MediaError::InvalidSignature, + MediaError::TokenExpired, + MediaError::TimestampOutOfWindow, + MediaError::Unauthorized, + MediaError::TokenRevoked, + MediaError::PubkeyMismatch, + MediaError::HashMismatch, + MediaError::ServerMismatch, + MediaError::MissingTag("t"), + ] { + let label = format!("{error:?}"); + let resp = error.into_response(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "Permissive path: expected 401 for {label}" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "expected JSON CT for {label}, got: {ct}" + ); + // No WWW-Authenticate in the legacy JSON path. + assert!( + resp.headers().get("www-authenticate").is_none(), + "Permissive path must not include WWW-Authenticate for {label}" + ); + // Exact body: legacy path emits {"error":"authentication failed"} [FI-INV-15]. + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body collect"); + assert_eq!( + body.as_ref(), + br#"{"error":"authentication failed"}"#, + "Permissive path: wrong body for {label}" + ); + } + } + + // ── Existing non-auth response map tests ──────────────────────────────── + #[test] fn serving_backend_failures_map_to_5xx_but_fences_remain_403() { for error in [ diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index 3198e1f830..81d341aa3a 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -19,7 +19,7 @@ pub use bucket_index::{ TaxonomySweepOutcome, }; pub use config::{MediaConfig, S3AddressingStyle}; -pub use error::MediaError; +pub use error::{BlossomDenialKind, MediaError}; pub use storage::{ BlobHeadMeta, BlobMeta, BulkDeleteOutcome, ByteStream, MediaStorage, ObjectVersionEntry, ObjectVersionKind, ObjectVersionRef, ObjectVersionsPage, diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 524b033280..1b998bf843 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -5,7 +5,7 @@ use bytes::Bytes; use sha2::{Digest, Sha256}; use tokio::io::AsyncWriteExt; -use crate::auth::verify_blossom_upload_auth; +use crate::auth::verify_upload_hash_only; use crate::config::MediaConfig; use crate::error::MediaError; use crate::storage::{BlobMeta, MediaStorage}; @@ -74,15 +74,15 @@ where let auth = auth_event.clone(); let bytes = body.clone(); let cfg = config.clone(); - // Validate the Blossom `server` tag against the host this request was bound - // to (the per-request tenant), not a process-global domain — a relay serves - // many tenant hosts. - let bound_host = ctx.host().to_string(); let (mime, sha256, ext) = tokio::task::spawn_blocking(move || -> Result<_, MediaError> { let (mime, ext) = validate(&bytes, &cfg)?; let sha256 = hex::encode(Sha256::digest(&bytes)); - // Buffered uploads (image + file): 10-minute auth window is plenty. - verify_blossom_upload_auth(&auth, &sha256, Some(bound_host.as_str()), 600)?; + // Post-body hash check only: the full auth event verification + // (signature, kind, freshness, server, cardinality) was already + // applied at the pre-body gate in the relay handler. Re-running the + // full verifier here would fail any upload that takes longer than the + // minted token's expiration window (60 s in Strict mode). + verify_upload_hash_only(&auth, &sha256)?; Ok((mime, sha256, ext)) }) .await @@ -404,12 +404,13 @@ pub async fn process_video_upload( // --- 3. Verify Blossom auth: x tag must match computed SHA-256 --- let auth = auth_event.clone(); let sha256_for_auth = sha256_hex.clone(); - // Validate the Blossom `server` tag against the bound tenant host (not a - // process-global domain) — a relay serves many tenant hosts. - let bound_host = ctx.host().to_string(); tokio::task::spawn_blocking(move || { - // Videos: 1-hour window — large uploads on slow connections need headroom. - verify_blossom_upload_auth(&auth, &sha256_for_auth, Some(bound_host.as_str()), 3600) + // Post-body hash check only: the full auth event verification + // (signature, kind, freshness, server, cardinality) was already + // applied at the pre-body gate in the relay handler. Re-running the + // full verifier here would reject any video upload that takes longer + // than the minted token's expiration window (60 s in Strict mode). + verify_upload_hash_only(&auth, &sha256_for_auth) }) .await .map_err(|_| MediaError::Internal)??; @@ -731,3 +732,357 @@ mod tests { assert!(desc.duration.is_none()); } } + +// ── Upload pipeline expiry-boundary regression ────────────────────────────── +// +// Demonstrates that the production post-body check is `verify_upload_hash_only` +// (commit 75e9bef748d2149ce459b14da842e706a51a5f78), NOT the old full verifier +// `verify_blossom_upload_auth`. +// +// The critical sequence: +// T=0: proof is minted with `created_at = now - 58`, `expiration = now + 2`. +// Strict admission passes: age 58s ≤ 60s window, lifetime 60s, expiry future. +// T=3s: body transfer completes; post-body check runs on now-expired proof. +// Old path: verify_blossom_upload_auth → TokenExpired (breaks upload). +// New path: verify_upload_hash_only → Ok if hash matches. +// +// These tests exercise both `process_upload` (buffered, upload.rs:85) and +// `process_video_upload` (streaming, upload.rs:413) so that reverting either +// post-body call to the old full verifier breaks the positive case, and +// removing the hash check breaks the negative case. +// +// Note: `nostr::Timestamp::now()` reads the OS wall clock directly; paused +// Tokio time does not advance it. The positive cases use a real 3-second +// sleep to sequence fresh admission at T=0 followed by expired completion at +// T≈3s. The negative (hash-mismatch) cases use a pre-expired proof and need +// no timing sequence. +// +// Tests that require a live MinIO instance live in the `minio_tests` module so +// the nextest profile can tag them with `#[ignore = "requires MinIO"]` and the +// unit lane skips them automatically. +#[cfg(test)] +mod minio_tests { + use super::*; + use buzz_core::tenant::{CommunityId, TenantContext}; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + use uuid::Uuid; + + /// Minimal valid 1×1 RGB PNG — passes validate_content and + /// validate_image_metadata_free without any metadata chunks. + /// + /// Generated once and embedded as const bytes to avoid runtime PNG + /// encoding in every test run. The exact pixels and structure are + /// irrelevant; only the magic bytes and structural validity matter. + const MINIMAL_PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR length + type + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // width=1, height=1 + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // bit-depth=8, color=RGB + 0xde, // IHDR CRC (partial — enough for magic-byte sniff) + 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, // IDAT length + type + 0x78, 0x9c, 0x63, 0xf8, 0xff, 0xff, 0x3f, 0x00, // zlib-compressed scanline + 0x05, 0xfe, 0x02, 0xfe, 0x0d, 0xef, 0x46, 0xb8, // (white pixel) + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, // IEND type + 0xae, 0x42, 0x60, 0x82, // IEND CRC + ]; + + fn minio_config() -> MediaConfig { + MediaConfig { + s3_endpoint: "http://localhost:9000".to_string(), + s3_access_key: "buzz_dev".to_string(), + s3_secret_key: "buzz_dev_secret".to_string(), + s3_bucket: "buzz-media".to_string(), + s3_region: "us-east-1".to_string(), + s3_addressing_style: crate::config::S3AddressingStyle::Path, + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + max_video_bytes: 524_288_000, + max_file_bytes: 104_857_600, + public_base_url: "http://localhost:9000/buzz-media".to_string(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + } + } + + fn test_tenant() -> TenantContext { + TenantContext::resolved(CommunityId::from_uuid(Uuid::nil()), "relay.example") + } + + /// Build a fresh Blossom upload auth event that passes Strict admission NOW. + /// + /// Strict invariants: + /// - `created_at` ≤ now + 5s (future-skew) + /// - now - `created_at` ≤ 60s (replay window) + /// - `expiration` ≤ `created_at` + 60s (token lifetime) + /// - `expiration` > now (not yet expired) + /// + /// We set `created_at = now - 58` (age = 58s, inside the 60s window), + /// `expiration = now - 58 + 60 = now + 2` (lifetime = 60s, strictly future). + /// Calling `verify_blossom_upload_auth(Strict)` at sign time must return `Ok`. + /// After a ≥3 s real sleep the proof is expired (expiration ≤ now): the old + /// full verifier would return `TokenExpired`, while `verify_upload_hash_only` + /// only checks the `x` tag and must still succeed. + fn fresh_strict_upload_auth(keys: &Keys, sha256: &str, server: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let created_at = now.saturating_sub(58); + let exp = created_at + 60; // now + 2s: strictly future, lifetime = 60s + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", sha256]).unwrap(), + Tag::parse(["expiration", &exp.to_string()]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(keys) + .expect("sign fresh upload auth") + } + + /// Build an already-expired Blossom upload auth event for the mismatch-hash + /// negative cases. No admission path is needed there: we only exercise the + /// post-body hash check, so the proof need not be fresh. + /// + /// `created_at = now - 120`, `expiration = now - 1`: definitively past for + /// the old full verifier (`TokenExpired`); the `x` tag is the only thing + /// `verify_upload_hash_only` reads. + fn expired_upload_auth(keys: &Keys, sha256: &str, server: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let created_at = now.saturating_sub(120); + let exp = now.saturating_sub(1); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", sha256]).unwrap(), + Tag::parse(["expiration", &exp.to_string()]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(keys) + .expect("sign expired upload auth") + } + + /// Case B (buffered pipeline): the NEW post-body path (`verify_upload_hash_only`) + /// accepts a proof whose expiry passes during the transfer. + /// + /// Sequence: + /// T=0: `fresh_strict_upload_auth` signs a 60s-lifetime proof that passes + /// Strict admission (`verify_blossom_upload_auth(Strict)` returns `Ok`). + /// T≈3s: real sleep lets the proof expire (expiration ≤ now). + /// T≈3s: `process_upload` runs; its post-body call is `verify_upload_hash_only`. + /// Old path: `verify_blossom_upload_auth` → `TokenExpired` (upload breaks). + /// New path: `verify_upload_hash_only` → `Ok` if hash matches. + /// + /// Note: `nostr::Timestamp::now()` reads the wall clock, not Tokio time. + /// Paused Tokio time does not advance it; a real sleep is required. + /// + /// Discriminating mutation: replacing `verify_upload_hash_only` at `upload.rs:85` + /// with `verify_blossom_upload_auth` causes this test to fail with `TokenExpired`. + /// + /// Requires a live MinIO instance (endpoint http://localhost:9000). + #[tokio::test] + #[ignore = "requires MinIO"] + async fn buffered_upload_accepts_expired_proof_when_hash_matches() { + use crate::auth::{verify_blossom_upload_auth, BlossomStrictness}; + + let keys = Keys::generate(); + let body = Bytes::from_static(MINIMAL_PNG); + let sha256 = hex::encode(sha2::Sha256::digest(&body)); + let auth = fresh_strict_upload_auth(&keys, &sha256, "relay.example"); + + // Assert that the proof passes Strict admission RIGHT NOW, before any sleep. + assert!( + verify_blossom_upload_auth( + &auth, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok(), + "proof must pass Strict admission at sign time" + ); + + // Wait for the proof to expire. The expiry is `now + 2s` at sign time; + // 3s guarantees expiration ≤ current time when process_upload runs. + // `nostr::Timestamp::now()` reads the OS wall clock directly. + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + let storage = MediaStorage::new(&minio_config()).expect("MinIO client must initialise"); + let ctx = test_tenant(); + + // process_upload calls verify_upload_hash_only post-body (the repaired path). + // The proof is now expired; a matching hash must still be accepted. + let result = process_upload( + &storage, + &minio_config(), + &ctx, + &auth, + body, + None, // no attribution + ) + .await; + + assert!( + result.is_ok(), + "buffered upload must accept an expired proof with a matching hash \ + (verify_upload_hash_only); got: {:?}", + result.err() + ); + } + + /// Case D (buffered pipeline): `verify_upload_hash_only` rejects a mismatched + /// body hash even when the proof is otherwise structurally valid. + /// + /// Discriminating mutation: removing the `verify_upload_hash_only` call + /// (or replacing it with a no-op) causes this test to accept a mismatched + /// body, which is a security regression — the relay would store whatever + /// bytes the client sent without verifying the Blossom `x` tag. + /// + /// Requires a live MinIO instance (endpoint http://localhost:9000). + #[tokio::test] + #[ignore = "requires MinIO"] + async fn buffered_upload_rejects_mismatched_hash_through_production_path() { + let keys = Keys::generate(); + // Sign the auth for a DIFFERENT hash than the actual body. + let mismatched_sha256 = "a".repeat(64); + let auth = expired_upload_auth(&keys, &mismatched_sha256, "relay.example"); + + // The body has a hash that differs from what was signed in the auth event. + let body = Bytes::from_static(MINIMAL_PNG); + + let storage = MediaStorage::new(&minio_config()).expect("MinIO client must initialise"); + let ctx = test_tenant(); + + let result = process_upload(&storage, &minio_config(), &ctx, &auth, body, None).await; + + assert!( + matches!(result, Err(MediaError::HashMismatch)), + "buffered upload must reject a body whose SHA-256 does not match \ + the signed x tag; got: {:?}", + result + ); + } + + /// Case B (streaming pipeline): `process_video_upload` accepts a proof whose + /// expiry passes during the transfer. + /// + /// Sequence mirrors the buffered case: + /// T=0: `fresh_strict_upload_auth` signs a 60s-lifetime proof that passes + /// Strict admission. + /// T≈3s: real sleep lets the proof expire. + /// T≈3s: `process_video_upload` streams to disk, computes SHA-256, then calls + /// `verify_upload_hash_only` (the repaired path). + /// Old path: `verify_blossom_upload_auth` → `TokenExpired`. + /// New path: `verify_upload_hash_only` → `Ok` if hash matches. + /// + /// Discriminating mutation: replacing `verify_upload_hash_only` at `upload.rs:413` + /// with `verify_blossom_upload_auth` causes this test to fail with `TokenExpired`. + /// + /// Requires a live MinIO instance (endpoint http://localhost:9000). + #[tokio::test] + #[ignore = "requires MinIO"] + async fn streaming_upload_accepts_expired_proof_when_hash_matches() { + use crate::auth::{verify_blossom_upload_auth, BlossomStrictness}; + use crate::validation::minimal_valid_mp4; + use futures_util::stream; + + let keys = Keys::generate(); + let body_bytes = minimal_valid_mp4(); + let sha256 = hex::encode(sha2::Sha256::digest(&body_bytes)); + let auth = fresh_strict_upload_auth(&keys, &sha256, "relay.example"); + + // Assert Strict admission passes at sign time. + assert!( + verify_blossom_upload_auth( + &auth, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok(), + "proof must pass Strict admission at sign time" + ); + + // Wait for the proof to expire (3s > 2s remaining until expiry). + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + let body_len = body_bytes.len() as u64; + // Wrap the bytes in a single-item stream of Ok(Bytes). + // axum::Error wraps std::io::Error; we never inject an error here. + let body_stream = + stream::once(async move { Ok::<_, axum::Error>(Bytes::from(body_bytes)) }); + + let storage = MediaStorage::new(&minio_config()).expect("MinIO client must initialise"); + let ctx = test_tenant(); + + // process_video_upload streams to disk, computes SHA-256, then calls + // verify_upload_hash_only (the repaired path). The proof is now expired; + // a matching hash must still be accepted. + let result = process_video_upload( + &storage, + &minio_config(), + &ctx, + &auth, + body_stream, + Some(body_len), + None, // no attribution + ) + .await; + + assert!( + result.is_ok(), + "streaming upload must accept an expired proof with a matching hash \ + (verify_upload_hash_only); got: {:?}", + result.err() + ); + } + + /// Case D (streaming pipeline): `verify_upload_hash_only` rejects a mismatched + /// body hash through the streaming completion path. + /// + /// Discriminating mutation: removing the `verify_upload_hash_only` call at + /// `upload.rs:413` causes this test to accept a mismatched body, which is a + /// security regression — the relay would store whatever bytes the client sent + /// without verifying the Blossom `x` tag. + /// + /// Requires a live MinIO instance (endpoint http://localhost:9000). + #[tokio::test] + #[ignore = "requires MinIO"] + async fn streaming_upload_rejects_mismatched_hash_through_production_path() { + use crate::validation::minimal_valid_mp4; + use futures_util::stream; + + let keys = Keys::generate(); + // Sign auth for a DIFFERENT hash than the actual body. + let mismatched_sha256 = "a".repeat(64); + let auth = expired_upload_auth(&keys, &mismatched_sha256, "relay.example"); + + let body_bytes = minimal_valid_mp4(); + let body_len = body_bytes.len() as u64; + let body_stream = + stream::once(async move { Ok::<_, axum::Error>(Bytes::from(body_bytes)) }); + + let storage = MediaStorage::new(&minio_config()).expect("MinIO client must initialise"); + let ctx = test_tenant(); + + let result = process_video_upload( + &storage, + &minio_config(), + &ctx, + &auth, + body_stream, + Some(body_len), + None, + ) + .await; + + assert!( + matches!(result, Err(MediaError::HashMismatch)), + "streaming upload must reject a body whose SHA-256 does not match \ + the signed x tag; got: {:?}", + result + ); + } +} diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index 706c354d04..980e28d8cb 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -954,6 +954,65 @@ pub fn mime_to_ext(mime: &str) -> &'static str { } } +/// Return a minimal structurally-valid H.264/avc1 MP4 body (618 bytes). +/// +/// Fast-start layout (ftyp + moov + mdat), single video track, 1 s, 320×240, +/// no audio. Generated from the same builder as the unit tests and verified to +/// pass `validate_video_file` with the default `MediaConfig`. +/// +/// Exposed only under `#[cfg(test)]` so it has no runtime cost; placed at +/// module scope (not inside `mod tests`) so other test modules in this crate +/// can import it via `use crate::validation::minimal_valid_mp4;`. +#[cfg(test)] +pub(crate) fn minimal_valid_mp4() -> Vec { + // ftyp(isom) + moov + mdat — pre-computed; identical to + // build_mp4_bytes(true, b"avc1", 1_000, 320, 240, false). + vec![ + 0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00, 0x00, 0x00, + 0x00, 0x69, 0x73, 0x6f, 0x6d, 0x00, 0x00, 0x02, 0x4e, 0x6d, 0x6f, 0x6f, 0x76, 0x00, 0x00, + 0x00, 0x6c, 0x6d, 0x76, 0x68, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x01, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x00, 0x01, 0xda, 0x74, 0x72, 0x61, 0x6b, 0x00, 0x00, 0x00, 0x5c, 0x74, 0x6b, + 0x68, 0x64, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, + 0x00, 0x00, 0x00, 0x01, 0x40, 0x00, 0x00, 0x00, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x01, 0x76, + 0x6d, 0x64, 0x69, 0x61, 0x00, 0x00, 0x00, 0x20, 0x6d, 0x64, 0x68, 0x64, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00, + 0x03, 0xe8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2d, 0x68, 0x64, 0x6c, 0x72, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x76, 0x69, 0x64, 0x65, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x48, 0x61, + 0x6e, 0x64, 0x6c, 0x65, 0x72, 0x00, 0x00, 0x00, 0x01, 0x21, 0x6d, 0x69, 0x6e, 0x66, 0x00, + 0x00, 0x00, 0x14, 0x76, 0x6d, 0x68, 0x64, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24, 0x64, 0x69, 0x6e, 0x66, 0x00, 0x00, 0x00, + 0x1c, 0x64, 0x72, 0x65, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x0c, 0x75, 0x72, 0x6c, 0x20, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xe1, 0x73, + 0x74, 0x62, 0x6c, 0x00, 0x00, 0x00, 0x79, 0x73, 0x74, 0x73, 0x64, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x69, 0x61, 0x76, 0x63, 0x31, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x40, 0x00, 0xf0, 0x00, 0x48, 0x00, 0x00, 0x00, + 0x48, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x13, 0x61, 0x76, 0x63, 0x43, 0x01, 0x42, 0x00, 0x1e, 0xff, 0xe1, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x73, 0x74, 0x74, 0x73, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00, + 0x00, 0x1c, 0x73, 0x74, 0x73, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x18, + 0x73, 0x74, 0x73, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x73, 0x74, 0x63, 0x6f, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x08, 0x6d, + 0x64, 0x61, 0x74, + ] +} + #[cfg(test)] mod tests { use super::*; @@ -2324,6 +2383,21 @@ mod tests { } } + /// Smoke-test: `minimal_valid_mp4()` (the bytes embedded for streaming upload + /// tests) must round-trip through `validate_video_file`. If this fails, the + /// embedded byte sequence needs to be re-generated. + #[test] + fn test_minimal_valid_mp4_passes_validate_video_file() { + let mp4_bytes = minimal_valid_mp4(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), &mp4_bytes).unwrap(); + let result = validate_video_file(tmp.path(), &test_config()); + assert!( + result.is_ok(), + "minimal_valid_mp4() must pass validate_video_file; got: {result:?}" + ); + } + #[test] fn test_validate_video_accepts_proprietary_major_with_isom_compatibility() { let mut mp4_bytes = build_minimal_mp4_moov_first(); diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 13437d9c0d..b5aa6d553b 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -22,7 +22,7 @@ use crate::handlers::ingest::{IngestAuth, IngestError}; use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; use crate::state::AppState; -use super::{api_error, internal_error, not_found, parse_query_or_400}; +use super::{api_error, db_read_error, internal_error, not_found, parse_query_or_400}; mod thread_roots; mod thread_window; @@ -772,7 +772,7 @@ async fn handle_channel_window_filter( &mut AuxReader::Session(&mut session), ) .await - .map_err(|e| internal_error(&format!("window aux error: {e}")))?; + .map_err(|e| db_read_error("window aux error", &e))?; for se in aux_events { if !seen_aux.insert(se.event.id) { continue; @@ -1635,7 +1635,7 @@ async fn query_events_authed( &mut AuxReader::Routed(&state.db, "bridge_thread_aux"), ) .await - .map_err(|e| internal_error(&format!("thread aux query error: {e}")))?; + .map_err(|e| db_read_error("thread aux query error", &e))?; for se in aux_events { if !seen_aux.insert(se.event.id) || !event_in_accessible_channel(&se, &accessible_channels) @@ -1792,7 +1792,7 @@ async fn query_events_authed( } } Err(e) => { - return Err(internal_error(&format!("query error: {e}"))); + return Err(db_read_error("query error", &e)); } } } @@ -2058,7 +2058,7 @@ async fn count_events_authed( match state.db.count_events_routed("bridge_count", &query).await { Ok(n) => total += n as u64, Err(e) => { - return Err(internal_error(&format!("count error: {e}"))); + return Err(db_read_error("count error", &e)); } } } else { @@ -2093,7 +2093,7 @@ async fn count_events_authed( } } Err(e) => { - return Err(internal_error(&format!("count error: {e}"))); + return Err(db_read_error("count error", &e)); } } } @@ -2129,7 +2129,7 @@ async fn count_events_authed( match state.db.count_events_routed("bridge_count", &query).await { Ok(n) => total += n as u64, Err(e) => { - return Err(internal_error(&format!("count error: {e}"))); + return Err(db_read_error("count error", &e)); } } } else { @@ -2163,7 +2163,7 @@ async fn count_events_authed( } } Err(e) => { - return Err(internal_error(&format!("count error: {e}"))); + return Err(db_read_error("count error", &e)); } } } @@ -2314,7 +2314,7 @@ async fn handle_bridge_search( .db .get_events_by_ids_routed("bridge_search_hydrate", tenant.community(), &id_refs) .await - .map_err(|e| internal_error(&format!("search fetch error: {e}")))?; + .map_err(|e| db_read_error("search fetch error", &e))?; // Build lookup map to preserve FTS relevance ordering. let event_map: std::collections::HashMap<[u8; 32], &buzz_core::StoredEvent> = stored_events @@ -7606,4 +7606,459 @@ mod postgres_tests { drop_scratch(&admin2, replica_pool, &replica_name).await; }); } + + // ── Statement-cancel propagation through every routed-read caller ───────── + // + // A replica whose `events` table is locked past its 300ms operator + // `statement_timeout` cancels every routed read with 57014, which the + // routed helpers now propagate instead of re-running on the writer. Every + // COUNT arm (fast/fallback × with/without `#h`) and search hydrate must + // then answer with the stable timeout contract on both transports: + // HTTP 503 `query timed out`, WS CLOSED `error: query timed out`. The + // ordinary-error controls rename `events` so reads fail with 42P01 and + // must stay a generic 500 / raw WS error. + + struct CancelFixture { + admin: sqlx::PgPool, + writer: sqlx::PgPool, + replica: sqlx::PgPool, + names: [String; 2], + state: Arc, + host: String, + community: buzz_core::CommunityId, + channel: String, + root: String, + reader: Keys, + } + + async fn cancel_fixture() -> CancelFixture { + use buzz_db::channel::{ChannelType, ChannelVisibility}; + let admin_url = crate::test_support::database_url(); + let admin = sqlx::PgPool::connect(&admin_url).await.expect("admin pool"); + let base = &admin_url[..admin_url.rfind('/').expect("db path")]; + let community = uuid::Uuid::new_v4(); + let channel = uuid::Uuid::new_v4(); + let host = format!("cancel-{}.local", community.simple()); + let author = Keys::generate(); + let root = EventBuilder::new(Kind::Custom(9), "root needle") + .tag(Tag::parse(["h", &channel.to_string()]).expect("h")) + .sign_with_keys(&author) + .expect("sign root"); + let mut pools = Vec::new(); + let mut names = Vec::new(); + for role in ["w", "r"] { + let name = format!("cancel_{role}_{}", uuid::Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) + .execute(&admin) + .await + .expect("create scratch db"); + if role == "r" { + sqlx::query(sqlx::AssertSqlSafe(format!( + "ALTER DATABASE {name} SET statement_timeout = '300ms'" + ))) + .execute(&admin) + .await + .expect("replica statement_timeout"); + } + let pool = sqlx::PgPool::connect(&format!("{base}/{name}")) + .await + .expect("connect scratch"); + buzz_db::migration::run_migrations(&pool) + .await + .expect("migrate"); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(&host) + .execute(&pool) + .await + .expect("seed community"); + let cid = buzz_core::CommunityId::from_uuid(community); + buzz_db::channel::create_channel_with_id( + &pool, + cid, + channel, + &format!("cancel-{}", channel.simple()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); + buzz_db::Db::from_pool(pool.clone()) + .insert_event(cid, &root, Some(channel)) + .await + .expect("insert root"); + pools.push(pool); + names.push(name); + } + let replica = pools.pop().expect("replica"); + let writer = pools.pop().expect("writer"); + + let mut config = crate::config::Config::from_env().expect("config"); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://cancel-test.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + let mut db = buzz_db::Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub"), + ); + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config.clone(), + db.clone(), + redis_pool, + buzz_audit::AuditService::new(writer.clone()), + pubsub, + buzz_auth::AuthService::new(config.auth.clone()), + buzz_search::SearchService::new(writer.clone()), + Arc::new(buzz_workflow::WorkflowEngine::new( + db, + buzz_workflow::WorkflowConfig::default(), + )), + Keys::generate(), + buzz_media::MediaStorage::new(&config.media).expect("media storage"), + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + CancelFixture { + admin, + writer, + replica, + names: [names[0].clone(), names[1].clone()], + state: Arc::new(state), + host, + community: buzz_core::CommunityId::from_uuid(community), + channel: channel.to_string(), + root: root.id.to_hex(), + reader: Keys::generate(), + } + } + + impl CancelFixture { + /// The four COUNT arms: fast (fully pushable) and fallback (`#t` is + /// not pushable, `#e` still reaches SQL), each with and without `#h`. + fn count_cases(&self) -> Vec<(&'static str, serde_json::Value)> { + let e = [&self.root]; + let h = [&self.channel]; + vec![ + ( + "fast #h", + serde_json::json!({"kinds": [9], "#h": h, "#e": e}), + ), + ( + "fallback #h", + serde_json::json!({"kinds": [9], "#h": h, "#e": e, "#t": ["x"]}), + ), + ("fast no-#h", serde_json::json!({"kinds": [9], "#e": e})), + ( + "fallback no-#h", + serde_json::json!({"kinds": [9], "#e": e, "#t": ["x"]}), + ), + ] + } + + fn search_filter(&self) -> serde_json::Value { + serde_json::json!({"kinds": [9], "#h": [&self.channel], "search": "needle"}) + } + + async fn http(&self, uri: &str, filter: &serde_json::Value) -> (StatusCode, Value) { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + let resp = crate::router::build_router(self.state.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(header::HOST, &self.host) + .header("x-pubkey", self.reader.public_key().to_hex()) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&[filter]).expect("json"))) + .expect("request"), + ) + .await + .expect("oneshot"); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body"); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) + } + + /// Drive a WS frame handler and return the first frame it sends. + async fn ws(&self, drive: F) -> String + where + F: FnOnce(Arc) -> Fut, + Fut: std::future::Future, + { + let (conn, mut send_rx) = self.ws_conn(); + drive(conn).await; + match send_rx.try_recv().expect("handler sent a frame") { + axum::extract::ws::Message::Text(t) => t.to_string(), + other => panic!("expected text frame, got {other:?}"), + } + } + + /// An authenticated reader connection and its outbound frame queue. + fn ws_conn( + &self, + ) -> ( + Arc, + tokio::sync::mpsc::Receiver, + ) { + let (send_tx, send_rx) = tokio::sync::mpsc::channel(64); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(4); + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: TenantContext::resolved(self.community, self.host.clone()), + remote_addr: "127.0.0.1:1234".parse().expect("addr"), + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: self.reader.public_key(), + scopes: Vec::new(), + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }, + )), + subscriptions: Arc::new(tokio::sync::Mutex::new(Default::default())), + send_tx, + ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + }); + (conn, send_rx) + } + + async fn ws_count(&self, filter: &serde_json::Value) -> String { + let filter: nostr::Filter = serde_json::from_value(filter.clone()).expect("filter"); + let state = self.state.clone(); + self.ws(|conn| { + crate::handlers::count::handle_count("c".into(), vec![filter], conn, state) + }) + .await + } + + async fn ws_search(&self) -> String { + let filter: nostr::Filter = + serde_json::from_value(self.search_filter()).expect("filter"); + let state = self.state.clone(); + self.ws(|conn| { + crate::handlers::req::handle_req("s".into(), vec![filter], vec![None], conn, state) + }) + .await + } + + async fn drop(self) { + drop(self.state); + for (pool, name) in [ + (self.writer, &self.names[0]), + (self.replica, &self.names[1]), + ] { + pool.close().await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(&self.admin) + .await; + } + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn routed_read_cancel_maps_to_timeout_contract_on_count_and_search() { + let fx = cancel_fixture().await; + + // Healthy controls: every arm and search hydrate serve before the lock. + for (arm, filter) in fx.count_cases() { + let (status, body) = fx.http("/count", &filter).await; + assert_eq!(status, StatusCode::OK, "healthy HTTP COUNT {arm}: {body}"); + let frame = fx.ws_count(&filter).await; + assert!( + frame.starts_with(r#"["COUNT""#), + "healthy WS COUNT {arm}: {frame}" + ); + } + let (status, body) = fx.http("/query", &fx.search_filter()).await; + assert_eq!(status, StatusCode::OK, "healthy HTTP search: {body}"); + assert!( + body.to_string().contains("root needle"), + "search hit: {body}" + ); + let frame = fx.ws_search().await; + assert!(frame.contains("root needle"), "healthy WS search: {frame}"); + + let mut locker = fx.replica.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock replica events"); + + let closed = |sub: &str| { + format!( + r#"["CLOSED","{sub}","{}"]"#, + crate::handlers::req::QUERY_TIMED_OUT_CLOSED + ) + }; + for (arm, filter) in fx.count_cases() { + let (status, body) = fx.http("/count", &filter).await; + assert_eq!( + (status, body["error"].as_str()), + ( + StatusCode::SERVICE_UNAVAILABLE, + Some(super::super::QUERY_TIMED_OUT) + ), + "HTTP COUNT {arm}" + ); + assert_eq!(fx.ws_count(&filter).await, closed("c"), "WS COUNT {arm}"); + } + let (status, body) = fx.http("/query", &fx.search_filter()).await; + assert_eq!( + (status, body["error"].as_str()), + ( + StatusCode::SERVICE_UNAVAILABLE, + Some(super::super::QUERY_TIMED_OUT) + ), + "HTTP search hydrate" + ); + assert_eq!(fx.ws_search().await, closed("s"), "WS search hydrate"); + locker.rollback().await.expect("unlock"); + + // Ordinary-error controls: `events` gone on both pools → 42P01 on the + // replica, writer re-run, 42P01 again. Not a timeout. + for pool in [&fx.writer, &fx.replica] { + sqlx::query("ALTER TABLE events RENAME TO events_gone") + .execute(pool) + .await + .expect("rename events"); + } + let (arm, filter) = fx.count_cases().swap_remove(0); + let (status, body) = fx.http("/count", &filter).await; + assert_eq!( + (status, body["error"].as_str()), + ( + StatusCode::INTERNAL_SERVER_ERROR, + Some("internal server error") + ), + "HTTP COUNT {arm} ordinary error" + ); + let frame = fx.ws_count(&filter).await; + assert!( + frame.starts_with(r#"["CLOSED","c","error: "#) && !frame.contains("query timed out"), + "WS COUNT {arm} ordinary error: {frame}" + ); + + fx.drop().await; + } + + /// A search REQ reusing a live subscription's ID retires it (NIP-01 + /// replacement) before its hydrate is cancelled, so the timeout CLOSED + /// leaves nothing registered under the ID. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn search_reusing_live_id_retires_it_before_timeout_closed() { + use crate::handlers::req::handle_req; + let fx = cancel_fixture().await; + let (conn, mut rx) = fx.ws_conn(); + let frames = |rx: &mut tokio::sync::mpsc::Receiver| { + std::iter::from_fn(|| rx.try_recv().ok()) + .map(|msg| match msg { + axum::extract::ws::Message::Text(t) => t.to_string(), + other => panic!("expected text frame, got {other:?}"), + }) + .collect::>() + }; + let channel: uuid::Uuid = fx.channel.parse().expect("channel uuid"); + let topic = buzz_pubsub::EventTopic::Channel(channel); + let tenant = TenantContext::resolved(fx.community, fx.host.clone()); + let live: nostr::Filter = + serde_json::from_value(serde_json::json!({"kinds": [9], "#h": [&fx.channel]})) + .expect("live filter"); + handle_req( + "x".into(), + vec![live], + vec![None], + conn.clone(), + fx.state.clone(), + ) + .await; + assert!( + frames(&mut rx) + .last() + .is_some_and(|f| f == r#"["EOSE","x"]"#), + "live x served" + ); + assert!(conn.subscriptions.lock().await.contains_key("x")); + assert!(fx + .state + .sub_registry + .get_filters(conn.conn_id, "x") + .is_some()); + assert_eq!(fx.state.pubsub.topic_refcount(&tenant, topic).await, 1); + + let mut locker = fx.replica.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock replica events"); + let search: nostr::Filter = + serde_json::from_value(fx.search_filter()).expect("search filter"); + let live_owner = *conn.subscriptions.lock().await.get("x").expect("x owned"); + let task = tokio::spawn(handle_req( + "x".into(), + vec![search], + vec![None], + conn.clone(), + fx.state.clone(), + )); + // Once search has claimed `x` its hydrate blocks on the lock. The old + // live fan-out must already be retired then, not only at final cleanup. + while conn.subscriptions.lock().await.get("x") == Some(&live_owner) { + tokio::task::yield_now().await; + } + assert!( + fx.state + .sub_registry + .get_filters(conn.conn_id, "x") + .is_none(), + "accepting search must retire live fan-out" + ); + assert_eq!(fx.state.pubsub.topic_refcount(&tenant, topic).await, 0); + task.await.expect("search task"); + locker.rollback().await.expect("unlock"); + + assert_eq!( + frames(&mut rx), + vec![format!( + r#"["CLOSED","x","{}"]"#, + crate::handlers::req::QUERY_TIMED_OUT_CLOSED + )] + ); + assert!(conn.subscriptions.lock().await.is_empty(), "conn map"); + assert!( + fx.state + .sub_registry + .get_filters(conn.conn_id, "x") + .is_none(), + "fan-out registration must be retired" + ); + assert_eq!(fx.state.pubsub.topic_refcount(&tenant, topic).await, 0); + + drop(conn); + fx.drop().await; + } } diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 62e5945b3d..7c56d557c7 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -19,8 +19,12 @@ use axum::{ }; use base64::Engine; use buzz_audit::{AuditAction, NewAuditEntry}; +use buzz_auth::DenialClass; use buzz_core::tenant::TenantContext; -use buzz_media::{BlobDescriptor, MediaError, UploadAttribution, UploadNetworkInfo}; +use buzz_media::auth::BlossomStrictness; +use buzz_media::{ + BlobDescriptor, BlossomDenialKind, MediaError, UploadAttribution, UploadNetworkInfo, +}; use crate::state::AppState; @@ -70,6 +74,10 @@ pub(crate) struct AuthenticatedUpload { /// door in `bridge.rs`. Server-resolved, never client-supplied. tenant: TenantContext, route_mode: UploadRouteMode, + /// NIP-FI strictness derived at extraction time. Carried to the handler so + /// post-body auth failures (hash-binding, x-tag mismatches) produce the same + /// mode-aware denial shape as pre-body failures. + strictness: BlossomStrictness, _upload_permit: UploadPermit, } @@ -79,6 +87,63 @@ enum UploadRouteMode { LegacyMedia, } +/// Mode-aware Blossom auth rejection for the relay layer. +/// +/// In `Strict` mode, Blossom denial errors map to the NIP-FI fixed +/// text/plain responses (`DenialClass` byte contract). In `Permissive` mode +/// (Off-mode deployments), the legacy JSON 401 shape is preserved unchanged +/// [FI-INV-15]. +/// +/// Non-Blossom errors (`blossom_denial_kind()` returns `None`) always fall +/// through to `MediaError::into_response()` regardless of mode. +pub(crate) struct MediaDenial(MediaError, BlossomStrictness); + +impl IntoResponse for MediaDenial { + fn into_response(self) -> Response { + let MediaDenial(error, strictness) = self; + if strictness == BlossomStrictness::Strict { + if let Some(kind) = error.blossom_denial_kind() { + let class = match kind { + BlossomDenialKind::MissingEvidence => DenialClass::MissingEvidence, + BlossomDenialKind::EvidenceRejected => DenialClass::EvidenceRejected, + BlossomDenialKind::AuthorizationDenied => DenialClass::AuthorizationDenied, + }; + tracing::warn!( + error = %error, + denial_class = ?class, + "Blossom auth denial (strict)" + ); + let mut builder = axum::http::Response::builder() + .status(class.http_status()) + .header(header::CONTENT_TYPE, class.content_type()); + if let Some(challenge) = class.www_authenticate() { + builder = builder.header("WWW-Authenticate", challenge); + } + return builder + .body(axum::body::Body::from(class.http_body())) + .expect("NIP-FI denial response is always valid"); + } + } + error.into_response() + } +} + +/// Wrap a `MediaError` with the active strictness to produce the correct +/// response shape at the relay boundary. +fn media_denial(error: MediaError, strictness: BlossomStrictness) -> MediaDenial { + MediaDenial(error, strictness) +} + +impl From for MediaDenial { + /// Default conversion uses Permissive mode — non-auth errors always fall + /// through to `MediaError::into_response()` regardless of mode, so the + /// strictness value is irrelevant. Auth errors at the extractor boundary + /// use explicit `media_denial(e, strictness)` calls instead. + fn from(e: MediaError) -> Self { + MediaDenial(e, BlossomStrictness::Permissive) + } +} + fn should_stream_as_video(sniff: &[u8]) -> bool { infer::get(sniff).is_some_and(|kind| kind.mime_type() == "video/mp4") || buzz_media::looks_like_iso_bmff(sniff) @@ -240,12 +305,8 @@ fn serving_lease_lost(error: anyhow::Error) -> MediaError { /// Returns a [`BlobDescriptor`] JSON on success. // TODO(v2): Add persistent per-pubkey storage quotas. Admission limits below // bound active parser/storage work, but they do not cap durable bytes stored. -// UploadContext is pub(crate) — it's an internal extractor type, never exposed -// outside this crate. The warning is benign: axum resolves it at compile time -// via trait bounds, not by name. -#[allow(private_interfaces)] #[allow(clippy::result_large_err)] // Response is the natural error type for axum closures -pub async fn upload_blob( +pub(crate) async fn upload_blob( State(state): State>, ctx: UploadContext, headers: HeaderMap, @@ -261,12 +322,14 @@ pub async fn upload_blob( // The closure must verify the auth event against the tenant host BEFORE // returning the proven pubkey to the admission gate — same ordering invariant // as the read path. [FI-TRACE-AUTHORITY-UNIFORM] + let strictness = blossom_strictness_from_state(&state); let tenant_host = ctx.tenant.host().to_owned(); let headers_clone = headers.clone(); let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { let auth_event = extract_blossom_auth(&headers_clone).map_err(|e| e.into_response())?; - // Permissive window (3600s): content type unknown until body arrives. - buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(&tenant_host), 3600) + // Pre-body check: freshness, cardinality, and server tag. The x-tag + // hash binding is checked after body completion. + buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(&tenant_host), strictness) .map_err(|e| e.into_response())?; let pubkey = auth_event.pubkey; Ok(Nip98Proof::new(pubkey, auth_event)) @@ -281,21 +344,23 @@ pub async fn upload_blob( // them because they protect body integrity, not the assertion boundary. let claimed_hash = match headers.get("x-sha-256").and_then(|v| v.to_str().ok()) { Some(h) => h.to_owned(), - None => return MediaError::MissingTag("x-sha-256").into_response(), + None => { + return media_denial(MediaError::MissingTag("x-sha-256"), strictness).into_response() + } }; if claimed_hash.len() != 64 || !claimed_hash .chars() .all(|c| matches!(c, '0'..='9' | 'a'..='f')) { - return MediaError::HashMismatch.into_response(); + return media_denial(MediaError::HashMismatch, strictness).into_response(); } let has_matching_x = auth_event .tags .iter() .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(&claimed_hash))); if !has_matching_x { - return MediaError::HashMismatch.into_response(); + return media_denial(MediaError::HashMismatch, strictness).into_response(); } // Post-admission: relay membership gate (NIP-43). @@ -309,7 +374,7 @@ pub async fn upload_blob( ) .await .map(|_| ()) - .map_err(|_| MediaError::RelayMembershipRequired) + .map_err(|_| media_denial(MediaError::RelayMembershipRequired, strictness)) { return e.into_response(); } @@ -337,6 +402,7 @@ pub async fn upload_blob( auth_event, tenant: ctx.tenant, route_mode: ctx.route_mode, + strictness, _upload_permit: upload_permit, }; upload_blob_inner(state, auth, headers, body).await @@ -359,13 +425,15 @@ async fn upload_blob_result( auth: AuthenticatedUpload, headers: HeaderMap, body: axum::body::Body, -) -> Result, MediaError> { +) -> Result, MediaDenial> { + let strictness = auth.strictness; let attribution = upload_attribution(&state, &auth, &headers).await; let serving_write = buzz_deletion::acquire_serving_write(&state.db, auth.tenant.community(), "media_upload") .await - .map_err(serving_write_error)?; + .map_err(serving_write_error) + .map_err(|e| media_denial(e, strictness))?; if auth.route_mode == UploadRouteMode::LegacyMedia { metrics::counter!("buzz_media_legacy_upload_route_total").increment(1); @@ -386,13 +454,19 @@ async fn upload_blob_result( sniff.extend_from_slice(&chunk[..chunk.len().min(needed)]); replay_chunks.push(chunk); } - Some(Err(error)) => return Err(MediaError::Io(error.to_string())), + Some(Err(error)) => { + return Err(media_denial(MediaError::Io(error.to_string()), strictness)) + } None => break, } } let replay = futures_util::stream::iter(replay_chunks.into_iter().map(Ok)).chain(source); - serving_write.verify().await.map_err(serving_lease_lost)?; + serving_write + .verify() + .await + .map_err(serving_lease_lost) + .map_err(MediaDenial::from)?; let mut descriptor = serving_write .protect(async { @@ -471,7 +545,15 @@ async fn upload_blob_result( Err(_) => MediaError::Internal, } } - })??; + }) + // The outer anyhow→MediaError map above captures lease-loss and + // protect-layer failures. Apply media_denial so Strict produces + // byte-exact NIP-FI responses for those errors. + .map_err(|e| media_denial(e, strictness))? + // The inner Result captures failures from the async body (process_*, + // hash mismatches). Wrap through media_denial so post-body auth errors + // get the same NIP-FI shape as pre-body ones. + .map_err(|e| media_denial(e, strictness))?; rewrite_descriptor_urls_for_tenant( &mut descriptor, @@ -577,9 +659,10 @@ fn extract_blossom_read_proof( headers: &HeaderMap, sha256: &str, tenant_host: &str, + strictness: BlossomStrictness, ) -> Result, MediaError> { let auth_event = extract_blossom_auth(headers)?; - buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant_host), 3600)?; + buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant_host), strictness)?; let pubkey = auth_event.pubkey; Ok(crate::nip_fi_http::Nip98Proof::new(pubkey, auth_event)) } @@ -595,7 +678,8 @@ async fn enforce_blossom_read_membership( tenant: &TenantContext, auth_event: &nostr::Event, headers: &HeaderMap, -) -> Result<(), MediaError> { + strictness: BlossomStrictness, +) -> Result<(), MediaDenial> { let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); crate::api::relay_members::enforce_relay_membership( state, @@ -606,7 +690,7 @@ async fn enforce_blossom_read_membership( ) .await .map(|_| ()) - .map_err(|_| MediaError::RelayMembershipRequired) + .map_err(|_| media_denial(MediaError::RelayMembershipRequired, strictness)) } fn blob_cache_control() -> &'static str { @@ -693,11 +777,11 @@ const MAX_RANGE_CHUNK: u64 = 16 * 1024 * 1024; /// /// All responses include `Accept-Ranges: bytes` so video players know seeking is supported. #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers -pub async fn get_blob( +pub(crate) async fn get_blob( State(state): State>, Path(sha256_ext): Path, req_headers: HeaderMap, -) -> Result { +) -> Result { validate_media_path(&sha256_ext)?; // Row zero: bind tenant. Blossom auth extraction and NIP-FI admission follow // so that in Enforce mode a missing/malformed Authorization header produces @@ -708,6 +792,7 @@ pub async fn get_blob( .next() .unwrap_or(&sha256_ext) .to_owned(); + let strictness = blossom_strictness_from_state(&state); let tenant_host = tenant.host().to_owned(); let headers_clone = req_headers.clone(); // NIP-FI admission with Blossom extraction as the NIP-98 closure. @@ -715,7 +800,7 @@ pub async fn get_blob( // EvidenceRejected). In Off mode: MediaError propagates unchanged [FI-INV-15]. use crate::nip_fi_http::admit_nip_fi_http_on_state; let admission = match admit_nip_fi_http_on_state(&state, &req_headers, move || { - extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host) + extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host, strictness) .map_err(|e| e.into_response()) }) { Ok(a) => a, @@ -723,8 +808,10 @@ pub async fn get_blob( }; let auth_event = admission.into_extra(); // Post-admission: membership gate. - enforce_blossom_read_membership(&state, &tenant, &auth_event, &req_headers).await?; - serve_blob_for_tenant(&state, &tenant, &sha256_ext, &req_headers).await + enforce_blossom_read_membership(&state, &tenant, &auth_event, &req_headers, strictness).await?; + serve_blob_for_tenant(&state, &tenant, &sha256_ext, &req_headers) + .await + .map_err(MediaDenial::from) } /// Serve a validated blob from an already-authorized tenant context. @@ -983,11 +1070,11 @@ fn parse_byte_range(range: &str, total: u64) -> Option<(u64, u64)> { /// object metadata — to prevent MIME spoofing via tampered storage. If the sidecar /// is missing, we return 404 rather than fall back to untrusted metadata. #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers -pub async fn head_blob( +pub(crate) async fn head_blob( State(state): State>, headers: HeaderMap, Path(sha256_ext): Path, -) -> Result { +) -> Result { validate_media_path(&sha256_ext)?; // Row zero: bind tenant. Blossom auth extraction and NIP-FI admission follow // so that in Enforce mode a missing/malformed Authorization header produces @@ -998,18 +1085,19 @@ pub async fn head_blob( .next() .unwrap_or(&sha256_ext) .to_owned(); + let strictness = blossom_strictness_from_state(&state); let tenant_host = tenant.host().to_owned(); let headers_clone = headers.clone(); use crate::nip_fi_http::admit_nip_fi_http_on_state; let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { - extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host) + extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host, strictness) .map_err(|e| e.into_response()) }) { Ok(a) => a, Err(resp) => return Ok(resp), }; let auth_event = admission.into_extra(); - enforce_blossom_read_membership(&state, &tenant, &auth_event, &headers).await?; + enforce_blossom_read_membership(&state, &tenant, &auth_event, &headers, strictness).await?; let cache_control = blob_cache_control(); // Sidecar gate FIRST — reject before any blob I/O. @@ -1035,7 +1123,7 @@ pub async fn head_blob( .await .map_err(|_| MediaError::NotFound)?; if requested_ext != sidecar.ext { - return Err(MediaError::NotFound); + return Err(MediaError::NotFound.into()); } } sidecar_mime @@ -1090,6 +1178,10 @@ async fn resolve_s3_key( /// Extract and verify a kind:24242 Blossom auth event from the `Authorization` header. /// /// Accepts both base64url (BUD-11 spec) and standard base64 (nostr-tools compat). +/// +/// Takes the first `Authorization` value. Repeated `Authorization` fields are +/// rejected in Enforce by the cardinality gate in `admit_nip_fi_http`; Off keeps +/// the legacy first-value behavior [FI-INV-15]. fn extract_blossom_auth(headers: &HeaderMap) -> Result { use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; @@ -1113,13 +1205,26 @@ fn extract_blossom_auth(headers: &HeaderMap) -> Result Ok(event) } +/// Derive `BlossomStrictness` from the relay's NIP-FI mode. +/// +/// Enforce applies the strict NIP-FI kind-24242 rules. Off keeps the +/// pre-NIP-FI permissive verifier [FI-INV-15]. DenyProtected never reaches a +/// verifier: `admit_nip_fi_http_on_state` answers 503 first. +fn blossom_strictness_from_state(state: &AppState) -> BlossomStrictness { + if state.config.nip_fi.is_enforce() { + BlossomStrictness::Strict + } else { + BlossomStrictness::Permissive + } +} + #[cfg(test)] mod tests { use super::*; use std::sync::Arc; use axum::{ - body::Body, + body::{to_bytes, Body}, http::{header, Request, StatusCode}, }; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; @@ -1128,6 +1233,251 @@ mod tests { const VALID_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + // ── MediaDenial response-shape tests (NIP-FI §755-773) ────────────────── + // These tests pin the byte-exact response contract for Strict mode and + // confirm Permissive mode produces the unchanged legacy JSON 401 shape. + + #[tokio::test] + async fn strict_missing_auth_produces_nip_fi_401_with_www_authenticate() { + let denial = MediaDenial(MediaError::MissingAuth, BlossomStrictness::Strict); + let resp = denial.into_response(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("text/plain"), + "expected text/plain CT, got: {ct}" + ); + + let www_auth = resp + .headers() + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "expected 'Nostr' WWW-Authenticate challenge" + ); + + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(body.as_ref(), b"authentication required\n"); + } + + #[tokio::test] + async fn strict_evidence_rejected_produces_nip_fi_403_text_plain() { + for error in [ + MediaError::InvalidSignature, + MediaError::TokenExpired, + MediaError::TimestampOutOfWindow, + MediaError::HashMismatch, + MediaError::ServerMismatch, + MediaError::MissingTag("server"), + MediaError::DuplicateTag("Authorization"), + MediaError::InvalidAuthScheme, + ] { + let label = format!("{error:?}"); + let denial = MediaDenial(error, BlossomStrictness::Strict); + let resp = denial.into_response(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "expected 403 for Strict {label}" + ); + + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("text/plain"), + "expected text/plain CT for {label}, got: {ct}" + ); + + assert!( + resp.headers().get("www-authenticate").is_none(), + "403 must not have WWW-Authenticate for {label}" + ); + + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "wrong body for {label}" + ); + } + } + + #[tokio::test] + async fn permissive_missing_auth_keeps_legacy_json_401() { + let denial = MediaDenial(MediaError::MissingAuth, BlossomStrictness::Permissive); + let resp = denial.into_response(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "Permissive must keep JSON CT, got: {ct}" + ); + + assert!( + resp.headers().get("www-authenticate").is_none(), + "Permissive must not add WWW-Authenticate" + ); + } + + #[tokio::test] + async fn permissive_evidence_rejected_keeps_legacy_json_401() { + for error in [ + MediaError::InvalidSignature, + MediaError::TokenExpired, + MediaError::HashMismatch, + MediaError::MissingTag("t"), + ] { + let label = format!("{error:?}"); + let denial = MediaDenial(error, BlossomStrictness::Permissive); + let resp = denial.into_response(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "Permissive: expected 401 for {label}" + ); + + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "Permissive must keep JSON CT for {label}, got: {ct}" + ); + } + } + + #[tokio::test] + async fn non_auth_errors_always_fall_through_regardless_of_mode() { + for strictness in [BlossomStrictness::Strict, BlossomStrictness::Permissive] { + let denial = MediaDenial(MediaError::NotFound, strictness); + assert_eq!(denial.into_response().status(), StatusCode::NOT_FOUND); + + let denial = MediaDenial(MediaError::Internal, strictness); + assert!(denial.into_response().status().is_server_error()); + } + } + + // ── Extractor hash-check denial sites (sites 1+2 per Paul's spot-check) ── + // These pins cover the missing-X-SHA-256 header (MissingTag) and the + // malformed/unmatched hash (HashMismatch) cases that previously bypassed + // the strictness split via `From for MediaDenial` (Permissive). + + #[tokio::test] + async fn strict_missing_x_sha256_header_produces_nip_fi_403() { + let denial = MediaDenial( + MediaError::MissingTag("x-sha-256"), + BlossomStrictness::Strict, + ); + let resp = denial.into_response(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "missing x-sha-256 header must be 403 in Strict mode" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("text/plain"), + "expected text/plain CT, got: {ct}" + ); + assert!( + resp.headers().get("www-authenticate").is_none(), + "403 must not have WWW-Authenticate" + ); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(body.as_ref(), b"evidence rejected\n"); + } + + #[tokio::test] + async fn strict_hash_mismatch_produces_nip_fi_403() { + let denial = MediaDenial(MediaError::HashMismatch, BlossomStrictness::Strict); + let resp = denial.into_response(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "hash mismatch must be 403 in Strict mode" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(ct.contains("text/plain"), "expected text/plain, got: {ct}"); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(body.as_ref(), b"evidence rejected\n"); + } + + #[tokio::test] + async fn permissive_missing_x_sha256_header_keeps_legacy_json_401() { + let denial = MediaDenial( + MediaError::MissingTag("x-sha-256"), + BlossomStrictness::Permissive, + ); + let resp = denial.into_response(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "Permissive must keep legacy 401 for MissingTag" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "Permissive must keep JSON CT, got: {ct}" + ); + assert!( + resp.headers().get("www-authenticate").is_none(), + "Permissive must not add WWW-Authenticate" + ); + } + + #[tokio::test] + async fn permissive_hash_mismatch_keeps_legacy_json_401() { + let denial = MediaDenial(MediaError::HashMismatch, BlossomStrictness::Permissive); + let resp = denial.into_response(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "Permissive must keep legacy 401 for HashMismatch" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "Permissive must keep JSON CT, got: {ct}" + ); + } + #[test] fn serving_write_error_taxonomy_separates_fence_from_backend_failure() { let fenced = anyhow::Error::from(buzz_db::DbError::AccessDenied("fenced".to_string())); @@ -1142,6 +1492,68 @@ mod tests { )); } + // ── Finding 5 (F5): membership denials in Strict mode → authorization denied ─ + + #[tokio::test] + async fn strict_relay_membership_required_produces_nip_fi_403_authorization_denied() { + let denial = MediaDenial( + MediaError::RelayMembershipRequired, + BlossomStrictness::Strict, + ); + let resp = denial.into_response(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "membership denial must be 403 in Strict mode" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("text/plain"), + "expected text/plain CT, got: {ct}" + ); + assert!( + resp.headers().get("www-authenticate").is_none(), + "403 authorization denied must not have WWW-Authenticate" + ); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "NIP-FI membership denial body must be 'authorization denied\\n'" + ); + } + + #[tokio::test] + async fn permissive_relay_membership_required_keeps_legacy_json_403() { + // In Permissive mode membership denial falls through to MediaError::into_response() + // which produces the legacy JSON 403. + let denial = MediaDenial( + MediaError::RelayMembershipRequired, + BlossomStrictness::Permissive, + ); + let resp = denial.into_response(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "membership denial must still be 403 in Permissive mode" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "Permissive membership denial must keep JSON CT, got: {ct}" + ); + } + #[test] fn feedback_inline_allows_only_sniffed_passive_raster_images() { // Real magic bytes for the four verified passive raster formats. @@ -1302,7 +1714,7 @@ mod tests { fn media_get_tags_for(host: &str, sha256: Option<&str>) -> Vec { let now = Timestamp::now().as_secs(); - let expiration = (now + 300).to_string(); + let expiration = (now + 55).to_string(); let mut tags = vec![ Tag::parse(["t", "get"]).expect("t tag"), Tag::parse(["expiration", &expiration]).expect("expiration tag"), @@ -1355,7 +1767,7 @@ mod tests { async fn media_read_rejects_upload_verb_wrong_server_and_wrong_x() { let keys = Keys::generate(); let now = Timestamp::now().as_secs(); - let expiration = (now + 300).to_string(); + let expiration = (now + 55).to_string(); let cases = [ vec![ Tag::parse(["t", "upload"]).expect("t tag"), @@ -1846,29 +2258,52 @@ mod tests { } /// Mint a valid Blossom upload auth header value. + /// NIP-FI-compliant: 55s lifetime, single `server` tag. fn blossom_upload_auth_value(keys: &Keys, host: &str, sha256_hex: &str) -> String { + blossom_upload_auth_value_with(keys, Some(host), sha256_hex, 55) + } + + /// Upload proof with a chosen lifetime and optional `server` tag, for + /// proofs that only the Permissive verifier accepts. + fn blossom_upload_auth_value_with( + keys: &Keys, + server: Option<&str>, + sha256_hex: &str, + lifetime_secs: u64, + ) -> String { use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; use nostr::JsonUtil as _; - let now = nostr::Timestamp::now().as_secs(); - let exp = now + 300; + let exp = nostr::Timestamp::now().as_secs() + lifetime_secs; + let mut tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["expiration", &exp.to_string()]).unwrap(), + Tag::parse(["x", sha256_hex]).unwrap(), + ]; + if let Some(server) = server { + tags.push(Tag::parse(["server", server]).unwrap()); + } let event = EventBuilder::new(Kind::from(24242), "Upload blob") - .tags(vec![ - Tag::parse(["t", "upload"]).unwrap(), - Tag::parse(["expiration", &exp.to_string()]).unwrap(), - Tag::parse(["server", host]).unwrap(), - Tag::parse(["x", sha256_hex]).unwrap(), - ]) + .tags(tags) .sign_with_keys(keys) .expect("sign blossom upload auth"); format!("Nostr {}", B64.encode(event.as_json().as_bytes())) } /// Mint a valid Blossom get auth header value. + /// NIP-FI-compliant: 55s lifetime, single `server` tag. fn blossom_get_auth_value(keys: &Keys, host: &str, sha256_hex: &str) -> String { + blossom_get_auth_value_with(keys, host, sha256_hex, 55) + } + + fn blossom_get_auth_value_with( + keys: &Keys, + host: &str, + sha256_hex: &str, + lifetime_secs: u64, + ) -> String { use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; use nostr::JsonUtil as _; - let now = nostr::Timestamp::now().as_secs(); - let exp = now + 300; + let exp = nostr::Timestamp::now().as_secs() + lifetime_secs; let event = EventBuilder::new(Kind::from(24242), "Get blob") .tags(vec![ Tag::parse(["t", "get"]).unwrap(), @@ -1989,12 +2424,33 @@ mod tests { host: &str, authorization: &[&str], assertion: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let sha = sha256_hex(AUDIO_BODY); + upload_request_with( + rt, + state, + route, + host, + (Some(&sha), AUDIO_BODY), + authorization, + assertion, + ) + } + + /// `upload_request` with an explicit `X-SHA-256` header (or none) and body. + fn upload_request_with( + rt: &tokio::runtime::Runtime, + state: &Arc, + route: &str, + host: &str, + (x_sha256, body): (Option<&str>, &[u8]), + authorization: &[&str], + assertion: Option<&str>, ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { let mut headers = axum::http::HeaderMap::new(); - headers.insert( - "x-sha-256", - sha256_hex(AUDIO_BODY).parse().expect("valid header"), - ); + if let Some(x_sha256) = x_sha256 { + headers.insert("x-sha-256", x_sha256.parse().expect("valid header")); + } for value in authorization { headers.append( axum::http::header::AUTHORIZATION, @@ -2013,7 +2469,7 @@ mod tests { route, host, headers, - AUDIO_BODY, + body, )) } @@ -3362,5 +3818,473 @@ mod tests { "admitted upload MUST release its per-key slot" ); } + + // ── Blossom strictness follows NIP-FI mode ────────────────────────── + // + // Enforce selects the Strict kind-24242 verifier; Off keeps the + // pre-NIP-FI Permissive one. Each proof below is accepted by + // Permissive and rejected by Strict (lifetime > 60s, or no `server` + // tag), so reverting `blossom_strictness_from_state` to Permissive + // turns the Enforce denials into the 415 the Off cases pin. + + fn permissive_only_upload_proofs(keys: &Keys, host: &str) -> [(String, &'static str); 2] { + let sha = sha256_hex(AUDIO_BODY); + [ + ( + blossom_upload_auth_value_with(keys, Some(host), &sha, 300), + "300s lifetime", + ), + ( + blossom_upload_auth_value_with(keys, None, &sha, 55), + "no server tag", + ), + ] + } + + fn mutate_state(state: &mut Arc, f: impl FnOnce(&mut crate::config::Config)) { + let state = Arc::get_mut(state).expect("fixture state is uniquely owned"); + f(Arc::make_mut(&mut state.config)); + } + + fn media_read( + rt: &tokio::runtime::Runtime, + state: &Arc, + method: &str, + host: &str, + authorization: &str, + assertion: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + authorization.parse().expect("valid header bytes"), + ); + if let Some(assertion) = assertion { + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + } + let path = format!("/media/{}", "a".repeat(64)); + rt.block_on(media_oneshot( + Arc::clone(state), + method, + &path, + host, + headers, + b"", + )) + } + + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_strict_rejects_permissive_only_proof() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + for (proof, case) in permissive_only_upload_proofs(&keys, &host) { + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request(&rt, &state, route, &host, &[&proof], Some(&assertion)), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"evidence rejected\n", + &format!("Enforce {route}: {case}"), + ); + } + } + } + + #[test] + #[ignore = "requires Postgres"] + fn upload_off_permissive_accepts_permissive_only_proof() { + let (rt, state, host) = media_fixture(media_off_test_state()); + for (proof, case) in permissive_only_upload_proofs(&Keys::generate(), &host) { + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request(&rt, &state, route, &host, &[&proof], None), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "application/json", + None, + AUDIO_REJECTION, + &format!("Off {route}: {case}"), + ); + } + } + } + + /// GET and HEAD each derive strictness independently; HEAD carries the + /// same denial headers with the body suppressed. + const READ_METHODS: [(&str, &[u8]); 2] = [("GET", b"evidence rejected\n"), ("HEAD", b"")]; + + #[test] + #[ignore = "requires Postgres"] + fn read_strictness_follows_nip_fi_mode() { + let keys = Keys::generate(); + let sha = "a".repeat(64); + + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let proof = blossom_get_auth_value_with(&keys, &host, &sha, 300); + for (method, body) in READ_METHODS { + assert_exact_response( + &media_read(&rt, &state, method, &host, &proof, Some(&assertion)), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + body, + &format!("Enforce {method}: 300s lifetime, non-member"), + ); + } + + let (rt, state, host) = media_fixture(media_off_test_state()); + let proof = blossom_get_auth_value_with(&keys, &host, &sha, 300); + for (method, _) in READ_METHODS { + let (status, _, body) = media_read(&rt, &state, method, &host, &proof, None); + assert_eq!( + status, + StatusCode::NOT_FOUND, + "Off {method} must pass Permissive auth and reach the sidecar gate; \ + body {body:?}" + ); + } + } + + // ── Off keeps main's Permissive `t` predicate [FI-INV-15] ─────────── + + /// Blossom proof with the given `t` tags plus valid expiration, server, + /// and x tags. + fn blossom_auth_value_with_t( + keys: &Keys, + t_tags: &[&[&str]], + host: &str, + sha256_hex: &str, + ) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; + use nostr::JsonUtil as _; + let exp = (nostr::Timestamp::now().as_secs() + 55).to_string(); + let mut tags: Vec = t_tags + .iter() + .map(|t| Tag::parse(t.iter().copied()).unwrap()) + .collect(); + tags.push(Tag::parse(["expiration", &exp]).unwrap()); + tags.push(Tag::parse(["server", host]).unwrap()); + tags.push(Tag::parse(["x", sha256_hex]).unwrap()); + let event = EventBuilder::new(Kind::from(24242), "Blossom auth") + .tags(tags) + .sign_with_keys(keys) + .expect("sign blossom auth"); + format!("Nostr {}", B64.encode(event.as_json().as_bytes())) + } + + const LEGACY_AUTH_FAILED: &[u8] = br#"{"error":"authentication failed"}"#; + + /// An empty-valued `t` beside a valid one is rejected with main's legacy + /// 401 JSON in either order; a valueless `t` is ignored, as on main. + #[test] + #[ignore = "requires Postgres"] + fn off_empty_t_tag_keeps_legacy_401_valueless_is_ignored() { + let (rt, state, host) = media_fixture(media_off_test_state()); + let keys = Keys::generate(); + let read_sha = "a".repeat(64); + let upload_sha = sha256_hex(AUDIO_BODY); + for (extra, rejected) in [(&["t", ""][..], true), (&["t"][..], false)] { + for (verb_first, order) in [(true, "verb first"), (false, "verb last")] { + let t_tags = |verb: &'static str| -> Vec<&[&str]> { + let verb: &[&str] = if verb == "get" { + &["t", "get"] + } else { + &["t", "upload"] + }; + if verb_first { + vec![verb, extra] + } else { + vec![extra, verb] + } + }; + let context = format!("Off {extra:?} {order}"); + let read_proof = + blossom_auth_value_with_t(&keys, &t_tags("get"), &host, &read_sha); + let read = media_read(&rt, &state, "GET", &host, &read_proof, None); + let upload_proof = + blossom_auth_value_with_t(&keys, &t_tags("upload"), &host, &upload_sha); + if rejected { + assert_exact_response( + &read, + StatusCode::UNAUTHORIZED, + "application/json", + None, + LEGACY_AUTH_FAILED, + &format!("{context} read"), + ); + } else { + assert_eq!(read.0, StatusCode::NOT_FOUND, "{context} read"); + } + for route in UPLOAD_ROUTES { + let response = + upload_request(&rt, &state, route, &host, &[&upload_proof], None); + if rejected { + assert_exact_response( + &response, + StatusCode::UNAUTHORIZED, + "application/json", + None, + LEGACY_AUTH_FAILED, + &format!("{context} {route}"), + ); + } else { + assert_exact_response( + &response, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "application/json", + None, + AUDIO_REJECTION, + &format!("{context} {route}"), + ); + } + } + } + } + } + + // ── Upload hash denials go through the handler in both modes ──────── + + /// Decodable 1x1 PNG: passes content validation on both upload routes, + /// so a signed/header hash that differs from its digest is rejected by + /// the post-body hash check before any storage call. + const PNG_BODY: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, + 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x78, + 0x9c, 0x63, 0xf8, 0xff, 0xff, 0x3f, 0x00, 0x05, 0xfe, 0x02, 0xfe, 0x0d, 0xef, 0x46, + 0xb8, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]; + + fn assert_upload_hash_denials(enforce: bool) { + let (rt, state, host) = if enforce { + media_fixture(media_enforce_test_state()) + } else { + media_fixture(media_off_test_state()) + }; + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let assertion = enforce.then_some(assertion.as_str()); + let audio_sha = sha256_hex(AUDIO_BODY); + let other_sha = "b".repeat(64); + let audio_proof = blossom_upload_auth_value(&keys, &host, &audio_sha); + let other_proof = blossom_upload_auth_value(&keys, &host, &other_sha); + /// `X-SHA-256` header value and request body. + type HashRequest<'a> = (Option<&'a str>, &'a [u8]); + let cases: [(&str, HashRequest, &str); 4] = [ + ("X-SHA-256 missing", (None, AUDIO_BODY), &audio_proof), + ( + "X-SHA-256 malformed", + (Some("not-hex"), AUDIO_BODY), + &audio_proof, + ), + ( + "X-SHA-256 not in signed x", + (Some(&other_sha), AUDIO_BODY), + &audio_proof, + ), + ( + "body hash differs from signed/header hash", + (Some(&other_sha), PNG_BODY), + &other_proof, + ), + ]; + for (case, request, proof) in cases { + for route in UPLOAD_ROUTES { + let response = upload_request_with( + &rt, + &state, + route, + &host, + request, + &[proof], + assertion, + ); + let context = format!("enforce={enforce} {route}: {case}"); + if enforce { + assert_exact_response( + &response, + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"evidence rejected\n", + &context, + ); + } else { + assert_exact_response( + &response, + StatusCode::UNAUTHORIZED, + "application/json", + None, + LEGACY_AUTH_FAILED, + &context, + ); + } + } + } + } + + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_hash_denials_are_evidence_rejected() { + assert_upload_hash_denials(true); + } + + #[test] + #[ignore = "requires Postgres"] + fn upload_off_hash_denials_keep_legacy_json_401() { + assert_upload_hash_denials(false); + } + + /// DenyProtected answers 503 before any proof check, so even a + /// compliant proof never reaches the Blossom verifier. + #[test] + #[ignore = "requires Postgres"] + fn media_deny_protected_is_503_before_proof_checks() { + let (rt, mut state, host) = media_fixture(media_off_test_state()); + mutate_state(&mut state, |c| c.nip_fi.mode = NipFiMode::DenyProtected); + let keys = Keys::generate(); + let class = buzz_auth::DenialClass::AuthorizationUnavailable; + let upload_proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + let read_proof = blossom_get_auth_value(&keys, &host, &"a".repeat(64)); + for (response, context) in [ + ( + upload_request(&rt, &state, "/upload", &host, &[&upload_proof], None), + "upload", + ), + ( + media_read(&rt, &state, "GET", &host, &read_proof, None), + "read", + ), + ] { + assert_eq!(response.0, StatusCode::SERVICE_UNAVAILABLE, "{context}"); + assert_eq!( + response.2.as_ref(), + class.http_body().as_bytes(), + "{context}" + ); + } + } + + // ── Membership denial shape follows NIP-FI mode ───────────────────── + + #[test] + #[ignore = "requires Postgres"] + fn enforce_membership_denial_is_authorization_denied() { + let (rt, mut state, host) = media_fixture(media_enforce_test_state()); + mutate_state(&mut state, |c| c.require_relay_membership = true); + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let upload_proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + let read_proof = blossom_get_auth_value(&keys, &host, &"a".repeat(64)); + for (response, context) in [ + ( + upload_request( + &rt, + &state, + "/upload", + &host, + &[&upload_proof], + Some(&assertion), + ), + "upload", + ), + ( + media_read(&rt, &state, "GET", &host, &read_proof, Some(&assertion)), + "read", + ), + ] { + assert_exact_response( + &response, + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"authorization denied\n", + &format!("Enforce {context} non-member"), + ); + } + assert_exact_response( + &media_read(&rt, &state, "HEAD", &host, &read_proof, Some(&assertion)), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"", + "Enforce HEAD non-member", + ); + } + + #[test] + #[ignore = "requires Postgres"] + fn off_membership_denial_keeps_legacy_json_403() { + let (rt, mut state, host) = media_fixture(media_off_test_state()); + mutate_state(&mut state, |c| c.require_relay_membership = true); + let keys = Keys::generate(); + let upload_proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + let read_proof = blossom_get_auth_value(&keys, &host, &"a".repeat(64)); + for (response, context) in [ + ( + upload_request(&rt, &state, "/upload", &host, &[&upload_proof], None), + "upload", + ), + ( + media_read(&rt, &state, "GET", &host, &read_proof, None), + "read", + ), + ] { + assert_exact_response( + &response, + StatusCode::FORBIDDEN, + "application/json", + None, + br#"{"error":"relay membership required"}"#, + &format!("Off {context} non-member"), + ); + } + assert_exact_response( + &media_read(&rt, &state, "HEAD", &host, &read_proof, None), + StatusCode::FORBIDDEN, + "application/json", + None, + b"", + "Off HEAD non-member", + ); + } + + /// Positive control: a relay member passes the gate under Enforce and + /// reaches the sidecar check (404 for an unknown blob), so the denials + /// above are membership-specific rather than blanket rejections. + #[test] + #[ignore = "requires Postgres"] + fn enforce_relay_member_read_reaches_sidecar() { + let (rt, mut state, host) = media_fixture(media_enforce_test_state()); + mutate_state(&mut state, |c| c.require_relay_membership = true); + let keys = Keys::generate(); + let community = rt + .block_on(state.db.ensure_configured_community(&host)) + .expect("community"); + rt.block_on(state.db.add_relay_member( + community.id, + &keys.public_key().to_hex(), + "member", + None, + )) + .expect("add relay member"); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let proof = blossom_get_auth_value(&keys, &host, &"a".repeat(64)); + for method in ["GET", "HEAD"] { + let (status, _, body) = + media_read(&rt, &state, method, &host, &proof, Some(&assertion)); + assert_eq!( + status, + StatusCode::NOT_FOUND, + "member {method}; body {body:?}" + ); + } + } } } diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index c84192a6e6..460f7f70b8 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -27,6 +27,24 @@ pub(crate) fn internal_error(msg: &str) -> (StatusCode, Json) api_error(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") } +/// Stable client-visible body for a read cancelled by its server-side +/// statement deadline. Clients match this string to skip retrying: a retry +/// would re-run the same expensive query. +pub(crate) const QUERY_TIMED_OUT: &str = "query timed out"; + +/// Map a DB read failure: a statement-deadline cancel becomes a distinct +/// 503 `query timed out`; anything else stays a generic 500. +pub(crate) fn db_read_error( + context: &str, + e: &buzz_db::DbError, +) -> (StatusCode, Json) { + if e.is_statement_cancelled() { + tracing::warn!("{context}: {e}"); + return api_error(StatusCode::SERVICE_UNAVAILABLE, QUERY_TIMED_OUT); + } + internal_error(&format!("{context}: {e}")) +} + #[allow(dead_code)] pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { api_error(StatusCode::NOT_FOUND, msg) @@ -406,6 +424,46 @@ pub mod relay_members { } } +#[cfg(test)] +mod db_read_error_tests { + use super::*; + + #[test] + fn non_cancel_db_error_stays_generic_500() { + let (status, body) = db_read_error("ctx", &buzz_db::DbError::NotFound("x".into())); + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body.0["error"], "internal server error"); + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn statement_timeout_maps_to_distinct_503() { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB"); + let mut tx = pool.begin().await.expect("begin"); + sqlx::query("SET LOCAL statement_timeout = '10ms'") + .execute(&mut *tx) + .await + .expect("set timeout"); + let err: buzz_db::DbError = sqlx::query("SELECT pg_sleep(1)") + .execute(&mut *tx) + .await + .expect_err("statement must be cancelled") + .into(); + assert!(err.is_statement_cancelled()); + + let (status, body) = db_read_error("ctx", &err); + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body.0["error"], QUERY_TIMED_OUT); + } +} + // ── parse_query_or_400 regression tests ────────────────────────────────────── #[cfg(test)] diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index f238f2037b..64095decc1 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -54,6 +54,44 @@ struct TransferCommunityResponse { const OPERATOR_REPLAY_SCOPE: &str = "operator-management"; +#[derive(Debug, Deserialize)] +struct ListenerPubkeysRequest { + pubkeys: Vec, +} + +fn parse_listener_pubkeys(body: &[u8]) -> Result>, (StatusCode, Json)> { + let request: ListenerPubkeysRequest = serde_json::from_slice(body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid operator-listener pubkeys JSON: {e}"), + ) + })?; + if request.pubkeys.is_empty() || request.pubkeys.len() > 1_000 { + return Err(api_error( + StatusCode::BAD_REQUEST, + "pubkeys must contain between 1 and 1000 entries", + )); + } + request + .pubkeys + .into_iter() + .map(|value| { + let normalized = validate_pubkey_hex(&value).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "pubkeys must contain 64-char hex public keys", + ) + })?; + hex::decode(normalized).map_err(|_| { + api_error( + StatusCode::BAD_REQUEST, + "pubkeys must contain 64-char hex public keys", + ) + }) + }) + .collect() +} + /// Shared deployment-global operator auth prelude. The canonical management /// origin and replay namespace are configuration, never tenant registry state /// or an inbound proxy `Host` header. @@ -98,6 +136,86 @@ async fn authorize_operator_request( Ok(pubkey) } +/// Authenticate a deployment-global operator listener using its configured +/// identity and a NIP-98 request signature. +async fn authorize_operator_listener_request( + state: &Arc, + headers: &HeaderMap, + method: &str, + path: &str, + body: &[u8], +) -> Result)> { + let origin = state + .config + .relay_operator_api_origin + .as_deref() + .ok_or_else(|| internal_error("operator API origin is not configured"))?; + let url = format!("{origin}{path}"); + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = bridge::verify_nip98_exempt_operator(headers, method, &url, Some(body))?; + check_operator_replay(state, event_id_bytes).await?; + if !state + .config + .operator_listener_delivery_urls + .contains_key(&pubkey.to_hex()) + { + return Err(api_error( + StatusCode::FORBIDDEN, + "actor not authorized: not a configured operator listener", + )); + } + Ok(pubkey) +} + +/// Register target pubkeys for the authenticated operator listener. +pub async fn register_listener_pubkeys( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let listener = authorize_operator_listener_request( + &state, + &headers, + "POST", + "/operator/listener/pubkeys", + &body, + ) + .await?; + let target_pubkeys = parse_listener_pubkeys(&body)?; + state + .db + .register_operator_listener_pubkeys(listener.as_bytes(), &target_pubkeys) + .await + .map_err(|e| internal_error(&format!("register operator-listener pubkeys: {e}")))?; + Ok(Json(serde_json::json!({}))) +} + +/// Remove target pubkeys for the authenticated operator listener. +pub async fn remove_listener_pubkeys( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let listener = authorize_operator_listener_request( + &state, + &headers, + "DELETE", + "/operator/listener/pubkeys", + &body, + ) + .await?; + let target_pubkeys = parse_listener_pubkeys(&body)?; + state + .db + .remove_operator_listener_pubkeys(listener.as_bytes(), &target_pubkeys) + .await + .map_err(|e| internal_error(&format!("remove operator-listener pubkeys: {e}")))?; + Ok(Json(serde_json::json!({}))) +} + async fn check_operator_replay( state: &AppState, event_id_bytes: [u8; 32], @@ -175,7 +293,11 @@ pub async fn provision_community( Err(msg) if msg.starts_with("actor not authorized") => { Err(api_error(StatusCode::FORBIDDEN, &msg)) } - Err(msg) if msg == "community already exists" || msg.starts_with("limit_reached:") => { + Err(msg) + if msg == "community already exists" + || msg.starts_with("limit_reached:") + || msg.starts_with("owner_conflict:") => + { Err(api_error(StatusCode::CONFLICT, &msg)) } Err(msg) @@ -196,6 +318,15 @@ pub struct ArchiveCommunityRequest { owner_pubkey: String, } +/// Authenticated owner intent mediated by a trusted deployment operator. +#[derive(Debug, Deserialize)] +pub struct DeleteCommunityRequest { + host: String, + owner_pubkey: String, + request_id: Uuid, + acknowledgement_version: i32, +} + /// Idempotently archive a community owned by the asserted end-user identity. pub async fn archive_community( State(state): State>, @@ -280,12 +411,23 @@ pub async fn unarchive_community( "invalid owner_pubkey: expected 64-char hex pubkey", ) })?; - let record = state + let result = state .db .unarchive_community_owned_by(&normalized_host, &owner) .await - .map_err(|e| internal_error(&format!("unarchive community: {e}")))? - .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "community not found"))?; + .map_err(|e| internal_error(&format!("unarchive community: {e}")))?; + let record = match result { + buzz_db::UnarchiveCommunityResult::Unarchived(record) => record, + buzz_db::UnarchiveCommunityResult::DeletionPending => { + return Err(api_error( + StatusCode::CONFLICT, + "community deletion is pending", + )); + } + buzz_db::UnarchiveCommunityResult::NotFound => { + return Err(api_error(StatusCode::NOT_FOUND, "community not found")); + } + }; tracing::info!(community = %record.id, host = %record.host, "community unarchived"); Ok(Json(serde_json::json!({ "community_id": record.id.to_string(), @@ -295,6 +437,113 @@ pub async fn unarchive_community( }))) } +/// Persist authenticated owner deletion intent without executing deletion work. +/// +/// `POST /operator/communities/delete`, NIP-98 signed by a pubkey in +/// `RELAY_OPERATOR_PUBKEYS`, body: +/// +/// ```json +/// { +/// "host": "archived.communities.example", +/// "owner_pubkey": "<64-char hex>", +/// "request_id": "", +/// "acknowledgement_version": 1 +/// } +/// ``` +/// +/// The request UUID is the correlation/idempotency key. Acceptance is a fast +/// PostgreSQL-only transaction and returns `202`; inventory, approval, +/// quiescing, object-store access, and executor work remain asynchronous. +/// +/// Owner consent is asserted by the operator, not proven to the relay. The +/// operator authenticates the owner and collects the acknowledgement upstream; +/// this request carries only the operator's NIP-98 signature. Authorization is +/// therefore operator authority plus "the asserted pubkey is still the owner". +/// `owner_pubkey` and `acknowledgement_version` are recorded as provenance for +/// that upstream ceremony, not verified as cryptographic owner consent. +pub async fn delete_community( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + const PATH: &str = "/operator/communities/delete"; + let operator = + authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?; + let request: DeleteCommunityRequest = serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid delete-community JSON: {e}"), + ) + })?; + let normalized_host = normalize_candidate_host(&request.host) + .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; + let deployment_host = buzz_core::tenant::relay_url_authority(&state.config.relay_url); + if normalized_host == deployment_host { + return Err(api_error( + StatusCode::CONFLICT, + "the deployment community cannot be deleted", + )); + } + let owner = validate_pubkey_hex(&request.owner_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid owner_pubkey: expected 64-char hex pubkey", + ) + })?; + let operator = operator.to_hex(); + let admission = state + .db + .deletion_store() + .admit_owner_request( + &normalized_host, + &owner, + &operator, + request.acknowledgement_version, + request.request_id, + ) + .await + .map_err(|error| internal_error(&format!("admit owner deletion request: {error}")))?; + let accepted = match admission { + buzz_db::deletion::OwnerDeletionAdmission::Accepted(request) => request, + buzz_db::deletion::OwnerDeletionAdmission::NotFoundOrNotOwner => { + return Err(api_error(StatusCode::NOT_FOUND, "community not found")); + } + buzz_db::deletion::OwnerDeletionAdmission::NotArchived => { + return Err(api_error( + StatusCode::CONFLICT, + "community must be archived before deletion", + )); + } + buzz_db::deletion::OwnerDeletionAdmission::LifecycleConflict => { + return Err(api_error( + StatusCode::CONFLICT, + "community deletion lifecycle is already active", + )); + } + buzz_db::deletion::OwnerDeletionAdmission::RequestConflict => { + return Err(api_error( + StatusCode::CONFLICT, + "deletion request conflicts with existing intent", + )); + } + buzz_db::deletion::OwnerDeletionAdmission::UnsupportedAcknowledgementVersion => { + return Err(api_error( + StatusCode::BAD_REQUEST, + "unsupported acknowledgement_version", + )); + } + }; + Ok(( + StatusCode::ACCEPTED, + Json(serde_json::json!({ + "request_id": accepted.id, + "community_id": accepted.community_id.to_string(), + "host": accepted.community_host, + "status": accepted.stage.to_string(), + })), + )) +} + /// List communities where a pubkey currently holds the `owner` role. pub async fn list_owned_communities( State(state): State>, @@ -417,6 +666,18 @@ pub async fn transfer_community( "owner_conflict: the current owner no longer matches expected_owner_pubkey", )); } + buzz_db::relay_members::TransferResult::LifecycleConflict => { + return Err(api_error( + StatusCode::CONFLICT, + "community must be active to transfer ownership", + )); + } + buzz_db::relay_members::TransferResult::DeletionPending => { + return Err(api_error( + StatusCode::CONFLICT, + "community deletion is pending", + )); + } buzz_db::relay_members::TransferResult::LimitReached => { return Err(api_error( StatusCode::CONFLICT, @@ -496,7 +757,10 @@ pub async fn community_availability( #[cfg(test)] mod postgres_tests { - use std::sync::Arc; + use std::{ + collections::HashSet, + sync::{Arc, Mutex}, + }; use axum::{ body::{to_bytes, Body}, @@ -529,6 +793,26 @@ mod postgres_tests { Box::pin(async { Ok(true) }) } } + + struct SeenOnceReplayGuard(Mutex>); + + impl buzz_auth::Nip98ReplayGuard for SeenOnceReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + let inserted = self + .0 + .lock() + .expect("replay set") + .insert(*event_id.as_bytes()); + Box::pin(async move { Ok(inserted) }) + } + } const INGRESS_HOST: &str = "operator-ingress.example"; fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { @@ -629,6 +913,16 @@ mod postgres_tests { ) -> axum::response::Response { let url = format!("http://{INGRESS_HOST}{path}"); let auth = nip98_auth_header(keys, &url, method, body.as_deref().map(str::as_bytes)); + operator_request_with_auth(state, method, path, body, auth).await + } + + async fn operator_request_with_auth( + state: Arc, + method: &str, + path: &str, + body: Option, + auth: String, + ) -> axum::response::Response { let mut request = Request::builder() .method(method) .uri(path) @@ -647,6 +941,40 @@ mod postgres_tests { .expect("response") } + /// Send a delete-community request with a caller-controlled `Authorization` + /// header so signature-binding failures can be exercised directly. + async fn raw_owner_delete( + state: Arc, + auth: Option, + extra_header: Option<(&str, String)>, + body: String, + ) -> axum::response::Response { + let mut request = Request::builder() + .method("POST") + .uri("/operator/communities/delete") + .header(header::HOST, INGRESS_HOST) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(auth) = auth { + request = request.header(header::AUTHORIZATION, auth); + } + if let Some((name, value)) = extra_header { + request = request.header(name, value); + } + build_router(state) + .oneshot(request.body(Body::from(body)).expect("request")) + .await + .expect("response") + } + + /// The endpoint must not have persisted intent under `request_id`. + async fn assert_no_persisted_request(state: &AppState, request_id: Uuid, case: &str) { + let found = state.db.deletion_store().get(request_id).await; + assert!( + matches!(found, Err(buzz_db::DbError::NotFound(_))), + "{case}: rejected request must not persist deletion intent" + ); + } + async fn provision_community( state: Arc, operator: &Keys, @@ -662,6 +990,29 @@ mod postgres_tests { signed_operator_request(state, operator, "POST", "/operator/communities", Some(body)).await } + async fn archive_for_owner_deletion(state: &AppState, host: &str, owner: &Keys) { + state + .db + .archive_community_owned_by( + host, + &owner.public_key().to_hex(), + &buzz_core::tenant::relay_url_authority(&state.config.relay_url), + ) + .await + .expect("archive community") + .expect("owned community"); + } + + fn owner_delete_body(host: &str, owner: &Keys, request_id: Uuid) -> String { + serde_json::json!({ + "host": host, + "owner_pubkey": owner.public_key().to_hex(), + "request_id": request_id, + "acknowledgement_version": 1, + }) + .to_string() + } + fn is_member_tag(tag: &Tag, pubkey: &str, role: &str) -> bool { let values = tag.as_slice(); values.first().is_some_and(|value| value == "member") @@ -731,6 +1082,500 @@ mod postgres_tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_delete_endpoint_accepts_archived_owner_intent_without_running_inventory() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &host, &owner).await; + let request_id = Uuid::new_v4(); + let response = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&host, &owner, request_id)), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + let json = read_json(response).await; + assert_eq!(json["request_id"], request_id.to_string()); + assert_eq!(json["status"], "submitted"); + let request = state + .db + .deletion_store() + .get(request_id) + .await + .expect("persisted deletion request"); + assert_eq!(request.stage, buzz_db::deletion::DeletionStage::Submitted); + assert!(request.inventory_manifest.is_none()); + assert!(request.inventory_digest.is_none()); + assert_eq!( + request.mediating_operator_pubkey.as_deref(), + Some(operator.public_key().to_hex().as_str()) + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_delete_endpoint_rejects_protected_host_and_malformed_or_mismatched_target() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let protected_host = buzz_core::tenant::relay_url_authority(&state.config.relay_url); + let protected = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&protected_host, &owner, Uuid::new_v4())), + ) + .await; + assert_eq!(protected.status(), StatusCode::CONFLICT); + + // Each malformed case keeps every unrelated field valid so it reaches + // the guard under test instead of tripping an earlier one, and none of + // them may leave durable intent behind. + let valid_owner = owner.public_key().to_hex(); + let reachable_host = format!("community-{}.example", Uuid::new_v4().simple()); + + let bad_host_id = Uuid::new_v4(); + let bad_host = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": "https://not-an-authority.example/path", + "owner_pubkey": valid_owner, + "request_id": bad_host_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ) + .await; + assert_eq!(bad_host.status(), StatusCode::BAD_REQUEST); + assert_no_persisted_request(&state, bad_host_id, "unnormalizable host").await; + + let bad_pubkey_id = Uuid::new_v4(); + let bad_pubkey = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": reachable_host, + "owner_pubkey": "not-a-pubkey", + "request_id": bad_pubkey_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ) + .await; + assert_eq!(bad_pubkey.status(), StatusCode::BAD_REQUEST); + let bad_pubkey_error = read_json(bad_pubkey).await; + assert!( + bad_pubkey_error + .get("error") + .and_then(Value::as_str) + .unwrap_or_default() + .contains("owner_pubkey"), + "invalid owner_pubkey must reach the pubkey guard: {bad_pubkey_error:?}" + ); + assert_no_persisted_request(&state, bad_pubkey_id, "invalid owner_pubkey").await; + + let bad_version_id = Uuid::new_v4(); + let bad_version = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": reachable_host, + "owner_pubkey": valid_owner, + "request_id": bad_version_id, + "acknowledgement_version": 2, + }) + .to_string(), + ), + ) + .await; + assert_eq!(bad_version.status(), StatusCode::BAD_REQUEST); + assert_no_persisted_request(&state, bad_version_id, "unsupported acknowledgement").await; + + let unknown_host_id = Uuid::new_v4(); + let unknown_host = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": reachable_host, + "owner_pubkey": valid_owner, + "request_id": unknown_host_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ) + .await; + assert_eq!(unknown_host.status(), StatusCode::NOT_FOUND); + assert_no_persisted_request(&state, unknown_host_id, "unprovisioned host").await; + + let bad_uuid = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": reachable_host, + "owner_pubkey": valid_owner, + "request_id": "not-a-uuid", + "acknowledgement_version": 1, + }) + .to_string(), + ), + ) + .await; + assert_eq!(bad_uuid.status(), StatusCode::BAD_REQUEST); + + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &host, &owner).await; + let request_id = Uuid::new_v4(); + let first = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&host, &owner, request_id)), + ) + .await; + assert_eq!(first.status(), StatusCode::ACCEPTED); + let other_host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &other_host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &other_host, &owner).await; + let mismatched = signed_operator_request( + state, + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&other_host, &owner, request_id)), + ) + .await; + assert_eq!(mismatched.status(), StatusCode::CONFLICT); + } + + /// Every signature-binding failure mode on the owner-delete endpoint. + /// + /// The endpoint mediates an irreversible request, so a caller that cannot + /// prove operator authority over this exact method, URL, and body must be + /// refused without leaving durable intent behind. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_delete_endpoint_requires_operator_bound_nip98_signature() { + let operator = Keys::generate(); + let outsider = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &host, &owner).await; + + let delete_url = format!("http://{INGRESS_HOST}/operator/communities/delete"); + + // 1. No Authorization header at all. + let unsigned_id = Uuid::new_v4(); + let unsigned = raw_owner_delete( + Arc::clone(&state), + None, + None, + owner_delete_body(&host, &owner, unsigned_id), + ) + .await; + assert_eq!(unsigned.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, unsigned_id, "unsigned").await; + + // 2. X-Pubkey only: the dev fallback is disabled for operator endpoints. + let x_pubkey_id = Uuid::new_v4(); + let x_pubkey_only = raw_owner_delete( + Arc::clone(&state), + None, + Some(("x-pubkey", operator.public_key().to_hex())), + owner_delete_body(&host, &owner, x_pubkey_id), + ) + .await; + assert_eq!(x_pubkey_only.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, x_pubkey_id, "X-Pubkey only").await; + + // 3. Valid NIP-98 from a key that is not an allowlisted operator. + let outsider_id = Uuid::new_v4(); + let outsider_body = owner_delete_body(&host, &owner, outsider_id); + let outsider_response = raw_owner_delete( + Arc::clone(&state), + Some(nip98_auth_header( + &outsider, + &delete_url, + "POST", + Some(outsider_body.as_bytes()), + )), + None, + outsider_body, + ) + .await; + assert_eq!(outsider_response.status(), StatusCode::FORBIDDEN); + assert_no_persisted_request(&state, outsider_id, "non-operator signer").await; + + // 4. Operator signature that omits the payload tag. + let no_payload_id = Uuid::new_v4(); + let no_payload = raw_owner_delete( + Arc::clone(&state), + Some(nip98_auth_header_without_payload( + &operator, + &delete_url, + "POST", + )), + None, + owner_delete_body(&host, &owner, no_payload_id), + ) + .await; + assert_eq!(no_payload.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, no_payload_id, "missing payload tag").await; + + // 5. Payload tag bound to a different body than the one sent. + let signed_id = Uuid::new_v4(); + let tampered_id = Uuid::new_v4(); + let signed_body = owner_delete_body(&host, &owner, signed_id); + let tampered = raw_owner_delete( + Arc::clone(&state), + Some(nip98_auth_header( + &operator, + &delete_url, + "POST", + Some(signed_body.as_bytes()), + )), + None, + owner_delete_body(&host, &owner, tampered_id), + ) + .await; + assert_eq!(tampered.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, tampered_id, "tampered payload").await; + assert_no_persisted_request(&state, signed_id, "tampered payload (signed id)").await; + + // 6. Signature bound to a different operator URL. + let wrong_url_id = Uuid::new_v4(); + let wrong_url_body = owner_delete_body(&host, &owner, wrong_url_id); + let wrong_url = raw_owner_delete( + Arc::clone(&state), + Some(nip98_auth_header( + &operator, + &format!("http://{INGRESS_HOST}/operator/communities"), + "POST", + Some(wrong_url_body.as_bytes()), + )), + None, + wrong_url_body, + ) + .await; + assert_eq!(wrong_url.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, wrong_url_id, "wrong signed URL").await; + + // 7. Signature bound to a different method. + let wrong_method_id = Uuid::new_v4(); + let wrong_method_body = owner_delete_body(&host, &owner, wrong_method_id); + let wrong_method = raw_owner_delete( + Arc::clone(&state), + Some(nip98_auth_header( + &operator, + &delete_url, + "GET", + Some(wrong_method_body.as_bytes()), + )), + None, + wrong_method_body, + ) + .await; + assert_eq!(wrong_method.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, wrong_method_id, "wrong signed method").await; + + // Falsifiability: the same shape, correctly bound, is accepted. + let accepted_id = Uuid::new_v4(); + let accepted = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&host, &owner, accepted_id)), + ) + .await; + assert_eq!(accepted.status(), StatusCode::ACCEPTED); + } + + /// The request UUID is the correlation identity, so a replay that changes + /// any bound field is a conflict rather than a second interpretation. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_delete_replay_rejects_any_changed_field_without_mutating_intent() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let other_owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &host, &owner).await; + + let other_host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &other_host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &other_host, &owner).await; + + let request_id = Uuid::new_v4(); + assert_eq!( + signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&host, &owner, request_id)), + ) + .await + .status(), + StatusCode::ACCEPTED + ); + let admitted = state + .db + .deletion_store() + .get(request_id) + .await + .expect("admitted request"); + + // Exactly one field differs per case; the rest replay verbatim. + let owner_hex = owner.public_key().to_hex(); + let cases: Vec<(&str, StatusCode, String)> = vec![ + ( + "changed owner_pubkey", + StatusCode::CONFLICT, + serde_json::json!({ + "host": host, + "owner_pubkey": other_owner.public_key().to_hex(), + "request_id": request_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ( + "changed host", + StatusCode::CONFLICT, + serde_json::json!({ + "host": other_host, + "owner_pubkey": owner_hex, + "request_id": request_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ( + "changed acknowledgement_version", + StatusCode::BAD_REQUEST, + serde_json::json!({ + "host": host, + "owner_pubkey": owner_hex, + "request_id": request_id, + "acknowledgement_version": 2, + }) + .to_string(), + ), + ]; + for (case, expected, body) in cases { + let response = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(body), + ) + .await; + assert_eq!(response.status(), expected, "{case}"); + let current = state + .db + .deletion_store() + .get(request_id) + .await + .expect("request still readable"); + assert_eq!(current.community_id, admitted.community_id, "{case}"); + assert_eq!(current.community_host, admitted.community_host, "{case}"); + assert_eq!(current.owner_pubkey, admitted.owner_pubkey, "{case}"); + assert_eq!( + current.acknowledgement_version, admitted.acknowledgement_version, + "{case}" + ); + assert_eq!(current.stage, admitted.stage, "{case}"); + } + + // An identical replay still converges on the same request. + let converged = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&host, &owner, request_id)), + ) + .await; + assert_eq!(converged.status(), StatusCode::ACCEPTED); + assert_eq!( + read_json(converged).await["request_id"], + request_id.to_string() + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn post_operator_body_requires_payload_tag() { @@ -770,6 +1615,162 @@ mod postgres_tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn listener_pubkey_routes_register_and_remove_only_for_authenticated_listener() { + let listener = Keys::generate(); + let other_listener = Keys::generate(); + let outsider = Keys::generate(); + let Some(mut state) = operator_test_state(&[]).await else { + return; + }; + Arc::get_mut(&mut state) + .expect("test state has a single owner") + .nip98_replay = Arc::new(SeenOnceReplayGuard(Mutex::new(HashSet::new()))); + let config = Arc::make_mut( + &mut Arc::get_mut(&mut state) + .expect("test state has a single owner") + .config, + ); + config.operator_listener_delivery_urls.insert( + listener.public_key().to_hex(), + url::Url::parse("http://listener.example/deliver").expect("listener URL"), + ); + config.operator_listener_delivery_urls.insert( + other_listener.public_key().to_hex(), + url::Url::parse("http://other-listener.example/deliver").expect("listener URL"), + ); + + let targets = [Keys::generate(), Keys::generate()]; + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB"); + let target_hex = targets + .iter() + .map(|target| target.public_key().to_hex()) + .collect::>(); + let register_body = serde_json::json!({"pubkeys": target_hex}).to_string(); + let response = signed_operator_request( + Arc::clone(&state), + &listener, + "POST", + "/operator/listener/pubkeys", + Some(register_body), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let listener_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_pubkeys WHERE listener_pubkey = $1", + ) + .bind(listener.public_key().as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count registered listener targets"); + let other_listener_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_pubkeys WHERE listener_pubkey = $1", + ) + .bind(other_listener.public_key().as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count other listener targets"); + assert_eq!(listener_count, 2); + assert_eq!(other_listener_count, 0); + + let body = serde_json::json!({"pubkeys": [target_hex[0]]}).to_string(); + let missing_payload_auth = nip98_auth_header_without_payload( + &listener, + &format!("http://{INGRESS_HOST}/operator/listener/pubkeys"), + "DELETE", + ); + let missing_payload = operator_request_with_auth( + Arc::clone(&state), + "DELETE", + "/operator/listener/pubkeys", + Some(body), + missing_payload_auth, + ) + .await; + assert_eq!(missing_payload.status(), StatusCode::UNAUTHORIZED); + + let unauthorized = signed_operator_request( + Arc::clone(&state), + &outsider, + "POST", + "/operator/listener/pubkeys", + Some(serde_json::json!({"pubkeys": [target_hex[0]]}).to_string()), + ) + .await; + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + + let malformed = signed_operator_request( + Arc::clone(&state), + &listener, + "POST", + "/operator/listener/pubkeys", + Some(r#"{"pubkeys":["not-a-pubkey"]}"#.to_string()), + ) + .await; + assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); + + let replay_body = serde_json::json!({"pubkeys": [target_hex[1]]}).to_string(); + let replay_auth = nip98_auth_header( + &listener, + &format!("http://{INGRESS_HOST}/operator/listener/pubkeys"), + "DELETE", + Some(replay_body.as_bytes()), + ); + let first_use = operator_request_with_auth( + Arc::clone(&state), + "DELETE", + "/operator/listener/pubkeys", + Some(replay_body.clone()), + replay_auth.clone(), + ) + .await; + assert_eq!(first_use.status(), StatusCode::OK); + let replay = operator_request_with_auth( + Arc::clone(&state), + "DELETE", + "/operator/listener/pubkeys", + Some(replay_body), + replay_auth, + ) + .await; + assert_eq!(replay.status(), StatusCode::UNAUTHORIZED); + let replay_error = read_json(replay).await; + assert!(replay_error["error"] + .as_str() + .unwrap_or_default() + .contains("replay")); + + let remove_body = serde_json::json!({"pubkeys": [target_hex[0]]}).to_string(); + let removed = signed_operator_request( + Arc::clone(&state), + &listener, + "DELETE", + "/operator/listener/pubkeys", + Some(remove_body), + ) + .await; + assert_eq!(removed.status(), StatusCode::OK); + let remaining: Vec> = sqlx::query_scalar( + "SELECT target_pubkey FROM operator_listener_pubkeys WHERE listener_pubkey = $1", + ) + .bind(listener.public_key().as_bytes().as_slice()) + .fetch_all(&pool) + .await + .expect("read remaining listener targets"); + assert!(remaining.is_empty()); + + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1 OR listener_pubkey = $2") + .bind(listener.public_key().as_bytes().as_slice()) + .bind(other_listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("clean up listener registrations"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn unmapped_management_host_can_check_availability() { @@ -1071,6 +2072,68 @@ mod postgres_tests { assert_snapshot_roles(&state, community.id, &[(&owner_hex, "owner")]).await; } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn archived_legacy_owner_convergence_returns_conflict_without_membership_change() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let replacement = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(state.clone(), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup community") + .expect("community exists"); + archive_for_owner_deletion(&state, &host, &owner).await; + + let body = serde_json::json!({ + "host": host, + "initial_owner_pubkey": replacement.public_key().to_hex(), + "create_only": false, + }) + .to_string(); + let response = signed_operator_request( + state.clone(), + &operator, + "POST", + "/operator/communities", + Some(body), + ) + .await; + + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!( + read_json(response).await["error"], + "owner_conflict: community must be active to rotate ownership" + ); + assert_eq!( + state + .db + .get_relay_member(community.id, &owner.public_key().to_hex()) + .await + .expect("get owner") + .expect("owner exists") + .role, + "owner" + ); + assert!(state + .db + .get_relay_member(community.id, &replacement.public_key().to_hex()) + .await + .expect("get replacement") + .is_none()); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn fresh_host_at_owner_limit_returns_limit_reached_conflict() { diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 20f6c68989..da69b7cbc2 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -1,11 +1,11 @@ //! Relay configuration from environment variables. -use std::net::SocketAddr; use std::time::Duration; +use std::{collections::HashMap, net::SocketAddr}; use sha2::{Digest, Sha256}; use thiserror::Error; -use tracing::warn; +use tracing::{error, warn}; /// Default maximum inbound WebSocket frame size in bytes. /// @@ -272,6 +272,9 @@ pub struct Config { /// skipped — a typo must not silently disable an operator. pub relay_operator_pubkeys: Vec, + /// Configured operator-listener identities and their HTTPS delivery URLs. + pub operator_listener_delivery_urls: HashMap, + /// Allow NIP-OA owner attestation for relay membership. /// /// When `true` and `require_relay_membership` is also `true`, agents @@ -350,8 +353,10 @@ pub struct Config { /// 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. + /// Hard timeout for one push gateway delivery request. pub push_gateway_timeout: Duration, + /// Hard timeout for one operator-listener delivery request. + pub operator_listener_timeout: Duration, /// Optional relay-hosted policy shown on join surfaces. Disabled when no /// documents or age attestation are configured. @@ -478,6 +483,58 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result { Ok(url) } +fn parse_operator_listener_delivery_urls( + raw: &str, +) -> Result, ConfigError> { + let mut endpoints = HashMap::new(); + let entries: Vec<&str> = raw + .split(';') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .collect(); + if !raw.trim().is_empty() && entries.is_empty() { + return Err(ConfigError::InvalidValue( + "BUZZ_OPERATOR_LISTENERS must contain pubkey:delivery_url pairs".to_string(), + )); + } + for entry in entries { + let (pubkey, delivery_url) = entry.split_once(':').ok_or_else(|| { + ConfigError::InvalidValue( + "BUZZ_OPERATOR_LISTENERS entries must be pubkey:delivery_url pairs".to_string(), + ) + })?; + let pubkey = pubkey.trim().to_ascii_lowercase(); + if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS entry has an invalid pubkey: {pubkey:?}" + ))); + } + let url = url::Url::parse(delivery_url.trim()).map_err(|e| { + ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS endpoint is not a valid URL: {e}" + )) + })?; + if url.scheme() != "https" + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(ConfigError::InvalidValue( + "BUZZ_OPERATOR_LISTENERS endpoints must be HTTPS URLs without credentials, query, or fragment" + .to_string(), + )); + } + if endpoints.insert(pubkey.clone(), url).is_some() { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS contains duplicate pubkey: {pubkey}" + ))); + } + } + Ok(endpoints) +} + fn parse_bool(name: &str, default: bool) -> Result { match std::env::var(name) { Err(std::env::VarError::NotPresent) => Ok(default), @@ -797,6 +854,21 @@ impl Config { ); } + let operator_listener_delivery_urls = match std::env::var("BUZZ_OPERATOR_LISTENERS") { + Ok(raw) => parse_operator_listener_delivery_urls(&raw)?, + Err(std::env::VarError::NotPresent) => HashMap::new(), + Err(error) => { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS must be valid UTF-8: {error}" + ))); + } + }; + if !operator_listener_delivery_urls.is_empty() && relay_operator_api_origin.is_none() { + error!( + "BUZZ_OPERATOR_LISTENERS is set but RELAY_OPERATOR_API_ORIGIN is not — operator-listener registration requests will reject every request until RELAY_OPERATOR_API_ORIGIN is set" + ); + } + let auth = buzz_auth::AuthConfig { rate_limits: rate_limit_config_from_env()?, }; @@ -1012,6 +1084,33 @@ impl Config { Err(_) => 2_000, }; let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis); + let operator_listener_timeout_millis = match std::env::var( + "BUZZ_OPERATOR_LISTENER_TIMEOUT_MS", + ) { + Ok(raw) => match raw + .parse::() + .ok() + .filter(|millis| (100..=10_000).contains(millis)) + { + Some(millis) => millis, + None => { + error!( + value = %raw, + "invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms (expected integer in 100..=10000)" + ); + 5_000 + } + }, + Err(std::env::VarError::NotPresent) => 5_000, + Err(err) => { + error!( + error = %err, + "invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms" + ); + 5_000 + } + }; + let operator_listener_timeout = Duration::from_millis(operator_listener_timeout_millis); const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024; let read_policy_markdown = |name: &str| -> Result, ConfigError> { @@ -1248,6 +1347,7 @@ impl Config { relay_owner_pubkey, relay_operator_api_origin, relay_operator_pubkeys, + operator_listener_delivery_urls, allow_nip_oa_auth, klipy, media, @@ -1269,6 +1369,7 @@ impl Config { push_executor_key_id, push_gateway_delivery_url, push_gateway_timeout, + operator_listener_timeout, join_policy, admin, web_dir, @@ -1478,6 +1579,20 @@ mod tests { (config, captured) } + fn config_with_operator_listeners(raw: Option<&str>) -> Result { + let previous = std::env::var_os("BUZZ_OPERATOR_LISTENERS"); + match raw { + Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value), + None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"), + } + let result = Config::from_env(); + match previous { + Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value), + None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"), + } + result + } + /// Assert `captured` contains a WARN naming the removal of `BUZZ_ADMIN_TOKEN` /// so the migration breadcrumb Will's ruling preserved cannot silently regress. fn assert_admin_token_removal_warning(captured: &str) { @@ -2293,6 +2408,93 @@ mod tests { } } + #[test] + fn operator_listener_routes_parse_and_normalize() { + let routes = parse_operator_listener_delivery_urls( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:https://one.example/mentions;bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:https://two.example/mentions", + ) + .expect("valid operator-listener routes"); + assert_eq!(routes.len(), 2); + assert!( + routes.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + assert!( + routes.contains_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + ); + } + + #[test] + fn operator_listener_routes_reject_unsafe_or_duplicate_entries() { + let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + for raw in [ + format!("{key}:http://listener.example/mentions"), + format!("{key}:https://user:pass@listener.example/mentions"), + format!("{key}:https://listener.example/mentions?token=secret"), + format!("{key}:https://listener.example/mentions;{key}:https://other.example"), + ] { + assert!( + parse_operator_listener_delivery_urls(&raw).is_err(), + "unsafe or duplicate route accepted: {raw}" + ); + } + } + + #[test] + fn malformed_operator_listener_config_prevents_startup() { + let _guard = ENV_MUTEX.lock().unwrap(); + let result = config_with_operator_listeners(Some(";")); + + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_OPERATOR_LISTENERS") + )); + } + + #[cfg(unix)] + #[test] + fn non_utf8_operator_listener_config_prevents_startup() { + use std::os::unix::ffi::OsStringExt; + + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_OPERATOR_LISTENERS"); + std::env::set_var( + "BUZZ_OPERATOR_LISTENERS", + std::ffi::OsString::from_vec(vec![0xff]), + ); + + let result = Config::from_env(); + + match previous { + Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value), + None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"), + } + + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_OPERATOR_LISTENERS must be valid UTF-8") + )); + } + + #[test] + fn valid_and_absent_operator_listener_config_preserve_routes() { + let _guard = ENV_MUTEX.lock().unwrap(); + let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let raw = format!("{key}:https://listener.example/mentions"); + let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config"); + assert_eq!( + config + .operator_listener_delivery_urls + .get(key) + .map(url::Url::as_str), + Some("https://listener.example/mentions") + ); + + let config = config_with_operator_listeners(None).expect("absent listener config"); + assert!(config.operator_listener_delivery_urls.is_empty()); + } + #[test] fn invalid_push_gateway_timeout_is_not_silently_defaulted() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 12a450861e..63060fe0be 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -16,7 +16,6 @@ use uuid::Uuid; use buzz_auth::{generate_challenge, AuthContext}; use buzz_core::tenant::TenantContext; -use nostr::Filter; use crate::handlers; use crate::metrics::AuthOutcome; @@ -34,8 +33,9 @@ const AUTH_TIMEOUT: Duration = Duration::from_secs(5); /// 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>>>; +/// Shared mutable subscription map for a single WebSocket connection: sub ID → +/// owner token of the request that currently holds it (see `close::next_owner`). +pub(crate) type ConnectionSubscriptions = Arc>>; /// Request for the writer to flush a restart close and report the result. pub(crate) struct RestartClose { @@ -447,20 +447,7 @@ async fn handle_active_connection( 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() { - state - .pubsub - .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Global) - .await; - } - for &channel_id in removed.scope.channel_ids() { - state - .pubsub - .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Channel(channel_id)) - .await; - } - } + crate::handlers::close::release_connection_subscriptions(&conn, &state).await; state.conn_manager.deregister(conn.conn_id); if let Some(auth_ctx) = authenticated { let remaining = state.conn_manager.connection_ids_for_pubkey_in_community( diff --git a/crates/buzz-relay/src/handlers/close.rs b/crates/buzz-relay/src/handlers/close.rs index 86f3d0da79..0fdb4dd167 100644 --- a/crates/buzz-relay/src/handlers/close.rs +++ b/crates/buzz-relay/src/handlers/close.rs @@ -1,35 +1,137 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use buzz_core::tenant::TenantContext; +use buzz_pubsub::EventTopic; + use tracing::debug; use crate::connection::ConnectionState; use crate::protocol::RelayMessage; use crate::state::AppState; +use crate::subscription::SubscriptionScope; /// Handle a CLOSE command — remove the subscription and send CLOSED acknowledgement. pub async fn handle_close(sub_id: String, conn: Arc, state: Arc) { - let conn_id = conn.conn_id; + // Client CLOSE targets whatever currently holds the ID, so it is + // unconditional. Retiring before CLOSED means nothing is routed to this + // sub after the acknowledgement. + let mut subs = conn.subscriptions.lock().await; + retire_locked(&mut subs, &sub_id, &conn, &state).await; + conn.send(RelayMessage::closed(&sub_id, "")); + drop(subs); - conn.subscriptions.lock().await.remove(&sub_id); + debug!(conn_id = %conn.conn_id, sub_id = %sub_id, "Subscription closed"); +} - // Deregister from the fan-out index before sending CLOSED so no new - // messages are routed to this sub after the client's CLOSE is acknowledged. - if let Some(removed) = state.sub_registry.remove_subscription(conn_id, &sub_id) { - if removed.scope.is_global() { - state - .pubsub - .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Global) - .await; - } - for &channel_id in removed.scope.channel_ids() { - state - .pubsub - .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Channel(channel_id)) - .await; +/// Monotonic source of subscription owner tokens. Each REQ claims its sub ID +/// with a fresh token, so a superseded request can tell it no longer owns it. +static NEXT_OWNER: AtomicU64 = AtomicU64::new(1); + +pub(crate) fn next_owner() -> u64 { + NEXT_OWNER.fetch_add(1, Ordering::Relaxed) +} + +// Lifecycle lock: `conn.subscriptions` serializes every change to a +// connection's subscription state — map token, registry entry, topic +// retention, and the terminal CLOSED frame. Neither the registry nor pubsub +// takes it, so holding it across their awaits cannot invert lock order. + +/// Drop `sub_id` from the map, the fan-out index, and its topic retention. +/// The caller holds the lifecycle lock (`subs`). +pub(crate) async fn retire_locked( + subs: &mut HashMap, + sub_id: &str, + conn: &ConnectionState, + state: &AppState, +) { + subs.remove(sub_id); + if let Some(removed) = state.sub_registry.remove_subscription(conn.conn_id, sub_id) { + release_scope_topics(state, &conn.tenant, &removed.scope).await; + } +} + +/// Retire `sub_id` only if the request holding `owner` still owns it, then +/// send `closed` (if any) before releasing the lifecycle lock, so a replacing +/// REQ can never claim the ID between the teardown and its terminal frame. +/// Returns whether this request still owned the ID. A superseded request +/// touches nothing and says nothing. +pub(crate) async fn close_if_owner( + sub_id: &str, + owner: u64, + closed: Option<&str>, + conn: &ConnectionState, + state: &AppState, +) -> bool { + let mut subs = conn.subscriptions.lock().await; + if subs.get(sub_id) != Some(&owner) { + return false; + } + retire_locked(&mut subs, sub_id, conn, state).await; + #[cfg(test)] + test_seam::pause().await; + if let Some(reason) = closed { + if !conn.send(RelayMessage::closed(sub_id, reason)) { + // The outbound channel is full or closed: the terminal frame is + // lost. Cancel the connection so the subscription is not silently + // orphaned — the client will reconnect and resubscribe. + conn.cancel.cancel(); + } + } + true +} + +/// Final subscription cleanup for a closed connection. Its cancellation token +/// must already be cancelled: claims check it under the lifecycle lock, so no +/// detached REQ task can register after this runs. +pub(crate) async fn release_connection_subscriptions(conn: &ConnectionState, state: &AppState) { + debug_assert!(conn.cancel.is_cancelled()); + let mut subs = conn.subscriptions.lock().await; + subs.clear(); + for removed in state.sub_registry.remove_connection(conn.conn_id) { + release_scope_topics(state, &conn.tenant, &removed.scope).await; + } +} + +/// Release the pubsub topics a subscription scope retained. +pub(crate) async fn release_scope_topics( + state: &AppState, + tenant: &TenantContext, + scope: &SubscriptionScope, +) { + if scope.is_global() { + state.pubsub.release_topic(tenant, EventTopic::Global).await; + } + for &channel_id in scope.channel_ids() { + state + .pubsub + .release_topic(tenant, EventTopic::Channel(channel_id)) + .await; + } +} + +/// Test-only pause point inside `close_if_owner`, between teardown and the +/// terminal frame, scoped to one task so parallel tests don't interfere. +#[cfg(test)] +pub(crate) mod test_seam { + use std::sync::Arc; + use tokio::sync::Notify; + + #[derive(Default)] + pub(crate) struct Pause { + pub(crate) reached: Notify, + pub(crate) resume: Notify, + } + + tokio::task_local! { + pub(crate) static PAUSE: Arc; + } + + pub(super) async fn pause() { + if let Ok(pause) = PAUSE.try_with(Arc::clone) { + pause.reached.notify_one(); + pause.resume.notified().await; } } - - conn.send(RelayMessage::closed(&sub_id, "")); - - debug!(conn_id = %conn_id, sub_id = %sub_id, "Subscription closed"); } diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 229b5f3716..1b13e6d921 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -21,9 +21,9 @@ //! { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } //! ``` //! -//! `initial_owner_pubkey` is optional. When present for an existing community, -//! it rotates that community owner through the same bootstrap path used by -//! `RELAY_OWNER_PUBKEY`; relay operators are deployment-root authorities. +//! `initial_owner_pubkey` is optional. Existing-community convergence may +//! rotate ownership only while the community is active and has no durable +//! deletion intent. Archive freezes ownership. use std::sync::Arc; @@ -238,14 +238,9 @@ async fn publish_membership_snapshot_if_required( /// signer here for the deployment-level `RELAY_OPERATOR_PUBKEYS` allowlist. /// /// Idempotency and owner semantics: the request is idempotent on the host row -/// (re-sending it never duplicates a community). When `initial_owner_pubkey` is -/// present, the owner is (re)bootstrapped via [`buzz_db::Db::bootstrap_owner`] -/// even if the community already existed — any previous owner is demoted to -/// admin, exactly like rotating `RELAY_OWNER_PUBKEY` for the deployment -/// community. This makes a retry after a partial failure (row created, owner -/// bootstrap crashed) converge, at the cost that an operator-signed request can -/// rotate an existing community's owner. The operator allowlist is therefore -/// documented as deployment-root authority, not create-only authority. +/// (re-sending it never duplicates a community). Initial owner bootstrap still +/// converges after a partial create, but rotating an existing owner requires an +/// active, non-archived community with no non-aborted deletion request. pub async fn provision_community( state: &Arc, operator_pubkey: &nostr::PublicKey, @@ -325,11 +320,22 @@ pub async fn provision_community( .map_err(|e| format!("failed to create community: {e}"))?; if let Some(owner_hex) = &initial_owner { - state + match state .db .provision_owner(record.id, owner_hex) .await - .map_err(|e| format!("community provisioned but owner bootstrap failed: {e}"))?; + .map_err(|e| format!("community provisioned but owner bootstrap failed: {e}"))? + { + buzz_db::relay_members::ProvisionOwnerResult::Applied => {} + buzz_db::relay_members::ProvisionOwnerResult::LifecycleConflict => { + return Err( + "owner_conflict: community must be active to rotate ownership".to_string(), + ); + } + buzz_db::relay_members::ProvisionOwnerResult::DeletionPending => { + return Err("owner_conflict: community deletion is pending".to_string()); + } + } publish_membership_snapshot_if_required(state, record.id, &record.host).await; } diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 5ea30b238b..50a77e42ef 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -198,7 +198,10 @@ pub async fn handle_count( match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send(RelayMessage::closed( + &sub_id, + &super::req::db_read_closed_reason(&e), + )); return; } } @@ -232,7 +235,10 @@ pub async fn handle_count( } } Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send(RelayMessage::closed( + &sub_id, + &super::req::db_read_closed_reason(&e), + )); return; } } @@ -272,7 +278,10 @@ pub async fn handle_count( match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send(RelayMessage::closed( + &sub_id, + &super::req::db_read_closed_reason(&e), + )); return; } } @@ -305,7 +314,10 @@ pub async fn handle_count( } } Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send(RelayMessage::closed( + &sub_id, + &super::req::db_read_closed_reason(&e), + )); return; } } diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 97d252cb7e..a428f1e821 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -267,8 +267,12 @@ pub async fn handle_req( )); return; } + let Some(owner) = claim_search_subscription(&sub_id, &conn, &state).await else { + return; + }; handle_search_req( &sub_id, + owner, &filters, &accessible_channels, token_channel_ids.is_none(), @@ -279,47 +283,22 @@ pub async fn handle_req( trace_state.as_ref(), ) .await; + // One-shot: drop the claim unless a newer REQ already took the ID. + super::close::close_if_owner(&sub_id, owner, None, &conn, &state).await; return; } - { - let mut subs = conn.subscriptions.lock().await; - subs.insert(sub_id.clone(), filters.clone()); - } - - let replaced = if let Some(channel_ids) = authorized_requested_channels.as_ref() { - state.sub_registry.register_channels_scoped( - conn.tenant.community(), - conn_id, - sub_id.clone(), - filters.clone(), - channel_ids.clone(), - ) - } else { - state.sub_registry.register_scoped( - conn.tenant.community(), - conn_id, - sub_id.clone(), - filters.clone(), - None, - ) + let Some(owner) = claim_live_subscription( + &sub_id, + &filters, + authorized_requested_channels.as_deref(), + &conn, + &state, + ) + .await + else { + return; }; - if let Some(replaced) = replaced { - release_subscription_topics(&state, &conn.tenant, &replaced.scope).await; - } - if let Some(channel_ids) = authorized_requested_channels.as_ref() { - for &channel_id in channel_ids { - state - .pubsub - .retain_topic(&conn.tenant, EventTopic::Channel(channel_id)) - .await; - } - } else { - state - .pubsub - .retain_topic(&conn.tenant, EventTopic::Global) - .await; - } debug!(conn_id = %conn_id, sub_id = %sub_id, "Subscription registered"); @@ -396,7 +375,11 @@ pub async fn handle_req( Ok(evs) => evs, Err(e) => { warn!(conn_id = %conn_id, sub_id = %sub_id, "Historical query failed: {e}"); - conn.send(RelayMessage::eose(&sub_id)); + if e.is_statement_cancelled() { + close_timed_out_subscription(&sub_id, owner, &conn, &state).await; + } else { + conn.send(RelayMessage::eose(&sub_id)); + } return; } }; @@ -597,6 +580,7 @@ pub(crate) fn build_search_channel_scope_filter( #[allow(clippy::too_many_arguments)] async fn handle_search_req( sub_id: &str, + owner: u64, filters: &[Filter], accessible_channels: &[uuid::Uuid], include_global: bool, @@ -724,6 +708,12 @@ async fn handle_search_req( .await { Ok(evs) => evs, + Err(e) if e.is_statement_cancelled() => { + // CLOSED (not EOSE) keeps partial results from reading as complete. + warn!(sub_id = %sub_id, "NIP-50 batch fetch timed out: {e}"); + close_timed_out_subscription(sub_id, owner, conn, state).await; + return; + } Err(e) => { warn!(sub_id = %sub_id, "NIP-50 batch fetch failed: {e}"); break; @@ -1269,21 +1259,107 @@ async fn handle_huddle_liveness_req( conn.send(RelayMessage::eose(sub_id)); } -async fn release_subscription_topics( +/// Claim `sub_id` for a live REQ under the lifecycle lock: record a fresh +/// owner token, register for fan-out (replacing any same-ID entry), retain its +/// topics, and release the replaced scope's. Returns the owner token, or +/// `None` once the connection is closing — the caller then exits silently. +async fn claim_live_subscription( + sub_id: &str, + filters: &[Filter], + channel_ids: Option<&[uuid::Uuid]>, + conn: &ConnectionState, state: &AppState, - tenant: &TenantContext, - scope: &crate::subscription::SubscriptionScope, -) { - if scope.is_global() { - state.pubsub.release_topic(tenant, EventTopic::Global).await; - } else { - for &channel_id in scope.channel_ids() { +) -> Option { + let mut subs = conn.subscriptions.lock().await; + if conn.cancel.is_cancelled() { + return None; + } + let owner = super::close::next_owner(); + subs.insert(sub_id.to_string(), owner); + let community = conn.tenant.community(); + let replaced = match channel_ids { + Some(ids) => state.sub_registry.register_channels_scoped( + community, + conn.conn_id, + sub_id.to_string(), + filters.to_vec(), + ids.to_vec(), + ), + None => state.sub_registry.register_scoped( + community, + conn.conn_id, + sub_id.to_string(), + filters.to_vec(), + None, + ), + }; + match channel_ids { + Some(ids) => { + for &channel_id in ids { + state + .pubsub + .retain_topic(&conn.tenant, EventTopic::Channel(channel_id)) + .await; + } + } + None => { state .pubsub - .release_topic(tenant, EventTopic::Channel(channel_id)) + .retain_topic(&conn.tenant, EventTopic::Global) .await; } } + if let Some(replaced) = replaced { + super::close::release_scope_topics(state, &conn.tenant, &replaced.scope).await; + } + Some(owner) +} + +/// Claim `sub_id` for a one-shot search REQ under the lifecycle lock. +/// Accepting it retires whatever holds the ID (NIP-01 replacement), even +/// though search never registers for fan-out. `None` once the connection is +/// closing. +async fn claim_search_subscription( + sub_id: &str, + conn: &ConnectionState, + state: &AppState, +) -> Option { + let mut subs = conn.subscriptions.lock().await; + if conn.cancel.is_cancelled() { + return None; + } + super::close::retire_locked(&mut subs, sub_id, conn, state).await; + let owner = super::close::next_owner(); + subs.insert(sub_id.to_string(), owner); + Some(owner) +} + +/// A read hit the server statement deadline. Sends `CLOSED` rather than `EOSE` +/// so clients don't treat the empty result as complete. CLOSED means the relay +/// dropped the sub (NIP-01) and clients won't `CLOSE` it, so it is retired in +/// the same lifecycle-lock critical section. A request a newer same-ID REQ +/// superseded owns nothing and says nothing. +async fn close_timed_out_subscription( + sub_id: &str, + owner: u64, + conn: &ConnectionState, + state: &AppState, +) { + super::close::close_if_owner(sub_id, owner, Some(QUERY_TIMED_OUT_CLOSED), conn, state).await; +} + +/// Stable CLOSED reason for a read cancelled by its server statement deadline. +/// Clients match it to skip retrying the same expensive query. +pub(crate) const QUERY_TIMED_OUT_CLOSED: &str = "error: query timed out"; + +/// CLOSED reason for a failed one-shot DB read (COUNT): a statement cancel gets +/// the stable timeout reason; anything else keeps the raw error. +pub(crate) fn db_read_closed_reason(e: &buzz_db::DbError) -> String { + if e.is_statement_cancelled() { + QUERY_TIMED_OUT_CLOSED.to_string() + } else { + format!("error: {e}") + } } fn extract_channel_id_from_filters(filters: &[Filter]) -> Option { @@ -1554,6 +1630,491 @@ mod tests { use super::*; use nostr::{Alphabet, Filter, SingleLetterTag}; + fn lifecycle_conn() -> ( + ConnectionState, + tokio::sync::mpsc::Receiver, + ) { + use std::sync::atomic::AtomicU8; + let (send_tx, send_rx) = tokio::sync::mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let conn = ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "t.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().expect("addr"), + auth_state: std::sync::Mutex::new(AuthState::Failed), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + send_tx, + ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }; + (conn, send_rx) + } + + fn text_filters() -> Vec { + vec![Filter::new().kind(nostr::Kind::TextNote)] + } + + fn registered(state: &AppState, conn: &ConnectionState, sub_id: &str) -> bool { + state + .sub_registry + .get_filters(conn.conn_id, sub_id) + .is_some() + } + + fn closed_frames( + rx: &mut tokio::sync::mpsc::Receiver, + ) -> Vec { + std::iter::from_fn(|| rx.try_recv().ok()) + .map(|msg| match msg { + axum::extract::ws::Message::Text(t) => t.to_string(), + other => panic!("expected text frame, got {other:?}"), + }) + .collect() + } + + #[tokio::test] + async fn timed_out_historical_read_deregisters_before_closed() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let channel = uuid::Uuid::new_v4(); + let topic = EventTopic::Channel(channel); + let owner = + claim_live_subscription("thread", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 1); + + close_timed_out_subscription("thread", owner, &conn, &state).await; + + assert!(conn.subscriptions.lock().await.is_empty()); + assert!( + !registered(&state, &conn, "thread"), + "fan-out registration must be gone" + ); + assert_eq!( + state.pubsub.topic_refcount(&conn.tenant, topic).await, + 0, + "retained topic must be released" + ); + assert_eq!( + closed_frames(&mut send_rx), + vec![format!(r#"["CLOSED","thread","{QUERY_TIMED_OUT_CLOSED}"]"#)] + ); + } + + /// A superseded REQ's late timeout must not tear down, or send a terminal + /// CLOSED for, the same-ID replacement that now owns the ID. + #[tokio::test] + async fn superseded_timeout_leaves_replacement_intact() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let (old_channel, new_channel) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4()); + let (old_topic, new_topic) = ( + EventTopic::Channel(old_channel), + EventTopic::Channel(new_channel), + ); + let old = + claim_live_subscription("x", &text_filters(), Some(&[old_channel]), &conn, &state) + .await + .expect("claim"); + let new = + claim_live_subscription("x", &text_filters(), Some(&[new_channel]), &conn, &state) + .await + .expect("claim"); + assert_eq!( + state.pubsub.topic_refcount(&conn.tenant, old_topic).await, + 0 + ); + + close_timed_out_subscription("x", old, &conn, &state).await; + + assert_eq!(conn.subscriptions.lock().await.get("x"), Some(&new)); + assert!( + registered(&state, &conn, "x"), + "replacement must stay registered" + ); + assert_eq!( + state.pubsub.topic_refcount(&conn.tenant, new_topic).await, + 1, + "replacement topic must stay retained" + ); + assert!( + closed_frames(&mut send_rx).is_empty(), + "superseded request must say nothing" + ); + } + + /// Accepting a search REQ retires the live subscription holding its ID, and + /// a search that a newer live REQ supersedes cannot close that REQ. + #[tokio::test] + async fn search_claim_retires_live_and_yields_to_replacement() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let channel = uuid::Uuid::new_v4(); + let topic = EventTopic::Channel(channel); + claim_live_subscription("x", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + + let search = claim_search_subscription("x", &conn, &state) + .await + .expect("claim"); + assert!( + !registered(&state, &conn, "x"), + "search must retire live fan-out" + ); + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 0); + + let live = claim_live_subscription("x", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + close_timed_out_subscription("x", search, &conn, &state).await; + + assert_eq!(conn.subscriptions.lock().await.get("x"), Some(&live)); + assert!(registered(&state, &conn, "x")); + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 1); + assert!(closed_frames(&mut send_rx).is_empty()); + } + + /// Concurrent same-ID claims and stale teardowns: whichever claim lands + /// last owns the ID, and every stale teardown is a silent no-op. + #[tokio::test] + async fn concurrent_claims_and_stale_teardowns_keep_the_last_owner() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let conn = Arc::new(conn); + let channel = uuid::Uuid::new_v4(); + let topic = EventTopic::Channel(channel); + let stale = claim_live_subscription("x", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + + let tasks = (0..16).map(|i| { + let (conn, state) = (Arc::clone(&conn), Arc::clone(&state)); + tokio::spawn(async move { + if i % 2 == 0 { + claim_live_subscription("x", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + } else { + close_timed_out_subscription("x", stale, &conn, &state).await; + } + }) + }); + for task in tasks.collect::>() { + task.await.expect("task"); + } + + let owner = *conn.subscriptions.lock().await.get("x").expect("x owned"); + assert_ne!(owner, stale); + assert!(registered(&state, &conn, "x")); + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 1); + close_timed_out_subscription("x", owner, &conn, &state).await; + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 0); + assert_eq!( + closed_frames(&mut send_rx), + vec![format!(r#"["CLOSED","x","{QUERY_TIMED_OUT_CLOSED}"]"#)], + "only the final owner's teardown may close" + ); + } + + fn subscribed(state: &AppState, conn: &ConnectionState, channel: uuid::Uuid) -> bool { + state + .sub_registry + .channel_subscriber_conns_scoped(conn.tenant.community(), channel) + .contains(&conn.conn_id) + } + + /// Poll `fut` exactly once and require it to park. A contender for a held + /// tokio mutex enqueues its waiter and returns `Pending` on that poll, so + /// this proves it reached its lock attempt without relying on scheduling. + async fn assert_parked(fut: &mut F, what: &str) { + let parked = std::future::poll_fn(|cx| { + std::task::Poll::Ready(std::pin::Pin::new(&mut *fut).poll(cx).is_pending()) + }) + .await; + assert!(parked, "{what} must be waiting on the lifecycle lock"); + } + + /// The owner's teardown and its terminal CLOSED are one critical section: + /// a replacement claiming meanwhile waits, so it can never be the target of + /// the old request's CLOSED. + #[tokio::test] + async fn timeout_closed_is_emitted_before_a_replacement_can_claim() { + use super::super::close::test_seam::{Pause, PAUSE}; + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let conn = Arc::new(conn); + let (a, b) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4()); + let old = claim_live_subscription("x", &text_filters(), Some(&[a]), &conn, &state) + .await + .expect("claim"); + + let pause = Arc::new(Pause::default()); + let teardown = tokio::spawn(PAUSE.scope(Arc::clone(&pause), { + let (conn, state) = (Arc::clone(&conn), Arc::clone(&state)); + async move { close_timed_out_subscription("x", old, &conn, &state).await } + })); + pause.reached.notified().await; + let (filters, b_scope) = (text_filters(), [b]); + let mut replacement = Box::pin(claim_live_subscription( + "x", + &filters, + Some(&b_scope), + &conn, + &state, + )); + assert_parked(&mut replacement, "replacement").await; + assert!( + !subscribed(&state, &conn, b), + "replacement must not claim between teardown and CLOSED" + ); + assert!(closed_frames(&mut send_rx).is_empty()); + + pause.resume.notify_one(); + teardown.await.expect("teardown"); + let new = replacement.await.expect("claim"); + + assert_eq!( + closed_frames(&mut send_rx), + vec![format!(r#"["CLOSED","x","{QUERY_TIMED_OUT_CLOSED}"]"#)] + ); + assert_eq!(conn.subscriptions.lock().await.get("x"), Some(&new)); + assert!(subscribed(&state, &conn, b) && !subscribed(&state, &conn, a)); + let refcount = |c| { + state + .pubsub + .topic_refcount(&conn.tenant, EventTopic::Channel(c)) + }; + assert_eq!((refcount(a).await, refcount(b).await), (0, 1)); + } + + /// Revoke selects and removes registry entries under the lifecycle lock, so + /// a same-ID replacement queued behind it keeps its token, scope and topic. + #[tokio::test] + async fn revoke_then_replacement_keeps_replacement_whole() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let conn = Arc::new(conn); + state.conn_manager.register( + conn.conn_id, + conn.send_tx.clone(), + conn.ctrl_tx.clone(), + None, + conn.cancel.clone(), + conn.tenant.community(), + Arc::clone(&conn.backpressure_count), + Arc::clone(&conn.subscriptions), + 3, + ); + let (a, b) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4()); + claim_live_subscription("x", &text_filters(), Some(&[a]), &conn, &state) + .await + .expect("claim"); + + let guard = conn.subscriptions.lock().await; + let mut revoke = Box::pin( + crate::handlers::side_effects::evict_conn_channel_subscriptions( + &conn.tenant, + &state, + a, + conn.conn_id, + ), + ); + assert_parked(&mut revoke, "revoke").await; + let (filters, b_scope) = (text_filters(), [b]); + let mut replacement = Box::pin(claim_live_subscription( + "x", + &filters, + Some(&b_scope), + &conn, + &state, + )); + assert_parked(&mut replacement, "replacement").await; + assert!( + subscribed(&state, &conn, a), + "revoke must not touch the registry before taking the lifecycle lock" + ); + drop(guard); + // The tokio mutex is FIFO: revoke queued first, so it runs first. + revoke.await; + let new = replacement.await.expect("claim"); + + assert_eq!(conn.subscriptions.lock().await.get("x"), Some(&new)); + assert!(subscribed(&state, &conn, b) && !subscribed(&state, &conn, a)); + let refcount = |c| { + state + .pubsub + .topic_refcount(&conn.tenant, EventTopic::Channel(c)) + }; + assert_eq!((refcount(a).await, refcount(b).await), (0, 1)); + assert_eq!( + closed_frames(&mut send_rx), + vec![r#"["CLOSED","x","restricted: channel access revoked"]"#.to_string()] + ); + state.conn_manager.deregister(conn.conn_id); + } + + /// Connection cleanup fences later claims: a detached REQ task resuming + /// after it gets `None` and leaves nothing behind. + #[tokio::test] + async fn claims_after_connection_cleanup_are_refused() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let channel = uuid::Uuid::new_v4(); + let topic = EventTopic::Channel(channel); + claim_live_subscription("live", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + + conn.cancel.cancel(); + super::super::close::release_connection_subscriptions(&conn, &state).await; + let late_live = + claim_live_subscription("late", &text_filters(), Some(&[channel]), &conn, &state).await; + let late_search = claim_search_subscription("search", &conn, &state).await; + + assert_eq!((late_live, late_search), (None, None)); + assert!(conn.subscriptions.lock().await.is_empty()); + for sub in ["live", "late", "search"] { + assert!( + !registered(&state, &conn, sub), + "{sub} must not be registered" + ); + } + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 0); + assert!(closed_frames(&mut send_rx).is_empty()); + } + + /// A terminal CLOSED that fails to enqueue after retirement cancels the + /// connection, so the subscription is never silently orphaned on a + /// congested connection. + /// + /// Regression for P2-2: before this fix, a `false` from `conn.send` was + /// silently ignored after retirement, leaving the subscription retired but + /// the connection alive without a CLOSED delivered to the client. + #[tokio::test] + async fn dropped_terminal_frame_cancels_connection() { + use std::sync::atomic::AtomicU8; + let state = crate::state::tests::test_state().await; + // Capacity-1 channel: one dummy message fills it, so the CLOSED + // `try_send` returns Full (below the grace_limit=3 auto-cancel) and + // the fix's explicit cancel must fire. + let (send_tx, mut send_rx) = tokio::sync::mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let conn = Arc::new(ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "t.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().expect("addr"), + auth_state: std::sync::Mutex::new(AuthState::Failed), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + send_tx, + ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }); + + // Claim, then fill the send buffer so the next `send` returns false. + let owner = claim_live_subscription("x", &text_filters(), None, &conn, &state) + .await + .expect("claim"); + // Occupy the one slot so try_send returns Full on the CLOSED. + send_rx.try_recv().ok(); // drain any prior messages + let _ = conn.send_tx.try_send(axum::extract::ws::Message::Text( + "filler".to_string().into(), + )); + assert!(!conn.cancel.is_cancelled(), "must not be cancelled yet"); + + // `close_timed_out_subscription` retires the sub and tries to send CLOSED. + // With the buffer full the frame is dropped and the fix must cancel. + close_timed_out_subscription("x", owner, &conn, &state).await; + + assert!( + conn.cancel.is_cancelled(), + "connection must be cancelled when the terminal frame is dropped" + ); + // The subscription is still retired — the map is empty. + assert!(conn.subscriptions.lock().await.is_empty()); + assert!(!registered(&state, &conn, "x")); + } + + /// A revoke `CLOSED restricted` that fails to enqueue after the map remove + /// cancels the connection, same as the timeout path. + /// + /// Regression for P2-2 (revoke branch): before this fix, a `false` from + /// `send_to` in `evict_conn_channel_subscriptions` was silently ignored + /// after retirement, leaving the subscription orphaned on a congested + /// connection. + #[tokio::test] + async fn revoke_dropped_terminal_frame_cancels_connection() { + use std::sync::atomic::AtomicU8; + let state = crate::state::tests::test_state().await; + // Capacity-1 channel: one dummy message fills it so the CLOSED + // restricted `try_send` returns Full (below grace_limit=3) and the + // fix's explicit cancel_conn must fire. + let (send_tx, mut send_rx) = tokio::sync::mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let conn = Arc::new(ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "t.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().expect("addr"), + auth_state: std::sync::Mutex::new(AuthState::Failed), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + send_tx: send_tx.clone(), + ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }); + // Register in conn_manager so evict_conn_channel_subscriptions can + // find the connection for cancel_conn. + state.conn_manager.register( + conn.conn_id, + send_tx, + conn.ctrl_tx.clone(), + None, + conn.cancel.clone(), + conn.tenant.community(), + Arc::clone(&conn.backpressure_count), + Arc::clone(&conn.subscriptions), + 3, + ); + + let channel = uuid::Uuid::new_v4(); + claim_live_subscription("x", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + + // Fill the one send slot so the CLOSED restricted try_send returns Full. + send_rx.try_recv().ok(); // drain any earlier messages + let _ = conn.send_tx.try_send(axum::extract::ws::Message::Text( + "filler".to_string().into(), + )); + assert!(!conn.cancel.is_cancelled(), "must not be cancelled yet"); + + crate::handlers::side_effects::evict_conn_channel_subscriptions( + &conn.tenant, + &state, + channel, + conn.conn_id, + ) + .await; + + assert!( + conn.cancel.is_cancelled(), + "connection must be cancelled when the revoke terminal frame is dropped" + ); + assert!(conn.subscriptions.lock().await.is_empty()); + assert!(!subscribed(&state, &conn, channel)); + + state.conn_manager.deregister(conn.conn_id); + } + #[test] fn huddle_liveness_filters_require_only_the_snapshot_kind() { let liveness = Filter::new().kind(nostr::Kind::Custom(KIND_HUDDLE_LIVENESS as u16)); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index f63c174d96..c2131b1a55 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -231,41 +231,42 @@ async fn disable_departed_member_workflows( } /// Close every live channel-scoped subscription on `conn_id`, removing them from -/// the connection's local map and sending `CLOSED restricted` for each. -async fn evict_conn_channel_subscriptions( +/// the connection's local map and sending `CLOSED restricted` for each. Runs +/// under the connection's lifecycle lock so a concurrent same-ID claim is +/// either fully before (and revoked) or fully after (and untouched). +pub(crate) async fn evict_conn_channel_subscriptions( tenant: &TenantContext, state: &Arc, channel_id: Uuid, conn_id: uuid::Uuid, ) { + // No map means the connection already ran its final cleanup, which + // removed its registry entries under this same lock. + let Some(subscriptions) = state.conn_manager.subscriptions_for(conn_id) else { + return; + }; + let mut conn_subscriptions = subscriptions.lock().await; let removed = state.sub_registry.remove_channel_subscriptions_scoped( tenant.community(), conn_id, channel_id, ); - if removed.is_empty() { - return; - } - - if let Some(subscriptions) = state.conn_manager.subscriptions_for(conn_id) { - let mut conn_subscriptions = subscriptions.lock().await; - for update in &removed { - if update.removed { - conn_subscriptions.remove(&update.sub_id); - } - } - } - for update in removed { + // A multi-channel sub keeps its other channels (and its map token). state .pubsub .release_topic(tenant, buzz_pubsub::EventTopic::Channel(channel_id)) .await; if update.removed { - let _ = state.conn_manager.send_to( + conn_subscriptions.remove(&update.sub_id); + if !state.conn_manager.send_to( conn_id, RelayMessage::closed(&update.sub_id, "restricted: channel access revoked"), - ); + ) { + // Terminal frame lost — cancel so the subscription is not + // silently orphaned on a congested connection. + state.conn_manager.cancel_conn(conn_id); + } } } } diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index ddec559617..70f3ef4892 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -33,16 +33,20 @@ pub mod mesh_boot; pub mod metrics; /// NIP-11 relay information document. pub mod nip11; +mod nip98; /// NIP-FI relay configuration: mode, issuer registry, JWKS warm/refresh. pub mod nip_fi_config; /// NIP-FI HTTP ingress enforcement: assertion extraction, verification, /// key-pairing check, and deny-map gate for every protected HTTP surface. pub(crate) mod nip_fi_http; +/// Deployment-global operator-listener mention delivery worker. +pub mod operator_listener; /// NIP-01 client/relay message parsing. pub mod protocol; /// Durable NIP-PL matcher and delivery worker. pub mod push_runtime; -mod readiness; +/// Readiness-probe telemetry and the per-pod dependency sampler behind `/_status`. +pub mod readiness; /// Axum router construction. pub mod router; /// Shared application state. diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 8211dcbacb..d2cf5fa787 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -227,6 +227,10 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { let usage_interval_secs = usage_metrics_interval_secs(); let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs); + let dependency_sample_completion_republish_interval = + buzz_relay::readiness::dependency_sample_completion_republish_interval( + usage_idle_timeout_secs, + ); let (boot, ()) = boot.run_required( StartupPhase::MetricsBind, || relay_metrics::try_install(config.metrics_port, usage_idle_timeout_secs), @@ -244,6 +248,7 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { info!( port = config.metrics_port, idle_timeout_secs = usage_idle_timeout_secs, + completion_republish_secs = dependency_sample_completion_republish_interval.as_secs(), "Prometheus metrics exporter started" ); @@ -455,7 +460,13 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { cfg.create_pool(Some(deadpool_redis::Runtime::Tokio1)) .map_err(|e| anyhow::anyhow!("Redis pool creation failed: {e}"))? }; - let redis_health_pool = redis_pool.clone(); // cheap Arc clone — shared with readiness handler + let redis_health_pool = redis_pool.clone(); // cheap Arc clone — shared with AppState + // One-time bootstrap gate, deliberately before AppState and therefore before + // the health listener binds. Post-start Redis failures are dependency + // failures and must never move readiness; never having connected at all is + // a broken deployment, not a blip. + buzz_relay::state::verify_redis_command_path(&redis_health_pool).await?; + info!("Redis command path connected"); let pubsub = Arc::new( PubSubManager::new(&config.redis_url, redis_pool) .await @@ -857,6 +868,24 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { info!("NIP-PL push disabled by BUZZ_PUSH_ENABLED"); } + // Registration cleanup is independent of configured routes. Delivery is + // opt-in, while accepted events populate the outbox transactionally. + tokio::spawn(buzz_relay::operator_listener::run_reaper(Arc::clone( + &state, + ))); + if !state.config.operator_listener_delivery_urls.is_empty() { + tokio::spawn(buzz_relay::operator_listener::run_delivery_worker( + Arc::clone(&state), + )); + info!( + listeners = state.config.operator_listener_delivery_urls.len(), + "operator-listener mention delivery worker started" + ); + } else { + info!("operator-listener mention delivery disabled by BUZZ_OPERATOR_LISTENERS"); + } + info!("operator-listener registration cleanup started"); + // Admin outbox delivery worker — drives `relay_admin_outbox` rows. // Uses DB-level leases (held_by / lease_expires_at) so multiple pods can // run the worker concurrently without double-delivery. @@ -1082,6 +1111,16 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { )); } + // Per-pod dependency diagnostics runtime: one seam starts the dependency + // sampler and its independent completion-epoch republisher together. + { + let diagnostics_state = Arc::clone(&state); + buzz_relay::readiness::start_dependency_sampler_and_completion_publisher( + diagnostics_state, + dependency_sample_completion_republish_interval, + ); + } + // Cross-pod connection-control consumer: receive disconnect commands from // Redis pub/sub (published by the pod that recorded a ban) and close any // matching local sockets. A member's live connections may land on any pod, @@ -1255,6 +1294,8 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { serve(router, health_router, Arc::clone(&state)).await?; state.community_revalidator_cancel.cancel(); + state.dependency_sampler_cancel.cancel(); + state.dependency_completion_publisher_cancel.cancel(); // Signal the audit worker to stop accepting, flush buffered entries, and // exit. Uses a CancellationToken so it works regardless of how many diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index c1e72f7b75..59bd90a0d7 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -290,6 +290,7 @@ pub fn try_install(port: u16, gauge_idle_timeout_secs: u64) -> Result<(), Metric metrics::set_global_recorder(recorder) .map_err(|_error| MetricsInstallError::RecorderConflict)?; describe_readiness_metrics(); + describe_community_admission_metrics(); describe_db_pool_metrics(); describe_auth_metrics(); initialize_auth_metric_series(); @@ -306,24 +307,42 @@ pub fn install(port: u16, gauge_idle_timeout_secs: u64) { .unwrap_or_else(|error| panic!("metrics exporter must install exactly once: {error}")); } -/// Register the frozen readiness metric descriptions with the active recorder. +/// Register the frozen readiness and dependency-diagnostic metric descriptions. +/// +/// The two `buzz_readiness_*` probe families describe local process lifecycle. +/// The dependency families keep their names for dashboard continuity but are +/// published by the per-pod dependency sampler, not by the Kubernetes probe or +/// by an `/_status` request — a shared-dependency failure no longer deroutes +/// the pod, and nobody has to read the endpoint for the metrics to move. pub(crate) fn describe_readiness_metrics() { metrics::describe_counter!( "buzz_readiness_checks_total", - "Kubernetes health-listener readiness probes by terminal bounded reason" + "Kubernetes health-listener readiness probes by lifecycle reason (ready, shutting_down)" ); metrics::describe_counter!( "buzz_readiness_dependency_checks_total", - "Completed readiness dependency attempts by dependency and bounded outcome" + "Completed dependency-sampler attempts by dependency and bounded outcome" ); metrics::describe_histogram!( "buzz_readiness_check_duration_seconds", metrics::Unit::Seconds, - "Completed readiness check duration without outcome label multiplication" + "Completed dependency-sampler check duration without outcome label multiplication" ); metrics::describe_gauge!( "buzz_readiness_state", - "Latest publishable readiness state by check, where 1 is ready and 0 is not ready" + "Latest private readiness-probe observation, where 1 is ready and 0 is shutting down" + ); + metrics::describe_gauge!( + "buzz_readiness_dependency_sample_completed_timestamp_seconds", + "Unix time the cached /_status dependency report completed, absent until the first sample completes; sampler completion advances it and the publisher re-emits it" + ); +} + +/// Register the bounded community-admission contract. +pub(crate) fn describe_community_admission_metrics() { + metrics::describe_counter!( + "buzz_community_admission_checks_total", + "Durable community-active checks at socket admission by bounded outcome" ); } @@ -541,7 +560,17 @@ pub(crate) fn readiness_test_recorder() -> ( metrics_exporter_prometheus::PrometheusRecorder, metrics_exporter_prometheus::PrometheusHandle, ) { - let recorder = configured_prometheus_builder(300).build_recorder(); + readiness_test_recorder_with_idle_timeout(300) +} + +#[cfg(test)] +pub(crate) fn readiness_test_recorder_with_idle_timeout( + gauge_idle_timeout_secs: u64, +) -> ( + metrics_exporter_prometheus::PrometheusRecorder, + metrics_exporter_prometheus::PrometheusHandle, +) { + let recorder = configured_prometheus_builder(gauge_idle_timeout_secs).build_recorder(); let handle = recorder.handle(); (recorder, handle) } diff --git a/crates/buzz-relay/src/nip98.rs b/crates/buzz-relay/src/nip98.rs new file mode 100644 index 0000000000..117131396d --- /dev/null +++ b/crates/buzz-relay/src/nip98.rs @@ -0,0 +1,22 @@ +//! Shared NIP-98 signing helpers for relay-originated HTTP requests. + +use base64::Engine as _; +use nostr::{EventBuilder, Kind, Tag}; +use sha2::{Digest as _, Sha256}; + +/// Build a NIP-98 authorization header for a signed JSON `POST` request. +pub(crate) fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result { + let hash = hex::encode(Sha256::digest(body)); + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", url])?, + Tag::parse(["method", "POST"])?, + Tag::parse(["payload", &hash])?, + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])?, + ]) + .sign_with_keys(keys)?; + Ok(format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event)?) + )) +} diff --git a/crates/buzz-relay/src/operator_listener.rs b/crates/buzz-relay/src/operator_listener.rs new file mode 100644 index 0000000000..a21292d2ae --- /dev/null +++ b/crates/buzz-relay/src/operator_listener.rs @@ -0,0 +1,776 @@ +//! Deployment-global operator-listener mention matching and notification delivery. + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use chrono::{DateTime, TimeDelta, Utc}; +use futures_util::future::join_all; +use serde::Serialize; +use tracing::{error, warn}; +use uuid::Uuid; + +use crate::{nip98::nip98_header, state::AppState}; + +use reqwest::StatusCode; + +const CLAIM_SECS: i64 = 30; +const DELIVERY_BATCH_LIMIT: i64 = 10; +const IDLE_POLL_FLOOR: Duration = Duration::from_millis(250); +const IDLE_POLL_CEILING: Duration = Duration::from_secs(2); +const OUTBOX_REAP_INTERVAL: Duration = Duration::from_secs(5 * 60); +const REGISTRATION_REAP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Notification sent to the configured operator-listener endpoint. +#[derive(Debug, Serialize)] +struct MentionNotification { + v: u8, + pubkey: String, + community_host: String, + event_id: String, + event_kind: i32, + event_created_at: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorkerIteration { + Worked, + Idle, + Failed, +} + +/// Reap stale deliveries every five minutes and expired registrations daily. +pub async fn run_reaper(state: Arc) { + tokio::join!( + async { + loop { + if let Err(error) = state.db.delete_expired_operator_listener_pubkeys().await { + warn!(%error, "operator-listener registration cleanup failed"); + } + tokio::time::sleep(REGISTRATION_REAP_INTERVAL).await; + } + }, + async { + loop { + if let Err(error) = state.db.reap_operator_listener_deliveries().await { + warn!(%error, "operator-listener outbox reap failed"); + } + tokio::time::sleep(OUTBOX_REAP_INTERVAL).await; + } + } + ); +} + +/// Continuously deliver operator-listener notification rows with bounded concurrency and retries. +pub async fn run_delivery_worker(state: Arc) { + let http = match build_delivery_http_client(state.config.operator_listener_timeout) { + Ok(http) => http, + Err(error) => { + error!(%error, "operator-listener HTTP client initialization failed"); + return; + } + }; + let mut idle_delay = IDLE_POLL_FLOOR; + loop { + match run_delivery_once(&state, &http).await { + WorkerIteration::Worked => idle_delay = IDLE_POLL_FLOOR, + WorkerIteration::Idle => { + tokio::time::sleep(idle_delay).await; + idle_delay = (idle_delay * 2).min(IDLE_POLL_CEILING); + } + WorkerIteration::Failed => tokio::time::sleep(Duration::from_secs(2)).await, + } + } +} + +fn build_delivery_http_client(timeout: Duration) -> Result { + reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .build() +} + +async fn run_delivery_once(state: &AppState, http: &reqwest::Client) -> WorkerIteration { + let transport = ReqwestDeliveryTransport { http }; + run_delivery_once_with_transport(state, &transport).await +} + +#[async_trait::async_trait] +trait DeliveryTransport: Sync { + async fn post( + &self, + url: &url::Url, + authorization: &str, + body: Vec, + ) -> Result; +} + +#[async_trait::async_trait] +trait DeliveryStore: Sync { + async fn release(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result; + async fn complete(&self, id: Uuid, claim_id: Uuid) -> Result; + async fn retry(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result; + async fn fail(&self, id: Uuid, claim_id: Uuid) -> Result; +} + +struct DbDeliveryStore<'a> { + db: &'a buzz_db::Db, +} + +#[async_trait::async_trait] +impl DeliveryStore for DbDeliveryStore<'_> { + async fn release(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result { + self.db + .release_operator_listener_delivery(id, claim_id, next) + .await + .map_err(|error| error.to_string()) + } + + async fn complete(&self, id: Uuid, claim_id: Uuid) -> Result { + self.db + .complete_operator_listener_delivery(id, claim_id) + .await + .map_err(|error| error.to_string()) + } + + async fn retry(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result { + self.db + .retry_operator_listener_delivery(id, claim_id, next) + .await + .map_err(|error| error.to_string()) + } + + async fn fail(&self, id: Uuid, claim_id: Uuid) -> Result { + self.db + .fail_operator_listener_delivery(id, claim_id) + .await + .map_err(|error| error.to_string()) + } +} + +struct ReqwestDeliveryTransport<'a> { + http: &'a reqwest::Client, +} + +#[async_trait::async_trait] +impl DeliveryTransport for ReqwestDeliveryTransport<'_> { + async fn post( + &self, + url: &url::Url, + authorization: &str, + body: Vec, + ) -> Result { + self.http + .post(url.clone()) + .header("Authorization", authorization) + .header("Content-Type", "application/json") + .body(body) + .send() + .await + .map(|response| response.status()) + .map_err(|error| error.to_string()) + } +} + +async fn run_delivery_once_with_transport( + state: &AppState, + transport: &T, +) -> WorkerIteration { + let claimed = match state + .db + .claim_operator_listener_deliveries( + DELIVERY_BATCH_LIMIT, + Utc::now() + TimeDelta::seconds(CLAIM_SECS), + ) + .await + { + Ok(claimed) => claimed, + Err(error) => { + error!(%error, "operator-listener delivery claim failed"); + return WorkerIteration::Failed; + } + }; + if claimed.is_empty() { + return WorkerIteration::Idle; + } + let store = DbDeliveryStore { db: &state.db }; + join_all(claimed.into_iter().map(|delivery| { + deliver_one( + &state.config.operator_listener_delivery_urls, + &state.relay_keypair, + &store, + transport, + delivery, + ) + })) + .await; + WorkerIteration::Worked +} + +async fn deliver_one( + routes: &HashMap, + relay_keypair: &nostr::Keys, + store: &S, + transport: &T, + delivery: buzz_db::operator_listener::ClaimedDelivery, +) { + let listener_hex = hex::encode(&delivery.listener_pubkey); + let Some(url) = routes.get(&listener_hex) else { + warn!( + delivery=%delivery.id, + listener=%listener_hex, + "operator-listener delivery has no route on this pod; releasing claim" + ); + if let Err(error) = store + .release( + delivery.id, + delivery.claim_id, + Utc::now() + TimeDelta::seconds(CLAIM_SECS), + ) + .await + { + error!(%error, delivery=%delivery.id, "failed to release unroutable operator-listener delivery"); + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "unroutable") + .increment(1); + return; + }; + let body = match serde_json::to_vec(&MentionNotification { + v: 1, + pubkey: hex::encode(&delivery.target_pubkey), + community_host: delivery.community_host.clone(), + event_id: hex::encode(&delivery.event_id), + event_kind: delivery.event_kind, + event_created_at: delivery.event_created_at.timestamp(), + }) { + Ok(body) => body, + Err(error) => { + fail_permanently( + store, + &delivery, + &format!("notification encoding failed: {error}"), + ) + .await; + return; + } + }; + let auth = match nip98_header(relay_keypair, url.as_str(), &body) { + Ok(auth) => auth, + Err(error) => { + fail_permanently( + store, + &delivery, + &format!("notification auth failed: {error}"), + ) + .await; + return; + } + }; + let response = transport.post(url, &auth, body).await; + match response { + Ok(status) if status.is_success() => { + match store.complete(delivery.id, delivery.claim_id).await { + Ok(true) => {} + Ok(false) => warn!( + delivery=%delivery.id, + "operator-listener delivery completion lost its claim" + ), + Err(error) => error!( + delivery=%delivery.id, + %error, + "failed to persist operator-listener delivery completion" + ), + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "accepted") + .increment(1); + } + Ok(status) + if status == StatusCode::REQUEST_TIMEOUT + || status == StatusCode::TOO_MANY_REQUESTS + || status.is_server_error() => + { + retry_or_fail(store, &delivery, format!("HTTP {status}")).await + } + Ok(status) => { + fail_permanently(store, &delivery, &format!("HTTP {status}")).await; + } + Err(error) => retry_or_fail(store, &delivery, error.to_string()).await, + } +} + +async fn fail_permanently( + store: &S, + delivery: &buzz_db::operator_listener::ClaimedDelivery, + reason: &str, +) { + error!(delivery=%delivery.id, %reason, "operator-listener delivery failed permanently"); + if let Err(error) = store.fail(delivery.id, delivery.claim_id).await { + error!(delivery=%delivery.id, %error, "failed to delete terminal operator-listener delivery"); + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "failed") + .increment(1); +} + +async fn retry_or_fail( + store: &S, + delivery: &buzz_db::operator_listener::ClaimedDelivery, + reason: String, +) { + if delivery.attempt >= buzz_db::operator_listener::MAX_DELIVERY_ATTEMPTS { + fail_permanently(store, delivery, &format!("retries exhausted: {reason}")).await; + return; + } + let delay = 2_i64.pow((delivery.attempt - 1).clamp(0, 7) as u32); + warn!( + delivery=%delivery.id, + attempt=delivery.attempt, + retry_in_seconds=delay, + %reason, + "operator-listener delivery failed; retrying" + ); + if let Err(error) = store + .retry( + delivery.id, + delivery.claim_id, + Utc::now() + TimeDelta::seconds(delay), + ) + .await + { + error!(delivery=%delivery.id, %error, "failed to persist operator-listener retry"); + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "retry").increment(1); +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use tokio::{ + io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, + net::{TcpListener, TcpStream}, + }; + + use super::*; + use buzz_core::tenant::CommunityId; + use nostr::Keys; + + #[test] + fn reapers_use_their_expected_intervals() { + assert_eq!(OUTBOX_REAP_INTERVAL, Duration::from_secs(5 * 60)); + assert_eq!( + REGISTRATION_REAP_INTERVAL, + Duration::from_secs(24 * 60 * 60) + ); + } + + #[derive(Debug, Clone, PartialEq, Eq)] + enum StoreCall { + Release(Uuid, Uuid, DateTime), + Complete(Uuid, Uuid), + Retry(Uuid, Uuid, DateTime), + Fail(Uuid, Uuid), + } + + #[derive(Default)] + struct MockStore { + calls: Mutex>, + } + + impl MockStore { + fn calls(&self) -> Vec { + self.calls.lock().expect("store calls lock").clone() + } + } + + #[async_trait::async_trait] + impl DeliveryStore for MockStore { + async fn release( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Release(id, claim_id, next)); + Ok(true) + } + + async fn complete(&self, id: Uuid, claim_id: Uuid) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Complete(id, claim_id)); + Ok(true) + } + + async fn retry( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Retry(id, claim_id, next)); + Ok(true) + } + + async fn fail(&self, id: Uuid, claim_id: Uuid) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Fail(id, claim_id)); + Ok(true) + } + } + + struct SentRequest { + url: url::Url, + authorization: String, + body: Vec, + } + + struct MockTransport { + response: Mutex>>, + request: Mutex>, + request_count: Mutex, + } + + impl MockTransport { + fn new(response: Result) -> Self { + Self { + response: Mutex::new(Some(response)), + request: Mutex::new(None), + request_count: Mutex::new(0), + } + } + + fn take_request(&self) -> Option { + self.request.lock().expect("transport request lock").take() + } + + fn request_count(&self) -> usize { + *self + .request_count + .lock() + .expect("transport request count lock") + } + } + + #[async_trait::async_trait] + impl DeliveryTransport for MockTransport { + async fn post( + &self, + url: &url::Url, + authorization: &str, + body: Vec, + ) -> Result { + *self + .request_count + .lock() + .expect("transport request count lock") += 1; + *self.request.lock().expect("transport request lock") = Some(SentRequest { + url: url.clone(), + authorization: authorization.to_owned(), + body, + }); + self.response + .lock() + .expect("transport response lock") + .take() + .expect("delivery should make one request") + } + } + + fn delivery_fixture( + attempt: i32, + ) -> ( + HashMap, + Keys, + buzz_db::operator_listener::ClaimedDelivery, + ) { + let listener = Keys::generate(); + let listener_pubkey = listener.public_key().to_bytes().to_vec(); + let route = url::Url::parse("https://listener.example/mentions").expect("test URL"); + let routes = HashMap::from([(hex::encode(&listener_pubkey), route)]); + ( + routes, + Keys::generate(), + buzz_db::operator_listener::ClaimedDelivery { + id: Uuid::new_v4(), + claim_id: Uuid::new_v4(), + listener_pubkey, + target_pubkey: vec![0x11; 32], + community: CommunityId::from_uuid(Uuid::new_v4()), + community_host: "community.example".to_owned(), + event_id: vec![0x22; 32], + event_kind: 9, + event_created_at: DateTime::from_timestamp(1_700_000_000, 0) + .expect("test timestamp"), + attempt, + }, + ) + } + + #[tokio::test] + async fn unroutable_delivery_releases_claim_without_http_request() { + let (_, relay_keypair, delivery) = delivery_fixture(1); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(StatusCode::NO_CONTENT)); + let before = Utc::now() + TimeDelta::seconds(CLAIM_SECS); + + deliver_one( + &HashMap::new(), + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let after = Utc::now() + TimeDelta::seconds(CLAIM_SECS); + assert!(transport.take_request().is_none()); + let calls = store.calls(); + assert_eq!(calls.len(), 1); + match &calls[0] { + StoreCall::Release(id, claim_id, next) => { + assert_eq!(*id, delivery.id); + assert_eq!(*claim_id, delivery.claim_id); + assert!((before..=after).contains(next)); + } + call => panic!("expected claim release, got {call:?}"), + } + } + + #[tokio::test] + async fn successful_delivery_posts_payload_and_completes_claim() { + let (routes, relay_keypair, delivery) = delivery_fixture(1); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(StatusCode::NO_CONTENT)); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let request = transport.take_request().expect("delivery request"); + assert_eq!(request.url, routes[&hex::encode(&delivery.listener_pubkey)]); + assert!(request.authorization.starts_with("Nostr ")); + let body: serde_json::Value = serde_json::from_slice(&request.body).expect("JSON body"); + assert_eq!(body["v"], 1); + assert_eq!(body["pubkey"], hex::encode(&delivery.target_pubkey)); + assert_eq!(body["community_host"], delivery.community_host); + assert_eq!(body["event_id"], hex::encode(&delivery.event_id)); + assert_eq!(body["event_kind"], delivery.event_kind); + assert_eq!( + body["event_created_at"], + delivery.event_created_at.timestamp() + ); + assert_eq!( + store.calls(), + [StoreCall::Complete(delivery.id, delivery.claim_id)] + ); + } + + #[tokio::test] + async fn rate_limit_and_server_errors_retry_with_exponential_delay() { + for status in [ + StatusCode::TOO_MANY_REQUESTS, + StatusCode::SERVICE_UNAVAILABLE, + ] { + let (routes, relay_keypair, delivery) = delivery_fixture(3); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(status)); + let before = Utc::now() + TimeDelta::seconds(4); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let after = Utc::now() + TimeDelta::seconds(4); + assert_eq!(transport.request_count(), 1, "HTTP {status}"); + assert!(transport.take_request().is_some()); + let calls = store.calls(); + assert_eq!(calls.len(), 1, "HTTP {status}"); + match &calls[0] { + StoreCall::Retry(id, claim_id, next) => { + assert_eq!(*id, delivery.id); + assert_eq!(*claim_id, delivery.claim_id); + assert!((before..=after).contains(next)); + } + call => panic!("expected HTTP {status} retry, got {call:?}"), + } + } + } + + #[tokio::test] + async fn deterministic_client_error_fails_without_retrying() { + let (routes, relay_keypair, delivery) = delivery_fixture(1); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(StatusCode::BAD_REQUEST)); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + assert_eq!(transport.request_count(), 1); + assert!(transport.take_request().is_some()); + assert_eq!( + store.calls(), + [StoreCall::Fail(delivery.id, delivery.claim_id)] + ); + } + + async fn read_http_request(stream: &mut TcpStream) -> (String, Vec) { + let mut reader = BufReader::new(stream); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("read request line"); + + let mut content_length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.expect("read header"); + if line == "\r\n" || line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + content_length = value.trim().parse().expect("valid content length"); + } + } + } + + let mut body = vec![0; content_length]; + reader + .read_exact(&mut body) + .await + .expect("read request body"); + (request_line, body) + } + + #[tokio::test] + async fn redirect_response_fails_delivery_without_retrying() { + let source = TcpListener::bind("127.0.0.1:0") + .await + .expect("source listener"); + let source_addr = source.local_addr().expect("source address"); + let target = TcpListener::bind("127.0.0.1:0") + .await + .expect("target listener"); + let target_addr = target.local_addr().expect("target address"); + let target_url = format!("http://{target_addr}/redirected"); + let target_task = tokio::spawn(async move { + match tokio::time::timeout(Duration::from_secs(1), target.accept()).await { + Ok(Ok((mut stream, _))) => { + let request = read_http_request(&mut stream).await; + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .expect("write target success response"); + Some(request) + } + Ok(Err(error)) => panic!("accept target request: {error}"), + Err(_) => None, + } + }); + let (_, relay_keypair, delivery) = delivery_fixture(1); + let source_url = + url::Url::parse(&format!("http://{source_addr}/mentions")).expect("source URL"); + let routes = HashMap::from([(hex::encode(&delivery.listener_pubkey), source_url)]); + let store = MockStore::default(); + let http = build_delivery_http_client(Duration::from_secs(2)).expect("delivery client"); + let transport = ReqwestDeliveryTransport { http: &http }; + + let source_task = tokio::spawn(async move { + let (mut stream, _) = source.accept().await.expect("accept source request"); + let request = read_http_request(&mut stream).await; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {target_url}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write redirect response"); + let second_request = tokio::time::timeout(Duration::from_millis(250), source.accept()) + .await + .is_ok(); + (request, second_request) + }); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let (request, second_source_request) = + tokio::time::timeout(Duration::from_secs(2), source_task) + .await + .expect("source server completed") + .expect("source server task"); + assert!(request.0.starts_with("POST /mentions HTTP/1.1")); + assert!(!request.1.is_empty()); + assert!( + !second_source_request, + "source received more than one request" + ); + let target_request = tokio::time::timeout(Duration::from_secs(2), target_task) + .await + .expect("target server completed") + .expect("target server task"); + assert_eq!( + store.calls(), + [StoreCall::Fail(delivery.id, delivery.claim_id)] + ); + assert!( + target_request.is_none(), + "redirect target unexpectedly received a request" + ); + } + + #[tokio::test] + async fn exhausted_delivery_attempts_fail_permanently() { + let (routes, relay_keypair, delivery) = + delivery_fixture(buzz_db::operator_listener::MAX_DELIVERY_ATTEMPTS); + let store = MockStore::default(); + let transport = MockTransport::new(Err("connection reset".to_owned())); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + assert!(transport.take_request().is_some()); + assert_eq!( + store.calls(), + [StoreCall::Fail(delivery.id, delivery.claim_id)] + ); + } +} diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 1e9469f4a4..d47dcc9d61 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -5,15 +5,13 @@ use std::{ time::{Duration, Instant}, }; -use base64::Engine as _; use buzz_core::filter::{filters_match, reader_authorized_for_event}; use chrono::{TimeDelta, Utc}; -use nostr::{EventBuilder, Filter, Kind, Tag}; +use nostr::Filter; use serde::{Deserialize, Serialize}; -use sha2::{Digest as _, Sha256}; use tracing::{error, warn}; -use crate::{handlers::push_lease::Subscription, state::AppState}; +use crate::{handlers::push_lease::Subscription, nip98::nip98_header, state::AppState}; const CLAIM_SECS: i64 = 30; const EVENT_USEFUL_SECS: i64 = 3600; @@ -666,22 +664,6 @@ async fn retry_or_fail( } } -fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result { - let hash = hex::encode(Sha256::digest(body)); - let event = EventBuilder::new(Kind::HttpAuth, "") - .tags([ - Tag::parse(["u", url])?, - Tag::parse(["method", "POST"])?, - Tag::parse(["payload", &hash])?, - Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])?, - ]) - .sign_with_keys(keys)?; - Ok(format!( - "Nostr {}", - base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event)?) - )) -} - fn class_rank(_: &str) -> u8 { 1 } @@ -696,6 +678,7 @@ mod tests { routing::post, Json, Router, }; + use nostr::{EventBuilder, Kind, Tag}; use serde_json::Value; use std::{future::IntoFuture, sync::Arc}; use tokio::sync::Mutex; diff --git a/crates/buzz-relay/src/readiness.rs b/crates/buzz-relay/src/readiness.rs index 79a1a98570..f39689f20a 100644 --- a/crates/buzz-relay/src/readiness.rs +++ b/crates/buzz-relay/src/readiness.rs @@ -1,43 +1,108 @@ -//! Readiness dependency evaluation and ordered metrics publication. +//! Readiness-probe telemetry and the dependency diagnostics behind `/_status`. //! -//! [`ReadinessCoordinator`] is process-owned. Its mutex is the linearization -//! point shared by health-probe commits and terminal shutdown, so an older -//! evaluation can never overwrite newer gauges or publish ready after shutdown. +//! Readiness is deliberately *not* a dependency question. A shared Postgres or +//! Redis failure is shared by every replica, so evaluating it in the probe took +//! the whole deployment out of the load balancer at once and left a reconnect +//! burst with nowhere to land. The Kubernetes probe therefore answers from this +//! process's own lifecycle (see [`crate::router`]), and the same dependency +//! evaluation is reported on the diagnostic `/_status` endpoint, which is never +//! wired to a probe. +//! +//! Dependency evaluation is also decoupled from requests. One per-pod loop +//! ([`run_dependency_sampler`]) evaluates on a fixed cadence, publishes the +//! dependency metrics, and caches the report; `/_status` only reads that cache. +//! Evaluating per request made the load a pressured dependency sees depend on +//! how often someone looked at the endpoint, with nothing bounding how many +//! evaluations could be in flight at once. use std::future::Future; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use std::time::Duration; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, SystemTime}; use buzz_db::{Db, DbError, DbReadinessOutcome}; use tokio::time::Instant; +use tokio_util::sync::CancellationToken; -const READINESS_TIMEOUT: Duration = Duration::from_secs(2); +use crate::state::AppState; + +const DEPENDENCY_TIMEOUT: Duration = Duration::from_secs(2); + +/// Fixed cadence of the per-pod dependency sampling loop. +/// +/// Slow enough that a pod adds negligible load to a shared dependency, fast +/// enough that an operator opening `/_status` during an incident reads +/// something current. Documented in `deploy/charts/buzz/README.md`. +pub const DEPENDENCY_SAMPLE_INTERVAL: Duration = Duration::from_secs(30); + +/// The completion-epoch publisher always refreshes at least this frequently. +/// +/// Main computes a cadence from the configured gauge idle timeout and caps it +/// at this value so the completion timestamp series survives idle eviction +/// without adding high-frequency noise. +const DEPENDENCY_SAMPLE_COMPLETION_REPUBLISH_MAX_INTERVAL: Duration = DEPENDENCY_SAMPLE_INTERVAL; + +/// Age past which a cached report is reported stale rather than current. +/// +/// Two cadences: one full cycle can be missed by an evaluation that consumed +/// its whole [`DEPENDENCY_TIMEOUT`] budget, so anything older than that means +/// the sampler itself is not keeping up. +const DEPENDENCY_SAMPLE_STALE_AFTER: Duration = DEPENDENCY_SAMPLE_INTERVAL.saturating_mul(2); /// Closed label set exported by `buzz_readiness_checks_total{reason}`. -#[cfg(test)] -pub(crate) const READINESS_REASON_LABELS: [&str; 12] = [ - "ready", - "shutting_down", - "postgres_pool_timeout", - "postgres_pool_error", - "postgres_query_timeout", - "postgres_query_error", - "redis_pool_timeout", - "redis_pool_error", - "deletion_catalog_timeout", - "deletion_catalog_error", - "overall_timeout", - "multiple_dependencies_failed", -]; - -/// Maximum raw Prometheus series emitted by readiness for one pod. /// -/// - 12 overall reasons +/// Readiness answers a local lifecycle question, so this set cannot grow with +/// the number of shared dependencies the relay talks to. +#[cfg(test)] +pub(crate) const READINESS_REASON_LABELS: [&str; 2] = ["ready", "shutting_down"]; + +/// Maximum raw Prometheus series emitted by readiness and its dependency +/// diagnostics for one pod. +/// +/// - 2 probe reasons /// - 11 valid dependency/outcome pairs (Postgres 5, Redis 3, catalog 3) /// - 4 histograms x (15 configured buckets + `+Inf` + count + sum) = 72 -/// - 4 current-state gauges +/// - 2 readiness gauges (overall lifecycle + completion epoch) #[cfg(test)] -pub(crate) const READINESS_RAW_SERIES_PER_POD: usize = 12 + 11 + (4 * 18) + 4; +pub(crate) const READINESS_RAW_SERIES_PER_POD: usize = 2 + 11 + (4 * 18) + 1 + 1; + +/// Terminal outcome of one readiness probe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReadinessReason { + Ready, + ShuttingDown, +} + +impl ReadinessReason { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Ready => "ready", + Self::ShuttingDown => "shutting_down", + } + } + + pub(crate) fn is_ready(self) -> bool { + self == Self::Ready + } +} + +/// Records one readiness probe served by the private health listener. +/// +/// The counter and gauge describe the same immutable lifecycle observation. +/// The gauge is therefore the latest private readiness-probe observation, not +/// a transition-owned lifecycle mirror. +pub(crate) fn record_readiness_probe(reason: ReadinessReason) { + metrics::counter!( + "buzz_readiness_checks_total", + "reason" => reason.label(), + ) + .increment(1); + metrics::gauge!("buzz_readiness_state", "check" => "overall").set(if reason.is_ready() { + 1.0 + } else { + 0.0 + }); +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PostgresOutcome { @@ -130,10 +195,14 @@ impl DeletionCatalogOutcome { } } +/// Aggregate dependency verdict reported in the `/_status` diagnostics body. +/// +/// This is a diagnostic field, never a metric label: it exists so an operator +/// reading `/_status` gets the same one-line summary the readiness body used to +/// carry. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ReadinessReason { +pub(crate) enum DependencyReason { Ready, - ShuttingDown, PostgresPoolTimeout, PostgresPoolError, PostgresQueryTimeout, @@ -146,11 +215,10 @@ pub(crate) enum ReadinessReason { MultipleDependenciesFailed, } -impl ReadinessReason { +impl DependencyReason { pub(crate) fn label(self) -> &'static str { match self { Self::Ready => "ready", - Self::ShuttingDown => "shutting_down", Self::PostgresPoolTimeout => "postgres_pool_timeout", Self::PostgresPoolError => "postgres_pool_error", Self::PostgresQueryTimeout => "postgres_query_timeout", @@ -178,26 +246,18 @@ impl TimedOutcome { } } +/// One completed dependency evaluation. Every dependency always runs, so the +/// report carries three outcomes and never a partial shape. #[derive(Debug, Clone, Copy)] -pub(crate) struct ReadinessEvaluation { - postgres: Option>, - redis: Option>, - deletion_catalog: Option>, - pub(crate) reason: ReadinessReason, +pub(crate) struct DependencyReport { + postgres: TimedOutcome, + redis: TimedOutcome, + deletion_catalog: TimedOutcome, + pub(crate) reason: DependencyReason, total_duration: Duration, } -impl ReadinessEvaluation { - pub(crate) fn shutting_down() -> Self { - Self { - postgres: None, - redis: None, - deletion_catalog: None, - reason: ReadinessReason::ShuttingDown, - total_duration: Duration::ZERO, - } - } - +impl DependencyReport { #[cfg(test)] pub(crate) fn from_results( postgres: TimedOutcome, @@ -216,34 +276,24 @@ impl ReadinessEvaluation { ) -> Self { let reason = final_reason(postgres.outcome, redis.outcome, deletion_catalog.outcome); Self { - postgres: Some(postgres), - redis: Some(redis), - deletion_catalog: Some(deletion_catalog), + postgres, + redis, + deletion_catalog, reason, total_duration, } } - pub(crate) fn is_ready(self) -> bool { - self.reason == ReadinessReason::Ready - } - pub(crate) fn postgres_ready(self) -> bool { - self.postgres - .is_some_and(|result| result.outcome.is_success()) + self.postgres.outcome.is_success() } pub(crate) fn redis_ready(self) -> bool { - self.redis.is_some_and(|result| result.outcome.is_success()) + self.redis.outcome.is_success() } pub(crate) fn deletion_catalog_ready(self) -> bool { - self.deletion_catalog - .is_some_and(|result| result.outcome.is_success()) - } - - fn dependencies_ran(self) -> bool { - self.postgres.is_some() || self.redis.is_some() || self.deletion_catalog.is_some() + self.deletion_catalog.outcome.is_success() } } @@ -251,37 +301,39 @@ fn final_reason( postgres: PostgresOutcome, redis: RedisOutcome, deletion_catalog: DeletionCatalogOutcome, -) -> ReadinessReason { +) -> DependencyReason { let failure_count = usize::from(!postgres.is_success()) + usize::from(!redis.is_success()) + usize::from(!deletion_catalog.is_success()); if failure_count == 0 { - return ReadinessReason::Ready; + return DependencyReason::Ready; } if failure_count > 1 { let all_failures_are_timeouts = (postgres.is_success() || postgres.is_timeout()) && (redis.is_success() || redis.is_timeout()) && (deletion_catalog.is_success() || deletion_catalog.is_timeout()); return if all_failures_are_timeouts { - ReadinessReason::OverallTimeout + DependencyReason::OverallTimeout } else { - ReadinessReason::MultipleDependenciesFailed + DependencyReason::MultipleDependenciesFailed }; } match postgres { - PostgresOutcome::PoolTimeout => ReadinessReason::PostgresPoolTimeout, - PostgresOutcome::PoolError => ReadinessReason::PostgresPoolError, - PostgresOutcome::QueryTimeout => ReadinessReason::PostgresQueryTimeout, - PostgresOutcome::QueryError => ReadinessReason::PostgresQueryError, + PostgresOutcome::PoolTimeout => DependencyReason::PostgresPoolTimeout, + PostgresOutcome::PoolError => DependencyReason::PostgresPoolError, + PostgresOutcome::QueryTimeout => DependencyReason::PostgresQueryTimeout, + PostgresOutcome::QueryError => DependencyReason::PostgresQueryError, PostgresOutcome::Success => match redis { - RedisOutcome::PoolTimeout => ReadinessReason::RedisPoolTimeout, - RedisOutcome::PoolError => ReadinessReason::RedisPoolError, + RedisOutcome::PoolTimeout => DependencyReason::RedisPoolTimeout, + RedisOutcome::PoolError => DependencyReason::RedisPoolError, RedisOutcome::Success => match deletion_catalog { - DeletionCatalogOutcome::OperationTimeout => ReadinessReason::DeletionCatalogTimeout, - DeletionCatalogOutcome::OperationError => ReadinessReason::DeletionCatalogError, - DeletionCatalogOutcome::Success => ReadinessReason::Ready, + DeletionCatalogOutcome::OperationTimeout => { + DependencyReason::DeletionCatalogTimeout + } + DeletionCatalogOutcome::OperationError => DependencyReason::DeletionCatalogError, + DeletionCatalogOutcome::Success => DependencyReason::Ready, }, }, } @@ -303,7 +355,7 @@ async fn evaluate_dependencies( postgres: P, redis: R, deletion_catalog: D, -) -> ReadinessEvaluation +) -> DependencyReport where P: Future, R: Future, @@ -312,7 +364,7 @@ where let started_at = Instant::now(); let (postgres, redis, deletion_catalog) = tokio::join!(timed(postgres), timed(redis), timed(deletion_catalog),); - ReadinessEvaluation::for_dependencies(postgres, redis, deletion_catalog, started_at.elapsed()) + DependencyReport::for_dependencies(postgres, redis, deletion_catalog, started_at.elapsed()) } async fn redis_check(pool: &deadpool_redis::Pool, deadline: Instant) -> RedisOutcome { @@ -345,16 +397,16 @@ fn classify_deletion_catalog_result(result: buzz_db::Result<()>) -> DeletionCata } #[async_trait::async_trait] -pub(crate) trait ReadinessEvaluator: Send + Sync { - async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> ReadinessEvaluation; +pub(crate) trait DependencyEvaluator: Send + Sync { + async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> DependencyReport; } -struct ProductionReadinessEvaluator; +struct ProductionDependencyEvaluator; #[async_trait::async_trait] -impl ReadinessEvaluator for ProductionReadinessEvaluator { - async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> ReadinessEvaluation { - let deadline = Instant::now() + READINESS_TIMEOUT; +impl DependencyEvaluator for ProductionDependencyEvaluator { + async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> DependencyReport { + let deadline = Instant::now() + DEPENDENCY_TIMEOUT; evaluate_dependencies( async { db.readiness_check(deadline).await.into() }, redis_check(redis_pool, deadline), @@ -364,150 +416,280 @@ impl ReadinessEvaluator for ProductionReadinessEvaluator { } } +/// One completed evaluation and when it was observed. #[derive(Debug, Clone, Copy)] -pub(crate) struct ProbeTicket { - generation: u64, +struct DependencySample { + report: DependencyReport, + observed_at: Instant, } +/// What the cache can tell `/_status`. +/// +/// "No report yet" is a distinct state, not a fabricated healthy one, and a +/// report is always accompanied by its age: a cached verdict presented without +/// one would read as authoritative however long ago it was taken. #[derive(Debug, Clone, Copy)] -pub(crate) enum ProbeStart { - Evaluate(ProbeTicket), - ShuttingDown, +pub(crate) enum DependencySnapshot { + /// The sampler has not completed its first evaluation yet. + NotYetSampled, + Sampled { + report: DependencyReport, + age: Duration, + stale: bool, + }, } -#[derive(Debug, Default)] -struct PublicationState { - next_generation: u64, - latest_published_generation: u64, - shutdown_generation: Option, -} - -/// Serializes readiness result publication with terminal process shutdown. -pub(crate) struct ReadinessCoordinator { - state: Mutex, - evaluator: Arc, -} - -impl Default for ReadinessCoordinator { - fn default() -> Self { - Self { - state: Mutex::new(PublicationState::default()), - evaluator: Arc::new(ProductionReadinessEvaluator), - } - } -} - -impl ReadinessCoordinator { - #[cfg(test)] - pub(crate) fn with_evaluator(evaluator: Arc) -> Self { - Self { - state: Mutex::new(PublicationState::default()), - evaluator, - } - } - - fn lock_state(&self) -> MutexGuard<'_, PublicationState> { - self.state.lock().unwrap_or_else(PoisonError::into_inner) - } - - pub(crate) async fn evaluate( - &self, - db: &Db, - redis_pool: &deadpool_redis::Pool, - ) -> ReadinessEvaluation { - self.evaluator.evaluate(db, redis_pool).await - } - - /// Allocates a health-probe generation or records a truthful shutdown fast path. - pub(crate) fn begin_probe(&self) -> ProbeStart { - let mut state = self.lock_state(); - if state.shutdown_generation.is_some() { - let evaluation = ReadinessEvaluation::shutting_down(); - record_attempt_metrics(&evaluation, ReadinessReason::ShuttingDown); - record_overall_state(false); - return ProbeStart::ShuttingDown; - } - - state.next_generation = state.next_generation.saturating_add(1); - ProbeStart::Evaluate(ProbeTicket { - generation: state.next_generation, - }) - } - - /// Commits one completed health probe through the shared publication fence. - pub(crate) fn finish_probe( - &self, - ticket: ProbeTicket, - evaluation: ReadinessEvaluation, - ) -> ReadinessEvaluation { - let mut state = self.lock_state(); - if state.shutdown_generation.is_some() { - record_attempt_metrics(&evaluation, ReadinessReason::ShuttingDown); - return ReadinessEvaluation::shutting_down(); - } - - record_attempt_metrics(&evaluation, evaluation.reason); - if ticket.generation > state.latest_published_generation { - record_current_state(&evaluation); - state.latest_published_generation = ticket.generation; - } - evaluation - } - - /// Returns whether a compatibility/public readiness evaluation may start. - pub(crate) fn public_evaluation_allowed(&self) -> bool { - self.lock_state().shutdown_generation.is_none() - } - - /// Makes shutdown dominate a public request that was already in flight. - pub(crate) fn finish_public_evaluation( - &self, - evaluation: ReadinessEvaluation, - ) -> ReadinessEvaluation { - if self.lock_state().shutdown_generation.is_some() { - ReadinessEvaluation::shutting_down() - } else { - evaluation - } - } - - /// Commits terminal shutdown and immediately publishes overall not-ready. - pub(crate) fn begin_shutdown(&self) { - let mut state = self.lock_state(); - if state.shutdown_generation.is_none() { - let generation = state.next_generation.saturating_add(1); - state.shutdown_generation = Some(generation); - record_overall_state(false); - } - } -} - -fn record_attempt_metrics(evaluation: &ReadinessEvaluation, reason: ReadinessReason) { - metrics::counter!( - "buzz_readiness_checks_total", - "reason" => reason.label(), +/// Republish cadence for the completion-epoch gauge. +/// +/// The configured gauge idle timeout comes from `main.rs`; this returns a +/// bounded cadence that is strictly below that timeout and never slower than +/// the dependency sampler itself. +pub fn dependency_sample_completion_republish_interval(gauge_idle_timeout_secs: u64) -> Duration { + let idle_timeout_secs = gauge_idle_timeout_secs.max(1); + let refresh_secs = idle_timeout_secs.saturating_div(3).max(1); + let strict_upper_bound_secs = idle_timeout_secs.saturating_sub(1).max(1); + Duration::from_secs( + refresh_secs + .min(strict_upper_bound_secs) + .min(DEPENDENCY_SAMPLE_COMPLETION_REPUBLISH_MAX_INTERVAL.as_secs()), ) - .increment(1); +} - if !evaluation.dependencies_ran() { - return; +/// The per-pod owner of shared-dependency evaluation. +/// +/// The production [`run_dependency_sampler`] loop is the sole runtime owner of +/// [`Self::sample`], so at most one evaluation exists at a time and no request +/// path can start another. The companion completion publisher only reads the +/// stored epoch, while `/_status` reads [`Self::snapshot`]; neither touches a +/// dependency. +pub(crate) struct DependencyDiagnostics { + evaluator: Arc, + latest: Mutex>, + sample_completion_epoch_seconds: AtomicU64, + sample_completion_written: AtomicBool, +} + +impl Default for DependencyDiagnostics { + fn default() -> Self { + Self::with_evaluator(Arc::new(ProductionDependencyEvaluator)) + } +} + +impl DependencyDiagnostics { + pub(crate) fn with_evaluator(evaluator: Arc) -> Self { + Self { + evaluator, + latest: Mutex::new(None), + sample_completion_epoch_seconds: AtomicU64::new(0), + sample_completion_written: AtomicBool::new(false), + } } + /// Runs one bounded evaluation, publishes its telemetry, and replaces the + /// cached report. + /// + /// The completion timestamp is written last, once the cache already serves + /// this report, so the gauge can never describe a sample `/_status` is not + /// yet answering with. + pub(crate) async fn sample(&self, db: &Db, redis_pool: &deadpool_redis::Pool) { + let report = self.evaluator.evaluate(db, redis_pool).await; + record_dependency_report(&report); + let sample = DependencySample { + report, + observed_at: Instant::now(), + }; + *self.latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(sample); + self.record_dependency_sample_completion(SystemTime::now()); + } + + /// The latest completed evaluation with its age. Starts no dependency work. + pub(crate) fn snapshot(&self) -> DependencySnapshot { + let latest = *self.latest.lock().unwrap_or_else(PoisonError::into_inner); + match latest { + None => DependencySnapshot::NotYetSampled, + Some(sample) => { + let age = sample.observed_at.elapsed(); + DependencySnapshot::Sampled { + report: sample.report, + age, + stale: age > DEPENDENCY_SAMPLE_STALE_AFTER, + } + } + } + } + + fn record_dependency_sample_completion(&self, completed_at: SystemTime) { + let epoch_seconds = dependency_sample_completion_epoch_seconds(completed_at); + self.sample_completion_epoch_seconds + .store(epoch_seconds, Ordering::Release); + self.sample_completion_written + .store(true, Ordering::Release); + publish_dependency_sample_completion_metric(epoch_seconds); + } + + fn latest_sample_completion_epoch_seconds(&self) -> Option { + self.sample_completion_written + .load(Ordering::Acquire) + .then(|| self.sample_completion_epoch_seconds.load(Ordering::Acquire)) + } + + /// Re-emits the stored epoch, then checks it is still the stored one. + /// + /// A sample can complete and publish a newer epoch while this write is in + /// flight, and the metrics facade exposes only a bare `set`, so nothing + /// downstream rejects the older value once it lands last. Verifying after + /// the write — rather than before it — is what closes that window: a + /// republish that lost the race re-emits the newer epoch instead of + /// leaving the exported series moved backwards. Another pass costs another + /// completed sample, so this ends as soon as no completion is racing it. + fn republish_dependency_sample_completion(&self) { + let mut published = None; + while let Some(epoch_seconds) = self.latest_sample_completion_epoch_seconds() { + if published == Some(epoch_seconds) { + break; + } + publish_dependency_sample_completion_metric(epoch_seconds); + published = Some(epoch_seconds); + } + } +} + +/// Runs the per-pod dependency sampling loop until `cancel` fires. +/// +/// One loop, one fixed cadence, each evaluation awaited before the next tick is +/// taken, so this pod never has two evaluations in flight. `Skip` matches the +/// community revalidator: an evaluation that overruns its slot delays the next +/// cycle instead of queueing a catch-up burst into the dependency that was +/// already slow. The first tick fires immediately, so the not-yet-sampled +/// window is one evaluation long. +pub async fn run_dependency_sampler(state: Arc, cancel: CancellationToken) { + run_dependency_sampler_for_diagnostics( + Arc::clone(&state.dependency_diagnostics), + state.db.clone(), + state.redis_pool.clone(), + DEPENDENCY_SAMPLE_INTERVAL, + cancel, + ) + .await; +} + +/// Starts the dependency sampler and completion republisher together. +/// +/// Main calls this once at startup so sampler and publisher ownership lives at +/// one seam instead of being wired independently. +pub fn start_dependency_sampler_and_completion_publisher( + state: Arc, + republish_interval: Duration, +) { + let sampler_cancel = state.dependency_sampler_cancel.clone(); + tokio::spawn(run_dependency_sampler(Arc::clone(&state), sampler_cancel)); + + let publisher_cancel = state.dependency_completion_publisher_cancel.clone(); + tokio::spawn(run_dependency_sample_completion_publisher( + state, + republish_interval, + publisher_cancel, + )); +} + +async fn run_dependency_sampler_for_diagnostics( + diagnostics: Arc, + db: Db, + redis_pool: deadpool_redis::Pool, + sample_interval: Duration, + cancel: CancellationToken, +) { + let mut interval = tokio::time::interval(sample_interval.max(Duration::from_millis(1))); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; + _ = cancel.cancelled() => break, + _ = interval.tick() => { + diagnostics.sample(&db, &redis_pool).await; + } + } + } +} + +/// Re-emits the latest completion epoch so the gauge survives recorder idle +/// eviction even when no newer dependency sample completes. +pub async fn run_dependency_sample_completion_publisher( + state: Arc, + republish_interval: Duration, + cancel: CancellationToken, +) { + run_dependency_sample_completion_publisher_for_diagnostics( + Arc::clone(&state.dependency_diagnostics), + republish_interval, + cancel, + ) + .await; +} + +async fn run_dependency_sample_completion_publisher_for_diagnostics( + diagnostics: Arc, + republish_interval: Duration, + cancel: CancellationToken, +) { + let mut interval = tokio::time::interval(republish_interval.max(Duration::from_millis(1))); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; + _ = cancel.cancelled() => break, + _ = interval.tick() => diagnostics.republish_dependency_sample_completion(), + } + } +} + +/// Records one dependency evaluation. Counters and durations only — dependency +/// health has no publishable "current state" now that no probe consumes it; a +/// per-dependency gauge would read as an authoritative verdict on infrastructure +/// this pod only samples every [`DEPENDENCY_SAMPLE_INTERVAL`]. +fn record_dependency_report(report: &DependencyReport) { metrics::histogram!( "buzz_readiness_check_duration_seconds", "check" => "overall", ) - .record(evaluation.total_duration.as_secs_f64()); + .record(report.total_duration.as_secs_f64()); - if let Some(result) = evaluation.postgres { - record_dependency_attempt("postgres", result.outcome.label(), result.duration); - } - if let Some(result) = evaluation.redis { - record_dependency_attempt("redis", result.outcome.label(), result.duration); - } - if let Some(result) = evaluation.deletion_catalog { - record_dependency_attempt("deletion_catalog", result.outcome.label(), result.duration); - } + record_dependency_attempt( + "postgres", + report.postgres.outcome.label(), + report.postgres.duration, + ); + record_dependency_attempt("redis", report.redis.outcome.label(), report.redis.duration); + record_dependency_attempt( + "deletion_catalog", + report.deletion_catalog.outcome.label(), + report.deletion_catalog.duration, + ); +} + +/// Publishes the Unix time the cached report completed. +/// +/// A completion timestamp rather than an age, because age then belongs to the +/// query — `time() - buzz_readiness_dependency_sample_completed_timestamp_seconds` +/// — and grows on its own while this pod is wedged. A gauge carrying the age +/// needs a writer to advance it, so the one failure it most needs to expose, a +/// sampler that stopped running, is the one that would freeze it at its last +/// value and read as permanently fresh. A completed sample is the only thing +/// that may advance this epoch; the independent publisher only re-emits the +/// stored value so the series survives gauge idle-eviction. This keeps the +/// `buzz_storage_sweep_age_seconds` convention that absence means +/// "not yet sampled" rather than "fresh". +/// +fn dependency_sample_completion_epoch_seconds(completed_at: SystemTime) -> u64 { + completed_at + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn publish_dependency_sample_completion_metric(epoch_seconds: u64) { + metrics::gauge!("buzz_readiness_dependency_sample_completed_timestamp_seconds") + .set(epoch_seconds as f64); } fn record_dependency_attempt(dependency: &'static str, outcome: &'static str, duration: Duration) { @@ -524,35 +706,6 @@ fn record_dependency_attempt(dependency: &'static str, outcome: &'static str, du .record(duration.as_secs_f64()); } -fn record_current_state(evaluation: &ReadinessEvaluation) { - record_overall_state(evaluation.is_ready()); - if let Some(result) = evaluation.postgres { - record_dependency_state("postgres", result.outcome.is_success()); - } - if let Some(result) = evaluation.redis { - record_dependency_state("redis", result.outcome.is_success()); - } - if let Some(result) = evaluation.deletion_catalog { - record_dependency_state("deletion_catalog", result.outcome.is_success()); - } -} - -fn record_overall_state(ready: bool) { - metrics::gauge!("buzz_readiness_state", "check" => "overall").set(if ready { - 1.0 - } else { - 0.0 - }); -} - -fn record_dependency_state(dependency: &'static str, ready: bool) { - metrics::gauge!("buzz_readiness_state", "check" => dependency).set(if ready { - 1.0 - } else { - 0.0 - }); -} - #[cfg(test)] mod tests { use metrics_util::debugging::{DebugValue, DebuggingRecorder}; @@ -560,17 +713,15 @@ mod tests { use super::*; - fn ready_evaluation() -> ReadinessEvaluation { - ReadinessEvaluation::from_results( - TimedOutcome::new(PostgresOutcome::Success, Duration::from_millis(35)), - TimedOutcome::new(RedisOutcome::Success, Duration::from_millis(10)), - TimedOutcome::new(DeletionCatalogOutcome::Success, Duration::from_millis(20)), - Duration::from_millis(35), - ) - } + type Snapshot = Vec<( + CompositeKey, + Option, + Option, + DebugValue, + )>; - fn redis_failure_evaluation() -> ReadinessEvaluation { - ReadinessEvaluation::from_results( + fn redis_failure_report() -> DependencyReport { + DependencyReport::from_results( TimedOutcome::new(PostgresOutcome::Success, Duration::from_millis(35)), TimedOutcome::new(RedisOutcome::PoolTimeout, Duration::from_secs(2)), TimedOutcome::new(DeletionCatalogOutcome::Success, Duration::from_millis(20)), @@ -579,12 +730,7 @@ mod tests { } fn exact_metric<'a>( - snapshot: &'a [( - CompositeKey, - Option, - Option, - DebugValue, - )], + snapshot: &'a Snapshot, name: &str, labels: &[(&str, &str)], ) -> Option<&'a DebugValue> { @@ -601,15 +747,7 @@ mod tests { }) } - fn gauge_value( - snapshot: &[( - CompositeKey, - Option, - Option, - DebugValue, - )], - check: &str, - ) -> f64 { + fn gauge_value(snapshot: &Snapshot, check: &str) -> f64 { let value = exact_metric(snapshot, "buzz_readiness_state", &[("check", check)]) .expect("readiness gauge"); let DebugValue::Gauge(value) = value else { @@ -620,7 +758,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn evaluation_preserves_a_completed_check_when_another_times_out() { - let evaluation = evaluate_dependencies( + let report = evaluate_dependencies( async { tokio::time::sleep(Duration::from_millis(35)).await; PostgresOutcome::Success @@ -636,15 +774,9 @@ mod tests { ) .await; - assert_eq!(evaluation.reason, ReadinessReason::RedisPoolTimeout); - assert_eq!( - evaluation.postgres.map(|result| result.duration), - Some(Duration::from_millis(35)) - ); - assert_eq!( - evaluation.redis.map(|result| result.duration), - Some(Duration::from_secs(2)) - ); + assert_eq!(report.reason, DependencyReason::RedisPoolTimeout); + assert_eq!(report.postgres.duration, Duration::from_millis(35)); + assert_eq!(report.redis.duration, Duration::from_secs(2)); } #[test] @@ -655,7 +787,7 @@ mod tests { RedisOutcome::PoolTimeout, DeletionCatalogOutcome::Success, ), - ReadinessReason::OverallTimeout + DependencyReason::OverallTimeout ); } @@ -696,7 +828,7 @@ mod tests { .map(DeletionCatalogOutcome::label), ["success", "operation_timeout", "operation_error"] ); - assert_eq!(READINESS_RAW_SERIES_PER_POD, 99); + assert_eq!(READINESS_RAW_SERIES_PER_POD, 87); } #[test] @@ -712,110 +844,76 @@ mod tests { } #[test] - fn slow_older_failure_cannot_overwrite_newer_success_gauges() { - let coordinator = ReadinessCoordinator::default(); - let ProbeStart::Evaluate(slow_a) = coordinator.begin_probe() else { - panic!("serving probe A"); - }; - let ProbeStart::Evaluate(fast_b) = coordinator.begin_probe() else { - panic!("serving probe B"); - }; + fn completion_timestamp_republish_interval_stays_below_idle_timeout() { + assert_eq!( + dependency_sample_completion_republish_interval(900), + DEPENDENCY_SAMPLE_INTERVAL, + "default idle timeout should republish on the sampler cadence" + ); + assert_eq!( + dependency_sample_completion_republish_interval(15), + Duration::from_secs(5), + "the minimum configured idle timeout must still get multiple republishes" + ); + assert!(dependency_sample_completion_republish_interval(15) < Duration::from_secs(15)); + } + + /// The readiness gauge and counter use the same immutable reason sampled by + /// the private probe. A dependency evaluation — however bad — must never + /// move them, which is what let a shared outage deroute every replica at + /// once. + #[test] + fn readiness_telemetry_tracks_lifecycle_and_dependency_failure_never_moves_it() { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); metrics::with_local_recorder(&recorder, || { - coordinator.finish_probe(fast_b, ready_evaluation()); - coordinator.finish_probe(slow_a, redis_failure_evaluation()); + record_readiness_probe(ReadinessReason::Ready); + record_dependency_report(&redis_failure_report()); }); - let snapshot = snapshotter.snapshot().into_vec(); + let after_failure = snapshotter.snapshot().into_vec(); - assert_eq!(gauge_value(&snapshot, "overall"), 1.0); - assert_eq!(gauge_value(&snapshot, "redis"), 1.0); + assert_eq!(gauge_value(&after_failure, "overall"), 1.0); assert!(matches!( exact_metric( - &snapshot, + &after_failure, "buzz_readiness_checks_total", &[("reason", "ready")] ), Some(DebugValue::Counter(1)) )); - assert!(matches!( - exact_metric( - &snapshot, - "buzz_readiness_checks_total", - &[("reason", "redis_pool_timeout")] - ), - Some(DebugValue::Counter(1)) - )); - } - - #[test] - fn slow_older_success_cannot_overwrite_newer_failure_gauges() { - let coordinator = ReadinessCoordinator::default(); - let ProbeStart::Evaluate(slow_a) = coordinator.begin_probe() else { - panic!("serving probe A"); - }; - let ProbeStart::Evaluate(fast_b) = coordinator.begin_probe() else { - panic!("serving probe B"); - }; - let recorder = DebuggingRecorder::new(); - let snapshotter = recorder.snapshotter(); - - metrics::with_local_recorder(&recorder, || { - coordinator.finish_probe(fast_b, redis_failure_evaluation()); - coordinator.finish_probe(slow_a, ready_evaluation()); - }); - let snapshot = snapshotter.snapshot().into_vec(); - - assert_eq!(gauge_value(&snapshot, "overall"), 0.0); - assert_eq!(gauge_value(&snapshot, "postgres"), 1.0); - assert_eq!(gauge_value(&snapshot, "redis"), 0.0); - assert_eq!(gauge_value(&snapshot, "deletion_catalog"), 1.0); - } - - #[test] - fn shutdown_fast_path_preserves_dependency_state_and_histograms() { - let coordinator = ReadinessCoordinator::default(); - let recorder = DebuggingRecorder::new(); - let snapshotter = recorder.snapshotter(); - - metrics::with_local_recorder(&recorder, || { - let ProbeStart::Evaluate(ticket) = coordinator.begin_probe() else { - panic!("initial serving probe"); - }; - coordinator.finish_probe(ticket, ready_evaluation()); - coordinator.begin_shutdown(); - assert!(matches!( - coordinator.begin_probe(), - ProbeStart::ShuttingDown - )); - }); - let after = snapshotter.snapshot().into_vec(); - - for dependency in ["postgres", "redis", "deletion_catalog"] { - assert_eq!( - gauge_value(&after, dependency), - 1.0, - "shutdown must not fabricate {dependency} state" - ); - } - for check in ["overall", "postgres", "redis", "deletion_catalog"] { - assert!( - matches!( - exact_metric( - &after, - "buzz_readiness_check_duration_seconds", - &[("check", check)] - ), - Some(DebugValue::Histogram(values)) if values.len() == 1 + assert!( + matches!( + exact_metric( + &after_failure, + "buzz_readiness_dependency_checks_total", + &[("dependency", "redis"), ("outcome", "pool_timeout")] ), - "shutdown fast path must not add a {check} duration" + Some(DebugValue::Counter(1)) + ), + "dependency diagnostics must still be counted" + ); + for dependency in ["postgres", "redis", "deletion_catalog"] { + assert!( + exact_metric( + &after_failure, + "buzz_readiness_state", + &[("check", dependency)] + ) + .is_none(), + "{dependency} must not publish a readiness gauge" ); } - assert_eq!(gauge_value(&after, "overall"), 0.0); + + metrics::with_local_recorder(&recorder, || { + record_readiness_probe(ReadinessReason::ShuttingDown); + }); + let after_shutdown = snapshotter.snapshot().into_vec(); + + assert_eq!(gauge_value(&after_shutdown, "overall"), 0.0); assert!(matches!( exact_metric( - &after, + &after_shutdown, "buzz_readiness_checks_total", &[("reason", "shutting_down")] ), @@ -823,33 +921,630 @@ mod tests { )); } + /// A shutdown probe records no dependency attempt or latency sample: it did + /// not evaluate anything, and fabricating a sample would misreport the + /// dependency's real health during a rollout. #[test] - fn shutdown_dominates_an_in_flight_success_without_resurrecting_gauges() { - let coordinator = ReadinessCoordinator::default(); - let ProbeStart::Evaluate(ticket) = coordinator.begin_probe() else { - panic!("serving probe"); - }; + fn a_readiness_probe_never_records_dependency_attempts() { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); - let response = metrics::with_local_recorder(&recorder, || { - coordinator.begin_shutdown(); - coordinator.finish_probe(ticket, ready_evaluation()) + metrics::with_local_recorder(&recorder, || { + record_readiness_probe(ReadinessReason::Ready); + record_readiness_probe(ReadinessReason::ShuttingDown); }); let snapshot = snapshotter.snapshot().into_vec(); - assert_eq!(response.reason, ReadinessReason::ShuttingDown); - assert_eq!(gauge_value(&snapshot, "overall"), 0.0); + assert!(snapshot.iter().all(|(key, _, _, _)| { + key.key().name() != "buzz_readiness_dependency_checks_total" + && key.key().name() != "buzz_readiness_check_duration_seconds" + })); + } + + /// A `Db` and a Redis pool on a closed port. The scripted evaluators below + /// never touch either, so no connection is ever attempted; they exist only + /// to satisfy the production `sample` signature. + fn unreachable_dependencies() -> (Db, deadpool_redis::Pool) { + let pool = sqlx::PgPool::connect_lazy("postgres://127.0.0.1:1/buzz").expect("lazy pg pool"); + let redis_pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1") + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + (Db::from_pool(pool), redis_pool) + } + + struct FixedEvaluator(DependencyReport); + + #[async_trait::async_trait] + impl DependencyEvaluator for FixedEvaluator { + async fn evaluate(&self, _db: &Db, _redis_pool: &deadpool_redis::Pool) -> DependencyReport { + self.0 + } + } + + struct DelayedEvaluator { + report: DependencyReport, + started: Arc, + release: Arc, + } + + #[async_trait::async_trait] + impl DependencyEvaluator for DelayedEvaluator { + async fn evaluate(&self, _db: &Db, _redis_pool: &deadpool_redis::Pool) -> DependencyReport { + self.started.notify_one(); + self.release.notified().await; + self.report + } + } + + fn ready_report() -> DependencyReport { + DependencyReport::from_results( + TimedOutcome::new(PostgresOutcome::Success, Duration::from_millis(3)), + TimedOutcome::new(RedisOutcome::Success, Duration::from_millis(2)), + TimedOutcome::new(DeletionCatalogOutcome::Success, Duration::from_millis(1)), + Duration::from_millis(3), + ) + } + + fn ready_diagnostics() -> DependencyDiagnostics { + DependencyDiagnostics::with_evaluator(Arc::new(FixedEvaluator(ready_report()))) + } + + fn sampled(snapshot: DependencySnapshot) -> (Duration, bool) { + let DependencySnapshot::Sampled { age, stale, .. } = snapshot else { + panic!("a completed sample must be reported as sampled"); + }; + (age, stale) + } + + /// An operator reading `/_status` must be able to tell "nothing has been + /// sampled yet" from "this is current" from "this outlived the sampler". + /// A cached report presented without its age would read as authoritative + /// however old it is. + #[tokio::test(start_paused = true)] + async fn freshness_separates_not_yet_sampled_from_a_fresh_and_a_stale_report() { + let (db, redis_pool) = unreachable_dependencies(); + let diagnostics = ready_diagnostics(); + assert!( - exact_metric(&snapshot, "buzz_readiness_state", &[("check", "postgres")]).is_none() + matches!(diagnostics.snapshot(), DependencySnapshot::NotYetSampled), + "no evaluation has completed, so there is nothing to report" ); - assert!(matches!( - exact_metric( - &snapshot, - "buzz_readiness_dependency_checks_total", - &[("dependency", "postgres"), ("outcome", "success")] + + diagnostics.sample(&db, &redis_pool).await; + assert_eq!(sampled(diagnostics.snapshot()), (Duration::ZERO, false)); + + tokio::time::advance(DEPENDENCY_SAMPLE_INTERVAL).await; + assert_eq!( + sampled(diagnostics.snapshot()), + (DEPENDENCY_SAMPLE_INTERVAL, false), + "one cadence of age is the steady state, not staleness" + ); + + tokio::time::advance(DEPENDENCY_SAMPLE_INTERVAL + Duration::from_secs(1)).await; + let (age, stale) = sampled(diagnostics.snapshot()); + assert_eq!(age, DEPENDENCY_SAMPLE_INTERVAL * 2 + Duration::from_secs(1)); + assert!(stale, "a report that outlived two cadences missed a cycle"); + } + + /// The completion timestamp written since the previous snapshot, if any. + /// + /// `Snapshotter::snapshot` drains, so a window in which nothing wrote this + /// metric either omits the key entirely or, once registered, replays as + /// `0`. Zero is not a time any sample could have completed at, so folding + /// it into `None` keeps "nobody wrote this in that window" expressible — + /// the property a completion timestamp must have and an age cannot. + fn sample_completion_metric_write(snapshot: &Snapshot) -> Option { + exact_metric( + snapshot, + "buzz_readiness_dependency_sample_completed_timestamp_seconds", + &[], + ) + .map(|value| { + let DebugValue::Gauge(value) = value else { + panic!("the sample completion timestamp must be a gauge"); + }; + value.into_inner() as u64 + }) + .filter(|written| *written != 0) + } + + fn epoch_seconds_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the system clock is after the Unix epoch") + .as_secs() + } + + fn sample_completion_metric_value_from_scrape(scrape: &str) -> Option { + scrape.lines().find_map(|line| { + line.strip_prefix("buzz_readiness_dependency_sample_completed_timestamp_seconds ") + .and_then(|value| value.parse::().ok()) + .map(|value| value as u64) + }) + } + + fn sample_completion_metric_type_from_scrape(scrape: &str) -> Option<&str> { + scrape.lines().find_map(|line| { + line.strip_prefix( + "# TYPE buzz_readiness_dependency_sample_completed_timestamp_seconds ", + ) + }) + } + + /// Minimal model of Datadog OpenMetrics v2 with `send_monotonic_counter:true`. + /// + /// Gauge values are forwarded as-is. Counter values become per-scrape deltas + /// (`monotonic_count` / `.count`), so the exported sample itself is no + /// longer available to monitor queries. + fn datadog_openmetrics_v2_completion_epoch( + scrape: &str, + previous_counter_raw: &mut Option, + ) -> Option { + let raw = sample_completion_metric_value_from_scrape(scrape)?; + match sample_completion_metric_type_from_scrape(scrape) { + Some("gauge") => Some(raw), + Some("counter") => { + let transformed = previous_counter_raw + .map(|previous| raw.saturating_sub(previous)) + .unwrap_or(raw); + *previous_counter_raw = Some(raw); + Some(transformed) + } + Some(other) => panic!("unexpected metric type: {other}"), + None => panic!("missing sample completion metric type"), + } + } + + /// The scrape-side half of the same question. Freshness is published as the + /// Unix time the cached report completed, written only by a completed + /// sample, so the age belongs to the query + /// (`time() - buzz_readiness_dependency_sample_completed_timestamp_seconds`) + /// and grows on its own while this pod is wedged. A gauge carrying the age + /// instead would need a writer to advance it, so the one failure it most + /// needs to expose — a sampler that stopped — is the one that would freeze + /// it at its last value and read as permanently fresh. + #[test] + fn the_completion_timestamp_gauge_tracks_completed_samples() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .start_paused(true) + .build() + .expect("paused current-thread runtime"); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let (db, redis_pool) = unreachable_dependencies(); + let diagnostics = ready_diagnostics(); + + assert_eq!( + sample_completion_metric_write(&snapshotter.snapshot().into_vec()), + None, + "absence is the not-yet-sampled signal, not a fresh zero" + ); + + let before_first = epoch_seconds_now(); + diagnostics.sample(&db, &redis_pool).await; + let after_first = epoch_seconds_now(); + let first = sample_completion_metric_write(&snapshotter.snapshot().into_vec()) + .expect("a completed sample must publish when it completed"); + assert!( + (before_first..=after_first).contains(&first), + "{first} must be the wall time the sample completed, \ + not an age, and not a stale reading" + ); + + // Paused time: no task, timer, or aging loop can run here. + tokio::time::advance(DEPENDENCY_SAMPLE_STALE_AFTER + Duration::from_secs(1)).await; + assert_eq!( + sample_completion_metric_write(&snapshotter.snapshot().into_vec()), + None, + "only a completed sample may write the gauge, so the age a \ + query derives from it grows with no server-side writer" + ); + assert!( + sampled(diagnostics.snapshot()).1, + "the cached `/_status` age reports the same report as stale" + ); + + let before_second = epoch_seconds_now(); + diagnostics.sample(&db, &redis_pool).await; + let after_second = epoch_seconds_now(); + let second = sample_completion_metric_write(&snapshotter.snapshot().into_vec()) + .expect("the next completed sample republishes the timestamp"); + assert!( + (before_second..=after_second).contains(&second) && second >= first, + "{second} must re-anchor to the second completion, after {first}" + ); + assert!( + !sampled(diagnostics.snapshot()).1, + "a fresh completion clears staleness" + ); + }); + }); + } + + #[test] + fn openmetrics_v2_preserves_epoch_only_when_raw_prometheus_type_is_gauge() { + let (recorder, handle) = crate::metrics::readiness_test_recorder(); + let mut previous_counter_raw = None; + + assert_eq!( + datadog_openmetrics_v2_completion_epoch(&handle.render(), &mut previous_counter_raw), + None, + "absence before the first completion remains absence after transform" + ); + + let first = 4_750_000_001_u64; + metrics::with_local_recorder(&recorder, || { + publish_dependency_sample_completion_metric(first); + }); + let after_first_completion = handle.render(); + assert_eq!( + datadog_openmetrics_v2_completion_epoch( + &after_first_completion, + &mut previous_counter_raw, ), - Some(DebugValue::Counter(1)) - )); + Some(first), + "after first completion the transformed value must be the completion epoch" + ); + + // Same raw scrape value after idle timeout: sampler produced no new completion. + assert_eq!( + datadog_openmetrics_v2_completion_epoch( + &after_first_completion, + &mut previous_counter_raw, + ), + Some(first), + "idle-timeout survival still must preserve the full epoch" + ); + + let second = 4_750_000_005_u64; + metrics::with_local_recorder(&recorder, || { + publish_dependency_sample_completion_metric(second); + }); + let after_later_completion = handle.render(); + assert_eq!( + datadog_openmetrics_v2_completion_epoch( + &after_later_completion, + &mut previous_counter_raw, + ), + Some(second), + "a later completion must transform to the new full epoch" + ); + + // Same raw value again while only the sampler is stopped. + assert_eq!( + datadog_openmetrics_v2_completion_epoch( + &after_later_completion, + &mut previous_counter_raw, + ), + Some(second), + "sampler stoppage must not collapse the epoch to a delta" + ); + } + + async fn wait_for_sample_completion_scrape_value( + handle: &metrics_exporter_prometheus::PrometheusHandle, + predicate: impl Fn(u64) -> bool, + ) -> u64 { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if let Some(value) = sample_completion_metric_value_from_scrape(&handle.render()) { + if predicate(value) { + return value; + } + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for completion timestamp scrape value" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + #[test] + fn dependency_runtime_retains_the_completion_timestamp_when_only_sampler_stops() { + let timeout = Duration::from_secs(1); + let republish_interval = Duration::from_millis(100); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .start_paused(true) + .build() + .expect("current-thread runtime"); + let (recorder, handle) = + crate::metrics::readiness_test_recorder_with_idle_timeout(timeout.as_secs()); + + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let mut state = crate::state::tests::test_state_with_database_url( + "postgres://127.0.0.1:1/buzz", + ) + .await; + let sample_started = Arc::new(tokio::sync::Notify::new()); + let release_sample = Arc::new(tokio::sync::Notify::new()); + Arc::get_mut(&mut state) + .expect("sole readiness state") + .set_dependency_evaluator(Arc::new(DelayedEvaluator { + report: ready_report(), + started: Arc::clone(&sample_started), + release: Arc::clone(&release_sample), + })); + + start_dependency_sampler_and_completion_publisher( + Arc::clone(&state), + republish_interval, + ); + + tokio::time::timeout(Duration::from_secs(5), sample_started.notified()) + .await + .expect("dependency sampler must start its first sample within 5 seconds"); + for _ in 0..3 { + tokio::time::advance(republish_interval).await; + tokio::task::yield_now().await; + } + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + None, + "republisher ticks must not fabricate an epoch before the first completion" + ); + + release_sample.notify_one(); + + let first = + wait_for_sample_completion_scrape_value(&handle, |value| value > 0).await; + + state.dependency_sampler_cancel.cancel(); + tokio::time::resume(); + + tokio::time::sleep(timeout + Duration::from_millis(200)).await; + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + Some(first), + "publisher must keep the completion epoch exported after idle timeout" + ); + + tokio::time::sleep(timeout + Duration::from_millis(200)).await; + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + Some(first), + "with no sampler activity, publisher must keep exporting the stored epoch" + ); + + state.dependency_completion_publisher_cancel.cancel(); + }); + }); + } + + /// How long a park step waits before it is a failure rather than a hang. + const PARK_SIGNAL_TIMEOUT: Duration = Duration::from_secs(10); + + /// A completion-epoch gauge write that can be stopped at the recorder + /// boundary — the last instruction of a publish, after the writer has + /// already read the epoch it is publishing. + /// + /// Arming names one epoch value and fires once, so the sampler's own write + /// passes straight through and only the republish under test parks. + struct PublishPark { + armed_for: Mutex>, + parked_tx: std::sync::mpsc::SyncSender<()>, + parked_rx: Mutex>, + release_tx: std::sync::mpsc::SyncSender<()>, + release_rx: Mutex>, + } + + impl PublishPark { + fn new() -> Self { + let (parked_tx, parked_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + Self { + armed_for: Mutex::new(None), + parked_tx, + parked_rx: Mutex::new(parked_rx), + release_tx, + release_rx: Mutex::new(release_rx), + } + } + + fn arm(&self, epoch_seconds: u64) { + *self.armed_for.lock().expect("park state") = Some(epoch_seconds); + } + + fn park_if_armed(&self, epoch_seconds: u64) { + { + let mut armed = self.armed_for.lock().expect("park state"); + if *armed != Some(epoch_seconds) { + return; + } + *armed = None; + } + self.parked_tx.send(()).expect("announce the parked write"); + self.release_rx + .lock() + .expect("release channel") + .recv_timeout(PARK_SIGNAL_TIMEOUT) + .expect("the parked write must be released"); + } + + fn wait_until_parked(&self) { + self.parked_rx + .lock() + .expect("park channel") + .recv_timeout(PARK_SIGNAL_TIMEOUT) + .expect("the armed write must reach the recorder"); + } + + fn release(&self) { + self.release_tx.send(()).expect("release the parked write"); + } + } + + /// Delegates to the real Prometheus recorder, with the completion-epoch + /// gauge routed through [`PublishPark`]. + struct ParkingRecorder { + inner: metrics_exporter_prometheus::PrometheusRecorder, + park: Arc, + } + + struct ParkingGauge { + inner: metrics::Gauge, + park: Arc, + } + + impl metrics::GaugeFn for ParkingGauge { + fn increment(&self, value: f64) { + self.inner.increment(value); + } + + fn decrement(&self, value: f64) { + self.inner.decrement(value); + } + + fn set(&self, value: f64) { + self.park.park_if_armed(value as u64); + self.inner.set(value); + } + } + + impl metrics::Recorder for ParkingRecorder { + fn describe_counter( + &self, + key: metrics::KeyName, + unit: Option, + description: metrics::SharedString, + ) { + self.inner.describe_counter(key, unit, description); + } + + fn describe_gauge( + &self, + key: metrics::KeyName, + unit: Option, + description: metrics::SharedString, + ) { + self.inner.describe_gauge(key, unit, description); + } + + fn describe_histogram( + &self, + key: metrics::KeyName, + unit: Option, + description: metrics::SharedString, + ) { + self.inner.describe_histogram(key, unit, description); + } + + fn register_counter( + &self, + key: &metrics::Key, + metadata: &metrics::Metadata<'_>, + ) -> metrics::Counter { + self.inner.register_counter(key, metadata) + } + + fn register_gauge( + &self, + key: &metrics::Key, + metadata: &metrics::Metadata<'_>, + ) -> metrics::Gauge { + let gauge = self.inner.register_gauge(key, metadata); + if key.name() == "buzz_readiness_dependency_sample_completed_timestamp_seconds" { + metrics::Gauge::from_arc(Arc::new(ParkingGauge { + inner: gauge, + park: Arc::clone(&self.park), + })) + } else { + gauge + } + } + + fn register_histogram( + &self, + key: &metrics::Key, + metadata: &metrics::Metadata<'_>, + ) -> metrics::Histogram { + self.inner.register_histogram(key, metadata) + } + } + + /// Two tasks write this gauge — the sampler, which owns the epoch, and the + /// idle-refresh publisher, which may only re-emit it — and the metrics + /// facade offers a bare `set` with no compare-and-set to lean on. So a + /// republish that read the stored epoch before a sample completed can still + /// be inside its own write when the newer epoch lands, and plain last-write + /// -wins would leave the exported series moved backwards until the next + /// republish tick. + /// + /// This parks the republish at the recorder, the last point in its write, + /// completes a newer sample behind it — which must not be blocked by the + /// parked republish — and only then releases it. The scrape must report the + /// newer completion. Times are injected so the two epochs are exact and the + /// interleaving does not depend on the wall clock. + #[test] + fn a_republish_racing_a_completion_cannot_move_the_exported_epoch_backwards() { + const EARLIER_EPOCH_SECONDS: u64 = 1_700_000_000; + const LATER_EPOCH_SECONDS: u64 = 1_700_000_030; + + let (prometheus, handle) = crate::metrics::readiness_test_recorder(); + let park = Arc::new(PublishPark::new()); + let recorder = Arc::new(ParkingRecorder { + inner: prometheus, + park: Arc::clone(&park), + }); + let diagnostics = Arc::new(ready_diagnostics()); + + metrics::with_local_recorder(recorder.as_ref(), || { + diagnostics.record_dependency_sample_completion( + SystemTime::UNIX_EPOCH + Duration::from_secs(EARLIER_EPOCH_SECONDS), + ); + }); + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + Some(EARLIER_EPOCH_SECONDS), + "the first completion owns the epoch" + ); + + park.arm(EARLIER_EPOCH_SECONDS); + let republisher = std::thread::spawn({ + let recorder = Arc::clone(&recorder); + let diagnostics = Arc::clone(&diagnostics); + move || { + metrics::with_local_recorder(recorder.as_ref(), || { + diagnostics.republish_dependency_sample_completion(); + }); + } + }); + + park.wait_until_parked(); + + metrics::with_local_recorder(recorder.as_ref(), || { + diagnostics.record_dependency_sample_completion( + SystemTime::UNIX_EPOCH + Duration::from_secs(LATER_EPOCH_SECONDS), + ); + }); + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + Some(LATER_EPOCH_SECONDS), + "a completed sample must publish its epoch while a republish is still in flight" + ); + + park.release(); + republisher.join().expect("republish thread"); + + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + Some(LATER_EPOCH_SECONDS), + "a republish that lost the race must not re-export the epoch it read \ + before the newer sample completed" + ); + } + + #[test] + fn readiness_reason_labels_are_the_closed_lifecycle_set() { + assert_eq!( + [ReadinessReason::Ready, ReadinessReason::ShuttingDown].map(ReadinessReason::label), + READINESS_REASON_LABELS + ); } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 50af16d644..9993ea4c8a 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -25,7 +25,7 @@ use crate::connection::handle_connection; use crate::metrics::track_metrics; use crate::nip11::{nip11_document, relay_info_handler}; use crate::nip_fi_http::http_denial; -use crate::readiness::{self, ReadinessEvaluation, ReadinessReason}; +use crate::readiness::{self, DependencySnapshot, ReadinessReason}; use crate::state::AppState; // ── NIP-FI fail-closed assertion guard ─────────────────────────────────────── @@ -317,6 +317,11 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities", get(api::operator::list_owned_communities).post(api::operator::provision_community), ) + .route( + "/operator/listener/pubkeys", + post(api::operator::register_listener_pubkeys) + .delete(api::operator::remove_listener_pubkeys), + ) .route( "/operator/communities/archive", post(api::operator::archive_community), @@ -325,6 +330,10 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities/unarchive", post(api::operator::unarchive_community), ) + .route( + "/operator/communities/delete", + post(api::operator::delete_community), + ) .route( "/operator/communities/availability", get(api::operator::community_availability), @@ -640,60 +649,48 @@ async fn liveness_handler() -> impl IntoResponse { (StatusCode::OK, "ok") } -/// Compatibility endpoint on the public listener. It evaluates dependencies -/// and preserves the existing response contract but never records rollout -/// telemetry. +/// Compatibility endpoint on the public listener. Same lifecycle answer as the +/// probe, but public traffic must never move rollout telemetry. async fn public_readiness_handler(State(state): State>) -> impl IntoResponse { - if !state.readiness.public_evaluation_allowed() { - return readiness_response(ReadinessEvaluation::shutting_down(), false); - } - - let evaluation = state.readiness.evaluate(&state.db, &state.redis_pool).await; - let evaluation = state.readiness.finish_public_evaluation(evaluation); - readiness_response(evaluation, false) + readiness_response(readiness_reason(&state)) } -/// Kubernetes health-listener endpoint. All rollout metrics flow through the -/// process-owned coordinator so shutdown and probe generations are ordered. +/// Kubernetes health-listener endpoint — the only source of rollout readiness +/// telemetry. Its single lifecycle sample determines every observable result +/// of this request: counter, gauge, HTTP status, and body. async fn kubernetes_readiness_handler(State(state): State>) -> impl IntoResponse { - let readiness::ProbeStart::Evaluate(ticket) = state.readiness.begin_probe() else { - return readiness_response(ReadinessEvaluation::shutting_down(), true); - }; - - let evaluation = state.readiness.evaluate(&state.db, &state.redis_pool).await; - let evaluation = state.readiness.finish_probe(ticket, evaluation); - readiness_response(evaluation, true) + let reason = readiness_reason(&state); + readiness::record_readiness_probe(reason); + readiness_response(reason) } -fn readiness_response( - evaluation: ReadinessEvaluation, - include_reason: bool, -) -> axum::response::Response { - if evaluation.reason == ReadinessReason::ShuttingDown { - return ( +/// Readiness answers for this process only. +/// +/// Shared Postgres, Redis, and deletion-catalog health used to gate this +/// answer, which meant one shared outage removed every replica from the load +/// balancer simultaneously and left a reconnect burst with nowhere to land. +/// Those checks now report on `/_status`. The health listener does not bind +/// until the database, migrations, Redis, and pub/sub are up (see +/// `buzz-relay/src/main.rs`), so an answering process is a booted process and +/// needs no separate startup state. +fn readiness_reason(state: &AppState) -> ReadinessReason { + if state.shutting_down.load(Ordering::Acquire) { + ReadinessReason::ShuttingDown + } else { + ReadinessReason::Ready + } +} + +fn readiness_response(reason: ReadinessReason) -> axum::response::Response { + match reason { + ReadinessReason::Ready => { + (StatusCode::OK, Json(json!({"status": "ready"}))).into_response() + } + ReadinessReason::ShuttingDown => ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({"status": "shutting_down"})), ) - .into_response(); - } - - let pg_ok = evaluation.postgres_ready(); - let redis_ok = evaluation.redis_ready(); - let deletion_catalog_ok = evaluation.deletion_catalog_ready(); - - if evaluation.is_ready() { - (StatusCode::OK, Json(json!({"status": "ready"}))).into_response() - } else { - let mut payload = json!({ - "status": "not_ready", - "postgres": pg_ok, - "redis": redis_ok, - "deletion_catalog": deletion_catalog_ok - }); - if include_reason { - payload["reason"] = json!(evaluation.reason.label()); - } - (StatusCode::SERVICE_UNAVAILABLE, Json(payload)).into_response() + .into_response(), } } @@ -710,9 +707,44 @@ fn status_payload(uptime_secs: u64) -> serde_json::Value { }) } -/// Status endpoint — service name, version, uptime, and intrinsic build identity. +/// The dependency fields the readiness body used to carry, now a diagnostic +/// read of the sampler's cache. +/// +/// `sample` is always present so a reader can never mistake a cached verdict +/// for a current one: `not_yet_sampled` before the sampler's first evaluation +/// completes, then `fresh` or `stale` alongside the report's own age. +fn dependency_diagnostics_payload(snapshot: DependencySnapshot) -> serde_json::Value { + let interval_seconds = readiness::DEPENDENCY_SAMPLE_INTERVAL.as_secs(); + match snapshot { + DependencySnapshot::NotYetSampled => json!({ + "sample": "not_yet_sampled", + "sample_interval_seconds": interval_seconds, + }), + DependencySnapshot::Sampled { report, age, stale } => json!({ + "sample": if stale { "stale" } else { "fresh" }, + "sample_interval_seconds": interval_seconds, + "sample_age_seconds": age.as_secs(), + "postgres": report.postgres_ready(), + "redis": report.redis_ready(), + "deletion_catalog": report.deletion_catalog_ready(), + "reason": report.reason.label(), + }), + } +} + +/// Status endpoint — service name, version, uptime, intrinsic build identity, +/// and the cached shared-dependency diagnostics. +/// +/// Health-listener only, and never wired to a Kubernetes probe: this is where +/// an operator looks to tell "the pod is fine, Postgres is not" apart from "the +/// pod is broken". It reads only what +/// [`readiness::run_dependency_sampler`] has already cached, so however often +/// it is polled it adds no load to the shared pools. async fn status_handler(State(state): State>) -> impl IntoResponse { - Json(status_payload(state.started_at.elapsed().as_secs())) + let mut payload = status_payload(state.started_at.elapsed().as_secs()); + payload["dependencies"] = + dependency_diagnostics_payload(state.dependency_diagnostics.snapshot()); + Json(payload) } /// `/_mesh` — live mesh status: peer table, connection/phi state, per-peer @@ -756,7 +788,6 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { #[cfg(test)] mod tests { use std::collections::VecDeque; - use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; use std::sync::{Mutex, PoisonError}; use std::time::Duration; @@ -765,91 +796,63 @@ mod tests { use opentelemetry::trace::TracerProvider as _; use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; use tokio::net::TcpListener; - use tokio::sync::{mpsc, Notify}; + use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::Message}; use tower::ServiceBuilder; use tracing::Instrument as _; use tracing_subscriber::prelude::*; use super::*; + use crate::readiness::DependencyReport; - struct ScriptedReadinessEvaluator { - evaluations: Mutex>, + struct ScriptedDependencyEvaluator { + evaluations: Mutex>, + evaluations_started: std::sync::atomic::AtomicUsize, } - impl ScriptedReadinessEvaluator { - fn new(evaluations: impl IntoIterator) -> Self { + impl ScriptedDependencyEvaluator { + fn new(evaluations: impl IntoIterator) -> Self { Self { evaluations: Mutex::new(evaluations.into_iter().collect()), + evaluations_started: std::sync::atomic::AtomicUsize::new(0), } } - fn push(&self, evaluation: ReadinessEvaluation) { + fn push(&self, evaluation: DependencyReport) { self.evaluations .lock() .unwrap_or_else(PoisonError::into_inner) .push_back(evaluation); } + + /// How many times a caller actually reached the shared dependencies. + fn evaluations_started(&self) -> usize { + self.evaluations_started.load(Ordering::SeqCst) + } } #[async_trait::async_trait] - impl readiness::ReadinessEvaluator for ScriptedReadinessEvaluator { + impl readiness::DependencyEvaluator for ScriptedDependencyEvaluator { async fn evaluate( &self, _db: &buzz_db::Db, _redis_pool: &deadpool_redis::Pool, - ) -> ReadinessEvaluation { + ) -> DependencyReport { + self.evaluations_started.fetch_add(1, Ordering::SeqCst); self.evaluations .lock() .unwrap_or_else(PoisonError::into_inner) .pop_front() - .expect("scripted readiness evaluation") + .expect("scripted dependency report") } } - struct BarrierReadinessEvaluator { - calls: AtomicUsize, - first_started: Notify, - release_first: Notify, - first: ReadinessEvaluation, - second: ReadinessEvaluation, - } - - impl BarrierReadinessEvaluator { - fn new(first: ReadinessEvaluation, second: ReadinessEvaluation) -> Self { - Self { - calls: AtomicUsize::new(0), - first_started: Notify::new(), - release_first: Notify::new(), - first, - second, - } - } - } - - #[async_trait::async_trait] - impl readiness::ReadinessEvaluator for BarrierReadinessEvaluator { - async fn evaluate( - &self, - _db: &buzz_db::Db, - _redis_pool: &deadpool_redis::Pool, - ) -> ReadinessEvaluation { - if self.calls.fetch_add(1, AtomicOrdering::SeqCst) == 0 { - self.first_started.notify_waiters(); - self.release_first.notified().await; - self.first - } else { - self.second - } - } - } - - fn readiness_evaluation( + fn dependency_report( postgres: readiness::PostgresOutcome, redis: readiness::RedisOutcome, deletion_catalog: readiness::DeletionCatalogOutcome, - ) -> ReadinessEvaluation { - ReadinessEvaluation::from_results( + ) -> DependencyReport { + DependencyReport::from_results( readiness::TimedOutcome::new(postgres, Duration::from_millis(35)), readiness::TimedOutcome::new(redis, Duration::from_millis(20)), readiness::TimedOutcome::new(deletion_catalog, Duration::from_millis(15)), @@ -857,8 +860,8 @@ mod tests { ) } - fn ready_evaluation() -> ReadinessEvaluation { - readiness_evaluation( + fn ready_report() -> DependencyReport { + dependency_report( readiness::PostgresOutcome::Success, readiness::RedisOutcome::Success, readiness::DeletionCatalogOutcome::Success, @@ -940,10 +943,19 @@ mod tests { Arc::new(state) } - async fn readiness_state(evaluator: Arc) -> Arc { + async fn readiness_state(evaluator: Arc) -> Arc { + let mut state = unreachable_dependency_state().await; + Arc::get_mut(&mut state) + .expect("sole reference") + .set_dependency_evaluator(evaluator); + state + } + + /// A relay process whose shared Postgres and Redis are both unroutable. + async fn unreachable_dependency_state() -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; - config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); + config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); // sadscan:disable np.postgres.1 -- local test-only credentials on a closed port config.redis_url = "redis://127.0.0.1:1".to_string(); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); let db = buzz_db::Db::from_pool(pool.clone()); @@ -963,7 +975,7 @@ mod tests { buzz_workflow::WorkflowConfig::default(), )); let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (mut state, _audit_shutdown) = AppState::new( + let (state, _audit_shutdown) = AppState::new( config, db, redis_pool, @@ -975,7 +987,6 @@ mod tests { nostr::Keys::generate(), media_storage, ); - state.set_readiness_evaluator(evaluator); Arc::new(state) } @@ -996,6 +1007,300 @@ mod tests { (status, payload) } + async fn status_request(router: Router) -> (StatusCode, serde_json::Value) { + let response = router + .oneshot( + Request::get("/_status") + .body(Body::empty()) + .expect("status request"), + ) + .await + .expect("status response"); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("status response body"); + let payload = serde_json::from_slice(&body).expect("status JSON"); + (status, payload) + } + + /// The incident regression. Shared Postgres and Redis pressure took every + /// replica out of the load balancer at once, so a reconnect burst had + /// nowhere to land. Readiness answers for this process only: a pod whose + /// shared dependencies are unreachable is still a healthy pod, and only a + /// local shutdown may withdraw it. + #[tokio::test] + async fn readiness_answers_from_local_lifecycle_not_shared_dependencies() { + let state = unreachable_dependency_state().await; + + for router in [ + build_health_router(state.clone()), + build_router(state.clone()), + ] { + assert_eq!( + readiness_request(router).await, + (StatusCode::OK, json!({"status": "ready"})), + "unreachable shared dependencies must not deroute a healthy pod" + ); + } + + state.begin_shutdown(); + + for router in [ + build_health_router(state.clone()), + build_router(state.clone()), + ] { + assert_eq!( + readiness_request(router).await, + ( + StatusCode::SERVICE_UNAVAILABLE, + json!({"status": "shutting_down"}) + ), + "a draining pod must still withdraw itself" + ); + } + } + + /// Dependency health did not disappear with the probe — it moved to the + /// diagnostic endpoint, which is never wired to a Kubernetes probe. The + /// fields the readiness body used to carry are still there, now qualified + /// by how old the sample behind them is. + #[tokio::test] + async fn status_retains_dependency_diagnostics_off_the_probe_path() { + let evaluator = Arc::new(ScriptedDependencyEvaluator::new([dependency_report( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::Success, + )])); + let state = readiness_state(evaluator).await; + state + .dependency_diagnostics + .sample(&state.db, &state.redis_pool) + .await; + + let (status, payload) = status_request(build_health_router(state)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["service"], "buzz-relay"); + assert_eq!( + payload["dependencies"], + json!({ + "sample": "fresh", + "sample_interval_seconds": 30, + "sample_age_seconds": 0, + "postgres": true, + "redis": false, + "deletion_catalog": true, + "reason": "redis_pool_timeout" + }) + ); + } + + /// `/_status` is an operator diagnostic, not a dependency driver. Evaluating + /// per request let operator curiosity — and anything that polls the + /// endpoint — add Postgres, Redis, and deletion-catalog work to a shared + /// dependency that is already under pressure, with no bound on how many + /// evaluations could be in flight at once. The endpoint reads the cached + /// report the per-pod sampler owns and starts nothing. + #[tokio::test] + async fn status_reads_the_cached_report_and_never_starts_a_dependency_check() { + let evaluator = Arc::new(ScriptedDependencyEvaluator::new([ready_report()])); + let state = readiness_state(evaluator.clone()).await; + let health = build_health_router(state.clone()); + + for _ in 0..3 { + let (status, payload) = status_request(health.clone()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + payload["dependencies"], + json!({ + "sample": "not_yet_sampled", + "sample_interval_seconds": 30, + }), + "before the first sample completes there is no report to serve" + ); + } + + assert_eq!( + evaluator.evaluations_started(), + 0, + "a status request must never reach the shared dependencies" + ); + + // Once the sampler has a report, and only then, the endpoint serves it. + state + .dependency_diagnostics + .sample(&state.db, &state.redis_pool) + .await; + let (status, payload) = status_request(health).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["dependencies"]["sample"], json!("fresh")); + assert_eq!(payload["dependencies"]["reason"], json!("ready")); + assert_eq!( + evaluator.evaluations_started(), + 1, + "the sampler is the only caller that evaluates" + ); + } + + /// Always answers, recording how many evaluations started and the peak + /// number in flight, so a loop test can assert cadence and single-flight + /// without a scripted queue to exhaust. + struct ObservedDependencyEvaluator { + report: DependencyReport, + duration: Duration, + started: std::sync::atomic::AtomicUsize, + in_flight: std::sync::atomic::AtomicUsize, + peak_in_flight: std::sync::atomic::AtomicUsize, + } + + impl ObservedDependencyEvaluator { + fn new(report: DependencyReport, duration: Duration) -> Self { + Self { + report, + duration, + started: std::sync::atomic::AtomicUsize::new(0), + in_flight: std::sync::atomic::AtomicUsize::new(0), + peak_in_flight: std::sync::atomic::AtomicUsize::new(0), + } + } + + fn started(&self) -> usize { + self.started.load(Ordering::SeqCst) + } + + fn peak_in_flight(&self) -> usize { + self.peak_in_flight.load(Ordering::SeqCst) + } + } + + #[async_trait::async_trait] + impl readiness::DependencyEvaluator for ObservedDependencyEvaluator { + async fn evaluate( + &self, + _db: &buzz_db::Db, + _redis_pool: &deadpool_redis::Pool, + ) -> DependencyReport { + self.started.fetch_add(1, Ordering::SeqCst); + let in_flight = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1; + self.peak_in_flight.fetch_max(in_flight, Ordering::SeqCst); + if !self.duration.is_zero() { + tokio::time::sleep(self.duration).await; + } + self.in_flight.fetch_sub(1, Ordering::SeqCst); + self.report + } + } + + /// Runs the production sampler on a paused clock for `window`, then cancels + /// it and returns the evaluator's observations. + async fn run_sampler_for( + evaluator: Arc, + window: Duration, + ) -> Arc { + let state = readiness_state(evaluator.clone()).await; + let cancel = state.dependency_sampler_cancel.clone(); + let sampler = tokio::spawn(readiness::run_dependency_sampler( + state.clone(), + cancel.clone(), + )); + tokio::time::sleep(window).await; + cancel.cancel(); + sampler.await.expect("sampler task"); + evaluator + } + + /// Dependency telemetry must keep describing the shared dependencies whether + /// or not anyone reads `/_status`. Request-driven evaluation meant a quiet + /// endpoint produced a flat dashboard during the exact outage it existed to + /// explain. + #[test] + fn the_dependency_sampler_emits_telemetry_without_any_request() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .start_paused(true) + .build() + .expect("paused current-thread runtime"); + let (recorder, handle) = crate::metrics::readiness_test_recorder(); + + metrics::with_local_recorder(&recorder, || { + crate::metrics::describe_readiness_metrics(); + runtime.block_on(async { + // The first tick fires immediately, then one per cadence. + let evaluator = run_sampler_for( + Arc::new(ObservedDependencyEvaluator::new( + ready_report(), + Duration::ZERO, + )), + readiness::DEPENDENCY_SAMPLE_INTERVAL * 3 + Duration::from_secs(1), + ) + .await; + + assert_eq!(evaluator.started(), 4); + let rendered = handle.render(); + assert_eq!( + metric_value( + &rendered, + "buzz_readiness_dependency_checks_total{dependency=\"postgres\",outcome=\"success\"}" + ), + 4.0, + "every sampling cycle must publish its dependency outcomes" + ); + assert_eq!( + metric_value( + &rendered, + "buzz_readiness_check_duration_seconds_count{check=\"overall\"}" + ), + 4.0 + ); + assert!( + !rendered.contains("buzz_readiness_checks_total{"), + "sampling is not a readiness probe and must not move probe telemetry" + ); + }); + }); + } + + /// The bound that replaces the request-driven design's lack of one. The + /// sampler awaits each evaluation before taking the next tick, so a + /// dependency slower than the cadence lowers the sampling rate instead of + /// stacking probes on top of the slowness that caused it. + #[test] + fn the_dependency_sampler_never_runs_two_evaluations_at_once() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .start_paused(true) + .build() + .expect("paused current-thread runtime"); + + runtime.block_on(async { + let slow = readiness::DEPENDENCY_SAMPLE_INTERVAL * 2 + Duration::from_secs(1); + let window = readiness::DEPENDENCY_SAMPLE_INTERVAL * 10; + let evaluator = run_sampler_for( + Arc::new(ObservedDependencyEvaluator::new(ready_report(), slow)), + window, + ) + .await; + + assert_eq!( + evaluator.peak_in_flight(), + 1, + "the sampler must own the only in-flight evaluation" + ); + // 61-second evaluations run back to back from t=0 in a 300-second + // window: five, not the ten ticks the cadence offered. An + // evaluation started per tick regardless of the last one would + // have started ten and held several open at once. + assert_eq!(evaluator.started(), 5); + assert!( + evaluator.started() + < (window.as_secs() / readiness::DEPENDENCY_SAMPLE_INTERVAL.as_secs()) as usize, + "a slow dependency must throttle sampling, not be sampled on every tick" + ); + }); + } + fn readiness_metric_lines(rendered: &str) -> Vec<&str> { rendered .lines() @@ -1003,15 +1308,6 @@ mod tests { .collect() } - fn sorted_readiness_metric_lines(rendered: &str) -> Vec { - let mut lines = readiness_metric_lines(rendered) - .into_iter() - .map(str::to_owned) - .collect::>(); - lines.sort(); - lines - } - fn metric_value(rendered: &str, exact_prefix: &str) -> f64 { rendered .lines() @@ -1023,16 +1319,23 @@ mod tests { .unwrap_or_else(|| panic!("missing metric line: {exact_prefix}")) } + /// The frozen telemetry contract for the health listener. + /// + /// Readiness is lifecycle-only: its counter carries exactly two reasons and + /// its gauge is the latest private readiness-probe observation, never a + /// dependency or a transition-owned lifecycle mirror. Dependency families + /// are still exported, but only by the per-pod sampler, and neither + /// public-listener traffic nor an `/_status` request moves anything. #[test] - fn production_readiness_routes_export_the_frozen_health_only_contract() { + fn production_health_routes_export_the_frozen_telemetry_contract() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("current-thread runtime"); - let evaluator = Arc::new(ScriptedReadinessEvaluator::new(std::iter::repeat_n( - ready_evaluation(), - 4, - ))); + // Seeded with the first sampling cycle only; the coverage loop below + // pushes the rest, one per cycle, so the evaluator never serves a + // report the assertions did not choose. + let evaluator = Arc::new(ScriptedDependencyEvaluator::new([ready_report()])); let (recorder, handle) = crate::metrics::readiness_test_recorder(); metrics::with_local_recorder(&recorder, || { @@ -1057,138 +1360,130 @@ mod tests { readiness_request(health.clone()).await, (StatusCode::OK, json!({"status": "ready"})) ); - let first_scrape = handle.render(); + let after_probe = handle.render(); - assert!(first_scrape.contains("# TYPE buzz_readiness_checks_total counter")); - assert!(first_scrape - .contains("# TYPE buzz_readiness_dependency_checks_total counter")); - assert!(first_scrape - .contains("# TYPE buzz_readiness_check_duration_seconds histogram")); - assert!(first_scrape.contains("# TYPE buzz_readiness_state gauge")); + assert!(after_probe.contains("# TYPE buzz_readiness_checks_total counter")); + assert!(after_probe.contains("# TYPE buzz_readiness_state gauge")); assert_eq!( - metric_value( - &first_scrape, - "buzz_readiness_checks_total{reason=\"ready\"}" - ), + metric_value(&after_probe, "buzz_readiness_checks_total{reason=\"ready\"}"), 1.0 ); + assert_eq!( + metric_value(&after_probe, "buzz_readiness_state{check=\"overall\"}"), + 1.0 + ); + assert!( + !after_probe.contains("buzz_readiness_dependency_checks_total{"), + "the probe must not touch a shared dependency" + ); + assert!( + !after_probe.contains("buzz_readiness_check_duration_seconds_count"), + "the probe must not record a dependency latency sample" + ); + + // Dependency telemetry now belongs to the sampler; the + // endpoint only reads what the sampler cached. + state + .dependency_diagnostics + .sample(&state.db, &state.redis_pool) + .await; + let (status, payload) = status_request(health.clone()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["dependencies"]["sample"], json!("fresh")); + assert_eq!(payload["dependencies"]["reason"], json!("ready")); + let after_status = handle.render(); + assert!( + after_status.contains("# TYPE buzz_readiness_dependency_checks_total counter") + ); + assert!( + after_status.contains("# TYPE buzz_readiness_check_duration_seconds histogram") + ); assert_eq!( metric_value( - &first_scrape, + &after_status, "buzz_readiness_dependency_checks_total{dependency=\"postgres\",outcome=\"success\"}" ), 1.0 ); - assert_eq!( - metric_value( - &first_scrape, - "buzz_readiness_state{check=\"overall\"}" - ), - 1.0 - ); for bucket in ["2", "2.5", "+Inf"] { - assert!(first_scrape.contains(&format!( + assert!(after_status.contains(&format!( "buzz_readiness_check_duration_seconds_bucket{{check=\"overall\",le=\"{bucket}\"}}" ))); } - assert!(!first_scrape.contains("result=")); - assert!(!first_scrape + assert!(!after_status.contains("result=")); + assert!(!after_status .lines() .filter(|line| line.starts_with("buzz_readiness_check_duration_seconds")) .any(|line| line.contains("outcome="))); + for dependency in ["postgres", "redis", "deletion_catalog"] { + assert!( + !after_status + .contains(&format!("buzz_readiness_state{{check=\"{dependency}\"}}")), + "dependency health has no publishable readiness gauge" + ); + } - let before_public_failure = sorted_readiness_metric_lines(&first_scrape); - evaluator.push(readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::PoolTimeout, - readiness::DeletionCatalogOutcome::Success, - )); - assert_eq!( - readiness_request(public.clone()).await, + // A failing dependency is reported and changes nothing about + // whether this pod stays in the load balancer. This set also + // covers every valid dependency/outcome pair, so the series + // total below is exact rather than merely bounded. + let coverage = [ ( - StatusCode::SERVICE_UNAVAILABLE, - json!({ - "status": "not_ready", - "postgres": true, - "redis": false, - "deletion_catalog": true - }) - ) - ); - assert_eq!( - sorted_readiness_metric_lines(&handle.render()), - before_public_failure - ); - - let contract_evaluations = [ - readiness_evaluation( readiness::PostgresOutcome::PoolTimeout, - readiness::RedisOutcome::Success, - readiness::DeletionCatalogOutcome::Success, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::OperationTimeout, ), - readiness_evaluation( + ( readiness::PostgresOutcome::PoolError, - readiness::RedisOutcome::Success, - readiness::DeletionCatalogOutcome::Success, + readiness::RedisOutcome::PoolError, + readiness::DeletionCatalogOutcome::OperationError, ), - readiness_evaluation( + ( readiness::PostgresOutcome::QueryTimeout, readiness::RedisOutcome::Success, readiness::DeletionCatalogOutcome::Success, ), - readiness_evaluation( + ( readiness::PostgresOutcome::QueryError, readiness::RedisOutcome::Success, readiness::DeletionCatalogOutcome::Success, ), - readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::PoolTimeout, - readiness::DeletionCatalogOutcome::Success, - ), - readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::PoolError, - readiness::DeletionCatalogOutcome::Success, - ), - readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::Success, - readiness::DeletionCatalogOutcome::OperationTimeout, - ), - readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::Success, - readiness::DeletionCatalogOutcome::OperationError, - ), - readiness_evaluation( - readiness::PostgresOutcome::PoolTimeout, - readiness::RedisOutcome::PoolTimeout, - readiness::DeletionCatalogOutcome::OperationTimeout, - ), - readiness_evaluation( - readiness::PostgresOutcome::PoolError, - readiness::RedisOutcome::PoolError, - readiness::DeletionCatalogOutcome::Success, - ), ]; - for evaluation in contract_evaluations { - evaluator.push(evaluation); - let (status, payload) = readiness_request(health.clone()).await; - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(payload["reason"], json!(evaluation.reason.label())); + for (index, (postgres, redis, deletion_catalog)) in + coverage.into_iter().enumerate() + { + evaluator.push(dependency_report(postgres, redis, deletion_catalog)); + state + .dependency_diagnostics + .sample(&state.db, &state.redis_pool) + .await; + let (status, degraded) = status_request(health.clone()).await; + assert_eq!(status, StatusCode::OK); + if index == 0 { + assert_eq!( + degraded["dependencies"], + json!({ + "sample": "fresh", + "sample_interval_seconds": 30, + "sample_age_seconds": 0, + "postgres": false, + "redis": false, + "deletion_catalog": false, + "reason": "overall_timeout" + }) + ); + } + assert_eq!( + readiness_request(health.clone()).await, + (StatusCode::OK, json!({"status": "ready"})), + "a failing dependency must never deroute this pod" + ); } - let before_shutdown = handle.render(); - let histogram_counts_before = ["overall", "postgres", "redis", "deletion_catalog"] - .map(|check| { - metric_value( - &before_shutdown, - &format!( - "buzz_readiness_check_duration_seconds_count{{check=\"{check}\"}}" - ), - ) - }); + let histogram_count_before = metric_value( + &handle.render(), + "buzz_readiness_check_duration_seconds_count{check=\"overall\"}", + ); state.begin_shutdown(); assert_eq!( readiness_request(public).await, @@ -1197,10 +1492,18 @@ mod tests { json!({"status": "shutting_down"}) ) ); - let after_public_shutdown = handle.render(); - assert!(after_public_shutdown + assert!(handle + .render() .lines() .all(|line| !line.contains("reason=\"shutting_down\""))); + assert_eq!( + metric_value( + &handle.render(), + "buzz_readiness_state{check=\"overall\"}" + ), + 1.0, + "shutdown and public traffic must not update the private probe gauge" + ); assert_eq!( readiness_request(health).await, @@ -1210,16 +1513,14 @@ mod tests { ) ); let final_scrape = handle.render(); - let histogram_counts_after = ["overall", "postgres", "redis", "deletion_catalog"] - .map(|check| { - metric_value( - &final_scrape, - &format!( - "buzz_readiness_check_duration_seconds_count{{check=\"{check}\"}}" - ), - ) - }); - assert_eq!(histogram_counts_after, histogram_counts_before); + assert_eq!( + metric_value( + &final_scrape, + "buzz_readiness_check_duration_seconds_count{check=\"overall\"}" + ), + histogram_count_before, + "shutdown must not fabricate a dependency latency sample" + ); assert_eq!( metric_value( &final_scrape, @@ -1228,13 +1529,27 @@ mod tests { 1.0 ); assert_eq!( - metric_value( - &final_scrape, - "buzz_readiness_state{check=\"overall\"}" - ), + metric_value(&final_scrape, "buzz_readiness_state{check=\"overall\"}"), 0.0 ); assert!(!final_scrape.contains("sensitive-sql-or-url")); + // Freshness is part of the frozen contract: one unlabelled + // gauge carrying when the cached report completed. The sampler + // advances it and the publisher re-emits it, so + // `time() - ` ages a stalled sampler out from a scrape + // alone. + assert!(final_scrape.contains( + "# TYPE buzz_readiness_dependency_sample_completed_timestamp_seconds gauge" + )); + assert_eq!( + final_scrape + .lines() + .filter(|line| line.starts_with( + "buzz_readiness_dependency_sample_completed_timestamp_seconds" + )) + .count(), + 1 + ); let exported_reasons = final_scrape .lines() @@ -1244,138 +1559,7 @@ mod tests { assert_eq!( readiness_metric_lines(&final_scrape).len(), readiness::READINESS_RAW_SERIES_PER_POD, - "readiness series contract must stay at or below its 99-series cap" - ); - }); - }); - } - - fn run_out_of_order_route_case( - first: ReadinessEvaluation, - second: ReadinessEvaluation, - ) -> (serde_json::Value, serde_json::Value, String) { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("current-thread runtime"); - let evaluator = Arc::new(BarrierReadinessEvaluator::new(first, second)); - let (recorder, handle) = crate::metrics::readiness_test_recorder(); - - metrics::with_local_recorder(&recorder, || { - runtime.block_on(async { - let state = readiness_state(evaluator.clone()).await; - let health = build_health_router(state); - let first_started = evaluator.first_started.notified(); - let slow_first = tokio::spawn(readiness_request(health.clone())); - first_started.await; - - let (_, second_payload) = readiness_request(health).await; - evaluator.release_first.notify_one(); - let (_, first_payload) = slow_first.await.expect("slow first probe task"); - (first_payload, second_payload, handle.render()) - }) - }) - } - - #[test] - fn real_health_route_generation_fence_covers_both_completion_orders() { - let failure = readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::PoolTimeout, - readiness::DeletionCatalogOutcome::Success, - ); - - let (older_failure, newer_success, success_scrape) = - run_out_of_order_route_case(failure, ready_evaluation()); - assert_eq!(older_failure["reason"], json!("redis_pool_timeout")); - assert_eq!(newer_success, json!({"status": "ready"})); - assert_eq!( - metric_value(&success_scrape, "buzz_readiness_state{check=\"overall\"}"), - 1.0 - ); - assert_eq!( - metric_value(&success_scrape, "buzz_readiness_state{check=\"redis\"}"), - 1.0 - ); - - let (older_success, newer_failure, failure_scrape) = - run_out_of_order_route_case(ready_evaluation(), failure); - assert_eq!(older_success, json!({"status": "ready"})); - assert_eq!(newer_failure["reason"], json!("redis_pool_timeout")); - assert_eq!( - metric_value(&failure_scrape, "buzz_readiness_state{check=\"overall\"}"), - 0.0 - ); - assert_eq!( - metric_value(&failure_scrape, "buzz_readiness_state{check=\"redis\"}"), - 0.0 - ); - for scrape in [&success_scrape, &failure_scrape] { - assert_eq!( - metric_value(scrape, "buzz_readiness_checks_total{reason=\"ready\"}"), - 1.0 - ); - assert_eq!( - metric_value( - scrape, - "buzz_readiness_checks_total{reason=\"redis_pool_timeout\"}" - ), - 1.0 - ); - } - } - - #[test] - fn real_health_route_shutdown_fence_dominates_an_in_flight_success() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("current-thread runtime"); - let evaluator = Arc::new(BarrierReadinessEvaluator::new( - ready_evaluation(), - ready_evaluation(), - )); - let (recorder, handle) = crate::metrics::readiness_test_recorder(); - - metrics::with_local_recorder(&recorder, || { - runtime.block_on(async { - let state = readiness_state(evaluator.clone()).await; - let health = build_health_router(state.clone()); - let first_started = evaluator.first_started.notified(); - let in_flight = tokio::spawn(readiness_request(health)); - first_started.await; - - state.begin_shutdown(); - evaluator.release_first.notify_one(); - assert_eq!( - in_flight.await.expect("in-flight readiness task"), - ( - StatusCode::SERVICE_UNAVAILABLE, - json!({"status": "shutting_down"}) - ) - ); - - let scrape = handle.render(); - assert_eq!( - metric_value(&scrape, "buzz_readiness_state{check=\"overall\"}"), - 0.0 - ); - assert!(scrape - .lines() - .all(|line| !line.starts_with("buzz_readiness_state{check=\"postgres\"}"))); - assert_eq!( - metric_value( - &scrape, - "buzz_readiness_checks_total{reason=\"shutting_down\"}" - ), - 1.0 - ); - assert_eq!( - metric_value( - &scrape, - "buzz_readiness_dependency_checks_total{dependency=\"postgres\",outcome=\"success\"}" - ), - 1.0 + "readiness series contract must stay at or below its 87-series cap" ); }); }); diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 7237640200..417e9bb763 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -182,10 +182,99 @@ impl Drop for CommunityConnectionGuard { } } +/// Message reported when the one-time Redis bootstrap gate rejects startup. +/// +/// Bounded and stable so operators and the boot regression test can match on +/// it without parsing the underlying driver error. +pub const REDIS_BOOTSTRAP_FAILURE: &str = "Redis command path unavailable at startup"; + +/// Budget for the one-time bootstrap PING. A refused port answers immediately; +/// this only bounds a blackholed address, where hanging forever would be worse +/// than exiting. +const REDIS_BOOTSTRAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Keep the Redis driver's per-command timeout outside the relay-owned startup +/// budget so [`REDIS_BOOTSTRAP_TIMEOUT`] remains the one authoritative bound. +const REDIS_BOOTSTRAP_DRIVER_TIMEOUT: std::time::Duration = + REDIS_BOOTSTRAP_TIMEOUT.saturating_mul(2); + +/// Proves once, during startup, that the Redis command path this pod will serve +/// from can actually be reached. +/// +/// `deadpool_redis` pools dial lazily and `PubSubManager::new` only allocates +/// channels, so without this nothing in boot ever opened a command connection: +/// a relay came up against a dead Redis, bound its health listener, and — since +/// readiness reports local lifecycle only — advertised ready forever. Binding +/// that listener is a one-way latch, so the check has to happen before it, and +/// it is deliberately a *startup* gate: once serving, a Redis blip is a +/// dependency failure and must never change readiness. +pub async fn verify_redis_command_path(pool: &deadpool_redis::Pool) -> anyhow::Result<()> { + let ping = async { + let connection = pool + .get() + .await + .map_err(|error| anyhow::anyhow!("{REDIS_BOOTSTRAP_FAILURE}: {error}"))?; + // This one-shot connection is removed from the pool so extending its + // driver timeout cannot leak into normal serving traffic. The relay's + // outer timeout below must bound both lazy checkout and PING. + let mut connection = deadpool_redis::Connection::take(connection); + connection.set_response_timeout(REDIS_BOOTSTRAP_DRIVER_TIMEOUT); + redis::cmd("PING") + .query_async::(&mut connection) + .await + .map_err(|error| anyhow::anyhow!("{REDIS_BOOTSTRAP_FAILURE}: {error}")) + }; + + match tokio::time::timeout(REDIS_BOOTSTRAP_TIMEOUT, ping).await { + Err(_) => Err(anyhow::anyhow!( + "{REDIS_BOOTSTRAP_FAILURE}: no response within {REDIS_BOOTSTRAP_TIMEOUT:?}" + )), + Ok(result) => result.map(|_| ()), + } +} + +/// Bounded outcome of the durable community-active check run when a socket is +/// admitted. +/// +/// `outcome` is the only dimension. Community, tenant, connection, and error +/// text are request-controlled and deliberately absent from the label set. +#[derive(Debug, Clone, Copy)] +enum AdmissionOutcome { + Active, + Inactive, + CheckError, +} + +impl AdmissionOutcome { + fn label(self) -> &'static str { + match self { + Self::Active => "active", + Self::Inactive => "inactive", + Self::CheckError => "check_error", + } + } +} + +fn record_admission_check(outcome: AdmissionOutcome) { + metrics::counter!( + "buzz_community_admission_checks_total", + "outcome" => outcome.label(), + ) + .increment(1); +} + /// Registers a socket, durably revalidates its community, then runs it. /// /// The ordering is the archival admission invariant: archive-before-query is /// observed by the query, while archive-after-registration sees the token. +/// +/// Admission is fail-closed: only an affirmative `Ok(true)` may serve. Both +/// `Ok(false)` and a lookup `Err` cancel, because neither proves this tenant is +/// currently admitted, and `docs/multi-tenant-relay.md` I5 +/// (`Inv_AdmissionFence`) grants capability only to an actor *currently* +/// admitted to that community. The two are still told apart in telemetry +/// (`buzz_community_admission_checks_total{outcome}`) so an operator can +/// separate archival from database pressure. pub(crate) async fn run_registered_community_connection( registry: &CommunityConnectionRegistry, connection_id: Uuid, @@ -201,9 +290,29 @@ pub(crate) async fn run_registered_community_connection record_admission_check(AdmissionOutcome::Active), + Ok(false) => { + record_admission_check(AdmissionOutcome::Inactive); + cancel.cancel(); + return; + } + Err(error) => { + // A lookup failure is not an answer, so it cannot authorize one. + // Admitting here would begin serving AUTH and REQ for a tenant + // whose lifecycle is unknown, and the adjacent host-binding seam + // already refuses on exactly this evidence (see + // `router::nip11_or_ws_handler`). The client sees an ordinary dial + // failure and retries. + record_admission_check(AdmissionOutcome::CheckError); + tracing::warn!( + %community_id, + %error, + "community active check failed; refusing the socket" + ); + cancel.cancel(); + return; + } } if cancel.is_cancelled() { return; @@ -350,6 +459,14 @@ impl ConnectionManager { .and_then(|entry| entry.authenticated_pubkey.read().ok()?.clone()) } + /// Cancel a single connection by ID. A no-op if the connection is not + /// registered (already deregistered or never known). + pub(crate) fn cancel_conn(&self, conn_id: Uuid) { + if let Some(entry) = self.connections.get(&conn_id) { + entry.cancel.cancel(); + } + } + /// Disconnect every live connection authenticated as `pubkey` **in /// `community`**, delivering a final `OK false` frame carrying `reason` /// before closing. @@ -719,8 +836,14 @@ pub struct AppState { pub audio_rooms: Arc, /// Set to `true` on SIGTERM — readiness probe returns 503. pub shutting_down: Arc, - /// Orders readiness gauge publication against terminal shutdown. - pub(crate) readiness: Arc, + /// Cached shared-dependency evaluation behind the diagnostic `/_status` + /// endpoint, owned by [`crate::readiness::run_dependency_sampler`]. Never + /// consulted by a Kubernetes probe, and never evaluated by a request. + pub(crate) dependency_diagnostics: Arc, + /// Stops only the periodic dependency sampler during graceful shutdown. + pub dependency_sampler_cancel: CancellationToken, + /// Stops only the completion-epoch publisher during graceful shutdown. + pub dependency_completion_publisher_cancel: CancellationToken, /// Process start time — used by `/_status` endpoint. pub started_at: Instant, /// Shared, community-scoped NIP-98 replay prevention. @@ -946,7 +1069,9 @@ impl AppState { git_pack_cache, audio_rooms: Arc::new(AudioRoomManager::new()), shutting_down: Arc::new(AtomicBool::new(false)), - readiness: Arc::new(crate::readiness::ReadinessCoordinator::default()), + dependency_diagnostics: Arc::new(crate::readiness::DependencyDiagnostics::default()), + dependency_sampler_cancel: CancellationToken::new(), + dependency_completion_publisher_cancel: CancellationToken::new(), started_at: Instant::now(), nip98_replay, gif_http_client, @@ -990,21 +1115,21 @@ impl AppState { ) } - /// Atomically closes readiness publication before exposing shutdown to - /// the relay's other fast-path lifecycle checks. + /// Withdraws this pod from routing. The lifecycle flag is authoritative for + /// `/_readiness`; the private probe publishes its sampled observation to + /// the readiness gauge on its next request. pub fn begin_shutdown(&self) { - self.readiness.begin_shutdown(); self.shutting_down.store(true, Ordering::Release); } #[cfg(test)] - pub(crate) fn set_readiness_evaluator( + pub(crate) fn set_dependency_evaluator( &mut self, - evaluator: Arc, + evaluator: Arc, ) { - self.readiness = Arc::new(crate::readiness::ReadinessCoordinator::with_evaluator( - evaluator, - )); + self.dependency_diagnostics = Arc::new( + crate::readiness::DependencyDiagnostics::with_evaluator(evaluator), + ); } /// Inter-relay mesh handle. `None` ⇒ mesh-off / single-instance: callers @@ -2096,6 +2221,144 @@ pub(crate) mod tests { assert!(!started_during.load(Ordering::SeqCst)); } + /// Reads one `buzz_community_admission_checks_total` series by exact label set. + fn admission_counter( + snapshot: &[( + metrics_util::CompositeKey, + Option, + Option, + metrics_util::debugging::DebugValue, + )], + outcome: &str, + ) -> Option { + snapshot.iter().find_map(|(key, _, _, value)| { + let labels = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + if key.key().name() != "buzz_community_admission_checks_total" + || labels != [("outcome", outcome)] + { + return None; + } + match value { + metrics_util::debugging::DebugValue::Counter(count) => Some(*count), + _ => panic!("community admission checks must be a counter"), + } + }) + } + + /// Admission is fail-closed on both non-affirmative outcomes. A confirmed + /// `Ok(false)` and a lookup `Err` are different diagnoses — the counter + /// keeps them apart — but neither is proof of current admission, and + /// `docs/multi-tenant-relay.md` I5 (`Inv_AdmissionFence`) grants read or + /// membership capability only to an actor *currently* admitted to that + /// community. Serving AUTH/REQ on an unproven tenant lifecycle is the + /// failure this guards. + #[test] + fn neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime"); + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + let (inactive_cancel, inactive_started, error_cancel, error_started, active_started) = + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xa)); + + let inactive_cancel = CancellationToken::new(); + let inactive_started = Arc::new(AtomicBool::new(false)); + let started = Arc::clone(&inactive_started); + run_registered_community_connection( + ®istry, + Uuid::new_v4(), + community, + CommunityConnectionControl::new(inactive_cancel.clone()), + || async { Ok(false) }, + move |_| async move { started.store(true, Ordering::SeqCst) }, + ) + .await; + + let error_cancel = CancellationToken::new(); + let error_started = Arc::new(AtomicBool::new(false)); + let started = Arc::clone(&error_started); + run_registered_community_connection( + ®istry, + Uuid::new_v4(), + community, + CommunityConnectionControl::new(error_cancel.clone()), + || async { Err(buzz_db::DbError::Sqlx(sqlx::Error::PoolTimedOut)) }, + move |_| async move { started.store(true, Ordering::SeqCst) }, + ) + .await; + + let active_started = Arc::new(AtomicBool::new(false)); + let started = Arc::clone(&active_started); + run_registered_community_connection( + ®istry, + Uuid::new_v4(), + community, + CommunityConnectionControl::new(CancellationToken::new()), + || async { Ok(true) }, + move |_| async move { started.store(true, Ordering::SeqCst) }, + ) + .await; + + ( + inactive_cancel, + inactive_started, + error_cancel, + error_started, + active_started, + ) + }) + }); + + assert!( + inactive_cancel.is_cancelled(), + "a confirmed-inactive community must still cancel its socket" + ); + assert!( + !inactive_started.load(Ordering::SeqCst), + "a confirmed-inactive community must never start the socket body" + ); + assert!( + error_cancel.is_cancelled(), + "a failed active check must cancel its socket, not admit it" + ); + assert!( + !error_started.load(Ordering::SeqCst), + "a failed active check must never start serving AUTH/REQ on an unproven tenant" + ); + assert!(active_started.load(Ordering::SeqCst)); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!(admission_counter(&snapshot, "inactive"), Some(1)); + assert_eq!(admission_counter(&snapshot, "check_error"), Some(1)); + assert_eq!(admission_counter(&snapshot, "active"), Some(1)); + + let label_sets = snapshot + .iter() + .filter(|(key, _, _, _)| key.key().name() == "buzz_community_admission_checks_total") + .map(|(key, _, _, _)| { + key.key() + .labels() + .map(|label| label.key().to_owned()) + .collect::>() + }) + .collect::>(); + assert_eq!(label_sets.len(), 3, "outcome is the only dimension"); + assert!( + label_sets.iter().all(|labels| labels == &["outcome"]), + "admission telemetry must never carry community, tenant, or error labels: {label_sets:?}" + ); + } + #[tokio::test] async fn revalidation_continues_after_one_community_lookup_failure() { let registry = CommunityConnectionRegistry::new(); diff --git a/crates/buzz-relay/tests/boot_lifecycle.rs b/crates/buzz-relay/tests/boot_lifecycle.rs index 17762bbdea..1d43fc3236 100644 --- a/crates/buzz-relay/tests/boot_lifecycle.rs +++ b/crates/buzz-relay/tests/boot_lifecycle.rs @@ -3,6 +3,7 @@ use std::{ io::{Read as _, Write as _}, net::{TcpListener, TcpStream}, process::{Child, Command, ExitStatus, Output, Stdio}, + sync::mpsc, thread::{self, JoinHandle}, time::{Duration, Instant}, }; @@ -10,11 +11,14 @@ use std::{ use serde_json::Value; use buzz_relay::lifecycle::StartupPhase; +use buzz_relay::state::REDIS_BOOTSTRAP_FAILURE; const VALID_RELAY_PRIVATE_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; const CHILD_TIMEOUT: Duration = Duration::from_secs(10); const MAX_CAPTURE_BYTES: u64 = 1024 * 1024; +/// Bound on every wait for the relay to export a metric family. +const METRICS_SCRAPE_DEADLINE: Duration = Duration::from_secs(8); struct RelayProcess { child: Option, @@ -156,20 +160,27 @@ fn scrape_metrics(port: u16) -> std::io::Result { } fn wait_for_relay_metrics(process: &mut RelayProcess, port: u16) -> String { - let deadline = Instant::now() + Duration::from_secs(8); + wait_for_scraped_metric(process, port, "buzz_audit_enabled") +} + +/// Polls the relay's own `/metrics` until `needle` appears, bounded by +/// [`METRICS_SCRAPE_DEADLINE`]. A relay that exits first is a failure, not a +/// timeout, so the panic names the real cause. +fn wait_for_scraped_metric(process: &mut RelayProcess, port: u16, needle: &str) -> String { + let deadline = Instant::now() + METRICS_SCRAPE_DEADLINE; loop { assert!( process.try_wait().is_none(), - "relay exited before its metrics endpoint became usable" + "relay exited before exporting {needle}" ); if let Ok(response) = scrape_metrics(port) { - if response.contains("buzz_audit_enabled") { + if response.contains(needle) { return response; } } assert!( Instant::now() < deadline, - "relay metrics did not become scrapeable within 8s" + "relay did not export {needle} within {METRICS_SCRAPE_DEADLINE:?}" ); thread::sleep(Duration::from_millis(20)); } @@ -500,3 +511,334 @@ fn successful_main_emits_complete_lifecycle_without_startup_metrics() { assert_terminal(&events, "metrics_bind", "succeeded", None); assert_terminal(&events, "process_telemetry", "succeeded", None); } + +/// Boot gates that need a live Postgres to reach the code under test. Named +/// `postgres_tests` so `.config/nextest.toml`'s `postgres-ci` default filter +/// discovers them structurally; the wrapper hands each test its own database +/// through `DATABASE_URL`. +mod postgres_tests { + use super::*; + + const REDIS_BOOTSTRAP_BUDGET: Duration = Duration::from_secs(5); + const REDIS_BOOTSTRAP_SCHEDULING_SLACK: Duration = Duration::from_secs(3); + + /// A TCP peer that completes Redis's metadata handshake, then reads and + /// holds PING without replying. This distinguishes a bounded checkout + + /// PING from a refused connection, which returns before the bootstrap + /// timeout is exercised. + struct HangingRedisPeer { + redis_url: String, + request_received: mpsc::Receiver>, + stop: mpsc::Sender<()>, + worker: Option>, + } + + impl HangingRedisPeer { + fn spawn() -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind fake Redis peer"); + listener + .set_nonblocking(true) + .expect("set fake Redis listener nonblocking"); + let port = listener.local_addr().expect("fake Redis address").port(); + let (request_tx, request_received) = mpsc::channel(); + let (stop, stop_rx) = mpsc::channel(); + let worker = thread::spawn(move || loop { + if stop_rx.try_recv().is_ok() { + return; + } + let (mut stream, _) = match listener.accept() { + Ok(accepted) => accepted, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + continue; + } + Err(error) => panic!("accept fake Redis connection: {error}"), + }; + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .expect("bound fake Redis read"); + let mut request = Vec::new(); + loop { + let mut chunk = [0_u8; 4096]; + match stream.read(&mut chunk) { + Ok(0) => return, + Ok(read) => { + request.extend_from_slice(&chunk[..read]); + assert!( + request.len() <= 4096, + "fake Redis peer received an oversized request" + ); + let setinfo_commands = request + .windows(b"SETINFO".len()) + .filter(|window| *window == b"SETINFO") + .count(); + if setinfo_commands >= 2 { + // redis-rs pipelines CLIENT SETINFO lib-name + // and lib-ver while establishing a connection. + // Complete that handshake so the relay reaches + // its explicit bootstrap PING, then hold it. + stream + .write_all(b"+OK\r\n+OK\r\n") + .expect("reply to Redis client handshake"); + request.clear(); + continue; + } + if request + .windows(b"PING".len()) + .any(|window| window == b"PING") + { + let _ = request_tx.send(request); + let _ = stop_rx.recv_timeout(CHILD_TIMEOUT); + return; + } + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + if stop_rx.try_recv().is_ok() { + return; + } + } + Err(error) => panic!("read fake Redis request: {error}"), + } + } + }); + Self { + redis_url: format!("redis://127.0.0.1:{port}"), + request_received, + stop, + worker: Some(worker), + } + } + + fn redis_url(&self) -> &str { + &self.redis_url + } + + fn assert_request_received(&self, logs: &str) { + let request = self + .request_received + .recv_timeout(Duration::from_secs(1)) + .unwrap_or_else(|_| panic!("relay must reach the fake Redis peer: {logs}")); + assert!(!request.is_empty(), "fake Redis peer read an empty request"); + } + } + + impl Drop for HangingRedisPeer { + fn drop(&mut self) { + let _ = self.stop.send(()); + if let Some(worker) = self.worker.take() { + worker.join().expect("fake Redis peer must not panic"); + } + } + } + + fn reserve_closed_port() -> u16 { + let reserved = TcpListener::bind(("127.0.0.1", 0)).expect("reserve port"); + let port = reserved.local_addr().expect("reserved address").port(); + drop(reserved); + port + } + + /// Runs the relay until it exits on its own, or kills it once `timeout` + /// passes. Unlike `run_relay`, a relay that keeps serving is a result to + /// assert on rather than a panic, which is the whole point here. + fn run_until_exit(environment: &[(&str, &str)], timeout: Duration) -> (bool, Output) { + let mut process = RelayProcess::spawn(environment); + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if process.try_wait().is_some() { + return (true, process.wait(Duration::from_secs(2))); + } + thread::sleep(Duration::from_millis(20)); + } + (false, process.terminate()) + } + + /// Redis is required for pub/sub fan-out, presence, and typing, but nothing + /// in boot ever opened a command connection: `deadpool_redis` pools dial + /// lazily and `PubSubManager::new` only allocates channels, so "Redis + /// pub/sub connected" was logged against a dead port. A relay could + /// therefore boot with Redis unreachable, bind its health listener, and — + /// now that readiness answers from local lifecycle alone — advertise ready + /// for the rest of its life. The bootstrap gate is the one-time proof that + /// the command path has connected at least once, and it has to land before + /// the listener binds, because binding is the one-way latch that makes this + /// pod routable. + /// + /// The git conformance probe is disabled so the only remaining startup-fatal + /// gate is the one under test. + #[test] + #[ignore = "requires PostgreSQL"] + fn unreachable_redis_fails_boot_before_the_health_listener_binds() { + let database_url = std::env::var("DATABASE_URL") + .expect("postgres lane provides DATABASE_URL for each test process"); + let redis_url = format!("redis://127.0.0.1:{}", reserve_closed_port()); + let metrics_port = reserve_closed_port().to_string(); + let health_port = reserve_closed_port(); + let health_port_value = health_port.to_string(); + + let (exited, output) = run_until_exit( + &[ + ("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY), + ("BUZZ_METRICS_PORT", &metrics_port), + ("BUZZ_HEALTH_PORT", &health_port_value), + ("DATABASE_URL", &database_url), + ("REDIS_URL", &redis_url), + ("BUZZ_GIT_CONFORMANCE_PROBE", "false"), + ], + CHILD_TIMEOUT, + ); + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + assert!( + !logs.contains("Health probe listener started"), + "the Redis bootstrap gate must run before the health listener binds: {logs}" + ); + assert!( + exited && !output.status.success(), + "an unreachable Redis command path must be startup-fatal: {logs}" + ); + assert!( + logs.contains(REDIS_BOOTSTRAP_FAILURE), + "the failure must name the gate that rejected boot: {logs}" + ); + assert!( + TcpListener::bind(("0.0.0.0", health_port)).is_ok(), + "the health port must never have been bound" + ); + } + + /// A peer that accepts the socket but withholds its Redis response exercises + /// the outer timeout around both lazy pool checkout and PING. Removing or + /// narrowing that timeout makes this test kill a still-running relay at the + /// deadline instead of observing a startup failure. + #[test] + #[ignore = "requires PostgreSQL"] + fn hanging_redis_peer_times_out_before_the_health_listener_binds() { + let database_url = std::env::var("DATABASE_URL") + .expect("postgres lane provides DATABASE_URL for each test process"); + let redis_peer = HangingRedisPeer::spawn(); + let metrics_port = reserve_closed_port().to_string(); + let health_port = reserve_closed_port(); + let health_port_value = health_port.to_string(); + let timeout = REDIS_BOOTSTRAP_BUDGET + REDIS_BOOTSTRAP_SCHEDULING_SLACK; + let started_at = Instant::now(); + + let (exited, output) = run_until_exit( + &[ + ("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY), + ("BUZZ_METRICS_PORT", &metrics_port), + ("BUZZ_HEALTH_PORT", &health_port_value), + ("DATABASE_URL", &database_url), + ("REDIS_URL", redis_peer.redis_url()), + ("BUZZ_GIT_CONFORMANCE_PROBE", "false"), + ], + timeout, + ); + let elapsed = started_at.elapsed(); + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + redis_peer.assert_request_received(&logs); + + assert!( + elapsed >= REDIS_BOOTSTRAP_BUDGET, + "the fake peer must hold the request through the bootstrap budget: {elapsed:?}: {logs}" + ); + assert!( + exited && elapsed < timeout && !output.status.success(), + "the outer bootstrap timeout must terminate the relay within scheduling slack: {elapsed:?}: {logs}" + ); + assert!( + logs.contains(REDIS_BOOTSTRAP_FAILURE), + "the timeout must report the bounded Redis bootstrap failure: {logs}" + ); + assert!( + !logs.contains("Health probe listener started"), + "the health listener must not bind before Redis bootstrap succeeds: {logs}" + ); + assert!( + TcpListener::bind(("0.0.0.0", health_port)).is_ok(), + "the health port must never have been bound" + ); + } + + /// Startup is the only owner of dependency evaluation. `/_status` just reads + /// the cache, a readiness probe records no dependency attempt at all, and no + /// request path may start a check — so if `main` stops spawning the sampler, + /// this pod evaluates Postgres, Redis, and the deletion catalog exactly + /// never: the dependency families stay absent from its scrape and `/_status` + /// answers `not_yet_sampled` for the pod's whole life. + /// + /// No in-process test can fail on that, because each one drives `sample` + /// itself. This one boots the real binary against real dependencies and + /// reads only the relay's own `/metrics` — no probe, no `/_status`, nothing + /// that could evaluate a dependency on the test's behalf. The first tick + /// fires immediately, so the wait is bounded by + /// [`METRICS_SCRAPE_DEADLINE`] and never a fixed sleep. + /// + /// The completion-timestamp gauge is the needle because `sample` writes it + /// last, after the cache already serves the report: observing it proves the + /// whole cycle ran, with no window where the counters have landed but the + /// timestamp has not. + #[test] + #[ignore = "requires PostgreSQL"] + fn startup_owns_the_dependency_sampler() { + let database_url = std::env::var("DATABASE_URL") + .expect("postgres lane provides DATABASE_URL for each test process"); + let redis_url = std::env::var("REDIS_URL") + .expect("the postgres lane runs alongside Redis and exports REDIS_URL"); + let metrics_port = reserve_closed_port(); + let metrics_port_value = metrics_port.to_string(); + let health_port_value = reserve_closed_port().to_string(); + let bind_addr = format!("127.0.0.1:{}", reserve_closed_port()); + + let mut process = RelayProcess::spawn(&[ + ("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY), + ("BUZZ_METRICS_PORT", &metrics_port_value), + ("BUZZ_HEALTH_PORT", &health_port_value), + ("BUZZ_BIND_ADDR", &bind_addr), + ("DATABASE_URL", &database_url), + ("REDIS_URL", &redis_url), + ("BUZZ_GIT_CONFORMANCE_PROBE", "false"), + ]); + let scrape = wait_for_scraped_metric( + &mut process, + metrics_port, + "buzz_readiness_dependency_sample_completed_timestamp_seconds", + ); + let output = process.terminate(); + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + assert!( + scrape.contains("buzz_readiness_dependency_checks_total{"), + "the same cycle must publish its per-dependency outcomes: {scrape}" + ); + assert!( + scrape.contains("buzz_readiness_check_duration_seconds"), + "a completed evaluation must publish its latency too: {scrape}" + ); + assert!( + !scrape.contains("buzz_readiness_checks_total{"), + "no readiness probe was sent, so the sampler alone produced this: {scrape}" + ); + assert!( + logs.contains("Health probe listener started"), + "the sampler must be owned by a relay that finished booting: {logs}" + ); + } +} diff --git a/crates/buzz-test-client/tests/e2e_media.rs b/crates/buzz-test-client/tests/e2e_media.rs index 690fd9c8a5..89484b798a 100644 --- a/crates/buzz-test-client/tests/e2e_media.rs +++ b/crates/buzz-test-client/tests/e2e_media.rs @@ -26,6 +26,17 @@ fn relay_http_url() -> String { std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()) } +/// Extract the host:port authority from the relay URL for use as the `server` tag. +fn relay_server_authority() -> String { + let url = relay_http_url(); + url.trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or("localhost:3000") + .to_string() +} + fn http_client() -> Client { Client::builder() .timeout(Duration::from_secs(15)) @@ -36,11 +47,13 @@ fn http_client() -> Client { /// Sign a kind:24242 Blossom upload auth event for the given sha256. fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "upload"]).expect("t tag"), Tag::parse(["x", sha256]).expect("x tag"), Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + Tag::parse(["server", &server]).expect("server tag"), ]; EventBuilder::new(Kind::from(24242), "Upload test") .tags(tags) @@ -56,11 +69,13 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { /// one token serves `{sha}.jpg` and `{sha}.thumb.jpg` alike. fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "get"]).expect("t tag"), Tag::parse(["x", sha256]).expect("x tag"), Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + Tag::parse(["server", &server]).expect("server tag"), ]; EventBuilder::new(Kind::from(24242), "Get test") .tags(tags) diff --git a/crates/buzz-test-client/tests/e2e_media_extended.rs b/crates/buzz-test-client/tests/e2e_media_extended.rs index d8adfaed98..9bd73b13fc 100644 --- a/crates/buzz-test-client/tests/e2e_media_extended.rs +++ b/crates/buzz-test-client/tests/e2e_media_extended.rs @@ -19,6 +19,17 @@ fn relay_ws_url() -> String { .replace("https://", "wss://") } +/// Extract the host:port authority from the relay URL for use as the `server` tag. +fn relay_server_authority() -> String { + let url = relay_http_url(); + url.trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or("localhost:3000") + .to_string() +} + fn http_client() -> Client { Client::builder() .timeout(Duration::from_secs(15)) @@ -28,10 +39,12 @@ fn http_client() -> Client { fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), + Tag::parse(["server", &server]).unwrap(), ]; EventBuilder::new(Kind::from(24242), "Upload test") .tags(tags) @@ -43,10 +56,12 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { /// unconditionally, so round-trip GETs must present one of these. fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "get"]).unwrap(), Tag::parse(["x", sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), + Tag::parse(["server", &server]).unwrap(), ]; EventBuilder::new(Kind::from(24242), "Get test") .tags(tags) @@ -259,7 +274,7 @@ async fn test_auth_wrong_kind() { vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), ], ); let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; @@ -281,7 +296,7 @@ async fn test_auth_missing_t_tag() { "Upload test", vec![ Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), ], ); let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; @@ -348,7 +363,7 @@ async fn test_auth_empty_content() { vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), ], ); let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; @@ -371,7 +386,7 @@ async fn test_auth_server_tag_mismatch() { vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), Tag::parse(["server", "evil.example.com"]).unwrap(), ], ); @@ -395,7 +410,7 @@ async fn test_auth_server_tag_correct() { vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), Tag::parse(["server", "localhost:3000"]).unwrap(), ], ); diff --git a/crates/buzz-test-client/tests/e2e_media_video.rs b/crates/buzz-test-client/tests/e2e_media_video.rs index 2ec0b1e698..819838aa2c 100644 --- a/crates/buzz-test-client/tests/e2e_media_video.rs +++ b/crates/buzz-test-client/tests/e2e_media_video.rs @@ -19,6 +19,17 @@ fn relay_http_url() -> String { std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()) } +/// Extract the host:port authority from the relay URL for use as the `server` tag. +fn relay_server_authority() -> String { + let url = relay_http_url(); + url.trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or("localhost:3000") + .to_string() +} + fn http_client() -> Client { Client::builder() .timeout(Duration::from_secs(30)) @@ -28,11 +39,13 @@ fn http_client() -> Client { fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "upload"]).expect("t tag"), Tag::parse(["x", sha256]).expect("x tag"), Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + Tag::parse(["server", &server]).expect("server tag"), ]; EventBuilder::new(Kind::from(24242), "Upload test") .tags(tags) @@ -45,11 +58,13 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { /// the 206 and 416 range behaviour below would never be reached. fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "get"]).expect("t tag"), Tag::parse(["x", sha256]).expect("x tag"), Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + Tag::parse(["server", &server]).expect("server tag"), ]; EventBuilder::new(Kind::from(24242), "Get test") .tags(tags) diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index a6b74e8757..f5075fd19d 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -110,10 +110,11 @@ SigV4 signing, so do not put the bucket into `s3.endpoint`; pass Railway's base Object storage is contacted during relay startup only when `BUZZ_GIT_CONFORMANCE_PROBE` is enabled (the relay default). A probe failure is -startup-fatal, so Kubernetes readiness never opens. If an operator explicitly -disables that probe through `relay.extraEnv`, `/_readiness` does not test object -storage; configuration is still parsed strictly, but reachability and addressing -errors surface on the first storage operation. +startup-fatal, so the process exits and Kubernetes readiness never opens. If an +operator explicitly disables that probe through `relay.extraEnv`, configuration +is still parsed strictly, but reachability and addressing errors surface on the +first storage operation. `/_readiness` tests no external dependency in either +case — see the readiness contract below. ### Early-startup telemetry contract @@ -125,26 +126,114 @@ These phases intentionally do not emit metrics. Most run before the Prometheus exporter exists, and one uniform log-only contract preserves every phase's real event time and failure without assigning an eventual scrape time to earlier work. +### Readiness contract + +**`/_readiness` reports local process lifecycle only.** It performs no +Postgres, Redis, or deletion-catalog I/O: `shutting_down` returns 503, and any +other state returns 200. Shared dependencies are shared by every replica, so +gating the probe on them removed the whole deployment from the load balancer at +once and left a reconnect burst with nowhere to land. There is no separate +"starting" state — the health listener does not bind until the database, +migrations, Redis, and pub/sub are up, so a process that can answer has booted. + +Shared-dependency health moved to **`/_status`** on the same private health +listener, under a `dependencies` object carrying the `postgres`, `redis`, +`deletion_catalog`, and aggregate `reason` fields the readiness body used to +return. Do not wire `/_status` to a Kubernetes probe. + +**`/_status` is a cached read.** It performs no Postgres, Redis, or +deletion-catalog I/O of its own. One background loop per pod evaluates the +three dependencies **every 30 seconds**, awaiting each evaluation before taking +the next tick, so a pod never has more than one evaluation in flight no matter +how often — or how rarely — the endpoint is read. The loop publishes the +dependency metrics below and caches the report `/_status` serves. Its first +cycle runs at startup, and each evaluation is bounded by a two-second budget. + +Every `dependencies` object therefore states how old its report is: + +| Field | Meaning | +|-------|---------| +| `sample: "not_yet_sampled"` | the first cycle has not completed; no `postgres`/`redis`/`deletion_catalog`/`reason` fields are present, because there is no observation to report | +| `sample: "fresh"` | the report is at most two cadences (60s) old | +| `sample: "stale"` | the report outlived two cadences, so the sampler missed at least one cycle — read the verdict as history, not as current state | +| `sample_age_seconds` | age of the report at request time (absent when `not_yet_sampled`) | +| `sample_interval_seconds` | the sampling cadence, `30` | + +Polling `/_status` more often than the cadence returns the same cached report; +it does not make the data fresher and adds no dependency load. + ### Readiness telemetry contract Only requests served by the private health listener (`BUZZ_HEALTH_PORT`) emit -rollout readiness telemetry. The compatibility `/_readiness` route on the public -app listener returns health but does not change these metrics. +rollout telemetry. The compatibility `/_readiness` route on the public app +listener returns the same lifecycle answer but does not change these metrics. + +| Metric | Type | Labels | Source | +|--------|------|--------|--------| +| `buzz_readiness_checks_total` | counter | `reason` ∈ {`ready`, `shutting_down`} | `/_readiness` | +| `buzz_readiness_state` | gauge | `check="overall"`; latest private probe observation, 1 ready or 0 shutting down | `/_readiness` | +| `buzz_readiness_dependency_checks_total` | counter | `dependency`, typed bounded `outcome` | dependency sampler | +| `buzz_readiness_check_duration_seconds` | histogram | `check` only | dependency sampler | +| `buzz_readiness_dependency_sample_completed_timestamp_seconds` | gauge | none; Unix time the cached report completed | completion publisher | + +The three dependency families keep their `buzz_readiness_*` names for dashboard +continuity, but nothing about them is request-driven any more: the 30-second +sampler publishes them whether or not anyone reads `/_status`, so a quiet +endpoint no longer produces a flat dashboard during the outage it exists to +explain. + +`buzz_readiness_dependency_sample_completed_timestamp_seconds` carries **when +the cached report completed**, in Unix seconds. The relay sampler is the only +owner allowed to advance that epoch, and it writes it immediately after the +cache is replaced. A separate bounded publisher re-emits the stored epoch often +enough to survive local gauge idle-timeout; republishing never advances the +timestamp. If a republish races a newer completion, one scrape can briefly see +the older epoch, but the republish path verifies after writing and repairs to the +newer stored epoch before that republish call returns. So the value stands still +when sampling stops, and the time elapsed since it was written is whatever the +reader computes at read time. + +Following the `buzz_storage_sweep_age_seconds` convention, the series is not +emitted until the first sample completes: its absence means "not yet sampled", +not "fresh". + +That is the whole server-side contract. Freshness alerting is built from this +gauge in the monitoring provider, and the monitor query, thresholds, and +per-pod tag grouping belong with the deployment's monitor configuration rather +than in this chart — they depend on the provider's query grammar and on the +tags its agent attaches, neither of which this repo owns. + +Two properties of the neighboring families are worth knowing when building +that alerting. `buzz_readiness_dependency_checks_total` and +`buzz_readiness_check_duration_seconds` are cumulative, so a sampler that stops +leaves their last values exported and scraped indefinitely: those series stay +present and flat rather than disappearing. And per-report freshness for a human +reading a single pod is already on the `sample`, `sample_age_seconds`, and +`sample_interval_seconds` fields of `/_status` above. + +The schema has a ceiling of 87 raw Prometheus series per pod: 2 probe reasons, +11 valid dependency/outcome pairs, 72 histogram series, and 2 gauges (overall lifecycle + completion timestamp). Do not +add pod, ReplicaSet, version, rollout, error text, SQL, URL, tenant, user, +community, pubkey, header, query, or other request-controlled labels. A +readiness probe records no dependency attempt or latency sample at all. +The readiness gauge is not a monotonic lifecycle mirror: shutdown changes the +authoritative lifecycle flag, and the next private readiness probe observes and +publishes that state. + +### Community admission telemetry | Metric | Type | Labels | |--------|------|--------| -| `buzz_readiness_checks_total` | counter | `reason` from the closed readiness-reason set | -| `buzz_readiness_dependency_checks_total` | counter | `dependency`, typed bounded `outcome` | -| `buzz_readiness_check_duration_seconds` | histogram | `check` only | -| `buzz_readiness_state` | gauge | `check` only; latest publishable generation | +| `buzz_community_admission_checks_total` | counter | `outcome` ∈ {`active`, `inactive`, `check_error`} | -The schema has a ceiling of 99 raw Prometheus series per pod: 12 overall -reasons, 11 valid dependency/outcome pairs, 72 histogram series, and 4 gauges. -Do not add pod, ReplicaSet, version, rollout, error text, SQL, URL, tenant, -user, community, pubkey, header, query, or other request-controlled labels. -Shutdown without dependency evaluation increments only -`buzz_readiness_checks_total{reason="shutting_down"}` and sets the overall -state to zero; it does not fabricate dependency failures or latency samples. +Counts the durable community-active check run before a socket is admitted. +Admission is fail-closed: only `active` serves. `inactive` (a confirmed +archival answer) and `check_error` (the lookup itself failed, so the tenant +lifecycle is unknown) both refuse the socket before any AUTH or REQ frame is +read; the client sees an ordinary dial failure and retries. The two outcomes +stay distinct so a rise in `check_error` reads as database pressure rather than +archival. `outcome` is the only dimension — community and error text are +request-controlled and must never become labels. ### Operation-aware database pool acquisition contract diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 7e908d35c5..20924147f0 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -294,20 +294,21 @@ pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result { Ok(mime) } -/// Lifetime of a Blossom `t=get` read token. Ten minutes keeps a token alive -/// across a video's range-request stream while staying well inside the -/// server's `created_at` freshness window (3600s, matching upload). -pub(crate) const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 600; +/// Lifetime of a Blossom `t=get` read token. +/// +/// 60 seconds is the maximum permitted by NIP-FI §Freshness: `expiration <= created_at + 60s`. +/// Callers that make range requests across longer video playback sessions must re-mint on +/// a 401 or 403 from the server (expired/rejected token), using a single fresh mint+retry. +pub(crate) const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 60; /// Sign a Blossom (BUD-01) `t=get` authorization event, server-scoped to the /// relay's authority, and return the full `Authorization` header value. /// /// Server-scoped (a `server` tag, no `x` tag): one token authorizes reads of -/// any blob on that host for its lifetime, which keeps avatar-grid bursts and -/// video range requests cheap. This is deliberately broader than per-blob -/// scoping and is safe only because the relay still enforces NIP-43 -/// membership on the verified pubkey — and because callers only attach this -/// header to requests bound for the relay origin itself. +/// any blob on that host for its lifetime. Callers must only attach this +/// header to requests bound for the relay origin itself (never third-party +/// origins). Tokens expire after `expiry_secs` (≤ 60 per NIP-FI §Freshness) +/// and must be re-minted on 401 or 403 for long-running range-request streams. pub(crate) fn sign_blossom_get_auth_header( keys: &Keys, base_url: &str, @@ -365,16 +366,16 @@ pub(crate) fn sign_blossom_upload_auth( expiry_secs: u64, base_url: &str, ) -> Result { + let server = extract_server_authority(base_url) + .ok_or_else(|| "cannot derive server authority from relay URL".to_string())?; let now = Timestamp::now().as_secs(); - let mut tags = vec![ + let tags = vec![ Tag::parse(vec!["t", "upload"]).map_err(|e| e.to_string())?, Tag::parse(vec!["x", sha256]).map_err(|e| e.to_string())?, Tag::parse(vec!["expiration", &(now + expiry_secs).to_string()]) .map_err(|e| e.to_string())?, + Tag::parse(vec!["server".to_string(), server]).map_err(|e| e.to_string())?, ]; - if let Some(domain) = extract_server_authority(base_url) { - tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?); - } EventBuilder::new(Kind::from(24242), "Upload buzz-media") .tags(tags) .sign_with_keys(keys) @@ -414,14 +415,12 @@ async fn do_upload( ) -> Result { let sha256 = hex::encode(Sha256::digest(&body)); - // Video uploads get a 1-hour auth window to survive slow connections; - // images use 5 minutes. Must match the server-side max_age_secs values - // in process_upload (600s) and process_video_upload (3600s). - let expiry_secs = if mime.starts_with("video/") { - 3600 - } else { - 300 - }; + // All upload tokens use a 60-second expiry window per NIP-FI §Freshness: + // `expiration <= created_at + 60s`. Upload tokens are minted immediately + // before the PUT request, so 60 seconds is ample for any upload over a + // reasonable connection (the body is already hashed and ready to send). + // The server-side window is also 60s in Strict mode, matching this value. + let expiry_secs = 60u64; let base_url = relay_api_base_url_with_override(state); let auth_event = { let keys = state.signing_keys()?; @@ -843,7 +842,7 @@ mod tests { #[test] fn test_sign_blossom_get_auth_header_shape() { let keys = Keys::generate(); - let header = sign_blossom_get_auth_header(&keys, "http://localhost:3000", 600).unwrap(); + let header = sign_blossom_get_auth_header(&keys, "http://localhost:3000", 60).unwrap(); let b64 = header.strip_prefix("Nostr ").expect("Nostr scheme prefix"); let json = URL_SAFE_NO_PAD.decode(b64).unwrap(); let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap(); @@ -863,13 +862,48 @@ mod tests { assert!(tag("x").is_none()); let expiration: u64 = tag("expiration").unwrap().parse().unwrap(); let now = Timestamp::now().as_secs(); - assert!(expiration > now && expiration <= now + 600); + // Token expiry must be within the 60s NIP-FI window. + assert!(expiration > now && expiration <= now + 60); } #[test] fn test_sign_blossom_get_auth_header_invalid_base_url() { let keys = Keys::generate(); - assert!(sign_blossom_get_auth_header(&keys, "not-a-url", 600).is_err()); + assert!(sign_blossom_get_auth_header(&keys, "not-a-url", 60).is_err()); + } + + #[test] + fn test_sign_blossom_upload_auth_shape() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let base_url = "http://relay.example:3000"; + let event = sign_blossom_upload_auth(&keys, &sha256, 60, base_url).unwrap(); + + assert_eq!(event.kind, nostr::Kind::from(24242)); + event.verify().expect("valid signature"); + + let tag = |name: &str| -> Option { + event.tags.iter().find_map(|t| { + let v = t.as_slice(); + (v.first().map(String::as_str) == Some(name)).then(|| v[1].clone()) + }) + }; + assert_eq!(tag("t").as_deref(), Some("upload")); + assert_eq!(tag("x").as_deref(), Some(sha256.as_str())); + // server tag MUST be present (NIP-FI §Upload proofs) + assert_eq!(tag("server").as_deref(), Some("relay.example:3000")); + // expiry must be within the 60s NIP-FI window + let expiration: u64 = tag("expiration").unwrap().parse().unwrap(); + let now = nostr::Timestamp::now().as_secs(); + assert!(expiration > now && expiration <= now + 60); + } + + #[test] + fn test_sign_blossom_upload_auth_fails_without_valid_url() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + // URL that cannot yield a server authority → should fail (server tag mandatory) + assert!(sign_blossom_upload_auth(&keys, &sha256, 60, "not-a-url").is_err()); } #[test] diff --git a/desktop/src-tauri/src/hpke_key_backup.rs b/desktop/src-tauri/src/hpke_key_backup.rs new file mode 100644 index 0000000000..24894ab1b2 --- /dev/null +++ b/desktop/src-tauri/src/hpke_key_backup.rs @@ -0,0 +1,393 @@ +//! HPKE encryption for a future enterprise identity-backup service. +//! +//! This module only creates the opaque client envelope. Recipient enrollment, +//! upload, authorization, storage, and recovery are deliberately outside its +//! scope. In particular, this is a native Rust API and is not registered as a +//! Tauri command: renderer-controlled recipient keys would turn encryption into +//! an nsec export path. +//! +//! This is a pure sealing component, not an active [`crate::app_state::AppState`] +//! backup operation. A future enrollment/upload path must serialize identity +//! selection and sealing with `AppState::identity_mutation`, obtain the live +//! identity through `AppState::signing_keys()`, and bind its public key to the +//! authenticated enrollment before upload. This API alone does not protect +//! against identity lock, loss, or concurrent rotation, and its tests do not +//! claim that runtime integration. + +use std::fmt; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use nostr::{PublicKey, SecretKey}; +use rustls::crypto::{ + aws_lc_rs::hpke::DH_KEM_P256_HKDF_SHA256_AES_256, + hpke::{Hpke, HpkePublicKey}, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use zeroize::Zeroizing; + +/// HPKE `info` bytes for every v1 nsec-backup context. +pub const HPKE_INFO_V1: &[u8] = b"buzz/nsec-backup/v1"; + +/// Version of the envelope and associated-data contract. +pub const ENVELOPE_VERSION_V1: u8 = 1; + +/// RFC 9180 KEM ID for DHKEM(P-256, HKDF-SHA256). +pub const KEM_ID_P256_HKDF_SHA256: u16 = 0x0010; + +/// RFC 9180 KDF ID for HKDF-SHA256. +pub const KDF_ID_HKDF_SHA256: u16 = 0x0001; + +/// RFC 9180 AEAD ID for AES-256-GCM. +pub const AEAD_ID_AES_256_GCM: u16 = 0x0002; + +/// Encoded length of an uncompressed SEC1 P-256 public key. +pub const P256_PUBLIC_KEY_LEN: usize = 65; + +/// Encoded length of a v1 ciphertext: 32 secret bytes plus a 16-byte GCM tag. +pub const CIPHERTEXT_LEN: usize = 48; + +const AAD_DOMAIN_V1: &[u8] = b"buzz/nsec-backup/aad/v1"; +const MAX_CONTEXT_FIELD_LEN: usize = 255; +const P256_PUBLIC_KEY_BASE64URL_LEN: usize = 87; +const CIPHERTEXT_BASE64URL_LEN: usize = 64; + +/// Errors produced while validating or sealing an HPKE nsec-backup envelope. +/// +/// Error values never contain secret-key bytes or plaintext. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HpkeBackupError { + /// An enrollment or envelope field did not satisfy the v1 wire contract. + InvalidField { + /// Name of the invalid field. + field: &'static str, + /// Non-sensitive validation failure. + reason: &'static str, + }, + /// The recipient key is not an uncompressed P-256 SEC1 point. + InvalidRecipientKey(&'static str), + /// An envelope is inconsistent with the v1 wire contract. + InvalidEnvelope(&'static str), + /// HPKE rejected the recipient key or failed to seal the plaintext. + EncryptionFailed, +} + +impl fmt::Display for HpkeBackupError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidField { field, reason } => { + write!(formatter, "invalid backup {field}: {reason}") + } + Self::InvalidRecipientKey(reason) => { + write!(formatter, "invalid HPKE recipient public key: {reason}") + } + Self::InvalidEnvelope(reason) => write!(formatter, "invalid backup envelope: {reason}"), + Self::EncryptionFailed => { + formatter.write_str("HPKE backup encryption failed; recipient key may be invalid") + } + } + } +} + +impl std::error::Error for HpkeBackupError {} + +/// Fixed algorithm identifiers carried by a v1 envelope. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct HpkeSuiteIds { + kem_id: u16, + kdf_id: u16, + aead_id: u16, +} + +impl HpkeSuiteIds { + const V1: Self = Self { + kem_id: KEM_ID_P256_HKDF_SHA256, + kdf_id: KDF_ID_HKDF_SHA256, + aead_id: AEAD_ID_AES_256_GCM, + }; + + fn is_v1(self) -> bool { + self == Self::V1 + } +} + +/// Enrollment values authenticated into one backup envelope. +#[derive(Clone, Debug)] +pub struct HpkeBackupEnrollment { + recipient_key_id: String, + service_namespace: String, + owner_id: String, + backup_id: Uuid, + recipient_public_key: [u8; P256_PUBLIC_KEY_LEN], +} + +impl HpkeBackupEnrollment { + /// Validate enrollment metadata supplied by a trusted native integration. + /// + /// Identifier strings are encoded as UTF-8 bytes without normalization in + /// AAD. Each must be 1 through 255 bytes and must not contain control + /// characters. The backup ID is encoded as its 16 raw UUID bytes. + pub fn new( + recipient_key_id: impl Into, + service_namespace: impl Into, + owner_id: impl Into, + backup_id: Uuid, + recipient_public_key_sec1: &[u8], + ) -> Result { + let recipient_key_id = recipient_key_id.into(); + let service_namespace = service_namespace.into(); + let owner_id = owner_id.into(); + validate_context_field("recipient key ID", &recipient_key_id)?; + validate_context_field("service namespace", &service_namespace)?; + validate_context_field("owner ID", &owner_id)?; + + let recipient_public_key: [u8; P256_PUBLIC_KEY_LEN] = recipient_public_key_sec1 + .try_into() + .map_err(|_| HpkeBackupError::InvalidRecipientKey("expected exactly 65 SEC1 bytes"))?; + if recipient_public_key[0] != 0x04 { + return Err(HpkeBackupError::InvalidRecipientKey( + "expected uncompressed SEC1 prefix 0x04", + )); + } + + Ok(Self { + recipient_key_id, + service_namespace, + owner_id, + backup_id, + recipient_public_key, + }) + } +} + +/// Opaque v1 backup envelope produced by [`seal_nostr_secret`]. +/// +/// JSON serialization is the wire representation. Binary HPKE values use +/// URL-safe base64 with no padding. This type contains no plaintext or raw nsec +/// material. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct HpkeBackupEnvelope { + version: u8, + suite: HpkeSuiteIds, + recipient_key_id: String, + service_namespace: String, + owner_id: String, + backup_id: String, + nostr_pubkey: String, + enc: String, + ciphertext: String, +} + +impl HpkeBackupEnvelope { + /// Reconstruct the v1 associated-data bytes from envelope metadata. + /// + /// This validates fixed suite/version values and canonical metadata + /// encodings before returning bytes. Receivers must separately validate + /// `enc` and ciphertext through the byte accessors before HPKE open. + pub fn associated_data(&self) -> Result, HpkeBackupError> { + if self.version != ENVELOPE_VERSION_V1 { + return Err(HpkeBackupError::InvalidEnvelope("unsupported version")); + } + if !self.suite.is_v1() { + return Err(HpkeBackupError::InvalidEnvelope("unsupported HPKE suite")); + } + validate_context_field("recipient key ID", &self.recipient_key_id)?; + validate_context_field("service namespace", &self.service_namespace)?; + validate_context_field("owner ID", &self.owner_id)?; + + let backup_id = Uuid::parse_str(&self.backup_id) + .map_err(|_| HpkeBackupError::InvalidEnvelope("backup ID is not a UUID"))?; + if backup_id.hyphenated().to_string() != self.backup_id { + return Err(HpkeBackupError::InvalidEnvelope( + "backup ID is not canonical lowercase hyphenated UUID", + )); + } + + let nostr_pubkey = PublicKey::from_hex(&self.nostr_pubkey) + .map_err(|_| HpkeBackupError::InvalidEnvelope("invalid Nostr public key"))?; + if nostr_pubkey.to_hex() != self.nostr_pubkey { + return Err(HpkeBackupError::InvalidEnvelope( + "Nostr public key is not canonical lowercase hex", + )); + } + + Ok(build_aad( + &self.recipient_key_id, + &self.service_namespace, + &self.owner_id, + backup_id.as_bytes(), + &nostr_pubkey.to_bytes(), + )) + } + + /// Decode and validate the RFC 9180 encapsulated P-256 public-key shape. + /// + /// This enforces the 65-byte uncompressed SEC1 representation and its + /// `0x04` prefix. The HPKE provider validates P-256 curve membership when + /// a recipient uses these bytes to open the envelope. + pub fn encapsulated_key_bytes(&self) -> Result, HpkeBackupError> { + let decoded = decode_canonical_base64( + &self.enc, + P256_PUBLIC_KEY_BASE64URL_LEN, + P256_PUBLIC_KEY_LEN, + "enc", + )?; + if decoded.first() != Some(&0x04) { + return Err(HpkeBackupError::InvalidField { + field: "enc", + reason: "expected uncompressed SEC1 prefix 0x04", + }); + } + Ok(decoded) + } + + /// Decode and validate the 32-byte-secret AES-256-GCM ciphertext. + pub fn ciphertext_bytes(&self) -> Result, HpkeBackupError> { + decode_canonical_base64( + &self.ciphertext, + CIPHERTEXT_BASE64URL_LEN, + CIPHERTEXT_LEN, + "ciphertext", + ) + } +} + +/// Seal one native Nostr secret key to a trusted HPKE backup enrollment. +/// +/// The function derives the Nostr public key from `secret_key` and encrypts its +/// raw 32-byte secret representation in RFC 9180 Base mode. `rustls` creates a +/// fresh ephemeral P-256 key for every call. HPKE Base mode does not +/// authenticate the sender; future upload authorization or a separate signature +/// must provide that property. +pub fn seal_nostr_secret( + secret_key: &SecretKey, + enrollment: &HpkeBackupEnrollment, +) -> Result { + let nostr_pubkey = PublicKey::from(secret_key.x_only_public_key(nostr::SECP256K1).0).to_hex(); + let mut envelope = HpkeBackupEnvelope { + version: ENVELOPE_VERSION_V1, + suite: HpkeSuiteIds::V1, + recipient_key_id: enrollment.recipient_key_id.clone(), + service_namespace: enrollment.service_namespace.clone(), + owner_id: enrollment.owner_id.clone(), + backup_id: enrollment.backup_id.hyphenated().to_string(), + nostr_pubkey, + enc: String::new(), + ciphertext: String::new(), + }; + let aad = envelope.associated_data()?; + // This clears our buffer on drop, but not the HPKE provider's internal + // plaintext copy, which can be freed without zeroization. + let plaintext = Zeroizing::new(secret_key.to_secret_bytes()); + + let recipient = HpkePublicKey(enrollment.recipient_public_key.to_vec()); + let (enc, ciphertext) = DH_KEM_P256_HKDF_SHA256_AES_256 + .seal(HPKE_INFO_V1, &aad, plaintext.as_ref(), &recipient) + .map_err(|_| HpkeBackupError::EncryptionFailed)?; + + if enc.0.len() != P256_PUBLIC_KEY_LEN || ciphertext.len() != CIPHERTEXT_LEN { + return Err(HpkeBackupError::EncryptionFailed); + } + envelope.enc = URL_SAFE_NO_PAD.encode(enc.0); + envelope.ciphertext = URL_SAFE_NO_PAD.encode(ciphertext); + Ok(envelope) +} + +fn validate_context_field(field: &'static str, value: &str) -> Result<(), HpkeBackupError> { + if value.is_empty() { + return Err(HpkeBackupError::InvalidField { + field, + reason: "must not be empty", + }); + } + if value.len() > MAX_CONTEXT_FIELD_LEN { + return Err(HpkeBackupError::InvalidField { + field, + reason: "must be at most 255 UTF-8 bytes", + }); + } + if value.chars().any(char::is_control) { + return Err(HpkeBackupError::InvalidField { + field, + reason: "must not contain control characters", + }); + } + Ok(()) +} + +fn build_aad( + recipient_key_id: &str, + service_namespace: &str, + owner_id: &str, + backup_id: &[u8; 16], + nostr_pubkey: &[u8; 32], +) -> Vec { + let mut aad = Vec::with_capacity( + AAD_DOMAIN_V1.len() + + 7 + + 6 + + recipient_key_id.len() + + service_namespace.len() + + owner_id.len() + + backup_id.len() + + nostr_pubkey.len(), + ); + aad.extend_from_slice(AAD_DOMAIN_V1); + aad.push(ENVELOPE_VERSION_V1); + aad.extend_from_slice(&KEM_ID_P256_HKDF_SHA256.to_be_bytes()); + aad.extend_from_slice(&KDF_ID_HKDF_SHA256.to_be_bytes()); + aad.extend_from_slice(&AEAD_ID_AES_256_GCM.to_be_bytes()); + push_framed(&mut aad, recipient_key_id.as_bytes()); + push_framed(&mut aad, service_namespace.as_bytes()); + push_framed(&mut aad, owner_id.as_bytes()); + aad.extend_from_slice(backup_id); + aad.extend_from_slice(nostr_pubkey); + aad +} + +fn push_framed(output: &mut Vec, value: &[u8]) { + // All callers first apply MAX_CONTEXT_FIELD_LEN, which is below u16::MAX. + output.extend_from_slice(&(value.len() as u16).to_be_bytes()); + output.extend_from_slice(value); +} + +fn decode_canonical_base64( + value: &str, + expected_encoded_len: usize, + expected_decoded_len: usize, + field: &'static str, +) -> Result, HpkeBackupError> { + // `str::len` is the UTF-8 byte length. Check it before decoding so an + // untrusted JSON string cannot cause an allocation proportional to input. + if value.len() != expected_encoded_len { + return Err(HpkeBackupError::InvalidField { + field, + reason: "unexpected encoded length", + }); + } + let decoded = URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| HpkeBackupError::InvalidField { + field, + reason: "invalid base64url", + })?; + if decoded.len() != expected_decoded_len { + return Err(HpkeBackupError::InvalidField { + field, + reason: "unexpected decoded length", + }); + } + if URL_SAFE_NO_PAD.encode(&decoded) != value { + return Err(HpkeBackupError::InvalidField { + field, + reason: "base64url is not canonical and unpadded", + }); + } + Ok(decoded) +} + +#[cfg(test)] +#[path = "hpke_key_backup_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/hpke_key_backup_tests.rs b/desktop/src-tauri/src/hpke_key_backup_tests.rs new file mode 100644 index 0000000000..3b15964ba5 --- /dev/null +++ b/desktop/src-tauri/src/hpke_key_backup_tests.rs @@ -0,0 +1,408 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use nostr::{Keys, SecretKey, ToBech32}; +use rustls::crypto::hpke::{EncapsulatedSecret, Hpke, HpkePrivateKey}; + +use super::*; + +fn test_secret() -> SecretKey { + SecretKey::from_slice(&[0x42; 32]).expect("fixed test secret is valid") +} + +fn test_backup_id() -> Uuid { + Uuid::parse_str("018f6f4e-83d1-7a4e-8c8e-4f914f7d31a2").expect("fixed UUID is valid") +} + +fn test_recipient() -> (rustls::crypto::hpke::HpkePublicKey, HpkePrivateKey) { + DH_KEM_P256_HKDF_SHA256_AES_256 + .generate_key_pair() + .expect("generate test HPKE recipient") +} + +fn enrollment(public_key: &[u8]) -> HpkeBackupEnrollment { + HpkeBackupEnrollment::new( + "backup-key-2026-09", + "backup/staging", + "subject:01J8A4M7Q1G4X8S8M51FS7K2HX", + test_backup_id(), + public_key, + ) + .expect("valid test enrollment") +} + +fn open_envelope( + envelope: &HpkeBackupEnvelope, + private_key: &HpkePrivateKey, +) -> Result, rustls::Error> { + let aad = envelope + .associated_data() + .map_err(|error| rustls::Error::General(error.to_string()))?; + let enc = envelope + .encapsulated_key_bytes() + .map_err(|error| rustls::Error::General(error.to_string()))?; + let ciphertext = envelope + .ciphertext_bytes() + .map_err(|error| rustls::Error::General(error.to_string()))?; + DH_KEM_P256_HKDF_SHA256_AES_256.open( + &EncapsulatedSecret(enc), + HPKE_INFO_V1, + &aad, + &ciphertext, + private_key, + ) +} + +#[test] +fn receiver_recovers_exact_secret_and_matching_pubkey() { + let (public_key, private_key) = test_recipient(); + let secret = test_secret(); + let envelope = seal_nostr_secret(&secret, &enrollment(&public_key.0)).expect("seal backup"); + + let opened = open_envelope(&envelope, &private_key).expect("open backup"); + assert_eq!(opened, secret.as_secret_bytes()); + assert_eq!( + envelope.nostr_pubkey, + Keys::new(secret).public_key().to_hex() + ); + assert_eq!(envelope.enc.len(), 87); + assert_eq!(envelope.ciphertext.len(), 64); + assert_eq!(envelope.encapsulated_key_bytes().unwrap().len(), 65); + assert_eq!(envelope.ciphertext_bytes().unwrap().len(), 48); +} + +#[test] +fn every_backup_uses_a_fresh_hpke_context() { + let (public_key, private_key) = test_recipient(); + let enrollment = enrollment(&public_key.0); + let secret = test_secret(); + + let first = seal_nostr_secret(&secret, &enrollment).expect("first seal"); + let second = seal_nostr_secret(&secret, &enrollment).expect("second seal"); + + assert_ne!(first.enc, second.enc); + assert_ne!(first.ciphertext, second.ciphertext); + assert_eq!(open_envelope(&first, &private_key).unwrap(), [0x42; 32]); + assert_eq!(open_envelope(&second, &private_key).unwrap(), [0x42; 32]); +} + +#[test] +fn malformed_and_invalid_recipient_keys_are_rejected() { + let short = [0x04; 64]; + assert!(matches!( + HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &short), + Err(HpkeBackupError::InvalidRecipientKey(_)) + )); + + let compressed = [0x02; 33]; + assert!( + HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &compressed) + .is_err() + ); + + let mut wrong_prefix = [0u8; P256_PUBLIC_KEY_LEN]; + wrong_prefix[0] = 0x03; + assert!( + HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &wrong_prefix) + .is_err() + ); + + let mut off_curve = [0u8; P256_PUBLIC_KEY_LEN]; + off_curve[0] = 0x04; + let enrollment = + HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &off_curve) + .expect("structurally encoded point reaches provider validation"); + assert_eq!( + seal_nostr_secret(&test_secret(), &enrollment), + Err(HpkeBackupError::EncryptionFailed) + ); +} + +#[test] +fn metadata_ciphertext_and_encapsulation_tampering_fail_closed() { + let (public_key, private_key) = test_recipient(); + let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap(); + + let mut recipient_key_id_tampered = envelope.clone(); + recipient_key_id_tampered + .recipient_key_id + .push_str("-different"); + let mut namespace_tampered = envelope.clone(); + namespace_tampered.service_namespace.push_str("-different"); + let mut owner_tampered = envelope.clone(); + owner_tampered.owner_id.push_str("-different"); + let mut backup_id_tampered = envelope.clone(); + backup_id_tampered.backup_id = Uuid::new_v4().hyphenated().to_string(); + let mut pubkey_tampered = envelope.clone(); + pubkey_tampered.nostr_pubkey = Keys::generate().public_key().to_hex(); + for tampered in [ + recipient_key_id_tampered, + namespace_tampered, + owner_tampered, + backup_id_tampered, + pubkey_tampered, + ] { + assert!(open_envelope(&tampered, &private_key).is_err()); + } + + let aad = envelope.associated_data().unwrap(); + let enc = envelope.encapsulated_key_bytes().unwrap(); + let ciphertext = envelope.ciphertext_bytes().unwrap(); + assert!(DH_KEM_P256_HKDF_SHA256_AES_256 + .open( + &EncapsulatedSecret(enc), + b"buzz/nsec-backup/v2", + &aad, + &ciphertext, + &private_key, + ) + .is_err()); + + let mut ciphertext_tampered = envelope.clone(); + let mut ciphertext = ciphertext_tampered.ciphertext_bytes().unwrap(); + ciphertext[0] ^= 1; + ciphertext_tampered.ciphertext = URL_SAFE_NO_PAD.encode(ciphertext); + assert!(open_envelope(&ciphertext_tampered, &private_key).is_err()); + + let mut enc_tampered = envelope.clone(); + let mut enc = enc_tampered.encapsulated_key_bytes().unwrap(); + let last = enc.len() - 1; + enc[last] ^= 1; + enc_tampered.enc = URL_SAFE_NO_PAD.encode(enc); + assert!(open_envelope(&enc_tampered, &private_key).is_err()); +} + +#[test] +fn wrong_recipient_cannot_open() { + let (public_key, _) = test_recipient(); + let (_, wrong_private_key) = test_recipient(); + let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap(); + assert!(open_envelope(&envelope, &wrong_private_key).is_err()); +} + +#[test] +fn enrollment_rejects_invalid_context_fields() { + let (public_key, _) = test_recipient(); + assert!( + HpkeBackupEnrollment::new("", "service", "owner", test_backup_id(), &public_key.0).is_err() + ); + assert!(HpkeBackupEnrollment::new( + "key", + "x".repeat(256), + "owner", + test_backup_id(), + &public_key.0 + ) + .is_err()); + assert!(HpkeBackupEnrollment::new( + "key", + "service", + "owner\nvalue", + test_backup_id(), + &public_key.0 + ) + .is_err()); +} + +#[test] +fn aad_length_framing_prevents_field_boundary_collisions() { + let (public_key, private_key) = test_recipient(); + let secret = test_secret(); + + for (left, right) in [ + (["ab", "c", "owner"], ["a", "bc", "owner"]), + (["key", "ab", "c"], ["key", "a", "bc"]), + ] { + assert_eq!(left.concat(), right.concat()); + let enrollment = + HpkeBackupEnrollment::new(left[0], left[1], left[2], test_backup_id(), &public_key.0) + .unwrap(); + let envelope = seal_nostr_secret(&secret, &enrollment).unwrap(); + assert_eq!( + open_envelope(&envelope, &private_key).unwrap(), + secret.as_secret_bytes() + ); + + let mut reassigned = envelope.clone(); + reassigned.recipient_key_id = right[0].into(); + reassigned.service_namespace = right[1].into(); + reassigned.owner_id = right[2].into(); + assert_ne!( + envelope.associated_data().unwrap(), + reassigned.associated_data().unwrap() + ); + assert!(open_envelope(&reassigned, &private_key).is_err()); + } +} + +#[test] +fn envelope_rejects_noncanonical_or_conflicting_metadata() { + let (public_key, _) = test_recipient(); + let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap(); + + let mut wrong_version = envelope.clone(); + wrong_version.version = 2; + assert!(wrong_version.associated_data().is_err()); + + let mut wrong_suite = envelope.clone(); + wrong_suite.suite.aead_id = 1; + assert!(wrong_suite.associated_data().is_err()); + + let mut uppercase_pubkey = envelope.clone(); + uppercase_pubkey.nostr_pubkey.make_ascii_uppercase(); + assert!(uppercase_pubkey.associated_data().is_err()); + + let mut padded_base64 = envelope.clone(); + padded_base64.enc.push('='); + assert!(padded_base64.encapsulated_key_bytes().is_err()); +} + +#[test] +fn envelope_rejects_binary_fields_with_wrong_byte_lengths_before_decode() { + let (public_key, _) = test_recipient(); + let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap(); + + let mut oversized_enc = envelope.clone(); + oversized_enc.enc.push_str(&"A".repeat(1_000_000)); + assert_eq!( + oversized_enc.encapsulated_key_bytes(), + Err(HpkeBackupError::InvalidField { + field: "enc", + reason: "unexpected encoded length", + }) + ); + + let mut short_enc = envelope.clone(); + short_enc.enc.pop(); + assert_eq!( + short_enc.encapsulated_key_bytes(), + Err(HpkeBackupError::InvalidField { + field: "enc", + reason: "unexpected encoded length", + }) + ); + + let mut multibyte_ciphertext = envelope.clone(); + multibyte_ciphertext.ciphertext = "é".repeat(envelope.ciphertext.len()); + assert_eq!( + multibyte_ciphertext.ciphertext_bytes(), + Err(HpkeBackupError::InvalidField { + field: "ciphertext", + reason: "unexpected encoded length", + }) + ); +} + +#[test] +fn envelope_rejects_malformed_base64_and_wrong_encapsulation_prefix() { + let (public_key, _) = test_recipient(); + let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap(); + + let mut malformed_enc = envelope.clone(); + malformed_enc.enc.replace_range(..1, "!"); + assert_eq!( + malformed_enc.encapsulated_key_bytes(), + Err(HpkeBackupError::InvalidField { + field: "enc", + reason: "invalid base64url", + }) + ); + + let mut malformed_ciphertext = envelope.clone(); + malformed_ciphertext.ciphertext.replace_range(..1, "!"); + assert_eq!( + malformed_ciphertext.ciphertext_bytes(), + Err(HpkeBackupError::InvalidField { + field: "ciphertext", + reason: "invalid base64url", + }) + ); + + let mut wrong_prefix = envelope.clone(); + let mut enc = URL_SAFE_NO_PAD.decode(&wrong_prefix.enc).unwrap(); + enc[0] = 0x03; + wrong_prefix.enc = URL_SAFE_NO_PAD.encode(enc); + assert_eq!( + wrong_prefix.encapsulated_key_bytes(), + Err(HpkeBackupError::InvalidField { + field: "enc", + reason: "expected uncompressed SEC1 prefix 0x04", + }) + ); +} + +#[test] +fn serialized_envelope_contains_no_plaintext_or_nsec() { + let (public_key, _) = test_recipient(); + let secret = test_secret(); + let envelope = seal_nostr_secret(&secret, &enrollment(&public_key.0)).unwrap(); + let json = serde_json::to_string(&envelope).unwrap(); + let raw_hex = hex::encode(secret.as_secret_bytes()); + let nsec = secret.to_bech32().unwrap(); + + assert!(!json.contains(&raw_hex)); + assert!(!json.contains(&nsec)); + assert!(!json.contains("nsec1")); +} + +#[test] +fn official_rfc9180_exact_suite_vector_opens() { + // RFC 9180's JSON vector for the KEM/KDF/AEAD tuple used here. + // Source pinned by the RFC itself: + // https://github.com/cfrg/draft-irtf-cfrg-hpke/blob/ + // 5f503c564da00b0687b3de75f1dfbdfc4079ad31/test-vectors.json + let info = hex::decode("4f6465206f6e2061204772656369616e2055726e").unwrap(); + let aad = hex::decode("436f756e742d30").unwrap(); + let plaintext = + hex::decode("4265617574792069732074727574682c20747275746820626561757479").unwrap(); + let enc = hex::decode( + "04c06b4f6bebc7bb495cb797ab753f911aff80aefb86fd8b6fcc35525f3ab5f03e0b21bd31a86c6048af3cb2d98e0d3bf01da5cc4c39ff5370d331a4f1f7d5a4e0", + ) + .unwrap(); + let ciphertext = hex::decode( + "58c61a45059d0c5704560e9d88b564a8b63f1364b8d1fcb3c4c6ddc1d291742465e902cd216f8908da49f8f96f", + ) + .unwrap(); + let recipient_private = HpkePrivateKey::from( + hex::decode("317f915db7bc629c48fe765587897e01e282d3e8445f79f27f65d031a88082b2").unwrap(), + ); + + let opened = DH_KEM_P256_HKDF_SHA256_AES_256 + .open( + &EncapsulatedSecret(enc), + &info, + &aad, + &ciphertext, + &recipient_private, + ) + .expect("open official CFRG vector"); + assert_eq!(opened, plaintext); +} + +#[derive(serde::Deserialize)] +struct InteropFixture { + recipient_public_key_sec1_hex: String, + recipient_private_key_hex: String, + nostr_secret_key_hex: String, + aad_hex: String, + envelope: HpkeBackupEnvelope, +} + +#[test] +fn checked_in_v1_fixture_opens_and_reconstructs_aad() { + let fixture: InteropFixture = + serde_json::from_str(include_str!("testdata/hpke_nsec_backup_v1.json")) + .expect("parse checked-in interop fixture"); + let recipient_public = hex::decode(&fixture.recipient_public_key_sec1_hex).unwrap(); + let recipient_private = + HpkePrivateKey::from(hex::decode(&fixture.recipient_private_key_hex).unwrap()); + let secret = hex::decode(&fixture.nostr_secret_key_hex).unwrap(); + + assert_eq!(recipient_public.len(), P256_PUBLIC_KEY_LEN); + assert_eq!( + hex::encode(fixture.envelope.associated_data().unwrap()), + fixture.aad_hex + ); + assert_eq!( + open_envelope(&fixture.envelope, &recipient_private).unwrap(), + secret + ); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index dce4a05a81..a37d9c536a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -10,6 +10,8 @@ mod deep_link; mod egress_guard; mod event_sync; mod events; +#[cfg_attr(not(test), allow(dead_code))] +mod hpke_key_backup; mod huddle; mod identity_storage; mod initial_window; diff --git a/desktop/src-tauri/src/media_proxy.rs b/desktop/src-tauri/src/media_proxy.rs index 21692ce523..79128eca0f 100644 --- a/desktop/src-tauri/src/media_proxy.rs +++ b/desktop/src-tauri/src/media_proxy.rs @@ -76,6 +76,38 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> } }; + // Re-mint and retry once on an auth failure (401 missing_evidence or 403 + // evidence_rejected). Under NIP-FI strict mode a read token expires after + // 60s, so a long video range-request stream will exhaust its token mid-flight. + // Single retry; if the fresh token also fails, propagate the error to the caller. + let resp = if (resp.status() == reqwest::StatusCode::UNAUTHORIZED + || resp.status() == reqwest::StatusCode::FORBIDDEN) + && has_range + { + if let Some(fresh_auth) = mint_media_get_auth(&app_state, &base_url) { + let mut retry = state + .client + .get(&upstream_url) + .timeout(std::time::Duration::from_secs(120)) + .header("authorization", fresh_auth); + if let Some(range) = req.headers().get("range") { + if let Ok(v) = range.to_str() { + retry = retry.header("range", v); + } + } + match retry.send().await { + Ok(r) => r, + Err(_) => { + return (StatusCode::BAD_GATEWAY, "upstream request failed").into_response(); + } + } + } else { + resp + } + } else { + resp + }; + let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); @@ -199,6 +231,34 @@ pub async fn handle_buzz_media( let result = upstream.send().await; + // Re-mint and retry once on auth failure (401/403) for range requests. + // Under NIP-FI strict mode a read token expires after 60s, exhausting + // mid-stream. Single retry with a fresh token; propagate on second failure. + let result = match result { + Ok(resp) + if (resp.status() == reqwest::StatusCode::UNAUTHORIZED + || resp.status() == reqwest::StatusCode::FORBIDDEN) + && has_range => + { + if let Some(fresh_auth) = mint_media_get_auth(&state, &base) { + let mut retry = state + .http_client + .get(&upstream_url) + .timeout(std::time::Duration::from_secs(60)) + .header("authorization", fresh_auth); + if let Some(range) = request.headers().get("range") { + if let Ok(v) = range.to_str() { + retry = retry.header("range", v); + } + } + retry.send().await + } else { + Ok(resp) + } + } + other => other, + }; + match result { Ok(resp) => { let status = resp.status().as_u16(); diff --git a/desktop/src-tauri/src/native_relay_client_finite_tests.rs b/desktop/src-tauri/src/native_relay_client_finite_tests.rs index 913d17f5fd..7b236b98ac 100644 --- a/desktop/src-tauri/src/native_relay_client_finite_tests.rs +++ b/desktop/src-tauri/src/native_relay_client_finite_tests.rs @@ -101,6 +101,7 @@ async fn terminal_refusal_and_deadline_do_not_become_empty_success() { for message in [ "restricted: denied", "error: transient", + "error: query timed out", "rate-limited: quota exceeded; retry in 10s", "rate-limited: quota exceeded", "rate-limited: quota exceeded; retry in 18446744073709551615s", diff --git a/desktop/src-tauri/src/native_relay_client_tests.rs b/desktop/src-tauri/src/native_relay_client_tests.rs index f12a689449..ee284be518 100644 --- a/desktop/src-tauri/src/native_relay_client_tests.rs +++ b/desktop/src-tauri/src/native_relay_client_tests.rs @@ -328,6 +328,32 @@ async fn a_closed_subscription_reopens_without_a_desired_set_change() { session.shutdown(); } +/// A statement deadline on a persistent subscription is transient (archive +/// subs are `limit: 0` live tails), so it backs off and reopens rather than +/// latching terminal. Finite requests still fail on it; see +/// `native_relay_client_finite_tests.rs`. +#[tokio::test] +async fn a_deadline_closed_subscription_reopens_after_backoff() { + let (relay_url, mut frames, closed) = stub_relay().await; + let (session, _events) = start(relay_url, Keys::generate(), None).await; + + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID); + settle().await; + + closed + .send(StubCommand::Closed( + PROBE_ID.into(), + "error: query timed out".into(), + )) + .await + .expect("stub relay accepts the closed command"); + + assert_eq!(next_req(&mut frames, "the reopened REQ").await, PROBE_ID); + + session.shutdown(); +} + /// A relay that rejects on policy must not be re-asked in a tight loop. #[tokio::test] async fn a_terminal_closed_is_not_retried_on_the_same_socket() { diff --git a/desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json b/desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json new file mode 100644 index 0000000000..0bbecac656 --- /dev/null +++ b/desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json @@ -0,0 +1,22 @@ +{ + "description": "Buzz HPKE nsec backup v1 Kotlin interop fixture; keys are test-only", + "recipient_public_key_sec1_hex": "04c5b6519400186a8e10abbdc05d3e450c8612e5dba06927091527110324acfb0371d98d662395d872fae967fe75ef24db20a7c2c1af19c34422d94f629b1b1dad", + "recipient_private_key_hex": "7ad00e296b224923f735144f3cc7e49746c8e3ebf20b8002d0a73998467d8f78", + "nostr_secret_key_hex": "4242424242424242424242424242424242424242424242424242424242424242", + "aad_hex": "62757a7a2f6e7365632d6261636b75702f6161642f76310100100001000200126261636b75702d6b65792d323032362d3039000e6261636b75702f73746167696e6700227375626a6563743a30314a3841344d3751314734583853384d35314653374b324858018f6f4e83d17a4e8c8e4f914f7d31a224653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "envelope": { + "version": 1, + "suite": { + "aead_id": 2, + "kdf_id": 1, + "kem_id": 16 + }, + "recipient_key_id": "backup-key-2026-09", + "service_namespace": "backup/staging", + "owner_id": "subject:01J8A4M7Q1G4X8S8M51FS7K2HX", + "backup_id": "018f6f4e-83d1-7a4e-8c8e-4f914f7d31a2", + "nostr_pubkey": "24653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "enc": "BHCSkhHMDDKXgK5vWuP4Gw3NNaMMdrN-1OPUISh_jczrjoZK7HBmMkZE2iogEKle5syyp3-wgGo69cPeHVNEuWA", + "ciphertext": "tkBhVlv3rco5mZOIEbESB2Hx_etTIvWQHJJP7hCOIgEysJjwQqnq7iCC0A516Du8" + } +} diff --git a/desktop/src/features/messages/lib/renderScopedReactions.test.mjs b/desktop/src/features/messages/lib/renderScopedReactions.test.mjs index 6429ecf811..37d971b268 100644 --- a/desktop/src/features/messages/lib/renderScopedReactions.test.mjs +++ b/desktop/src/features/messages/lib/renderScopedReactions.test.mjs @@ -174,3 +174,30 @@ test("failed hydration releases ids so the next render can retry", async () => { [messageId], ); }); + +for (const message of [ + "error: query timed out", + "relay returned 503 Service Unavailable: query timed out", +]) { + test(`deadline-failed hydration keeps ids claimed: ${message}`, async () => { + const messageId = hex("1"); + const queryClient = makeQueryClientStub([event(messageId, 9)]); + + await hydrateRenderScopedReactions({ + channelId: CHANNEL_ID, + messageIds: [messageId], + queryClient, + deps: { + fetchReactionEventsForMessages: async () => { + throw new Error(message); + }, + }, + }); + + // Still claimed: the next render must not re-send the same slow read. + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [messageId]), + [], + ); + }); +} diff --git a/desktop/src/features/messages/lib/renderScopedReactions.ts b/desktop/src/features/messages/lib/renderScopedReactions.ts index 2c35cdf79e..e5128770e8 100644 --- a/desktop/src/features/messages/lib/renderScopedReactions.ts +++ b/desktop/src/features/messages/lib/renderScopedReactions.ts @@ -6,6 +6,7 @@ import type { TimelineMessage } from "../types"; import { relayClient } from "@/shared/api/relayClient"; import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters"; import type { RelayEvent } from "@/shared/api/types"; +import { isQueryDeadlineError } from "@/shared/lib/relayError"; export type RenderScopedReactionDeps = { fetchReactionEventsForMessages: ( @@ -24,9 +25,22 @@ const defaultDeps: RenderScopedReactionDeps = { }; const hydratedMessageIdsByChannel = new Map>(); +// Ids held claimed after a relay deadline, so automatic re-renders skip them +// until the user reopens the channel. +const deadlineClaimedIdsByChannel = new Map>(); export function resetRenderScopedReactionHydration() { hydratedMessageIdsByChannel.clear(); + deadlineClaimedIdsByChannel.clear(); +} + +export function releaseDeadlineClaimedReactionIds(channelId: string) { + const ids = deadlineClaimedIdsByChannel.get(channelId); + if (!ids) { + return; + } + deadlineClaimedIdsByChannel.delete(channelId); + releaseRenderScopedReactionIds(channelId, [...ids]); } function hydratedSetForChannel(channelId: string): Set { @@ -130,7 +144,19 @@ export async function hydrateRenderScopedReactions(input: { (current = []) => sortMessages([...current, ...reactionEvents]), ); } catch (error) { - releaseRenderScopedReactionIds(input.channelId, messageIds); + // Keep deadline-failed ids claimed: releasing them lets the next render + // re-send the same slow `#e` read. Reopening the channel releases them; + // other failures stay retryable immediately. + if (isQueryDeadlineError(error)) { + let held = deadlineClaimedIdsByChannel.get(input.channelId); + if (!held) { + held = new Set(); + deadlineClaimedIdsByChannel.set(input.channelId, held); + } + for (const id of messageIds) held.add(id); + } else { + releaseRenderScopedReactionIds(input.channelId, messageIds); + } console.error( "Failed to hydrate visible reactions for channel", input.channelId, diff --git a/desktop/src/features/messages/lib/useRenderScopedReactionHydration.jsdom-test.mjs b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.jsdom-test.mjs new file mode 100644 index 0000000000..97d3d6d2af --- /dev/null +++ b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.jsdom-test.mjs @@ -0,0 +1,124 @@ +/** + * Reopen recovery for deadline-held reactions, through the production hook. + * + * A reaction fetch that hits the relay deadline keeps its ids claimed so + * automatic re-renders do not re-send the slow read. Reopening the channel is + * the explicit retry: the hook's `channelId` effect releases the held ids. + * + * Falsifiability: deleting the release effect in + * `useRenderScopedReactionHydration.ts` leaves the reopen with no fetch. + */ + +import assert from "node:assert/strict"; +import { afterEach, mock, test } from "node:test"; + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { relayClient } from "@/shared/api/relayClient"; +import { channelMessagesKey } from "./messageQueryKeys.ts"; +import { resetRenderScopedReactionHydration } from "./renderScopedReactions.ts"; +import { useRenderScopedReactionHydration } from "./useRenderScopedReactionHydration.ts"; + +const CHANNEL_A = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; +const CHANNEL_B = "8a1c3f52-6d0e-4b7a-9c21-0f5e7d3b2a10"; +const MESSAGE_ID = "1".repeat(64); +const REACTION = { + id: "9".repeat(64), + pubkey: "a".repeat(64), + kind: 7, + created_at: 1_700_000_000, + content: "✅", + tags: [["e", MESSAGE_ID]], + sig: "sig", +}; + +afterEach(() => { + mock.restoreAll(); + resetRenderScopedReactionHydration(); +}); + +function Harness({ channelId, entries }) { + useRenderScopedReactionHydration({ + activeChannel: { id: channelId, channelType: "stream" }, + mainTimelineEntries: entries, + threadHeadMessage: null, + threadMessages: [], + }); + return null; +} + +async function flush() { + // Hydration is deferred with window.setTimeout(0). + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +} + +test("reopening a channel refetches deadline-held reactions; same-channel re-renders do not", async () => { + const requests = []; + let failWithDeadline = true; + mock.method(relayClient, "fetchAuxEventsByReference", async (channelId) => { + requests.push(channelId); + if (channelId === CHANNEL_A && failWithDeadline) { + throw new Error("error: query timed out"); + } + return channelId === CHANNEL_A ? [REACTION] : []; + }); + mock.method(console, "error", () => {}); + + const queryClient = new QueryClient(); + queryClient.setQueryData(channelMessagesKey(CHANNEL_A), []); + const container = document.createElement("div"); + const root = createRoot(container); + const render = (channelId) => + act(() => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Harness, { + channelId, + // Fresh array each render, as a live timeline update produces. + entries: [{ message: { id: MESSAGE_ID }, summary: null }], + }), + ), + ); + }); + + await render(CHANNEL_A); + await flush(); + assert.deepEqual( + requests, + [CHANNEL_A], + "first open fetches and hits the deadline", + ); + + failWithDeadline = false; + await render(CHANNEL_A); + await flush(); + assert.deepEqual( + requests, + [CHANNEL_A], + "same-channel re-render keeps ids held", + ); + + await render(CHANNEL_B); + await flush(); + await render(CHANNEL_A); + await flush(); + assert.deepEqual( + requests, + [CHANNEL_A, CHANNEL_B, CHANNEL_A], + "reopening channel A refetches its held ids", + ); + assert.ok( + queryClient + .getQueryData(channelMessagesKey(CHANNEL_A)) + .some((event) => event.id === REACTION.id), + "recovered reaction lands in the channel cache", + ); + + act(() => root.unmount()); +}); diff --git a/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts index 8bc3f7724c..641bf80cde 100644 --- a/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts +++ b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts @@ -7,6 +7,7 @@ import type { Channel } from "@/shared/api/types"; import { collectRenderScopedReactionMessageIds, hydrateRenderScopedReactions, + releaseDeadlineClaimedReactionIds, } from "./renderScopedReactions"; export function useRenderScopedReactionHydration(input: { @@ -16,6 +17,13 @@ export function useRenderScopedReactionHydration(input: { threadMessages: MainTimelineEntry[]; }) { const queryClient = useQueryClient(); + const channelId = input.activeChannel?.id; + + // Opening a channel is the explicit retry for reactions a relay deadline + // held back. Declared first so it runs before this render's hydration. + React.useEffect(() => { + if (channelId) releaseDeadlineClaimedReactionIds(channelId); + }, [channelId]); React.useEffect(() => { const channelId = input.activeChannel?.id; diff --git a/desktop/src/features/messages/queryDeadlineRetry.test.mjs b/desktop/src/features/messages/queryDeadlineRetry.test.mjs new file mode 100644 index 0000000000..c8dd559bfa --- /dev/null +++ b/desktop/src/features/messages/queryDeadlineRetry.test.mjs @@ -0,0 +1,154 @@ +/** + * Deadline-retry contract, exercised under the production QueryClient: + * a read that hit the relay's `query timed out` deadline is fetched once; an + * ordinary failure, including a client-side request timeout, keeps its retries. + * Removing the predicate from `createBuzzQueryClient` or `useThreadReplies` + * makes the deadline cases here fetch more than once. + */ + +import assert from "node:assert/strict"; +import { mock } from "node:test"; +import test from "node:test"; +import { registerHooks } from "node:module"; + +import { JSDOM } from "jsdom"; + +globalThis.__tauriGetThreadReplies = async () => ({ + events: [], + nextCursor: null, +}); + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/api/tauri") { + return { shortCircuit: true, url: "buzz-deadline-stub:tauri" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-deadline-stub:tauri") { + return { + format: "module", + shortCircuit: true, + source: `export async function getThreadReplies(...args) { + return globalThis.__tauriGetThreadReplies(...args); +} +export default {};`, + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", + pretendToBeVisual: true, +}); +dom.window.document.hasFocus = () => true; +Object.assign(globalThis, { + IS_REACT_ACT_ENVIRONMENT: true, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, +}); + +const { act, cleanup, renderHook } = await import("@testing-library/react"); +const { createElement } = await import("react"); +const { QueryClientProvider, useQuery } = await import("@tanstack/react-query"); +const { createBuzzQueryClient } = await import("@/shared/api/queryClient"); +const { useThreadReplies, useThreadRepliesForRoots } = await import( + "./useThreadReplies.ts" +); + +const CASES = [ + { + name: "client request timeout", + message: "relay unreachable: request timed out", + deadline: false, + }, + { + name: "relay deadline", + message: "relay returned 503 Service Unavailable: query timed out", + deadline: true, + }, + { + name: "ordinary error", + message: "relay returned 500: internal server error", + deadline: false, + }, +]; + +// Mount `useHook` under the production client, drive every retry delay with +// fake timers until the query settles in error, and return the fetch count. +async function fetchCountUntilError(useHook, isSettled) { + mock.timers.enable({ apis: ["setTimeout"] }); + const queryClient = createBuzzQueryClient(); + const wrapper = ({ children }) => + createElement(QueryClientProvider, { client: queryClient }, children); + try { + const { result, unmount } = renderHook(useHook, { wrapper }); + for (let i = 0; i < 20 && !isSettled(result.current); i++) { + mock.timers.tick(60_000); + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + } + assert.ok(isSettled(result.current), "query never settled in error"); + unmount(); + } finally { + cleanup(); + queryClient.clear(); + mock.timers.reset(); + } +} + +for (const { name, message, deadline } of CASES) { + test(`createBuzzQueryClient default retry: ${name}`, async () => { + let calls = 0; + await fetchCountUntilError( + () => + useQuery({ + queryKey: ["deadline-default", name], + queryFn: async () => { + calls += 1; + throw new Error(message); + }, + }), + (r) => r.isError, + ); + assert.equal(calls, deadline ? 1 : 2); + }); + + test(`useThreadRepliesForRoots (3 roots) retry: ${name}`, async () => { + const calls = new Map(); + globalThis.__tauriGetThreadReplies = async (rootId) => { + calls.set(rootId, (calls.get(rootId) ?? 0) + 1); + throw new Error(message); + }; + const channel = { id: `chan-roots-${name}`, channelType: "group" }; + const roots = ["root-a", "root-b", "root-c"]; + await fetchCountUntilError( + () => useThreadRepliesForRoots(channel, roots), + (r) => r.isError && !r.isPending, + ); + assert.deepEqual( + Object.fromEntries(calls), + Object.fromEntries(roots.map((root) => [root, deadline ? 1 : 2])), + ); + }); + + test(`useThreadReplies single-root retry: ${name}`, async () => { + let calls = 0; + globalThis.__tauriGetThreadReplies = async () => { + calls += 1; + throw new Error(message); + }; + const channel = { id: `chan-single-${name}`, channelType: "group" }; + await fetchCountUntilError( + () => useThreadReplies(channel, "root-single"), + (r) => r.isError, + ); + // useThreadReplies keeps its own `< 3` retries for ordinary failures. + assert.equal(calls, deadline ? 1 : 4); + }); +} diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index 9ad86e46a8..e23eb2a189 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -11,6 +11,7 @@ import { sortMessages, } from "@/features/messages/lib/messageQueryKeys"; import { getThreadReplies } from "@/shared/api/tauri"; +import { isQueryDeadlineError } from "@/shared/lib/relayError"; import type { Channel, RelayEvent, @@ -163,7 +164,10 @@ export function useThreadReplies( // throws on attempts 1 and 2; attempt 3 records exhaustion first so // loadThreadReplies returns data directly — the terminal attempt always // resolves to success. - retry: 3, + // A read that hit the relay's statement deadline is not retried: + // each retry would re-run the same slow server-side query. + retry: (failureCount, error) => + failureCount < 3 && !isQueryDeadlineError(error), retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 30_000), }); diff --git a/desktop/src/features/profile/profileBatchResilience.test.mjs b/desktop/src/features/profile/profileBatchResilience.test.mjs index 53f8c1ff11..7939581600 100644 --- a/desktop/src/features/profile/profileBatchResilience.test.mjs +++ b/desktop/src/features/profile/profileBatchResilience.test.mjs @@ -43,8 +43,13 @@ test("useUsersBatchQuery: global queryClient defaults are unchanged", async () = new URL("../../shared/api/queryClient.ts", import.meta.url), "utf8", ); - // Global retry must remain 1 — other queries chose that semantics. - assert.match(source, /retry:\s*1/, "global retry default must remain 1"); + // Global retry budget must remain 1 — other queries chose that semantics. + // Query-deadline failures are exempt so a slow server query is not re-run. + assert.match( + source, + /failureCount < 1 && !isQueryDeadlineError\(error\)/, + "global retry default must remain 1, skipping query deadlines", + ); // Global refetchOnWindowFocus must remain false. assert.match( source, diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.ts b/desktop/src/features/sidebar/lib/channelMutesSync.ts index 5e8a17e74d..dd40aaac4a 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.ts +++ b/desktop/src/features/sidebar/lib/channelMutesSync.ts @@ -46,6 +46,11 @@ export class ChannelMuteSyncManager { private lastRemoteCreatedAt: number; private pendingStore: ChannelMuteStore | null = null; private lastPublishedStore: ChannelMuteStore | null = null; + /** The bootstrap first-copy seed; retired once any relay head is observed. */ + private seedStore: ChannelMuteStore | null = null; + /** Decoded heads, LWW-merged within the 500-entry bound, for each attempt. */ + private remoteStore: ChannelMuteStore | null = null; + private remoteRev = 0; private destroyed = false; constructor(pubkey: string, relayUrl: string) { @@ -71,6 +76,7 @@ export class ChannelMuteSyncManager { if (!result) { return { status: "failed", createdAt: event.created_at }; } + this.remember(result.store); return { status: "found", data: result, @@ -87,6 +93,22 @@ export class ChannelMuteSyncManager { this.lastRemoteCreatedAt = createdAt; } advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); + // A seed only fills a relay believed empty; any head supersedes it, even + // one whose payload a concurrent reader drops or cannot decrypt. + if (this.seedStore && this.pendingStore === this.seedStore) { + this.cancelPendingMutePublish(); + this.pendingStore = null; + } + this.seedStore = null; + } + + private remember(store: ChannelMuteStore): void { + const next = this.remoteStore + ? mergeStores(this.remoteStore, store) + : store; + // Only a changed winner invalidates in-flight attempts. + if (!this.sameEntries(this.remoteStore, next)) this.remoteRev++; + this.remoteStore = next; } cancelPendingMutePublish(): void { @@ -127,19 +149,21 @@ export class ChannelMuteSyncManager { this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); if (!remote) return store; - return mergeStores(store, remote.store); + this.remember(remote.store); + return store; } catch { return store; } } - private isIdenticalToLastPublished(store: ChannelMuteStore): boolean { - if (!this.lastPublishedStore) return false; - const lastKeys = Object.keys(this.lastPublishedStore.channels); + /** Same winners (IDs, `muted`, `updatedAt`), ignoring key order. */ + private sameEntries(prev: ChannelMuteStore | null, store: ChannelMuteStore) { + if (!prev) return false; + const lastKeys = Object.keys(prev.channels); const currentKeys = Object.keys(store.channels); if (lastKeys.length !== currentKeys.length) return false; for (const key of currentKeys) { - const last = this.lastPublishedStore.channels[key]; + const last = prev.channels[key]; const current = store.channels[key]; if ( !last || @@ -152,14 +176,29 @@ export class ChannelMuteSyncManager { } private async doPublish(store: ChannelMuteStore): Promise { + // A seed retired while acquired (preflight, crypto or socket wait) aborts. + const seed = store === this.seedStore; + let rev = this.remoteRev; // a changed winner before dispatch voids it + const stale = () => + this.destroyed || + (seed && this.seedStore !== store) || + rev !== this.remoteRev; + // Only the attempt that still owns `pendingStore` may clear it. + const release = () => { + if (this.pendingStore === store) this.pendingStore = null; + }; try { - const merged = await this.fetchOwnBlobBeforePublish(store); + const pre = await this.fetchOwnBlobBeforePublish(store); + const merged = this.remoteStore + ? mergeStores(pre, this.remoteStore) + : pre; + rev = this.remoteRev; // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish // was awaited (community switch during in-flight fetch). If so, abort // before touching the relay. - if (this.destroyed) return; - if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; + if (stale()) return; + if (this.sameEntries(this.lastPublishedStore, merged)) { + release(); return; } const payload = { @@ -180,17 +219,21 @@ export class ChannelMuteSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); - if (this.destroyed) return; + if (stale()) return; await relayClient.publishEvent( event, "Timed out publishing channel mutes.", "Failed to publish channel mutes.", + () => !stale(), ); this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; - this.pendingStore = null; + release(); } catch (error) { console.warn("[channelMutesSync] publish failed:", error); + } finally { + const owned = !this.destroyed && this.pendingStore === store; + if (owned && rev !== this.remoteRev) this.publishMutes(store); } } @@ -210,7 +253,8 @@ export class ChannelMuteSyncManager { // still advances the watermark and blocks future seed-publish. this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { - if (result) { + if (result && !this.destroyed) { + this.remember(result.store); onUpdate(result); } }); @@ -229,7 +273,10 @@ export class ChannelMuteSyncManager { lastHead: this.lastRemoteCreatedAt, localStore, isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, - publishFn: (s) => this.publishMutes(s), + publishFn: (s) => { + this.seedStore = s; + this.publishMutes(s); + }, }); } diff --git a/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs deleted file mode 100644 index a54bc152e2..0000000000 --- a/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs +++ /dev/null @@ -1,79 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { swapSectionOrder } from "./channelSectionsHelpers.ts"; - -function makeStore(sections, assignments = {}) { - return { version: 1, sections, assignments }; -} - -function makeSection(id, name, order) { - return { id, name, order }; -} - -test("move up succeeds: middle section swaps order with the one above", () => { - const store = makeStore([ - makeSection("a", "A", 0), - makeSection("b", "B", 1), - makeSection("c", "C", 2), - ]); - const result = swapSectionOrder(store, "b", "up"); - assert.notEqual(result, null); - const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order])); - assert.equal(byId.b, 0); - assert.equal(byId.a, 1); - assert.equal(byId.c, 2); -}); - -test("move down succeeds: middle section swaps order with the one below", () => { - const store = makeStore([ - makeSection("a", "A", 0), - makeSection("b", "B", 1), - makeSection("c", "C", 2), - ]); - const result = swapSectionOrder(store, "b", "down"); - assert.notEqual(result, null); - const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order])); - assert.equal(byId.b, 2); - assert.equal(byId.c, 1); - assert.equal(byId.a, 0); -}); - -test("move up at top boundary returns null", () => { - const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]); - assert.equal(swapSectionOrder(store, "a", "up"), null); -}); - -test("move down at bottom boundary returns null", () => { - const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]); - assert.equal(swapSectionOrder(store, "b", "down"), null); -}); - -test("non-existent section returns null", () => { - const store = makeStore([makeSection("a", "A", 0)]); - assert.equal(swapSectionOrder(store, "z", "up"), null); -}); - -test("single section move up returns null", () => { - const store = makeStore([makeSection("a", "A", 0)]); - assert.equal(swapSectionOrder(store, "a", "up"), null); -}); - -test("single section move down returns null", () => { - const store = makeStore([makeSection("a", "A", 0)]); - assert.equal(swapSectionOrder(store, "a", "down"), null); -}); - -test("non-contiguous orders: swap uses actual order values not indices", () => { - const store = makeStore([ - makeSection("a", "A", 0), - makeSection("b", "B", 5), - makeSection("c", "C", 10), - ]); - const result = swapSectionOrder(store, "b", "up"); - assert.notEqual(result, null); - const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order])); - assert.equal(byId.b, 0); - assert.equal(byId.a, 5); - assert.equal(byId.c, 10); -}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts b/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts deleted file mode 100644 index fe41cfee6b..0000000000 --- a/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ChannelSectionStore } from "./channelSectionsStorage"; - -export function swapSectionOrder( - prev: ChannelSectionStore, - sectionId: string, - direction: "up" | "down", -): ChannelSectionStore | null { - const target = prev.sections.find((s) => s.id === sectionId); - if (!target) return null; - const sorted = prev.sections.slice().sort((a, b) => a.order - b.order); - const idx = sorted.findIndex((s) => s.id === sectionId); - const neighborIdx = direction === "up" ? idx - 1 : idx + 1; - if (neighborIdx < 0 || neighborIdx >= sorted.length) return null; - const neighbor = sorted[neighborIdx]; - const sections = prev.sections.map((s) => { - if (s.id === target.id) return { ...s, order: neighbor.order }; - if (s.id === neighbor.id) return { ...s, order: target.order }; - return s; - }); - return { ...prev, sections }; -} diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs index f28108e5b5..e72fca2e39 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs @@ -2,15 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - boundChannelSectionsStore, - DEFAULT_STORE, - MAX_CHANNEL_SECTION_ASSIGNMENTS, - MAX_CHANNEL_SECTIONS, parseChannelSectionPayload, - readChannelSectionsStore, storageKey, - stripOrphanedAssignments, - writeChannelSectionsStore, } from "./channelSectionsStorage.ts"; import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; @@ -25,19 +18,6 @@ if (typeof globalThis.window === "undefined") { }; } -function makeStore(overrides = {}) { - return { - version: 1, - sections: overrides.sections ?? [{ id: "s1", name: "Test", order: 0 }], - assignments: overrides.assignments ?? {}, - ...overrides, - }; -} - -function makeSection(overrides = {}) { - return { id: "s1", name: "Test", order: 0, ...overrides }; -} - test("parseChannelSectionPayload: valid complete payload returns correct store", () => { const payload = { version: 1, @@ -121,116 +101,6 @@ test("parseChannelSectionPayload: orphaned assignments are stripped", () => { assert.deepEqual(result?.assignments, { chan1: "s1" }); }); -test("stripOrphanedAssignments: store with no orphans returns same reference", () => { - const store = makeStore({ - sections: [makeSection({ id: "s1" })], - assignments: { chan1: "s1" }, - }); - assert.equal(stripOrphanedAssignments(store), store); -}); - -test("stripOrphanedAssignments: store with orphaned assignments returns new object without them", () => { - const store = makeStore({ - sections: [makeSection({ id: "s1" })], - assignments: { chan1: "s1", chan2: "ghost" }, - }); - const result = stripOrphanedAssignments(store); - assert.notEqual(result, store); - assert.deepEqual(result.assignments, { chan1: "s1" }); -}); - -test("stripOrphanedAssignments: store with all valid assignments returns same reference", () => { - const store = makeStore({ - sections: [ - makeSection({ id: "s1" }), - makeSection({ id: "s2", name: "B", order: 1 }), - ], - assignments: { chan1: "s1", chan2: "s2" }, - }); - assert.equal(stripOrphanedAssignments(store), store); -}); - -test("stripOrphanedAssignments: empty store returns same reference", () => { - const store = makeStore({ sections: [], assignments: {} }); - assert.equal(stripOrphanedAssignments(store), store); -}); - -test("boundChannelSectionsStore caps sections and assignments", () => { - const sections = Array.from( - { length: MAX_CHANNEL_SECTIONS + 1 }, - (_, index) => makeSection({ id: `section-${index}`, order: index }), - ); - const assignments = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS + 1 }, (_, index) => [ - `channel-${index}`, - "section-100", - ]), - ); - - const bounded = boundChannelSectionsStore( - makeStore({ sections, assignments }), - ); - - assert.equal(bounded.sections.length, MAX_CHANNEL_SECTIONS); - assert.equal( - bounded.sections.some((section) => section.id === "section-0"), - false, - ); - assert.equal( - Object.keys(bounded.assignments).length, - MAX_CHANNEL_SECTION_ASSIGNMENTS, - ); - assert.equal(bounded.assignments["channel-0"], undefined); -}); - -test("writeChannelSectionsStore + readChannelSectionsStore: write then read returns same data", () => { - const pubkey = "pk-roundtrip"; - const store = makeStore({ - sections: [makeSection({ id: "s1", name: "Work", order: 0 })], - assignments: { chan1: "s1" }, - }); - const written = writeChannelSectionsStore(pubkey, store); - assert.equal(written, true); - const result = readChannelSectionsStore(pubkey); - assert.deepEqual(result, store); -}); - -test("readChannelSectionsStore: non-existent key returns DEFAULT_STORE", () => { - const result = readChannelSectionsStore("pk-does-not-exist-xyz"); - assert.deepEqual(result, DEFAULT_STORE); -}); - -test("readChannelSectionsStore: corrupt JSON returns DEFAULT_STORE", () => { - const pubkey = "pk-corrupt"; - window.localStorage.setItem(storageKey(pubkey), "not-valid-json{{{"); - const result = readChannelSectionsStore(pubkey); - assert.deepEqual(result, DEFAULT_STORE); -}); - -test("readChannelSectionsStore: object with wrong version returns DEFAULT_STORE", () => { - const pubkey = "pk-wrong-version"; - window.localStorage.setItem( - storageKey(pubkey), - JSON.stringify({ version: 2, sections: [], assignments: {} }), - ); - const result = readChannelSectionsStore(pubkey); - assert.deepEqual(result, DEFAULT_STORE); -}); - -test("writeChannelSectionsStore: returns false when setItem throws", () => { - const pubkey = "pk-throws"; - const original = window.localStorage.setItem; - window.localStorage.setItem = () => { - throw new Error("storage full"); - }; - try { - const result = writeChannelSectionsStore(pubkey, makeStore()); - assert.equal(result, false); - } finally { - window.localStorage.setItem = original; - } -}); - test("storageKey: returns expected format with pubkey", () => { assert.equal(storageKey("abc123"), "buzz-channel-sections.v1:abc123"); }); @@ -263,100 +133,6 @@ test("storageKey: equivalent relay URLs (case + trailing slash) map to the same assert.equal(k1, k2); }); -test("readChannelSectionsStore + writeChannelSectionsStore: scoped write/read roundtrip", () => { - const pubkey = "pk-relay-roundtrip"; - const relay = "wss://relay.example.com"; - const store = makeStore({ - sections: [makeSection({ id: "s1", name: "Work", order: 0 })], - assignments: { chan1: "s1" }, - }); - assert.equal(writeChannelSectionsStore(pubkey, store, relay), true); - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, store); -}); - -test("readChannelSectionsStore: scoped key is isolated from other relay's data", () => { - const pubkey = "pk-isolation"; - const relayA = "wss://relay-a.example.com"; - const relayB = "wss://relay-b.example.com"; - const storeA = makeStore({ - sections: [makeSection({ id: "sa", name: "Relay A section", order: 0 })], - assignments: {}, - }); - writeChannelSectionsStore(pubkey, storeA, relayA); - // Relay B should see empty store, not relay A's data. - const resultB = readChannelSectionsStore(pubkey, relayB); - assert.deepEqual(resultB, DEFAULT_STORE); -}); - -test("readChannelSectionsStore: migrates legacy unscoped data on first scoped read", () => { - const pubkey = "pk-migrate"; - const relay = "wss://relay-migrate.example.com"; - const legacyStore = makeStore({ - sections: [makeSection({ id: "sl", name: "Legacy Section", order: 0 })], - assignments: {}, - }); - // Write under legacy (pubkey-only) key. - writeChannelSectionsStore(pubkey, legacyStore); - // First scoped read should migrate and return the legacy data. - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, legacyStore); - // Legacy key must be deleted after migration (globally one-time guarantee). - const legacyKey = storageKey(pubkey); - assert.equal(window.localStorage.getItem(legacyKey), null); - // Subsequent scoped reads should hit the scoped key directly. - const result2 = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result2, legacyStore); -}); - -test("readChannelSectionsStore: migration is globally one-time — relay B sees DEFAULT_STORE after relay A migrates", () => { - const pubkey = "pk-migrate-once"; - const relayA = "wss://relay-migrate-once-a.example.com"; - const relayB = "wss://relay-migrate-once-b.example.com"; - const legacyStore = makeStore({ - sections: [makeSection({ id: "sm", name: "Migrated Section", order: 0 })], - assignments: {}, - }); - // Write under legacy key then migrate via relay A. - writeChannelSectionsStore(pubkey, legacyStore); - readChannelSectionsStore(pubkey, relayA); // triggers migration, deletes legacy key - // Relay B must see DEFAULT_STORE — legacy data must not bleed in. - const resultB = readChannelSectionsStore(pubkey, relayB); - assert.deepEqual(resultB, DEFAULT_STORE); - // Relay B scoped key must not have been created. - const scopedBKey = storageKey(pubkey, relayB); - assert.equal(window.localStorage.getItem(scopedBKey), null); -}); - -test("readChannelSectionsStore: migration only copies non-empty legacy stores", () => { - const pubkey = "pk-migrate-empty"; - const relay = "wss://relay-migrate-empty.example.com"; - // Write an explicitly empty store under the legacy key. - writeChannelSectionsStore(pubkey, DEFAULT_STORE); - // Empty legacy store should NOT be migrated — we get DEFAULT_STORE. - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, DEFAULT_STORE); -}); - -test("readChannelSectionsStore: scoped key takes precedence over legacy key after migration", () => { - const pubkey = "pk-precedence"; - const relay = "wss://relay-precedence.example.com"; - const legacyStore = makeStore({ - sections: [makeSection({ id: "sold", name: "Old Section", order: 0 })], - assignments: {}, - }); - const newStore = makeStore({ - sections: [makeSection({ id: "snew", name: "New Section", order: 0 })], - assignments: {}, - }); - // Write legacy data then scoped data (simulates post-migration state). - writeChannelSectionsStore(pubkey, legacyStore); - writeChannelSectionsStore(pubkey, newStore, relay); - // Scoped read must return the scoped (newer) store, not the legacy one. - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, newStore); -}); - test("parseChannelSectionPayload: preserves icon field when present", () => { const payload = { version: 1, diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index f01154751b..ae79a57ead 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -17,20 +17,14 @@ export type ChannelSectionStore = { assignments: Record; }; -export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({ - version: 1, - sections: [], - assignments: {}, -}); - /** * Returns the localStorage key for channel sections. * * When `relayUrl` is provided the key is scoped to that relay (normalized via * the same `normalizeRelayUrl` used by all relay-scoped local stores) so * sections from different communities/relays don't bleed across each other. - * When omitted the legacy pubkey-only key is returned (used only during - * one-time migration in `readChannelSectionsStore`). + * When omitted the legacy pubkey-only key is returned (migrated once on the + * first scoped read). */ export function storageKey(pubkey: string, relayUrl?: string): string { if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`; @@ -39,42 +33,7 @@ export function storageKey(pubkey: string, relayUrl?: string): string { return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalized)}`; } -export function boundChannelSectionsStore( - store: ChannelSectionStore, -): ChannelSectionStore { - const sections = store.sections - .slice() - .sort((left, right) => left.order - right.order) - .slice(-MAX_CHANNEL_SECTIONS); - const sectionIds = new Set(sections.map((section) => section.id)); - const assignments = Object.fromEntries( - Object.entries(store.assignments) - .filter(([, sectionId]) => sectionIds.has(sectionId)) - .slice(-MAX_CHANNEL_SECTION_ASSIGNMENTS), - ); - if ( - sections.length === store.sections.length && - Object.keys(assignments).length === Object.keys(store.assignments).length - ) { - return store; - } - return { ...store, sections, assignments }; -} - -export function stripOrphanedAssignments( - store: ChannelSectionStore, -): ChannelSectionStore { - const sectionIds = new Set(store.sections.map((s) => s.id)); - const cleaned = Object.fromEntries( - Object.entries(store.assignments).filter(([, sid]) => sectionIds.has(sid)), - ); - const stripped = - Object.keys(cleaned).length === Object.keys(store.assignments).length - ? store - : { ...store, assignments: cleaned }; - return boundChannelSectionsStore(stripped); -} - +/** Parses the legacy `sections`/`assignments` fields; orphaned assignments are dropped. */ export function parseChannelSectionPayload( json: unknown, ): ChannelSectionStore | null { @@ -105,92 +64,17 @@ export function parseChannelSectionPayload( ]; }) : []; + const sectionIds = new Set(sections.map((s) => s.id)); const assignments: Record = typeof obj.assignments === "object" && obj.assignments !== null && !Array.isArray(obj.assignments) ? Object.fromEntries( Object.entries(obj.assignments as Record).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", + (entry): entry is [string, string] => + typeof entry[1] === "string" && sectionIds.has(entry[1]), ), ) : {}; - return stripOrphanedAssignments({ version: 1, sections, assignments }); -} - -function parseRaw(raw: string | null): ChannelSectionStore | null { - if (!raw) return null; - try { - const parsed = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) { - return null; - } - return parseChannelSectionPayload(parsed); - } catch { - return null; - } -} - -/** - * Read the section store for `pubkey` scoped to `relayUrl`. - * - * On first access for a scoped key, migrates any existing data from the - * legacy pubkey-only key so users don't lose their sections on upgrade. - * After a successful migration write the legacy key is deleted, making - * this a globally one-time operation — subsequent empty scoped-key reads - * (i.e. a different relay) won't see legacy data and won't trigger - * cross-relay contamination via the seed-publish path. - */ -export function readChannelSectionsStore( - pubkey: string, - relayUrl?: string, -): ChannelSectionStore { - try { - const key = storageKey(pubkey, relayUrl); - const raw = window.localStorage.getItem(key); - - // Scoped key already has data — use it directly. - if (raw !== null) { - return parseRaw(raw) ?? DEFAULT_STORE; - } - - // No scoped data yet. If we were given a relay scope, attempt a - // one-time migration from the legacy pubkey-only key. - if (relayUrl) { - const legacyKey = storageKey(pubkey); - const legacyRaw = window.localStorage.getItem(legacyKey); - const migrated = parseRaw(legacyRaw); - if (migrated && migrated.sections.length > 0) { - // Persist under the scoped key and remove the legacy key so this - // migration cannot fire again for any other relay scope. - try { - window.localStorage.setItem(key, JSON.stringify(migrated)); - window.localStorage.removeItem(legacyKey); - } catch { - // Ignore write failures — we still return the migrated value. - } - return migrated; - } - } - - return DEFAULT_STORE; - } catch { - return DEFAULT_STORE; - } -} - -export function writeChannelSectionsStore( - pubkey: string, - store: ChannelSectionStore, - relayUrl?: string, -): boolean { - try { - window.localStorage.setItem( - storageKey(pubkey, relayUrl), - JSON.stringify(boundChannelSectionsStore(store)), - ); - return true; - } catch { - return false; - } + return { version: 1, sections, assignments }; } diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs deleted file mode 100644 index 904ac1f3f2..0000000000 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ /dev/null @@ -1,282 +0,0 @@ -import assert from "node:assert/strict"; -import test, { mock } from "node:test"; - -import { relayClient } from "@/shared/api/relayClient"; -import { ChannelSectionSyncManager } from "./channelSectionsSync.ts"; -import { - makeFakeWindow, - installFakeWindow, - installTauriMock, -} from "./sidebarSyncTestHelpers.mjs"; - -function makeStore(overrides = {}) { - return { - version: 1, - sections: overrides.sections ?? [], - assignments: overrides.assignments ?? {}, - ...overrides, - }; -} - -function makeSectionsStore(sections = []) { - return { version: 1, sections, assignments: {} }; -} - -const RELAY = "wss://r.test"; -const RELAY_KEY = encodeURIComponent(RELAY); - -// ─── destroy() must cancel pending publish, not flush ───────────────────────── - -// Regression guard for the community-switch cross-relay publish vector: -// edit sections in relay A → destroy() is called (relayUrl dep change) → -// no publish should fire. -test("destroy: cancels pending publish without flushing to the relay", () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-test", RELAY); - manager.publishSections( - makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), - ); - assert.ok(fw._hasTimer(), "debounce timer should be set"); - manager.destroy(); - assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); - assert.equal(publishCalls.length, 0); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// Regression guard for the timer-fired race: debounce fires → doPublish awaits -// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. -test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { - let releaseFetch = null; - const publishCalls = []; - mock.method( - relayClient, - "fetchEvents", - () => - new Promise((res) => { - releaseFetch = () => res([]); - }), - ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-race", RELAY); - manager.publishSections( - makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), - ); - fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish - manager.destroy(); - releaseFetch(); - await new Promise((r) => setTimeout(r, 0)); - assert.equal( - publishCalls.length, - 0, - "publishEvent must not fire after destroy", - ); - } finally { - restore(); - mock.reset(); - } -}); - -test("destroy: is safe to call with no pending publish", () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-no-pending", RELAY); - assert.doesNotThrow(() => manager.destroy()); - } finally { - restore(); - } -}); - -// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── -// Wiring tests 1-3 drive the production bootstrap() path; policy tested once -// in sidebarSyncWatermark.test.mjs. - -// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) -test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-fail", RELAY); - const result = await manager.bootstrap( - makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), - ); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) -test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, - "1700000000", - ); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-stale", RELAY); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, - ) ?? "0", - ) > 0, - ); - const result = await manager.bootstrap( - makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), - ); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) -test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-fresh", RELAY); - const result = await manager.bootstrap( - makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), - ); - assert.equal(result.action, "hold"); - assert.ok(manager.getPendingStore() !== null); - } finally { - restore(); - mock.reset(); - } -}); - -// 4. LWW baseline: newer decryptable pre-publish event still wins after an -// undecryptable head was recorded. -// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison -// 200>200=false → local wins instead of remote → wrong content encrypted. -test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { - const REMOTE_ID = "remote-section-from-relay"; - let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { - callCount++; - return Promise.resolve([ - { - pubkey: "pk-lww", - content: callCount === 1 ? "bad-cipher" : "good-cipher", - created_at: callCount === 1 ? 100 : 200, - id: `evt-${callCount}`, - }, - ]); - }); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installTauriMock( - JSON.stringify({ - version: 1, - sections: [{ id: REMOTE_ID, name: "Remote", order: 0 }], - assignments: {}, - }), - ); - try { - const manager = new ChannelSectionSyncManager("pk-lww", RELAY); - await manager.fetchRemoteSections(); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sections:pk-lww:${RELAY_KEY}`, - ) ?? "0", - ) >= 100, - ); - manager.publishSections( - makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }]), - ); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - const pt = tauri.capturedPlaintext(); - assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); - assert.ok( - JSON.parse(pt).sections?.some((s) => s.id === REMOTE_ID), - `remote sections must win LWW merge — got: ${pt}`, - ); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// 5. live-sub: undecryptable event on live path records head before decrypt -// Mutation test: removing recordRemoteHead before decrypt in the live callback -// leaves watermark at 0 after a live event. -test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { - let liveCallback = null; - mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { - liveCallback = onEvent; - return Promise.resolve(async () => {}); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-live", RELAY); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, - ), - null, - "watermark starts absent", - ); - await manager.subscribeToSections(() => {}); - assert.ok( - liveCallback !== null, - "subscribeLive must have captured the callback", - ); - liveCallback({ - pubkey: "pk-live", - content: "!bad-cipher!", - created_at: 1700005555, - id: "live-evt-1", - }); - await new Promise((r) => setTimeout(r, 0)); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, - ) ?? "0", - ) >= 1700005555, - "live undecryptable event must advance the watermark before decrypt is attempted", - ); - } finally { - restore(); - mock.reset(); - } -}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 858b62430f..1774bbbf93 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -1,276 +1,107 @@ -import { relayClient } from "@/shared/api/relayClient"; -import { - nip44DecryptFromSelf, - nip44EncryptToSelf, - signRelayEvent, -} from "@/shared/api/tauri"; -import type { RelayEvent } from "@/shared/api/types"; -import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds"; import { + MAX_CHANNEL_SECTION_ASSIGNMENTS, + MAX_CHANNEL_SECTIONS, parseChannelSectionPayload, + storageKey, type ChannelSection, - type ChannelSectionStore, } from "./channelSectionsStorage"; -import { - advanceWatermark, - readWatermark, - runBootstrap, - type FetchResult, -} from "./sidebarSyncWatermark"; +import type { Lane } from "./sidebarLaneReconciler"; +import { isReg, type Reg, type Tree } from "./sidebarLwwMap"; -const D_TAG = "channel-sections"; -const BLOB_TYPE = D_TAG; -const DEBOUNCE_MS = 2_000; +const isString = (value: unknown) => typeof value === "string"; -export type RemoteSections = { - store: ChannelSectionStore; - createdAt: number; - eventId: string; +type SectionNode = { name?: Reg; icon?: Reg; order?: Reg; live?: Reg }; + +export type SectionsView = { + sections: ChannelSection[]; + assignments: Record; }; -async function decryptAndParse( - event: RelayEvent, -): Promise { - try { - const plaintext = await nip44DecryptFromSelf(event.content); - const store = parseChannelSectionPayload(JSON.parse(plaintext)); - if (!store) return null; - return { store, createdAt: event.created_at, eventId: event.id }; - } catch { - return null; - } -} +const val = (reg: unknown) => (isReg(reg) ? reg[2] : undefined); -export class ChannelSectionSyncManager { - private pubkey: string; - private relayUrl: string; - private debounceTimer: number | null = null; - private lastRemoteCreatedAt: number; - private pendingStore: ChannelSectionStore | null = null; - private lastPublishedStore: ChannelSectionStore | null = null; - private destroyed = false; - - constructor(pubkey: string, relayUrl: string) { - this.pubkey = pubkey; - this.relayUrl = relayUrl; - // Hydrate from localStorage so we never seed-publish if a remote blob has - // been seen in a prior session. - this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); - } - - async fetchRemoteSections(): Promise> { - try { - const events = await relayClient.fetchEvents({ - kinds: [KIND_CHANNEL_SECTIONS], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 1, - }); - if (events.length === 0 || events[0].pubkey !== this.pubkey) { - return { status: "absent" }; - } - const event = events[0]; - // An event exists — record its created_at regardless of whether we can - // decrypt it, so seed-publish is blocked even when the payload is - // unreadable (e.g. wrong key). - this.recordRemoteHead(event.created_at); - const result = await decryptAndParse(event); - if (!result) { - return { status: "failed", createdAt: event.created_at }; - } - return { - status: "found", - data: result, - createdAt: result.createdAt, - eventId: result.eventId, - }; - } catch { - return { status: "failed" }; - } - } - - /** Update in-memory + persisted watermark. */ - private recordRemoteHead(createdAt: number): void { - if (createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = createdAt; - } - advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); - } - - cancelPendingPublish(): void { - if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } - } - - getPendingStore(): ChannelSectionStore | null { - return this.pendingStore; - } - - publishSections(store: ChannelSectionStore): void { - this.pendingStore = store; - if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - } - this.debounceTimer = window.setTimeout(() => { - this.debounceTimer = null; - void this.doPublish(store); - }, DEBOUNCE_MS); - } - - private async fetchOwnBlobBeforePublish( - store: ChannelSectionStore, - ): Promise { - try { - const events = await relayClient.fetchEvents({ - kinds: [KIND_CHANNEL_SECTIONS], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 1, - }); - if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const event = events[0]; - // Snapshot the watermark before advancing it: after recordRemoteHead - // runs, lastRemoteCreatedAt equals event.created_at, so the LWW - // comparison remote.createdAt > lastRemoteCreatedAt would always be - // false and silently suppress the merge. - const headBeforeFetch = this.lastRemoteCreatedAt; - this.recordRemoteHead(event.created_at); - const remote = await decryptAndParse(event); - if (!remote) return store; - // Sections use whole-blob LWW: take whichever is newer - if (remote.createdAt > headBeforeFetch) { - return remote.store; - } - return store; - } catch { - return store; - } - } - - private isIdenticalToLastPublished(store: ChannelSectionStore): boolean { - if (!this.lastPublishedStore) return false; - const lastSections = this.lastPublishedStore.sections; - const currentSections = store.sections; - if (lastSections.length !== currentSections.length) return false; - for (let i = 0; i < currentSections.length; i++) { - const last = lastSections[i] as ChannelSection | undefined; - const current = currentSections[i] as ChannelSection; - if ( - !last || - last.id !== current.id || - last.name !== current.name || - last.icon !== current.icon || - last.order !== current.order - ) - return false; - } - const lastAssignKeys = Object.keys(this.lastPublishedStore.assignments); - const currentAssignKeys = Object.keys(store.assignments); - if (lastAssignKeys.length !== currentAssignKeys.length) return false; - for (const key of currentAssignKeys) { - if (this.lastPublishedStore.assignments[key] !== store.assignments[key]) - return false; - } - return true; - } - - private async doPublish(store: ChannelSectionStore): Promise { - try { - const merged = await this.fetchOwnBlobBeforePublish(store); - // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish - // was awaited (community switch during in-flight fetch). If so, abort - // before touching the relay. - if (this.destroyed) return; - if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; - return; - } - const payload = { - version: 1, - sections: merged.sections, - assignments: merged.assignments, - }; - const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); - const createdAt = Math.max( - Math.floor(Date.now() / 1_000), - this.lastRemoteCreatedAt + 1, - ); - const event = await signRelayEvent({ - kind: KIND_CHANNEL_SECTIONS, - content: ciphertext, - createdAt, - tags: [ - ["d", D_TAG], - ["t", D_TAG], // relay discoverability; not used in our filters - ], - }); - // Final guard immediately before the network call — sign/encrypt are - // synchronous-ish but cheap; the relay socket may have moved to a - // different community by the time we reach this point. - if (this.destroyed) return; - await relayClient.publishEvent( - event, - "Timed out publishing channel sections.", - "Failed to publish channel sections.", - ); - this.recordRemoteHead(event.created_at); - this.lastPublishedStore = merged; - this.pendingStore = null; - } catch (error) { - console.warn("[channelSectionsSync] publish failed:", error); - } - } - - async subscribeToSections( - onUpdate: (remote: RemoteSections) => void, - ): Promise<() => Promise> { - return relayClient.subscribeLive( - { - kinds: [KIND_CHANNEL_SECTIONS], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 0, - }, - (event: RelayEvent) => { - if (event.pubkey !== this.pubkey) return; - // Record the raw head before decrypt so an undecryptable live event - // still advances the watermark and blocks future seed-publish. - this.recordRemoteHead(event.created_at); - void decryptAndParse(event).then((result) => { - if (result) { - onUpdate(result); - } - }); - }, +/** Live sections by `(order, id)`, with their canonical `order`. */ +function rankedSections(tree: Tree) { + const nodes = (tree.s ?? {}) as Record; + return Object.entries(nodes) + .filter(([, n]) => val(n.live) === true && isString(val(n.name))) + .map(([id, n]) => ({ + id, + name: val(n.name) as string, + icon: val(n.icon), + order: Number(val(n.order) ?? 0), + })) + .sort( + (a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), ); - } - - /** - * Fetches the remote blob on first mount, records the remote head, and - * delegates the seed/hold/apply-remote decision to `runBootstrap`. - */ - async bootstrap(localStore: ChannelSectionStore) { - const fetchResult = await this.fetchRemoteSections(); - return runBootstrap({ - fetchResult, - lastHead: this.lastRemoteCreatedAt, - localStore, - isLocalNonEmpty: (s) => s.sections.length > 0, - publishFn: (s) => this.publishSections(s), - }); - } - - destroy(): void { - // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. - // Pending debounce-window changes are intentionally dropped: flushing - // could publish relay A's sections to relay B via the shared relayClient - // singleton. On return, bootstrap's found path whole-blob-replaces from - // remote, so any dropped pending edit is lost. - this.destroyed = true; - this.cancelPendingPublish(); - this.pendingStore = null; - } } + +/** Highest canonical `order` among live sections, or -1 when there are none. */ +export const maxLiveOrder = (tree: Tree) => + Math.max(-1, ...rankedSections(tree).map((s) => s.order)); + +/** Live sections, densely renumbered. */ +function liveSections(tree: Tree): ChannelSection[] { + return rankedSections(tree).map(({ icon, ...s }, order) => ({ + ...s, + ...(isString(icon) && icon ? { icon: icon as string } : {}), + order, + })); +} + +/** The sections and assignments older clients (and this UI) read. */ +export function projectSections(tree: Tree): SectionsView { + const sections = liveSections(tree); + const live = new Set(sections.map((s) => s.id)); + const assignments: Record = {}; + for (const [channelId, reg] of Object.entries(tree.a ?? {})) { + const target = val(reg); + if (isString(target) && live.has(target as string)) + assignments[channelId] = target as string; + } + return { sections, assignments }; +} + +/** Channel sections: kind 30078, d-tag `channel-sections`, per-field registers. */ +export const SECTIONS_LANE: Lane = { + dTag: "channel-sections", + storageKey, + legacyStorageKey: (pubkey) => storageKey(pubkey), + shape: { + s: { + "*": { + name: isString, + icon: (v) => v === null || isString(v), + order: Number.isSafeInteger, + live: (v) => typeof v === "boolean", + }, + }, + a: { "*": (v) => v === null || isString(v) }, + }, + fromLegacy(json, stamp) { + const store = parseChannelSectionPayload(json); + if (!store) return null; + const s: Tree = Object.fromEntries( + store.sections.map(({ id, name, icon, order }) => [ + id, + { + name: stamp(name), + icon: stamp(icon ?? null), + order: stamp(Math.round(order)), + live: stamp(true), + }, + ]), + ); + const a: Tree = Object.fromEntries( + Object.entries(store.assignments).map(([id, target]) => [ + id, + stamp(target), + ]), + ); + return { s, a }; + }, + project: projectSections, + withinLimits: (p) => + (p.sections as unknown[]).length <= MAX_CHANNEL_SECTIONS && + Object.keys(p.assignments as object).length <= + MAX_CHANNEL_SECTION_ASSIGNMENTS, +}; diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs index a7422d3a21..360e7410dc 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs @@ -2,15 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - boundChannelSortStore, - DEFAULT_SORT_MODE, - DEFAULT_STORE, - MAX_CHANNEL_SORT_GROUPS, parseChannelSortPayload, - sectionSortGroupKey, sortChannelsForSidebar, - sortModeForGroup, - stripOrphanedSectionModes, } from "./channelSortPreference.ts"; function makeChannel(id, name, lastMessageAt = null) { @@ -81,116 +74,6 @@ test("parseChannelSortPayload: wrong version returns null", () => { ); }); -test("parseChannelSortPayload: non-object input returns null", () => { - assert.equal(parseChannelSortPayload(null), null); - assert.equal(parseChannelSortPayload("alpha"), null); - assert.equal(parseChannelSortPayload(42), null); -}); - -// ── sortModeForGroup / defaults ────────────────────────────────────────────── - -test("default sort mode is alpha and default store has no overrides", () => { - assert.equal(DEFAULT_SORT_MODE, "alpha"); - assert.deepEqual(DEFAULT_STORE.groups, {}); -}); - -test("sortModeForGroup: unset group falls back to alpha", () => { - assert.equal(sortModeForGroup(DEFAULT_STORE, "channels"), "alpha"); - assert.equal(sortModeForGroup(DEFAULT_STORE, "dms"), "alpha"); -}); - -test("sortModeForGroup: set groups are independent", () => { - const store = { - version: 1, - groups: { channels: "recent", [sectionSortGroupKey("abc")]: "recent" }, - }; - assert.equal(sortModeForGroup(store, "channels"), "recent"); - assert.equal(sortModeForGroup(store, sectionSortGroupKey("abc")), "recent"); - assert.equal(sortModeForGroup(store, "forums"), "alpha"); - assert.equal(sortModeForGroup(store, sectionSortGroupKey("xyz")), "alpha"); -}); - -test("sectionSortGroupKey: namespaced by section id", () => { - assert.equal(sectionSortGroupKey("abc"), "section:abc"); -}); - -// ── stripOrphanedSectionModes ──────────────────────────────────────────────── - -test("stripOrphanedSectionModes: drops modes for deleted sections", () => { - const store = { - version: 1, - groups: { - channels: "recent", - [sectionSortGroupKey("live")]: "recent", - [sectionSortGroupKey("deleted")]: "alpha", - }, - }; - assert.deepEqual(stripOrphanedSectionModes(store, ["live"]), { - version: 1, - groups: { channels: "recent", [sectionSortGroupKey("live")]: "recent" }, - }); -}); - -test("stripOrphanedSectionModes: fixed groups survive with no live sections", () => { - const store = { - version: 1, - groups: { - starred: "recent", - channels: "alpha", - forums: "recent", - dms: "recent", - [sectionSortGroupKey("gone")]: "recent", - }, - }; - assert.deepEqual(stripOrphanedSectionModes(store, []), { - version: 1, - groups: { - starred: "recent", - channels: "alpha", - forums: "recent", - dms: "recent", - }, - }); -}); - -test("stripOrphanedSectionModes: returns same reference when nothing is stale", () => { - const store = { - version: 1, - groups: { channels: "recent", [sectionSortGroupKey("live")]: "recent" }, - }; - assert.equal(stripOrphanedSectionModes(store, ["live", "other"]), store); -}); - -test("stripOrphanedSectionModes: does not mutate the input store", () => { - const store = { - version: 1, - groups: { [sectionSortGroupKey("gone")]: "recent" }, - }; - stripOrphanedSectionModes(store, []); - assert.deepEqual(store.groups, { [sectionSortGroupKey("gone")]: "recent" }); -}); - -test("boundChannelSortStore caps custom sections while preserving fixed groups", () => { - const groups = { - channels: "recent", - ...Object.fromEntries( - Array.from({ length: MAX_CHANNEL_SORT_GROUPS }, (_, index) => [ - sectionSortGroupKey(String(index)), - "alpha", - ]), - ), - }; - - const bounded = boundChannelSortStore({ version: 1, groups }); - - assert.equal(Object.keys(bounded.groups).length, MAX_CHANNEL_SORT_GROUPS); - assert.equal(bounded.groups.channels, "recent"); - assert.equal(bounded.groups[sectionSortGroupKey("0")], undefined); - assert.equal(bounded.groups[sectionSortGroupKey("99")], "alpha"); -}); - -// ── sortChannelsForSidebar ─────────────────────────────────────────────────── - test("alpha: sorts case-insensitively with deterministic code-unit collation", () => { const sorted = sortChannelsForSidebar( [ diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts index 4de9768d84..446b0d5947 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -24,11 +24,6 @@ export type ChannelSortStore = { export const DEFAULT_SORT_MODE: ChannelSortMode = "alpha"; -export const DEFAULT_STORE: ChannelSortStore = Object.freeze({ - version: 1, - groups: {}, -}); - export function sectionSortGroupKey(sectionId: string): ChannelSortGroupKey { return `section:${sectionId}`; } @@ -47,43 +42,7 @@ export function storageKey(pubkey: string, relayUrl?: string): string { return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalized)}`; } -/** - * Drops per-section sort modes whose custom section no longer exists so - * deleted sections don't leave stale `section:` keys in localStorage - * forever. Fixed group keys (starred/channels/forums/dms) are always kept. - * Returns the same store reference when nothing needs stripping. - */ -export function stripOrphanedSectionModes( - store: ChannelSortStore, - liveSectionIds: Iterable, -): ChannelSortStore { - const liveKeys = new Set( - [...liveSectionIds].map((id) => sectionSortGroupKey(id)), - ); - const kept = Object.entries(store.groups).filter( - ([key]) => !key.startsWith("section:") || liveKeys.has(key), - ); - if (kept.length === Object.keys(store.groups).length) return store; - return { ...store, groups: Object.fromEntries(kept) }; -} - -export function boundChannelSortStore( - store: ChannelSortStore, -): ChannelSortStore { - const entries = Object.entries(store.groups); - if (entries.length <= MAX_CHANNEL_SORT_GROUPS) return store; - const isFixedGroup = (key: string) => - key === "starred" || - key === "channels" || - key === "forums" || - key === "dms"; - const fixed = entries.filter(([key]) => isFixedGroup(key)); - const custom = entries - .filter(([key]) => !isFixedGroup(key)) - .slice(-(MAX_CHANNEL_SORT_GROUPS - fixed.length)); - return { ...store, groups: Object.fromEntries([...fixed, ...custom]) }; -} - +/** Parses the legacy `groups` field; unknown keys are kept, unknown modes dropped. */ export function parseChannelSortPayload( json: unknown, ): ChannelSortStore | null { @@ -101,43 +60,7 @@ export function parseChannelSortPayload( ), ) : {}; - return boundChannelSortStore({ version: 1, groups }); -} - -export function readChannelSortStore( - pubkey: string, - relayUrl?: string, -): ChannelSortStore { - try { - const raw = window.localStorage.getItem(storageKey(pubkey, relayUrl)); - if (!raw) return DEFAULT_STORE; - return parseChannelSortPayload(JSON.parse(raw)) ?? DEFAULT_STORE; - } catch { - return DEFAULT_STORE; - } -} - -export function writeChannelSortStore( - pubkey: string, - store: ChannelSortStore, - relayUrl?: string, -): boolean { - try { - window.localStorage.setItem( - storageKey(pubkey, relayUrl), - JSON.stringify(boundChannelSortStore(store)), - ); - return true; - } catch { - return false; - } -} - -export function sortModeForGroup( - store: ChannelSortStore, - group: ChannelSortGroupKey, -): ChannelSortMode { - return store.groups[group] ?? DEFAULT_SORT_MODE; + return { version: 1, groups }; } function channelRecencyMs(channel: Channel): number | null { diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs deleted file mode 100644 index 28159eedd3..0000000000 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ /dev/null @@ -1,262 +0,0 @@ -import assert from "node:assert/strict"; -import test, { mock } from "node:test"; - -import { relayClient } from "@/shared/api/relayClient"; -import { ChannelSortSyncManager } from "./channelSortSync.ts"; -import { - makeFakeWindow, - installFakeWindow, - installTauriMock, -} from "./sidebarSyncTestHelpers.mjs"; - -function makeStore(groups = {}) { - return { version: 1, groups }; -} - -const RELAY = "wss://r.test"; -const RELAY_KEY = encodeURIComponent(RELAY); - -// ─── destroy() must cancel pending publish, not flush ───────────────────────── -test("destroy: cancels pending publish without flushing to the relay", () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-test", RELAY); - manager.publishSortPrefs(makeStore({ channels: "recent" })); - assert.ok(fw._hasTimer(), "debounce timer should be set"); - manager.destroy(); - assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); - assert.equal(publishCalls.length, 0); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// Regression guard for the timer-fired race: debounce fires → doPublish awaits -// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. -test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { - let releaseFetch = null; - const publishCalls = []; - mock.method( - relayClient, - "fetchEvents", - () => - new Promise((res) => { - releaseFetch = () => res([]); - }), - ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-race", RELAY); - manager.publishSortPrefs(makeStore({ dms: "recent" })); - fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish - manager.destroy(); - releaseFetch(); - await new Promise((r) => setTimeout(r, 0)); - assert.equal( - publishCalls.length, - 0, - "publishEvent must not fire after destroy", - ); - } finally { - restore(); - mock.reset(); - } -}); - -test("destroy: is safe to call with no pending publish", () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-no-pending", RELAY); - assert.doesNotThrow(() => manager.destroy()); - } finally { - restore(); - } -}); - -// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── -// Wiring tests 1-3 drive the production bootstrap() path; policy tested once -// in sidebarSyncWatermark.test.mjs. - -// 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls -// Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set. -test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-fail", RELAY); - const result = await manager.bootstrap(makeStore({ channels: "recent" })); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// 2. absent + persisted head > 0 → hold, zero publish calls (the dev-build stale-copy case) -// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. -test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, - "1700000000", - ); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-stale", RELAY); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, - ) ?? "0", - ) > 0, - ); - const result = await manager.bootstrap(makeStore({ channels: "recent" })); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// 3. absent + head 0 + local non-empty → seed-publish queued (first-sync preserved) -// Mutation: removing the absent+head-0 seed call leaves pendingStore null. -test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-fresh", RELAY); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-fresh:${RELAY_KEY}`, - ), - null, - ); - const result = await manager.bootstrap(makeStore({ channels: "recent" })); - assert.equal(result.action, "hold"); - assert.ok(manager.getPendingStore() !== null); - } finally { - restore(); - mock.reset(); - } -}); - -// 4. LWW baseline: newer decryptable pre-publish event still wins after an -// undecryptable head was recorded. -// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison -// 200>200=false → local wins instead of remote → wrong content encrypted. -test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { - const REMOTE_KEY = "remote-group-from-relay"; - let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { - callCount++; - return Promise.resolve([ - { - pubkey: "pk-lww", - content: callCount === 1 ? "bad-cipher" : "good-cipher", - created_at: callCount === 1 ? 100 : 200, - id: `evt-${callCount}`, - }, - ]); - }); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installTauriMock( - JSON.stringify({ version: 1, groups: { [REMOTE_KEY]: "recent" } }), - ); - try { - const manager = new ChannelSortSyncManager("pk-lww", RELAY); - await manager.fetchRemoteSortPrefs(); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-lww:${RELAY_KEY}`, - ) ?? "0", - ) >= 100, - ); - manager.publishSortPrefs(makeStore({ "local-group": "recent" })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - const pt = tauri.capturedPlaintext(); - assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); - assert.ok( - JSON.parse(pt).groups && REMOTE_KEY in JSON.parse(pt).groups, - `remote groups must win LWW merge — got: ${pt}`, - ); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// 5. live-sub: undecryptable event on live path records head before decrypt -// Mutation test: removing recordRemoteHead before decrypt in the live callback -// leaves watermark at 0 after a live event. -test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { - let liveCallback = null; - mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { - liveCallback = onEvent; - return Promise.resolve(async () => {}); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-live", RELAY); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, - ), - null, - "watermark starts absent", - ); - await manager.subscribeToSortPrefs(() => {}); - assert.ok( - liveCallback !== null, - "subscribeLive must have captured the callback", - ); - liveCallback({ - pubkey: "pk-live", - content: "!bad-cipher!", - created_at: 1700005555, - id: "live-evt-1", - }); - await new Promise((r) => setTimeout(r, 0)); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, - ) ?? "0", - ) >= 1700005555, - "live undecryptable event must advance the watermark before decrypt is attempted", - ); - } finally { - restore(); - mock.reset(); - } -}); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts index fe71fe62df..6129c7a313 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.ts +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -1,263 +1,37 @@ -import { relayClient } from "@/shared/api/relayClient"; -import { - nip44DecryptFromSelf, - nip44EncryptToSelf, - signRelayEvent, -} from "@/shared/api/tauri"; -import type { RelayEvent } from "@/shared/api/types"; -import { KIND_CHANNEL_SORT } from "@/shared/constants/kinds"; import { + MAX_CHANNEL_SORT_GROUPS, parseChannelSortPayload, - type ChannelSortStore, + storageKey, + type ChannelSortMode, } from "./channelSortPreference"; -import { - advanceWatermark, - readWatermark, - runBootstrap, - type FetchResult, -} from "./sidebarSyncWatermark"; +import type { Lane } from "./sidebarLaneReconciler"; +import { isReg, type Tree } from "./sidebarLwwMap"; -const D_TAG = "channel-sort"; -const BLOB_TYPE = D_TAG; -const DEBOUNCE_MS = 2_000; +/** Non-null sort modes per group key; `null` registers are explicit resets. */ +export function projectSort(tree: Tree): { + groups: Record; +} { + const groups: Record = {}; + for (const [key, reg] of Object.entries(tree.g ?? {})) { + if (isReg(reg) && reg[2] !== null) groups[key] = reg[2] as ChannelSortMode; + } + return { groups }; +} -export type RemoteSortPrefs = { - store: ChannelSortStore; - createdAt: number; - eventId: string; -}; - -async function decryptAndParse( - event: RelayEvent, -): Promise { - try { - const plaintext = await nip44DecryptFromSelf(event.content); - const store = parseChannelSortPayload(JSON.parse(plaintext)); +/** Sidebar sort modes: kind 30078, d-tag `channel-sort`, one register per group key. */ +export const SORT_LANE: Lane = { + dTag: "channel-sort", + storageKey, + shape: { g: { "*": (v) => v === null || v === "alpha" || v === "recent" } }, + fromLegacy(json, stamp) { + const store = parseChannelSortPayload(json); if (!store) return null; - return { store, createdAt: event.created_at, eventId: event.id }; - } catch { - return null; - } -} - -/** - * Syncs the per-group sidebar sort preferences across clients via encrypted - * NIP-78 app data (kind 30078, d-tag `channel-sort`), following the same - * pattern as channel sections: NIP-44 encrypted-to-self content, debounced - * writes, and whole-blob last-write-wins. The sort map is a compact, - * low-frequency preference blob, so whole-blob LWW (like sections) is - * sufficient — per-key merge (like stars/mutes) would be unnecessary - * complexity here. - */ -export class ChannelSortSyncManager { - private pubkey: string; - private relayUrl: string; - private debounceTimer: number | null = null; - private lastRemoteCreatedAt: number; - private pendingStore: ChannelSortStore | null = null; - private lastPublishedStore: ChannelSortStore | null = null; - private destroyed = false; - - constructor(pubkey: string, relayUrl: string) { - this.pubkey = pubkey; - this.relayUrl = relayUrl; - this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); - } - - async fetchRemoteSortPrefs(): Promise> { - try { - const events = await relayClient.fetchEvents({ - kinds: [KIND_CHANNEL_SORT], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 1, - }); - if (events.length === 0 || events[0].pubkey !== this.pubkey) { - return { status: "absent" }; - } - const event = events[0]; - this.recordRemoteHead(event.created_at); - const result = await decryptAndParse(event); - if (!result) { - return { status: "failed", createdAt: event.created_at }; - } - return { - status: "found", - data: result, - createdAt: result.createdAt, - eventId: result.eventId, - }; - } catch { - return { status: "failed" }; - } - } - - private recordRemoteHead(createdAt: number): void { - if (createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = createdAt; - } - advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); - } - - cancelPendingPublish(): void { - if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } - } - - getPendingStore(): ChannelSortStore | null { - return this.pendingStore; - } - - publishSortPrefs(store: ChannelSortStore): void { - this.pendingStore = store; - if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - } - this.debounceTimer = window.setTimeout(() => { - this.debounceTimer = null; - void this.doPublish(store); - }, DEBOUNCE_MS); - } - - private async fetchOwnBlobBeforePublish( - store: ChannelSortStore, - ): Promise { - try { - const events = await relayClient.fetchEvents({ - kinds: [KIND_CHANNEL_SORT], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 1, - }); - if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const event = events[0]; - // Snapshot the watermark before advancing it: after recordRemoteHead - // runs, lastRemoteCreatedAt equals event.created_at, so the LWW - // comparison remote.createdAt > lastRemoteCreatedAt would always be - // false and silently suppress the merge. - const headBeforeFetch = this.lastRemoteCreatedAt; - this.recordRemoteHead(event.created_at); - const remote = await decryptAndParse(event); - if (!remote) return store; - // Sort prefs use whole-blob LWW: take whichever is newer - if (remote.createdAt > headBeforeFetch) { - return remote.store; - } - return store; - } catch { - return store; - } - } - - private isIdenticalToLastPublished(store: ChannelSortStore): boolean { - if (!this.lastPublishedStore) return false; - const lastGroups = this.lastPublishedStore.groups; - const currentGroups = store.groups; - const lastKeys = Object.keys(lastGroups); - const currentKeys = Object.keys(currentGroups); - if (lastKeys.length !== currentKeys.length) return false; - for (const key of currentKeys) { - if (lastGroups[key] !== currentGroups[key]) return false; - } - return true; - } - - private async doPublish(store: ChannelSortStore): Promise { - try { - const merged = await this.fetchOwnBlobBeforePublish(store); - // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish - // was awaited (community switch during in-flight fetch). If so, abort - // before touching the relay. - if (this.destroyed) return; - if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; - return; - } - const payload = { - version: 1, - groups: merged.groups, - }; - const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); - const createdAt = Math.max( - Math.floor(Date.now() / 1_000), - this.lastRemoteCreatedAt + 1, - ); - const event = await signRelayEvent({ - kind: KIND_CHANNEL_SORT, - content: ciphertext, - createdAt, - tags: [ - ["d", D_TAG], - ["t", D_TAG], // relay discoverability; not used in our filters - ], - }); - // Final guard immediately before the network call — sign/encrypt are - // synchronous-ish but cheap; the relay socket may have moved to a - // different community by the time we reach this point. - if (this.destroyed) return; - await relayClient.publishEvent( - event, - "Timed out publishing channel sort preferences.", - "Failed to publish channel sort preferences.", - ); - this.recordRemoteHead(event.created_at); - this.lastPublishedStore = merged; - this.pendingStore = null; - } catch (error) { - console.warn("[channelSortSync] publish failed:", error); - } - } - - async subscribeToSortPrefs( - onUpdate: (remote: RemoteSortPrefs) => void, - ): Promise<() => Promise> { - return relayClient.subscribeLive( - { - kinds: [KIND_CHANNEL_SORT], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 0, - }, - (event: RelayEvent) => { - if (event.pubkey !== this.pubkey) return; - // Record the raw head before decrypt so an undecryptable live event - // still advances the watermark and blocks future seed-publish. - this.recordRemoteHead(event.created_at); - void decryptAndParse(event).then((result) => { - if (result) { - onUpdate(result); - } - }); - }, + const g: Tree = Object.fromEntries( + Object.entries(store.groups).map(([key, mode]) => [key, stamp(mode)]), ); - } - - /** - * Fetches the remote blob on first mount, records the remote head, and - * delegates the seed/hold/apply-remote decision to `runBootstrap`. - */ - async bootstrap(localStore: ChannelSortStore) { - const fetchResult = await this.fetchRemoteSortPrefs(); - return runBootstrap({ - fetchResult, - lastHead: this.lastRemoteCreatedAt, - localStore, - isLocalNonEmpty: (s) => Object.keys(s.groups).length > 0, - publishFn: (s) => this.publishSortPrefs(s), - }); - } - - destroy(): void { - // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. - // Pending debounce-window changes are intentionally dropped: flushing - // could publish relay A's sort prefs to relay B via the shared relayClient - // singleton. On return, bootstrap's found path whole-blob-replaces from - // remote, so any dropped pending edit is lost. - this.destroyed = true; - this.cancelPendingPublish(); - this.pendingStore = null; - } -} + return { g }; + }, + project: projectSort, + withinLimits: (p) => + Object.keys(p.groups as object).length <= MAX_CHANNEL_SORT_GROUPS, +}; diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.ts b/desktop/src/features/sidebar/lib/channelStarsSync.ts index a5abec03fb..a07f1a68d6 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.ts +++ b/desktop/src/features/sidebar/lib/channelStarsSync.ts @@ -46,6 +46,11 @@ export class ChannelStarSyncManager { private lastRemoteCreatedAt: number; private pendingStore: ChannelStarStore | null = null; private lastPublishedStore: ChannelStarStore | null = null; + /** The bootstrap first-copy seed; retired once any relay head is observed. */ + private seedStore: ChannelStarStore | null = null; + /** Decoded heads, LWW-merged within the 500-entry bound, for each attempt. */ + private remoteStore: ChannelStarStore | null = null; + private remoteRev = 0; private destroyed = false; constructor(pubkey: string, relayUrl: string) { @@ -71,6 +76,7 @@ export class ChannelStarSyncManager { if (!result) { return { status: "failed", createdAt: event.created_at }; } + this.remember(result.store); return { status: "found", data: result, @@ -87,6 +93,22 @@ export class ChannelStarSyncManager { this.lastRemoteCreatedAt = createdAt; } advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); + // A seed only fills a relay believed empty; any head supersedes it, even + // one whose payload a concurrent reader drops or cannot decrypt. + if (this.seedStore && this.pendingStore === this.seedStore) { + this.cancelPendingStarPublish(); + this.pendingStore = null; + } + this.seedStore = null; + } + + private remember(store: ChannelStarStore): void { + const next = this.remoteStore + ? mergeStores(this.remoteStore, store) + : store; + // Only a changed winner invalidates in-flight attempts. + if (!this.sameEntries(this.remoteStore, next)) this.remoteRev++; + this.remoteStore = next; } cancelPendingStarPublish(): void { @@ -127,19 +149,21 @@ export class ChannelStarSyncManager { this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); if (!remote) return store; - return mergeStores(store, remote.store); + this.remember(remote.store); + return store; } catch { return store; } } - private isIdenticalToLastPublished(store: ChannelStarStore): boolean { - if (!this.lastPublishedStore) return false; - const lastKeys = Object.keys(this.lastPublishedStore.channels); + /** Same winners (IDs, `starred`, `updatedAt`), ignoring key order. */ + private sameEntries(prev: ChannelStarStore | null, store: ChannelStarStore) { + if (!prev) return false; + const lastKeys = Object.keys(prev.channels); const currentKeys = Object.keys(store.channels); if (lastKeys.length !== currentKeys.length) return false; for (const key of currentKeys) { - const last = this.lastPublishedStore.channels[key]; + const last = prev.channels[key]; const current = store.channels[key]; if ( !last || @@ -152,14 +176,29 @@ export class ChannelStarSyncManager { } private async doPublish(store: ChannelStarStore): Promise { + // A seed retired while acquired (preflight, crypto or socket wait) aborts. + const seed = store === this.seedStore; + let rev = this.remoteRev; // a changed winner before dispatch voids it + const stale = () => + this.destroyed || + (seed && this.seedStore !== store) || + rev !== this.remoteRev; + // Only the attempt that still owns `pendingStore` may clear it. + const release = () => { + if (this.pendingStore === store) this.pendingStore = null; + }; try { - const merged = await this.fetchOwnBlobBeforePublish(store); + const pre = await this.fetchOwnBlobBeforePublish(store); + const merged = this.remoteStore + ? mergeStores(pre, this.remoteStore) + : pre; + rev = this.remoteRev; // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish // was awaited (community switch during in-flight fetch). If so, abort // before touching the relay. - if (this.destroyed) return; - if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; + if (stale()) return; + if (this.sameEntries(this.lastPublishedStore, merged)) { + release(); return; } const payload = { @@ -180,17 +219,21 @@ export class ChannelStarSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); - if (this.destroyed) return; + if (stale()) return; await relayClient.publishEvent( event, "Timed out publishing channel stars.", "Failed to publish channel stars.", + () => !stale(), ); this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; - this.pendingStore = null; + release(); } catch (error) { console.warn("[channelStarsSync] publish failed:", error); + } finally { + const owned = !this.destroyed && this.pendingStore === store; + if (owned && rev !== this.remoteRev) this.publishStars(store); } } @@ -210,7 +253,8 @@ export class ChannelStarSyncManager { // still advances the watermark and blocks future seed-publish. this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { - if (result) { + if (result && !this.destroyed) { + this.remember(result.store); onUpdate(result); } }); @@ -229,7 +273,10 @@ export class ChannelStarSyncManager { lastHead: this.lastRemoteCreatedAt, localStore, isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, - publishFn: (s) => this.publishStars(s), + publishFn: (s) => { + this.seedStore = s; + this.publishStars(s); + }, }); } diff --git a/desktop/src/features/sidebar/lib/sidebarLaneHooks.test.mjs b/desktop/src/features/sidebar/lib/sidebarLaneHooks.test.mjs new file mode 100644 index 0000000000..e3d19a0d3b --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLaneHooks.test.mjs @@ -0,0 +1,572 @@ +import assert from "node:assert/strict"; +import { after, before, beforeEach, mock, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { url: "http://localhost" }); +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); +}); +after(() => dom.window.close()); + +const PK = "f".repeat(64); +const RELAY = "wss://r.test"; +let fx; +let seq = 0; +const ev = (dTag, json, createdAt) => ({ + id: String(++seq).padStart(64, "0"), + pubkey: PK, + kind: 30078, + created_at: createdAt, + content: JSON.stringify(json), + tags: [["d", dTag]], + sig: "s", +}); + +async function harness() { + const rtl = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + mock.method(relayClient, "fetchEvents", async (filter) => { + if (fx.onFetch) return fx.onFetch(); + const head = fx.heads[filter["#d"][0]]; + if (fx.gate) { + fx.gated = (fx.gated ?? 0) + 1; // a fetch entered the gated preflight + await fx.gate; + } + return head ? [head] : []; + }); + mock.method(relayClient, "subscribeLive", async (_f, onEvent) => { + fx.live = onEvent; + return async () => {}; + }); + mock.method(relayClient, "subscribeToReconnects", (fn) => { + fx.reconnect = fn; + return () => {}; + }); + mock.method(relayClient, "publishEvent", async (event, _t, _e, isCurrent) => { + fx.attempts.push(event); // the manager reached the send boundary + if (fx.holdPublish) await fx.holdPublish; + if (isCurrent && !isCurrent()) throw new Error("canceled"); + fx.published.push(event); + const d = event.tags[0][1]; // relay rule: newer wins, ties to lower id + const [a, b] = [event, fx.heads[d]]; + if ( + !b || + a.created_at > b.created_at || + (a.created_at === b.created_at && a.id < b.id) + ) + fx.heads[d] = event; + }); + mock.method(console, "warn", () => {}); + window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + if (cmd === "nip44_encrypt_to_self") + return Promise.resolve(fx.holdCrypto).then(() => args.plaintext); + if (cmd === "nip44_decrypt_from_self") return args.ciphertext; + if (cmd === "sign_event") + return JSON.stringify({ + ...ev(args.tags[0][1], JSON.parse(args.content), args.createdAt), + content: args.content, + }); + throw new Error(`unmocked ${cmd}`); + }, + }; + // Advance fake time in steps, letting async work between timers settle. + const advance = async (ms) => { + for (let t = 0; t < ms; t += 1_000) { + await rtl.act(async () => { + mock.timers.tick(1_000); + for (let i = 0; i < 10; i++) await new Promise(setImmediate); + }); + } + }; + return { ...rtl, advance }; +} + +beforeEach(() => { + window.localStorage.clear(); + mock.restoreAll(); + mock.timers.reset(); + mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 }); + fx = { heads: {}, published: [], attempts: [], reconnect: null }; +}); + +for (const [name, modPath, hookName, dTag, idsKey] of [ + [ + "stars", + "./useChannelStars.ts", + "useChannelStars", + "channel-stars", + "starredChannelIds", + ], + [ + "mutes", + "./useChannelMutes.ts", + "useChannelMutes", + "channel-mutes", + "mutedChannelIds", + ], +]) { + test(`${name}: the real hook recovers a missed head on the 5 s and steady 60 s ticks`, async () => { + const { renderHook, advance, cleanup } = await harness(); + const hook = (await import(modPath))[hookName]; + const entry = (ids) => + Object.fromEntries( + ids.map((id) => [ + id, + { [name === "stars" ? "starred" : "muted"]: true, updatedAt: 1 }, + ]), + ); + const { result } = renderHook(() => hook(PK, RELAY)); + await advance(1_000); + assert.equal(result.current[idsKey].size, 0); + fx.heads[dTag] = ev(dTag, { version: 1, channels: entry(["c1"]) }, 100); + await advance(5_000); + assert.deepEqual([...result.current[idsKey]], ["c1"]); + await advance(10_000 + 30_000); // back-off climbs to its steady 60 s + fx.heads[dTag] = ev( + dTag, + { version: 1, channels: entry(["c1", "c2"]) }, + 200, + ); + await advance(60_000); + assert.deepEqual([...result.current[idsKey]].sort(), ["c1", "c2"]); + cleanup(); + }); +} + +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((res, rej) => ([resolve, reject] = [res, rej])); + return { promise, resolve, reject }; +}; + +for (const [name, modPath, hookName, dTag, idsKey, verb, field] of [ + [ + "stars", + "./useChannelStars.ts", + "useChannelStars", + "channel-stars", + "starredChannelIds", + "starChannel", + "starred", + ], + [ + "mutes", + "./useChannelMutes.ts", + "useChannelMutes", + "channel-mutes", + "mutedChannelIds", + "muteChannel", + "muted", + ], +]) { + const payload = (ids, updatedAt = 1) => ({ + version: 1, + channels: Object.fromEntries( + ids.map((id) => [id, { [field]: true, updatedAt }]), + ), + }); + const headIds = () => + Object.entries(JSON.parse(fx.heads[dTag].content).channels) + .filter(([, v]) => v[field]) + .map(([id]) => id) + .sort(); + const visible = () => + document.dispatchEvent(new window.Event("visibilitychange")); + const setup = async () => { + Object.defineProperty(document, "visibilityState", { + value: "visible", + configurable: true, + }); + const h = await harness(); + const hook = (await import(modPath))[hookName]; + fx.storageKey = ( + await import(`./channel${name === "stars" ? "Stars" : "Mutes"}Storage.ts`) + ).storageKey; + return { ...h, hook }; + }; + // B (or the union) must hold in the UI and the persisted cache alike. + const shows = (result, ids, msg) => { + const cached = JSON.parse(window.localStorage.getItem(fx.storageKey(PK))); + assert.deepEqual([...result.current[idsKey]].sort(), ids, msg); + const on = Object.entries(cached.channels).filter(([, v]) => v[field]); + assert.deepEqual(on.map(([id]) => id).sort(), ids, msg); + }; + // A later authoritative head still applies: recovery kept polling. + const resumes = async ({ act, advance }, result, ids) => { + fx.heads[dTag] = ev(dTag, payload([...ids, "c7"], 9), 2e9); + await act(async () => visible()); + await advance(1_000); + shows(result, [...ids, "c7"].sort(), "polling resumed"); + }; + + test(`${name}: an older ACK leaves a newer edit pending through recovery`, async () => { + const h = await setup(); + const { renderHook, act, advance, cleanup, hook } = h; + const { result } = renderHook(() => hook(PK, RELAY)); + await advance(1_000); + act(() => result.current[verb]("c1")); // edit A + const ack = deferred(); + fx.holdPublish = ack.promise; + await advance(2_000); // A is at the socket, its ACK held + act(() => result.current[verb]("c2")); // edit B, deadline +2 s + fx.holdPublish = null; + await act(async () => ack.resolve()); + assert.deepEqual(headIds(), ["c1"], "A's ACK exit ran before recovery"); + await act(async () => visible()); // recovery before B's deadline + shows(result, ["c1", "c2"], "B survives the recovery"); + await advance(3_000); + assert.deepEqual(headIds(), ["c1", "c2"], "B reached the relay"); + shows(result, ["c1", "c2"], "B settled"); + await resumes(h, result, ["c1", "c2"]); + cleanup(); + }); + + test(`${name}: an identical-payload exit leaves a newer edit pending through recovery`, async () => { + const h = await setup(); + const { renderHook, act, advance, cleanup, hook } = h; + const { result } = renderHook(() => hook(PK, RELAY)); + await advance(1_000); + act(() => result.current[verb]("c1")); // A + const ack = deferred(); + fx.holdPublish = ack.promise; + await advance(2_000); + await act(async () => fx.reconnect()); // re-queues pending A + fx.holdPublish = null; + await act(async () => ack.resolve()); + const gate = deferred(); + fx.gate = gate.promise; + await advance(2_000); // A's repeat waits in preflight + assert.equal(fx.gated, 1, "A's repeat entered its gated preflight"); + act(() => result.current[verb]("c2")); // B + fx.gate = null; + await act(async () => gate.resolve()); // repeat of A exits as identical + assert.equal(fx.attempts.length, 1, "A's repeat sent nothing"); + await act(async () => visible()); + shows(result, ["c1", "c2"], "B survives the recovery"); + await advance(3_000); + assert.deepEqual(headIds(), ["c1", "c2"], "B reached the relay"); + shows(result, ["c1", "c2"], "B settled"); + await resumes(h, result, ["c1", "c2"]); + cleanup(); + }); + + const stages = ["absent", "failed", "found", "crypto", "socket"]; + const edits = ["edit", "edit-absent", "edit-failed", "edit2"]; + const mid = ["edit-late", "edit-crypto", "edit-stale"]; // decoded mid-attempt + edits.push(...mid, "edit-conflict"); + for (const stage of [...stages, ...edits, "ack"]) { + const what = + { + edit: "a genuine edit survives the seed's retirement", + "edit-absent": "an edit whose preflight is absent keeps R", + "edit-failed": "an edit whose preflight fails keeps R", + edit2: "a second edit whose preflight fails keeps R", + "edit-late": "R decoded during a failing preflight is kept", + "edit-crypto": "R decoded during encryption requeues the edit", + "edit-stale": "an observation with no changed winner cancels nothing", + "edit-conflict": "R's future-dated false beats the edit's true", + ack: "the seed's own ACK leaves a genuine edit pending", + }[stage] ?? `an acquired seed yields to R (retired at ${stage})`; + test(`${name}: startup recovery; ${what}`, async () => { + const { renderHook, act, advance, cleanup, hook } = await setup(); + window.localStorage.setItem( + fx.storageKey(PK), + JSON.stringify(payload(["s1"])), + ); + const off = { [field]: false, updatedAt: 3e12 }; // future-dated winner + const channels = payload(["r1"], 2e12).channels; + if (stage === "edit-conflict") channels.c9 = off; + const R = ev(dTag, { version: 1, channels }, 1e9 + 30); // future-dated + if (stage !== "ack") fx.heads[dTag] = R; + const calls = []; + fx.onFetch = () => { + const d = deferred(); + calls.push(d); + return d.promise; + }; + const { result } = renderHook(() => hook(PK, RELAY)); + await act(async () => calls[0].resolve([])); // bootstrap: absent, seeds S + const held = deferred(); + if (mid.includes(stage)) { + const [late, early] = [stage === "edit-late", stage === "edit-stale"]; + act(() => result.current[verb]("c9")); + if (early) await act(async () => calls[1].resolve([R])); + if (!late) fx.holdCrypto = held.promise; + await advance(2_000); // the edit's preflight waits + if (!late) await act(async () => calls[2].resolve([])); + const old = ev(dTag, payload(["r1"]), 1e9 + 10); + if (early) + await act(async () => fx.live(old)); // stale copy + else await act(async () => calls[1].resolve([R])); // decodes R + if (late) calls[2].reject(new Error("x")); + await act(async () => held.resolve()); + fx.holdCrypto = null; + const sends = early ? 1 : 0; // only R's arrival voids the snapshot + if (!late) assert.equal(fx.attempts.length, sends, "fence"); + } else if (edits.includes(stage)) { + const fail = (c) => + stage === "edit-absent" ? c.resolve([]) : c.reject(new Error("x")); + act(() => result.current[verb]("c9")); + await act(async () => calls[1].resolve([R])); // recovery observes R + await advance(2_000); + const ok = stage === "edit" || stage === "edit2"; + await act(async () => (ok ? calls[2].resolve([R]) : fail(calls[2]))); + if (stage === "edit2") { + act(() => result.current[verb]("c8")); // second edit + await advance(2_000); + await act(async () => fail(calls.at(-1))); + } + } else if (stage === "ack") { + fx.holdPublish = held.promise; + await advance(2_000); // S acquired + await act(async () => calls[1].resolve([])); + await act(async () => calls[2].resolve([])); // S at the socket + act(() => result.current[verb]("c9")); // genuine edit B + fx.onFetch = fx.holdPublish = null; + await act(async () => held.resolve()); // S's own ACK + } else if (stage === "crypto" || stage === "socket") { + fx[stage === "crypto" ? "holdCrypto" : "holdPublish"] = held.promise; + await advance(2_000); // seed acquired + await act(async () => calls[2].resolve([])); // preflight: absent + await act(async () => calls[1].resolve([R])); // retire mid-stage + await act(async () => held.resolve()); + const sends = stage === "crypto" ? 0 : 1; // observable send boundary + assert.equal(fx.attempts.length, sends, `retired during ${stage}`); + } else { + await advance(2_000); // seed acquired; its preflight waits + await act(async () => calls[1].resolve([R])); // recovery observes R + if (stage === "failed") calls[2].reject(new Error("offline")); + else calls[2].resolve(stage === "found" ? [R] : []); + } + await advance(3_000); + // The later scheduled read gets whatever head the relay kept. + fx.onFetch = null; + for (const c of calls) c.resolve([fx.heads[dTag]]); + await advance(60_000); + const kept = stage.startsWith("edit") + ? [...({ edit2: ["c8", "c9"], "edit-conflict": [] }[stage] ?? ["c9"])] + .concat("r1", "s1") + .sort() + : { ack: ["c9", "s1"] }[stage]; + shows(result, kept ?? ["r1", "s1"], "UI and cache"); + const sent = fx.published.map((e) => JSON.parse(e.content).channels); + if (edits.includes(stage)) + assert.ok( + sent.every((c) => c.r1), + "no send lacks r1 (presence only)", + ); + if (!kept) { + assert.equal(fx.published.length, 0, "the seed never published"); + assert.equal(fx.heads[dTag], R, "R stays the relay head"); + } + assert.deepEqual(headIds(), kept ?? ["r1"]); + const head = JSON.parse(fx.heads[dTag].content).channels; + const cache = window.localStorage.getItem(fx.storageKey(PK)); + const both = [head, JSON.parse(cache).channels]; // relay and cache + for (const ch of edits.includes(stage) ? both : []) { + assert.deepEqual(ch.r1, { [field]: true, updatedAt: 2e12 }); + if (stage === "edit-conflict") assert.deepEqual(ch.c9, off); + } + cleanup(); // a fresh reader consumes only the retained head + window.localStorage.clear(); + const fresh = renderHook(() => hook(PK, RELAY)); + await advance(1_000); + assert.deepEqual( + [...fresh.result.current[idsKey]].sort(), + kept ?? ["r1"], + ); + cleanup(); + }); + } +} + +test("sections hook: 60 s tick, reconnect, cross-tab, pending edit and in-flight unmount", async () => { + const { renderHook, act, advance, cleanup } = await harness(); + const { useChannelSections } = await import("./useChannelSections.ts"); + const { storageKey } = await import("./channelSectionsStorage.ts"); + const legacy = (names) => ({ + version: 1, + sections: names.map((n, order) => ({ id: n, name: n, order })), + assignments: {}, + }); + fx.heads["channel-sections"] = ev("channel-sections", legacy(["a"]), 100); + const { result, unmount } = renderHook(() => useChannelSections(PK, RELAY)); + await advance(1_000); + const names = () => result.current.sections.map((s) => s.name); + assert.deepEqual(names(), ["a"]); + + // Missed live event after the back-off reached 60 s (ticks at 5/15/45 s): + // only the steady tick at 105 s picks it up. + await advance(45_000); + fx.heads["channel-sections"] = ev( + "channel-sections", + legacy(["a", "b"]), + 200, + ); + await advance(58_000); + assert.deepEqual(names(), ["a"], "not yet: no tick before 105 s"); + await advance(2_000); + assert.deepEqual(names(), ["a", "b"]); + + // Reconnect re-reads. + fx.heads["channel-sections"] = ev( + "channel-sections", + legacy(["a", "b", "c"]), + 300, + ); + await act(async () => fx.reconnect()); + await advance(1_000); + assert.deepEqual(names(), ["a", "b", "c"]); + + // Another tab's write merges (never replaces). + const other = legacy(["d"]); + await act(async () => + window.dispatchEvent( + new window.StorageEvent("storage", { + key: storageKey(PK, RELAY), + newValue: JSON.stringify(other), + }), + ), + ); + assert.deepEqual(names().sort(), ["a", "b", "c", "d"]); + + // A read (here, reconnect) during the edit debounce merges but cannot + // publish early or shorten the debounce. + const before = fx.published.length; + act(() => void result.current.createSection("e")); + fx.heads["channel-sections"] = ev( + "channel-sections", + legacy(["a", "b", "c", "f"]), + 400, + ); + await act(async () => fx.reconnect()); + await advance(1_000); + assert.equal(fx.published.length, before, "held by the debounce"); + assert.ok(names().includes("f") && names().includes("e")); + + // Unmount while the publish is in flight: nothing reaches the socket. + let release; + fx.holdPublish = new Promise((r) => (release = r)); + await advance(2_000); + unmount(); + release(); + await advance(1_000); + assert.equal(fx.published.length, before); + cleanup(); +}); + +test("sections hook: create appends past gaps; reorder completes against the live list", async () => { + const { renderHook, act, advance, cleanup } = await harness(); + const { useChannelSections } = await import("./useChannelSections.ts"); + const { storageKey } = await import("./channelSectionsStorage.ts"); + const { result } = renderHook(() => useChannelSections(PK, RELAY)); + await advance(1_000); + const names = () => result.current.sections.map((s) => s.name); + const ids = {}; + for (const n of ["a", "b", "c"]) + act(() => { + ids[n] = result.current.createSection(n).id; + }); + act(() => result.current.deleteSection(ids.b)); // canonical ranks 0 and 2 + act(() => void result.current.createSection("d")); + assert.deepEqual(names(), ["a", "c", "d"], "new section sorts last"); + + // A section merged in from another tab after the drag snapshot was taken. + const snapshot = result.current.sections.map((s) => s.id); + await act(async () => + window.dispatchEvent( + new window.StorageEvent("storage", { + key: storageKey(PK, RELAY), + newValue: JSON.stringify({ + version: 1, + sections: [{ id: "x", name: "x", order: 9 }], + assignments: {}, + }), + }), + ), + ); + act(() => + result.current.reorderSections([...snapshot].reverse().concat("gone")), + ); + assert.deepEqual(names(), ["d", "c", "a", "x"]); + const orders = JSON.parse(window.localStorage.getItem(storageKey(PK, RELAY))) + .meta.s; + assert.ok(orders.x.order, "the missing live section got an order register"); + cleanup(); +}); + +const UPGRADE_LANES = [ + { + name: "sections", + dTag: "channel-sections", + load: async () => [ + (await import("./useChannelSections.ts")).useChannelSections, + (await import("./channelSectionsStorage.ts")).storageKey, + ], + legacy: (name) => ({ + version: 1, + sections: [{ id: "x", name, order: 0 }], + assignments: {}, + }), + ui: (r) => r.sections[0]?.name, + fromDoc: (doc) => doc.sections[0]?.name, + edit: (r, v) => r.renameSection("x", v), + values: ["Eng", "Platform", "Mine"], + }, + { + name: "sort", + dTag: "channel-sort", + load: async () => [ + (await import("./useChannelSortPreference.ts")).useChannelSortPreference, + (await import("./channelSortPreference.ts")).storageKey, + ], + legacy: (mode) => ({ version: 1, groups: { channels: mode } }), + ui: (r) => r.sortModeFor("channels"), + fromDoc: (doc) => doc.groups.channels, + edit: (r, v) => { + r.setSortModeFor("channels", "alpha"); // authored, then back + r.setSortModeFor("channels", v); + }, + values: ["recent", "alpha", "recent"], + }, +]; + +for (const L of UPGRADE_LANES) { + for (const edited of [false, true]) { + const what = edited + ? "a genuine local edit still beats the relay" + : "a cache-only import yields to the relay head"; + test(`${L.name} first upgrade: ${what}`, async () => { + const { renderHook, act, advance, cleanup } = await harness(); + const [hook, key] = await L.load(); + const [cached, remote, mine] = L.values; + window.localStorage.setItem( + key(PK, RELAY), + JSON.stringify(L.legacy(cached)), + ); + fx.heads[L.dTag] = ev(L.dTag, L.legacy(remote), 100); + let open; + fx.gate = new Promise((r) => (open = r)); + const { result } = renderHook(() => hook(PK, RELAY)); + if (edited) act(() => L.edit(result.current, mine)); + fx.gate = null; + open(); + await advance(5_000); + const want = edited ? mine : remote; + assert.equal(L.ui(result.current), want, "UI"); + const cache = JSON.parse(window.localStorage.getItem(key(PK, RELAY))); + assert.equal(L.fromDoc(cache), want, "cache"); + const head = JSON.parse(fx.heads[L.dTag].content); + assert.equal(L.fromDoc(head), want, "relay head"); + assert.ok(head.meta, "a fresh reader gets the registers"); + cleanup(); + }); + } +} diff --git a/desktop/src/features/sidebar/lib/sidebarLaneReconciler.ts b/desktop/src/features/sidebar/lib/sidebarLaneReconciler.ts new file mode 100644 index 0000000000..a36e76d091 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLaneReconciler.ts @@ -0,0 +1,387 @@ +import * as React from "react"; + +import { relayClient } from "@/shared/api/relayClient"; +import { PublishCanceledError } from "@/shared/api/relayEventPublisher"; +import { + nip44DecryptFromSelf, + nip44EncryptToSelf, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds"; +import { + decodeDoc, + encodeDoc, + LaneStore, + type LaneCodec, +} from "./sidebarLaneStore"; +import { canonical, isEmptyTree, type Tree } from "./sidebarLwwMap"; +import { advanceWatermark, readWatermark } from "./sidebarSyncWatermark"; +import { useStaleReaderRecovery } from "./useStaleReaderRecovery"; + +/** Relay event size ceiling shared by every sidebar blob. */ +const MAX_CIPHERTEXT_BYTES = 65_535; +const DEBOUNCE_MS = 2_000; +const BACKOFF_MS = [5_000, 10_000, 30_000, 60_000, 60_000]; + +export type Lane = LaneCodec & { + dTag: string; + /** False when the projected payload exceeds today's count caps. */ + withinLimits: (projection: Record) => boolean; +}; + +type Status = "unknown" | "empty" | "decoding" | "decoded" | "unreadable"; +type Head = { + id: string; + createdAt: number; + status: Status; + digest: string | null; +}; + +/** `a` beats `b`: later `created_at`, then lower id (the relay's replaceable winner). */ +const beats = (a: RelayEvent, b: Head) => + a.created_at > b.createdAt || (a.created_at === b.createdAt && a.id < b.id); + +/** + * Converges one lane's local tree with its relay head. Publishes only when the + * decoded head's canonical bytes differ from the local tree's, never over an + * undecoded or unreadable head, and never over an absence after a head has + * been seen in this scope (the watermark). Every publish attempt waits for + * `notBefore` (edit debounce or failure backoff); reads and merges do not. + */ +export class LaneReconciler { + head: Head = { id: "", createdAt: 0, status: "unknown", digest: null }; + private lastHead: number; + private attempt: object | null = null; + private recheck = false; + private failures = 0; + private notBefore = 0; + private observations = 0; + private timer: ReturnType | null = null; + private destroyed = false; + + private readonly lane: Lane; + private readonly store: LaneStore; + private readonly pubkey: string; + private readonly relayUrl: string; + + constructor(lane: Lane, store: LaneStore, pubkey: string, relayUrl: string) { + this.lane = lane; + this.store = store; + this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastHead = readWatermark(pubkey, lane.dTag, relayUrl); + } + + filter(limit: number) { + return { + kinds: [KIND_CHANNEL_SECTIONS], + authors: [this.pubkey], + "#d": [this.lane.dTag], + limit, + }; + } + + /** Records a raw head; one that beats the current head holds publishing until decoded. */ + private observe(event: RelayEvent): void { + this.observations++; + this.lastHead = Math.max(this.lastHead, event.created_at); + advanceWatermark( + this.pubkey, + this.lane.dTag, + this.relayUrl, + event.created_at, + ); + if (!beats(event, this.head)) return; + this.head = { + id: event.id, + createdAt: event.created_at, + status: "decoding", + digest: null, + }; + } + + /** Decodes and merges `event`; its status lands only while it is still the head. */ + async ingest(event: RelayEvent): Promise { + if (event.pubkey !== this.pubkey) return; + this.observe(event); + const mine = this.observations; + let json: unknown = null; + try { + json = JSON.parse(await nip44DecryptFromSelf(event.content)); + } catch { + json = null; + } + if (this.destroyed) return; + const doc = decodeDoc(this.lane, json, event.created_at * 1_000); + if (doc) this.store.merge(doc.tree, doc.legacy); + // Only the newest observation (event or absence) may set eligibility; a + // stale decode still merges its content above. + if (this.head.id === event.id && this.observations === mine) { + this.head = doc + ? { ...this.head, status: "decoded", digest: canonical(json) } + : { ...this.head, status: "unreadable", digest: null }; + } + this.reconcile(); + } + + /** One relay read. Throws on transport failure. */ + async read(): Promise { + const seen = this.observations; + const [event] = await relayClient.fetchEvents(this.filter(1)); + if (this.destroyed) return; + if (event && event.pubkey === this.pubkey) return this.ingest(event); + // Absence counts only if nothing was observed while it was in flight. It + // permits a first copy for a genuinely new scope; after a head has been + // seen it demotes the head and holds until a readable head returns. + if (seen === this.observations) { + this.observations++; + const status = this.lastHead === 0 ? "empty" : "unknown"; + this.head = { ...this.head, status, digest: null }; + } + this.reconcile(); + } + + reconcile(): void { + if (this.destroyed) return; + const wait = this.notBefore - Date.now(); + if (wait > 0) { + this.wake(wait); // hold; re-read at the deadline + return; + } + if (this.attempt) { + this.recheck = true; + return; + } + const { status, digest } = this.head; + if (status !== "empty" && status !== "decoded") return; // hold; retried on cadence + const tree = this.store.get(); + const settled = + status === "empty" + ? isEmptyTree(tree) + : digest === canonical(encodeDoc(this.lane, tree)); + if (settled) { + this.failures = 0; + return; + } + void this.runAttempt(); + } + + /** Re-reads after `delay`. Never shortens a publish hold (`defer`). */ + wake(delay = 0): void { + if (this.destroyed) return; + if (this.timer !== null) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = null; + this.read().catch(() => this.backoff()); + }, delay); + } + + /** Holds publishing for at least `delay`; local edits coalesce behind it. */ + defer(delay = DEBOUNCE_MS): void { + this.notBefore = Math.max(this.notBefore, Date.now() + delay); + this.wake(delay); + } + + private backoff(): void { + const delay = BACKOFF_MS[ + Math.min(this.failures, BACKOFF_MS.length - 1) + ] as number; + this.failures++; + this.defer(delay); + } + + private async runAttempt(): Promise { + const token = {}; + this.attempt = token; + let outcome: "settled" | "acked" | "held" | "failed" = "failed"; + const held = () => Date.now() < this.notBefore; + let head = this.head; + let tree: Tree = this.store.get(); + try { + await this.read(); // preflight: merge the current head first + head = this.head; + tree = this.store.get(); + if (held()) { + outcome = "held"; // a deadline arrived during preflight + return; + } + const doc = encodeDoc(this.lane, tree); + const empty = head.status === "empty" && isEmptyTree(tree); + if (head.status !== "empty" && head.status !== "decoded") { + outcome = "settled"; // hold: the recovery cadence re-reads + return; + } + if ( + empty || + head.digest === canonical(doc) || + !this.lane.withinLimits(doc) + ) { + outcome = "settled"; // equal, or over limit: kept locally only + return; + } + const dropped = () => + this.destroyed || + held() || + this.store.get() !== tree || + this.head.id !== head.id || + this.head.status !== head.status; + // A deliberate deadline cancel keeps the deadline; stale drops back off. + // The cause is captured when the drop happens, not when it is consumed. + let heldDrop = false; + const drop = () => { + if (!dropped()) return false; + heldDrop = held(); + if (heldDrop) outcome = "held"; + return true; + }; + const content = await nip44EncryptToSelf(JSON.stringify(doc)); + if (new TextEncoder().encode(content).length > MAX_CIPHERTEXT_BYTES) { + outcome = "settled"; + return; + } + if (drop()) return; + const event = await signRelayEvent({ + kind: KIND_CHANNEL_SECTIONS, + content, + createdAt: Math.max(Math.floor(Date.now() / 1_000), head.createdAt + 1), + tags: [ + ["d", this.lane.dTag], + ["t", this.lane.dTag], // relay discoverability; not used in our filters + ], + }); + if (drop()) return; + try { + await relayClient.publishEvent( + event, + `Timed out publishing ${this.lane.dTag}.`, + `Failed to publish ${this.lane.dTag}.`, + () => !drop(), // checked again right before each socket send + ); + } catch (error) { + if (error instanceof PublishCanceledError && heldDrop) return; + if (!String((error as Error)?.message).startsWith("duplicate:")) + throw error; + } + outcome = "acked"; + this.lastHead = Math.max(this.lastHead, event.created_at); + advanceWatermark( + this.pubkey, + this.lane.dTag, + this.relayUrl, + event.created_at, + ); + } catch (error) { + console.warn(`[${this.lane.dTag}] sync attempt failed:`, error); + } finally { + if (this.attempt === token) this.attempt = null; + const recheck = this.recheck; + this.recheck = false; + // An ACK proves nothing about the head; verify with a read. Real + // failures and stale drops retry behind the backoff, never immediately. + if (outcome === "acked") this.wake(); + else if (outcome === "held") + this.wake(this.notBefore - Date.now()); // deadline kept; no retry now + else if (outcome === "failed") this.backoff(); + else if (recheck && (this.head !== head || this.store.get() !== tree)) { + this.reconcile(); + } + } + } + + destroy(): void { + this.destroyed = true; + if (this.timer !== null) clearTimeout(this.timer); + } +} + +/** + * Binds a lane to React for one pubkey + relay scope: the store's tree as + * state, and a reconciler fed by bootstrap, live events, reconnects, local + * edits, and the stale-reader recovery cadence. + */ +export function useLaneSync( + lane: Lane, + pubkey: string | undefined, + relayUrl: string | undefined, +): { tree: Tree; edit: (fn: (tree: Tree) => Tree) => void } { + const store = React.useMemo( + () => (pubkey && relayUrl ? new LaneStore(lane, pubkey, relayUrl) : null), + [lane, pubkey, relayUrl], + ); + const [reconciler, setReconciler] = React.useState( + null, + ); + const reconcilerRef = React.useRef(null); + reconcilerRef.current = reconciler; + const tree = React.useSyncExternalStore( + React.useCallback( + (fn: () => void) => store?.subscribe(fn) ?? (() => {}), + [store], + ), + () => store?.get() ?? EMPTY, + ); + + React.useEffect(() => { + if (!store || !pubkey || !relayUrl) return; + const rec = new LaneReconciler(lane, store, pubkey, relayUrl); + setReconciler(rec); + let disposed = false; + let unsubLive: (() => Promise) | null = null; + const detachTabs = store.attachCrossTab(); + const unsubReconnect = relayClient.subscribeToReconnects(() => rec.wake()); + void relayClient + .subscribeLive(rec.filter(0), (event) => { + if (!disposed) void rec.ingest(event); + }) + .then((dispose) => { + if (disposed) void dispose(); + else unsubLive = dispose; + }) + .catch(() => {}); + return () => { + disposed = true; + rec.destroy(); + detachTabs(); + unsubReconnect(); + if (unsubLive) void unsubLive(); + setReconciler(null); + }; + }, [lane, store, pubkey, relayUrl]); + + // D2: the shared recovery cadence drives reads even while an edit is + // pending; the reconciler consumes every result, so nothing is applied here. + const recoveryRead = React.useCallback(async () => { + store?.persist(); + await reconciler?.read().catch(() => {}); + return undefined; + }, [store, reconciler]); + useStaleReaderRecovery({ + enabled: reconciler !== null, + fetch: recoveryRead, + hasPending: NEVER_PENDING, + getRevision: ZERO, + makeUpdater: IDENTITY, + setStore: NOOP, + }); + + // A local edit persists immediately and publishes after the debounce; an + // edit made before the reconciler exists is picked up by its first read. + const edit = React.useCallback( + (fn: (tree: Tree) => Tree) => { + if (!store) return; + const before = store.get(); + store.transact(fn); + if (store.get() !== before) reconcilerRef.current?.defer(); + }, + [store], + ); + + return { tree, edit }; +} + +const EMPTY: Tree = Object.freeze({}) as Tree; +const NEVER_PENDING = () => false; +const ZERO = () => 0; +const IDENTITY = () => (prev: never) => prev; +const NOOP = () => {}; diff --git a/desktop/src/features/sidebar/lib/sidebarLaneStore.ts b/desktop/src/features/sidebar/lib/sidebarLaneStore.ts new file mode 100644 index 0000000000..fdbc2117d4 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLaneStore.ts @@ -0,0 +1,158 @@ +import { + canonical, + isReg, + LEGACY_DEV, + mergeTrees, + validTree, + type Reg, + type Shape, + type Tree, +} from "./sidebarLwwMap"; + +/** How one sidebar preference maps between its register tree and its JSON. */ +export type LaneCodec = { + storageKey: (pubkey: string, relayUrl: string) => string; + /** Pre-relay-scoping key migrated once on first read, then removed. */ + legacyStorageKey?: (pubkey: string) => string; + /** Shape of `meta` (excluding `meta.v`). */ + shape: Shape; + /** Registers for a meta-less payload, every value stamped by `stamp`. */ + fromLegacy: (json: unknown, stamp: (value: T) => Reg) => Tree | null; + /** The legacy fields older clients read, projected from the registers. */ + project: (tree: Tree) => Record; +}; + +export type DecodedDoc = { tree: Tree; legacy: boolean }; + +/** + * Decodes a `version: 1` payload. A meta-less payload (older writer) is + * imported at version `legacyV`; the caller merges it only into missing + * leaves. Returns null when unreadable, including an unknown `meta.v`. + */ +export function decodeDoc( + codec: LaneCodec, + json: unknown, + legacyV: number, +): DecodedDoc | null { + if (typeof json !== "object" || json === null) return null; + const { version, meta } = json as { version?: unknown; meta?: unknown }; + if (version !== 1) return null; + if (meta === undefined) { + // Validated like `meta`, so legacy keys obey the same own-key rules. + const tree = validTree( + codec.fromLegacy(json, (value) => [legacyV, LEGACY_DEV, value]), + codec.shape, + ); + return tree && !isReg(tree) ? { tree, legacy: true } : null; + } + if ((meta as { v?: unknown } | null)?.v !== 1) return null; + const { v: _v, ...fields } = meta as Record; + const tree = validTree(fields, codec.shape); + return tree && !isReg(tree) ? { tree, legacy: false } : null; +} + +export function encodeDoc(codec: LaneCodec, tree: Tree) { + return { version: 1, ...codec.project(tree), meta: { v: 1, ...tree } }; +} + +/** + * The one authoritative register tree for a lane in a pubkey + relay scope. + * Every read and edit goes through `transact`; the full tree persists at rest + * (limits gate only publishing). + */ +export class LaneStore { + private tree: Tree = {}; + private dirty = false; + /** Pre-relay-scoping key to remove once the scoped copy is durable. */ + private legacyKey: string | undefined; + private listeners = new Set<() => void>(); + private readonly key: string; + + private readonly codec: LaneCodec; + + constructor(codec: LaneCodec, pubkey: string, relayUrl: string) { + this.codec = codec; + this.key = codec.storageKey(pubkey, relayUrl); + const legacyKey = codec.legacyStorageKey?.(pubkey); + const raw = this.readRaw(this.key) ?? this.readRaw(legacyKey); + // Meta-less local caches import at a stable stamp any remote beats. + const doc = raw === undefined ? null : decodeDoc(codec, raw, 1); + if (!doc) return; + this.tree = doc.tree; + this.dirty = doc.legacy; + this.legacyKey = legacyKey; + this.persist(); + } + + private readRaw(key: string | undefined): unknown { + try { + const raw = key ? window.localStorage.getItem(key) : null; + return raw === null ? undefined : JSON.parse(raw); + } catch { + return undefined; + } + } + + get = (): Tree => this.tree; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + /** + * Applies `fn`. A tree with unchanged canonical bytes has no side effects + * beyond retrying an earlier failed write. + */ + transact(fn: (tree: Tree) => Tree): void { + const next = fn(this.tree); + if (next === this.tree || canonical(next) === canonical(this.tree)) { + this.persist(); + return; + } + this.tree = next; + this.dirty = true; + this.persist(); + for (const listener of this.listeners) listener(); + } + + merge(remote: Tree, onlyMissing = false): void { + this.transact((tree) => mergeTrees(tree, remote, onlyMissing)); + } + + /** + * Writes the tree if an earlier write failed or is outstanding, then retires + * the legacy key once the scoped copy is durable. Both retry on later calls. + */ + persist(): boolean { + try { + if (this.dirty) { + const raw = canonical(encodeDoc(this.codec, this.tree)); + window.localStorage.setItem(this.key, raw); + this.dirty = false; + } + if (this.legacyKey && this.readRaw(this.key) !== undefined) { + window.localStorage.removeItem(this.legacyKey); + this.legacyKey = undefined; + } + } catch { + // Stays pending; retried on the next transaction or recovery tick. + } + return !this.dirty; + } + + /** Merges (never replaces) another tab's copy of this scope. */ + attachCrossTab(): () => void { + const handler = (event: StorageEvent) => { + if (event.key !== this.key || event.newValue === null) return; + try { + const doc = decodeDoc(this.codec, JSON.parse(event.newValue), 1); + if (doc) this.merge(doc.tree, doc.legacy); + } catch { + // Ignore a malformed foreign write. + } + }; + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); + } +} diff --git a/desktop/src/features/sidebar/lib/sidebarLaneSync.test.mjs b/desktop/src/features/sidebar/lib/sidebarLaneSync.test.mjs new file mode 100644 index 0000000000..e6a2e7a46f --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLaneSync.test.mjs @@ -0,0 +1,643 @@ +import assert from "node:assert/strict"; +import test, { beforeEach, mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { SECTIONS_LANE, projectSections } from "./channelSectionsSync.ts"; +import { SORT_LANE, projectSort } from "./channelSortSync.ts"; +import { + PublishCanceledError, + publishSessionEvent, +} from "@/shared/api/relayEventPublisher"; +import { LaneReconciler } from "./sidebarLaneReconciler.ts"; +import { LaneStore } from "./sidebarLaneStore.ts"; +import { canonical, setRegs } from "./sidebarLwwMap.ts"; + +const PK = "f".repeat(64); +const RELAY = "wss://r.test"; +const storage = new Map(); +globalThis.window ??= {}; +Object.assign(globalThis.window, { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => { + if (fx.storageFails) throw new Error("quota"); + fx.writes++; + storage.set(k, v); + }, + removeItem: (k) => storage.delete(k), + }, + addEventListener: () => {}, + removeEventListener: () => {}, +}); + +/** Fake relay (one replaceable head), identity crypto, scripted failures. */ +let fx; +let seq = 0; +beforeEach(() => { + storage.clear(); + mock.restoreAll(); + fx = { + head: null, + published: [], + fetchFails: false, + publish: "ok", + storageFails: false, + writes: 0, + badDecrypt: new Set(), + }; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + if (cmd === "nip44_encrypt_to_self") return args.plaintext; + if (cmd === "nip44_decrypt_from_self") { + if (fx.badDecrypt.has(args.ciphertext)) throw new Error("bad"); + if (fx.holdDecrypt && --fx.holdAfter < 0) await fx.holdDecrypt; + return args.ciphertext; + } + if (cmd === "sign_event") + return JSON.stringify(ev(args.content, args.createdAt)); + throw new Error(`unmocked ${cmd}`); + }, + }; + mock.method(relayClient, "fetchEvents", async () => { + const result = fx.head ? [fx.head] : []; // snapshot at request time + if (fx.gate) await fx.gate; + if (fx.fetchFails) throw new Error("offline"); + return result; + }); + mock.method(console, "warn", () => {}); + mock.method(relayClient, "publishEvent", async (event, _t, _e, isCurrent) => { + fx.beforeSend?.(); + if (!isCurrent()) throw (await fx.consume) ?? new PublishCanceledError(); + fx.published.push(event); + if (fx.publish === "cancelText") { + fx.publish = "ok"; + fx.afterSend(); + throw new Error(new PublishCanceledError().message); // a relay's text + } + if (fx.publish === "timeout") throw new Error("Timed out"); + if (fx.publish === "reject") throw new Error("blocked: nope"); + if (fx.publish === "duplicate") throw new Error("duplicate: have it"); + if (fx.publish !== "lost") fx.head = event; + }); +}); + +function ev(content, createdAt) { + seq++; + return { + id: String(seq).padStart(64, "0"), + pubkey: PK, + kind: 30078, + created_at: createdAt, + content, + tags: [], + sig: "s", + }; +} + +function device(lane) { + const store = new LaneStore(lane, PK, RELAY); + const rec = new LaneReconciler(lane, store, PK, RELAY); + rec.wake = () => {}; // timers are driven explicitly via read() + return { store, rec }; +} + +const settle = () => new Promise((r) => setImmediate(r)); +async function sync(d) { + await d.rec.read(); + for (let i = 0; i < 5; i++) await settle(); +} + +const LANES = [ + { + name: "sections", + lane: SECTIONS_LANE, + edit: (tree, key, value) => + setRegs(tree, [ + [["s", key, "name"], value], + [["s", key, "order"], 0], + [["s", key, "live"], true], + ]), + remove: (tree, key) => setRegs(tree, [[["s", key, "live"], false]]), + view: (tree) => + Object.fromEntries( + projectSections(tree).sections.map((s) => [s.id, s.name]), + ), + legacy: (items) => ({ + version: 1, + sections: Object.entries(items).map(([id, name], order) => ({ + id, + name, + order, + })), + assignments: {}, + }), + big: (tree) => + setRegs( + tree, + Array.from({ length: 101 }, (_, i) => [ + ["s", `s${i}`, "live"], + true, + ]).flatMap((w, i) => [ + w, + [["s", `s${i}`, "name"], "n"], + [["s", `s${i}`, "order"], i], + ]), + ), + }, + { + name: "sort", + lane: SORT_LANE, + edit: (tree, key, value) => + setRegs(tree, [ + [ + ["g", `section:${key}`], + value === "B" || value === "Y" ? "recent" : "alpha", + ], + ]), + remove: (tree, key) => setRegs(tree, [[["g", `section:${key}`], null]]), + view: (tree) => + Object.fromEntries( + Object.entries(projectSort(tree).groups).map(([k, m]) => [ + k.slice(8), + m === "recent" ? "B" : "A", + ]), + ), + legacy: (items) => ({ + version: 1, + groups: Object.fromEntries( + Object.entries(items).map(([k, v]) => [ + `section:${k}`, + v === "B" ? "recent" : "alpha", + ]), + ), + }), + big: (tree) => + setRegs( + tree, + Array.from({ length: 105 }, (_, i) => [ + ["g", `section:${i}`], + "recent", + ]), + ), + }, +]; + +for (const L of LANES) { + test(`${L.name}: new scope with local data publishes its first copy`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + await sync(d); + assert.equal(fx.published.length, 1); + const doc = JSON.parse(fx.published[0].content); + assert.equal(doc.version, 1); + assert.equal(doc.meta.v, 1); + await sync(d); + assert.equal(fx.published.length, 1, "equal digest: no republish"); + }); + + test(`${L.name}: absence after a seen head holds (never publishes over it)`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + await sync(d); + fx.head = null; // relay transiently returns nothing + const d2 = device(L.lane); // restart: watermark > 0 + d2.store.transact((t) => L.edit(t, "k2", "B")); + await sync(d2); + assert.equal(fx.published.length, 1); + assert.equal(d2.rec.head.status, "unknown"); + }); + + test(`${L.name}: stale device merges instead of overwriting newer items`, async () => { + const a = device(L.lane); + a.store.transact((t) => L.edit(t, "k1", "A")); + await sync(a); + storage.clear(); // B: separate install with its own local cache + const b = device(L.lane); + b.store.transact((t) => L.edit(t, "k2", "B")); + await sync(b); + assert.deepEqual(L.view(JSON.parse(fx.head.content).meta), { + k1: "A", + k2: "B", + }); + await sync(a); + assert.deepEqual(L.view(a.store.get()), { k1: "A", k2: "B" }); + }); + + test(`${L.name}: a delete on one device survives a stale device's publish`, async () => { + const a = device(L.lane); + a.store.transact((t) => L.edit(L.edit(t, "k1", "A"), "k2", "B")); + await sync(a); + const snapshot = new Map(storage); + a.store.transact((t) => L.remove(t, "k1")); + await sync(a); + storage.clear(); + for (const [k, v] of snapshot) if (!k.includes("clock")) storage.set(k, v); + const stale = device(L.lane); // cache from before the delete + stale.store.transact((t) => L.edit(t, "k3", "A")); + await sync(stale); + assert.deepEqual(L.view(JSON.parse(fx.head.content).meta), { + k2: "B", + k3: "A", + }); + }); + + test(`${L.name}: old writer without meta adds items but cannot delete`, async () => { + const a = device(L.lane); + a.store.transact((t) => L.edit(t, "k1", "A")); + await sync(a); + fx.head = ev(JSON.stringify(L.legacy({ k9: "B" })), fx.head.created_at + 1); + await sync(a); + assert.deepEqual(L.view(a.store.get()), { k1: "A", k9: "B" }); + assert.equal( + JSON.parse(fx.head.content).meta.v, + 1, + "next upgraded publish restores meta", + ); + }); + + test(`${L.name}: meta-less local cache imports once at a stable stamp`, async () => { + storage.set( + L.lane.storageKey(PK, RELAY), + JSON.stringify(L.legacy({ k1: "A" })), + ); + const d = device(L.lane); + const first = canonical(d.store.get()); + assert.deepEqual(L.view(d.store.get()), { k1: "A" }); + assert.equal(canonical(device(L.lane).store.get()), first); + }); + + test(`${L.name}: publish exits (timeout, reject, duplicate) release the attempt`, async (t) => { + t.mock.timers.enable({ apis: ["Date"], now: 1e12 }); + for (const outcome of ["timeout", "reject", "duplicate"]) { + storage.clear(); + fx.head = null; + fx.publish = outcome; + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + const before = fx.published.length; + await sync(d); + assert.equal(fx.published.length, before + 1, outcome); + fx.publish = "ok"; + if (outcome === "duplicate") fx.head = fx.published.at(-1); + t.mock.timers.tick(60_000); // past the failure backoff + await sync(d); + assert.equal( + fx.published.length, + before + (outcome === "duplicate" ? 1 : 2), + `${outcome} retry`, + ); + } + }); + + test(`${L.name}: preflight failure inside an attempt backs off, then recovers`, async (t) => { + t.mock.timers.enable({ apis: ["Date"], now: 1e12 }); + const d = device(L.lane); + d.store.transact((tr) => L.edit(tr, "k2", "B")); + fx.fetchFails = true; // the attempt's own preflight read fails + await d.rec.ingest(ev(JSON.stringify(L.legacy({ k1: "A" })), 100)); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 0); + fx.fetchFails = false; + fx.head = ev(JSON.stringify(L.legacy({ k1: "A" })), 100); + await sync(d); + assert.equal(fx.published.length, 0, "held by the backoff"); + t.mock.timers.tick(5_000); + await sync(d); + assert.equal(fx.published.length, 1); + }); + + test(`${L.name}: a stale preflight decode cannot undo a newer absence`, async () => { + const h0 = ev(JSON.stringify(L.legacy({ k1: "A" })), 100); + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k2", "B")); + let release; + fx.holdDecrypt = new Promise((r) => (release = r)); + fx.holdAfter = 1; // the attempt's preflight decode of H0 waits + fx.head = h0; + void d.rec.ingest(h0); + for (let i = 0; i < 5; i++) await settle(); + fx.head = null; + await d.rec.read(); // recovery read during the acquired attempt + release(); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 0); + assert.equal(d.rec.head.status, "unknown"); + fx.holdDecrypt = null; + fx.head = h0; + await sync(d); + assert.equal(fx.published.length, 1, "a later readable head re-enables"); + }); + + for (const fails of [false, true]) { + const what = fails ? "a failure to its full backoff" : "the send"; + test(`${L.name}: an edit during preflight holds ${what}`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 }); + const h0 = ev(JSON.stringify(L.legacy({ k1: "A" })), 100); + const store = new LaneStore(L.lane, PK, RELAY); + const rec = new LaneReconciler(L.lane, store, PK, RELAY); + store.transact((tr) => L.edit(tr, "k2", "B")); + let open; + fx.gate = new Promise((r) => (open = r)); + fx.head = h0; + void rec.ingest(h0); // attempt acquired; preflight fetch waits + for (let i = 0; i < 5; i++) await settle(); + store.transact((tr) => L.edit(tr, "k3", "A")); + rec.defer(); // edit deadline: +2 s + fx.fetchFails = fails; // the acquired preflight then really fails + fx.gate = null; + open(); + for (let i = 0; i < 5; i++) await settle(); + fx.fetchFails = false; + assert.equal(fx.published.length, 0); + t.mock.timers.tick(2_000); // real wake() re-arms; no manual read + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, fails ? 0 : 1, "edit deadline"); + t.mock.timers.tick(3_000); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1, "after the 5 s failure backoff"); + rec.destroy(); + }); + } + + test(`${L.name}: a relay rejection with the cancel text during a hold backs off`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 }); + const store = new LaneStore(L.lane, PK, RELAY); + const rec = new LaneReconciler(L.lane, store, PK, RELAY); + store.transact((tr) => L.edit(tr, "k1", "A")); + fx.publish = "cancelText"; + fx.afterSend = () => rec.defer(); // an edit hold starts mid-send + await rec.read(); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1); + t.mock.timers.tick(2_000); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1, "not re-armed as a hold"); + t.mock.timers.tick(3_000); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 2, "retried after the 5 s backoff"); + rec.destroy(); + }); + + test(`${L.name}: a hold cancel consumed after its deadline stays a hold`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 }); + const store = new LaneStore(L.lane, PK, RELAY); + const rec = new LaneReconciler(L.lane, store, PK, RELAY); + store.transact((tr) => L.edit(tr, "k1", "A")); + let consume; + fx.consume = new Promise((r) => (consume = r)); + fx.beforeSend = () => { + fx.beforeSend = null; + rec.defer(); + }; + await rec.read(); // the send is canceled by the hold + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.beforeSend, null, "canceled at the send boundary"); + t.mock.timers.tick(2_000); // the hold expires before the cancel is consumed + fx.consume = null; + consume(); + for (let i = 0; i < 5; i++) await settle(); + t.mock.timers.tick(1); // the kept, expired deadline re-arms at once + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1, "no failure backoff"); + rec.destroy(); + }); + + test(`${L.name}: unreadable head holds quietly; content never published over it`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + fx.head = ev("not json", 10); + await sync(d); + assert.equal(d.rec.head.status, "unreadable"); + assert.equal(fx.published.length, 0); + }); + + test(`${L.name}: stale decode merges content but does not settle status`, async () => { + const d = device(L.lane); + const older = ev(JSON.stringify(L.legacy({ k1: "A" })), 10); + const newer = ev("garbage", 11); + fx.badDecrypt.add("garbage"); + const p = d.rec.ingest(older); + await d.rec.ingest(newer); // newer head observed before older decode lands + await p; + assert.equal(d.rec.head.id, newer.id); + assert.equal(d.rec.head.status, "unreadable"); + assert.deepEqual(L.view(d.store.get()), { k1: "A" }); + }); + + test(`${L.name}: over limit stays local and is not published`, async () => { + const d = device(L.lane); + d.store.transact(L.big); + await sync(d); + assert.equal(fx.published.length, 0); + assert.ok( + storage.get(L.lane.storageKey(PK, RELAY)).length > 1000, + "kept durable locally", + ); + }); + + test(`${L.name}: destroyed reconciler never publishes`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + d.rec.destroy(); + await sync(d); + assert.equal(fx.published.length, 0); + }); +} + +test("sections: projection is live-only, dense integer order, live assignments", () => { + const A = "a".repeat(16); + const tree = { + s: { + x: { name: [1, A, "X"], order: [1, A, 7], live: [1, A, true] }, + y: { name: [1, A, "Y"], order: [1, A, 3], live: [1, A, true] }, + z: { name: [1, A, "Z"], order: [1, A, 0], live: [1, A, false] }, + }, + a: { c1: [1, A, "x"], c2: [1, A, "z"], c3: [1, A, null] }, + }; + assert.deepEqual(projectSections(tree), { + sections: [ + { id: "y", name: "Y", order: 0 }, + { id: "x", name: "X", order: 1 }, + ], + assignments: { c1: "x" }, + }); +}); + +test("sections: rename concurrent with delete stays deleted", async () => { + const d = device(SECTIONS_LANE); + d.store.transact((t) => LANES[0].edit(t, "k1", "A")); + const base = d.store.get(); + const renamed = setRegs(base, [[["s", "k1", "name"], "B"]]); + const deleted = setRegs(base, [[["s", "k1", "live"], false]]); + d.store.merge(renamed); + d.store.merge(deleted); + assert.deepEqual(projectSections(d.store.get()).sections, []); +}); + +// ─── pass-1 regressions ──────────────────────────────────────────────────── + +for (const L of LANES) { + test(`${L.name}: absence on a running reconciler holds until a head returns`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + await sync(d); + const h1 = fx.head; + fx.head = null; // transient empty read after H0 was decoded + d.store.transact((t) => L.edit(t, "k2", "B")); + await sync(d); + assert.equal(fx.published.length, 1); + assert.equal(d.rec.head.status, "unknown"); + fx.head = h1; + await sync(d); + assert.equal(fx.published.length, 2, "readable head re-enables publish"); + }); + + test(`${L.name}: empty verification after an ACK does not republish`, async () => { + fx.publish = "lost"; // ACKed, but reads keep returning nothing + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + await sync(d); + await sync(d); + await sync(d); + assert.equal(fx.published.length, 1); + }); + + test(`${L.name}: a stale absent read cannot demote a newer observation`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + let open; + fx.gate = new Promise((r) => (open = r)); + const pending = d.rec.read(); // absent, still in flight + fx.gate = null; + fx.head = ev(JSON.stringify(L.legacy({ k1: "A" })), 50); + await d.rec.ingest(fx.head); + open(); + await pending; + assert.equal(d.rec.head.status, "decoded"); + await sync(d); // drain the attempt this head enabled + }); + + test(`${L.name}: an imported empty container settles without publishing`, async () => { + storage.set(L.lane.storageKey(PK, RELAY), JSON.stringify(L.legacy({}))); + const d = device(L.lane); + await sync(d); + assert.equal(fx.published.length, 0); + }); + + test(`${L.name}: backoff holds recovery, live and reconnect attempts`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 }); + const store = new LaneStore(L.lane, PK, RELAY); + const rec = new LaneReconciler(L.lane, store, PK, RELAY); + fx.publish = "reject"; + store.transact((tr) => L.edit(tr, "k1", "A")); + await rec.read(); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1); + fx.publish = "ok"; + await rec.read(); // recovery tick at t+0 + rec.wake(); // reconnect must not shorten the 5 s backoff + t.mock.timers.tick(4_000); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1, "held inside backoff"); + t.mock.timers.tick(1_000); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 2, "published after the deadline"); + rec.destroy(); + }); + + test(`${L.name}: a change during the publish wait cancels the send`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + fx.beforeSend = () => d.store.transact((t) => L.edit(t, "k2", "B")); + await sync(d); + assert.equal(fx.published.length, 0); + fx.beforeSend = null; + d.rec.destroy(); + }); + + test(`${L.name}: legacy pubkey key migrates once and stays relay-scoped`, () => { + const legacyKey = L.lane.legacyStorageKey?.(PK); + if (!legacyKey) return; + storage.set(legacyKey, JSON.stringify(L.legacy({ k1: "A" }))); + fx.storageFails = true; + const failed = new LaneStore(L.lane, PK, RELAY); + assert.deepEqual(L.view(failed.get()), { k1: "A" }); + assert.ok(storage.has(legacyKey), "kept until the scoped write lands"); + fx.storageFails = false; + failed.persist(); // same store, e.g. the next recovery tick + assert.ok(!storage.has(legacyKey)); + const other = new LaneStore(L.lane, PK, "wss://other.test"); + assert.deepEqual(L.view(other.get()), {}, "second relay starts clean"); + storage.set(legacyKey, JSON.stringify(L.legacy({ k9: "B" }))); + assert.deepEqual(L.view(new LaneStore(L.lane, PK, RELAY).get()), { + k1: "A", + }); + }); + + test(`${L.name}: two tabs converge and go quiet`, () => { + const handlers = []; + window.addEventListener = (_type, h) => handlers.push(h); + const tabs = [0, 1].map(() => new LaneStore(L.lane, PK, RELAY)); + for (const tab of tabs) tab.attachCrossTab(); + window.addEventListener = () => {}; + const key = L.lane.storageKey(PK, RELAY); + const deliver = (to) => + handlers[to]({ key, newValue: storage.get(key) ?? null }); + tabs[0].transact((t) => L.edit(t, "k1", "A")); + const lost = storage.get(key); + tabs[1].transact((t) => L.edit(t, "k2", "B")); // overwrites tab 0's write + handlers[1]({ key, newValue: lost }); + deliver(0); + const writes = fx.writes; + let notified = 0; + for (const tab of tabs) tab.subscribe(() => notified++); + deliver(0); + deliver(1); + assert.equal(fx.writes, writes, "no further writes"); + assert.equal(notified, 0, "no further notifications"); + assert.deepEqual(L.view(tabs[0].get()), { k1: "A", k2: "B" }); + assert.equal(canonical(tabs[0].get()), canonical(tabs[1].get())); + }); + + test(`${L.name}: a byte-identical clone does not write or notify`, () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + let notified = 0; + d.store.subscribe(() => notified++); + d.store.transact((t) => ({ ...t })); + assert.equal(notified, 0); + }); +} + +test("publisher: isCurrent gates the first send and the reconnect retry", async () => { + const sends = []; + let current = true; + const session = { + generation: () => 1, + ownership: () => 1, + pendingEvents: new Map(), + send: async (payload) => { + sends.push(payload); + throw new Error("socket closed"); + }, + reconnect: async () => { + current = false; // lane changed while reconnecting + return 1; + }, + normalizeError: (e) => e, + recoverSocketFailure: (e) => e, + }; + Object.assign(globalThis.window, { setTimeout, clearTimeout }); + const event = { id: "e1" }; + await assert.rejects( + publishSessionEvent(session, event, "t", "s", () => current), + PublishCanceledError, + ); + assert.equal(sends.length, 1, "retry never sent"); + assert.equal(session.pendingEvents.size, 0); + await assert.rejects( + publishSessionEvent(session, event, "t", "s", () => false), + PublishCanceledError, + ); + assert.equal(sends.length, 1, "first send never sent"); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarLwwMap.test.mjs b/desktop/src/features/sidebar/lib/sidebarLwwMap.test.mjs new file mode 100644 index 0000000000..70116ebacf --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLwwMap.test.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canonical, + compareRegs, + LEGACY_DEV, + mergeTrees, + setRegs, + validTree, +} from "./sidebarLwwMap.ts"; + +const storage = new Map(); +globalThis.window ??= {}; +globalThis.window.localStorage = { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), +}; + +const A = "a".repeat(16); +const B = "b".repeat(16); + +test("compareRegs: version, then device, then tombstone, then value bytes", () => { + assert.ok(compareRegs([2, A, "x"], [1, B, "y"]) > 0); + assert.ok(compareRegs([1, B, "x"], [1, A, "y"]) > 0); + assert.ok(compareRegs([1, A, null], [1, A, "x"]) > 0); + assert.ok(compareRegs([1, A, false], [1, A, true]) > 0); + assert.ok(compareRegs([1, A, "b"], [1, A, "a"]) > 0); + assert.equal(compareRegs([1, A, "a"], [1, A, "a"]), 0); +}); + +test("mergeTrees: commutative, associative, idempotent", () => { + const x = { s: { k: { name: [1, A, "x"] } }, a: { c1: [5, A, "k"] } }; + const y = { s: { k: { name: [2, B, "y"], live: [1, B, true] } } }; + const z = { a: { c1: [5, B, null], c2: [3, A, "k"] } }; + const xy = mergeTrees(x, y); + assert.equal(canonical(xy), canonical(mergeTrees(y, x))); + assert.equal( + canonical(mergeTrees(mergeTrees(x, y), z)), + canonical(mergeTrees(x, mergeTrees(y, z))), + ); + assert.equal(mergeTrees(xy, xy), xy); + assert.deepEqual(xy.s.k.name, [2, B, "y"]); +}); + +test("mergeTrees: returns the same reference when nothing changes", () => { + const a = { g: { dms: [9, A, "recent"] } }; + assert.equal(mergeTrees(a, { g: { dms: [1, B, "alpha"] } }), a); +}); + +test("mergeTrees onlyMissing: fills absent leaves and never overrides", () => { + const local = { s: { k: { name: [1, A, "mine"] } } }; + const legacy = { + s: { + k: { name: [9e12, LEGACY_DEV, "old"] }, + n: { name: [9e12, LEGACY_DEV, "new"] }, + }, + }; + const out = mergeTrees(local, legacy, true); + assert.equal(out.s.k.name[2], "mine"); + assert.equal(out.s.n.name[2], "new"); +}); + +test("setRegs: identical edit mints no version and returns the same tree", () => { + const t = setRegs({}, [[["g", "dms"], "recent"]]); + assert.equal(setRegs(t, [[["g", "dms"], "recent"]]), t); +}); + +test("setRegs: new version exceeds every version seen and the install's last", () => { + const future = Date.now() + 1e9; + const t = setRegs({ g: { dms: [future, B, "alpha"] } }, [ + [["g", "dms"], "recent"], + ]); + assert.equal(t.g.dms[0], future + 1); + const t2 = setRegs({}, [[["g", "x"], "alpha"]]); + assert.ok(t2.g.x[0] > future, "lastIssued persists across documents"); + assert.equal(t2.g.x[1], t.g.dms[1], "device id persists per install"); + assert.match(t2.g.x[1], /^[0-9a-f]{16}$/); +}); + +test("setRegs all: writes every leaf, including unchanged ones", () => { + const t = { s: { a: { order: [1, A, 0] }, b: { order: [1, A, 1] } } }; + const out = setRegs( + t, + [ + [["s", "a", "order"], 0], + [["s", "b", "order"], 0], + ], + true, + ); + assert.ok(out.s.a.order[0] > 1); + assert.equal(out.s.a.order[0], out.s.b.order[0]); +}); + +test("validTree: skips invalid records, keeps valid siblings", () => { + const shape = { + s: { + "*": { name: (v) => typeof v === "string", order: Number.isSafeInteger }, + }, + }; + const out = validTree( + { + s: { + ok: { name: [1, A, "n"], order: [1, A, 2] }, + badName: { name: [1, A, 7], order: [1, A, 0] }, + badDev: { name: [1, "zz", "n"] }, + badV: { name: [1.5, A, "n"] }, + __proto__: { name: [1, A, "p"] }, + }, + extra: { x: [1, A, 1] }, + }, + shape, + ); + assert.deepEqual(out, { + s: { + ok: { name: [1, A, "n"], order: [1, A, 2] }, + badName: { order: [1, A, 0] }, + badDev: {}, + badV: {}, + }, + }); +}); + +test("canonical: key order independent and stable", () => { + assert.equal( + canonical({ b: 1, a: { d: [1], c: null } }), + canonical({ a: { c: null, d: [1] }, b: 1 }), + ); + assert.equal(canonical({ a: undefined, b: 1 }), '{"b":1}'); +}); + +test("inherited names are ordinary keys in either arrival order", () => { + const A = "a".repeat(16); + const one = { g: { constructor: [2, A, "recent"] } }; + const two = { g: { toString: [3, A, "alpha"] } }; + const ab = mergeTrees(mergeTrees({}, one), two); + const ba = mergeTrees(mergeTrees({}, two), one); + assert.equal(canonical(ab), canonical(ba)); + assert.deepEqual(Object.keys(ab.g).sort(), ["constructor", "toString"]); + const set = setRegs({ g: {} }, [[["g", "constructor"], "alpha"]]); + assert.equal(set.g.constructor[2], "alpha"); + const valid = validTree(JSON.parse(canonical(ab)), { + g: { "*": () => true }, + }); + assert.equal(canonical(valid), canonical(ab)); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarLwwMap.ts b/desktop/src/features/sidebar/lib/sidebarLwwMap.ts new file mode 100644 index 0000000000..bb9a8771ac --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLwwMap.ts @@ -0,0 +1,203 @@ +/** + * Per-field last-writer-wins registers for synced sidebar preferences. + * + * A document is a tree whose leaves are registers `[v, dev, value]`: `v` is a + * millisecond version, `dev` a random per-install id. Merging keeps, for every + * leaf, the register that sorts highest, so any two devices that have seen the + * same registers hold byte-identical documents regardless of arrival order. + */ + +export type Reg = [v: number, dev: string, value: T]; +export type Tree = { [key: string]: Tree | Reg | undefined }; + +/** Device id stamped on registers imported from meta-less (legacy) data. */ +export const LEGACY_DEV = "0".repeat(16); + +const CLOCK_KEY = "buzz-sidebar-clock.v1"; +const DEV_RE = /^[0-9a-f]{16}$/; +const encoder = new TextEncoder(); + +function compareBytes(left: string, right: string): number { + const a = encoder.encode(left); + const b = encoder.encode(right); + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i]) return (a[i] as number) - (b[i] as number); + } + return a.length - b.length; +} + +const isTombstone = (value: unknown) => value === null || value === false; + +/** Orders by `v`, then `dev` bytewise, then tombstone over value, then value JSON bytewise. */ +export function compareRegs(a: Reg, b: Reg): number { + if (a[0] !== b[0]) return a[0] - b[0]; + if (a[1] !== b[1]) return compareBytes(a[1], b[1]); + const tombA = isTombstone(a[2]); + if (tombA !== isTombstone(b[2])) return tombA ? 1 : -1; + return compareBytes(JSON.stringify(a[2]), JSON.stringify(b[2])); +} + +export const isReg = (node: unknown): node is Reg => Array.isArray(node); + +/** Own-property read: dynamic keys such as `constructor` never hit the prototype. */ +export const own = (node: Tree, key: string): Tree | Reg | undefined => + Object.hasOwn(node, key) ? node[key] : undefined; + +/** True when no register (tombstones included) exists anywhere in `node`. */ +export const isEmptyTree = (node: Tree): boolean => + Object.values(node).every( + (child) => child === undefined || (!isReg(child) && isEmptyTree(child)), + ); + +/** A register imported from a meta-less local cache (stamp 1); any remote beats it. */ +const isCachePlaceholder = (reg: Reg) => reg[0] === 1 && reg[1] === LEGACY_DEV; + +/** + * Merges `b` into `a`. With `onlyMissing`, `b` only fills leaves `a` lacks or + * holds as a cache placeholder (legacy import: omission never means deletion, + * and authored registers win). Returns `a` itself when nothing changes. + */ +export function mergeTrees(a: T, b: T, onlyMissing = false): T { + let out: Tree | null = null; + for (const [key, bv] of Object.entries(b)) { + const av = own(a, key); + let next = av; + if (av === undefined) next = bv; + else if (bv === undefined) continue; + else if (isReg(av) && isReg(bv)) { + if ((!onlyMissing || isCachePlaceholder(av)) && compareRegs(bv, av) > 0) + next = bv; + } else if (!isReg(av) && !isReg(bv)) { + next = mergeTrees(av, bv, onlyMissing); + } + if (next !== av) { + out ??= { ...a }; + out[key] = next; + } + } + return (out ?? a) as T; +} + +function maxVersion(node: Tree): number { + let max = 0; + for (const child of Object.values(node)) { + if (child === undefined) continue; + max = Math.max(max, isReg(child) ? child[0] : maxVersion(child)); + } + return max; +} + +function randomDev(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** `v = max(now, lastIssued + 1, maxSeen + 1)`; `dev` and `lastIssued` persist per install. */ +function nextStamp(maxSeen: number): [number, string] { + let dev = randomDev(); + let last = 0; + try { + const saved = JSON.parse(window.localStorage.getItem(CLOCK_KEY) ?? "null"); + if (DEV_RE.test(saved?.dev) && Number.isSafeInteger(saved?.last)) { + dev = saved.dev; + last = saved.last; + } + } catch { + // Fall back to a fresh clock; ordering still holds via `maxSeen`. + } + const v = Math.max(Date.now(), last + 1, maxSeen + 1); + try { + window.localStorage.setItem(CLOCK_KEY, JSON.stringify({ dev, last: v })); + } catch { + // Non-fatal: the next stamp still exceeds every version in the document. + } + return [v, dev]; +} + +function getAt(tree: Tree, path: string[]): Tree | Reg | undefined { + let node: Tree | Reg | undefined = tree; + for (const key of path) { + if (node === undefined || isReg(node)) return undefined; + node = own(node, key); + } + return node; +} + +function setAt(tree: Tree, [key, ...rest]: string[], reg: Reg): Tree { + const child = own(tree, key as string); + const next = + rest.length === 0 + ? reg + : setAt(child && !isReg(child) ? child : {}, rest, reg); + return { ...tree, [key as string]: next }; +} + +/** + * Writes each `[path, value]` as a fresh register sharing one stamp. An edit + * where every target already holds its value mints no version and returns + * `tree` itself. Otherwise only changed leaves are written, or with `all` + * every leaf (a whole-list operation such as a reorder). + */ +export function setRegs( + tree: T, + writes: Array<[path: string[], value: unknown]>, + all = false, +): T { + const changed = writes.filter(([path, value]) => { + const current = getAt(tree, path); + return !( + isReg(current) && JSON.stringify(current[2]) === JSON.stringify(value) + ); + }); + if (changed.length === 0) return tree; + const [v, dev] = nextStamp(maxVersion(tree)); + let out: Tree = tree; + for (const [path, value] of all ? writes : changed) + out = setAt(out, path, [v, dev, value]); + return out as T; +} + +/** A leaf validator, or a node shape: fixed fields, or `{"*": child}` for maps. */ +export type Shape = ((value: unknown) => boolean) | { [key: string]: Shape }; + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * Keeps only well-formed nodes of `shape`. An invalid register or record is + * dropped while its valid siblings survive. + */ +export function validTree(node: unknown, shape: Shape): Tree | Reg | undefined { + if (typeof shape === "function") { + return isReg(node) && + node.length === 3 && + Number.isSafeInteger(node[0]) && + node[0] >= 0 && + typeof node[1] === "string" && + DEV_RE.test(node[1]) && + shape(node[2]) + ? node + : undefined; + } + if (!isObject(node)) return undefined; + const out: Tree = {}; + for (const [key, child] of Object.entries(node)) { + if (key === "__proto__") continue; + const sub = Object.hasOwn(shape, key) ? shape[key] : shape["*"]; + const valid = sub === undefined ? undefined : validTree(child, sub); + if (valid !== undefined) out[key] = valid; + } + return out; +} + +/** Stable key-sorted JSON; the digest and no-op comparison for documents. */ +export function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (isObject(value)) { + const keys = Object.keys(value) + .filter((key) => value[key] !== undefined) + .sort(compareBytes); + return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index 20b5745325..60b48d8242 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -14,6 +14,7 @@ import { } from "./channelMutesStorage"; import { ChannelMuteSyncManager } from "./channelMutesSync"; import type { RemoteMutes } from "./channelMutesSync"; +import { useStaleReaderRecovery } from "./useStaleReaderRecovery"; export function useChannelMutes( pubkey: string | undefined, @@ -33,6 +34,9 @@ export function useChannelMutes( const managerRef = React.useRef(null); const lastAppliedRemoteTs = React.useRef(0); const lastAppliedEventId = React.useRef(""); + // Local-mutation revision: incremented on every user edit so an in-flight + // retry fetch that started before the edit is discarded at apply time. + const localRevision = React.useRef(0); React.useEffect(() => { if (!pubkey || !relayUrl) { @@ -75,14 +79,16 @@ export function useChannelMutes( if (remote.createdAt < lastAppliedRemoteTs.current) return prev; if ( remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current + remote.eventId >= lastAppliedEventId.current ) return prev; - lastAppliedRemoteTs.current = remote.createdAt; - lastAppliedEventId.current = remote.eventId; managerRef.current?.cancelPendingMutePublish(); const merged = mergeStores(prev, remote.store); if (!writeChannelMutesStore(pubkey, merged)) return prev; + // Advance the applied head only after the cache write succeeds so a + // failed write leaves the same head retryable on the next tick. + lastAppliedRemoteTs.current = remote.createdAt; + lastAppliedEventId.current = remote.eventId; return merged; }; }, @@ -150,6 +156,31 @@ export function useChannelMutes( }; }, [pubkey, relayUrl, applyRemote]); + // Retry effect: see useStaleReaderRecovery for full behavior contract. + const retryFetch = React.useCallback( + () => managerRef.current?.fetchRemoteMutes(), + [], + ); + const retryHasPending = React.useCallback( + () => managerRef.current?.getPendingMuteStore() != null, + [], + ); + const retryGetRevision = React.useCallback(() => localRevision.current, []); + const retryMakeUpdater = React.useCallback( + (data: RemoteMutes) => applyRemote(data), + [applyRemote], + ); + useStaleReaderRecovery({ + enabled: !!pubkey && !!relayUrl, + fetch: retryFetch, + hasPending: retryHasPending, + getRevision: retryGetRevision, + makeUpdater: retryMakeUpdater, + setStore, + pubkey, + relayUrl, + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const mutedChannelIds = React.useMemo( () => mutedChannelIdsFromStore(store), @@ -172,6 +203,7 @@ export function useChannelMutes( channelId, ); if (!writeChannelMutesStore(pubkey, next)) return prev; + localRevision.current += 1; managerRef.current?.publishMutes(next); return next; }); diff --git a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs deleted file mode 100644 index 401b59d9c1..0000000000 --- a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import assert from "node:assert/strict"; -import { after, before, test } from "node:test"; - -import { JSDOM } from "jsdom"; - -const dom = new JSDOM("", { - url: "http://localhost", -}); - -before(() => { - Object.assign(globalThis, { - document: dom.window.document, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - window: dom.window, - }); -}); - -after(() => dom.window.close()); - -test("assignChannel refreshes an existing assignment before the next eviction", async () => { - const { act, cleanup, renderHook } = await import("@testing-library/react"); - const { relayClient } = await import("@/shared/api/relayClient"); - const { MAX_CHANNEL_SECTION_ASSIGNMENTS, storageKey } = await import( - "./channelSectionsStorage.ts" - ); - const { useChannelSections } = await import("./useChannelSections.ts"); - - const originalFetchEvents = relayClient.fetchEvents; - const originalSubscribeLive = relayClient.subscribeLive; - const originalSubscribeToReconnects = relayClient.subscribeToReconnects; - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = () => () => {}; - - const pubkey = "pk-at-capacity"; - const relayUrl = "wss://relay.example"; - const assignments = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS }, (_, index) => [ - `chan-${String(index).padStart(4, "0")}`, - "section-1", - ]), - ); - window.localStorage.setItem( - storageKey(pubkey, relayUrl), - JSON.stringify({ - version: 1, - sections: [ - { id: "section-1", name: "One", order: 0 }, - { id: "section-2", name: "Two", order: 1 }, - ], - assignments, - }), - ); - - try { - const { result, unmount } = renderHook(() => - useChannelSections(pubkey, relayUrl), - ); - - act(() => result.current.assignChannel("chan-0000", "section-2")); - act(() => result.current.assignChannel("chan-new", "section-1")); - - assert.equal(result.current.assignments["chan-0000"], "section-2"); - assert.equal(result.current.assignments["chan-new"], "section-1"); - assert.equal(result.current.assignments["chan-0001"], undefined); - assert.equal( - Object.keys(result.current.assignments).length, - MAX_CHANNEL_SECTION_ASSIGNMENTS, - ); - unmount(); - } finally { - cleanup(); - relayClient.fetchEvents = originalFetchEvents; - relayClient.subscribeLive = originalSubscribeLive; - relayClient.subscribeToReconnects = originalSubscribeToReconnects; - } -}); diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 5a544e82bc..9278b6dc43 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -1,24 +1,38 @@ import * as React from "react"; -import { relayClient } from "@/shared/api/relayClient"; import { - boundChannelSectionsStore, - DEFAULT_STORE, - readChannelSectionsStore, - storageKey, - writeChannelSectionsStore, -} from "./channelSectionsStorage"; -import { ChannelSectionSyncManager } from "./channelSectionsSync"; -import type { RemoteSections } from "./channelSectionsSync"; -import { swapSectionOrder } from "./channelSectionsHelpers"; + maxLiveOrder, + projectSections, + SECTIONS_LANE, +} from "./channelSectionsSync"; +import { useLaneSync } from "./sidebarLaneReconciler"; +import { own, setRegs, type Tree } from "./sidebarLwwMap"; export type { ChannelSection } from "./channelSectionsStorage"; -import type { - ChannelSection, - ChannelSectionStore, -} from "./channelSectionsStorage"; +import type { ChannelSection } from "./channelSectionsStorage"; +/** + * One whole-list order operation against the current live list: dead IDs are + * dropped, live IDs missing from `requested` keep their relative order at the + * end, and every live section gets a new `order` register. + */ +function writeOrder(tree: Tree, requested: string[]) { + const live = projectSections(tree).sections.map((s) => s.id); + const wanted = new Set(requested.filter((id) => live.includes(id))); + const ids = [...wanted, ...live.filter((id) => !wanted.has(id))]; + return setRegs( + tree, + ids.map((id, index) => [["s", id, "order"], index]), + true, + ); +} + +/** + * Channel sections for one pubkey + relay scope, merged per field across + * devices (see `sidebarLaneReconciler`). Deletes are `live = false` + * tombstones; unassign is an explicit `null` register. + */ export function useChannelSections( pubkey: string | undefined, relayUrl?: string, @@ -34,313 +48,93 @@ export function useChannelSections( assignChannel: (channelId: string, sectionId: string) => void; unassignChannel: (channelId: string) => void; } { - const [store, setStore] = React.useState(() => { - if (!pubkey) { - return DEFAULT_STORE; - } - return readChannelSectionsStore(pubkey, relayUrl); - }); - - const managerRef = React.useRef(null); - const lastAppliedRemoteTs = React.useRef(0); - const lastAppliedEventId = React.useRef(""); - - React.useEffect(() => { - if (!pubkey || !relayUrl) { - setStore(DEFAULT_STORE); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; - return; - } - setStore(readChannelSectionsStore(pubkey, relayUrl)); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; - managerRef.current = new ChannelSectionSyncManager(pubkey, relayUrl); - return () => { - managerRef.current?.destroy(); - managerRef.current = null; - }; - }, [pubkey, relayUrl]); - - React.useEffect(() => { - if (!pubkey) { - return; - } - const key = storageKey(pubkey, relayUrl); - const handler = (e: StorageEvent) => { - if (e.key !== key) { - return; - } - setStore(readChannelSectionsStore(pubkey, relayUrl)); - }; - window.addEventListener("storage", handler); - return () => { - window.removeEventListener("storage", handler); - }; - }, [pubkey, relayUrl]); - - const applyRemote = React.useCallback( - ( - remote: RemoteSections, - ): ((prev: ChannelSectionStore) => ChannelSectionStore) => { - return (prev) => { - if (!pubkey) return prev; - if (remote.createdAt < lastAppliedRemoteTs.current) return prev; - if ( - remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current - ) - return prev; - lastAppliedRemoteTs.current = remote.createdAt; - lastAppliedEventId.current = remote.eventId; - managerRef.current?.cancelPendingPublish(); - if (!writeChannelSectionsStore(pubkey, remote.store, relayUrl)) - return prev; - return remote.store; - }; - }, - [pubkey, relayUrl], - ); - - React.useEffect(() => { - if (!pubkey || !relayUrl) return; - let cancelled = false; - const local = readChannelSectionsStore(pubkey, relayUrl); - void managerRef.current?.bootstrap(local).then((result) => { - if (cancelled) return; - if (result.action === "apply-remote") { - setStore(applyRemote(result.data)); - } - // "hold": seed already performed by bootstrap (if first-sync), or - // blocked (failed fetch / prior watermark). Hook does nothing. - }); - return () => { - cancelled = true; - }; - }, [pubkey, relayUrl, applyRemote]); - - React.useEffect(() => { - if (!pubkey) return; - let unsub: (() => Promise) | null = null; - let cancelled = false; - void managerRef.current - ?.subscribeToSections((remote) => { - if (cancelled) return; - setStore(applyRemote(remote)); - }) - .then((dispose) => { - if (cancelled) { - void dispose(); - } else { - unsub = dispose; - } - }); - return () => { - cancelled = true; - if (unsub) void unsub(); - }; - }, [pubkey, applyRemote]); - - React.useEffect(() => { - if (!pubkey) return; - let cancelled = false; - const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSections().then((result) => { - if (cancelled) return; - if (result.status === "found") { - setStore(applyRemote(result.data)); - } - const pending = managerRef.current?.getPendingStore(); - if (pending) { - managerRef.current?.publishSections(pending); - } - }); - }); - return () => { - cancelled = true; - unsub(); - }; - }, [pubkey, applyRemote]); - - const sections = React.useMemo( - () => store.sections.slice().sort((a, b) => a.order - b.order), - [store.sections], + const { tree, edit } = useLaneSync(SECTIONS_LANE, pubkey, relayUrl); + const { sections, assignments } = React.useMemo( + () => projectSections(tree), + [tree], ); const createSection = React.useCallback( (name: string, icon?: string): ChannelSection | null => { - if (!pubkey) return null; - const prev = readChannelSectionsStore(pubkey, relayUrl); - const maxOrder = - prev.sections.length > 0 - ? Math.max(...prev.sections.map((s) => s.order)) - : -1; - const section: ChannelSection = { - id: crypto.randomUUID(), - name, - ...(icon ? { icon } : {}), - order: maxOrder + 1, - }; - setStore((current) => { - const next = boundChannelSectionsStore({ - ...current, - sections: [...current.sections, section], - }); - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return current; - managerRef.current?.publishSections(next); - return next; + if (!pubkey || !relayUrl) return null; + const id = crypto.randomUUID(); + let order = 0; + edit((prev) => { + order = projectSections(prev).sections.length; + return setRegs(prev, [ + [["s", id, "name"], name], + [["s", id, "icon"], icon || null], + [["s", id, "order"], maxLiveOrder(prev) + 1], + [["s", id, "live"], true], + ]); }); - return section; + return { id, name, ...(icon ? { icon } : {}), order }; }, - [pubkey, relayUrl], + [pubkey, relayUrl, edit], ); const renameSection = React.useCallback( - (sectionId: string, newName: string, icon?: string) => { - if (!pubkey) { - return; - } - setStore((prev) => { - const next: ChannelSectionStore = { - ...prev, - sections: prev.sections.map((s) => - s.id === sectionId - ? { - id: s.id, - name: newName, - ...(icon ? { icon } : {}), - order: s.order, - } - : s, - ), - }; - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { - return prev; - } - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], + (sectionId: string, newName: string, icon?: string) => + edit((prev) => + setRegs(prev, [ + [["s", sectionId, "name"], newName], + [["s", sectionId, "icon"], icon || null], + ]), + ), + [edit], ); const deleteSection = React.useCallback( - (sectionId: string) => { - if (!pubkey) { - return; - } - setStore((prev) => { - const assignments = { ...prev.assignments }; - for (const channelId of Object.keys(assignments)) { - if (assignments[channelId] === sectionId) { - delete assignments[channelId]; - } - } - const next: ChannelSectionStore = { - ...prev, - sections: prev.sections.filter((s) => s.id !== sectionId), - assignments, - }; - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { - return prev; - } - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], - ); - - const moveSectionUp = React.useCallback( - (sectionId: string) => { - if (!pubkey) return; - setStore((prev) => { - const next = swapSectionOrder(prev, sectionId, "up"); - if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl)) - return prev; - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], - ); - - const moveSectionDown = React.useCallback( - (sectionId: string) => { - if (!pubkey) return; - setStore((prev) => { - const next = swapSectionOrder(prev, sectionId, "down"); - if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl)) - return prev; - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], + (sectionId: string) => + edit((prev) => setRegs(prev, [[["s", sectionId, "live"], false]])), + [edit], ); const reorderSections = React.useCallback( - (orderedIds: string[]) => { - if (!pubkey) return; - setStore((prev) => { - const sections = prev.sections.map((s) => { - const newOrder = orderedIds.indexOf(s.id); - return newOrder === -1 ? s : { ...s, order: newOrder }; - }); - const next: ChannelSectionStore = { ...prev, sections }; - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev; - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], + (orderedIds: string[]) => edit((prev) => writeOrder(prev, orderedIds)), + [edit], + ); + + const moveSection = React.useCallback( + (sectionId: string, step: -1 | 1) => + edit((prev) => { + const ids = projectSections(prev).sections.map((s) => s.id); + const from = ids.indexOf(sectionId); + const to = from + step; + if (from < 0 || to < 0 || to >= ids.length) return prev; + [ids[from], ids[to]] = [ids[to] as string, sectionId]; + return writeOrder(prev, ids); + }), + [edit], + ); + const moveSectionUp = React.useCallback( + (sectionId: string) => moveSection(sectionId, -1), + [moveSection], + ); + const moveSectionDown = React.useCallback( + (sectionId: string) => moveSection(sectionId, 1), + [moveSection], ); const assignChannel = React.useCallback( - (channelId: string, sectionId: string) => { - if (!pubkey) { - return; - } - setStore((prev) => { - const assignments = { ...prev.assignments }; - delete assignments[channelId]; - assignments[channelId] = sectionId; - const next = boundChannelSectionsStore({ - ...prev, - assignments, - }); - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { - return prev; - } - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], + (channelId: string, sectionId: string) => + edit((prev) => setRegs(prev, [[["a", channelId], sectionId]])), + [edit], ); const unassignChannel = React.useCallback( - (channelId: string) => { - if (!pubkey) { - return; - } - setStore((prev) => { - const assignments = { ...prev.assignments }; - delete assignments[channelId]; - const next: ChannelSectionStore = { ...prev, assignments }; - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { - return prev; - } - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], + (channelId: string) => + edit((prev) => + prev.a && own(prev.a as Tree, channelId) + ? setRegs(prev, [[["a", channelId], null]]) + : prev, + ), + [edit], ); return { sections, - assignments: store.assignments, + assignments, createSection, renameSection, deleteSection, diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index 85c07b1c39..0d9a997ced 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -1,191 +1,44 @@ import * as React from "react"; -import { relayClient } from "@/shared/api/relayClient"; import { - boundChannelSortStore, - DEFAULT_STORE, - readChannelSortStore, - sortModeForGroup, - storageKey, - stripOrphanedSectionModes, - writeChannelSortStore, + DEFAULT_SORT_MODE, type ChannelSortGroupKey, type ChannelSortMode, - type ChannelSortStore, } from "./channelSortPreference"; -import { ChannelSortSyncManager } from "./channelSortSync"; -import type { RemoteSortPrefs } from "./channelSortSync"; +import { SORT_LANE } from "./channelSortSync"; +import { useLaneSync } from "./sidebarLaneReconciler"; +import { isReg, own, setRegs } from "./sidebarLwwMap"; /** - * Persistent per-group sidebar sort preferences, scoped by pubkey + relay so - * they don't bleed across identities or communities (same scoping as channel - * sections). Each sidebar grouping (starred, channels, forums, dms, and each - * custom section) carries its own saved Recent/A–Z mode; unset groups default - * to A–Z. Mirrors changes made in other windows via the storage event. - * - * Preferences sync across clients via encrypted NIP-78 app data (kind 30078, - * d-tag `channel-sort`), following the channel-sections pattern: localStorage - * stays the instant/offline cache, the relay blob is the cross-client source - * of truth, and conflicts resolve with whole-blob last-write-wins. - * - * When `liveSectionIds` is provided, writes also prune `section:` entries - * whose custom section no longer exists, so deleted sections don't leave - * stale keys in localStorage. + * Persistent per-group sidebar sort preferences, scoped by pubkey + relay. + * Each grouping (starred, channels, forums, dms, and each custom section) + * carries its own Recent/A–Z mode; unset groups default to A–Z. Each group + * is its own register, so devices merge per group (see + * `sidebarLaneReconciler`) rather than overwriting the whole blob. */ export function useChannelSortPreference( pubkey: string | undefined, relayUrl?: string, - liveSectionIds?: string[], ): { sortModeFor: (group: ChannelSortGroupKey) => ChannelSortMode; setSortModeFor: (group: ChannelSortGroupKey, mode: ChannelSortMode) => void; } { - const [store, setStore] = React.useState(() => { - if (!pubkey) return DEFAULT_STORE; - return readChannelSortStore(pubkey, relayUrl); - }); - - const managerRef = React.useRef(null); - const lastAppliedRemoteTs = React.useRef(0); - const lastAppliedEventId = React.useRef(""); - - React.useEffect(() => { - if (!pubkey || !relayUrl) { - setStore(DEFAULT_STORE); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; - return; - } - setStore(readChannelSortStore(pubkey, relayUrl)); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; - managerRef.current = new ChannelSortSyncManager(pubkey, relayUrl); - return () => { - managerRef.current?.destroy(); - managerRef.current = null; - }; - }, [pubkey, relayUrl]); - - React.useEffect(() => { - if (!pubkey) return; - const key = storageKey(pubkey, relayUrl); - const handler = (e: StorageEvent) => { - if (e.key !== key) return; - setStore(readChannelSortStore(pubkey, relayUrl)); - }; - window.addEventListener("storage", handler); - return () => { - window.removeEventListener("storage", handler); - }; - }, [pubkey, relayUrl]); - - const applyRemote = React.useCallback( - ( - remote: RemoteSortPrefs, - ): ((prev: ChannelSortStore) => ChannelSortStore) => { - return (prev) => { - if (!pubkey) return prev; - if (remote.createdAt < lastAppliedRemoteTs.current) return prev; - if ( - remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current - ) - return prev; - lastAppliedRemoteTs.current = remote.createdAt; - lastAppliedEventId.current = remote.eventId; - managerRef.current?.cancelPendingPublish(); - if (!writeChannelSortStore(pubkey, remote.store, relayUrl)) return prev; - return remote.store; - }; - }, - [pubkey, relayUrl], - ); - - React.useEffect(() => { - if (!pubkey || !relayUrl) return; - let cancelled = false; - const local = readChannelSortStore(pubkey, relayUrl); - void managerRef.current?.bootstrap(local).then((result) => { - if (cancelled) return; - if (result.action === "apply-remote") { - setStore(applyRemote(result.data)); - } - // "hold": seed already performed by bootstrap (if first-sync), or blocked. - }); - return () => { - cancelled = true; - }; - }, [pubkey, relayUrl, applyRemote]); - - React.useEffect(() => { - if (!pubkey) return; - let unsub: (() => Promise) | null = null; - let cancelled = false; - void managerRef.current - ?.subscribeToSortPrefs((remote) => { - if (cancelled) return; - setStore(applyRemote(remote)); - }) - .then((dispose) => { - if (cancelled) { - void dispose(); - } else { - unsub = dispose; - } - }); - return () => { - cancelled = true; - if (unsub) void unsub(); - }; - }, [pubkey, applyRemote]); - - React.useEffect(() => { - if (!pubkey) return; - let cancelled = false; - const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSortPrefs().then((result) => { - if (cancelled) return; - if (result.status === "found") { - setStore(applyRemote(result.data)); - } - const pending = managerRef.current?.getPendingStore(); - if (pending) { - managerRef.current?.publishSortPrefs(pending); - } - }); - }); - return () => { - cancelled = true; - unsub(); - }; - }, [pubkey, applyRemote]); + const { tree, edit } = useLaneSync(SORT_LANE, pubkey, relayUrl); const sortModeFor = React.useCallback( - (group: ChannelSortGroupKey) => sortModeForGroup(store, group), - [store], + (group: ChannelSortGroupKey) => { + const reg = tree.g && !isReg(tree.g) ? own(tree.g, group) : undefined; + return isReg(reg) && reg[2] !== null + ? (reg[2] as ChannelSortMode) + : DEFAULT_SORT_MODE; + }, + [tree], ); const setSortModeFor = React.useCallback( - (group: ChannelSortGroupKey, mode: ChannelSortMode) => { - if (!pubkey) return; - setStore((prev) => { - const withUpdate: ChannelSortStore = { - ...prev, - groups: { ...prev.groups, [group]: mode }, - }; - // Prune sort modes left behind by deleted custom sections on write so - // the stored map can't grow unboundedly with stale `section:` keys. - const next = boundChannelSortStore( - liveSectionIds - ? stripOrphanedSectionModes(withUpdate, liveSectionIds) - : withUpdate, - ); - if (!writeChannelSortStore(pubkey, next, relayUrl)) return prev; - managerRef.current?.publishSortPrefs(next); - return next; - }); - }, - [pubkey, relayUrl, liveSectionIds], + (group: ChannelSortGroupKey, mode: ChannelSortMode) => + edit((prev) => setRegs(prev, [[["g", group], mode]])), + [edit], ); return { sortModeFor, setSortModeFor }; diff --git a/desktop/src/features/sidebar/lib/useChannelStars.ts b/desktop/src/features/sidebar/lib/useChannelStars.ts index 855c8de858..0eeabe644a 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.ts +++ b/desktop/src/features/sidebar/lib/useChannelStars.ts @@ -14,6 +14,7 @@ import { } from "./channelStarsStorage"; import { ChannelStarSyncManager } from "./channelStarsSync"; import type { RemoteStars } from "./channelStarsSync"; +import { useStaleReaderRecovery } from "./useStaleReaderRecovery"; export function useChannelStars( pubkey: string | undefined, @@ -33,6 +34,9 @@ export function useChannelStars( const managerRef = React.useRef(null); const lastAppliedRemoteTs = React.useRef(0); const lastAppliedEventId = React.useRef(""); + // Local-mutation revision: incremented on every user edit so an in-flight + // retry fetch that started before the edit is discarded at apply time. + const localRevision = React.useRef(0); React.useEffect(() => { if (!pubkey || !relayUrl) { @@ -75,14 +79,16 @@ export function useChannelStars( if (remote.createdAt < lastAppliedRemoteTs.current) return prev; if ( remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current + remote.eventId >= lastAppliedEventId.current ) return prev; - lastAppliedRemoteTs.current = remote.createdAt; - lastAppliedEventId.current = remote.eventId; managerRef.current?.cancelPendingStarPublish(); const merged = mergeStores(prev, remote.store); if (!writeChannelStarsStore(pubkey, merged)) return prev; + // Advance the applied head only after the cache write succeeds so a + // failed write leaves the same head retryable on the next tick. + lastAppliedRemoteTs.current = remote.createdAt; + lastAppliedEventId.current = remote.eventId; return merged; }; }, @@ -150,6 +156,31 @@ export function useChannelStars( }; }, [pubkey, relayUrl, applyRemote]); + // Retry effect: see useStaleReaderRecovery for full behavior contract. + const retryFetch = React.useCallback( + () => managerRef.current?.fetchRemoteStars(), + [], + ); + const retryHasPending = React.useCallback( + () => managerRef.current?.getPendingStarStore() != null, + [], + ); + const retryGetRevision = React.useCallback(() => localRevision.current, []); + const retryMakeUpdater = React.useCallback( + (data: RemoteStars) => applyRemote(data), + [applyRemote], + ); + useStaleReaderRecovery({ + enabled: !!pubkey && !!relayUrl, + fetch: retryFetch, + hasPending: retryHasPending, + getRevision: retryGetRevision, + makeUpdater: retryMakeUpdater, + setStore, + pubkey, + relayUrl, + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const starredChannelIds = React.useMemo( () => starredChannelIdsFromStore(store), @@ -172,6 +203,7 @@ export function useChannelStars( channelId, ); if (!writeChannelStarsStore(pubkey, next)) return prev; + localRevision.current += 1; managerRef.current?.publishStars(next); return next; }); diff --git a/desktop/src/features/sidebar/lib/useStaleReaderRecovery.ts b/desktop/src/features/sidebar/lib/useStaleReaderRecovery.ts new file mode 100644 index 0000000000..2927db60e5 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useStaleReaderRecovery.ts @@ -0,0 +1,158 @@ +import React from "react"; + +import type { FetchResult } from "./sidebarSyncWatermark.ts"; + +/** + * Shared bounded-backoff retry effect for all four sidebar sync hooks. + * + * Polls the relay at 5 → 10 → 30 → 60 s (steady at 60 s) so a stale view + * recovers without an edit-kick or reconnect. Fires immediately on + * `visibilitychange` to "visible" so returning from background converges + * quickly. Single-flight: skips ticks while a fetch is in progress. + * + * **Pending-first guard (apply-time, not response-time):** + * `hasPending()` and the effect lifetime are re-evaluated inside the React + * state updater, after `fetch()` resolves but before `applyRemote` runs. + * This closes the window where a local edit updater queued before the remote + * response could be executed after it: React may execute queued updaters in + * the same batch, so checking only at response time is insufficient. + * + * **Mutation revision guard:** + * A revision counter is snapshotted at the start of each `fetch()` call and + * rechecked inside the updater. If a local mutation landed while the request + * was in flight the response is discarded — no old relay read can roll back a + * successfully published local edit. + * + * Both guards apply inside `makeUpdater(data)`, the lane-supplied function + * that wraps `applyRemote` and `setStore`. The checks happen before + * `applyRemote`'s returned updater runs, so `cancelPending*Publish` inside + * `applyRemote` is never reached on a fenced path. + * + * Residual: a pending edit whose publish permanently fails and is never + * resolved by a reconnect will defer recovery indefinitely. Main today has + * zero recovery in that state, so this is not a regression. + * + * @param enabled Must be true for the effect to activate + * (e.g. `!!pubkey && !!relayUrl`). + * @param fetch Calls the manager's `fetchRemote*()` for this lane. + * @param hasPending Returns true when a local unpublished edit is in + * flight. Called inside the state updater. + * @param getRevision Returns the current local-mutation revision counter. + * Called at fetch start (snapshot) and inside the + * updater (recheck). + * @param makeUpdater Given the fetched data, returns a React state updater + * that applies `applyRemote(data)` to the previous + * store. Called only when both guards pass. + * @param setStore The hook's React state setter; called with the fenced + * updater produced by `makeUpdater`. + * @param pubkey Identity key — forces the recovery effect to restart + * when the active pubkey changes. Pass whenever the + * `makeUpdater` closure does not directly capture pubkey + * (e.g. if `applyRemote` only depends on pubkey but the + * outer `makeUpdater` wraps a stable ref). + * @param relayUrl Relay key — forces the recovery effect to restart when + * the relay changes. Required for stars/mutes whose + * `applyRemote` does not capture relayUrl; without it a + * still-mounted hook switching relay A→B would keep + * applying A's stale fetch after the switch. + */ +export function useStaleReaderRecovery({ + enabled, + fetch, + hasPending, + getRevision, + makeUpdater, + setStore, + pubkey, + relayUrl, +}: { + enabled: boolean; + fetch: () => Promise | undefined> | undefined; + hasPending: () => boolean; + getRevision: () => number; + makeUpdater: (data: T) => (prev: S) => S; + setStore: React.Dispatch>; + /** Passed to bind the effect lifetime to identity; see `@param pubkey`. */ + pubkey?: string | undefined; + /** Passed to bind the effect lifetime to relay; see `@param relayUrl`. */ + relayUrl?: string | undefined; +}): void { + // biome-ignore lint/correctness/useExhaustiveDependencies: pubkey and relayUrl are intentional — they bind the recovery effect lifetime to identity and relay so a still-mounted hook switching relay A→B cancels the old effect (stars/mutes applyRemote does not capture relayUrl in its deps) + React.useEffect(() => { + if (!enabled) return; + let cancelled = false; + let inFlight = false; + const BACKOFF_STEPS = [5_000, 10_000, 30_000, 60_000]; + let stepIndex = 0; + let timer: ReturnType | null = null; + + const tick = async () => { + if (cancelled || inFlight) return; + inFlight = true; + // Snapshot the local-mutation revision before the async fetch so we can + // detect a mutation that landed while the request was in flight. + const revisionAtFetch = getRevision(); + try { + // A read started while an edit is pending can return the pre-edit + // blob after the edit publishes and pending clears; skip it. + const result = hasPending() ? undefined : await fetch(); + if (!cancelled && result?.status === "found") { + const remoteUpdater = makeUpdater(result.data); + // Apply inside a state updater so hasPending() and the cancelled / + // revision guards are evaluated at execution time, not at response + // time. This closes the React-batch window where a local edit + // updater could be queued before this remote updater but execute + // after it. + setStore((prev) => { + if (cancelled) return prev; + if (hasPending()) return prev; + if (getRevision() !== revisionAtFetch) return prev; + return remoteUpdater(prev); + }); + } + } finally { + inFlight = false; + } + if (!cancelled) { + const delay = BACKOFF_STEPS[ + Math.min(stepIndex, BACKOFF_STEPS.length - 1) + ] as number; + stepIndex = Math.min(stepIndex + 1, BACKOFF_STEPS.length - 1); + timer = setTimeout(() => { + void tick(); + }, delay); + } + }; + + const onVisible = () => { + if (document.visibilityState !== "visible") return; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + stepIndex = 0; + void tick(); + }; + + document.addEventListener("visibilitychange", onVisible); + // Kick off the first tick immediately so bootstrap failures are recovered + // without waiting a full backoff interval. + void tick(); + + return () => { + cancelled = true; + document.removeEventListener("visibilitychange", onVisible); + if (timer !== null) clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + enabled, + fetch, + hasPending, + getRevision, + makeUpdater, + setStore, + pubkey, + relayUrl, + ]); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 93b6defe48..0bc6186e06 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -286,7 +286,6 @@ export function AppSidebar({ const { sortModeFor, setSortModeFor } = useChannelSortPreference( currentPubkey, activeCommunity?.relayUrl, - sectionIds, ); const [createSectionState, setCreateSectionState] = React.useState<{ diff --git a/desktop/src/shared/api/queryClient.ts b/desktop/src/shared/api/queryClient.ts index 6302f4e064..5590f1d967 100644 --- a/desktop/src/shared/api/queryClient.ts +++ b/desktop/src/shared/api/queryClient.ts @@ -1,5 +1,6 @@ import { focusManager, QueryClient } from "@tanstack/react-query"; +import { isQueryDeadlineError } from "@/shared/lib/relayError"; import { isAppFocused, subscribeAppFocus, @@ -25,7 +26,10 @@ export function createBuzzQueryClient() { return new QueryClient({ defaultOptions: { queries: { - retry: 1, + // One retry, except after the relay's `query timed out` answer: that + // would re-run the same slow server-side query. + retry: (failureCount, error) => + failureCount < 1 && !isQueryDeadlineError(error), refetchOnWindowFocus: false, networkMode: "always", gcTime: 5 * 60 * 1_000, diff --git a/desktop/src/shared/api/relayClientPublishRejection.test.mjs b/desktop/src/shared/api/relayClientPublishRejection.test.mjs index 503ac329af..14940788ef 100644 --- a/desktop/src/shared/api/relayClientPublishRejection.test.mjs +++ b/desktop/src/shared/api/relayClientPublishRejection.test.mjs @@ -42,6 +42,7 @@ Date.now = () => fakeNow; const { RelayClient } = await import("./relayClientSession.ts"); const { invokeTauri } = await import("./tauri.ts"); +const { PublishCanceledError } = await import("./relayEventPublisher.ts"); const { activateRateLimit, isRateLimited, resetRateLimitGate } = await import( "./relayRateLimitGate.ts" ); @@ -351,3 +352,25 @@ test("HTTP failure does not clear an existing WS backoff", async () => { await deliver(client, ["OK", event.id, true, ""]); assert.equal(await published, event); }); + +test("isCurrent forwarded by publishEvent cancels a rate-limited send", async () => { + reset(); + activateRateLimit(4); + const client = connectedClient(); + const event = { id: "7".repeat(64), kind: 30078 }; + let current = true; + const published = client.publishEvent( + event, + "timed out", + "send failed", + () => current, + ); + await Promise.resolve(); + assert.equal(sendAttempts.length, 0, "waits behind the gate"); + current = false; + resetRateLimitGate(); + await assert.rejects(published, PublishCanceledError); + assert.equal(sendAttempts.length, 0, "nothing reached the native send"); + assert.equal(client.pendingEvents.size, 0); + assert.equal(pendingTimers.size, 0, "no publication timeout armed"); +}); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index fdf159411a..ce64037570 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -842,6 +842,7 @@ export class RelayClient { event: RelayEvent, timeoutMessage: string, sendErrorMessage: string, + isCurrent?: () => boolean, ) { return publishSessionEvent( { @@ -859,6 +860,7 @@ export class RelayClient { event, timeoutMessage, sendErrorMessage, + isCurrent, ); } diff --git a/desktop/src/shared/api/relayClosedPolicy.test.mjs b/desktop/src/shared/api/relayClosedPolicy.test.mjs index 2e5cad4b7c..49f8189cca 100644 --- a/desktop/src/shared/api/relayClosedPolicy.test.mjs +++ b/desktop/src/shared/api/relayClosedPolicy.test.mjs @@ -26,6 +26,7 @@ test("classifyRelayClosed: terminal messages return terminal", () => { "unsupported: filter", "error: mixed search and non-search filters not supported", "error: too many subscriptions", + "error: query timed out", ]) { assert.equal(classifyRelayClosed(message), "terminal", message); } diff --git a/desktop/src/shared/api/relayClosedPolicy.ts b/desktop/src/shared/api/relayClosedPolicy.ts index 9e747a2df1..a5ff5372ef 100644 --- a/desktop/src/shared/api/relayClosedPolicy.ts +++ b/desktop/src/shared/api/relayClosedPolicy.ts @@ -32,7 +32,10 @@ export function classifyRelayClosed(message: string): RelayClosedClass { normalized.startsWith("duplicate:") || normalized.startsWith("unsupported:") || normalized.startsWith("error: mixed search") || - normalized.startsWith("error: too many subscriptions") + normalized.startsWith("error: too many subscriptions") || + // Server statement deadline: re-sending the same REQ re-runs the same + // slow query, so a live retry loop would hammer the database. + normalized.startsWith("error: query timed out") ) { return "terminal"; } diff --git a/desktop/src/shared/api/relayClosedRecovery.test.mjs b/desktop/src/shared/api/relayClosedRecovery.test.mjs index de75aad3ba..c09b90e4c5 100644 --- a/desktop/src/shared/api/relayClosedRecovery.test.mjs +++ b/desktop/src/shared/api/relayClosedRecovery.test.mjs @@ -670,6 +670,35 @@ test("terminal CLOSED deletes subscription and does not retry", () => { assert.equal(firedAt.length, 0, "terminal CLOSED must not retry"); }); +test("live CLOSED query deadline retries with backoff instead of removing", () => { + resetAll(0); + const firedAt = []; + const subscriptions = new Map([ + [ + "live-1", + { + mode: "live", + filter: { kinds: [9], "#h": ["ch-1"], limit: 1000 }, + onEvent: () => {}, + resolveReady: () => {}, + }, + ], + ]); + handleRelayClosed({ + subscriptions, + subId: "live-1", + message: "error: query timed out", + sendReq: () => { + firedAt.push(fakeNow); + return Promise.resolve(); + }, + }); + assert.equal(subscriptions.has("live-1"), true, "live sub must survive"); + assert.equal(firedAt.length, 0, "retry waits for backoff"); + tickTo(10_000); + assert.equal(firedAt.length, 1, "live sub must be re-sent after backoff"); +}); + // ── Test: rejecting closeSubscription does not produce an unhandled rejection ─ // // Load-bearing for the `.catch(() => {})` guard on the op-timeout CLOSE send. diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts index cf04d83126..348d55e299 100644 --- a/desktop/src/shared/api/relayClosedRecovery.ts +++ b/desktop/src/shared/api/relayClosedRecovery.ts @@ -1,4 +1,5 @@ import { classifyRelayClosed } from "@/shared/api/relayClosedPolicy"; +import { isQueryDeadlineError } from "@/shared/lib/relayError"; import { activateRateLimit, parseRateLimitHint, @@ -132,7 +133,13 @@ function recoverLiveSubscriptionFromClosed({ subscription.resolveReady?.("closed"); subscription.resolveReady = undefined; - const closedClass = classifyRelayClosed(message); + // A deadline on a live sub is treated as retryable: resubscribe with the + // same filter under the 1-30s backoff, which can keep retrying at the cap. + // `since` is deliberately not advanced to now; that could skip events + // missed while the sub was closed. + const closedClass = isQueryDeadlineError(message) + ? "retryable" + : classifyRelayClosed(message); if (closedClass === "terminal") { // Auth/access/filter failure — permanently remove the subscription so it diff --git a/desktop/src/shared/api/relayEventPublisher.ts b/desktop/src/shared/api/relayEventPublisher.ts index ff71992670..07adcda103 100644 --- a/desktop/src/shared/api/relayEventPublisher.ts +++ b/desktop/src/shared/api/relayEventPublisher.ts @@ -13,18 +13,32 @@ type PublishSession = { recoverSocketFailure: (error: unknown, fallback: string) => Error; }; -/** Publish once, with one reconnect retry, without crossing session ownership. */ +/** Typed so no relay rejection text can be mistaken for a caller's cancel. */ +export class PublishCanceledError extends Error { + constructor() { + super("Relay publish canceled by its caller."); + this.name = "PublishCanceledError"; + } +} + +/** + * Publish once, with one reconnect retry, without crossing session ownership. + * `isCurrent`, when given, is checked immediately before every socket send; a + * false result rejects with `PublishCanceledError` and sends nothing. + */ export async function publishSessionEvent( session: PublishSession, event: RelayEvent, timeoutMessage: string, sendErrorMessage: string, + isCurrent?: () => boolean, ): Promise { const publishOwnership = session.ownership(); await waitForRateLimit(); if (publishOwnership !== session.ownership()) { throw new Error("Relay disconnected for community switch."); } + if (isCurrent?.() === false) throw new PublishCanceledError(); const publishGeneration = session.generation(); return new Promise((resolve, reject) => { @@ -65,6 +79,12 @@ export async function publishSessionEvent( "Relay publish was superseded by a session change.", ); } + if (isCurrent?.() === false) { + window.clearTimeout(timeout); + session.pendingEvents.delete(event.id); + reject(new PublishCanceledError()); + return; + } await session.send(["EVENT", event], retryGeneration); } catch (retryError) { if (session.pendingEvents.get(event.id) !== pendingEvent) return; diff --git a/desktop/src/shared/lib/relayError.test.mjs b/desktop/src/shared/lib/relayError.test.mjs index 7ae121f2b5..07b018b9b1 100644 --- a/desktop/src/shared/lib/relayError.test.mjs +++ b/desktop/src/shared/lib/relayError.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isRelayUnreachableError } from "./relayError.ts"; +import { isQueryDeadlineError, isRelayUnreachableError } from "./relayError.ts"; test("isRelayUnreachableError: Error with prefix returns true", () => { assert.equal( @@ -55,3 +55,39 @@ test("isRelayUnreachableError: plain object returns false", () => { false, ); }); + +test("isQueryDeadlineError: relay statement deadline, HTTP and WS forms", () => { + assert.equal( + isQueryDeadlineError( + new Error("relay returned 503 Service Unavailable: query timed out"), + ), + true, + ); + assert.equal(isQueryDeadlineError("error: query timed out"), true); +}); + +test("isQueryDeadlineError: client-side timeouts stay retryable", () => { + for (const message of [ + "relay unreachable: request timed out", + "relay request timed out", + "relay request timed out or cancelled", + ]) { + assert.equal(isQueryDeadlineError(new Error(message)), false, message); + } +}); + +test("isQueryDeadlineError: other relay errors stay retryable", () => { + assert.equal( + isQueryDeadlineError( + new Error( + "relay returned 500 Internal Server Error: internal server error", + ), + ), + false, + ); + assert.equal( + isQueryDeadlineError(new Error("relay unreachable: 403")), + false, + ); + assert.equal(isQueryDeadlineError(undefined), false); +}); diff --git a/desktop/src/shared/lib/relayError.ts b/desktop/src/shared/lib/relayError.ts index ca16aa41e2..285a8651fd 100644 --- a/desktop/src/shared/lib/relayError.ts +++ b/desktop/src/shared/lib/relayError.ts @@ -32,3 +32,14 @@ export function isRelayUnreachableError(error: unknown): boolean { } return false; } + +/** + * Returns true only for the relay's own statement-deadline answer: HTTP 503 + * `query timed out` or WS CLOSED `error: query timed out`. Retrying re-runs + * the same expensive query, so callers should not. Client-side timeouts + * (`request timed out`) are network stalls and stay retryable. + */ +export function isQueryDeadlineError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("query timed out"); +} diff --git a/desktop/tests/e2e/channel-sort.spec.ts b/desktop/tests/e2e/channel-sort.spec.ts index da70147032..03fdfbe599 100644 --- a/desktop/tests/e2e/channel-sort.spec.ts +++ b/desktop/tests/e2e/channel-sort.spec.ts @@ -178,7 +178,10 @@ test.describe("per-group channel sort", () => { const stored = await page.evaluate((key) => { return JSON.parse(window.localStorage.getItem(key) ?? "null"); }, SORT_STORAGE_KEY); - expect(stored).toEqual({ version: 1, groups: { channels: "recent" } }); + expect(stored).toMatchObject({ + version: 1, + groups: { channels: "recent" }, + }); // Survives reload. await page.reload(); @@ -246,6 +249,6 @@ test.describe("per-group channel sort", () => { const stored = await page.evaluate((key) => { return JSON.parse(window.localStorage.getItem(key) ?? "null"); }, SORT_STORAGE_KEY); - expect(stored).toEqual({ version: 1, groups: { dms: "recent" } }); + expect(stored).toMatchObject({ version: 1, groups: { dms: "recent" } }); }); }); diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 27bf08b731..5313bdaf72 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -2172,6 +2172,13 @@ test("thread summary badge survives a retained older-history prepend", async ({ await page.getByTestId("channel-deep-history").click(); await expect(page.getByTestId("chat-title")).toHaveText("deep-history"); + // Page only after live admission and its head refresh. That refresh + // replaces the window, so an earlier prepend can be discarded. + await waitForMockChannelHeadReady( + page, + "deep-history", + "feedf00d-0000-4000-8000-000000000007", + ); const timeline = page.getByTestId("message-timeline"); const badgeSelector = '[data-testid="message-thread-summary"][data-thread-head-id="mock-deep-history-599"]'; diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index 2c86f5a9bd..060d356d28 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -1513,12 +1513,14 @@ test("right-click menus expose distinct selectors for links, relay video, and of // ── Relay video menu: Download video + Copy link, appearing only once the // relay origin resolves (the reactivity fix) ───────────────────────────── - await emitVideoMessage(page, { + const relayMessage = (await emitVideoMessage(page, { url: MENU_RELAY_VIDEO_URL, sha: MENU_RELAY_VIDEO_SHA, filename: "relay-clip.mp4", - }); - const relayPlayer = page.getByTestId("video-player").last(); + })) as { id: string }; + const relayPlayer = page + .locator(`[data-message-id="${relayMessage.id}"]`) + .getByTestId("video-player"); await expect(relayPlayer).toBeVisible(); // Right-click the player surface. `force` skips the actionability guard: the // Play-button overlay sits above the video, but the contextmenu event still @@ -1559,12 +1561,14 @@ test("right-click menus expose distinct selectors for links, relay video, and of await expect(page.locator("[data-video-context-menu]")).toHaveCount(0); // ── Off-relay video control: renders and offers Copy link, never Download ─ - await emitVideoMessage(page, { + const offRelayMessage = (await emitVideoMessage(page, { url: MENU_OFF_RELAY_VIDEO_URL, sha: MENU_OFF_RELAY_VIDEO_SHA, filename: "external-clip.mp4", - }); - const offRelayPlayer = page.getByTestId("video-player").last(); + })) as { id: string }; + const offRelayPlayer = page + .locator(`[data-message-id="${offRelayMessage.id}"]`) + .getByTestId("video-player"); await expect(offRelayPlayer).toBeVisible(); await offRelayPlayer.click({ button: "right", force: true }); diff --git a/docs/deployment-identity.md b/docs/deployment-identity.md index 32d8c426e7..f5bc8710d1 100644 --- a/docs/deployment-identity.md +++ b/docs/deployment-identity.md @@ -47,6 +47,15 @@ The relay health listener exposes intrinsic build identity at `/_status`: "source_sha": "<40-character-source-sha>", "id": "github-actions::", "url": "https://github.com/block/buzz/actions/runs//attempts/" + }, + "dependencies": { + "sample": "fresh", + "sample_interval_seconds": 30, + "sample_age_seconds": 12, + "postgres": true, + "redis": true, + "deletion_catalog": true, + "reason": "ready" } } ``` @@ -54,6 +63,16 @@ The relay health listener exposes intrinsic build identity at `/_status`: Non-CI builds report stable `unknown` or `local` fallback values instead of claiming provenance they do not have. +`dependencies` is a cached diagnostic snapshot of shared-dependency health. A +per-pod background loop evaluates the dependencies every 30 seconds; the +endpoint only reads the latest report and never contacts a dependency itself, +so polling it costs nothing. `sample` is always present and reports whether +that cached verdict is `fresh`, `stale`, or `not_yet_sampled` — before the +first cycle completes the health fields are absent rather than defaulted. +`/_readiness` does not consult any of this — see +[the readiness contract](../deploy/charts/buzz/README.md#readiness-contract) — +so this endpoint must never be wired to a Kubernetes probe. + ## Helm digest pinning Buzz chart `0.1.8` and newer accept an immutable image digest: diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index fee7909dfd..9448d6bc66 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -190,7 +190,7 @@ community configuration is missing or unavailable. ### Multi-issuer registry -The global [`IssuerRegistry`](../../crates/buzz-auth/src/nip_fi/config.rs) remains +The global `IssuerRegistry` remains shared for issuer policy and JWKS lookup; community authorization is a separate deployment mapping from canonical host URI to `(expected_aud, authorized_issuers)`. `VerifyAssertion` consumes the @@ -198,7 +198,7 @@ Host-resolved mapping, so a globally known issuer is not implicitly trusted by every community. The `nostr_pubkey` claim is unconditionally required — absence rejects -regardless of issuer policy (NIP-FI v2, PR #7221). +regardless of issuer policy. ### JWKS snapshot @@ -396,40 +396,6 @@ specified below is the recovery path for lost propagation, exactly as for relay restart. A success response from the receiving process does not imply cluster-wide application. -> **Note — adopted position (session-only vs deny-until-TTL):** -> -> The session-only model closes existing connections but places no -> protocol-level bound on reconnection. For session-only, if the issuer also -> stops issuing new assertions after a disconnect call, cumulative residual -> access is bounded by: -> -> ``` -> max(0, min(exp, iat + maximum_assertion_age) - now) -> ``` -> -> `max_connection_lifetime_seconds` only partitions that interval into -> individual sessions; it does not shorten the total window. If the issuer -> continues issuing new assertions, cumulative access extends indefinitely. -> -> The **deny-until-TTL** model closes this reconnect window. The relay holds a -> memory-resident deny set keyed by `(iss, pubkey)`, with absolute expiry -> carried by the issuer as `until` in the disconnect command. Any admission -> attempt for that key is denied until the entry expires. The issuer must boot -> the deny TTL to outlast the longest live assertion it may have already -> issued; otherwise an unexpired assertion lets the client back in the moment -> the entry expires. -> -> The deny set is RAM-cache, not a database — the same operational posture as -> the JWKS snapshot. A relay restart clears the set; the issuer, as the -> durable system of record for revocations, SHOULD re-push still-active denies -> when it observes a relay restart (same publish/cache pattern as JWKS). A -> fresh relay MAY consult the issuer before first admissions to close the -> startup race; this is non-normative. -> -> This design was chosen because session-only disconnection must outlast -> the live socket to mean anything as a revocation primitive; a self-expiring -> RAM entry preserves the zero-persistence guarantee while closing the window. - ### Transport The disconnect endpoint is an authenticated issuer→relay API, not a public @@ -515,7 +481,7 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): // leaves neither behind: the jti is not burned, and the caller may safely // retry the same signed command. Performing the jti reservation alone // (without the deny-entry insertion) would burn the command identity on a - // capacity failure, making the new 503 contract unimplementable. Capacity + // capacity failure, making the 503 contract unimplementable. Capacity // is checked against the per-issuer bound for claims.iss; a different // issuer's capacity exhaustion does not produce a 503 here. // On same-key collision: retain max(existing_until, until) — never shorten @@ -535,9 +501,6 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): Any failure at any step is fail-closed: no side effects occur and the relay returns the appropriate error. -This verifier and the disconnect API endpoint are follow-on code changes -outside this PR. - ### Request ```text @@ -620,10 +583,9 @@ to all three requirements on all three endpoints, including both POST endpoints; it is not a payload-only exemption. This exemption is required by Git's credential protocol. The credential helper -receives only credential metadata and never sees request bodies -(`crates/git-credential-nostr/src/lib.rs:98-132`), so a body hash cannot exist -in the signed event. This limitation is architectural to Git's credential -protocol. +receives only credential metadata and never sees request bodies, so a body +hash cannot exist in the signed event. This limitation is architectural to +Git's credential protocol. The NIP-FI assertion and pairing requirement is unchanged and applies per request on every one of these endpoints. Each request MUST still carry and @@ -645,8 +607,7 @@ The following compensating controls are REQUIRED for this proof pattern: 7. Every push MUST pass pre-receive hook push authorization. This exemption applies solely to the Git credential-helper proof pattern on -these three endpoints. It is not a precedent for any other surface. A client -change that enables per-request signing supersedes this exemption. +these three endpoints. It is not a precedent for any other surface. ### Media possession-proof exception (Blossom, kind 24242) @@ -656,8 +617,7 @@ alternative possession format — not a general "signed Nostr event" escape hatc and not precedent for any other surface. Any future alternative proof format requires an explicit axis-by-axis security review covering: payload/resource binding, method/operation scope, audience/tenant scope, freshness, signature/key -pairing, transport cardinality, and cross-endpoint replay. Per-request NIP-98 -support supersedes this exception when available. +pairing, transport cardinality, and cross-endpoint replay. #### Scope fence @@ -665,7 +625,7 @@ Kind-24242 proofs are valid **only** on the following routes and operations: | Proof type (`t` tag) | Valid route | Method | |---|---|---| -| `upload` | `PUT /upload` (and temporary alias `PUT /media/upload` until that alias is removed) | PUT | +| `upload` | `PUT /upload`, `PUT /media/upload` | PUT | | `get` | `GET /media/{hash…}`, `HEAD /media/{hash…}` | GET, HEAD | No other protected route may accept a kind-24242 proof. A kind-24242 event @@ -738,17 +698,6 @@ requirement: full assertion verification, exact key equality between the assertion's `nostr_pubkey` claim and the kind-24242 event's public key, and deny-map enforcement (see Admission procedure, steps 1–6). -The effectiveness of deny-map enforcement is contingent on the real -issuer-scoped deny map. Until that map is operational, the stub implementation -constitutes a **known gap** in this section's security guarantees. - -#### Compliance note - -The implementation as of PR #7264 pairs via a permissive Blossom verifier and -is explicitly non-compliant with this section. The named gaps are: -multi-tag acceptance, a 3600-second proof window, and an optional `server` -tag. These are resolved when the bounded hardening task lands. - ### Request format Each protected HTTP request MUST present both of the following: @@ -868,7 +817,6 @@ normative behavior for them: - Directory integration and account-offboarding automation: issuer-side. - Audit logging beyond what the relay operator chooses to retain: issuer-side. - Delegation: out of scope. -- Companion profiles (NIP-FI-EDGE, NIP-FI-LIFECYCLE, NIP-FI-DELEG, NIP-FI-CONF, NIP-FI-MODEL): removed. ## Discovery @@ -979,10 +927,10 @@ gap by requiring assertion verification on every protected HTTP request. The per-request re-verification model means there is no cached admission window; a deny-set entry takes effect on the very next request. -**SSRF.** The JWKS fetcher implements SSRF protection: HTTPS-only URI +**SSRF.** The JWKS fetcher MUST enforce SSRF protection: HTTPS-only URI validation, DNS resolution with IP deny-list enforcement, address pinning to -prevent DNS rebinding TOCTOU, and redirect denial. The complete IANA -Special-Purpose address deny table is implemented; see `crates/buzz-core/src/network.rs`. +prevent DNS rebinding TOCTOU, and redirect denial. The IP deny-list covers +the complete IANA Special-Purpose address registry. **Issuer compromise.** A compromised assertion issuer can impersonate any identity but cannot prove possession of the assertion-named Nostr key. The NIP-42 diff --git a/migrations/0050_operator_listener_mentions.sql b/migrations/0050_operator_listener_mentions.sql new file mode 100644 index 0000000000..10f81f50aa --- /dev/null +++ b/migrations/0050_operator_listener_mentions.sql @@ -0,0 +1,57 @@ +-- Deployment-global operator-listener mention delivery. +-- Listener registrations span communities; community_id on the outbox is event +-- provenance, not a tenant boundary. + +-- Migration 0044 restored this function to the pre-0041 exclusion set. Extend +-- it here so migrated databases keep the outbox outside tenant fencing and +-- community-deletion catalog discovery, matching the desired-state schema. +CREATE OR REPLACE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN +LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT target::TEXT = ANY (ARRAY[ + 'community_deletion_requests', 'community_deletion_approvals', + 'community_deletion_checkpoints', 'community_serving_write_leases', + 'community_deletion_executor_heartbeats', 'product_feedback', + 'rate_limit_violations', 'operator_listener_outbox' + ]::TEXT[]) +$$; + +CREATE TABLE operator_listener_pubkeys ( + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (listener_pubkey, target_pubkey) +); +CREATE INDEX operator_listener_pubkeys_target + ON operator_listener_pubkeys (target_pubkey, listener_pubkey); +CREATE INDEX operator_listener_pubkeys_created_at + ON operator_listener_pubkeys (created_at); + +CREATE TABLE operator_listener_outbox ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + community_id UUID NOT NULL, + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + event_kind INTEGER NOT NULL, + event_created_at TIMESTAMPTZ NOT NULL, + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'sending')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_until TIMESTAMPTZ, + claim_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (listener_pubkey, target_pubkey, community_id, event_id) +); +CREATE INDEX operator_listener_outbox_due + ON operator_listener_outbox (next_attempt_at, created_at, id) + WHERE state = 'pending'; +CREATE INDEX operator_listener_outbox_recovery + ON operator_listener_outbox (lease_until, created_at, id) + WHERE state = 'sending'; +CREATE INDEX operator_listener_outbox_created_at + ON operator_listener_outbox (created_at); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('operator_listener_pubkeys', 'deployment-global target registrations for operator listeners'), + ('operator_listener_outbox', 'deployment-global mention delivery queue; community_id is event provenance'); diff --git a/migrations/0051_owner_community_deletion_admission.sql b/migrations/0051_owner_community_deletion_admission.sql new file mode 100644 index 0000000000..25002b32da --- /dev/null +++ b/migrations/0051_owner_community_deletion_admission.sql @@ -0,0 +1,81 @@ +-- Structured provenance for owner-origin whole-community deletion requests. +-- +-- The existing request UUID is the stable correlation/idempotency identity. +-- Owner admission supplies that UUID instead of creating a second identity +-- column, while these bounded columns distinguish owner intent from the +-- deployment operator that mediated it. +-- +-- The owner branch rejects a NULL in every provenance column before testing +-- its shape. A bare `col ~ '...'` on a NULL column yields NULL, and a CHECK is +-- satisfied by NULL, so `FALSE OR NULL` would silently admit an owner-origin +-- row with no owner key, no mediating operator, or no acknowledgement version. +-- +-- The null rejection is spelled `NOT (col IS NULL)` rather than +-- `col IS NOT NULL`: pgschema drops a named CHECK whose body contains +-- `IS NOT NULL` and still exits 0, which would leave the desired-state +-- bootstrap in `schema/schema.sql` silently unguarded while the migration path +-- stayed correct. `store::deletion::owner_provenance_contract` asserts both +-- schema sources against one case table so that divergence fails loudly. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE community_deletion_requests + ADD COLUMN request_origin TEXT NOT NULL DEFAULT 'operator' + CHECK (request_origin IN ('operator', 'owner')), + ADD COLUMN owner_pubkey TEXT, + ADD COLUMN mediating_operator_pubkey TEXT, + ADD COLUMN acknowledgement_version INTEGER, + ADD CONSTRAINT community_deletion_owner_provenance CHECK ( + (request_origin = 'operator' + AND owner_pubkey IS NULL + AND mediating_operator_pubkey IS NULL + AND acknowledgement_version IS NULL) + OR + (request_origin = 'owner' + AND NOT (owner_pubkey IS NULL) + AND NOT (mediating_operator_pubkey IS NULL) + AND NOT (acknowledgement_version IS NULL) + AND owner_pubkey ~ '^[0-9a-f]{64}$' + AND mediating_operator_pubkey ~ '^[0-9a-f]{64}$' + AND acknowledgement_version BETWEEN 1 AND 32767 + AND requested_by = owner_pubkey) + ); + +CREATE OR REPLACE FUNCTION prevent_community_deletion_request_retargeting() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.community_host IS DISTINCT FROM OLD.community_host + THEN + RAISE EXCEPTION 'community deletion target identity is immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + IF NEW.request_origin IS DISTINCT FROM OLD.request_origin + OR NEW.owner_pubkey IS DISTINCT FROM OLD.owner_pubkey + OR NEW.mediating_operator_pubkey IS DISTINCT FROM OLD.mediating_operator_pubkey + OR NEW.acknowledgement_version IS DISTINCT FROM OLD.acknowledgement_version + THEN + RAISE EXCEPTION 'community deletion request provenance is immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + IF OLD.inventory_frozen_at IS NOT NULL AND ( + NEW.schema_manifest IS DISTINCT FROM OLD.schema_manifest + OR NEW.storage_manifest IS DISTINCT FROM OLD.storage_manifest + OR NEW.inventory_manifest IS DISTINCT FROM OLD.inventory_manifest + OR NEW.inventory_digest IS DISTINCT FROM OLD.inventory_digest + OR NEW.inventory_frozen_at IS DISTINCT FROM OLD.inventory_frozen_at + ) THEN + RAISE EXCEPTION 'frozen community deletion inventory is immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + IF OLD.destructive_storage_frozen_at IS NOT NULL AND ( + NEW.destructive_storage_manifest IS DISTINCT FROM OLD.destructive_storage_manifest + OR NEW.destructive_storage_frozen_at IS DISTINCT FROM OLD.destructive_storage_frozen_at + ) THEN + RAISE EXCEPTION 'frozen destructive storage manifest is immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + RETURN NEW; +END; +$$; diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 468dbb0486..d518bd1878 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -25,6 +25,7 @@ import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/flapping_bee.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; +import '../../shared/widgets/load_error_view.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/masked_avatar_badge.dart'; import '../../shared/widgets/message_author_meta.dart'; @@ -715,12 +716,10 @@ class ChannelDetailPage extends HookConsumerWidget { titleContentHeight: appBarTitleContentHeight, ), ), - child: Center( - child: Text( - 'Failed to load messages', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, - ), + child: LoadErrorView( + message: 'Failed to load messages', + onRetry: () => ref.invalidate( + channelMessagesProvider(channel.id), ), ), ), diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index 221aef367a..a87fdf7c7f 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -247,6 +247,8 @@ class ChannelMessagesNotifier extends Notifier>> { } catch (error) { _initialWindowQueryInFlight = false; _liveSummaryRootsDuringInitialWindowQuery.clear(); + // Legacy history would re-run the timed-out work another way. + if (isRelayDeadlineError(error)) rethrow; debugPrint( '[ChannelMessagesNotifier] channel window unavailable for $channelId, falling back to WS history: $error', ); diff --git a/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart b/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart index 7fa36c2b14..7d22de342b 100644 --- a/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart +++ b/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart @@ -332,7 +332,8 @@ extension _ThreadSummaryState on ChannelMessagesNotifier { } } catch (error) { if (!current()) return; - if (attempt < 2) { + // Re-sending a timed-out recount re-runs the same slow scan. + if (attempt < 2 && !isRelayDeadlineError(error)) { // Retain this active queue slot during backoff, so retries share the // same concurrency budget and stop when newer work supersedes them. await Future.delayed(Duration(milliseconds: 500 << attempt)); diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index c5a77ba930..982e39fa2a 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -338,8 +338,18 @@ class ChannelsNotifier extends AsyncNotifier> { _fetchLastMessageEvents(session, activeChannels), ); final lastMessageMap = {}; + // An unavailable batch keeps the timestamps already known rather than + // installing channels whose latest message looks absent. + if (events == null) { + for (final channel in state.value ?? const []) { + final at = channel.lastMessageAt; + if (at != null) { + lastMessageMap[channel.id] = at.millisecondsSinceEpoch ~/ 1000; + } + } + } final mutedChannelIds = _mutedChannelIds(); - for (final event in events) { + for (final event in events ?? const []) { final channelId = event.channelId; if (channelId == null) continue; final channel = channelById[channelId]; @@ -423,7 +433,7 @@ class ChannelsNotifier extends AsyncNotifier> { /// bridge request. The relay preserves NIP-01 per-filter limits while /// executing the filters with bounded concurrency, avoiding an unbounded /// burst of websocket REQs on communities with many channels. - Future> _fetchLastMessageEvents( + Future?> _fetchLastMessageEvents( RelaySessionNotifier session, List channels, ) async { @@ -447,7 +457,8 @@ class ChannelsNotifier extends AsyncNotifier> { ); } - Future> _fetchChannelHistoryBatch( + /// Returns null when the batch is unavailable: it hit a relay deadline. + Future?> _fetchChannelHistoryBatch( RelaySessionNotifier session, List filters, { required String operation, @@ -457,6 +468,11 @@ class ChannelsNotifier extends AsyncNotifier> { try { return await session.queryRelay(filters); } catch (error) { + // Per-filter fallback would re-run the timed-out work another way. + if (isRelayDeadlineError(error)) { + debugPrint('[ChannelsNotifier] batched $operation hit the deadline'); + return null; + } debugPrint( '[ChannelsNotifier] batched $operation failed; ' 'using bounded websocket fallback: $error', @@ -465,13 +481,19 @@ class ChannelsNotifier extends AsyncNotifier> { const fallbackConcurrency = 4; final events = []; + var deadline = false; for (var start = 0; start < filters.length; start += fallbackConcurrency) { + // Stop sending once a filter hit the deadline; partial events are not + // a result. + if (deadline) return null; final end = min(start + fallbackConcurrency, filters.length); final results = await Future.wait( filters.sublist(start, end).map((filter) async { try { return await session.fetchHistory(filter); - } catch (_) { + } catch (error) { + // A timed-out filter makes the batch unavailable, not empty. + if (isRelayDeadlineError(error)) deadline = true; return const []; } }), @@ -480,6 +502,7 @@ class ChannelsNotifier extends AsyncNotifier> { events.addAll(result); } } + if (deadline) return null; return events; } @@ -592,6 +615,8 @@ class ChannelsNotifier extends AsyncNotifier> { filters, operation: 'unread catch-up', ); + // Unavailable: keep the unread state already observed. + if (events == null) return; // The relay round-trip above is the window Jed's probes park in: a newer // refresh, a community switch or an identity switch here means every // write below belongs to a channel list the user has left. @@ -605,7 +630,7 @@ class ChannelsNotifier extends AsyncNotifier> { } } - var recorded = false; + final recordedAtByChannel = {}; for (final event in events) { final channelId = event.channelId; if (channelId == null) continue; @@ -626,15 +651,25 @@ class ChannelsNotifier extends AsyncNotifier> { continue; } _recordUnreadEvent(channel, event, myPk); - recorded = true; + recordedAtByChannel[channelId] = max( + recordedAtByChannel[channelId] ?? 0, + event.createdAt, + ); } // Republish only when this catch-up actually changed unread state. A // batch that recorded nothing has nothing to show, and a failed or // superseded batch must not repaint another refresh's list: the retired // check above already returned in that case, and no await separates it // from here, so a second check would be dead code. - if (recorded) { - state = state.whenData((channels) => List.of(channels)); + // Recorded events also advance `lastMessageAt`, as live events do: a + // deadline-unavailable latest-message batch leaves it unset. + if (recordedAtByChannel.isNotEmpty) { + state = state.whenData( + (channels) => [ + for (final channel in channels) + _advanceLastMessageAt(channel, recordedAtByChannel[channel.id]), + ], + ); } } catch (error) { if (!ref.mounted) return; @@ -642,6 +677,17 @@ class ChannelsNotifier extends AsyncNotifier> { } } + static Channel _advanceLastMessageAt(Channel channel, int? createdAt) { + if (createdAt == null) return channel; + final at = DateTime.fromMillisecondsSinceEpoch( + createdAt * 1000, + isUtc: true, + ); + final current = channel.lastMessageAt; + if (current != null && !at.isAfter(current)) return channel; + return channel.copyWith(lastMessageAt: at); + } + void _handleLiveEvent(NostrEvent event) { final channelId = event.channelId; if (channelId == null) return; diff --git a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart index 7267795734..02bebfadd0 100644 --- a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart +++ b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart @@ -19,6 +19,13 @@ class MediaVideoViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); + // Tracks a VideoPlayerController that has been created (i.e. platform + // resources allocated via createWithOptions()) but whose initialize()+play() + // chain has not yet completed or failed. The effect cleanup path disposes + // this directly so a close-during-init does not leak the native player, + // even when the async chain is suspended waiting for the initialized event + // or for play() to return. + final pendingController = useRef(null); final videoFile = useRef(null); final downloadRequestAbort = useRef?>(null); final downloadSubscription = useRef>?>(null); @@ -48,34 +55,13 @@ class MediaVideoViewerPage extends HookConsumerWidget { final auth = ref.read(mediaGetAuthServiceProvider); final uri = Uri.parse(videoUrl); - // ExoPlayer supports the request headers on every range request, so - // keep Android on its streaming path. iOS uses the authenticated local - // copy below because AVPlayer can drop those headers after the first - // request. - if (Platform.isAndroid) { - VideoPlayerController? streamingController; - try { - streamingController = VideoPlayerController.networkUrl( - uri, - httpHeaders: auth.headersFor(videoUrl), - ); - await streamingController.initialize(); - await streamingController.play(); - if (disposed) { - await streamingController.dispose(); - return; - } - controller.value = streamingController; - return; - } catch (_) { - if (streamingController != null) { - await streamingController.dispose(); - } - // Fall through to the authenticated local-file path only when the - // streaming controller cannot initialize. - } - } - + // All platforms: download to an authenticated local file so the proof + // is bound at request time rather than frozen into controller headers. + // (iOS already used this path; Android previously used streaming headers + // but video_player_android 2.9.5 freezes those headers into static + // DefaultHttpDataSource request properties — a proof minted at + // controller creation time becomes stale after 60 s, causing seeks + // outside the buffer to fail with expiry rejection.) try { final client = ref.read(mediaHttpClientProvider); final requestAbort = Completer(); @@ -85,6 +71,14 @@ class MediaVideoViewerPage extends HookConsumerWidget { uri, abortTrigger: requestAbort.future, )..headers.addAll(auth.headersFor(videoUrl)); + // A GET carries no request body. StreamedRequest's sink MUST be + // closed to signal end-of-stream: IOClient.send() awaits + // stream.pipe(ioRequest) before returning a response, and pipe + // blocks until the source stream ends. Without close(), every + // download hangs in loading until the request is aborted. + // close() is unawaited because it may not complete until after + // the pipe is in progress (streamed_request.dart:15-29). + unawaited(request.sink.close()); late final http.StreamedResponse response; try { response = await client.send(request); @@ -98,7 +92,12 @@ class MediaVideoViewerPage extends HookConsumerWidget { return; } if (response.statusCode < 200 || response.statusCode >= 300) { - await response.stream.drain(); + // Cancel (not drain) the error-body stream so a stalled server body + // cannot hold the download open. `drain()` waits for the upstream + // to close the stream; `_cancelVideoResponse` subscribes and + // immediately cancels, which closes the underlying connection without + // waiting for the full response body [F2r(b)]. + await _cancelVideoResponse(response); throw HttpException( 'Video download failed (${response.statusCode})', uri: uri, @@ -146,14 +145,72 @@ class MediaVideoViewerPage extends HookConsumerWidget { } final localController = VideoPlayerController.file(file); - await localController.initialize(); - await localController.play(); - if (disposed) { - await localController.dispose(); - await deleteVideoFile(); - return; + // Register as pending BEFORE the first async suspension + // (initialize()) so the effect cleanup can always reach it. + // video_player 2.11.1 allocates the native player synchronously + // inside createWithOptions() before _creatingCompleter completes; + // a close arriving at any point after this line will find the + // controller in pendingController and dispose it correctly. + pendingController.value = localController; + // Own the controller before any async suspension so a failed + // initialize() or play() — or a disposal that races with init — + // can always call dispose() unconditionally [F2r(a)]. + // video_player 2.11.1 completes the init future with an error on + // native failure but does NOT dispose the player; Android 2.9.5 + // retains the native player until explicit disposal. Without this + // wrapper, a PlatformException from initialize() unwinds to the + // outer catch where controller.value is still null, so the cleanup + // teardown's `if (activeController != null)` guard silently skips + // disposal — leaking the native player and its event subscription. + try { + await localController.initialize(); + if (disposed) { + // Effect cleanup will also see pendingController.value and + // dispose it; clear the ref here to avoid a double-dispose. + pendingController.value = null; + await localController.dispose(); + await deleteVideoFile(); + return; + } + await localController.play(); + if (disposed) { + pendingController.value = null; + await localController.dispose(); + await deleteVideoFile(); + return; + } + pendingController.value = null; + controller.value = localController; + } catch (_) { + // Start disposal without awaiting it, then rethrow immediately. + // + // video_player 2.11.1 initialize() creates _creatingCompleter at + // the top of the method, then awaits createWithOptions() before + // completing it (video_player.dart:546,587-590). If + // createWithOptions() itself throws, _creatingCompleter is never + // completed, and dispose() waits on it unconditionally at :682-683. + // Awaiting dispose() here would therefore deadlock: the outer catch + // never sets error.value, the error UI is never shown, and the + // viewer is left in an infinite loading state. + // + // Note: if createWithOptions() throws, _creatingCompleter is never + // completed, so the unawaited disposal stalls at the same wait. + // This bypasses the deadlock for the outer catch but does not + // release the native player in the create-failure case. After a + // successful create, _creatingCompleter is completed at :590, so + // the detached disposal runs normally; errors from that detached + // future are caught and logged below rather than becoming uncaught + // async errors [F2r(d)]. + pendingController.value = null; + unawaited( + localController.dispose().catchError((Object disposeError) { + debugPrint( + '[VideoViewer] dispose() failed after load error: $disposeError', + ); + }), + ); + rethrow; } - controller.value = localController; } catch (loadError) { if (!disposed) error.value = loadError.toString(); } @@ -168,6 +225,15 @@ class MediaVideoViewerPage extends HookConsumerWidget { } unawaited(downloadSubscription.value?.cancel() ?? Future.value()); unawaited(downloadSink.value?.close() ?? Future.value()); + // Dispose whichever controller is reachable: a controller that has + // finished init+play and been published to controller.value, OR one + // that is still mid-init (registered in pendingController before the + // first await). Exactly one of these is non-null at any moment; + // clearing both refs prevents a double-dispose if initializeVideo() + // races with the teardown. + final activePending = pendingController.value; + pendingController.value = null; + if (activePending != null) unawaited(activePending.dispose()); final activeController = controller.value; if (activeController != null) unawaited(activeController.dispose()); unawaited(deleteVideoFile()); diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart index 7ff39fac53..fdb92f6020 100644 --- a/mobile/lib/features/channels/thread_detail_helpers.dart +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -173,3 +173,23 @@ class _ThreadTypingIndicator extends StatelessWidget { ); } } + +/// Display evidence while the authoritative thread query has no value (still +/// loading, or failed on first load): the route snapshot plus live, cached +/// and optimistic replies. The page keeps the query's loading/error status, +/// so this list is never presented as complete, and nothing is re-queried. +List _provisionalThreadMessages( + List routeSnapshot, + List observed, + List liveChannelEvents, +) { + final byId = { + for (final message in routeSnapshot) + if (!_isDeletedBy(liveChannelEvents, message.id)) message.id: message, + for (final message in observed) message.id: message, + }; + return byId.values.toList()..sort((a, b) { + final order = a.createdAt.compareTo(b.createdAt); + return order != 0 ? order : a.id.compareTo(b.id); + }); +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 0bb4cedb34..56d7015b0f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/mentions/agent_identity_provider.dart'; @@ -148,8 +149,13 @@ class ThreadDetailPage extends HookConsumerWidget { liveChannelEvents, threadHead.id, ); - final allMsgs = fetchedReplies == null - ? allMessages + final allMsgs = fetchedReplies == null || !relayRepliesAvailable + ? _provisionalThreadMessages( + allMessages, + fetchedReplies ?? + formatTimeline(liveChannelEvents, currentPubkey: currentPubkey), + liveChannelEvents, + ) : [ // Only fall back to the pushed-route snapshot when neither source // carries the head, and no live deletion has suppressed it. That @@ -894,6 +900,8 @@ class ThreadDetailPage extends HookConsumerWidget { bottomInset: timelineBottomInset, replies: replies, relayReplyState: relayReplyState, + onRetryReplies: () => + ref.invalidate(threadRepliesProvider(repliesArgs)), localSendAnimations: localSendAnimations, trackActiveScrollPosition: trackActiveScrollPosition, headIsDeleted: liveDeletionHidesHead, diff --git a/mobile/lib/features/channels/thread_detail_page/message_list.dart b/mobile/lib/features/channels/thread_detail_page/message_list.dart index a33aa9a4ee..28160ee88b 100644 --- a/mobile/lib/features/channels/thread_detail_page/message_list.dart +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -10,6 +10,7 @@ class _ThreadMessageList extends StatelessWidget { final double bottomInset; final List replies; final AsyncValue> relayReplyState; + final VoidCallback onRetryReplies; final Map localSendAnimations; final Widget Function(Widget child) trackActiveScrollPosition; final bool headIsDeleted; @@ -36,6 +37,7 @@ class _ThreadMessageList extends StatelessWidget { required this.bottomInset, required this.replies, required this.relayReplyState, + required this.onRetryReplies, required this.localSendAnimations, required this.trackActiveScrollPosition, required this.headIsDeleted, @@ -164,6 +166,17 @@ class _ThreadMessageList extends StatelessWidget { ), ), ), + if (relayReplyState.hasError && + !relayReplyState.isLoading) + IconButton( + key: const ValueKey('thread-replies-retry'), + onPressed: onRetryReplies, + tooltip: 'Retry', + icon: const Icon( + LucideIcons.refreshCcw, + size: 16, + ), + ), const SizedBox(width: Grid.xxs), Expanded( child: Divider( diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart index 13880427cf..352c1bc73f 100644 --- a/mobile/lib/features/forum/forum_posts_view.dart +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -5,9 +5,11 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/relay/relay_closed_policy.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/load_error_view.dart'; import '../../shared/widgets/bee_refresh_indicator.dart'; import '../channels/channel.dart'; import '../channels/compose_bar.dart'; @@ -39,10 +41,22 @@ class ForumPostsView extends HookConsumerWidget { final providerContainer = ProviderScope.containerOf(context, listen: false); final forumDelivery = ForumEventDelivery.capture(providerContainer); - // Periodic refresh (every 15s, matching desktop). + // Periodic refresh (every 15s, matching desktop). A settled deadline + // pauses polling; reopening the forum is the explicit retry. useEffect(() { + final provider = forumPostsProvider(channel.id); + // Only a deadline already settled when this surface mounts is a + // reopen; a first load that fails after mounting is not retried. + // Hooks run effects during build, so defer the invalidation past it. + if (isSettledRelayDeadline(ref.read(provider))) { + Future.microtask(() { + if (context.mounted) ref.invalidate(provider); + }); + } final timer = Stream.periodic(const Duration(seconds: 15)).listen((_) { - ref.invalidate(forumPostsProvider(channel.id)); + if (!isSettledRelayDeadline(ref.read(provider))) { + ref.invalidate(provider); + } }); return timer.cancel; }, [channel.id]); @@ -65,6 +79,8 @@ class ForumPostsView extends HookConsumerWidget { ) : null, body: postsAsync.when( + // A retry from an error shows loading, not the stale error and its Retry. + skipLoadingOnRefresh: !postsAsync.hasError, loading: () => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), child: const Center( @@ -76,13 +92,9 @@ class ForumPostsView extends HookConsumerWidget { ), error: (e, _) => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), - child: Center( - child: Text( - 'Failed to load posts', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, - ), - ), + child: LoadErrorView( + message: 'Failed to load posts', + onRetry: () => ref.invalidate(forumPostsProvider(channel.id)), ), ), data: (response) { diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 298630b42c..8b802bac4a 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -7,11 +7,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/relay/relay_closed_policy.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/load_error_view.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../channels/compose_bar.dart'; import '../channels/message_content.dart'; @@ -45,12 +47,25 @@ class ForumThreadPage extends HookConsumerWidget { forumThreadProvider((channelId: channelId, eventId: postEventId)), ); - // Periodic refresh (every 10s, matching desktop). + // Periodic refresh (every 10s, matching desktop). A settled deadline + // pauses polling; reopening the thread is the explicit retry. useEffect(() { + final provider = forumThreadProvider(( + channelId: channelId, + eventId: postEventId, + )); + // Only a deadline already settled when this surface mounts is a + // reopen; a first load that fails after mounting is not retried. + // Hooks run effects during build, so defer the invalidation past it. + if (isSettledRelayDeadline(ref.read(provider))) { + Future.microtask(() { + if (context.mounted) ref.invalidate(provider); + }); + } final timer = Stream.periodic(const Duration(seconds: 10)).listen((_) { - ref.invalidate( - forumThreadProvider((channelId: channelId, eventId: postEventId)), - ); + if (!isSettledRelayDeadline(ref.read(provider))) { + ref.invalidate(provider); + } }); return timer.cancel; }, [channelId, postEventId]); @@ -79,6 +94,8 @@ class ForumThreadPage extends HookConsumerWidget { ], ), body: threadAsync.when( + // A retry from an error shows loading, not the stale error and its Retry. + skipLoadingOnRefresh: !threadAsync.hasError, loading: () => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), child: const Center( @@ -90,12 +107,10 @@ class ForumThreadPage extends HookConsumerWidget { ), error: (e, _) => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), - child: Center( - child: Text( - 'Failed to load thread', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, - ), + child: LoadErrorView( + message: 'Failed to load thread', + onRetry: () => ref.invalidate( + forumThreadProvider((channelId: channelId, eventId: postEventId)), ), ), ), diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 5b40be40d3..6c1aa734df 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -6,6 +6,7 @@ import 'app.dart'; import 'features/age_gate/age_signal_push_bootstrap.dart'; import 'features/invites/invite_join_provider.dart'; import 'shared/push/push_bridge.dart'; +import 'shared/relay/relay_closed_policy.dart'; import 'shared/theme/theme_provider.dart'; void main() => runBuzzApp(const App()); @@ -18,16 +19,26 @@ Future runBuzzApp(Widget app) async { // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); - runApp( - ProviderScope( - overrides: [ - savedPrefsProvider.overrideWithValue(prefs), - inviteJoinRecoveryProvider.overrideWith( - (ref) => - (scope) => buildMobileInviteJoinRecovery(ref, scope), - ), - ], - child: AgeSignalPushBootstrap(child: app), - ), - ); + runApp(buildRootProviderScope(prefs: prefs, child: app)); } + +/// The app's root scope, shared with tests so its production wiring is +/// asserted rather than re-declared. +ProviderScope buildRootProviderScope({ + required SharedPreferences prefs, + required Widget child, +}) => ProviderScope( + // A relay statement deadline cannot resolve on retry — the same expensive + // query would re-run and hit the same limit. Return null (no retry) for + // deadline errors and fall through to the default exponential backoff for + // everything else. + retry: relayProviderRetry, + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + inviteJoinRecoveryProvider.overrideWith( + (ref) => + (scope) => buildMobileInviteJoinRecovery(ref, scope), + ), + ], + child: AgeSignalPushBootstrap(child: child), +); diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index b21eeca36d..8611ce5b56 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -7,10 +7,12 @@ import 'package:nostr/nostr.dart' as nostr; import 'relay_provider.dart'; const _mediaGetAuthKind = 24242; -const _mediaGetAuthLifetimeSeconds = 600; +const _mediaGetAuthLifetimeSeconds = 60; /// Re-sign this long before the cached auth event expires, so an in-flight /// request signed just before the boundary still lands well within validity. +/// With a 60-second lifetime, the margin equals the lifetime: each request +/// mints a fresh token (mint-per-request pattern for NIP-FI compliance). const _mediaGetAuthRefreshMarginSeconds = 60; /// Builds BUD-01 Blossom `t=get` auth headers for relay-host media URLs. @@ -19,11 +21,13 @@ const _mediaGetAuthRefreshMarginSeconds = 60; /// so callers can safely use this on arbitrary profile/custom-emoji URLs without /// leaking Buzz credentials to third-party hosts. /// -/// The signed header is memoized until [_mediaGetAuthRefreshMarginSeconds] -/// before expiry: repeated calls return the byte-identical map instead of -/// producing a fresh Schnorr signature per widget build. The service itself is -/// rebuilt (dropping the memo) whenever the relay config — base URL or signing -/// identity — changes, via [mediaGetAuthServiceProvider]. +/// With a 60-second proof lifetime the refresh margin equals the lifetime, so +/// `_refreshAt == signedAt` and the cache never hits: every call mints a fresh +/// proof (mint-per-request pattern). This is intentional for NIP-FI compliance +/// — caching a 60-second token is staleness-prone and provides no meaningful +/// reduction in signing work. The service itself is rebuilt whenever the relay +/// config — base URL or signing identity — changes, via +/// [mediaGetAuthServiceProvider]. class MediaGetAuthService { final String _baseUrl; final String? _nsec; diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 4f79080efd..21f5de4df2 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -33,7 +33,7 @@ const _requiresLegacyMediaStoragePermissionMethod = const _readClipboardImageMethod = 'readClipboardImage'; const _clipboardHasImageMethod = 'clipboardHasImage'; const _uploadAuthKind = 24242; -const _uploadAuthLifetimeSeconds = 300; +const _uploadAuthLifetimeSeconds = 60; const _heicBrands = { 'heic', 'heix', @@ -688,12 +688,17 @@ class MediaUploadService { final expiration = (_now().millisecondsSinceEpoch ~/ 1000) + _uploadAuthLifetimeSeconds; + final serverAuthority = extractServerAuthority(_baseUrl); + if (serverAuthority == null) { + throw Exception( + 'Cannot mint upload auth: no server authority in relay URL: $_baseUrl', + ); + } final tags = >[ ['t', 'upload'], ['x', sha256], ['expiration', '$expiration'], - if (extractServerAuthority(_baseUrl) case final authority?) - ['server', authority], + ['server', serverAuthority], ]; return nostr.Event.from( diff --git a/mobile/lib/shared/relay/relay_closed_policy.dart b/mobile/lib/shared/relay/relay_closed_policy.dart index d39084b189..4013470c7c 100644 --- a/mobile/lib/shared/relay/relay_closed_policy.dart +++ b/mobile/lib/shared/relay/relay_closed_policy.dart @@ -1,3 +1,50 @@ +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'relay_client.dart'; + +// Shared constant so [isRelayDeadlineError] and [classifyRelayClosed] cannot +// drift apart. +const _queryTimedOutPrefix = 'error: query timed out'; + +/// Returns true when [error] is a relay server-side statement deadline that +/// cannot be resolved by retrying the same query. +/// +/// Two surfaces carry the same deadline signal: +/// - HTTP `/query` or `/count`: [RelayException] with status 503 and a JSON +/// body containing `"error": "query timed out"`. +/// - WebSocket CLOSED: a plain [Exception] whose message is +/// `"error: query timed out"` (the relay's stable CLOSED reason string). +/// +/// Both surfaces are checked so the caller (e.g. a Riverpod retry hook) does +/// not need to distinguish which transport produced the error. +bool isRelayDeadlineError(Object error) { + if (error is RelayException && error.statusCode == 503) { + try { + final decoded = jsonDecode(error.body); + if (decoded is Map) { + final message = decoded['error']; + if (message is String && + message.trim().toLowerCase() == 'query timed out') { + return true; + } + } + } on FormatException { + // Non-JSON 503 — not a deadline. + } + return false; + } + // WebSocket history CLOSED: Exception("error: query timed out") + // Strip the "Exception: " prefix Flutter adds to Exception.toString(), then + // check via classifyRelayClosed so this path shares the same source of truth. + var message = error.toString().trim().toLowerCase(); + const prefix = 'exception: '; + if (message.startsWith(prefix)) message = message.substring(prefix.length); + return classifyRelayClosed(message) == RelayClosedClass.terminal && + message.startsWith(_queryTimedOutPrefix); +} + /// Recovery policy for a relay `CLOSED` subscription message. enum RelayClosedClass { /// A transient failure that may recover when the same REQ is retried. @@ -24,7 +71,10 @@ RelayClosedClass classifyRelayClosed(String message) { normalized.startsWith('duplicate:') || normalized.startsWith('unsupported:') || normalized.startsWith('error: mixed search') || - normalized.startsWith('error: too many subscriptions')) { + normalized.startsWith('error: too many subscriptions') || + // Server statement deadline: re-sending the same REQ re-runs the same + // slow query. + normalized.startsWith(_queryTimedOutPrefix)) { return RelayClosedClass.terminal; } return RelayClosedClass.retryable; @@ -37,3 +87,25 @@ int? parseRateLimitRetrySeconds(String message) { final match = _rateLimitRetryPattern.firstMatch(message); return match == null ? null : int.tryParse(match.group(1)!); } + +/// Whether [value] has settled on a relay deadline. +/// +/// The forum periodic poll timers pause while a query is in this state; +/// reopening the view is the explicit retry. Other refresh paths (reconnect, +/// resume, live events, unread backstop) still re-send, each bounded by the +/// relay's 20s statement deadline. +bool isSettledRelayDeadline(AsyncValue value) => + value.hasError && !value.isLoading && isRelayDeadlineError(value.error!); + +/// Production Riverpod retry policy for relay providers. +/// +/// A relay statement deadline cannot resolve on retry — the same expensive +/// query would re-run and hit the same limit. Returns `null` (no retry) for +/// deadline errors and falls through to [ProviderContainer.defaultRetry] for +/// everything else. +/// +/// Pass this to [ProviderScope.retry] at the root of the widget tree. +Duration? relayProviderRetry(int retryCount, Object error) { + if (isRelayDeadlineError(error)) return null; + return ProviderContainer.defaultRetry(retryCount, error); +} diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 51fb8bf51d..ece4b9266b 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -730,7 +730,12 @@ class RelaySessionNotifier extends Notifier { final liveSub = _liveSubscriptions[subId]; if (liveSub == null) return; final readyCompleter = liveSub.readyCompleter; - if (closedClass == RelayClosedClass.terminal) { + // A deadline is terminal for one-shot history, but a live subscription + // retries it: same filter under backoff, which can keep retrying at the + // cap. `since` is not advanced, so events missed while closed aren't + // skipped. + if (closedClass == RelayClosedClass.terminal && + !isRelayDeadlineError(message)) { if (readyCompleter != null && !readyCompleter.isCompleted) { readyCompleter.completeError(Exception(message)); } diff --git a/mobile/lib/shared/widgets/load_error_view.dart b/mobile/lib/shared/widgets/load_error_view.dart new file mode 100644 index 0000000000..50952e3cb8 --- /dev/null +++ b/mobile/lib/shared/widgets/load_error_view.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../theme/theme.dart'; + +/// Settled load failure with an explicit Retry. After a relay deadline, +/// Riverpod does not auto-retry, so this is the user's way to re-send. +class LoadErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const LoadErrorView({ + super.key, + required this.message, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.triangleAlert, + size: Grid.lg, + color: context.colors.error, + ), + const SizedBox(height: Grid.xxs), + Text( + message, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xs), + FilledButton.icon( + key: const ValueKey('load-error-retry'), + onPressed: onRetry, + icon: const Icon(LucideIcons.refreshCcw, size: 16), + label: const Text('Retry'), + ), + ], + ), + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 76e9e9d6d4..023e9666fa 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1089,7 +1089,7 @@ packages: source: hosted version: "2.2.1" path_provider_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: path_provider_platform_interface sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" @@ -1129,7 +1129,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" @@ -1654,7 +1654,7 @@ packages: source: hosted version: "2.9.4" video_player_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: video_player_platform_interface sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f4f0bcac7a..5e53e37e98 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -59,6 +59,10 @@ dev_dependencies: custom_lint: ^0.8.0 riverpod_lint: ^3.1.0 mocktail: ^1.0.4 + # Test-only platform interface fakes (depend_on_referenced_packages). + path_provider_platform_interface: ^2.1.2 + plugin_platform_interface: ^2.1.8 + video_player_platform_interface: ^6.6.0 flutter: uses-material-design: true diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 20251f2195..4feaa96732 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -10187,6 +10187,62 @@ void main() { }); group('Error and loading states', () { + Widget errorScope(ChannelMessagesNotifier notifier) => ProviderScope( + overrides: [ + channelMessagesProvider(_channelId).overrideWith(() => notifier), + channelTypingProvider( + _channelId, + ).overrideWith(() => _FakeTypingNotifier([])), + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier([_testChannel]), + ), + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'http://localhost:3000'), + ), + savedPrefsProvider.overrideWithValue(_testPrefs), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: ChannelDetailPage(channel: _testChannel), + ), + ); + final retry = find.byKey(const ValueKey('load-error-retry')); + + for (final (name, error) in [ + ( + 'deadline', + RelayException(503, '{"error":"query timed out"}') as Object, + ), + ('ordinary error', Exception('bridge down') as Object), + ]) { + testWidgets('Retry after a $name loads once', (tester) async { + final notifier = _RetryCountingMessagesNotifier( + () => AsyncError(error, StackTrace.current), + ); + await tester.pumpWidget(errorScope(notifier)); + await tester.pumpAndSettle(); + expect(find.text('Failed to load messages'), findsOneWidget); + expect(notifier.loads, 1); + final before = notifier.loads; + notifier.nextResult = () => const AsyncData([]); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(notifier.loads, before + 1); + expect(retry, findsNothing); + }); + } + + testWidgets('no Retry while messages load', (tester) async { + final notifier = _RetryCountingMessagesNotifier( + () => const AsyncLoading(), + ); + await tester.pumpWidget(errorScope(notifier)); + await tester.pump(); + expect(retry, findsNothing); + expect(find.text('Failed to load messages'), findsNothing); + }); + testWidgets('shows error message on failure', (tester) async { await tester.pumpWidget( ProviderScope( @@ -10891,6 +10947,225 @@ void main() { expect(highlightedDecoration.color!.a, lessThan(0.12)); }); + group('after a first-load deadline', () { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final mid = _textMsg( + id: 'mid', + pubkey: 'bob', + content: 'Nested parent', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + + Future<(_FakeMessagesNotifier, int Function())> openDeadlined( + WidgetTester tester, + NostrEvent head, + ) async { + var attempts = 0; + final messages = _FakeMessagesNotifier([root, mid]); + final timeline = formatTimeline([root, mid]); + await tester.pumpWidget( + _buildTestable( + messages: [root, mid], + messagesNotifier: messages, + disableRetries: true, + threadReplyLoaders: { + 'root': () { + attempts++; + return Future.error( + RelayException(503, '{"error":"query timed out"}'), + ); + }, + }, + home: ThreadDetailPage( + threadHead: timeline.firstWhere((m) => m.id == head.id), + allMessages: timeline, + channelId: _testChannel.id, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + expect(attempts, 1); + return (messages, () => attempts); + } + + NostrEvent liveReply(String id, List> tags) => _textMsg( + id: id, + pubkey: 'carol', + content: 'Live $id', + createdAt: 1200, + extraTags: tags, + ); + + testWidgets('shows an incoming direct reply', (tester) async { + final (messages, attempts) = await openDeadlined(tester, root); + messages.setMessages([ + root, + mid, + liveReply('direct', const [ + ['e', 'root', '', 'reply'], + ]), + ]); + await tester.pumpAndSettle(); + expect(find.text('Live direct'), findsOneWidget); + expect(find.textContaining('Couldn’t refresh'), findsOneWidget); + expect(attempts(), 1); + }); + + for (final provisional in [false, true]) { + testWidgets( + 'Retry ${provisional ? 'beside provisional replies' : 'in the empty state'} ' + 'loads once', + (tester) async { + // The empty variant has no reply at all, cached or provisional. + final seed = provisional ? [root, mid] : [root]; + final timeline = formatTimeline(seed); + var fail = true; + var loads = 0; + final messages = _FakeMessagesNotifier(seed); + await tester.pumpWidget( + _buildTestable( + messages: seed, + messagesNotifier: messages, + disableRetries: true, + threadReplyLoaders: { + 'root': () { + loads++; + if (!fail) return Future.value(const []); + return Future.error( + RelayException(503, '{"error":"query timed out"}'), + ); + }, + }, + home: ThreadDetailPage( + threadHead: timeline.firstWhere((m) => m.id == 'root'), + allMessages: timeline, + channelId: _testChannel.id, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + final retry = find.byKey(const ValueKey('thread-replies-retry')); + if (provisional) { + messages.setMessages([ + root, + mid, + liveReply('direct', const [ + ['e', 'root', '', 'reply'], + ]), + ]); + await tester.pumpAndSettle(); + expect(find.text('Live direct'), findsOneWidget); + } else { + expect(find.text('Couldn’t load replies'), findsOneWidget); + } + expect(retry, findsOneWidget); + final target = tester.getSize(retry); + expect( + target.width, + greaterThanOrEqualTo(kMinInteractiveDimension), + ); + expect( + target.height, + greaterThanOrEqualTo(kMinInteractiveDimension), + ); + final before = loads; + fail = false; + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(loads, before + 1); + expect(retry, findsNothing); + }, + ); + } + + testWidgets('no replies Retry while the scan loads', (tester) async { + final timeline = formatTimeline([root, mid]); + await tester.pumpWidget( + _buildTestable( + messages: [root, mid], + threadReplyLoaders: { + 'root': () => Completer>().future, + }, + home: ThreadDetailPage( + threadHead: timeline.firstWhere((m) => m.id == 'root'), + allMessages: timeline, + channelId: _testChannel.id, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(find.textContaining('Couldn’t'), findsNothing); + expect( + find.byKey(const ValueKey('thread-replies-retry')), + findsNothing, + ); + }); + + testWidgets('shows an incoming nested reply', (tester) async { + final (messages, attempts) = await openDeadlined(tester, mid); + messages.setMessages([ + root, + mid, + liveReply('nested', const [ + ['e', 'root', '', 'root'], + ['e', 'mid', '', 'reply'], + ]), + ]); + await tester.pumpAndSettle(); + expect(find.text('Live nested'), findsOneWidget); + expect(attempts(), 1); + }); + + testWidgets('keeps a sent reply visible after acceptance', ( + tester, + ) async { + final (messages, attempts) = await openDeadlined(tester, root); + final sent = _textMsg( + id: 'sent', + pubkey: 'me', + content: 'My reply', + createdAt: 1300, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(ThreadDetailPage)), + ); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.read(threadLocalRepliesProvider(args).notifier).add(sent); + await tester.pumpAndSettle(); + expect(find.text('My reply'), findsOneWidget); + // Acceptance: the confirmed reply moves from the optimistic overlay + // into the channel cache (cacheConfirmedThreadReplies). + messages.setMessages([root, mid, sent]); + container.read(threadLocalRepliesProvider(args).notifier).confirm({ + 'sent', + }); + await tester.pumpAndSettle(); + expect(find.text('My reply'), findsOneWidget); + expect(attempts(), 1); + }); + }); + testWidgets('opens a nested reply in its direct-parent thread', ( tester, ) async { @@ -14650,6 +14925,23 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier { } } +/// Counts loads; each settles after mount, like the real network load. +class _RetryCountingMessagesNotifier extends ChannelMessagesNotifier { + _RetryCountingMessagesNotifier(this.nextResult) : super(_channelId); + + AsyncValue> Function() nextResult; + int loads = 0; + + @override + AsyncValue> build() { + loads++; + final result = nextResult(); + if (result is AsyncLoading) return result; + Future(() => state = result); + return const AsyncLoading(); + } +} + class _ErrorMessagesNotifier extends ChannelMessagesNotifier { _ErrorMessagesNotifier() : super(_channelId); diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index 729bf45fc7..bc6f1dab1d 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -312,6 +312,57 @@ void main() { ); }); + group('relay deadline owners', () { + RelayException deadline() => + RelayException(503, '{"error":"query timed out"}'); + int threadScans(_RecordingRelaySessionNotifier session) => session + .queryFilters + .where((filter) => filter.extensions.containsKey('depth_limit')) + .length; + NostrEvent reply(int i) => _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + + test('a window deadline does not fall back to websocket history', () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [deadline()], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await session.subscribed; + await _pumpEventQueue(); + expect(session.operations, ['subscribe', 'query']); + expect( + container.read(channelMessagesProvider(_channelId)).hasError, + true, + ); + }); + + test('a recount deadline ends its retry backoff', () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + deadline(), + deadline(), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + for (var i = 0; i < 300; i++) { + session.emit(reply(i)); + } + await Future.delayed(const Duration(milliseconds: 2000)); + expect(threadScans(session), 1); + }); + }); + test('still loads history when live subscription fails', () async { final relaySession = _RecordingRelaySessionNotifier(failSubscribe: true); final container = _buildContainer(relaySession); diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 3bb1be5beb..e277a4dfb3 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1632,6 +1632,168 @@ void main() { expect(channels.single.lastMessageAt?.millisecondsSinceEpoch, 20 * 1000); }); + group('latest-message batch failure', () { + Future> fallbackFilters(Object error) async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + )..messageBatchError = error; + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.queryBatches.isNotEmpty); + await Future.delayed(Duration.zero); + return session.historyFilters + .where( + (filter) => + filter.kinds.contains(EventKind.streamMessageV2) && + (filter.tags['#h']?.contains(_channelA) ?? false), + ) + .toList(); + } + + test('a relay deadline never reaches the websocket fallback', () async { + expect( + await fallbackFilters( + RelayException(503, '{"error":"query timed out"}'), + ), + isEmpty, + ); + }); + + test('an ordinary failure still uses the websocket fallback', () async { + expect(await fallbackFilters(Exception('bridge down')), isNotEmpty); + }); + }); + + group('deadline-unavailable channel batches', () { + Object deadline() => RelayException(503, '{"error":"query timed out"}'); + bool isUnread(List batch) => + batch.isNotEmpty && batch.every((filter) => filter.since != null); + NostrEvent message(String id, int createdAt, {bool mention = false}) => + NostrEvent( + id: id, + pubkey: 'alice', + createdAt: createdAt, + kind: EventKind.streamMessageV2, + tags: [ + const ['h', _channelA], + if (mention) const ['p', myPk], + ], + content: 'hi', + sig: 'sig', + ); + + test('a latest-message deadline keeps known timestamps', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + recentMessages: [message('m1', 30)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _settle(); + session.latestBatchError = deadline(); + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + expect( + container.read(channelsProvider).value!.single.lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(30 * 1000, isUtc: true), + ); + expect(session.messageHistoryCount, 0); + }); + + test( + 'a cold-start latest-message deadline is filled by unread catch-up', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + recentMessages: [message('mention', 30, mention: true)], + )..latestBatchError = deadline(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.queryBatches.any(isUnread)); + await _settle(); + final notifier = container.read(channelsProvider.notifier); + expect(notifier.observedUnreadEventsByChannel[_channelA]?.keys, [ + 'mention', + ]); + expect( + container.read(channelsProvider).value!.single.lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(30 * 1000, isUtc: true), + ); + expect(session.messageHistoryCount, 0); + }, + ); + + test('unread catch-up never moves lastMessageAt backward', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + recentMessages: [message('older', 30, mention: true)], + )..pauseNextUnreadCatchUpQuery(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await session.nextUnreadCatchUpQueryStarted; + // A newer live event lands while the catch-up is in flight. + session.emit(message('newer', 60, mention: true)); + session.resumePausedUnreadCatchUpQuery(); + await _settle(); + expect( + container + .read(channelsProvider.notifier) + .observedUnreadEventsByChannel[_channelA] + ?.keys, + containsAll(['older', 'newer']), + ); + expect( + container.read(channelsProvider).value!.single.lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(60 * 1000, isUtc: true), + ); + }); + + // HTTP fails ordinarily, then a per-filter websocket fallback times out: + // the batch is unavailable and no later chunk is sent. + test('a websocket fallback deadline makes the batch unavailable', () async { + final ids = [for (var i = 0; i < 6; i++) 'chunk-channel-$i']; + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + for (final id in ids) _membership(id, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + for (final id in ids) _meta(id: id, name: id), + ], + recentMessages: [message('m1', 30)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _settle(); + general() => container + .read(channelsProvider) + .value! + .firstWhere((channel) => channel.id == _channelA); + final lastMessageAt = general().lastMessageAt; + expect(lastMessageAt, isNotNull); + session + ..messageBatchError = Exception('bridge down') + ..messageHistoryError = deadline(); + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + // Unavailable, not empty: known timestamps survive. + expect(general().lastMessageAt, lastMessageAt); + // Each batch stops after its first chunk of four filters. + expect(session.messageHistoryCount % 4, 0); + expect(session.messageHistoryCount, lessThan(2 * ids.length)); + }); + }); + test( 'loads all channel timestamps through one batched relay query', () async { @@ -2261,6 +2423,11 @@ class _FakeRelaySession extends RelaySessionNotifier { final List historyFilters = []; final List> queryBatches = []; + Object? messageBatchError; + Object? latestBatchError; + Object? messageHistoryError; + int messageHistoryCount = 0; + final List directoryQueryFilters = []; final List membershipQueryFilters = []; final List subscribeFilters = []; @@ -2536,6 +2703,9 @@ class _FakeRelaySession extends RelaySessionNotifier { // Member metadata query — return only matching `d` tags. return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList(); } + // Per-filter websocket fallback of a message batch. + messageHistoryCount++; + if (messageHistoryError case final error?) throw error; return const []; } @@ -2614,11 +2784,13 @@ class _FakeRelaySession extends RelaySessionNotifier { return filter.until == null ? directorySnapshot : const []; } queryBatches.add(filters); + if (messageBatchError case final error?) throw error; // The unread catch-up is the only batch that carries `since` on every // filter; the latest-message batch leaves it null. Snapshot the messages at // request time so a parked response reflects the scope that asked for it. final isUnreadCatchUp = filters.isNotEmpty && filters.every((filter) => filter.since != null); + if (!isUnreadCatchUp && latestBatchError != null) throw latestBatchError!; final messageSnapshot = List.of(recentMessages); if (isUnreadCatchUp) { // Claim the parked slot so the refresh that follows the switch can run diff --git a/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart b/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart new file mode 100644 index 0000000000..b6f2a5e30f --- /dev/null +++ b/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart @@ -0,0 +1,920 @@ +// Regression tests for MediaVideoViewerPage lifecycle fixes: +// +// F2r(a): VideoPlayerController is disposed when native initialisation fails. +// Before the fix, a PlatformException from initialize() unwound to the +// outer catch with controller.value still null — the cleanup teardown's +// `if (activeController != null)` guard silently skipped disposal, +// leaking the native player and its event subscription. +// +// F2r(b): A non-2xx error-body is cancelled (listen+cancel) rather than +// drained. Before the fix, `response.stream.drain()` waited for the +// server to close the stream — a stalled error body (e.g. a 403 on +// a slow connection) could block initializeVideo indefinitely, and +// there was no abort handle to cancel it. +// +// F2r(c): A VideoPlayerController created but never initialized (pending) +// is disposed when the widget is unmounted. Before the fix, the +// controller lived only in the async function's stack frame; a close +// arriving while initialize() was awaiting the initialized event left +// the native player allocated forever. +// +// F2r(d): createWithOptions() failure shows the error UI instead of leaving +// the viewer in an infinite loading state. video_player 2.11.1 +// creates _creatingCompleter before awaiting createWithOptions() and +// completes it only AFTER the await returns. If creation throws, +// _creatingCompleter is never completed and dispose() deadlocks waiting +// on it. The fix uses unawaited(dispose()) in the catch so the outer +// catch runs immediately and sets error.value. +// +// Transport: AbortableStreamedRequest sink must be closed before send(). +// Without it, IOClient.send() awaits stream.pipe(ioRequest) which +// blocks until the sink is closed — every download hangs indefinitely +// with the real http.Client. Two probes: a local loopback server that +// reads the full request body before replying (fails if sink is not +// closed), and a MockClient-based fake that calls request.finalize() +// for unit coverage. + +import 'dart:async'; +import 'dart:io'; + +import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/shared/relay/media_auth.dart'; +import 'package:buzz/shared/relay/media_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +import '../../../helpers/widget_helpers.dart'; + +// ── Fakes ──────────────────────────────────────────────────────────────────── + +/// Minimal fake for path_provider so getTemporaryDirectory() works in tests. +/// Uses MockPlatformInterfaceMixin to bypass PlatformInterface.verify(). +class _FakePathProviderPlatform extends Fake + with MockPlatformInterfaceMixin + implements PathProviderPlatform { + @override + Future getTemporaryPath() async => + Directory.systemTemp.resolveSymbolicLinksSync(); +} + +/// Fake VideoPlayerPlatform that tracks `dispose` calls and optionally +/// forces native initialisation to fail with a PlatformException. +/// +/// Extends VideoPlayerPlatform directly (inheriting the platform token from the +/// super constructor) so PlatformInterface.verify() succeeds without needing +/// MockPlatformInterfaceMixin. +class _FakeVideoPlayerPlatform extends VideoPlayerPlatform { + final bool forceInitError; + final bool neverInitialize; + // When true, createWithOptions() itself throws a PlatformException. + // video_player 2.11.1 awaits createWithOptions() before completing + // _creatingCompleter (video_player.dart:587-590); if creation throws, + // _creatingCompleter is never completed and dispose() deadlocks waiting + // on it. This flag exercises the F2r(d) production fix. + final bool forceCreateError; + int disposeCallCount = 0; + int nextPlayerId = 0; + final Map> _streams = {}; + + _FakeVideoPlayerPlatform({ + this.forceInitError = false, + this.neverInitialize = false, + this.forceCreateError = false, + }); + + @override + Future init() async {} + + @override + Future createWithOptions(VideoCreationOptions options) async { + if (forceCreateError) { + throw PlatformException( + code: 'VideoError', + message: 'Fake native create failure', + ); + } + return create(options.dataSource); + } + + @override + Future create(DataSource dataSource) async { + final id = nextPlayerId++; + final controller = StreamController( + onListen: () { + // Emit the event/error only when the stream is first subscribed so that + // VideoPlayerController.initialize() is already listening. Emitting + // before the subscription means the event is dropped and initialize() + // hangs waiting for the initialized signal. + if (forceInitError) { + _streams[id]!.addError( + PlatformException( + code: 'VideoError', + message: 'Fake native init failure', + ), + ); + } else if (!neverInitialize) { + _streams[id]!.add( + VideoEvent( + eventType: VideoEventType.initialized, + size: const Size(100, 100), + duration: const Duration(seconds: 1), + ), + ); + } + // neverInitialize: no event emitted — initialize() hangs forever. + }, + ); + _streams[id] = controller; + return id; + } + + @override + Future dispose(int playerId) async { + disposeCallCount++; + // Record the dispose call and close the event stream. + // + // Note: an error injected here does NOT reach initialize()'s pending + // listener on the pinned video_player 2.11.1 path. dispose() cancels + // _eventSubscription (:687) before calling _videoPlayerPlatform.dispose() + // (:688), so any event emitted here goes to a closed listener. This fake + // records the disposal call and closes its stream; it does not settle the + // pending initialize() future. + final stream = _streams[playerId]; + if (stream != null) { + if (!stream.isClosed) { + stream.addError( + StateError('VideoPlayerController disposed before initialization'), + ); + } + await stream.close(); + } + } + + /// Return a minimal stand-in widget. VideoPlayerPlatform.buildViewWithOptions + /// delegates to this; without it every test that successfully initializes a + /// player throws UnimplementedError when the VideoPlayer widget renders. + @override + Widget buildView(int playerId) => const SizedBox.shrink(); + + @override + Stream videoEventsFor(int playerId) => _streams[playerId]!.stream; + + @override + Future play(int playerId) async {} + + @override + Future pause(int playerId) async {} + + @override + Future setLooping(int playerId, bool looping) async {} + + @override + Future setVolume(int playerId, double volume) async {} + + @override + Future seekTo(int playerId, Duration position) async {} + + @override + Future setPlaybackSpeed(int playerId, double speed) async {} + + @override + Future getPosition(int playerId) async => Duration.zero; + + @override + Future setMixWithOthers(bool mixWithOthers) async {} +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// A [VideoPlayerPlatform] that creates successfully (returns a real player ID +/// and emits a forceInitError event to trigger an init/play failure), but whose +/// [dispose()] throws a [PlatformException]. +/// +/// Used to verify that the viewer's `unawaited(dispose().catchError(...))` path +/// does NOT surface an uncaught async error when disposal fails after a +/// post-create load failure. +class _FailingDisposeVideoPlayerPlatform extends VideoPlayerPlatform { + int disposeCallCount = 0; + int nextPlayerId = 0; + final Map> _streams = {}; + + /// Completed when dispose() is first entered. Awaited inside + /// [WidgetTester.runAsync] with a bounded real-zone timeout; timers created + /// in that zone fire normally, unlike FakeAsync timers outside runAsync. + final Completer disposedCompleter = Completer(); + + @override + Future init() async {} + + @override + Future createWithOptions(VideoCreationOptions options) async { + return create(options.dataSource); + } + + @override + Future create(DataSource dataSource) async { + final id = nextPlayerId++; + final controller = StreamController( + onListen: () { + // Emit a PlatformException so initialize() throws, reaching the inner catch. + _streams[id]!.addError( + PlatformException( + code: 'VideoError', + message: 'Fake post-create init failure', + ), + ); + }, + ); + _streams[id] = controller; + return id; + } + + @override + Future dispose(int playerId) async { + disposeCallCount++; + if (!disposedCompleter.isCompleted) disposedCompleter.complete(); + // Throw to simulate a native disposal failure. + // The production catch(.catchError) must absorb this without propagating + // an uncaught async error while the viewer is showing its error UI. + throw PlatformException( + code: 'DisposalError', + message: 'Fake native disposal failure', + ); + } + + @override + Widget buildView(int playerId) => const SizedBox.shrink(); + + @override + Stream videoEventsFor(int playerId) => _streams[playerId]!.stream; + + @override + Future play(int playerId) async {} + + @override + Future pause(int playerId) async {} + + @override + Future setLooping(int playerId, bool looping) async {} + + @override + Future setVolume(int playerId, double volume) async {} + + @override + Future seekTo(int playerId, Duration position) async {} + + @override + Future setPlaybackSpeed(int playerId, double speed) async {} + + @override + Future getPosition(int playerId) async => Duration.zero; + + @override + Future setMixWithOthers(bool mixWithOthers) async {} +} + +/// Returns a [http.StreamedResponse] with the given [statusCode] whose body +/// stream is controlled by [bodyController]. The caller closes [bodyController] +/// to release a drain; leaving it open proves that the fix (listen+cancel) +/// completes without waiting for the stream to close. +http.StreamedResponse _streamedResponse( + int statusCode, + StreamController> bodyController, +) => http.StreamedResponse(bodyController.stream, statusCode); + +/// A no-op auth service (returns empty headers for any URL, including +/// non-relay URLs so the test media URL does not need a signed nsec). +MediaGetAuthService _noopAuth() => + MediaGetAuthService(baseUrl: 'https://relay.test', nsec: null); + +/// A fake [http.Client] that calls [request.finalize()] and drains the +/// request body before returning a response. If the request sink is not +/// closed, finalize() returns an open stream and the drain hangs — which is +/// exactly what the sink-fix prevents. +class _FinalizingFakeClient extends http.BaseClient { + final http.StreamedResponse Function() responseBuilder; + bool requestBodyDrained = false; + + _FinalizingFakeClient({required this.responseBuilder}); + + @override + Future send(http.BaseRequest request) async { + // Drain the finalized request body. If sink.close() was not called this + // stream never ends and the test times out — verifying the transport fix. + await request.finalize().drain(); + requestBodyDrained = true; + return responseBuilder(); + } +} + +/// A fake [http.Client] for viewer-path abort tests. +/// +/// `send()` signals arrival immediately, then suspends until the request's +/// `abortTrigger` completes. When the viewer's effect cleanup fires +/// `downloadRequestAbort.complete()`, that trigger arrives here and +/// `send()` throws [RequestAbortedException] — proving that unmounting +/// the widget closes the in-flight download through the actual viewer +/// abort-wiring path. +/// +/// The fake requires an [http.AbortableStreamedRequest] with a non-null +/// trigger. If the viewer's wiring is absent, `send()` throws [StateError], +/// which the viewer catches at its outer `catch (loadError)` boundary. The +/// test then fails at the [abortObservedCompleter] deadline because the abort +/// is never observed — not immediately, but after the deadline expires. +/// +/// No drain: this fake's only job is the abort-trigger chain. Sink-close +/// correctness is covered separately by [_FinalizingFakeClient]. +class _StallingAbortableClient extends http.BaseClient { + final Completer requestArrivedCompleter = Completer(); + + /// Completed when `abortObserved` is set; use with a deadline instead of sleeping. + final Completer abortObservedCompleter = Completer(); + bool abortObserved = false; + + @override + Future send(http.BaseRequest request) async { + if (!requestArrivedCompleter.isCompleted) { + requestArrivedCompleter.complete(); + } + // Require an abortable request with a non-null trigger. If the viewer's + // AbortableStreamedRequest wiring or the abortTrigger: parameter is absent, + // fail immediately — do NOT treat missing wiring as a successful abort. + if (request case http.AbortableStreamedRequest(:final abortTrigger?)) { + await abortTrigger; + abortObserved = true; + if (!abortObservedCompleter.isCompleted) { + abortObservedCompleter.complete(); + } + throw http.RequestAbortedException(request.url); + } + throw StateError( + '_StallingAbortableClient: expected AbortableStreamedRequest with ' + 'non-null abortTrigger; got ${request.runtimeType}. ' + 'The viewer abort wiring is absent.', + ); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + PathProviderPlatform.instance = _FakePathProviderPlatform(); + }); + + // Transport: request sink must be closed before send(). + // + // Red-with-old-code: before the `unawaited(request.sink.close())` fix, + // _FinalizingFakeClient.send() drained an open stream and the test timed + // out. With the fix the drain completes immediately, the download succeeds, + // and the video controller initializes. + testWidgets( + 'Transport: request sink is closed before send() — fake drain probe', + (tester) async { + final fakePlayer = _FakeVideoPlayerPlatform(); + VideoPlayerPlatform.instance = fakePlayer; + + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => + http.StreamedResponse(Stream.value([0, 1, 2, 3]), 200), + ); + addTearDown(fakeClient.close); + + await tester.runAsync(() async { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + + expect( + fakeClient.requestBodyDrained, + isTrue, + reason: 'request sink must be closed so send() can finalize the body', + ); + }, + ); + + // Transport: request sink must be closed before send(). + // + // Real-IO loopback probes (TestWidgetsFlutterBinding intercepts HttpClient + // within this suite) live in video_viewer_transport_test.dart, which uses + // plain test() without TestWidgetsFlutterBinding. The fake drain probe + // below covers the same contract without the binding conflict. + // + // + // Red-with-old-code: before the fix the `localController` was created but + // only published to `controller.value` after a successful initialize()+play(). + // On error the outer `catch (loadError)` ran without ever calling + // `localController.dispose()`. With forceInitError=true the fake emits a + // PlatformException; `initialize()` throws; the new inner catch calls + // `dispose()` before rethrowing. disposeCallCount >= 1 verifies it. + testWidgets('F2r(a): VideoPlayerController is disposed when native init fails', ( + tester, + ) async { + final fakePlayer = _FakeVideoPlayerPlatform(forceInitError: true); + VideoPlayerPlatform.instance = fakePlayer; + + // 200-ok response with a tiny immediate body so the download phase + // completes and initializeVideo() reaches the VideoPlayerController.file() + // path. The _FinalizingFakeClient drains the request body, which proves + // the sink is closed (if not, the drain hangs and the test times out). + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => + http.StreamedResponse(Stream.value([0, 1, 2, 3]), 200), + ); + addTearDown(fakeClient.close); + + await tester.runAsync(() async { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + // Give the initializeVideo() async chain time to complete: HTTP response, + // file write, VideoPlayerController.initialize(), and dispose(). + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + + // The fake must have recorded at least one dispose() call, confirming + // the native player was released even on an initialisation failure. + expect( + fakePlayer.disposeCallCount, + greaterThanOrEqualTo(1), + reason: 'VideoPlayerController must be disposed when initialize() throws', + ); + }); + + // F2r(b): close-during-error-body must cancel the stream and show the + // error UI while the body is still open. + // + // Red-with-old-code: before the fix a 403 response body was consumed via + // `response.stream.drain()`, which suspends until the upstream closes + // the stream. With the fix, `_cancelVideoResponse(response)` subscribes and + // immediately cancels. + // + // Discriminating assertion: (1) the body stream's onCancel fires while the + // body is still open (never would with drain()), and (2) the error UI is + // visible while the body remains open. Restoring drain() breaks both. + testWidgets('F2r(b): non-2xx error body is cancelled, not drained', ( + tester, + ) async { + final fakePlayer = _FakeVideoPlayerPlatform(); + VideoPlayerPlatform.instance = fakePlayer; + + // Body stream that NEVER closes — simulates a slow/stalled server. + // drain() would block here indefinitely; _cancelVideoResponse completes + // immediately by subscribing and cancelling. + // + // The Completer fires as soon as the stream's onCancel callback runs, + // giving the test a bounded completion signal instead of a fixed sleep. + // It is the cancellation of the body (not settling) that proves the fix: + // the assertions below check that (a) the cancel fires while the body is + // still open, and (b) the error UI is visible at that moment. + final cancelledCompleter = Completer(); + var bodyStreamCancelled = false; + final stalledBody = StreamController>( + onCancel: () { + bodyStreamCancelled = true; + if (!cancelledCompleter.isCompleted) cancelledCompleter.complete(); + }, + ); + addTearDown(stalledBody.close); + + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => _streamedResponse(403, stalledBody), + ); + addTearDown(fakeClient.close); + + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + + // Wait for the body-cancellation signal rather than a fixed sleep. + // disableAnimations: true stops BuzzLoadingIndicator from repeating so + // pumpAndSettle converges once the error state is set. + // With drain(), cancelledCompleter never completes and this times out. + await tester.runAsync( + () => cancelledCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'body-cancellation signal not received within 5 s', + ), + ), + ); + await tester.pumpAndSettle(); + + // (1) The body stream's onCancel must have fired — confirming listen+cancel + // was used, not drain(). With drain(), onCancel fires only when the + // whole drain completes (which never happens here). + expect( + bodyStreamCancelled, + isTrue, + reason: + 'error-body stream must be cancelled (listen+cancel), not drained', + ); + + // (2) The error UI must be visible while the body stream is still open + // (stalledBody was never closed). _MediaLoadFailure shows this text + // when error.value is set — which only happens after _cancelVideoResponse + // completes and the HttpException propagates to the outer catch. + // With drain(), error.value is never set (drain hangs), so this + // assertion fails. + expect( + find.text('Failed to load video'), + findsOneWidget, + reason: 'error UI must be visible while the stalled body is still open', + ); + + // No controller is created before a 403 response. + expect( + fakePlayer.disposeCallCount, + 0, + reason: 'No controller is created before a 403 response', + ); + + // (3) Explicitly unmount the viewer and verify no disposal fires from the + // teardown either — no controller was ever created. + // Pass the same overrides so Riverpod's debug assertion + // (_debugOverridesLength == overrides.length) does not fire. + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const SizedBox.shrink(), + ), + ); + await tester.pumpAndSettle(); + expect( + fakePlayer.disposeCallCount, + 0, + reason: 'Unmounting after 403 must not dispose a non-existent controller', + ); + }); + + // F2r(c): VideoPlayerController created but never initialized must be + // disposed when the widget is unmounted. + // + // Scenario: download succeeds, VideoPlayerController.file() is constructed + // and registered in pendingController, but initialize() hangs forever (the + // fake never emits an initialized event). Unmounting fires the effect + // cleanup which must dispose the pending controller via pendingController. + // + // Red-with-old-code: before the pendingController ref, localController lived + // only in the async function's stack frame; the effect cleanup read only + // controller.value (null until init completes) and disposed nothing — the + // native player was leaked. + testWidgets( + 'F2r(c): controller created but never initialized is disposed on unmount', + (tester) async { + final fakePlayer = _FakeVideoPlayerPlatform(neverInitialize: true); + VideoPlayerPlatform.instance = fakePlayer; + + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => + http.StreamedResponse(Stream.value([0, 1, 2, 3]), 200), + ); + addTearDown(fakeClient.close); + + await tester.runAsync(() async { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + // Allow enough time for the download to complete and for + // VideoPlayerController.file() to be constructed, but NOT long + // enough for initialize() to complete (it never will). + await Future.delayed(const Duration(milliseconds: 300)); + + // Now unmount — this triggers the effect cleanup with the pending + // controller still in pendingController.value (never reached play()). + // Pass the same overrides so Riverpod's debug assertion + // (_debugOverridesLength == overrides.length) does not fire. + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const SizedBox.shrink(), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + + // The pending controller must have been disposed by the effect cleanup, + // even though initialize() never returned. + expect( + fakePlayer.disposeCallCount, + greaterThanOrEqualTo(1), + reason: + 'pendingController must be disposed on unmount even if init never completes', + ); + }, + ); + + // F2r(d): createWithOptions() failure must show the error UI, not leave + // the viewer in an infinite loading state. + // + // Scenario: the platform plugin's createWithOptions() itself throws a + // PlatformException before returning a player ID. video_player 2.11.1 + // creates _creatingCompleter at initialize():546 and completes it only on + // the line AFTER await createWithOptions() (:587-590). If creation throws, + // _creatingCompleter is never completed, and dispose() awaits it at :682-683. + // The old code `await localController.dispose()` in the catch block therefore + // deadlocks: the outer catch never runs, error.value is never set, and the + // viewer remains on the loading screen. + // + // Fix: `unawaited(localController.dispose().catchError(...))` in the catch + // rethrows immediately so the outer catch sets error.value and shows the UI. + // + // Red-with-old-code: the old `await localController.dispose()` never returns + // (dispose() waits on the uncompleted _creatingCompleter); the 300 ms window + // ends with error.value still unset and the error UI absent — the assertion + // fails. With `unawaited(dispose())` the outer catch runs immediately and + // sets error.value; the 300 ms window is enough for the fix to take effect. + // + // Note: create itself fails here, so _creatingCompleter is never completed + // and the unawaited dispose() stalls at the same wait — this fix bypasses + // the deadlock for the outer catch, but does not release the native player. + // No dispose count is claimed for this case. + testWidgets( + 'F2r(d): createWithOptions() failure shows error UI (not infinite spinner)', + (tester) async { + final fakePlayer = _FakeVideoPlayerPlatform(forceCreateError: true); + VideoPlayerPlatform.instance = fakePlayer; + + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => + http.StreamedResponse(Stream.value([0, 1, 2, 3]), 200), + ); + addTearDown(fakeClient.close); + + await tester.runAsync(() async { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + // Allow the download to complete, createWithOptions() to throw, and + // the outer catch to set error.value. With the old `await dispose()` + // the outer catch is blocked — `error.value` is never set and + // `find.text('Failed to load video')` fails. + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pumpAndSettle(); + + // The error UI must be visible: unawaited dispose + rethrow lets the + // outer catch set error.value and show _MediaLoadFailure. + // With the old `await dispose()` the viewer stalls and this fails. + expect( + find.text('Failed to load video'), + findsOneWidget, + reason: + 'createWithOptions() failure must show error UI, not infinite spinner', + ); + }, + ); + + // Detached disposal error handling: a successfully-created controller whose + // init fails and whose dispose() ALSO throws must not surface an uncaught + // async error. The unawaited(dispose().catchError(...)) path in the inner + // catch must absorb the disposal exception with logging. + // + // Red-with-old-code (before the .catchError addition): the detached future + // throws PlatformException unhandled; the Flutter test binding's own + // FlutterError.onError captures it and fails the test. With .catchError + // the error is absorbed before reaching the binding's handler. + // + // Note: no FlutterError.onError override is needed here. testWidgets + // automatically fails the test if any uncaught Flutter error reaches the + // binding's handler — the test passing IS the assertion that no uncaught + // error occurred. + testWidgets( + 'F2r(d)+: post-create disposal failure shows error UI and no uncaught error', + (tester) async { + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => + http.StreamedResponse(Stream.value([0, 1, 2, 3]), 200), + ); + addTearDown(fakeClient.close); + + // Construct the fake and its completer inside runAsync so that the + // bounded await below is registered in the real scheduler zone, not + // FakeAsync. A Duration-based timeout or future created outside runAsync + // becomes a FakeAsync timer; the binding never auto-advances fake time in + // testWidgets, so it hangs to the 30 s outer runner timeout instead of + // failing promptly. Inside runAsync, timers are dispatched to the real + // event loop and fire normally. + // + // Zone ownership of the completion: the viewer mounts here, so + // initializeVideo() starts in the real zone. The inner catch calls + // unawaited(localController.dispose().catchError(...)) also in the real + // zone. _FailingDisposeVideoPlayerPlatform.dispose() runs synchronously + // inside that detached future, completing disposedCompleter before any + // suspension. The await below (also in the real zone) therefore resolves + // as soon as the microtask queue drains the detached disposal future. + // + // Removal check: removing only the unawaited disposal call leaves + // disposedCompleter never completed; the 5 s timeout fires, failing the + // test immediately and deterministically. + await tester.runAsync(() async { + final fakePlayer = _FailingDisposeVideoPlayerPlatform(); + VideoPlayerPlatform.instance = fakePlayer; + + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + + // Wait for the disposal path to be entered, bounded by a real-zone + // timeout. The viewer downloads the body, creates the player, emits a + // PlatformException from the event stream, enters the inner catch, and + // calls unawaited(dispose().catchError(...)). dispose() completes + // disposedCompleter synchronously at its entry point before throwing. + await fakePlayer.disposedCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'dispose() was not entered within 5 s ' + '— the unawaited disposal path may have been removed', + ), + ); + + // Pump to flush the error state set in the outer catch after the + // detached disposal starts. + await tester.pumpAndSettle(); + + // Error UI must appear — disposal failure must not block the outer catch. + expect( + find.text('Failed to load video'), + findsOneWidget, + reason: 'post-create disposal failure must still show error UI', + ); + // dispose() must have been called — confirms the unawaited disposal path ran. + expect( + fakePlayer.disposeCallCount, + greaterThanOrEqualTo(1), + reason: 'dispose() must have been called on the failing player', + ); + // The test passing without a framework error IS the assertion that + // the disposal PlatformException was absorbed by .catchError and did + // not reach the binding's uncaught-error handler. + }); + }, + ); + + // Viewer-path abort: unmounting the widget while the download is in-flight + // must abort the HTTP request through the viewer's own wiring. + // + // The viewer creates an AbortableStreamedRequest with abortTrigger wired to + // downloadRequestAbort.value (a Completer). The effect cleanup calls + // activeRequestAbort.complete() on unmount, which fires abortTrigger. + // + // Red-with-reverted-wiring: removing only `activeRequestAbort.complete()` + // from the cleanup leaves the trigger pending and abortObserved stays false + // at the deadline — the abortObservedCompleter times out. + // Deleting the whole AbortableStreamedRequest / abortTrigger wiring causes + // the fake to throw StateError from send(); the viewer catches it as a load + // error, so abortObserved is never set and the test fails at the deadline. + testWidgets( + 'Viewer abort-path: unmount fires abortTrigger and cancels in-flight download', + (tester) async { + final fakePlayer = _FakeVideoPlayerPlatform(); + VideoPlayerPlatform.instance = fakePlayer; + + final stallingClient = _StallingAbortableClient(); + addTearDown(stallingClient.close); + + await tester.runAsync(() async { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(stallingClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abort-test.mp4', + ), + ), + ); + + // Wait for the viewer to start the download and reach the stall point. + await stallingClient.requestArrivedCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'Viewer did not start download within 5 s', + ), + ); + + // Verify abort has NOT fired yet (before unmount). + expect( + stallingClient.abortObserved, + isFalse, + reason: 'abort must not fire before unmount', + ); + + // Now unmount — this fires the effect cleanup which calls + // activeRequestAbort.complete(), completing abortTrigger. + // Pass the same overrides so Riverpod's debug assertion does not fire. + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(stallingClient), + ], + child: const SizedBox.shrink(), + ), + ); + // Pump once to flush the effect cleanup microtasks that fire during + // the widget-tree disposal (useEffect cleanup in Flutter hooks runs + // synchronously in the pumpWidget call above, but the Completer + // completion and the abortTrigger await chain resolve on subsequent + // microtask turns — yield here so they settle before the deadline). + await tester.pump(); + + // Wait for the abort to be observed with a deadline — no sleep. + // Deleting activeRequestAbort.complete() in cleanup → times out here. + await stallingClient.abortObservedCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'Abort was not observed within 5 s after unmount', + ), + ); + }); + await tester.pump(); + + // The fake must have received the abort — proving that the viewer's + // effect cleanup correctly wired the Completer to the AbortableStreamedRequest. + expect( + stallingClient.abortObserved, + isTrue, + reason: + 'viewer unmount must complete abortTrigger and cancel the in-flight download', + ); + }, + ); +} diff --git a/mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart b/mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart new file mode 100644 index 0000000000..d82f3dce8f --- /dev/null +++ b/mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart @@ -0,0 +1,169 @@ +// Real-IO transport probes for MediaVideoViewerPage. +// +// These tests exercise the actual IOClient (dart:io) transport path for +// AbortableStreamedRequest. They are intentionally in a SEPARATE file +// from video_viewer_test.dart because TestWidgetsFlutterBinding.ensureInitialized() +// in the main test file installs a suite-wide HttpClient override that returns +// status 400 for ALL requests — including plain test() calls in the same suite. +// By isolating these here, the real loopback HttpServer can be reached. +// +// Transport probe #1: request sink must be closed before send(). +// A local loopback server reads the full request body before replying. +// Without the `unawaited(request.sink.close())` fix, IOClient.send() blocks +// at `stream.pipe(ioRequest)` forever — test times out. +// +// Transport probe #2: abort trigger cancels an in-flight download. +// The server holds the response open via a teardown-controlled release gate +// so the abort fires against a genuinely in-flight transfer rather than +// racing a completed response. After server confirms request arrival, +// the abort is triggered; send() must throw the typed +// RequestAbortedException within a bounded deadline. + +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart' show IOClient; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'Transport: request sink is closed — real IO loopback probe (success)', + () async { + final videoBytes = [0, 1, 2, 3]; + + // Start a local HTTP server that reads the full request body BEFORE + // sending the response. If the sink is not closed, the drain hangs. + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + + server.listen((req) async { + await req.drain(); // hangs if sink was not closed + req.response + ..statusCode = 200 + ..headers.contentType = ContentType('video', 'mp4') + ..contentLength = videoBytes.length + ..add(videoBytes); + await req.response.close(); + }); + + final serverUrl = + 'http://${server.address.host}:${server.port}/video.mp4'; + final client = IOClient( + HttpClient()..idleTimeout = const Duration(milliseconds: 1), + ); + addTearDown(client.close); + + final requestAbort = Completer(); + final request = http.AbortableStreamedRequest( + 'GET', + Uri.parse(serverUrl), + abortTrigger: requestAbort.future, + ); + // THE FIX: close the sink before send so the pipe completes. + unawaited(request.sink.close()); + + final response = await client + .send(request) + .timeout( + const Duration(seconds: 5), + onTimeout: () => + throw TimeoutException('send() did not complete within 5 s'), + ); + + expect( + response.statusCode, + 200, + reason: 'sink closed → server receives full request → replies 200', + ); + await response.stream.drain().timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'response drain did not complete within 5 s', + ), + ); + }, + ); + + test( + 'Transport: abort trigger cancels an in-flight download — real IO loopback', + () async { + // The server signals that the request has arrived (so the abort fires + // AFTER the connection is established, not before). + final requestArrivedCompleter = Completer(); + + // Teardown-controlled gate: the server handler awaits this before + // calling response.close(), so the response body stays open until the + // test explicitly releases it (on success) or teardown releases it (on + // failure). Without this gate, response.close() would send an empty 200 + // immediately and the abort would race against a completed response — + // making the probe scheduling-sensitive rather than a controlled + // in-flight cancellation. + final releaseResponse = Completer(); + + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + addTearDown(() { + if (!releaseResponse.isCompleted) releaseResponse.complete(); + }); + + server.listen((req) async { + await req.drain(); + // Signal that the server has received the request, then hold the + // response open until the test releases it or teardown fires. + if (!requestArrivedCompleter.isCompleted) { + requestArrivedCompleter.complete(); + } + // Await the teardown-controlled gate before attempting to close. + // After an abort the client may have already torn down the connection, + // so close() may throw — caught and discarded here. + await releaseResponse.future; + try { + await req.response.close(); + } catch (_) { + // Connection may already be torn down by the client abort — expected. + } + }); + + final serverUrl = + 'http://${server.address.host}:${server.port}/video.mp4'; + final client = IOClient( + HttpClient()..idleTimeout = const Duration(milliseconds: 1), + ); + addTearDown(client.close); + + final requestAbort = Completer(); + final request = http.AbortableStreamedRequest( + 'GET', + Uri.parse(serverUrl), + abortTrigger: requestAbort.future, + ); + unawaited(request.sink.close()); + + // Start the request, wait for server-arrival confirmation, then abort. + final sendFuture = client.send(request); + // Bounded wait: if the server doesn't see the request within 5s, fail. + await requestArrivedCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'Loopback server did not receive request within 5 s', + ), + ); + requestAbort.complete(); + + // send() must throw RequestAbortedException within a bounded deadline. + // The typed assertion distinguishes an abort from any other exception. + await expectLater( + sendFuture.timeout(const Duration(seconds: 5)), + throwsA(isA()), + reason: 'abort must cause send() to throw RequestAbortedException', + ); + + // Release the server handler so it can exit cleanly. The abort may + // have already torn down the connection, so close() in the handler may + // throw and be discarded. Teardown also releases this gate; this is + // belt-and-suspenders cleanup for the success path. + if (!releaseResponse.isCompleted) releaseResponse.complete(); + }, + ); +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 8fe47e8803..150ccd4290 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -40,15 +40,18 @@ Widget _testable( ], child: MaterialApp( theme: AppTheme.light(), - home: Builder( - builder: (context) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(disableAnimations: disableAnimations), - // The app states its code style here, above the navigator. - child: AppMarkdownTheme(child: Scaffold(body: child)), - ), + // Use MaterialApp.builder so the MediaQuery override (including + // disableAnimations) applies to every pushed route, not just the + // home scaffold. Navigator-pushed routes (e.g. MediaVideoViewerPage) + // skip a home-level Builder wrapper entirely. + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + // AppMarkdownTheme must wrap all routes that render message content. + child: AppMarkdownTheme(child: child!), ), + home: Scaffold(body: child), ), ); } @@ -2216,6 +2219,7 @@ Photos ], ], ), + disableAnimations: true, ), ); await tester.pumpAndSettle(); @@ -2284,6 +2288,7 @@ Photos ], ], ), + disableAnimations: true, ), ); await tester.pumpAndSettle(); diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 2d22026530..690ada9317 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -164,6 +166,56 @@ Widget _buildThreadPage({ ); } +class _CountingForumSession extends RelaySessionNotifier { + _CountingForumSession(this.error); + + Object? error; + int fetchCount = 0; + Completer? park; + + /// Forum-surface attempts only: the posts list (45001) or thread replies + /// (`#e`), excluding shared sub-providers such as profiles and emoji. + int forumAttempts = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + fetchCount++; + if (filter.ids == null && + (filter.kinds.contains(45001) || filter.tags.containsKey('#e'))) { + forumAttempts++; + if (park case final park?) await park.future; + } + if (error case final error?) throw error; + return const []; + } +} + +Widget _buildLiveForum(_CountingForumSession session, Widget child) => + ProviderScope( + // Isolate the timers from Riverpod's own error retry. + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(() => session), + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(const {})), + knownAgentPubkeysProvider.overrideWithValue(const {}), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + savedPrefsProvider.overrideWithValue(_testPrefs), + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'http://localhost:3000'), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: child), + ), + ); + void main() { setUp(() async { SharedPreferences.setMockInitialValues({}); @@ -194,6 +246,134 @@ void main() { ); }); + group('forum polling after a relay deadline', () { + final deadline = RelayException(503, '{"error":"query timed out"}'); + final surfaces = { + 'posts view (15s)': ( + ForumPostsView(channel: _forumChannel, currentPubkey: 'self'), + const Duration(seconds: 15), + ), + 'thread page (10s)': ( + const ForumThreadPage( + channelId: _channelId, + postEventId: 'post1', + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + const Duration(seconds: 10), + ), + }; + for (final MapEntry(key: name, value: (surface, interval)) + in surfaces.entries) { + testWidgets('$name pauses on a deadline', (tester) async { + final session = _CountingForumSession(deadline); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + final settled = session.fetchCount; + expect(session.forumAttempts, 1); + for (var tick = 0; tick < 4; tick++) { + await tester.pump(interval); + } + expect(session.fetchCount, settled); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('$name keeps polling after an ordinary error', ( + tester, + ) async { + final session = _CountingForumSession(Exception('reset')); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + final settled = session.fetchCount; + await tester.pump(interval); + await tester.pump(); + expect(session.fetchCount, greaterThan(settled)); + await tester.pumpWidget(const SizedBox()); + }); + + for (final (kind, error) in [ + ('deadline', deadline as Object), + ('ordinary error', Exception('reset') as Object), + ]) { + testWidgets('$name Retry after a $kind loads once', (tester) async { + final session = _CountingForumSession(error); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + await tester.pump(); + final retry = find.byKey(const ValueKey('load-error-retry')); + expect(retry, findsOneWidget); + final before = session.forumAttempts; + session.error = null; + await tester.tap(retry); + await tester.pump(); + await tester.pump(); + expect(session.forumAttempts, before + 1); + // The post list settles empty; the thread page has no fake post, + // so it settles on not-found again (the count shows the reload). + if (surface is ForumPostsView) expect(retry, findsNothing); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('$name hides Retry while a $kind retry is pending', ( + tester, + ) async { + final session = _CountingForumSession(error); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + await tester.pump(); + final retry = find.byKey(const ValueKey('load-error-retry')); + expect(retry, findsOneWidget); + final before = session.forumAttempts; + final park = session.park = Completer(); + await tester.tap(retry); + await tester.pump(); + await tester.pump(); + expect(retry, findsNothing); + expect(session.forumAttempts, before + 1); + // The replacement fails the same way and settles on Retry again. + park.complete(); + await tester.pump(); + await tester.pump(); + expect(retry, findsOneWidget); + expect(session.forumAttempts, before + 1); + await tester.pumpWidget(const SizedBox()); + }); + } + + testWidgets('$name shows no Retry while loading', (tester) async { + final session = _CountingForumSession(deadline) + ..park = Completer(); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + expect(session.forumAttempts, 1); + expect(find.byKey(const ValueKey('load-error-retry')), findsNothing); + session.park!.complete(); + await tester.pump(); + await tester.pump(); + expect(find.byKey(const ValueKey('load-error-retry')), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('$name reopening retries a deadline', (tester) async { + final session = _CountingForumSession(deadline); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + expect(session.forumAttempts, 1); + await tester.pumpWidget(_buildLiveForum(session, const SizedBox())); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + await tester.pump(); + expect(session.forumAttempts, 2); + for (var tick = 0; tick < 4; tick++) { + await tester.pump(interval); + } + expect(session.forumAttempts, 2); + await tester.pumpWidget(const SizedBox()); + }); + } + }); + group('ForumPostCard', () { testWidgets('renders author name and content', (tester) async { await tester.pumpWidget( diff --git a/mobile/test/helpers/widget_helpers.dart b/mobile/test/helpers/widget_helpers.dart index b7f119db87..11b4b42141 100644 --- a/mobile/test/helpers/widget_helpers.dart +++ b/mobile/test/helpers/widget_helpers.dart @@ -7,12 +7,20 @@ class WidgetHelpers { static Widget testable({ required Widget child, List overrides = const [], + bool disableAnimations = false, }) { return ProviderScope( overrides: overrides, child: MaterialApp( theme: AppTheme.light(), - home: Scaffold(body: child), + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: Scaffold(body: child), + ), + ), ), ); } diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index 4f43ec4770..e3626cf892 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -31,30 +31,35 @@ void main() { PaintingBinding.instance.imageCache.clearLiveImages(); }); - group('MediaGetAuthService memoization', () { - test('repeated calls return byte-identical headers', () { + group('MediaGetAuthService mint-per-request', () { + test('consecutive calls return distinct headers (mint-per-request)', () { final nsec = nostr.Keys.generate().nsec; final auth = _auth(nsec: nsec); final first = auth.headersFor(_mediaUrl); final second = auth.headersFor(_mediaUrl); expect(first, isNotEmpty); - expect(identical(first, second), isTrue); + // With lifetime == margin == 60s, refreshAt == signedAt, so the cache + // never hits: each call produces a fresh proof. + expect(identical(first, second), isFalse); + expect(second['Authorization'], isNot(first['Authorization'])); }); - test('re-signs only at the refresh margin before expiry', () { + test('each call produces a fresh token regardless of elapsed time', () { final nsec = nostr.Keys.generate().nsec; var current = DateTime.utc(2026, 7, 21, 12); final auth = _auth(nsec: nsec, now: () => current); final first = auth.headersFor(_mediaUrl); - // 600s lifetime - 60s margin = re-sign boundary at +540s. - current = current.add(const Duration(seconds: 539)); - expect(identical(auth.headersFor(_mediaUrl), first), isTrue); + // No elapsed time: still mints fresh. + final atZero = auth.headersFor(_mediaUrl); + expect(identical(atZero, first), isFalse); + expect(atZero['Authorization'], isNot(first['Authorization'])); - current = current.add(const Duration(seconds: 2)); - final refreshed = auth.headersFor(_mediaUrl); - expect(identical(refreshed, first), isFalse); - expect(refreshed['Authorization'], isNot(first['Authorization'])); + // Advance clock: still mints fresh. + current = current.add(const Duration(seconds: 30)); + final at30 = auth.headersFor(_mediaUrl); + expect(identical(at30, first), isFalse); + expect(at30['Authorization'], isNot(first['Authorization'])); }); test('non-relay URLs get no headers even with a key', () { diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index e9dbed302e..dd72efe73e 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -292,7 +292,7 @@ void main() { expect(authEvent['content'], 'Get buzz-media'); expect(authEvent['tags'], contains(equals(['t', 'get']))); expect(authEvent['tags'], contains(equals(['server', 'relay.example']))); - expect(authEvent['tags'], contains(equals(['expiration', '1700000600']))); + expect(authEvent['tags'], contains(equals(['expiration', '1700000060']))); }); test('does not sign non-relay or non-media URLs', () { @@ -419,7 +419,7 @@ void main() { equals(['x', capturedRequest!.headers['X-SHA-256']!]), ), ); - expect(tags, anyElement(equals(['expiration', '1700000300']))); + expect(tags, anyElement(equals(['expiration', '1700000060']))); expect( tags, anyElement(equals(['server', 'relay.example:8443'])), diff --git a/mobile/test/shared/relay/relay_closed_policy_test.dart b/mobile/test/shared/relay/relay_closed_policy_test.dart index ff78ea89ef..d83f658f40 100644 --- a/mobile/test/shared/relay/relay_closed_policy_test.dart +++ b/mobile/test/shared/relay/relay_closed_policy_test.dart @@ -16,6 +16,7 @@ void main() { 'unsupported: filter extension': RelayClosedClass.terminal, 'error: mixed search and channel filter': RelayClosedClass.terminal, 'error: too many subscriptions': RelayClosedClass.terminal, + 'error: query timed out': RelayClosedClass.terminal, 'error: relay temporarily unavailable': RelayClosedClass.retryable, 'subscription closed by relay': RelayClosedClass.retryable, }; diff --git a/mobile/test/shared/relay/relay_deadline_provider_retry_test.dart b/mobile/test/shared/relay/relay_deadline_provider_retry_test.dart new file mode 100644 index 0000000000..c2b53103c0 --- /dev/null +++ b/mobile/test/shared/relay/relay_deadline_provider_retry_test.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/thread_replies_provider.dart'; +import 'package:buzz/features/forum/forum_provider.dart'; +import 'package:buzz/main.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// --------------------------------------------------------------------------- +// Fake sessions +// --------------------------------------------------------------------------- + +class _DeadlineHttpSession extends RelaySessionNotifier { + int queryCount = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryCount++; + // Relay HTTP 503 with JSON body: {"error":"query timed out"} + throw RelayException(503, '{"error":"query timed out"}'); + } +} + +class _DeadlineWsSession extends RelaySessionNotifier { + int historyCount = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + historyCount++; + // WebSocket CLOSED: Exception("error: query timed out") + throw Exception('error: query timed out'); + } +} + +class _OrdinaryErrorSession extends RelaySessionNotifier { + int queryCount = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryCount++; + throw RelayException(500, '{"error":"internal server error"}'); + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryCount++; + throw RelayException(500, '{"error":"internal server error"}'); + } +} + +// --------------------------------------------------------------------------- +// isRelayDeadlineError unit tests +// --------------------------------------------------------------------------- + +void main() { + test('the production root scope uses relayProviderRetry', () async { + SharedPreferences.setMockInitialValues({}); + final scope = buildRootProviderScope( + prefs: await SharedPreferences.getInstance(), + child: const SizedBox(), + ); + expect(scope.retry, same(relayProviderRetry)); + }); + + group('isRelayDeadlineError', () { + test('matches HTTP 503 query timed out', () { + expect( + isRelayDeadlineError( + RelayException(503, '{"error":"query timed out"}'), + ), + isTrue, + ); + }); + + test('matches HTTP 503 with whitespace in body value', () { + expect( + isRelayDeadlineError( + RelayException(503, '{"error":" Query Timed Out "}'), + ), + isTrue, + ); + }); + + test('does not match HTTP 503 with different error', () { + expect( + isRelayDeadlineError( + RelayException(503, '{"error":"service unavailable"}'), + ), + isFalse, + ); + }); + + test('does not match HTTP 503 with non-JSON body', () { + expect(isRelayDeadlineError(RelayException(503, 'plain text')), isFalse); + }); + + test('does not match HTTP 500', () { + expect( + isRelayDeadlineError( + RelayException(500, '{"error":"query timed out"}'), + ), + isFalse, + ); + }); + + test('matches WebSocket CLOSED exception string', () { + expect(isRelayDeadlineError(Exception('error: query timed out')), isTrue); + }); + + test('does not match ordinary exception', () { + expect(isRelayDeadlineError(Exception('connection refused')), isFalse); + }); + + test('does not match TimeoutException', () { + expect( + isRelayDeadlineError( + TimeoutException( + 'Relay history request timed out after 0:00:08.000000', + ), + ), + isFalse, + ); + }); + }); + + // ------------------------------------------------------------------------- + // threadRepliesProvider — HTTP path + // ------------------------------------------------------------------------- + + group('threadRepliesProvider deadline retry suppression', () { + const args = ThreadRepliesArgs(channelId: 'chan', rootId: 'root'); + + test( + 'deadline: settles error in exactly one call and does not rebuild', + () async { + final session = _DeadlineHttpSession(); + final container = ProviderContainer( + retry: relayProviderRetry, + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + + final sub = container.listen(threadRepliesProvider(args), (_, _) {}); + addTearDown(sub.close); + + await container.pump(); + await Future.delayed(const Duration(milliseconds: 500)); + + expect(container.read(threadRepliesProvider(args)).hasError, isTrue); + expect(session.queryCount, 1); + }, + ); + + test( + 'ordinary error: retries at least once with production retry policy', + () async { + final session = _OrdinaryErrorSession(); + final container = ProviderContainer( + retry: relayProviderRetry, + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + + final sub = container.listen(threadRepliesProvider(args), (_, _) {}); + addTearDown(sub.close); + + await container.pump(); + await Future.delayed(const Duration(milliseconds: 400)); + + // At least 2 calls means Riverpod issued at least one retry. + expect(session.queryCount, greaterThanOrEqualTo(2)); + }, + ); + }); + + // ------------------------------------------------------------------------- + // forumPostsProvider — WebSocket history path + // ------------------------------------------------------------------------- + + group('forumPostsProvider deadline retry suppression', () { + test( + 'deadline: settles error in exactly one call and does not rebuild', + () async { + final session = _DeadlineWsSession(); + final container = ProviderContainer( + retry: relayProviderRetry, + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + + final sub = container.listen(forumPostsProvider('chan'), (_, _) {}); + addTearDown(sub.close); + + await container.pump(); + await Future.delayed(const Duration(milliseconds: 500)); + + expect(container.read(forumPostsProvider('chan')).hasError, isTrue); + expect(session.historyCount, 1); + }, + ); + }); +} diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 413f12a82e..50bdb5201d 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -1100,6 +1100,38 @@ void main() { expect(_reqs(socket).where((req) => req[1] == 'l-1'), hasLength(1)); }); + test('a deadline CLOSED retries a live sub but fails history', () async { + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final statuses = []; + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribeWithStatus( + _channelFilter, + (_) {}, + onStatusChanged: statuses.add, + ); + session.debugHandleMessage(['EOSE', 'l-1']); + await subscribe; + + session.debugHandleMessage(['CLOSED', 'l-1', 'error: query timed out']); + expect(statuses.last, RelaySubscriptionStatus.retrying); + timers.single.fire(); + await Future.delayed(Duration.zero); + expect(_reqs(socket).where((req) => req[1] == 'l-1'), hasLength(2)); + + final history = session.fetchHistory(_channelFilter); + final historyId = _reqs(socket).last[1] as String; + session.debugHandleMessage(['CLOSED', historyId, 'error: query timed out']); + await expectLater(history, throwsA(isA())); + }); + test('unsubscribe and dispose cancel CLOSED retry timers', () async { final timers = <_ManualTimer>[]; final socket = _RecordingRelaySocket(); diff --git a/schema/schema.sql b/schema/schema.sql index 1a0a849f7a..37112cc670 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -278,6 +278,10 @@ CREATE INDEX idx_events_not_before ON events (community_id, not_before) -- EXPLAIN before its work lands (Quinn option A; Max's index-spelling caveat). CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +-- e-tag containment (`tags @> '[["e",""]]'`) for the aux closure and #e +-- reads. Mirrors migrations/0004; jsonb_path_ops supports exactly @>. +CREATE INDEX idx_events_tags_gin ON events USING GIN (tags jsonb_path_ops); + -- ── Event mentions ──────────────────────────────────────────────────────────── -- Conformance: "Channel-less global events and DMs" (#p fan-out). The join to -- events MUST carry the community tuple (e.community_id = m.community_id AND @@ -1232,6 +1236,11 @@ CREATE TABLE community_deletion_requests ( 'logically_verified', 'retention_pending', 'aborted' )), requested_by TEXT NOT NULL, + request_origin TEXT NOT NULL DEFAULT 'operator' + CHECK (request_origin IN ('operator', 'owner')), + owner_pubkey TEXT, + mediating_operator_pubkey TEXT, + acknowledgement_version INTEGER, reason TEXT, schema_manifest JSONB, storage_manifest JSONB, @@ -1268,6 +1277,21 @@ CREATE TABLE community_deletion_requests ( CHECK ((aborted_at IS NULL) = (aborted_by IS NULL)), CHECK ((aborted_at IS NULL) = (abort_reason IS NULL)), CHECK ((inventory_frozen_at IS NULL) = (inventory_digest IS NULL)), + CONSTRAINT community_deletion_owner_provenance CHECK ( + (request_origin = 'operator' + AND owner_pubkey IS NULL + AND mediating_operator_pubkey IS NULL + AND acknowledgement_version IS NULL) + OR + (request_origin = 'owner' + AND NOT (owner_pubkey IS NULL) + AND NOT (mediating_operator_pubkey IS NULL) + AND NOT (acknowledgement_version IS NULL) + AND owner_pubkey ~ '^[0-9a-f]{64}$' + AND mediating_operator_pubkey ~ '^[0-9a-f]{64}$' + AND acknowledgement_version BETWEEN 1 AND 32767 + AND requested_by = owner_pubkey) + ), UNIQUE (id, community_id, inventory_digest) ); CREATE UNIQUE INDEX community_deletion_requests_active_community @@ -1293,7 +1317,7 @@ CREATE TABLE community_deletion_approvals ( ON DELETE RESTRICT ); -CREATE FUNCTION prevent_community_deletion_request_retargeting() +CREATE OR REPLACE FUNCTION prevent_community_deletion_request_retargeting() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -1304,6 +1328,14 @@ BEGIN RAISE EXCEPTION 'community deletion target identity is immutable' USING ERRCODE = 'integrity_constraint_violation'; END IF; + IF NEW.request_origin IS DISTINCT FROM OLD.request_origin + OR NEW.owner_pubkey IS DISTINCT FROM OLD.owner_pubkey + OR NEW.mediating_operator_pubkey IS DISTINCT FROM OLD.mediating_operator_pubkey + OR NEW.acknowledgement_version IS DISTINCT FROM OLD.acknowledgement_version + THEN + RAISE EXCEPTION 'community deletion request provenance is immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; IF OLD.inventory_frozen_at IS NOT NULL AND ( NEW.schema_manifest IS DISTINCT FROM OLD.schema_manifest OR NEW.storage_manifest IS DISTINCT FROM OLD.storage_manifest @@ -1494,7 +1526,7 @@ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ 'community_deletion_requests', 'community_deletion_approvals', 'community_deletion_checkpoints', 'community_serving_write_leases', 'community_deletion_executor_heartbeats', 'product_feedback', - 'rate_limit_violations' + 'rate_limit_violations', 'operator_listener_outbox' ]::TEXT[]) $$; @@ -1878,6 +1910,51 @@ CREATE INDEX idx_relay_admin_outbox_pending INSERT INTO _operator_global_tables (table_name, reason) VALUES ('relay_admin_outbox', 'deployment-global enforcement artifact delivery queue'); +-- ── Operator-listener mention delivery ────────────────────────────────────── +-- Listener registrations are deployment-global. The outbox records community +-- provenance for the event, but is intentionally not tenant-owned. + +CREATE TABLE operator_listener_pubkeys ( + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (listener_pubkey, target_pubkey) +); +CREATE INDEX operator_listener_pubkeys_target + ON operator_listener_pubkeys (target_pubkey, listener_pubkey); +CREATE INDEX operator_listener_pubkeys_created_at + ON operator_listener_pubkeys (created_at); + +CREATE TABLE operator_listener_outbox ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + community_id UUID NOT NULL, + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + event_kind INTEGER NOT NULL, + event_created_at TIMESTAMPTZ NOT NULL, + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'sending')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_until TIMESTAMPTZ, + claim_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (listener_pubkey, target_pubkey, community_id, event_id) +); +CREATE INDEX operator_listener_outbox_due + ON operator_listener_outbox (next_attempt_at, created_at, id) + WHERE state = 'pending'; +CREATE INDEX operator_listener_outbox_recovery + ON operator_listener_outbox (lease_until, created_at, id) + WHERE state = 'sending'; +CREATE INDEX operator_listener_outbox_created_at + ON operator_listener_outbox (created_at); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('operator_listener_pubkeys', 'deployment-global target registrations for operator listeners'), + ('operator_listener_outbox', 'deployment-global mention delivery queue; community_id is event provenance'); + -- ── Relay operator audit (append-only roster mutation trail) ───────────────── -- One row per PUT/DELETE /operators/{pubkey} mutation. The roster is the -- deployment-wide root of trust and its mutations overwrite/remove in place; diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 3732cdc476..ff18a07fd1 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -155,9 +155,9 @@ run_unit_tests() { run_test_step "buzz-acp unit tests" \ cargo test -p buzz-acp --lib -- --nocapture - # Mirror the three infra-free relay handler modules in `just test-unit`'s - # nextest expression. Keep the side-effects filter pinned to `::tests::` so - # it does not select the sibling Postgres-backed test module. + # Mirror the relay filters from `just test-unit`: the three handler modules, + # storage-snapshot helpers, readiness and router unit suites, and the single + # scoped admission regression in state::tests, plus the REQ lifecycle tests. run_test_step "buzz-relay channel authorization tests" \ cargo test -p buzz-relay --lib handlers::channel_authz:: -- --nocapture @@ -169,6 +169,29 @@ run_unit_tests() { run_test_step "buzz-relay storage snapshot tests" \ cargo test -p buzz-relay --lib storage_sweep::tests:: -- --nocapture + + # Infra-free REQ subscription-lifecycle tests, by exact name: the rest of + # handlers::req needs a database. + run_test_step "buzz-relay REQ subscription lifecycle tests" \ + cargo test -p buzz-relay --lib -- --exact --nocapture \ + handlers::req::tests::timed_out_historical_read_deregisters_before_closed \ + handlers::req::tests::superseded_timeout_leaves_replacement_intact \ + handlers::req::tests::search_claim_retires_live_and_yields_to_replacement \ + handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner \ + handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim \ + handlers::req::tests::revoke_then_replacement_keeps_replacement_whole \ + handlers::req::tests::claims_after_connection_cleanup_are_refused \ + handlers::req::tests::dropped_terminal_frame_cancels_connection \ + handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection + + run_test_step "buzz-relay readiness tests" \ + cargo test -p buzz-relay --lib readiness::tests:: -- --nocapture + + run_test_step "buzz-relay router tests" \ + cargo test -p buzz-relay --lib router::tests:: -- --nocapture + + run_test_step "buzz-relay admission regression test" \ + cargo test -p buzz-relay --lib state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket -- --nocapture } # ---- DB / integration tests (infra required) -------------------------------- diff --git a/scripts/seed-long-thread.sh b/scripts/seed-long-thread.sh new file mode 100755 index 0000000000..70e0d18297 --- /dev/null +++ b/scripts/seed-long-thread.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Seed one long thread on the local dev relay, for reproducing and eyeballing +# long-thread loading in the apps. +# +# just relay # stock local relay on ws://localhost:3000 +# just seed-long-thread 187 # or: scripts/seed-long-thread.sh [replies] +# +# Targets SEED_RELAY_URL (default: .env RELAY_URL, else ws://localhost:3000) +# and refuses non-loopback relays. Ambient BUZZ_RELAY_URL/BUZZ_PRIVATE_KEY are +# ignored so an agent or shell session can never seed its real relay. Posts +# with SEED_PRIVATE_KEY (default: a fresh throwaway key) into a new open stream +# channel, or into SEED_CHANNEL if that key may post there. +# +# Every write (channel, root, replies, reactions) counts against the relay's +# 60 human messages/min, so writes are paced SEED_DELAY seconds apart (default +# 1.05): the default run takes ~3-4 minutes on a stock relay. Set SEED_DELAY=0 +# when BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN is raised on the relay. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${REPO_ROOT}" + +if [[ -f ".env" ]]; then + set -o allexport + # shellcheck disable=SC1091 + source .env + set +o allexport +fi + +REPLIES="${1:-187}" +REACTIONS="${SEED_REACTIONS:-20}" +export BUZZ_RELAY_URL="${SEED_RELAY_URL:-${RELAY_URL:-ws://localhost:3000}}" +export BUZZ_PRIVATE_KEY="${SEED_PRIVATE_KEY:-$(openssl rand -hex 32)}" +unset BUZZ_AUTH_TAG +if [[ ! "$BUZZ_RELAY_URL" =~ ^wss?://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?/?$ ]]; then + echo "refusing non-local relay: $BUZZ_RELAY_URL" >&2 + exit 1 +fi +BUZZ="${BUZZ_CLI:-cargo run -q -p buzz-cli --}" +DELAY="${SEED_DELAY:-1.05}" +write() { $BUZZ "$@"; sleep "$DELAY"; } + +channel="${SEED_CHANNEL:-}" +if [[ -z "$channel" ]]; then + channel="$(write channels create --name "long-thread-$$" --type stream --visibility open | jq -er .channel_id)" +fi +root="$(write messages send --channel "$channel" --content "long thread root ($REPLIES replies)" | jq -er .event_id)" + +ids=() +for i in $(seq 1 "$REPLIES"); do + ids+=("$(write messages send --channel "$channel" --reply-to "$root" --content "reply $i" | jq -er .event_id)") +done +if (( REPLIES > 0 )); then + for i in $(seq 1 "$REACTIONS"); do + write reactions add --event "${ids[$(( (i * 7) % REPLIES ))]}" --emoji "👍" >/dev/null + done +fi + +echo "channel=$channel" +echo "root=$root" +echo "replies=$REPLIES reactions=$(( REPLIES > 0 ? REACTIONS : 0 ))" diff --git a/scripts/test-video-upload.sh b/scripts/test-video-upload.sh index 09c4ffed9f..90e632428c 100755 --- a/scripts/test-video-upload.sh +++ b/scripts/test-video-upload.sh @@ -55,14 +55,15 @@ echo "Test MP4: ${FILE_SIZE} bytes, sha256=${SHA256:0:16}..." echo "" # ── Helper: build Blossom auth header ────────────────────────────────────────── -# Creates a kind:24242 event with t=upload, x=, expiration=+5min. +# Creates a kind:24242 event with t=upload, x=, expiration=+60s, server=. blossom_auth() { local sha256="$1" - local now exp auth_event auth_b64 + local now exp server auth_event auth_b64 now=$(date +%s) - exp=$((now + 300)) + exp=$((now + 60)) + server=$(echo "$RELAY_URL" | sed -E 's|^https?://||' | cut -d'/' -f1) auth_event=$(nak event \ --sec "$NSEC" \ @@ -71,6 +72,7 @@ blossom_auth() { -t t=upload \ -t "x=$sha256" \ -t "expiration=$exp" \ + -t "server=$server" \ 2>/dev/null) auth_b64=$(echo -n "$auth_event" | base64 | tr -d '\n')