diff --git a/Cargo.lock b/Cargo.lock index 7f28640f86a..ae298c6fefa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1466,6 +1466,7 @@ dependencies = [ "rand 0.10.1", "redis", "reqwest 0.13.4", + "ring", "rust-s3", "rustls", "serde", diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 1a4f67538e7..5f027fccf78 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -65,9 +65,9 @@ pub(crate) use runtime::{ pub use store::{ admin_moderation, allowlist, api_token, archived_identities, artifact, channel, channel_members, 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, + partition, personal_read, 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; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 47e112d7998..26ceba80cad 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -705,7 +705,12 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 55); + assert_eq!(migrations.len(), 56); + assert_eq!(migrations[55].version, 56); + assert!(migrations[55] + .sql + .as_str() + .contains("CREATE TABLE personal_read_accounts")); assert_eq!(migrations[48].version, 49); assert_eq!(migrations[49].version, 50); assert_eq!(migrations[50].version, 51); @@ -2048,6 +2053,22 @@ mod postgres_tests { let mut expected_fences = migration.fence_attachments.clone(); expected_fences.remove("product_feedback"); expected_fences.remove("rate_limit_violations"); + let personal = surface( + MIGRATOR + .iter() + .find(|m| m.version == 56) + .expect("personal read migration") + .sql + .as_ref(), + ); + for (table, definition) in personal.tables { + assert_eq!( + schema.tables.get(&table), + Some(&definition), + "personal read table {table} differs" + ); + } + expected_fences.extend(personal.fence_attachments); expected_fences.extend(["artifact_heads", "artifact_revisions"].map(str::to_owned)); assert_eq!( expected_fences, schema.fence_attachments, 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 1904bf6363d..6e4248a7406 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, 55); + assert_eq!(version, 56); 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/deletion.rs b/crates/buzz-db/src/store/deletion.rs index ac52056de98..26857a218be 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -92,6 +92,8 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ "moderation_actions", "moderation_reports", "parameterized_event_watermarks", + "personal_read_accounts", + "personal_read_frontiers", "pubkey_allowlist", "push_leases", "push_match_queue", @@ -110,6 +112,8 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ /// Foreign-key-safe child-before-parent order for the PostgreSQL purge. pub const PURGE_SCOPED_TABLES: &[&str] = &[ + "personal_read_frontiers", + "personal_read_accounts", "workflow_approvals", "scheduled_workflow_fires", "workflow_runs", diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 79f0f078ab2..8e43f9af3e3 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -30,6 +30,8 @@ pub mod moderation; pub mod operator_listener; /// Monthly table partition management. pub mod partition; +/// Private signer-owned accessory read progress. +pub mod personal_read; /// Buzz product-feedback sidecar persistence. pub mod product_feedback; /// Community-scoped push lease and durable wake-outbox persistence. diff --git a/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs new file mode 100644 index 00000000000..0c578a34e2e --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs @@ -0,0 +1,468 @@ +//! Read progress follows relay arrival (`received_at`), never author time. +//! Each case sets every arrival explicitly: back-to-back inserts share a clock. +use super::{postgres_tests::fixture, *}; +use crate::Db; +use buzz_core::CommunityId; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use sqlx::PgPool; +use uuid::Uuid; + +/// Store `event` as having arrived at `arrived` (Unix seconds). +async fn arrive(pool: &PgPool, community: CommunityId, event: &nostr::Event, arrived: u64) { + let updated = sqlx::query( + "UPDATE events SET received_at=to_timestamp($3) WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(arrived as f64) + .execute(pool) + .await + .unwrap() + .rows_affected(); + assert_eq!(updated, 1, "arrival must land on exactly one stored event"); +} + +/// A message authored at `authored` that arrives at `arrived`. +async fn post( + db: &Db, + pool: &PgPool, + community: CommunityId, + channel: Uuid, + authored: u64, + arrived: u64, + tags: Vec, +) -> nostr::Event { + let event = EventBuilder::new(Kind::Custom(9), format!("authored {authored}")) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(authored)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + arrive(pool, community, &event, arrived).await; + event +} + +/// A reply to `root` that mentions the actor, so it counts without membership. +#[allow(clippy::too_many_arguments)] +async fn reply( + db: &Db, + pool: &PgPool, + community: CommunityId, + channel: Uuid, + root: &nostr::Event, + actor: &Keys, + authored: u64, + arrived: u64, +) -> nostr::Event { + let mention = Tag::public_key(actor.public_key()); + let event = post( + db, + pool, + community, + channel, + authored, + arrived, + vec![mention], + ) + .await; + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") + .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()) + .bind(event.created_at.as_secs() as f64) + .bind(channel).bind(root.id.as_bytes().as_slice()).execute(pool).await.unwrap(); + event +} + +async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSummary { + db.personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap() + .channels + .remove(0) +} + +async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent) { + let outcome = db + .apply_personal_read_intent(community, &actor.public_key(), &intent) + .await + .unwrap(); + assert_eq!(outcome, IntentOutcome::Applied); +} + +fn mark_through(channel: Uuid, root: Option<&str>, message: &str) -> ReadIntent { + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: root.map(str::to_owned), + }, + message_id: message.to_owned(), + } +} + +/// Channel-timeline states of `messages`, in order. +async fn states( + db: &Db, + community: CommunityId, + actor: &Keys, + channel: Uuid, + messages: &[&nostr::Event], +) -> Vec { + let query = ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_ids: messages.iter().map(|e| e.id.to_hex()).collect(), + }; + let page = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[query], + ) + .await + .unwrap(); + let page = serde_json::to_value(page).unwrap(); + page["contexts"][0]["messages"] + .as_array() + .unwrap() + .iter() + .map(|m| m["status"].as_str().unwrap().to_owned()) + .collect() +} + +fn exact(count: &ReadCount) -> Option { + match count { + ReadCount::Exact { value } => Some(*value), + _ => None, + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn late_arrival_with_old_author_time_is_unread() { + let (db, pool, community, channel, actor, read) = fixture().await; + let now = read.created_at.as_secs(); + arrive(&pool, community, &read, now - 60).await; + apply( + &db, + community, + &actor, + mark_through(channel, None, &read.id.to_hex()), + ) + .await; + + // Authored ten minutes before the read message, arriving after it was read. + let late = post(&db, &pool, community, channel, now - 600, now - 30, vec![]).await; + + assert_eq!( + states(&db, community, &actor, channel, &[&read, &late]).await, + ["read", "unread"] + ); + assert_eq!( + exact(&sidebar(&db, community, &actor).await.unread), + Some(1) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn future_dated_anchor_does_not_swallow_later_arrivals() { + let (db, pool, community, channel, actor, earlier) = fixture().await; + let now = earlier.created_at.as_secs(); + arrive(&pool, community, &earlier, now - 60).await; + // Stamped ten minutes ahead; the relay accepts up to fifteen. + let ahead = post(&db, &pool, community, channel, now + 600, now - 40, vec![]).await; + apply( + &db, + community, + &actor, + mark_through(channel, None, &ahead.id.to_hex()), + ) + .await; + + let later = post(&db, &pool, community, channel, now, now - 30, vec![]).await; + + assert_eq!( + states(&db, community, &actor, channel, &[&earlier, &ahead, &later]).await, + ["read", "read", "unread"] + ); + assert_eq!( + exact(&sidebar(&db, community, &actor).await.unread), + Some(1) + ); +} + +/// Marking the sidebar's own latest message must clear the badge even when the +/// last arrival is not the newest by author time. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_as_read_with_the_sidebar_anchor_clears_a_late_arrival() { + let (db, pool, community, channel, actor, first) = fixture().await; + let now = first.created_at.as_secs(); + arrive(&pool, community, &first, now - 60).await; + post(&db, &pool, community, channel, now + 1, now - 40, vec![]).await; + post(&db, &pool, community, channel, now - 600, now - 30, vec![]).await; + assert_eq!( + exact(&sidebar(&db, community, &actor).await.unread), + Some(3) + ); + + let anchor = sidebar(&db, community, &actor) + .await + .latest_message_id + .expect("a channel with messages has a latest message"); + apply( + &db, + community, + &actor, + ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: anchor, + }, + ) + .await; + + assert_eq!( + exact(&sidebar(&db, community, &actor).await.unread), + Some(0) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_thread_read_with_the_sidebar_anchor_clears_a_late_reply() { + let (db, pool, community, channel, actor, root) = fixture().await; + let now = root.created_at.as_secs(); + arrive(&pool, community, &root, now - 60).await; + apply( + &db, + community, + &actor, + mark_through(channel, None, &root.id.to_hex()), + ) + .await; + reply( + &db, + &pool, + community, + channel, + &root, + &actor, + now + 1, + now - 40, + ) + .await; + reply( + &db, + &pool, + community, + channel, + &root, + &actor, + now - 600, + now - 30, + ) + .await; + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(2)); + assert_eq!(row.threads.items.len(), 1); + + let root_id = root.id.to_hex(); + let anchor = row.threads.items[0].latest_reply_id.clone(); + apply( + &db, + community, + &actor, + mark_through(channel, Some(&root_id), &anchor), + ) + .await; + + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(0)); + assert!(row.threads.items.is_empty() && row.threads.complete); +} + +/// The shallow latest probe reads the newest `MAX_CHANNEL_SCAN + 1` events by +/// author time, of any kind. A late arrival with an older author time than +/// that many events is counted unread, so the unread scan must supply the +/// anchor or Mark as read with the sidebar anchor leaves it. +async fn mark_as_read_behind_fillers(fillers: u64) -> Option { + let (db, pool, community, channel, actor, first) = fixture().await; + let now = first.created_at.as_secs(); + arrive(&pool, community, &first, now - 60).await; + apply( + &db, + community, + &actor, + mark_through(channel, None, &first.id.to_hex()), + ) + .await; + for i in 0..fillers { + // Reactions are not counted, but the probe's LIMIT sees them. + let filler = EventBuilder::new(Kind::Custom(7), "+") + .custom_created_at(nostr::Timestamp::from(now - 500 + i)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &filler, Some(channel)) + .await + .unwrap(); + arrive(&pool, community, &filler, now - 50).await; + } + post(&db, &pool, community, channel, now - 600, now - 10, vec![]).await; + assert_eq!( + exact(&sidebar(&db, community, &actor).await.unread), + Some(1) + ); + + let anchor = sidebar(&db, community, &actor) + .await + .latest_message_id + .expect("a channel with messages has a latest message"); + apply( + &db, + community, + &actor, + ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: anchor, + }, + ) + .await; + exact(&sidebar(&db, community, &actor).await.unread) +} + +/// Control: with one slot to spare the late arrival is inside the probe. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_as_read_clears_a_late_arrival_inside_the_latest_probe() { + let fillers = MAX_CHANNEL_SCAN as u64 - 1; + assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_as_read_clears_a_late_arrival_outside_the_latest_probe() { + let fillers = MAX_CHANNEL_SCAN as u64; + assert_eq!(mark_as_read_behind_fillers(fillers).await, Some(0)); +} + +/// When the newest 257 events are all uncounted, the only message is still in +/// the unread scan: the summary reports it as found, as one coherent triple. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn latest_message_behind_a_full_probe_of_reactions_is_found() { + let (db, pool, community, channel, actor, first) = fixture().await; + let now = first.created_at.as_secs(); + let demoted = sqlx::query("UPDATE events SET kind=7 WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(first.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap() + .rows_affected(); + assert_eq!(demoted, 1); + let only = post(&db, &pool, community, channel, now - 600, now - 10, vec![]).await; + for i in 0..MAX_CHANNEL_SCAN as u64 { + let filler = EventBuilder::new(Kind::Custom(7), "+") + .custom_created_at(nostr::Timestamp::from(now - 500 + i)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &filler, Some(channel)) + .await + .unwrap(); + arrive(&pool, community, &filler, now - 50).await; + } + + let row = sidebar(&db, community, &actor).await; + assert_eq!(row.latest_message_id, Some(only.id.to_hex())); + assert_eq!(row.latest_message_at, Some((now - 600) as i64)); + assert!(row.latest_message_complete); + assert_eq!(exact(&row.unread), Some(1)); +} + +/// Store `event` as having arrived at exactly `seconds` plus `micros`. Built +/// from integers, never a float, so microsecond order cannot hinge on rounding. +async fn arrive_exact( + pool: &PgPool, + community: CommunityId, + event: &nostr::Event, + seconds: i64, + micros: u32, +) { + let at = chrono::DateTime::::from_timestamp(seconds, micros * 1_000).unwrap(); + let updated = sqlx::query("UPDATE events SET received_at=$3 WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(at) + .execute(pool) + .await + .unwrap() + .rows_affected(); + assert_eq!(updated, 1, "arrival must land on exactly one stored event"); + let stored: chrono::DateTime = + sqlx::query_scalar("SELECT received_at FROM events WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(stored, at, "received_at must keep microseconds"); +} + +/// Two messages with the same author time arriving within one second; marks +/// the one at `pick` and returns both channel states. +async fn mark_within_one_second(micros: [u32; 2], pick: usize) -> (Vec, Option) { + let (db, pool, community, channel, actor, first) = fixture().await; + let now = first.created_at.as_secs(); + let second = post(&db, &pool, community, channel, now, now, vec![]).await; + let arrived = now as i64 - 30; + arrive_exact(&pool, community, &first, arrived, micros[0]).await; + arrive_exact(&pool, community, &second, arrived, micros[1]).await; + let both = [&first, &second]; + apply( + &db, + community, + &actor, + mark_through(channel, None, &both[pick].id.to_hex()), + ) + .await; + ( + states(&db, community, &actor, channel, &both).await, + exact(&sidebar(&db, community, &actor).await.unread), + ) +} + +/// Mid-second stamps, so truncating or rounding the frontier to whole seconds +/// either reads the later message or leaves the anchor unread. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn arrivals_one_microsecond_apart_in_the_same_second_are_ordered() { + assert_eq!( + mark_within_one_second([500_000, 500_001], 0).await, + (vec!["read".to_owned(), "unread".to_owned()], Some(1)) + ); +} + +/// The Order section: everything that arrived at or before the anchor is read, +/// so an identical stamp reads both, whichever is marked. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn marking_either_of_two_identical_arrivals_reads_both() { + for pick in [0, 1] { + assert_eq!( + mark_within_one_second([500_000, 500_000], pick).await, + (vec!["read".to_owned(), "read".to_owned()], Some(0)), + "marked index {pick}" + ); + } +} diff --git a/crates/buzz-db/src/store/personal_read/classification.rs b/crates/buzz-db/src/store/personal_read/classification.rs new file mode 100644 index 00000000000..dfc83abe357 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/classification.rs @@ -0,0 +1,30 @@ +//! Selector eligibility and shared directed-reason rules. Aggregate SQL applies +//! the same eligibility before grouping, covered by the PostgreSQL parity test. +use super::model::{Reason, ELIGIBLE_KINDS}; + +pub(super) fn eligible( + kind: i32, + own: bool, + deleted: bool, + created_ms: i64, + cutoff_ms: i64, +) -> bool { + ELIGIBLE_KINDS.contains(&kind) && !own && !deleted && created_ms >= cutoff_ms +} + +/// Why a message is directed, before conversation membership is known. +pub(super) fn reason(channel_type: &str, actor_hex: &str, tags: &[Vec]) -> Option { + let tagged = |name: &str, matches: &dyn Fn(&str) -> bool| { + tags.iter() + .any(|tag| tag.len() >= 2 && tag[0] == name && matches(&tag[1])) + }; + if channel_type == "dm" { + Some(Reason::Direct) + } else if tagged("p", &|value| value.eq_ignore_ascii_case(actor_hex)) { + Some(Reason::Mention) + } else if tagged("broadcast", &|value| value == "1") { + Some(Reason::Broadcast) + } else { + None + } +} diff --git a/crates/buzz-db/src/store/personal_read/context.rs b/crates/buzz-db/src/store/personal_read/context.rs new file mode 100644 index 00000000000..39a537575aa --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/context.rs @@ -0,0 +1,257 @@ +//! Explicit selectors over the same private frontier authority. No history API. +use super::{classification, model::*, participation, projection::read_account, writes}; +use crate::{observability, Db, DbError, Result}; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use sqlx::{Acquire, Row}; +use std::collections::HashMap; +use uuid::Uuid; + +impl Db { + /// Resolve bounded explicit contexts/messages in a single read-only snapshot. + /// Callers must recheck admission and resource access outside this snapshot. + pub async fn personal_read_contexts( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + retention_seconds: u32, + queries: &[ContextQuery], + ) -> Result { + if queries.is_empty() + || queries.len() > MAX_CONTEXTS + || queries.iter().map(|q| q.message_ids.len()).sum::() > MAX_CONTEXT_MESSAGES + || queries.iter().any(|q| { + q.message_ids + .iter() + .any(|id| writes::event_id(id).is_none()) + }) + { + return Err(DbError::InvalidData("invalid context selectors".into())); + } + let mut conn = observability::acquire_writer( + &self.pool, + observability::WriterOperation::SubscriptionHistory, + ) + .await?; + let mut tx = conn.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *tx) + .await?; + writes::deadlines(&mut tx).await?; + let actor_bytes = actor.to_bytes(); + let account = read_account(&mut tx, retention_seconds).await?; + let mut contexts = Vec::with_capacity(queries.len()); + // Replies whose state turns on conversation membership, by position. + let mut pending = Vec::new(); + for query in queries { + let root = + match writes::valid_target(&mut tx, community, &actor_bytes, &query.target).await { + Ok(Some(root)) => root, + Ok(None) => { + contexts.push(ContextState::Unavailable); + continue; + } + Err(DbError::InvalidData(_)) => { + contexts.push(ContextState::Unknown); + continue; + } + Err(error) => return Err(error), + }; + // A thread's effective prefix includes the channel's whole-channel cut, + // exactly as the sidebar projection counts it. + let prefix: Option> = sqlx::query_scalar( + "SELECT GREATEST( + (SELECT through_timestamp FROM personal_read_frontiers + WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=$4), + (SELECT threads_through_timestamp FROM personal_read_frontiers + WHERE community_id=$1 AND actor=$2 AND channel_id=$3 AND root_id=''::bytea + AND $4<>''::bytea))", + ) + .bind(community.as_uuid()) + .bind(actor_bytes.as_slice()) + .bind(query.target.channel_id) + .bind(&root) + .fetch_one(&mut *tx) + .await?; + let ids: Vec> = query + .message_ids + .iter() + .filter_map(|id| writes::event_id(id)) + .collect(); + let rows = sqlx::query( + "SELECT encode(e.id,'hex') AS id,e.kind,e.created_at,e.received_at, + e.deleted_at IS NOT NULL AS deleted,e.pubkey=$3 AS own, + CASE WHEN octet_length(e.tags::text)<=8192 THEN e.tags ELSE NULL END AS tags, + tm.root_event_id,tm.parent_event_id,c.channel_type::text AS channel_type + FROM events e JOIN channels c ON c.community_id=e.community_id AND c.id=e.channel_id + LEFT JOIN thread_metadata tm ON tm.community_id=e.community_id + AND tm.event_id=e.id AND tm.event_created_at=e.created_at AND tm.channel_id=e.channel_id + WHERE e.community_id=$1 AND e.channel_id=$2 AND e.id=ANY($4)", + ).bind(community.as_uuid()).bind(query.target.channel_id) + .bind(actor_bytes.as_slice()).bind(&ids).fetch_all(&mut *tx).await?; + let by_id: HashMap = rows + .into_iter() + .map(|row| Ok((row.try_get::("id")?, row))) + .collect::>()?; + let mut messages = Vec::with_capacity(ids.len()); + for id in &query.message_ids { + let state = if let Some(row) = by_id.get(&id.to_ascii_lowercase()) { + let tags: Option = row.try_get("tags")?; + let parsed = + tags.and_then(|v| serde_json::from_value::>>(v).ok()); + if let Some(tags) = parsed { + let canonical: Option> = row.try_get("root_event_id")?; + let marked_reply = buzz_core::nip10::parse_thread_markers_from_parts( + tags.iter().map(Vec::as_slice), + ) + .resolve() + .is_some(); + let message_id = writes::event_id(id).unwrap_or_default(); + let is_reply = canonical.as_ref().is_some_and(|r| r != &message_id); + if marked_reply && canonical.is_none() { + MessageReadState::Unknown + } else if (root.is_empty() && is_reply) + || (!root.is_empty() + && (!is_reply || canonical.as_ref() != Some(&root))) + { + // The root's own timeline state is never the thread's state. + MessageReadState::Unavailable + } else { + let created: DateTime = row.try_get("created_at")?; + let received: DateTime = row.try_get("received_at")?; + let kind: i32 = row.try_get("kind")?; + if !classification::eligible( + kind, + row.try_get("own")?, + row.try_get("deleted")?, + created.timestamp_millis(), + account.cutoff_ms, + ) { + MessageReadState::NotCounted + } else if prefix.is_some_and(|p| received <= p) { + MessageReadState::Read + } else { + let reason = classification::reason( + &row.try_get::("channel_type")?, + &actor.to_hex(), + &tags, + ); + // Membership outranks a broadcast and decides a + // plain reply. A DM or mention needs no lookup. + if is_reply + && !matches!(reason, Some(Reason::Direct | Reason::Mention)) + { + let parent: Option> = row.try_get("parent_event_id")?; + if let Some(parent) = parent { + pending.push(( + contexts.len(), + messages.len(), + (query.target.channel_id, parent), + )); + } + } + // Provisional for a pending reply: see `settle`. + if is_reply && reason.is_none() { + MessageReadState::Unknown + } else { + MessageReadState::Unread { reason } + } + } + } + } else { + MessageReadState::Unknown + } + } else { + MessageReadState::Unavailable + }; + messages.push(ContextMessage { + message_id: id.clone(), + state, + }); + } + contexts.push(ContextState::Available { messages }); + } + let targets: Vec<_> = pending.iter().map(|(.., key)| key.clone()).collect(); + let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; + for (context, message, key) in pending { + if let ContextState::Available { messages } = &mut contexts[context] { + settle(&mut messages[message].state, members.get(&key).copied()); + } + } + tx.commit().await?; + Ok(ContextPage { account, contexts }) + } + + /// Final bounded access check on the writer, outside a projection snapshot. + /// Open-channel access is independent of joined-sidebar membership. + pub async fn personal_read_accessible_contexts( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + channels: &[Uuid], + ) -> Result> { + if channels.len() > MAX_CONTEXTS { + return Err(DbError::InvalidData("too many context channels".into())); + } + let mut conn = observability::acquire_writer( + &self.pool, + observability::WriterOperation::Authorization, + ) + .await?; + Ok(sqlx::query_scalar( + "SELECT c.id FROM channels c WHERE c.community_id=$1 AND c.id=ANY($2) + AND c.deleted_at IS NULL AND (c.visibility='open' OR EXISTS ( + SELECT 1 FROM channel_members cm WHERE cm.community_id=$1 + AND cm.channel_id=c.id AND cm.pubkey=$3 AND cm.removed_at IS NULL))", + ) + .bind(community.as_uuid()) + .bind(channels) + .bind(actor.to_bytes().as_slice()) + .fetch_all(&mut *conn) + .await?) + } +} + +/// Apply conversation membership to a pending reply's provisional state: +/// `unknown` for a plain reply, `unread` with reason `broadcast` for a +/// broadcast. An undecided lookup (`None`) leaves it as it is. +fn settle(state: &mut MessageReadState, member: Option) { + match member { + Some(true) => { + *state = MessageReadState::Unread { + reason: Some(Reason::Conversation), + } + } + // A broadcast counts outside the actor's conversations too. + Some(false) if matches!(state, MessageReadState::Unknown) => { + *state = MessageReadState::NotCounted + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn membership_settles_a_pending_reply_and_an_undecided_lookup_fabricates_nothing() { + let broadcast = || MessageReadState::Unread { + reason: Some(Reason::Broadcast), + }; + for (provisional, member, expected) in [ + (MessageReadState::Unknown, Some(true), "conversation"), + (MessageReadState::Unknown, Some(false), "not_counted"), + (MessageReadState::Unknown, None, "unknown"), + (broadcast(), Some(true), "conversation"), + (broadcast(), Some(false), "broadcast"), + (broadcast(), None, "broadcast"), + ] { + let mut state = provisional; + settle(&mut state, member); + let wire = serde_json::to_value(&state).unwrap(); + let got = wire["reason"].as_str().or(wire["status"].as_str()); + assert_eq!(got, Some(expected), "{member:?}"); + } + } +} diff --git a/crates/buzz-db/src/store/personal_read/mod.rs b/crates/buzz-db/src/store/personal_read/mod.rs new file mode 100644 index 00000000000..fb056c600c7 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/mod.rs @@ -0,0 +1,29 @@ +//! Private, signer-owned accessory read progress, separate from NIP-RS events. +//! +//! A frontier is the relay arrival time of the message a context was read +//! through; the unread horizon alone uses author time. Only fixed context +//! intents advance frontiers, never a query scan cap. + +mod classification; +mod context; +mod model; +mod participation; +mod projection; +mod writes; + +pub use model::*; + +#[cfg(test)] +mod postgres_tests; + +#[cfg(test)] +mod participation_postgres_tests; + +#[cfg(test)] +mod projection_postgres_tests; + +#[cfg(test)] +mod threads_postgres_tests; + +#[cfg(test)] +mod arrival_postgres_tests; diff --git a/crates/buzz-db/src/store/personal_read/model.rs b/crates/buzz-db/src/store/personal_read/model.rs new file mode 100644 index 00000000000..5786881ba87 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/model.rs @@ -0,0 +1,256 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Maximum independent operations in one HTTP request. +pub const MAX_INTENTS: usize = 100; +/// Default unread-tracking duration, not event or encrypted NIP-RS retention. +pub const DEFAULT_RETENTION_SECONDS: u32 = 30 * 24 * 60 * 60; + +/// A channel or canonical thread; absence of a root denotes only the channel timeline. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ReadTarget { + /// Channel UUID, interpreted only in the authenticated community. + pub channel_id: Uuid, + /// Canonical thread-root event ID, when targeting one thread. + pub root_id: Option, +} + +/// Fixed operands make retries converge without a server operation journal. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ReadIntent { + /// Advance a context through one fixed message, including equal arrivals. + MarkThrough { + /// Channel or canonical thread being marked. + target: ReadTarget, + /// Fixed anchor; retry must not substitute the latest message. + message_id: String, + }, + /// Advance the channel timeline and every thread in it through one fixed + /// message's arrival time. The anchor may be a reply; ancestry is irrelevant. + MarkChannelRead { + /// Channel being marked, including all of its threads. + channel_id: Uuid, + /// Fixed anchor; retry must not substitute the latest message. + message_id: String, + }, +} + +/// Outcome for one independent transaction, never acknowledged before commit. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum IntentOutcome { + /// The fixed frontier operand committed. + Applied, + /// Missing and forbidden contexts deliberately share one outcome. + Blocked, + /// Invalid operands; no changes committed for this intent. + Invalid, +} + +/// The tracking boundary for the authenticated account. +#[derive(Clone, Debug, Serialize)] +pub struct ReadAccount { + /// Configured tracking duration in seconds. + pub retention_seconds: u32, + /// Read-time author-time cutoff (Unix milliseconds), not a discard boundary. + pub cutoff_ms: i64, +} + +/// Maximum channel summaries in one sidebar page. +pub const MAX_CHANNELS: usize = 20; +/// Maximum unread-thread summaries per channel row. +pub const MAX_THREAD_SUMMARIES: usize = 5; +/// Bounded event evidence per channel; exhaustion is never inferred at this cap. +pub const MAX_CHANNEL_SCAN: usize = 256; +/// Unread-window work budget per channel, before eligibility/ancestry joins. +pub const MAX_UNREAD_SCAN: usize = 4096; +/// Conversation kinds eligible for ordinary unread state (not edits/reactions). +pub const ELIGIBLE_KINDS: [i32; 4] = [9, 40002, 45001, 45003]; + +/// An honest aggregate: capped evidence cannot establish exact zero. +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ReadCount { + /// Exhausted the authoritative candidate set. + Exact { + /// Total within the tracking horizon. + value: u32, + }, + /// Incomplete evidence establishes no positive lower bound. No numeric value. + Unknown, + /// More evidence exists or ancestry/membership could not be proved. + AtLeast { + /// Proven lower bound, not a fabricated badge cap. + value: u32, + }, +} + +impl ReadCount { + pub(super) fn from_evidence(value: u32, complete: bool) -> Self { + if complete { + Self::Exact { value } + } else if value == 0 { + Self::Unknown + } else { + Self::AtLeast { value } + } + } +} + +/// One joined-channel summary, not a second conversation/history API. +#[derive(Debug, Serialize)] +pub struct ChannelReadSummary { + /// Joined channel UUID. + pub channel_id: Uuid, + /// Existing channel name. + pub name: String, + /// Existing channel type. + pub channel_type: String, + /// Archived channels stay in the roster; presentation remains client-owned. + pub archived: bool, + /// Existing DM visibility preference (not an authorization decision). + pub hidden: bool, + /// Unread messages that count: every top-level message, and a reply only + /// when it has a [`Reason`]. Other replies are not unread at all. + pub unread: ReadCount, + /// The unread subset with a [`Reason`]: everything but ordinary top-level + /// messages. This is not Desktop notification eligibility: follows and + /// mutes do not change this count. + pub attention: ReadCount, + /// Last eligible nondeleted event to arrive, inside the unread horizon when + /// any is, whatever its author or read progress: marking through it reads + /// the row. + /// None proves absence only when latest_message_complete is true. + pub latest_message_id: Option, + /// Display activity: the greatest author time (Unix seconds) among eligible + /// nondeleted events, not necessarily latest_message_id's own. None exactly + /// when it is None. + pub latest_message_at: Option, + /// Whether the latest lookup found a result or exhausted channel history. + /// False means the bounded probe found none, but an unexamined tail remains. + pub latest_message_complete: bool, + /// Threads with unread replies, newest unread reply first. + pub threads: ThreadSummaries, +} + +/// A bounded, ordered list of unread threads within one channel row. +#[derive(Debug, Serialize)] +pub struct ThreadSummaries { + /// At most MAX_THREAD_SUMMARIES, by latest_reply_at DESC then root_id ASC. + pub items: Vec, + /// True only when evidence was exhausted and no thread was omitted. + pub complete: bool, +} + +/// Unread replies in one canonical thread; every one has a [`Reason`]. No +/// conversation bytes. +#[derive(Debug, Serialize)] +pub struct ThreadReadSummary { + /// Canonical thread-root event ID. + pub root_id: String, + /// Unread replies in this thread (same definition as the row count). + pub unread: ReadCount, + /// Last observed unread reply to arrive: marking through it reads the thread. + pub latest_reply_id: String, + /// Author time (Unix seconds) of latest_reply_id. + pub latest_reply_at: i64, +} + +/// A bounded roster page, with no cross-page snapshot or removal inference. +#[derive(Debug, Serialize)] +pub struct SidebarPage { + /// Effective read-state lifecycle for this response. + pub account: ReadAccount, + /// Joined channels only, never every accessible public channel. + pub channels: Vec, + /// Exclusive UUID roster cursor. None means this roster scan exhausted. + pub next_cursor: Option, +} + +/// Maximum explicit contexts in one request. +pub const MAX_CONTEXTS: usize = 20; +/// Maximum explicit message selectors across the entire context request. +pub const MAX_CONTEXT_MESSAGES: usize = 100; + +/// A context and concrete messages already known through Nostr history/live reads. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ContextQuery { + /// Channel timeline or canonical thread, never an arbitrary filter. + pub target: ReadTarget, + /// Optional concrete message selectors; not an event history query. + #[serde(default)] + pub message_ids: Vec, +} + +/// Why an unread message is directed at the actor: the first that holds. +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Reason { + /// Its channel is a DM. + Direct, + /// It tags the actor with `p`. + Mention, + /// It replies to a message the actor wrote, or to one the actor also + /// replied to, in the same channel. Only live eligible messages qualify. + Conversation, + /// It carries `broadcast=1`. + Broadcast, +} + +/// Read progress and eligibility for one concrete message, not a public receipt. +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum MessageReadState { + /// Missing, inaccessible, or outside the requested context. No existence oracle. + Unavailable, + /// Evidence cannot safely establish ancestry, eligibility or membership. + Unknown, + /// Not unread: own, deleted, auxiliary, outside the horizon, or a reply + /// with no [`Reason`]. + NotCounted, + /// Covered by this context's frontier. + Read, + /// Counts, and is beyond this context's frontier. + Unread { + /// Null only for an ordinary top-level message. A broadcast reply whose + /// membership is undecided reports `broadcast`. + reason: Option, + }, +} + +/// An explicit message result, in request order. +#[derive(Debug, Serialize)] +pub struct ContextMessage { + /// Requested ID, not an independently disclosed event ID. + pub message_id: String, + /// Actor-private state within the requested context. + #[serde(flatten)] + pub state: MessageReadState, +} + +/// A context result. Denied and missing resources share an indistinguishable shape. +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ContextState { + /// Missing or inaccessible context. + Unavailable, + /// Canonical context could not be proved. + Unknown, + /// Context authority at the response snapshot. + Available { + /// Bounded explicit selectors, in request order. + messages: Vec, + }, +} + +/// Actor-private bounded context response; no cross-request snapshot guarantee. +#[derive(Debug, Serialize)] +pub struct ContextPage { + /// Effective read-state lifecycle for this response. + pub account: ReadAccount, + /// One result per requested context, in request order. + pub contexts: Vec, +} diff --git a/crates/buzz-db/src/store/personal_read/participation.rs b/crates/buzz-db/src/store/personal_read/participation.rs new file mode 100644 index 00000000000..682bca30489 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/participation.rs @@ -0,0 +1,127 @@ +//! Conversation membership from the existing thread store, bounded by time. +use super::model::ELIGIBLE_KINDS; +use crate::Result; +use buzz_core::CommunityId; +use sqlx::{Acquire, PgConnection, Row}; +use std::collections::HashMap; +use uuid::Uuid; + +// Bound multiplicative work independently of the unread evidence window. +const MAX_PARENTS: usize = 1024; + +/// Whether each `(channel, parent)` is one of the actor's conversations: the +/// actor wrote the parent or has a reply to it. Only live eligible messages +/// qualify. An absent key is undecided: past the target cap, or the statement +/// deadline expired. Membership is independent of unread retention and read +/// frontiers. +pub(super) async fn resolve( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], + targets: &[(Uuid, Vec)], +) -> Result), bool>> { + if targets.is_empty() { + return Ok(HashMap::new()); + } + let targets = select_targets(targets); + let channels: Vec<_> = targets.iter().map(|(channel, _)| *channel).collect(); + let parents: Vec<_> = targets.iter().map(|(_, parent)| parent.clone()).collect(); + // Optional inference must not abort authoritative unread/frontier reads. + // A nested transaction is a savepoint; rollback also restores the caller's + // statement timeout. No state is written in this read-only inference. + let mut budget = conn.begin().await?; + sqlx::query("SET LOCAL statement_timeout = '500ms'") + .execute(&mut *budget) + .await?; + sqlx::query("SET LOCAL jit = off") + .execute(&mut *budget) + .await?; + // Exact: a row cap would lose a real member behind a busy parent's other + // replies. LATERAL ... LIMIT 1 keeps the work on the replies under the + // requested parents; a plain EXISTS may be hashed over the whole tenant. + let result = sqlx::query( + "SELECT t.channel_id,t.parent_id,(own.hit OR replied.hit) IS TRUE AS member + FROM unnest($3::uuid[],$4::bytea[]) t(channel_id,parent_id) + LEFT JOIN LATERAL ( + SELECT e.pubkey=$2 AND e.deleted_at IS NULL AND e.kind=ANY($5) AS hit + FROM events e WHERE e.community_id=$1 AND e.channel_id=t.channel_id AND e.id=t.parent_id + ORDER BY e.created_at DESC LIMIT 1 + ) own ON true + LEFT JOIN LATERAL ( + SELECT true AS hit FROM thread_metadata tm JOIN events e ON e.community_id=$1 + AND e.channel_id=t.channel_id AND e.created_at=tm.event_created_at AND e.id=tm.event_id + WHERE tm.community_id=$1 AND tm.channel_id=t.channel_id AND tm.parent_event_id=t.parent_id + AND e.pubkey=$2 AND e.deleted_at IS NULL AND e.kind=ANY($5) + AND own.hit IS NOT TRUE + LIMIT 1 + ) replied ON true", + ) + .bind(community.as_uuid()) + .bind(actor) + .bind(channels) + .bind(parents) + .bind(ELIGIBLE_KINDS.as_slice()) + .fetch_all(&mut *budget) + .await; + budget.rollback().await?; + let rows = match result { + Ok(rows) => rows, + Err(sqlx::Error::Database(error)) + if matches!(error.code().as_deref(), Some("57014" | "55P03")) => + { + return Ok(HashMap::new()); + } + Err(error) => return Err(error.into()), + }; + rows.into_iter() + .map(|row| { + Ok(( + (row.try_get("channel_id")?, row.try_get("parent_id")?), + row.try_get("member")?, + )) + }) + .collect() +} + +// Keep selection independent of the optional SQL deadline: a timeout must not +// hide a regression in the cardinality bound. +fn select_targets(targets: &[(Uuid, Vec)]) -> Vec<(Uuid, Vec)> { + let mut targets = targets.to_vec(); + targets.sort_unstable(); + targets.dedup(); + targets.truncate(MAX_PARENTS); + targets +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_selection_caps_unique_parents_independently_of_sql_timeout() { + // Literal contract boundaries deliberately do not derive from MAX_PARENTS. + for count in [0_u32, 1, 1023, 1024, 1025] { + let unique: Vec<_> = (0..count) + .map(|i| (Uuid::nil(), i.to_be_bytes().to_vec())) + .collect(); + let input: Vec<_> = unique + .iter() + .rev() + .chain(unique.iter().rev()) + .cloned() + .collect(); + let selected = select_targets(&input); + assert_eq!(selected, unique[..unique.len().min(1024)], "count {count}"); + } + } + + #[test] + fn target_selection_keeps_channel_identity() { + let first = (Uuid::from_u128(1), vec![7; 32]); + let second = (Uuid::from_u128(2), vec![7; 32]); + assert_eq!( + select_targets(&[second.clone(), first.clone(), second.clone()]), + vec![first, second] + ); + } +} diff --git a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs new file mode 100644 index 00000000000..682cb045af3 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs @@ -0,0 +1,670 @@ +//! Which replies count: the direct-parent conversation rule, its deletion and +//! channel edges, and the bounds on the membership lookup. +use super::*; +use crate::{ + channel::{ChannelType, ChannelVisibility}, + Db, +}; +use buzz_core::CommunityId; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use serde_json::{json, Value}; +use sqlx::PgPool; +use uuid::Uuid; + +const DAY: u64 = 86_400; + +/// One community with the reading actor and a peer. +struct World { + db: Db, + pool: PgPool, + community: CommunityId, + actor: Keys, + peer: Keys, + now: u64, +} + +impl World { + async fn new() -> Self { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("conversation-{}.local", Uuid::new_v4())) + .await + .unwrap() + .id; + Self { + db, + pool, + community, + actor: Keys::generate(), + peer: Keys::generate(), + now: Timestamp::now().as_secs(), + } + } + + /// A channel the actor has joined. + async fn channel(&self) -> Uuid { + self.db + .create_channel( + self.community, + &Uuid::new_v4().to_string(), + ChannelType::Stream, + ChannelVisibility::Open, + None, + &self.actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id + } + + /// A top-level message. + async fn post(&self, channel: Uuid, author: &Keys, at: u64, tags: Vec) -> nostr::Event { + let event = EventBuilder::new(Kind::Custom(9), Uuid::new_v4().to_string()) + .tags(tags) + .custom_created_at(Timestamp::from(at)) + .sign_with_keys(author) + .unwrap(); + self.db + .insert_event(self.community, &event, Some(channel)) + .await + .unwrap(); + event + } + + /// A canonical reply to `parent` in `root`'s thread. `parent` need not be + /// stored in `channel`: thread metadata records what the reply claims. + async fn reply( + &self, + channel: Uuid, + author: &Keys, + root: &nostr::Event, + parent: Option<&nostr::Event>, + at: u64, + tags: Vec, + ) -> nostr::Event { + let event = self.post(channel, author, at, tags).await; + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$6,1)") + .bind(self.community.as_uuid()).bind(event.id.as_bytes().as_slice()).bind(at as f64) + .bind(channel).bind(root.id.as_bytes().as_slice()) + .bind(parent.map(|parent| parent.id.as_bytes().as_slice())) + .execute(&self.pool).await.unwrap(); + event + } + + /// The storage write that author and staff deletions share. + async fn delete(&self, event: &nostr::Event) { + assert!(self + .db + .soft_delete_event_and_update_thread(self.community, event.id.as_bytes(), None, None) + .await + .unwrap()); + } + + async fn row(&self, channel: Uuid) -> ChannelReadSummary { + self.db + .personal_read_sidebar_channels( + self.community, + &self.actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[channel], + ) + .await + .unwrap() + .channels + .remove(0) + } + + /// The wire state of one message in the channel timeline or `root`'s thread. + async fn state( + &self, + channel: Uuid, + root: Option<&nostr::Event>, + message: &nostr::Event, + ) -> Value { + let page = self + .db + .personal_read_contexts( + self.community, + &self.actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: root.map(|root| root.id.to_hex()), + }, + message_ids: vec![message.id.to_hex()], + }], + ) + .await + .unwrap(); + let mut state = wire(&page)["contexts"][0]["messages"][0].take(); + state.as_object_mut().unwrap().remove("message_id"); + state + } + + fn mention(&self) -> Tag { + Tag::parse(["p", &self.actor.public_key().to_hex()]).unwrap() + } +} + +fn wire(value: &T) -> Value { + serde_json::to_value(value).unwrap() +} + +fn exact(value: u32) -> Value { + json!({"status":"exact","value":value}) +} + +/// Unread with a reason; `Value::Null` for an ordinary top-level message. +fn unread(reason: impl Into) -> Value { + json!({"status":"unread","reason":reason.into()}) +} + +fn status(status: &str) -> Value { + json!({ "status": status }) +} + +fn broadcast() -> Tag { + Tag::parse(["broadcast", "1"]).unwrap() +} + +fn item(root: &nostr::Event, unread: u32, latest: &nostr::Event) -> Value { + json!({"root_id":root.id.to_hex(),"unread":exact(unread), + "latest_reply_id":latest.id.to_hex(),"latest_reply_at":latest.created_at.as_secs()}) +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_reply_counts_only_in_a_conversation_the_actor_wrote_or_replied_to() { + let w = World::new().await; + // Every witness predates the unread horizon: membership does not expire. + let old = w.now - 40 * DAY; + + // The actor wrote the root. A peer answers it; another peer reply continues + // under that answer, where the actor has written nothing. + let c = w.channel().await; + let root = w.post(c, &w.actor, old, vec![]).await; + let answer = w + .reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) + .await; + let nested = w + .reply(c, &w.peer, &root, Some(&answer), w.now + 2, vec![]) + .await; + assert_eq!(w.state(c, None, &root).await, status("not_counted")); + assert_eq!( + w.state(c, Some(&root), &answer).await, + unread("conversation") + ); + assert_eq!( + w.state(c, Some(&root), &nested).await, + status("not_counted") + ); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(1)); + assert_eq!(wire(&row.attention), exact(1)); + // The newer reply that does not count is not the thread's preview. + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 1, &answer)],"complete":true}) + ); + + // Joining the nested conversation makes its earlier reply count. The + // actor's own reply adds nothing. + w.reply(c, &w.actor, &root, Some(&answer), w.now + 3, vec![]) + .await; + assert_eq!( + w.state(c, Some(&root), &nested).await, + unread("conversation") + ); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(2)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 2, &nested)],"complete":true}) + ); + + // A peer's thread. The actor replied to one of two parents, long ago. + let c = w.channel().await; + let root = w.post(c, &w.peer, old, vec![]).await; + let joined = w.reply(c, &w.peer, &root, Some(&root), old, vec![]).await; + let other = w.reply(c, &w.peer, &root, Some(&root), old, vec![]).await; + w.reply(c, &w.actor, &root, Some(&joined), old, vec![]) + .await; + let sibling = w + .reply(c, &w.peer, &root, Some(&joined), w.now + 1, vec![]) + .await; + let elsewhere = w + .reply(c, &w.peer, &root, Some(&other), w.now + 2, vec![]) + .await; + assert_eq!( + w.state(c, Some(&root), &sibling).await, + unread("conversation") + ); + assert_eq!( + w.state(c, Some(&root), &elsewhere).await, + status("not_counted") + ); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(1)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 1, &sibling)],"complete":true}) + ); + + // A conversation the actor never joined: one unread top-level message, and + // a reply that is not unread even in its own thread. + let c = w.channel().await; + let root = w.post(c, &w.peer, w.now, vec![]).await; + let reply = w + .reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) + .await; + assert_eq!(w.state(c, None, &root).await, unread(Value::Null)); + assert_eq!(w.state(c, Some(&root), &reply).await, status("not_counted")); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(1)); + assert_eq!(wire(&row.attention), exact(0)); + assert_eq!(wire(&row.threads), json!({"items":[],"complete":true})); + + // A reply whose parent was never recorded is undecided, not absent. + let orphan = w.reply(c, &w.peer, &root, None, w.now + 2, vec![]).await; + assert_eq!(w.state(c, Some(&root), &orphan).await, status("unknown")); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), json!({"status":"at_least","value":1})); + assert_eq!(wire(&row.attention), status("unknown")); + assert_eq!(wire(&row.threads), json!({"items":[],"complete":false})); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_directed_reply_counts_outside_the_actors_conversations() { + let w = World::new().await; + let c = w.channel().await; + let root = w.post(c, &w.peer, w.now, vec![]).await; + let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await; + let own = w + .reply(c, &w.actor, &root, Some(&root), w.now + 1, vec![]) + .await; + let mention = w + .reply( + c, + &w.peer, + &root, + Some(&parent), + w.now + 2, + vec![w.mention()], + ) + .await; + let shout = w + .reply( + c, + &w.peer, + &root, + Some(&parent), + w.now + 3, + vec![broadcast()], + ) + .await; + let both = w + .reply( + c, + &w.peer, + &root, + Some(&parent), + w.now + 4, + vec![broadcast(), w.mention()], + ) + .await; + assert_eq!(w.state(c, Some(&root), &own).await, status("not_counted")); + assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention")); + assert_eq!(w.state(c, Some(&root), &shout).await, unread("broadcast")); + assert_eq!(w.state(c, Some(&root), &both).await, unread("mention")); + // The root, the actor's sibling `parent`, and the three directed replies. + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(5)); + assert_eq!(wire(&row.attention), exact(4)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 4, &both)],"complete":true}) + ); + + // Conversation outranks broadcast, never mention. Counts do not move. + w.reply(c, &w.actor, &root, Some(&parent), w.now + 5, vec![]) + .await; + assert_eq!(w.state(c, Some(&root), &mention).await, unread("mention")); + assert_eq!( + w.state(c, Some(&root), &shout).await, + unread("conversation") + ); + assert_eq!(wire(&w.row(c).await.unread), exact(5)); + + // In a DM every peer message is direct, tagged or not, joined or not. + let dm = w.channel().await; + sqlx::query("UPDATE channels SET channel_type='dm' WHERE community_id=$1 AND id=$2") + .bind(w.community.as_uuid()) + .bind(dm) + .execute(&w.pool) + .await + .unwrap(); + let root = w.post(dm, &w.peer, w.now, vec![w.mention()]).await; + let parent = w + .reply(dm, &w.peer, &root, Some(&root), w.now, vec![]) + .await; + let reply = w + .reply(dm, &w.peer, &root, Some(&parent), w.now + 1, vec![]) + .await; + assert_eq!(w.state(dm, None, &root).await, unread("direct")); + assert_eq!(w.state(dm, Some(&root), &reply).await, unread("direct")); + let row = w.row(dm).await; + assert_eq!(wire(&row.unread), exact(3)); + assert_eq!(wire(&row.attention), exact(3)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn eligibility_then_the_read_frontier_are_reported_before_membership() { + let w = World::new().await; + let c = w.channel().await; + let root = w.post(c, &w.peer, w.now, vec![]).await; + let parent = w.reply(c, &w.peer, &root, Some(&root), w.now, vec![]).await; + let reply = |author, at, tags| w.reply(c, author, &root, Some(&parent), w.now + at, tags); + // Peer replies to a parent the actor neither wrote nor replied to. + let outside = reply(&w.peer, 1, vec![]).await; + let deleted = reply(&w.peer, 2, vec![]).await; + w.delete(&deleted).await; + let own = w + .reply(c, &w.actor, &root, Some(&root), w.now + 3, vec![]) + .await; + let anchor = reply(&w.peer, 4, vec![w.mention()]).await; + let later = reply(&w.peer, 5, vec![]).await; + assert_eq!( + w.state(c, Some(&root), &outside).await, + status("not_counted") + ); + + assert_eq!( + w.db.apply_personal_read_intent( + w.community, + &w.actor.public_key(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: c, + root_id: Some(root.id.to_hex()), + }, + message_id: anchor.id.to_hex(), + }, + ) + .await + .unwrap(), + IntentOutcome::Applied + ); + for (message, expected) in [ + (&outside, "read"), + (&deleted, "not_counted"), + (&own, "not_counted"), + (&anchor, "read"), + (&later, "not_counted"), + ] { + assert_eq!(w.state(c, Some(&root), message).await, status(expected)); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_deleted_message_is_no_witness_and_a_surviving_reply_still_is() { + let w = World::new().await; + let c = w.channel().await; + let root = w.post(c, &w.peer, w.now - 40 * DAY, vec![]).await; + let reply = |author, parent, at| w.reply(c, author, &root, Some(parent), w.now + at, vec![]); + + // The actor wrote the parent and never replied under it. + let wrote = reply(&w.actor, &root, 0).await; + let to_wrote = reply(&w.peer, &wrote, 10).await; + // The actor's only reply to a peer's parent. + let once = reply(&w.peer, &root, 0).await; + let only = reply(&w.actor, &once, 1).await; + let to_once = reply(&w.peer, &once, 11).await; + // Two replies by the actor to a peer's parent. + let twice = reply(&w.peer, &root, 0).await; + let first = reply(&w.actor, &twice, 1).await; + reply(&w.actor, &twice, 2).await; + let to_twice = reply(&w.peer, &twice, 12).await; + // The actor wrote the parent and also replied under it. + let both = reply(&w.actor, &root, 0).await; + let under = reply(&w.actor, &both, 1).await; + let to_both = reply(&w.peer, &both, 13).await; + + // Whether each counts: `unread` in a conversation, or else `not_counted`. + let counted = || async { + let mut counted = Vec::new(); + for message in [&to_wrote, &to_once, &to_twice, &to_both] { + let state = w.state(c, Some(&root), message).await; + let yes = state == unread("conversation"); + assert!(yes || state == status("not_counted"), "{state}"); + counted.push(yes); + } + counted + }; + let (yes, no) = (true, false); + // `once` and `twice` are peer replies to the root, which the actor has + // replied to (`wrote`, `both`), so they count as well. + assert_eq!(counted().await, [yes, yes, yes, yes]); + assert_eq!(wire(&w.row(c).await.unread), exact(6)); + + // Each deletion changes only its own parent's conversation. + w.delete(&wrote).await; + assert_eq!(counted().await, [no, yes, yes, yes]); + w.delete(&only).await; + assert_eq!(counted().await, [no, no, yes, yes]); + w.delete(&first).await; + assert_eq!(counted().await, [no, no, yes, yes]); + w.delete(&both).await; + assert_eq!(counted().await, [no, no, yes, yes]); + assert_eq!(wire(&w.row(c).await.unread), exact(2)); + w.delete(&under).await; + assert_eq!(counted().await, [no, no, yes, no]); + // With `wrote` and `both` gone the actor has no reply to the root either. + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(1)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 1, &to_twice)],"complete":true}) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn membership_is_scoped_to_the_replys_own_channel() { + let w = World::new().await; + let (a, b) = (w.channel().await, w.channel().await); + let old = w.now - 40 * DAY; + + // The actor replied to a peer's parent, but that reply is stored in B. + let theirs = w.post(a, &w.peer, old, vec![]).await; + w.reply(b, &w.actor, &theirs, Some(&theirs), old, vec![]) + .await; + let in_a = w + .reply(a, &w.peer, &theirs, Some(&theirs), w.now, vec![]) + .await; + assert_eq!( + w.state(a, Some(&theirs), &in_a).await, + status("not_counted") + ); + assert_eq!(wire(&w.row(a).await.unread), exact(0)); + + // The actor wrote a parent in A. A peer reply in B claims it as its parent. + let mine = w.post(a, &w.actor, old, vec![]).await; + w.reply(b, &w.peer, &mine, Some(&mine), w.now, vec![]).await; + let row = w.row(b).await; + assert_eq!(wire(&row.unread), exact(0)); + assert_eq!(wire(&row.threads), json!({"items":[],"complete":true})); + + // The same parents count once the actor is a member in the reply's channel. + w.reply(a, &w.actor, &theirs, Some(&theirs), old, vec![]) + .await; + let to_mine = w.reply(a, &w.peer, &mine, Some(&mine), w.now, vec![]).await; + assert_eq!( + w.state(a, Some(&theirs), &in_a).await, + unread("conversation") + ); + assert_eq!( + w.state(a, Some(&mine), &to_mine).await, + unread("conversation") + ); + assert_eq!(wire(&w.row(a).await.unread), exact(2)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn membership_is_exact_behind_a_busy_parent() { + let w = World::new().await; + let c = w.channel().await; + let old = w.now - 40 * DAY; + let root = w.post(c, &w.peer, old, vec![]).await; + // The actor's reply is the oldest of 259 under one parent: a 256-row + // window over the parent's replies would never reach it. + w.reply(c, &w.actor, &root, Some(&root), old, vec![]).await; + let mut last = root.clone(); + for i in 0..258 { + last = w + .reply(c, &w.peer, &root, Some(&root), w.now + i, vec![]) + .await; + } + assert_eq!(w.state(c, Some(&root), &last).await, unread("conversation")); + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(258)); + assert_eq!(wire(&row.attention), exact(258)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&root, 258, &last)],"complete":true}) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn threads_are_capped_after_replies_that_do_not_count_are_removed() { + let w = World::new().await; + let c = w.channel().await; + let old = w.now - 40 * DAY; + // The actor's thread has the oldest unread reply. Five unjoined threads + // are newer and would fill the list if the cap came first. + let mine = w.post(c, &w.actor, old, vec![]).await; + let answer = w.reply(c, &w.peer, &mine, Some(&mine), w.now, vec![]).await; + for i in 1..=5 { + let root = w.post(c, &w.peer, old, vec![]).await; + w.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![]) + .await; + } + let row = w.row(c).await; + assert_eq!(wire(&row.unread), exact(1)); + assert_eq!( + wire(&row.threads), + json!({"items":[item(&mine, 1, &answer)],"complete":true}) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn the_parent_budget_leaves_replies_undecided_and_fabricates_no_absence() { + let w = World::new().await; + let c = w.channel().await; + // The actor is in the first conversation. Whether it is inside the budget + // depends on ID order, so only the bounds are asserted. + for i in 0..1025 { + let author = if i == 0 { &w.actor } else { &w.peer }; + let root = w.post(c, author, w.now, vec![]).await; + w.reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) + .await; + if i == 1023 { + // 1024 parents fit: 1023 peer roots, and the reply to the actor's. + // The independent SQL deadline may still withhold the answer. + let row = w.row(c).await; + assert!( + [exact(1024), json!({"status":"at_least","value":1023})] + .contains(&wire(&row.unread)), + "{:?}", + row.unread + ); + assert!( + [exact(1), status("unknown")].contains(&wire(&row.attention)), + "{:?}", + row.attention + ); + } + } + // 1025 parents do not: one reply is undecided, so nothing is exact. + let row = w.row(c).await; + assert!( + [1024, 1025] + .map(|value| json!({"status":"at_least","value":value})) + .contains(&wire(&row.unread)), + "{:?}", + row.unread + ); + assert!( + [json!({"status":"at_least","value":1}), status("unknown")].contains(&wire(&row.attention)), + "{:?}", + row.attention + ); + assert!(!row.threads.complete); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_lookup_timeout_decides_nothing_and_preserves_the_callers_transaction() { + let w = World::new().await; + let c = w.channel().await; + let root = w.post(c, &w.peer, w.now, vec![]).await; + let reply = w + .reply(c, &w.peer, &root, Some(&root), w.now + 1, vec![]) + .await; + let mut held = w.pool.begin().await.unwrap(); + // Force the real resolver to time out, then check that its outer snapshot + // and original statement budget remain usable. + sqlx::query("LOCK TABLE thread_metadata IN ACCESS EXCLUSIVE MODE") + .execute(&mut *held) + .await + .unwrap(); + let mut reader = w.pool.begin().await.unwrap(); + sqlx::query("SET LOCAL statement_timeout='2000ms'") + .execute(&mut *reader) + .await + .unwrap(); + for lock_timeout in ["0", "10ms"] { + sqlx::query("SELECT set_config('lock_timeout',$1,true)") + .bind(lock_timeout) + .execute(&mut *reader) + .await + .unwrap(); + sqlx::query("SET LOCAL jit=on") + .execute(&mut *reader) + .await + .unwrap(); + let result = participation::resolve( + &mut reader, + w.community, + &w.actor.public_key().to_bytes(), + &[(c, root.id.as_bytes().to_vec())], + ) + .await + .unwrap(); + assert!(result.is_empty(), "timeout provides no negative evidence"); + let setting: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *reader) + .await + .unwrap(); + assert_eq!(setting, "2s"); + let jit: String = sqlx::query_scalar("SHOW jit") + .fetch_one(&mut *reader) + .await + .unwrap(); + assert_eq!(jit, "on", "optional inference restores caller settings"); + } + reader.rollback().await.unwrap(); + held.rollback().await.unwrap(); + assert_eq!(w.state(c, Some(&root), &reply).await, status("not_counted")); +} diff --git a/crates/buzz-db/src/store/personal_read/postgres_tests.rs b/crates/buzz-db/src/store/personal_read/postgres_tests.rs new file mode 100644 index 00000000000..8339cb9a4ab --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -0,0 +1,1051 @@ +use super::*; +use crate::{ + channel::{ChannelType, ChannelVisibility}, + Db, +}; +use buzz_core::CommunityId; +use nostr::{EventBuilder, Keys, Kind}; +use sqlx::PgPool; +use uuid::Uuid; + +pub(super) async fn fixture() -> (Db, PgPool, CommunityId, Uuid, Keys, nostr::Event) { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("personal-read-{}.local", Uuid::new_v4())) + .await + .unwrap() + .id; + let actor = Keys::generate(); + let channel = db + .create_channel( + community, + "private reads", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let event = EventBuilder::new(Kind::Custom(9), "read this, not its sibling") + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + (db, pool, community, channel, actor, event) +} + +/// Move every community message past the default horizon by author time, the +/// only clock the unread window reads. +async fn expire(pool: &PgPool, community: CommunityId) { + sqlx::query("UPDATE events SET created_at=created_at-interval '31 days' WHERE community_id=$1") + .bind(community.as_uuid()) + .execute(pool) + .await + .unwrap(); +} + +async fn add_history(db: &Db, community: CommunityId, channel: Uuid, count: usize) -> nostr::Event { + let author = Keys::generate(); + let mut last = None; + let base = nostr::Timestamp::now().as_secs(); + for i in 0..count as u64 { + let event = EventBuilder::new(Kind::Custom(9), format!("history {i}")) + .custom_created_at(nostr::Timestamp::from(base + i)) + .sign_with_keys(&author) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + last = Some(event); + } + last.unwrap() +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_sidebar_marked_history_keeps_latest_but_unscanned_threads_are_unknown() { + let (db, _pool, community, channel, actor, _) = fixture().await; + let last = add_history(&db, community, channel, MAX_UNREAD_SCAN + 44).await; + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id: last.id.to_hex(), + }, + ) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); + let wire = serde_json::to_value(&page).unwrap(); + assert_eq!( + wire["channels"][0]["unread"], + serde_json::json!({"status":"unknown"}) + ); + assert_eq!( + wire["channels"][0]["attention"], + serde_json::json!({"status":"unknown"}) + ); + assert_eq!( + page.channels[0].latest_message_id.as_deref(), + Some(last.id.to_hex().as_str()) + ); + assert!(page.channels[0].latest_message_complete); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_sidebar_author_window_excludes_expired_and_late_old_messages() { + let (db, pool, community, channel, actor, _) = fixture().await; + let latest = add_history(&db, community, channel, MAX_UNREAD_SCAN + 44).await; + expire(&pool, community).await; + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + matches!(page.channels[0].unread, ReadCount::Exact { value: 0 }), + "an empty author-time window proves exhaustion" + ); + assert_eq!( + page.channels[0].latest_message_id.as_deref(), + Some(latest.id.to_hex().as_str()) + ); + assert!(page.channels[0].latest_message_complete); + // Acceptance time is irrelevant: a message accepted now with an author + // time beyond the horizon stays out; one authored now is counted. + let now = nostr::Timestamp::now().as_secs(); + for (age, unread) in [(40 * 86400, 0), (0, 1)] { + let event = EventBuilder::new(Kind::Custom(9), "accepted now") + .custom_created_at(nostr::Timestamp::from(now - age)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + matches!(page.channels[0].unread, ReadCount::Exact { value } if value == unread), + "age={age}" + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_sidebar_window_budget_counts_boundary_and_ineligible_tail() { + let (db, pool, community, channel, actor, _) = fixture().await; + // The fixture contributes one event, so this is exactly the evidence budget. + add_history(&db, community, channel, MAX_UNREAD_SCAN - 1).await; + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + matches!(page.channels[0].unread, ReadCount::Exact { value } if value == MAX_UNREAD_SCAN as u32) + ); + let overflow = EventBuilder::new(Kind::Custom(9), "one beyond the budget") + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &overflow, Some(channel)) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + matches!(page.channels[0].unread, ReadCount::AtLeast { value } if value == MAX_UNREAD_SCAN as u32) + ); + // Expire all but 601 messages. Of these, 300 are own and 300 deleted. + // Eligibility is downstream of the bounded unread window, not the old 256 cap. + sqlx::query("WITH ranked AS (SELECT created_at,id,row_number() OVER (ORDER BY created_at DESC,id) AS n FROM events WHERE community_id=$1 AND channel_id=$2) + UPDATE events e SET created_at=CASE WHEN r.n>601 THEN e.created_at-interval '31 days' ELSE e.created_at END, + pubkey=CASE WHEN r.n<=300 THEN $3 ELSE e.pubkey END, + deleted_at=CASE WHEN r.n>300 AND r.n<=600 THEN now() ELSE NULL END + FROM ranked r WHERE e.community_id=$1 AND e.created_at=r.created_at AND e.id=r.id") + .bind(community.as_uuid()).bind(channel).bind(actor.public_key().to_bytes().as_slice()).execute(&pool).await.unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 1 } + )); + // Filtering ineligible evidence must not erase the raw-window overflow. + sqlx::query( + "UPDATE events SET created_at=date_trunc('second',now()),pubkey=$2 WHERE community_id=$1 AND channel_id=$3", + ) + .bind(community.as_uuid()) + .bind(actor.public_key().to_bytes().as_slice()) + .bind(channel) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_sidebar_is_read_only_and_does_not_wait_for_account() { + let (db, pool, community, channel, actor, event) = fixture().await; + db.personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "GET must not create private state"); + let mark = ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: event.id.to_hex(), + }; + db.apply_personal_read_intent(community, &actor.public_key(), &mark) + .await + .unwrap(); + let mut held = pool.begin().await.unwrap(); + sqlx::query("SELECT actor FROM personal_read_accounts WHERE community_id=$1 FOR UPDATE") + .bind(community.as_uuid()) + .fetch_all(&mut *held) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 0 } + )); + held.rollback().await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_intent_does_not_lock_shared_conversation_rows() { + let (db, pool, community, channel, actor, event) = fixture().await; + sqlx::query("UPDATE channels SET ttl_seconds=86400 WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(channel) + .execute(&pool) + .await + .unwrap(); + let mut held = pool.begin().await.unwrap(); + super::writes::lock_account(&mut held, community, &actor.public_key().to_bytes()) + .await + .unwrap(); + let result = super::writes::apply( + &mut held, + community, + &actor.public_key().to_bytes(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id: event.id.to_hex(), + }, + ) + .await + .unwrap(); + assert!(matches!(result, IntentOutcome::Applied)); + // Exercise the actual event-insert TTL trigger while private progress is + // uncommitted, then verify event deletion can update the observed row. + let incoming = EventBuilder::new(Kind::Custom(9), "concurrent ephemeral ingest") + .sign_with_keys(&Keys::generate()) + .unwrap(); + tokio::time::timeout( + std::time::Duration::from_secs(2), + db.insert_event(community, &incoming, Some(channel)), + ) + .await + .unwrap() + .unwrap(); + let mut legacy = pool.begin().await.unwrap(); + sqlx::query("SET LOCAL lock_timeout='100ms'") + .execute(&mut *legacy) + .await + .unwrap(); + sqlx::query("UPDATE channels SET ttl_deadline=clock_timestamp()+interval '1 day' WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()).bind(channel).execute(&mut *legacy).await.unwrap(); + sqlx::query("UPDATE events SET deleted_at=clock_timestamp() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .execute(&mut *legacy) + .await + .unwrap(); + legacy.rollback().await.unwrap(); + held.rollback().await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_diff_alone_leaves_the_sidebar_row_unchanged() { + let (db, _, community, channel, actor, _) = fixture().await; + let mut rows = Vec::new(); + for diff in [false, true] { + if diff { + // Newest in the channel and addressed to the actor: as loud as a + // diff can be. + let at = nostr::Timestamp::now().as_secs() + 5; + let event = EventBuilder::new(Kind::Custom(40008), "a diff") + .custom_created_at(nostr::Timestamp::from(at)) + .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + } + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 1 } + )); + rows.push(serde_json::to_value(&page.channels[0]).unwrap()); + } + assert_eq!(rows[0], rows[1], "not unread, not attention, not latest"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_latest_includes_own_and_excludes_deleted_auxiliary() { + let (db, pool, community, channel, actor, _) = fixture().await; + let base = nostr::Timestamp::now().as_secs(); + let own = EventBuilder::new(Kind::Custom(9), "own latest") + .custom_created_at(nostr::Timestamp::from(base + 1)) + .sign_with_keys(&actor) + .unwrap(); + db.insert_event(community, &own, Some(channel)) + .await + .unwrap(); + for (offset, kind) in [(2, 9), (4, 7)] { + let event = EventBuilder::new(Kind::Custom(kind), format!("ineligible {offset}")) + .custom_created_at(nostr::Timestamp::from(base + offset)) + .sign_with_keys(&actor) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + if offset == 2 { + sqlx::query( + "UPDATE events SET deleted_at=clock_timestamp() WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + } + } + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert_eq!( + page.channels[0].latest_message_id.as_deref(), + Some(own.id.to_hex().as_str()) + ); + assert!(page.channels[0].latest_message_complete); + expire(&pool, community).await; + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert_eq!( + page.channels[0].latest_message_id.as_deref(), + Some(own.id.to_hex().as_str()) + ); + assert!(page.channels[0].latest_message_complete); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_latest_old_message_is_not_an_empty_channel() { + let (db, pool, community, channel, actor, event) = fixture().await; + expire(&pool, community).await; + let empty = db + .create_channel( + community, + "truly empty", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + let old = page + .channels + .iter() + .find(|c| c.channel_id == channel) + .unwrap(); + assert_eq!( + old.latest_message_id.as_deref(), + Some(event.id.to_hex().as_str()) + ); + assert!(old.latest_message_complete); + assert!(matches!(old.unread, ReadCount::Exact { value: 0 })); + let empty = page + .channels + .iter() + .find(|c| c.channel_id == empty) + .unwrap(); + assert!(empty.latest_message_id.is_none()); + assert!(empty.latest_message_complete); + // A long run of ineligible rows must instead report that latest is unknown. + sqlx::query("UPDATE events SET kind=7 WHERE community_id=$1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + add_history(&db, community, channel, MAX_UNREAD_SCAN + 44).await; + sqlx::query("UPDATE events SET kind=7 WHERE community_id=$1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + let unknown = page + .channels + .iter() + .find(|c| c.channel_id == channel) + .unwrap(); + assert!(unknown.latest_message_id.is_none()); + assert!(!unknown.latest_message_complete); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { + let (db, pool, community, channel, actor, event) = fixture().await; + let target = ReadTarget { + channel_id: channel, + root_id: None, + }; + let invalid = ReadIntent::MarkThrough { + target: target.clone(), + message_id: "not an event id".into(), + }; + assert_eq!( + db.apply_personal_read_intent(community, &actor.public_key(), &invalid) + .await + .unwrap(), + IntentOutcome::Invalid + ); + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "invalid intent rolls back account creation"); + let intent = ReadIntent::MarkThrough { + target: target.clone(), + message_id: event.id.to_hex(), + }; + for _ in 0..2 { + assert_eq!( + db.apply_personal_read_intent(community, &actor.public_key(), &intent) + .await + .unwrap(), + IntentOutcome::Applied + ); + } + // Arrives after `event`; its earlier author time must not matter. + let later = EventBuilder::new(Kind::Custom(9), "later") + .custom_created_at(nostr::Timestamp::from(event.created_at.as_secs() - 10)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &later, Some(channel)) + .await + .unwrap(); + for (anchor, why) in [ + (&later, "a later arrival advances the frontier"), + (&event, "an earlier anchor applies without moving it back"), + ] { + let intent = ReadIntent::MarkThrough { + target: target.clone(), + message_id: anchor.id.to_hex(), + }; + assert_eq!( + db.apply_personal_read_intent(community, &actor.public_key(), &intent) + .await + .unwrap(), + IntentOutcome::Applied, + "{why}" + ); + } + let at_later_arrival: bool = sqlx::query_scalar( + "SELECT f.through_timestamp=e.received_at FROM personal_read_frontiers f, events e + WHERE f.community_id=$1 AND f.actor=$2 AND e.community_id=$1 AND e.id=$3", + ) + .bind(community.as_uuid()) + .bind(actor.public_key().to_bytes().as_slice()) + .bind(later.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .unwrap(); + assert!(at_later_arrival); + let stranger = Keys::generate(); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", + ) + .bind(community.as_uuid()) + .bind(stranger.public_key().to_bytes().as_slice()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + let sparse: Option = + sqlx::query_scalar("SELECT to_regclass('personal_read_seen')::text") + .fetch_one(&pool) + .await + .unwrap(); + assert!(sparse.is_none()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_channel_and_thread_never_inherit_each_other() { + let (db, pool, community, channel, actor, root) = fixture().await; + let base = root.created_at.as_secs(); + // Directed, so it counts outside the actor's conversations. + let reply = EventBuilder::new(Kind::Custom(9), "unseen thread reply") + .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(base + 10)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &reply, Some(channel)) + .await + .unwrap(); + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") + .bind(community.as_uuid()).bind(reply.id.as_bytes().as_slice()).bind((base+10) as f64) + .bind(channel).bind(root.id.as_bytes().as_slice()).execute(&pool).await.unwrap(); + let top = EventBuilder::new(Kind::Custom(9), "later timeline message") + .custom_created_at(nostr::Timestamp::from(base + 20)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &top, Some(channel)) + .await + .unwrap(); + let channel_target = ReadTarget { + channel_id: channel, + root_id: None, + }; + assert_eq!( + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: channel_target.clone(), + message_id: reply.id.to_hex() + } + ) + .await + .unwrap(), + IntentOutcome::Blocked + ); + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: channel_target, + message_id: top.id.to_hex(), + }, + ) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + matches!(page.channels[0].unread, ReadCount::Exact { value: 1 }), + "timeline reading must leave unseen reply unread" + ); + let second_actor = Keys::generate(); + db.apply_personal_read_intent( + community, + &second_actor.public_key(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: Some(root.id.to_hex()), + }, + message_id: reply.id.to_hex(), + }, + ) + .await + .unwrap(); + let roots: Vec> = sqlx::query_scalar( + "SELECT root_id FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", + ) + .bind(community.as_uuid()) + .bind(second_actor.public_key().to_bytes().as_slice()) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + roots, + vec![root.id.as_bytes().to_vec()], + "thread reading never creates a channel frontier" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_contexts_bound_selectors_and_use_only_matching_frontiers() { + let (db, pool, community, channel, actor, root) = fixture().await; + let base = root.created_at.as_secs(); + // A broadcast counts for every reader, in or out of the conversation. + let reply = EventBuilder::new(Kind::Custom(9), "thread only") + .tags([nostr::Tag::parse(["broadcast", "1"]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(base + 1)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &reply, Some(channel)) + .await + .unwrap(); + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") + .bind(community.as_uuid()).bind(reply.id.as_bytes().as_slice()).bind((base+1) as f64) + .bind(channel).bind(root.id.as_bytes().as_slice()).execute(&pool).await.unwrap(); + let queries = vec![ + ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_ids: vec![root.id.to_hex(), reply.id.to_hex(), "00".repeat(32)], + }, + ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: Some(root.id.to_hex()), + }, + message_ids: vec![root.id.to_hex(), reply.id.to_hex()], + }, + ]; + let page = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(); + let page = serde_json::to_value(page).unwrap(); + assert_eq!(page["contexts"][0]["messages"][0]["status"], "unread"); + assert_eq!(page["contexts"][0]["messages"][1]["status"], "unavailable"); + assert_eq!(page["contexts"][0]["messages"][2]["status"], "unavailable"); + assert_eq!(page["contexts"][1]["messages"][0]["status"], "unavailable"); + assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); + assert_eq!(page["contexts"][1]["messages"][1]["reason"], "broadcast"); + let accounts: i64 = + sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(accounts, 0, "context GET must never create authority"); + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: queries[0].target.clone(), + message_id: root.id.to_hex(), + }, + ) + .await + .unwrap(); + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(page["contexts"][0]["messages"][0]["status"], "read"); + assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: queries[1].target.clone(), + message_id: reply.id.to_hex(), + }, + ) + .await + .unwrap(); + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(page["contexts"][1]["messages"][1]["status"], "read"); + let other = Keys::generate(); + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &other.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(page["contexts"][0]["messages"][0]["status"], "unread"); + assert_eq!(page["contexts"][1]["messages"][1]["status"], "unread"); + assert!(db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[] + ) + .await + .is_err()); + let oversized = vec![ContextQuery { + target: queries[0].target.clone(), + message_ids: vec![root.id.to_hex(); MAX_CONTEXT_MESSAGES + 1], + }]; + assert!(db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &oversized + ) + .await + .is_err()); + sqlx::query("UPDATE channels SET visibility='private' WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(channel) + .execute(&pool) + .await + .unwrap(); + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &other.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + page["contexts"], + serde_json::json!([{"status":"unavailable"},{"status":"unavailable"}]) + ); + assert!(db + .personal_read_accessible_contexts(community, &other.public_key(), &[channel]) + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_context_author_horizon_unknown_ancestry_and_deletion() { + let (db, pool, community, channel, actor, root) = fixture().await; + let old = EventBuilder::new(Kind::Custom(9), "directed, about to expire") + .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &old, Some(channel)) + .await + .unwrap(); + let unresolved = EventBuilder::new(Kind::Custom(9), "ancestry missing") + .tags([nostr::Tag::parse(["e", &root.id.to_hex(), "", "reply"]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &unresolved, Some(channel)) + .await + .unwrap(); + let queries = [ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_ids: vec![old.id.to_hex(), unresolved.id.to_hex(), root.id.to_hex()], + }]; + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(page["contexts"][0]["messages"][0]["status"], "unread"); + assert_eq!(page["contexts"][0]["messages"][0]["reason"], "mention"); + assert_eq!(page["contexts"][0]["messages"][1]["status"], "unknown"); + sqlx::query( + "UPDATE events SET created_at=created_at-interval '31 days' WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(old.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(root.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + let page = serde_json::to_value( + db.personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(page["contexts"][0]["messages"][0]["status"], "not_counted"); + assert_eq!(page["contexts"][0]["messages"][2]["status"], "not_counted"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_covered_corruption_requires_canonical_matching_frontier() { + let (db, pool, community, channel, actor, root) = fixture().await; + let reply = EventBuilder::new(Kind::Custom(9), "canonical reply") + .custom_created_at(nostr::Timestamp::from(root.created_at.as_secs() - 1)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &reply, Some(channel)) + .await + .unwrap(); + for (event, depth) in [(&root, 0), (&reply, 1)] { + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,$6)") + .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()) + .bind(event.created_at.as_secs() as f64).bind(channel) + .bind(root.id.as_bytes().as_slice()).bind(depth).execute(&pool).await.unwrap(); + } + db.apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id: root.id.to_hex(), + }, + ) + .await + .unwrap(); + // Corrupt both stored payloads. Only canonical evidence plus its matching + // frontier can make their tags irrelevant, never the channel prefix alone. + sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") + .bind(community.as_uuid()) + .bind(serde_json::json!([["p", 42]])) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); + sqlx::query("INSERT INTO personal_read_frontiers (community_id,actor,channel_id,root_id,through_timestamp) + VALUES ($1,$2,$3,$4,now())") + .bind(community.as_uuid()).bind(actor.public_key().to_bytes().as_slice()).bind(channel) + .bind(root.id.as_bytes().as_slice()) + .execute(&pool).await.unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 0 } + )); + assert!(matches!( + page.channels[0].attention, + ReadCount::Exact { value: 0 } + )); + // Removing canonical metadata must not let a channel frontier hide unknown + // ancestry/corruption, even though both frontiers cover the row. + sqlx::query("DELETE FROM thread_metadata WHERE community_id=$1 AND event_id=$2") + .bind(community.as_uuid()) + .bind(reply.id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!(page.channels[0].unread, ReadCount::Unknown)); + assert!(matches!(page.channels[0].attention, ReadCount::Unknown)); +} diff --git a/crates/buzz-db/src/store/personal_read/projection.rs b/crates/buzz-db/src/store/personal_read/projection.rs new file mode 100644 index 00000000000..b839ca7459f --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/projection.rs @@ -0,0 +1,441 @@ +//! Bounded evidence projection. The cap limits evidence, not the definition of +//! unread: an unexamined tail yields a positive lower bound or an unknown count. + +use super::{model::*, participation, writes}; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use serde_json::Value; +use sqlx::{Acquire, PgConnection, Row}; +use std::cmp::Reverse; +use std::collections::{hash_map::Entry, HashMap}; +use uuid::Uuid; + +use crate::{observability, Db, DbError, Result}; + +impl Db { + /// Read a bounded joined roster from the writer. One SQL statement projects + /// channels, event evidence and read authority at a compatible MVCC cut. + /// Callers must recheck admission/resource access before releasing this data. + pub async fn personal_read_sidebar( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + retention_seconds: u32, + limit: usize, + after: Option, + ) -> Result { + if !(1..=MAX_CHANNELS).contains(&limit) { + return Err(DbError::InvalidData("invalid sidebar limit".into())); + } + self.sidebar(community, actor, retention_seconds, limit, after, None) + .await + } + + /// Refresh specific joined channels in one snapshot. A requested channel + /// absent from the result was not a joined, nondeleted channel at that cut. + pub async fn personal_read_sidebar_channels( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + retention_seconds: u32, + channels: &[Uuid], + ) -> Result { + let unique: std::collections::HashSet<_> = channels.iter().collect(); + if !(1..=MAX_CHANNELS).contains(&channels.len()) || unique.len() != channels.len() { + return Err(DbError::InvalidData("invalid sidebar channels".into())); + } + self.sidebar( + community, + actor, + retention_seconds, + channels.len(), + None, + Some(channels), + ) + .await + } + + async fn sidebar( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + retention_seconds: u32, + limit: usize, + after: Option, + only: Option<&[Uuid]>, + ) -> Result { + let mut conn = observability::acquire_writer( + &self.pool, + observability::WriterOperation::SubscriptionHistory, + ) + .await?; + let mut tx = conn.begin().await?; + // The horizon and frontier evidence share one read-only cut. + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *tx) + .await?; + writes::deadlines(&mut tx).await?; + sqlx::query("SET LOCAL jit = off").execute(&mut *tx).await?; + let actor_bytes = actor.to_bytes(); + let account = read_account(&mut tx, retention_seconds).await?; + // The inner event LIMIT is deliberately before eligibility filtering. + // This bounds rows/joins even with long deleted or self-authored runs. + // Aggregate equivalent eligible evidence before transfer. Multiplicity + // preserves counts; raw scan count (not group count) proves exhaustion. + // SQL eligibility mirrors classification::eligible (PostgreSQL parity test). + // Canonical covered rows need no tags; missing ancestry stays unknown. + // Validate relevant tag parts before compacting directed/ancestry facts. + // PostgreSQL scalar "p" ->> 0 is "p": the type check must reject it too. + // Canonical timeline roots share an empty (present) root sentinel. + // Tags are bounded before transfer; oversized/corrupt evidence stays + // unknown, never falsely top-level/unmentioned/read. + // Latest comes from the unread scan, so its ID arrives no earlier than + // anything counted; the shallow probe answers only when the horizon + // holds no message. Both are newest-first by author time, so the deeper + // one finds the same greatest author time whenever the probe finds any. + let rows = sqlx::query( + r#"WITH roster AS MATERIALIZED ( + SELECT c.id,c.name,c.channel_type::text AS channel_type, + c.archived_at IS NOT NULL AS archived, cm.hidden_at IS NOT NULL AS hidden + FROM channel_members cm JOIN channels c + ON c.community_id=cm.community_id AND c.id=cm.channel_id + WHERE cm.community_id=$1 AND cm.pubkey=$2 AND cm.removed_at IS NULL + AND c.deleted_at IS NULL AND ($3::uuid IS NULL OR c.id>$3) + AND ($9::uuid[] IS NULL OR c.id=ANY($9)) + ORDER BY c.id LIMIT $4 + ) + SELECT r.*, COALESCE(e.latest_message_id, latest.latest_message_id) AS latest_message_id, + COALESCE(e.latest_message_at, latest.latest_message_at) AS latest_message_at, + (COALESCE(e.latest_message_id, latest.latest_message_id) IS NOT NULL + OR latest.candidates <= $5-1) AS latest_message_complete, + e.scanned,COALESCE(e.evidence,'[]'::jsonb) AS evidence FROM roster r + LEFT JOIN LATERAL ( + WITH candidates AS MATERIALIZED ( + SELECT id,created_at,received_at,kind,deleted_at FROM events + WHERE community_id=$1 AND channel_id=r.id + ORDER BY created_at DESC,id LIMIT $5 + ) + SELECT count(*) AS candidates, + (array_agg(encode(id,'hex') ORDER BY received_at DESC,id) + FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id, + (array_agg(extract(epoch FROM created_at)::bigint ORDER BY created_at DESC,id) + FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_at + FROM candidates + ) latest ON true + LEFT JOIN personal_read_frontiers cf ON cf.community_id=$1 AND cf.actor=$2 + AND cf.channel_id=r.id AND cf.root_id=''::bytea + LEFT JOIN LATERAL ( + WITH candidates AS MATERIALIZED ( + SELECT id,pubkey,created_at,received_at,deleted_at,kind,tags + FROM events WHERE community_id=$1 AND channel_id=r.id AND created_at >= $7 + ORDER BY created_at DESC,id LIMIT $8 + ), classified AS ( + SELECT e.*, tm.root_event_id AS root, tm.parent_event_id AS parent, + COALESCE(tm.root_event_id<>e.id,false) AS is_reply, + COALESCE(e.received_at <= + CASE WHEN tm.root_event_id IS NOT NULL AND tm.root_event_id<>e.id + THEN GREATEST(tf.through_timestamp, cf.threads_through_timestamp) + ELSE cf.through_timestamp END,false) AS covered + FROM (SELECT * FROM candidates ORDER BY created_at DESC,id LIMIT $8-1) e + LEFT JOIN thread_metadata tm ON tm.community_id=$1 AND tm.channel_id=r.id + AND tm.event_created_at=e.created_at AND tm.event_id=e.id + LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2 + AND tf.channel_id=r.id AND tf.root_id=tm.root_event_id AND tm.root_event_id<>e.id + WHERE e.kind=ANY($6) AND e.pubkey<>$2 AND e.deleted_at IS NULL + ), grouped AS ( + SELECT CASE WHEN root IS NULL THEN NULL + WHEN is_reply THEN encode(root,'hex') ELSE '' END AS root, + CASE WHEN is_reply THEN encode(parent,'hex') END AS parent, + is_reply, covered, + -- ->>0 also selects scalar "p"/"e": reject nonarrays first. + -- C collation matches Rust's ASCII case/hex rules. Reply + -- markers matter only without canonical ancestry; otherwise + -- metadata, not tag spelling, owns the context. + CASE WHEN octet_length(tags::text)<=8192 + AND jsonb_typeof(tags)='array' THEN + (SELECT CASE WHEN bool_or(jsonb_typeof(tag)<>'array' + OR jsonb_path_exists(tag,'strict $[*] ? (@.type() != "string")')) + THEN NULL ELSE jsonb_build_object( + 'directed',COALESCE(bool_or( + (tag->>0='p' AND lower((tag->>1) COLLATE "C")=encode($2,'hex')) + OR (tag->>0='broadcast' AND tag->>1='1')),false), + 'reply_marked',root IS NULL AND COALESCE(bool_or(tag->>0='e' + AND tag->>3='reply' + AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$'),false)) END + FROM jsonb_array_elements(tags) t(tag) + WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts, + count(*) AS n, + (extract(epoch FROM max(received_at))*1000000)::bigint AS newest_arrival, + (array_agg(encode(id,'hex') ORDER BY received_at DESC,id))[1] AS newest_id, + (array_agg(extract(epoch FROM created_at)::bigint + ORDER BY received_at DESC,id))[1] AS newest_at + FROM classified + WHERE NOT covered OR root IS NULL + GROUP BY 1,2,3,4,5 + ), scan AS ( + SELECT count(*) AS scanned, + (array_agg(encode(id,'hex') ORDER BY received_at DESC,id) + FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL))[1] AS latest_message_id, + max(extract(epoch FROM created_at)::bigint) + FILTER (WHERE kind=ANY($6) AND deleted_at IS NULL) AS latest_message_at + FROM candidates + ) + SELECT scan.*, (SELECT jsonb_agg(to_jsonb(grouped)) FROM grouped) AS evidence FROM scan + ) e ON true ORDER BY r.id"#, + ).bind(community.as_uuid()).bind(actor_bytes.as_slice()).bind(after) + .bind((limit+1) as i64).bind((MAX_CHANNEL_SCAN+1) as i64) + .bind(ELIGIBLE_KINDS.as_slice()) + .bind(DateTime::from_timestamp_millis(account.cutoff_ms) + .ok_or_else(|| DbError::InvalidData("invalid unread cutoff".into()))?) + .bind((MAX_UNREAD_SCAN+1) as i64) + .bind(only) + .fetch_all(&mut *tx).await?; + let has_more = rows.len() > limit; + let mut channels = Vec::new(); + let mut pending = Vec::new(); + for row in rows.into_iter().take(limit) { + let evidence: Value = row.try_get("evidence")?; + let evidence = evidence + .as_array() + .ok_or_else(|| DbError::InvalidData("invalid sidebar evidence".into()))?; + let complete = row.try_get::("scanned")? <= MAX_UNREAD_SCAN as i64; + let channel_type: String = row.try_get("channel_type")?; + let mut unread = 0; + let mut attention = 0; + let mut unread_complete = complete; + let mut threads: HashMap, Replies> = HashMap::new(); + let mut undirected = Vec::new(); + for e in evidence { + let n = e["n"] + .as_u64() + .filter(|n| *n <= MAX_UNREAD_SCAN as u64) + .ok_or_else(|| DbError::InvalidData("invalid evidence multiplicity".into()))? + as u32; + let Some(facts) = e["facts"].as_object() else { + unread_complete = false; + continue; + }; + // SQL's bounded ancestry fact is parity-tested against the shared + // NIP-10 parser; no raw tag payload crosses the DB boundary. + if facts.get("reply_marked") == Some(&Value::Bool(true)) && e["root"].is_null() { + unread_complete = false; + continue; + } + if e["covered"] == true { + continue; + } + let directed = + channel_type == "dm" || facts.get("directed") == Some(&Value::Bool(true)); + // Roots are timeline messages; descendants belong exclusively + // to their canonical thread. Never inherit the channel prefix. + if e["is_reply"] != true { + unread += n; + if directed { + attention += n; + } + continue; + } + let Some(root) = e["root"].as_str().and_then(writes::event_id) else { + unread_complete = false; + continue; + }; + let replies = Replies { + n, + newest: ( + e["newest_arrival"].as_i64().ok_or_else(invalid_newest)?, + e["newest_id"] + .as_str() + .ok_or_else(invalid_newest)? + .to_owned(), + ), + newest_at: e["newest_at"].as_i64().ok_or_else(invalid_newest)?, + }; + // A directed reply counts whatever its conversation. Any other + // reply counts only in one of the actor's conversations. + if directed { + count(&mut threads, root, replies); + } else if let Some(parent) = e["parent"].as_str().and_then(writes::event_id) { + undirected.push((root, parent, replies)); + } else { + unread_complete = false; + } + } + pending.push((threads, undirected, unread, attention, unread_complete)); + channels.push(ChannelReadSummary { + channel_id: row.try_get("id")?, + name: row.try_get("name")?, + channel_type, + archived: row.try_get("archived")?, + hidden: row.try_get("hidden")?, + // Counts and threads wait for conversation membership below. + unread: ReadCount::Unknown, + attention: ReadCount::Unknown, + latest_message_id: row.try_get("latest_message_id")?, + latest_message_at: row.try_get("latest_message_at")?, + latest_message_complete: row.try_get("latest_message_complete")?, + threads: ThreadSummaries { + items: Vec::new(), + complete: false, + }, + }); + } + let targets: Vec<_> = channels + .iter() + .zip(&pending) + .flat_map(|(channel, (_, undirected, ..))| { + undirected + .iter() + .map(|(_, parent, _)| (channel.channel_id, parent.clone())) + }) + .collect(); + let members = participation::resolve(&mut tx, community, &actor_bytes, &targets).await?; + for (channel, (mut threads, undirected, mut unread, mut attention, mut complete)) in + channels.iter_mut().zip(pending) + { + // Relevance is decided before counts, previews and the thread cap. + for (root, parent, replies) in undirected { + match members.get(&(channel.channel_id, parent)) { + Some(true) => count(&mut threads, root, replies), + Some(false) => {} + None => complete = false, + } + } + let mut items = Vec::with_capacity(threads.len()); + for (root, thread) in threads { + unread += thread.n; + attention += thread.n; + items.push(ThreadReadSummary { + root_id: hex::encode(root), + unread: ReadCount::from_evidence(thread.n, complete), + latest_reply_id: thread.newest.1, + latest_reply_at: thread.newest_at, + }); + } + channel.unread = ReadCount::from_evidence(unread, complete); + channel.attention = ReadCount::from_evidence(attention, complete); + channel.threads = summarize(items, complete); + } + let next_cursor = if has_more { + channels.last().map(|c| c.channel_id) + } else { + None + }; + tx.commit().await?; + Ok(SidebarPage { + account, + channels, + next_cursor, + }) + } +} + +/// Uncovered replies in one canonical thread: one evidence group, or the +/// thread's counted total. +struct Replies { + n: u32, + /// Last of them to arrive: (arrival microseconds, lowercase hex ID). + newest: (i64, String), + /// Its author seconds. + newest_at: i64, +} + +/// Add replies that count to their thread. +fn count(threads: &mut HashMap, Replies>, root: Vec, replies: Replies) { + match threads.entry(root) { + Entry::Vacant(slot) => { + slot.insert(replies); + } + Entry::Occupied(mut slot) => { + let thread = slot.get_mut(); + thread.n += replies.n; + // Latest arrival first; equal arrivals break toward the smaller ID. + if (replies.newest.0, Reverse(&replies.newest.1)) + > (thread.newest.0, Reverse(&thread.newest.1)) + { + thread.newest = replies.newest; + thread.newest_at = replies.newest_at; + } + } + } +} + +fn invalid_newest() -> DbError { + DbError::InvalidData("invalid thread evidence".into()) +} + +/// Order newest unread reply first (root ID breaks ties) and cap the list. The +/// list is complete only when evidence was exhausted and nothing was omitted. +pub(super) fn summarize( + mut items: Vec, + evidence_complete: bool, +) -> ThreadSummaries { + items.sort_unstable_by(|a, b| { + b.latest_reply_at + .cmp(&a.latest_reply_at) + .then_with(|| a.root_id.cmp(&b.root_id)) + }); + let complete = evidence_complete && items.len() <= MAX_THREAD_SUMMARIES; + items.truncate(MAX_THREAD_SUMMARIES); + ThreadSummaries { items, complete } +} + +/// Read-time horizon only: frontier state is not discarded on expiry. +pub(super) async fn read_account( + conn: &mut PgConnection, + retention_seconds: u32, +) -> Result { + let cutoff: DateTime = sqlx::query_scalar( + "SELECT date_trunc('milliseconds',transaction_timestamp()-make_interval(secs=>$1::double precision))", + ) + .bind(f64::from(retention_seconds)) + .fetch_one(conn) + .await?; + Ok(ReadAccount { + retention_seconds, + cutoff_ms: cutoff.timestamp_millis(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(root: u8, at: i64) -> ThreadReadSummary { + ThreadReadSummary { + root_id: hex::encode([root; 32]), + unread: ReadCount::Exact { value: 1 }, + latest_reply_id: hex::encode([root; 32]), + latest_reply_at: at, + } + } + + #[test] + fn thread_summaries_order_newest_first_break_ties_by_root_and_cap_at_five() { + // Literal contract boundaries deliberately do not derive from the constant. + for count in [0_u8, 1, 4, 5, 6, 7] { + // Descending roots with pairwise-equal times exercise the tie-break. + let items: Vec<_> = (0..count) + .rev() + .map(|i| item(i, i64::from(i / 2))) + .collect(); + let summaries = summarize(items, true); + let roots: Vec<_> = summaries.items.iter().map(|t| t.root_id.clone()).collect(); + let mut expected: Vec<_> = (0..count).map(|i| (i64::from(i / 2), i)).collect(); + expected.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + let expected: Vec<_> = expected + .into_iter() + .take(5) + .map(|(_, i)| hex::encode([i; 32])) + .collect(); + assert_eq!(roots, expected, "count {count}"); + assert_eq!(summaries.complete, count <= 5, "count {count}"); + } + } + + #[test] + fn thread_summaries_are_incomplete_when_evidence_is() { + let summaries = summarize(vec![item(1, 1)], false); + assert_eq!(summaries.items.len(), 1); + assert!(!summaries.complete); + } +} diff --git a/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs new file mode 100644 index 00000000000..27fdc713a09 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/projection_postgres_tests.rs @@ -0,0 +1,262 @@ +use super::{classification, postgres_tests::fixture, *}; +use serde_json::json; + +#[test] +fn unread_horizon_includes_its_own_cutoff() { + for (created_ms, counted) in [(999, false), (1000, true), (1001, true)] { + assert_eq!( + classification::eligible(9, false, false, created_ms, 1000), + counted + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sidebar_sql_eligibility_matches_selector_classifier() { + let (db, pool, community, channel, actor, event) = fixture().await; + let query = [ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_ids: vec![event.id.to_hex()], + }]; + let now = chrono::Utc::now().timestamp_millis(); + let horizon = i64::from(DEFAULT_RETENTION_SECONDS) * 1000; + for kind in [9, 40002, 45001, 45003, 1, 7, 39002, 40008] { + for own in [false, true] { + for deleted in [false, true] { + // Now, then one minute inside and one minute outside the horizon. + for age in [0, horizon - 60_000, horizon + 60_000] { + let created = now - age; + sqlx::query("UPDATE events SET kind=$2,pubkey=$3,deleted_at=CASE WHEN $4 THEN now() ELSE NULL END,created_at=to_timestamp($5::double precision/1000) WHERE community_id=$1") + .bind(community.as_uuid()).bind(kind) + .bind(if own { actor.public_key().to_bytes() } else { event.pubkey.to_bytes() }.as_slice()) + .bind(deleted).bind(created as f64).execute(&pool).await.unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + let expected = u32::from(classification::eligible( + kind, + own, + deleted, + created, + page.account.cutoff_ms, + )); + assert!( + matches!(page.channels[0].unread, ReadCount::Exact { value } if value == expected), + "kind={kind} own={own} deleted={deleted} age={age}" + ); + // The per-message selector must agree with the aggregate. + let contexts = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &query, + ) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(&contexts).unwrap()["contexts"][0]["messages"][0] + ["status"], + ["not_counted", "unread"][expected as usize], + "kind={kind} own={own} deleted={deleted} age={age}" + ); + } + } + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sidebar_compacted_tags_preserve_directed_and_corruption_rules() { + let (db, pool, community, _, actor, _) = fixture().await; + let actor_hex = actor.public_key().to_hex().to_uppercase(); + for (tags, unread, attention) in [ + (json!([]), Some(1), Some(0)), + (json!(["p"]), None, None), + (json!(["e"]), None, None), + (json!(["broadcast"]), None, None), + ( + json!([["p", actor_hex, "relay", "petname"]]), + Some(1), + Some(1), + ), + (json!([["p", "00".repeat(32)]]), Some(1), Some(0)), + (json!([["broadcast", "1", "extra"]]), Some(1), Some(1)), + (json!([["broadcast", "0"]]), Some(1), Some(0)), + (json!([["p", "00".repeat(32), 42]]), None, None), + (json!([["broadcast", "0", 42]]), None, None), + (json!([["e", "00".repeat(32), "", "root", 42]]), None, None), + (json!([["p", actor_hex], ["p", "other", 42]]), None, None), + (json!([["e", "00".repeat(32), "", "reply"]]), None, None), + (json!([["x", 42]]), Some(1), Some(0)), + (json!({"p":actor_hex}), None, None), + (json!([["p", "x".repeat(8193)]]), None, None), + ] { + sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") + .bind(community.as_uuid()) + .bind(&tags) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + for (actual, expected) in [ + (&page.channels[0].unread, unread), + (&page.channels[0].attention, attention), + ] { + assert!( + match (actual, expected) { + (ReadCount::Exact { value }, Some(n)) => *value == n, + (ReadCount::Unknown, None) => true, + _ => false, + }, + "tags={tags} actual={actual:?} expected={expected:?}" + ); + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sidebar_ancestry_fact_matches_shared_nip10_parser() { + let (db, pool, community, _, actor, _) = fixture().await; + let ids = [ + "a".repeat(64), + "A".repeat(64), + "aB09".repeat(16), + "a".repeat(63), + "a".repeat(65), + "g".repeat(64), + "١".repeat(64), + "A".repeat(64), + format!("{}\n", "a".repeat(64)), + ]; + let mut cases: Vec>> = Vec::new(); + for id in ids { + for marker in ["reply", "Reply", "root", "mention"] { + cases.push(vec![vec!["e".into(), id.clone(), "".into(), marker.into()]]); + } + } + cases.push(vec![vec!["e".into(), "a".repeat(64), "reply".into()]]); + cases.push(vec![ + vec!["e".into(), "g".repeat(64), "".into(), "reply".into()], + vec!["e".into(), "a".repeat(64), "".into(), "reply".into()], + ]); + cases.push(vec![ + vec!["e".into(), "a".repeat(64), "".into(), "reply".into()], + vec!["e".into(), "g".repeat(64), "".into(), "reply".into()], + ]); + for tags in cases { + let reply = + buzz_core::nip10::parse_thread_markers_from_parts(tags.iter().map(Vec::as_slice)) + .resolve() + .is_some(); + sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") + .bind(community.as_uuid()) + .bind(json!(tags)) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!( + if reply { + matches!(page.channels[0].unread, ReadCount::Unknown) + } else { + matches!(page.channels[0].unread, ReadCount::Exact { value: 1 }) + }, + "tags={tags:?}" + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sidebar_directed_fact_matches_selector_classifier() { + let (db, pool, community, channel, actor, _) = fixture().await; + let actor_hex = actor.public_key().to_hex(); + let fullwidth: String = actor_hex + .chars() + .map(|c| if c.is_ascii_alphabetic() { 'A' } else { c }) + .collect(); + assert_ne!(fullwidth, actor_hex); + let cases: Vec>> = serde_json::from_value(json!([ + [], + [["p", actor_hex]], + [["p", actor_hex.to_uppercase()]], + [["p", fullwidth]], + [["p"]], + [["broadcast", "1"]], + [["broadcast", "true"]], + [["p", "00".repeat(32)]], + [["broadcast", "0"], ["p", actor_hex.to_uppercase()]] + ])) + .unwrap(); + for channel_type in ["stream", "dm"] { + sqlx::query( + "UPDATE channels SET channel_type=$3::channel_type WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(channel_type) + .execute(&pool) + .await + .unwrap(); + for tags in &cases { + let expected = + u32::from(classification::reason(channel_type, &actor_hex, tags).is_some()); + sqlx::query("UPDATE events SET tags=$2 WHERE community_id=$1") + .bind(community.as_uuid()) + .bind(json!(tags)) + .execute(&pool) + .await + .unwrap(); + let page = db + .personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap(); + assert!(matches!( + page.channels[0].unread, + ReadCount::Exact { value: 1 } + )); + assert!( + matches!(page.channels[0].attention, ReadCount::Exact { value } if value == expected), + "channel_type={channel_type} tags={tags:?} expected={expected}" + ); + } + } +} diff --git a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs new file mode 100644 index 00000000000..b8fc5248594 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs @@ -0,0 +1,650 @@ +//! Whole-channel reads, thread summaries and targeted refresh. +use super::{postgres_tests::fixture, *}; +use crate::{ + channel::{ChannelType, ChannelVisibility}, + Db, +}; +use buzz_core::CommunityId; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use sqlx::PgPool; +use uuid::Uuid; + +async fn post( + db: &Db, + community: CommunityId, + channel: Uuid, + at: u64, + tags: Vec, +) -> nostr::Event { + post_as(db, community, channel, &Keys::generate(), at, tags).await +} + +async fn post_as( + db: &Db, + community: CommunityId, + channel: Uuid, + author: &Keys, + at: u64, + tags: Vec, +) -> nostr::Event { + let event = EventBuilder::new(Kind::Custom(9), format!("message at {at}")) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(at)) + .sign_with_keys(author) + .unwrap(); + db.insert_event(community, &event, Some(channel)) + .await + .unwrap(); + event +} + +/// A canonical reply to the root: stored event plus its thread metadata. +async fn reply( + db: &Db, + pool: &PgPool, + community: CommunityId, + channel: Uuid, + root: &nostr::Event, + at: u64, + tags: Vec, +) -> nostr::Event { + let event = post(db, community, channel, at, tags).await; + link(pool, community, channel, root, &event).await; + event +} + +async fn link( + pool: &PgPool, + community: CommunityId, + channel: Uuid, + root: &nostr::Event, + event: &nostr::Event, +) { + sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) + VALUES ($1,$2,to_timestamp($3),$4,$5,$5,1)") + .bind(community.as_uuid()).bind(event.id.as_bytes().as_slice()) + .bind(event.created_at.as_secs() as f64) + .bind(channel).bind(root.id.as_bytes().as_slice()).execute(pool).await.unwrap(); +} + +/// Put the actor in the root's conversation, so that plain replies to it +/// count. The actor's own reply is never unread. +async fn join( + db: &Db, + pool: &PgPool, + community: CommunityId, + channel: Uuid, + actor: &Keys, + root: &nostr::Event, +) { + let at = root.created_at.as_secs(); + let own = post_as(db, community, channel, actor, at, vec![]).await; + link(pool, community, channel, root, &own).await; +} + +async fn sidebar(db: &Db, community: CommunityId, actor: &Keys) -> ChannelReadSummary { + db.personal_read_sidebar( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + 20, + None, + ) + .await + .unwrap() + .channels + .remove(0) +} + +async fn apply(db: &Db, community: CommunityId, actor: &Keys, intent: ReadIntent) -> IntentOutcome { + db.apply_personal_read_intent(community, &actor.public_key(), &intent) + .await + .unwrap() +} + +fn channel_read(channel: Uuid, message: &nostr::Event) -> ReadIntent { + ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: message.id.to_hex(), + } +} + +fn exact(count: &ReadCount) -> Option { + match count { + ReadCount::Exact { value } => Some(*value), + _ => None, + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_channel_read_covers_every_thread_through_a_reply_anchor() { + let (db, pool, community, channel, actor, root) = fixture().await; + join(&db, &pool, community, channel, &actor, &root).await; + let base = root.created_at.as_secs(); + let first = reply(&db, &pool, community, channel, &root, base + 10, vec![]).await; + post(&db, community, channel, base + 20, vec![]).await; + let second = reply(&db, &pool, community, channel, &root, base + 30, vec![]).await; + + // A reply anchor is valid for the whole channel, unlike channel mark_through. + let channel_only = ReadTarget { + channel_id: channel, + root_id: None, + }; + let through_reply = ReadIntent::MarkThrough { + target: channel_only, + message_id: first.id.to_hex(), + }; + assert_eq!( + apply(&db, community, &actor, through_reply).await, + IntentOutcome::Blocked + ); + assert_eq!( + apply(&db, community, &actor, channel_read(channel, &first)).await, + IntentOutcome::Applied + ); + let row = sidebar(&db, community, &actor).await; + assert_eq!( + exact(&row.unread), + Some(2), + "top at +20 and reply at +30 remain" + ); + assert_eq!(row.threads.items.len(), 1); + assert_eq!(row.threads.items[0].latest_reply_id, second.id.to_hex()); + assert!(row.threads.complete); + + assert_eq!( + apply(&db, community, &actor, channel_read(channel, &second)).await, + IntentOutcome::Applied + ); + let row = sidebar(&db, community, &actor).await; + assert_eq!( + exact(&row.unread), + Some(0), + "every thread is covered by the cut" + ); + assert!(row.threads.items.is_empty() && row.threads.complete); + + // A reply that arrives after the cut is unread, however far backdated. + let late = reply(&db, &pool, community, channel, &root, base + 5, vec![]).await; + let newer = reply(&db, &pool, community, channel, &root, base + 40, vec![]).await; + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(2)); + assert_eq!(row.threads.items[0].latest_reply_id, newer.id.to_hex()); + + // Contexts apply the same effective thread frontier. + let page = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[ContextQuery { + target: ReadTarget { + channel_id: channel, + root_id: Some(root.id.to_hex()), + }, + message_ids: vec![second.id.to_hex(), late.id.to_hex(), newer.id.to_hex()], + }], + ) + .await + .unwrap(); + let wire = serde_json::to_value(&page).unwrap(); + assert!(wire["contexts"][0].get("through_timestamp").is_none()); + assert_eq!(wire["contexts"][0]["messages"][0]["status"], "read"); + assert_eq!(wire["contexts"][0]["messages"][1]["status"], "unread"); + assert_eq!(wire["contexts"][0]["messages"][2]["status"], "unread"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn mark_channel_read_validates_anchor_without_ancestry_and_stays_independent() { + let (db, pool, community, channel, actor, root) = fixture().await; + let base = root.created_at.as_secs(); + // Unresolved ancestry blocks ordinary marks, but not an arrival-only cut. + let orphan_parent = "ab".repeat(32); + let orphan = post( + &db, + community, + channel, + base + 10, + vec![Tag::parse(["e", &orphan_parent, "", "reply"]).unwrap()], + ) + .await; + assert!(db + .apply_personal_read_intent( + community, + &actor.public_key(), + &ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None + }, + message_id: orphan.id.to_hex(), + }, + ) + .await + .is_err()); + assert_eq!( + apply(&db, community, &actor, channel_read(channel, &orphan)).await, + IntentOutcome::Applied + ); + + // Missing, auxiliary and malformed anchors. + let missing = ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: "cd".repeat(32), + }; + assert_eq!( + apply(&db, community, &actor, missing).await, + IntentOutcome::Blocked + ); + let malformed = ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: "zz".into(), + }; + assert_eq!( + apply(&db, community, &actor, malformed).await, + IntentOutcome::Invalid + ); + let reaction = EventBuilder::new(Kind::Custom(7), "+") + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &reaction, Some(channel)) + .await + .unwrap(); + assert_eq!( + apply(&db, community, &actor, channel_read(channel, &reaction)).await, + IntentOutcome::Blocked + ); + + // Ordinary channel marks never set the whole-channel cut. + let other = Keys::generate(); + assert_eq!( + apply( + &db, + community, + &other, + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None + }, + message_id: root.id.to_hex(), + }, + ) + .await, + IntentOutcome::Applied + ); + let cuts: Vec>> = sqlx::query_scalar( + "SELECT threads_through_timestamp FROM personal_read_frontiers WHERE community_id=$1 AND actor=$2", + ) + .bind(community.as_uuid()) + .bind(other.public_key().to_bytes().as_slice()) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(cuts, vec![None]); + + // The schema admits the cut on channel rows only. + let thread_cut = sqlx::query( + "UPDATE personal_read_frontiers SET threads_through_timestamp=now() + WHERE community_id=$1 AND actor=$2", + ) + .bind(community.as_uuid()) + .bind(actor.public_key().to_bytes().as_slice()) + .execute(&pool) + .await; + assert!(thread_cut.is_ok(), "channel row accepts the cut"); + let root_id = root.id.to_hex(); + let thread = ReadTarget { + channel_id: channel, + root_id: Some(root_id), + }; + let mark = ReadIntent::MarkThrough { + target: thread, + message_id: root.id.to_hex(), + }; + assert_eq!( + apply(&db, community, &actor, mark).await, + IntentOutcome::Applied + ); + assert!(sqlx::query( + "UPDATE personal_read_frontiers SET threads_through_timestamp=now() + WHERE community_id=$1 AND actor=$2 AND root_id<>''::bytea", + ) + .bind(community.as_uuid()) + .bind(actor.public_key().to_bytes().as_slice()) + .execute(&pool) + .await + .is_err()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn absent_whole_channel_cut_never_covers_epoch_zero_replies() { + let (db, pool, community, channel, actor, root) = fixture().await; + join(&db, &pool, community, channel, &actor, &root).await; + reply(&db, &pool, community, channel, &root, 0, vec![]).await; + // A channel row exists, with no whole-channel cut: NULL must stay NULL. + apply( + &db, + community, + &actor, + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id: root.id.to_hex(), + }, + ) + .await; + // Widen the horizon to reach the epoch, so that only a NULL cut read as + // zero could hide this reply. + let row = db + .personal_read_sidebar(community, &actor.public_key(), u32::MAX, 20, None) + .await + .unwrap() + .channels + .remove(0); + assert_eq!(exact(&row.unread), Some(1), "epoch-zero reply stays unread"); + assert_eq!(row.threads.items[0].latest_reply_at, 0); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_summaries_order_cap_anchor_and_sum_to_reply_unread() { + let (db, pool, community, channel, actor, fixture_root) = fixture().await; + // After the fixture root, so marking the newest root covers every root. + let base = fixture_root.created_at.as_secs() + 1; + let mut roots = Vec::new(); + for i in 0..6 { + let root = post(&db, community, channel, base + i, vec![]).await; + join(&db, &pool, community, channel, &actor, &root).await; + roots.push(root); + } + let newest_root = roots.last().unwrap().clone(); + apply( + &db, + community, + &actor, + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: None, + }, + message_id: newest_root.id.to_hex(), + }, + ) + .await; + // Threads 0 and 1 tie on newest reply time; thread 2 has one directed + // reply and two that arrive last, together. + let mut anchors = Vec::new(); + for (i, root) in roots.iter().enumerate() { + let at = base + 100 + [50, 50, 40, 30, 20, 10][i]; + anchors.push(reply(&db, &pool, community, channel, root, at, vec![]).await); + } + reply( + &db, + &pool, + community, + channel, + &roots[2], + base + 101, + vec![Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()], + ) + .await; + let twin = reply( + &db, + &pool, + community, + channel, + &roots[2], + base + 140, + vec![], + ) + .await; + sqlx::query( + "UPDATE events SET received_at=(SELECT received_at FROM events WHERE id=$1) WHERE id=$2", + ) + .bind(twin.id.as_bytes().as_slice()) + .bind(anchors[2].id.as_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(8)); + assert!(!row.threads.complete, "six unread threads exceed the cap"); + let mut tied = [roots[0].id.to_hex(), roots[1].id.to_hex()]; + tied.sort(); + let order: Vec<_> = row + .threads + .items + .iter() + .map(|t| t.root_id.clone()) + .collect(); + assert_eq!( + order, + vec![ + tied[0].clone(), + tied[1].clone(), + roots[2].id.to_hex(), + roots[3].id.to_hex(), + roots[4].id.to_hex(), + ] + ); + let third = &row.threads.items[2]; + assert_eq!(exact(&third.unread), Some(3)); + assert_eq!(third.latest_reply_at, (base + 140) as i64); + assert_eq!( + third.latest_reply_id, + std::cmp::min(anchors[2].id.to_hex(), twin.id.to_hex()), + "equal arrivals break toward the smaller ID" + ); + // The row's anchor is its last arrival; its activity is the greatest + // author time, another message's. + assert_eq!(row.latest_message_id.as_ref(), Some(&third.latest_reply_id)); + assert_eq!(row.latest_message_at, Some((base + 150) as i64)); + + // Reading one listed thread through its anchor completes the list. + let first = row.threads.items[0].clone_target(channel); + assert_eq!( + apply(&db, community, &actor, first).await, + IntentOutcome::Applied + ); + let row = sidebar(&db, community, &actor).await; + assert!(row.threads.complete); + assert_eq!(row.threads.items.len(), 5); + let sum: u32 = row + .threads + .items + .iter() + .map(|t| exact(&t.unread).unwrap()) + .sum(); + assert_eq!( + Some(sum), + exact(&row.unread), + "complete summaries account for every reply" + ); +} + +impl ThreadReadSummary { + fn clone_target(&self, channel: Uuid) -> ReadIntent { + ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: channel, + root_id: Some(self.root_id.clone()), + }, + message_id: self.latest_reply_id.clone(), + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_on_a_never_unread_root_is_selectable_and_readable_by_itself() { + let (db, pool, community, channel, actor, root) = fixture().await; + let base = root.created_at.as_secs(); + let mention = || vec![Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]; + // A diff is never unread, not even one addressed to the actor. + let diff = EventBuilder::new(Kind::Custom(40008), "a diff") + .tags(mention()) + .custom_created_at(nostr::Timestamp::from(base + 1)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + db.insert_event(community, &diff, Some(channel)) + .await + .unwrap(); + let on_diff = reply(&db, &pool, community, channel, &diff, base + 10, mention()).await; + let elsewhere = reply(&db, &pool, community, channel, &root, base + 20, mention()).await; + + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(3), "the root and two replies"); + assert_eq!(row.threads.items.len(), 2); + let listed = &row.threads.items[1]; + assert_eq!(listed.root_id, diff.id.to_hex()); + assert_eq!(listed.latest_reply_id, on_diff.id.to_hex()); + + // The listed thread is a usable context; the diff stays uncounted. + let thread = ReadTarget { + channel_id: channel, + root_id: Some(listed.root_id.clone()), + }; + let timeline = ReadTarget { + channel_id: channel, + root_id: None, + }; + let queries = [(&thread, &on_diff), (&timeline, &diff)].map(|(target, message)| ContextQuery { + target: target.clone(), + message_ids: vec![message.id.to_hex()], + }); + let page = db + .personal_read_contexts( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &queries, + ) + .await + .unwrap(); + let wire = serde_json::to_value(&page).unwrap(); + assert_eq!( + wire["contexts"][0]["messages"][0], + serde_json::json!({"message_id":on_diff.id.to_hex(),"status":"unread","reason":"mention"}) + ); + assert_eq!(wire["contexts"][1]["messages"][0]["status"], "not_counted"); + + // The diff is still no anchor; the listed reply reads its thread alone. + let through_diff = ReadIntent::MarkThrough { + target: thread, + message_id: diff.id.to_hex(), + }; + assert_eq!( + apply(&db, community, &actor, through_diff).await, + IntentOutcome::Blocked + ); + assert_eq!( + apply(&db, community, &actor, listed.clone_target(channel)).await, + IntentOutcome::Applied + ); + let row = sidebar(&db, community, &actor).await; + assert_eq!(exact(&row.unread), Some(2)); + assert_eq!(row.threads.items.len(), 1); + assert_eq!(row.threads.items[0].latest_reply_id, elsewhere.id.to_hex()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_summaries_are_incomplete_when_tag_evidence_could_hide_a_root() { + let (db, pool, community, channel, actor, root) = fixture().await; + join(&db, &pool, community, channel, &actor, &root).await; + let listed = reply( + &db, + &pool, + community, + channel, + &root, + root.created_at.as_secs() + 1, + vec![], + ) + .await; + let hidden = post( + &db, + community, + channel, + root.created_at.as_secs() + 2, + vec![], + ) + .await; + sqlx::query("UPDATE events SET tags=$3 WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(hidden.id.as_bytes().as_slice()) + .bind(serde_json::json!([["e", "x".repeat(9000)]])) + .execute(&pool) + .await + .unwrap(); + let row = sidebar(&db, community, &actor).await; + assert_eq!(row.threads.items.len(), 1); + assert_eq!(row.threads.items[0].latest_reply_id, listed.id.to_hex()); + assert!(!row.threads.complete); + assert!(!matches!( + row.threads.items[0].unread, + ReadCount::Exact { .. } + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn targeted_sidebar_returns_only_requested_joined_channels_in_one_snapshot() { + let (db, _pool, community, channel, actor, _) = fixture().await; + let create = |name: &'static str, owner: Keys| { + let db = db.clone(); + async move { + db.create_channel( + community, + name, + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id + } + }; + let joined = create("joined", actor.clone()).await; + let foreign = create("not joined", Keys::generate()).await; + let _unrequested = create("unrequested", actor.clone()).await; + let page = db + .personal_read_sidebar_channels( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &[joined, foreign, channel], + ) + .await + .unwrap(); + let mut expected = vec![channel, joined]; + expected.sort(); + let ids: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect(); + assert_eq!(ids, expected); + assert!(page.next_cursor.is_none()); + for bad in [ + vec![], + vec![channel, channel], + (0..21).map(|_| Uuid::new_v4()).collect(), + ] { + assert!(db + .personal_read_sidebar_channels( + community, + &actor.public_key(), + DEFAULT_RETENTION_SECONDS, + &bad + ) + .await + .is_err()); + } +} diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs new file mode 100644 index 00000000000..9947d2b7ab6 --- /dev/null +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -0,0 +1,281 @@ +use super::model::*; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use sqlx::{Acquire, PgConnection, Row}; +use uuid::Uuid; + +use crate::{observability, Db, Result}; + +pub(super) fn event_id(value: &str) -> Option> { + if value.len() != 64 || value.bytes().any(|b| !b.is_ascii_hexdigit()) { + return None; + } + hex::decode(value).ok() +} + +pub(super) async fn deadlines(conn: &mut PgConnection) -> Result<()> { + sqlx::query("SET LOCAL statement_timeout = '2000ms'") + .execute(&mut *conn) + .await?; + sqlx::query("SET LOCAL lock_timeout = '500ms'") + .execute(&mut *conn) + .await?; + Ok(()) +} + +/// Serialize private frontier writes, never shared conversation rows. +pub(super) async fn lock_account( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], +) -> Result<()> { + sqlx::query( + "INSERT INTO personal_read_accounts (community_id,actor) VALUES ($1,$2) + ON CONFLICT (community_id,actor) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(actor) + .execute(&mut *conn) + .await?; + sqlx::query( + "SELECT actor FROM personal_read_accounts WHERE community_id=$1 AND actor=$2 FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(actor) + .fetch_one(conn) + .await?; + Ok(()) +} + +/// Resource access is independent of roster membership. Do not row-lock shared +/// conversation tables: private progress must not serialize legacy ingest or +/// deletion. A racing revoke hides projections; it need not erase private intent. +async fn access( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], + channel: Uuid, +) -> Result { + let visibility: Option = sqlx::query_scalar( + "SELECT visibility::text FROM channels + WHERE community_id=$1 AND id=$2 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(channel) + .fetch_optional(&mut *conn) + .await?; + match visibility.as_deref() { + None => Ok(false), + Some("open") => Ok(true), + Some(_) => Ok(sqlx::query_scalar::<_, Vec>( + "SELECT pubkey FROM channel_members WHERE community_id=$1 AND channel_id=$2 + AND pubkey=$3 AND removed_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(actor) + .fetch_optional(&mut *conn) + .await? + .is_some()), + } +} + +struct Message { + id: Vec, + received_at: DateTime, + root: Option>, +} + +/// A channel event and its canonical ancestry. `kinds` bounds the lookup: an +/// anchor must be a kind that can be unread; a thread root need not be. +async fn message( + conn: &mut PgConnection, + community: CommunityId, + channel: Uuid, + id: &[u8], + kinds: Option<&[i32]>, +) -> Result> { + let row = sqlx::query( + "SELECT e.id, e.received_at, e.tags, tm.root_event_id + FROM events e LEFT JOIN thread_metadata tm ON tm.community_id=e.community_id + AND tm.event_created_at=e.created_at AND tm.event_id=e.id AND tm.channel_id=e.channel_id + WHERE e.community_id=$1 AND e.channel_id=$2 AND e.id=$3 + AND ($4::int4[] IS NULL OR e.kind=ANY($4)) + LIMIT 1", + ).bind(community.as_uuid()).bind(channel).bind(id).bind(kinds).fetch_optional(&mut *conn).await?; + row.map(|row| { + let received_at: DateTime = row.try_get("received_at")?; + let id: Vec = row.try_get("id")?; + let root: Option> = row.try_get("root_event_id")?; + let tags: serde_json::Value = row.try_get("tags")?; + let tags: Vec> = serde_json::from_value(tags) + .map_err(|_| crate::DbError::InvalidData("invalid message tags".into()))?; + let markers = + buzz_core::nip10::parse_thread_markers_from_parts(tags.iter().map(Vec::as_slice)); + if markers.resolve().is_some() && root.is_none() { + return Err(crate::DbError::InvalidData( + "unresolved message ancestry".into(), + )); + } + Ok(Message { + id, + received_at, + root, + }) + }) + .transpose() +} + +/// An eligible-kind message's arrival time, deleted or not, without tags. +async fn anchor_received_at( + conn: &mut PgConnection, + community: CommunityId, + channel: Uuid, + id: &[u8], +) -> Result>> { + Ok(sqlx::query_scalar( + "SELECT received_at FROM events + WHERE community_id=$1 AND channel_id=$2 AND id=$3 AND kind=ANY($4) LIMIT 1", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(id) + .bind(ELIGIBLE_KINDS.as_slice()) + .fetch_optional(conn) + .await?) +} + +pub(super) async fn valid_target( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], + target: &ReadTarget, +) -> Result>> { + let root = match &target.root_id { + Some(root) => match event_id(root) { + Some(id) => id, + None => return Ok(None), + }, + None => Vec::new(), + }; + if !access(conn, community, actor, target.channel_id).await? { + return Ok(None); + } + if !root.is_empty() { + // Deleted roots still own living replies, and so do roots of a kind + // that is never unread itself (a diff). Validate actual ancestry, not + // absence of metadata: a missing index row is not a top-level proof. + let Some(msg) = message(conn, community, target.channel_id, &root, None).await? else { + return Ok(None); + }; + if msg.root.as_ref().is_some_and(|r| r != &msg.id) { + return Ok(None); + } + } + Ok(Some(root)) +} + +async fn frontier( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], + target: &ReadTarget, + root: &[u8], + through: DateTime, +) -> Result<()> { + sqlx::query( + "INSERT INTO personal_read_frontiers + (community_id, actor, channel_id, root_id, through_timestamp) VALUES ($1,$2,$3,$4,$5) + ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE + SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, EXCLUDED.through_timestamp)", + ).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root).bind(through) + .execute(&mut *conn).await?; + Ok(()) +} + +pub(super) async fn apply( + conn: &mut PgConnection, + community: CommunityId, + actor: &[u8], + intent: &ReadIntent, +) -> Result { + match intent { + ReadIntent::MarkThrough { target, message_id } => { + let Some(id) = event_id(message_id) else { + return Ok(IntentOutcome::Invalid); + }; + let Some(root) = valid_target(conn, community, actor, target).await? else { + return Ok(IntentOutcome::Blocked); + }; + let Some(msg) = message( + conn, + community, + target.channel_id, + &id, + Some(&ELIGIBLE_KINDS), + ) + .await? + else { + return Ok(IntentOutcome::Blocked); + }; + let is_reply = msg.root.as_ref().is_some_and(|r| r != &msg.id); + if (root.is_empty() && is_reply) + || (!root.is_empty() && msg.id != root && msg.root.as_ref() != Some(&root)) + { + return Ok(IntentOutcome::Blocked); + } + frontier(conn, community, actor, target, &root, msg.received_at).await?; + } + ReadIntent::MarkChannelRead { + channel_id, + message_id, + } => { + let Some(id) = event_id(message_id) else { + return Ok(IntentOutcome::Invalid); + }; + if !access(conn, community, actor, *channel_id).await? { + return Ok(IntentOutcome::Blocked); + } + // Only the anchor's arrival matters: ancestry cannot change which + // messages a whole-channel cut covers. + let Some(through) = anchor_received_at(conn, community, *channel_id, &id).await? else { + return Ok(IntentOutcome::Blocked); + }; + sqlx::query( + "INSERT INTO personal_read_frontiers (community_id, actor, channel_id, root_id, + through_timestamp, threads_through_timestamp) VALUES ($1,$2,$3,''::bytea,$4,$4) + ON CONFLICT (community_id, actor, channel_id, root_id) DO UPDATE + SET through_timestamp=GREATEST(personal_read_frontiers.through_timestamp, $4), + threads_through_timestamp=GREATEST(personal_read_frontiers.threads_through_timestamp, $4)", + ).bind(community.as_uuid()).bind(actor).bind(channel_id).bind(through) + .execute(&mut *conn).await?; + } + } + Ok(IntentOutcome::Applied) +} + +impl Db { + /// Apply one independent intent. Blocked/invalid intents roll back *all* + /// private account changes. Retrying after an + /// ambiguous commit is safe because only fixed max operands are used. + pub async fn apply_personal_read_intent( + &self, + community: CommunityId, + actor: &nostr::PublicKey, + intent: &ReadIntent, + ) -> Result { + let mut conn = + observability::acquire_writer(&self.pool, observability::WriterOperation::EventWrite) + .await?; + let mut tx = conn.begin().await?; + deadlines(&mut tx).await?; + let actor = actor.to_bytes(); + lock_account(&mut tx, community, &actor).await?; + let outcome = apply(&mut tx, community, &actor, intent).await?; + match outcome { + IntentOutcome::Applied => tx.commit().await?, + IntentOutcome::Blocked | IntentOutcome::Invalid => tx.rollback().await?, + } + Ok(outcome) + } +} diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index df5b972b0ca..d83c5e4f731 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -88,6 +88,8 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] +# Generate ephemeral P-256 signing fixtures; already in the runtime dependency graph. +ring = "0.17" # The mesh-llm smoke harnesses moved to `crates/buzz-mesh-smoke`. A # dev-dependency feeds every target of its package, so keeping the mesh-llm # SDK here forced the entire mesh-llm/skippy/rmcp tree to compile before any diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index d22835db2d8..12c1f8da5d3 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2918,7 +2918,7 @@ fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value { mod artifact_postgres_tests; #[cfg(test)] -mod postgres_tests { +pub(crate) mod postgres_tests { use super::*; use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag}; use std::sync::Mutex; @@ -4297,7 +4297,7 @@ mod postgres_tests { /// - Redis pool points at the local dev instance for the admission check. /// /// Returns `None` when local Postgres is not reachable. - pub(super) async fn bridge_handler_test_state() -> Option> { + pub(crate) async fn bridge_handler_test_state() -> Option> { let mut config = crate::config::Config::for_test(); // [FI-TRACE-ENV-RACE] config.database_url = crate::test_support::database_url(); // Use the real local Redis so enforce_http_admission can pass. @@ -5066,18 +5066,39 @@ mod postgres_tests { Some(Arc::new(state)) } - // EC P-256 test key constants shared by positive-control and cardinality tests. - // Private key (PKCS#8 PEM) + public key coordinates (JWK x/y/kid). - // Used by `nip_fi_enforce_test_state_with_verifier()` and - // `signed_assertion_for_pubkey()`. const HANDLER_TEST_ISSUER: &str = "https://issuer.example"; const HANDLER_TEST_AUDIENCE: &str = "https://relay.example"; const HANDLER_TEST_KID: &str = "test-key-1"; - const HANDLER_TEST_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ - WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ - zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ - -----END PRIVATE KEY-----\n"; + + /// Ephemeral signing material shared by the test signer and verifier. + /// Never persist a private key fixture in source or on disk. + fn handler_test_key() -> &'static (jsonwebtoken::EncodingKey, jsonwebtoken::jwk::JwkSet) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_FIXED_SIGNING}; + static KEY: std::sync::OnceLock<(jsonwebtoken::EncodingKey, jsonwebtoken::jwk::JwkSet)> = + std::sync::OnceLock::new(); + KEY.get_or_init(|| { + let rng = ring::rand::SystemRandom::new(); + let algorithm = &ECDSA_P256_SHA256_FIXED_SIGNING; + let der = EcdsaKeyPair::generate_pkcs8(algorithm, &rng).expect("generate test key"); + let pair = EcdsaKeyPair::from_pkcs8(algorithm, der.as_ref(), &rng) + .expect("parse generated test key"); + // P-256 public keys use uncompressed SEC1: 0x04 || x || y. + let public = pair.public_key().as_ref(); + assert_eq!(public.len(), 65); + assert_eq!(public[0], 4); + let jwks = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": HANDLER_TEST_KID, + "x": URL_SAFE_NO_PAD.encode(&public[1..33]), + "y": URL_SAFE_NO_PAD.encode(&public[33..65]) + }] + })) + .expect("valid generated JWKS"); + (jsonwebtoken::EncodingKey::from_ec_der(der.as_ref()), jwks) + }) + } /// Build a NIP-FI Enforce AppState with a real injected P-256 verifier. /// @@ -5089,19 +5110,11 @@ mod postgres_tests { AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, }; - use jsonwebtoken::{jwk::JwkSet, Algorithm}; + use jsonwebtoken::Algorithm; let mut state = (*nip_fi_enforce_test_state().await?).clone(); - let jwks: JwkSet = serde_json::from_value(serde_json::json!({ - "keys": [{ - "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", - "kid": HANDLER_TEST_KID, - "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", - "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" - }] - })) - .expect("valid test JWKS"); + let jwks = handler_test_key().1.clone(); let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); let key_set = AssertionKeySet::new_for_test(HANDLER_TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) @@ -5137,7 +5150,7 @@ mod postgres_tests { /// Mint a signed NIP-FI assertion whose `nostr_pubkey` = `pubkey_hex`, /// using the shared HANDLER_TEST_* key material. fn signed_assertion_for_pubkey(pubkey_hex: &str) -> String { - use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use jsonwebtoken::{Algorithm, Header}; let now = chrono::Utc::now().timestamp(); let claims = serde_json::json!({ "iss": HANDLER_TEST_ISSUER, @@ -5150,9 +5163,8 @@ mod postgres_tests { let mut header = Header::new(Algorithm::ES256); header.kid = Some(HANDLER_TEST_KID.to_owned()); header.typ = Some("nip-fi+jwt".to_owned()); - let key = - EncodingKey::from_ec_pem(HANDLER_TEST_EC_PEM.as_bytes()).expect("valid test EC PEM"); - jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + let key = &handler_test_key().0; + jsonwebtoken::encode(&header, &claims, key).expect("sign assertion") } /// Build a HeaderMap containing a valid NIP-98 Authorization header + @@ -6054,11 +6066,235 @@ mod postgres_tests { ); } + // All accessory methods must preserve the shared admission wire contract. + // Keep each route independent so the unfixed adapter fails all three tests. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_buzz_v1_sidebar_wire_contract() { + buzz_v1_wire_contract("GET", "/buzz/v1/me/sidebar?limit=1", b"").await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_buzz_v1_contexts_wire_contract() { + let targets = serde_json::json!([{"target":{"channel_id":uuid::Uuid::new_v4()}}]); + let encoded: String = targets + .to_string() + .bytes() + .map(|b| format!("%{b:02X}")) + .collect(); + buzz_v1_wire_contract( + "GET", + &format!("/buzz/v1/me/read-state?targets={encoded}"), + b"", + ) + .await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_buzz_v1_write_wire_contract() { + let body = serde_json::to_vec(&serde_json::json!({"intents":[{ + "type":"mark_channel_read", "channel_id":uuid::Uuid::new_v4(), + "message_id":"ab".repeat(32) + }]})) + .expect("serialize intent"); + buzz_v1_wire_contract("POST", "/buzz/v1/me/read-state", &body).await; + } + + async fn buzz_v1_wire_contract(method: &str, path: &str, request_body: &[u8]) { + use axum::http::{header, StatusCode}; + use buzz_auth::{CrossPodMergeResult, IssuerCapacity, NipFiDenyMap, NipFiMode}; + + let fixture = nip_fi_enforce_test_state_with_verifier() + .await + .expect("local Postgres"); + let mut state = (*fixture).clone(); + Arc::make_mut(&mut state.config).buzz_v1_enabled = true; + let deny_map = Arc::new(NipFiDenyMap::new( + 10, + vec![IssuerCapacity { + issuer: HANDLER_TEST_ISSUER.to_owned(), + capacity: 10, + }], + )); + state.nip_fi_deny_map = Some(deny_map.clone()); + let state = Arc::new(state); + let host = format!("bffv1-{}.local", uuid::Uuid::new_v4().simple()); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + let url = format!("https://{host}{path}"); + let actor = Keys::generate(); + let other = Keys::generate(); + let paired = same_key_nip98_and_assertion_headers(&actor, &url, method, request_body); + let (status, headers, body) = oneshot_request_full( + state.clone(), + method, + path, + &host, + paired.clone(), + request_body, + ) + .await; + assert_eq!(status, StatusCode::OK, "admitted control: {body:?}"); + assert_eq!(headers[header::CONTENT_TYPE], "application/json"); + assert_eq!(headers[header::CACHE_CONTROL], "private, no-store"); + let value: serde_json::Value = serde_json::from_slice(&body).expect("control JSON"); + if method == "POST" { + assert_eq!( + body.as_ref(), + br#"{"outcomes":[{"status":"blocked"}],"projection_status":"not_requested"}"# + ); + } else { + assert!(value["account"]["cutoff_ms"].is_i64()); + assert_eq!( + value["account"]["retention_seconds"], + state.config.buzz_v1_retention_seconds + ); + if path.contains("sidebar") { + assert_eq!(value["channels"], serde_json::json!([])); + assert_eq!(value["next_cursor"], serde_json::Value::Null); + } else { + assert_eq!( + value["contexts"], + serde_json::json!([{"status":"unavailable"}]) + ); + } + } + + for case in [ + "mismatch", + "denied", + "missing_nip98", + "invalid_nip98", + "missing_assertion", + "invalid_assertion", + ] { + let mut request_headers = paired.clone(); + match case { + "mismatch" => { + let assertion = signed_assertion_for_pubkey(&other.public_key().to_hex()); + request_headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}") + .parse() + .expect("assertion header"), + ); + } + "denied" => { + let now = chrono::Utc::now(); + assert_eq!( + deny_map.merge_cross_pod_deny( + HANDLER_TEST_ISSUER, + &actor.public_key(), + now + chrono::Duration::minutes(5), + now + ), + CrossPodMergeResult::Merged + ); + } + "missing_nip98" => { + request_headers.remove(header::AUTHORIZATION); + } + "invalid_nip98" => { + request_headers.insert(header::AUTHORIZATION, "Nostr invalid".parse().unwrap()); + } + "missing_assertion" => { + request_headers.remove(buzz_auth::CLIENT_ATTACHED_HEADER); + } + "invalid_assertion" => { + request_headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + "Bearer invalid".parse().unwrap(), + ); + } + _ => unreachable!(), + } + let (status, headers, body) = oneshot_request_full( + state.clone(), + method, + path, + &host, + request_headers, + request_body, + ) + .await; + let missing = case.starts_with("missing_"); + assert_eq!( + status, + if missing { + StatusCode::UNAUTHORIZED + } else { + StatusCode::FORBIDDEN + }, + "{case}" + ); + assert_eq!( + headers[header::CONTENT_TYPE], + "text/plain; charset=utf-8", + "{case}" + ); + assert_eq!( + body.as_ref(), + if missing { + b"authentication required\n".as_slice() + } else if case.starts_with("invalid_") { + b"evidence rejected\n".as_slice() + } else { + b"authorization denied\n".as_slice() + }, + "{case}" + ); + assert_eq!( + headers + .get(header::WWW_AUTHENTICATE) + .map(|v| v.to_str().unwrap()), + missing.then_some("Nostr"), + "{case}" + ); + // Outer assertion middleware owns its own cache policy. + if !case.ends_with("assertion") { + assert_eq!( + headers[header::CACHE_CONTROL], + "private, no-store", + "{case}" + ); + } + } + + // Off ignores the same real deny entry, and retains application JSON for + // missing request auth rather than leaking the bridge's error envelope. + let mut off = (*state).clone(); + Arc::make_mut(&mut off.config).nip_fi.mode = NipFiMode::Off; + let off = Arc::new(off); + assert_eq!( + oneshot_request_full(off.clone(), method, path, &host, paired, request_body) + .await + .0, + StatusCode::OK + ); + let (status, headers, body) = oneshot_request_full( + off, + method, + path, + &host, + axum::http::HeaderMap::new(), + request_body, + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(headers[header::CONTENT_TYPE], "application/json"); + assert_eq!(headers[header::CACHE_CONTROL], "private, no-store"); + assert!(!headers.contains_key(header::WWW_AUTHENTICATE)); + let error: serde_json::Value = serde_json::from_slice(&body).expect("Off JSON"); + assert_eq!(error["error"]["code"], "unauthorized"); + assert!(uuid::Uuid::parse_str(error["error"]["request_id"].as_str().unwrap()).is_ok()); + } + // ── Caller key-pairing witness: moderation mismatched key → 403 ───────── - // - // Mirror of the GIF case through the moderation route. - // Falsifying mutation: remove key-pairing check → admission passes → 403 from - // moderation authz (not NIP-FI) with different JSON body. #[test] #[ignore = "requires Postgres"] fn nip_fi_enforce_moderation_mismatched_key_is_403() { @@ -6496,7 +6732,7 @@ mod postgres_tests { AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, }; - use jsonwebtoken::{jwk::JwkSet, Algorithm}; + use jsonwebtoken::Algorithm; let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -6560,29 +6796,15 @@ mod postgres_tests { // ── 2. Build the verifier with StaticIssuerKeySource + test key ─────── // - // The verifier is seeded with a known P-256 public key. Tokens that - // claim `iss=https://issuer.test` will be verified against this key. + // The verifier is seeded with an ephemeral P-256 public key. Tokens that + // claim `iss=https://issuer.example` will be verified against this key. // A token with an all-zero signature will fail `InvalidSignatureOrClaims` // → DenialClass::EvidenceRejected → 403. // - // Key constants match the canonical test key in buzz-auth - // (verifier/tests.rs): TEST_JWK_X / TEST_JWK_Y / TEST_KID / ISSUER. const TEST_ISSUER: &str = "https://issuer.example"; const TEST_AUDIENCE: &str = "https://relay.example"; - const TEST_KID: &str = "test-key-1"; - let jwks: JwkSet = serde_json::from_value(serde_json::json!({ - "keys": [{ - "kty": "EC", - "crv": "P-256", - "use": "sig", - "alg": "ES256", - "kid": TEST_KID, - "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", - "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" - }] - })) - .expect("valid test JWKS"); + let jwks = handler_test_key().1.clone(); let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); let key_set = AssertionKeySet::new_for_test(TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) @@ -6744,7 +6966,7 @@ mod postgres_tests { AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, }; - use jsonwebtoken::{jwk::JwkSet, Algorithm}; + use jsonwebtoken::Algorithm; let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -6953,27 +7175,12 @@ mod postgres_tests { panic!("local Postgres not reachable (enforce)"); }; - // Inject the real verifier with the static test key. + // Inject the real verifier with the ephemeral test key. const TEST_ISSUER: &str = "https://issuer.example"; const TEST_AUDIENCE: &str = "https://relay.example"; const TEST_KID: &str = "test-key-1"; - // PKCS#8 private key matching TEST_JWK_X/Y — same key used by - // nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires. - const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ - WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ - zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ - -----END PRIVATE KEY-----\n"; - - let jwks: JwkSet = serde_json::from_value(serde_json::json!({ - "keys": [{ - "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", - "kid": TEST_KID, - "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", - "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" - }] - })) - .expect("valid test JWKS"); + + let jwks = handler_test_key().1.clone(); let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); let key_set = AssertionKeySet::new_for_test(TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) @@ -7015,7 +7222,7 @@ mod postgres_tests { // Mint a valid signed assertion for an arbitrary test pubkey. let assertion_pubkey_hex = nostr::Keys::generate().public_key().to_hex(); let valid_assertion = { - use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use jsonwebtoken::{Algorithm, Header}; let now = chrono::Utc::now().timestamp(); let claims = serde_json::json!({ "iss": TEST_ISSUER, @@ -7028,9 +7235,8 @@ mod postgres_tests { let mut header = Header::new(Algorithm::ES256); header.kid = Some(TEST_KID.to_owned()); header.typ = Some("nip-fi+jwt".to_owned()); - let key = - EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); - jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + let key = &handler_test_key().0; + jsonwebtoken::encode(&header, &claims, key).expect("sign assertion") }; // Pre-condition: verifier accepts the token. @@ -7059,7 +7265,7 @@ mod postgres_tests { // Mint a same-key assertion: nostr_pubkey = keys2's public key. let same_key_assertion = { - use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use jsonwebtoken::{Algorithm, Header}; let now = chrono::Utc::now().timestamp(); let claims = serde_json::json!({ "iss": TEST_ISSUER, @@ -7072,9 +7278,8 @@ mod postgres_tests { let mut header = Header::new(Algorithm::ES256); header.kid = Some(TEST_KID.to_owned()); header.typ = Some("nip-fi+jwt".to_owned()); - let key = - EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); - jsonwebtoken::encode(&header, &claims, &key).expect("sign same-key assertion") + let key = &handler_test_key().0; + jsonwebtoken::encode(&header, &claims, key).expect("sign same-key assertion") }; // Pre-condition: same-key assertion is accepted. assert!( diff --git a/crates/buzz-relay/src/api/buzz_v1/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs new file mode 100644 index 00000000000..08434a253d1 --- /dev/null +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -0,0 +1,177 @@ +use crate::{ + api::{bridge, relay_members}, + state::AppState, +}; +use axum::{ + http::{header, HeaderMap, StatusCode, Uri}, + response::{IntoResponse, Response}, + Json, +}; +use buzz_core::TenantContext; +use serde_json::{json, Value}; +use std::sync::Arc; + +pub(super) enum Error { + Application { + status: StatusCode, + code: &'static str, + }, + Admission(Box), +} +impl Error { + pub(super) fn new(status: StatusCode, code: &'static str) -> Self { + Self::Application { status, code } + } + pub(super) fn unavailable() -> Self { + Self::new(StatusCode::SERVICE_UNAVAILABLE, "unavailable") + } + pub(super) fn terminal_denial(&self) -> bool { + let status = match self { + Self::Application { status, .. } => *status, + Self::Admission(response) => response.status(), + }; + matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) + } + pub(super) fn invalid() -> Self { + Self::new(StatusCode::BAD_REQUEST, "invalid_request") + } +} +impl IntoResponse for Error { + fn into_response(self) -> Response { + let mut response = match self { + Self::Admission(response) => *response, + Self::Application { status, code } => { + let mut response = ( + status, + Json(json!({"error":{"code":code, + "request_id":uuid::Uuid::new_v4().to_string()}})), + ) + .into_response(); + if status == StatusCode::TOO_MANY_REQUESTS + || status == StatusCode::SERVICE_UNAVAILABLE + { + response + .headers_mut() + .insert(header::RETRY_AFTER, header::HeaderValue::from_static("60")); + } + response + } + }; + response.headers_mut().insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("private, no-store"), + ); + response + } +} + +pub(super) fn bridge_error((status, _): (StatusCode, Json)) -> Error { + let code = match status { + StatusCode::UNAUTHORIZED => "unauthorized", + StatusCode::FORBIDDEN => "forbidden", + StatusCode::TOO_MANY_REQUESTS => "rate_limited", + _ => "unavailable", + }; + Error::new(status, code) +} + +pub(super) struct Principal { + pub(super) tenant: TenantContext, + pub(super) actor: nostr::PublicKey, + signed_at: Option, +} + +pub(super) async fn authorize( + state: &Arc, + headers: &HeaderMap, + uri: &Uri, + method: &'static str, + body: Option<&[u8]>, +) -> Result { + let tenant = crate::nip_fi_shadow::bind_tenant(state, headers) + .await + .ok_or_else(|| Error::new(StatusCode::NOT_FOUND, "not_found"))?; + let path = uri + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or(uri.path()); + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + // Private state always requires cryptographic identity, even on a dev relay. + // NIP-FI admission owns NIP-98 extraction, assertion pairing and deny map. + let admission = crate::nip_fi_http::admit_nip_fi_http_on_state( + state, + headers, + bridge::make_nip98_closure_for_admission( + headers.clone(), + method, + url, + body.map(<[u8]>::to_vec), + true, + body.is_some(), + ), + ) + .map_err(|response| { + if state.config.nip_fi.mode.restricts() { + // Preserve the shared NIP-FI wire contract, not just its status. + Error::Admission(Box::new(response)) + } else { + bridge_error((response.status(), Json(Value::Null))) + } + })?; + let actor = *admission.proven_pubkey(); + let (event_id, signed_at) = admission.into_extra(); + bridge::enforce_http_admission(state, &tenant, &actor) + .await + .map_err(bridge_error)?; + bridge::check_nip98_replay(state, &tenant, event_id) + .await + .map_err(bridge_error)?; + let principal = Principal { + tenant, + actor, + signed_at, + }; + recheck(state, headers, &principal).await?; + Ok(principal) +} + +pub(super) async fn recheck( + state: &AppState, + headers: &HeaderMap, + principal: &Principal, +) -> Result<(), Error> { + relay_members::enforce_relay_membership( + state, + principal.tenant.community(), + &principal.actor.to_bytes(), + relay_members::extract_auth_tag_header(headers), + principal.signed_at, + ) + .await + .map_err(bridge_error)?; + let restrictions = state + .db + .moderation_restriction_state(principal.tenant.community(), &principal.actor.to_bytes()) + .await + .map_err(|_| Error::unavailable())?; + if restrictions.banned { + return Err(Error::new(StatusCode::FORBIDDEN, "forbidden")); + } + // Timeouts block posting conversation content, not personal reading. + Ok(()) +} + +pub(super) fn response(value: impl serde::Serialize) -> Result { + let bytes = serde_json::to_vec(&value).map_err(|_| Error::unavailable())?; + if bytes.len() > 1024 * 1024 { + return Err(Error::unavailable()); + } + Ok(( + [ + (header::CONTENT_TYPE, "application/json"), + (header::CACHE_CONTROL, "private, no-store"), + ], + bytes, + ) + .into_response()) +} diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs new file mode 100644 index 00000000000..01e0e4110ec --- /dev/null +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -0,0 +1,226 @@ +use super::auth::{self, Error}; +use crate::state::AppState; +use axum::{ + body::Bytes, + extract::{OriginalUri, Query, State}, + http::HeaderMap, + response::Response, +}; +use buzz_db::personal_read::{ReadIntent, MAX_CHANNELS, MAX_INTENTS}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{sync::Arc, time::Duration}; +use uuid::Uuid; + +/// 1..=MAX_CHANNELS unique UUIDs, comma-separated; anything else is invalid. +fn parse_channel_ids(value: &str) -> Option> { + let ids = value + .split(',') + .map(|id| Uuid::parse_str(id).ok()) + .collect::>>()?; + let unique: std::collections::HashSet<_> = ids.iter().collect(); + ((1..=MAX_CHANNELS).contains(&ids.len()) && unique.len() == ids.len()).then_some(ids) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SidebarQuery { + limit: Option, + cursor: Option, + /// Comma-separated channel UUIDs to refresh; exclusive with paging. + channel_ids: Option, +} + +pub(super) async fn sidebar( + State(state): State>, + headers: HeaderMap, + OriginalUri(uri): OriginalUri, + query: Result, axum::extract::rejection::QueryRejection>, +) -> Result { + tokio::time::timeout(Duration::from_secs(8), async { + let principal = auth::authorize(&state, &headers, &uri, "GET", None).await?; + let Query(query) = query.map_err(|_| Error::invalid())?; + if query + .limit + .is_some_and(|limit| !(1..=MAX_CHANNELS).contains(&limit)) + { + return Err(Error::invalid()); + } + let community = principal.tenant.community(); + let retention = state.config.buzz_v1_retention_seconds; + let page = match query.channel_ids { + Some(ids) => { + let ids = parse_channel_ids(&ids) + .filter(|_| query.limit.is_none() && query.cursor.is_none()) + .ok_or_else(Error::invalid)?; + state + .db + .personal_read_sidebar_channels(community, &principal.actor, retention, &ids) + .await + } + None => { + state + .db + .personal_read_sidebar( + community, + &principal.actor, + retention, + query.limit.unwrap_or(MAX_CHANNELS), + query.cursor, + ) + .await + } + } + .map_err(|_| Error::unavailable())?; + auth::recheck(&state, &headers, &principal).await?; + let channels: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect(); + let memberships = state + .db + .membership_pairs( + principal.tenant.community(), + &channels, + &[principal.actor.to_bytes().to_vec()], + ) + .await + .map_err(|_| Error::unavailable())?; + if memberships.len() != channels.len() { + return Err(Error::unavailable()); + } + auth::response(page) + }) + .await + .map_err(|_| Error::unavailable())? +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Batch { + intents: Vec, +} + +pub(super) async fn write( + State(state): State>, + headers: HeaderMap, + OriginalUri(uri): OriginalUri, + body: Bytes, +) -> Result { + if body.len() > 64 * 1024 { + return Err(Error::invalid()); + } + let principal = auth::authorize(&state, &headers, &uri, "POST", Some(&body)).await?; + let batch: Batch = serde_json::from_slice(&body).map_err(|_| Error::invalid())?; + if batch.intents.is_empty() || batch.intents.len() > MAX_INTENTS { + return Err(Error::invalid()); + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(8); + let mut outcomes = Vec::with_capacity(batch.intents.len()); + for item in batch.intents { + let Ok(intent) = serde_json::from_value::(item) else { + outcomes.push(json!({"status":"invalid"})); + continue; + }; + // A deadline/DB failure after commit is ambiguous, not a false failure. + // Earlier acknowledged commits survive all later projection/item failures. + outcomes.push( + tokio::time::timeout_at( + deadline, + write_intent(&state, &headers, &principal, &intent), + ) + .await + .unwrap_or_else(|_| json!({"status":"unknown","retryable":true})), + ); + } + auth::response(json!({"outcomes":outcomes,"projection_status":"not_requested"})) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ContextQuery { + // JSON array carried as one URL-encoded, signed query parameter. + targets: String, +} + +pub(super) async fn contexts( + State(state): State>, + headers: HeaderMap, + OriginalUri(uri): OriginalUri, + query: Result, axum::extract::rejection::QueryRejection>, +) -> Result { + use buzz_db::personal_read::{ + ContextQuery as Target, ContextState, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, + }; + tokio::time::timeout(Duration::from_secs(8), async { + if uri.to_string().len() > 16 * 1024 { + return Err(Error::invalid()); + } + let principal = auth::authorize(&state, &headers, &uri, "GET", None).await?; + let Query(query) = query.map_err(|_| Error::invalid())?; + let targets: Vec = + serde_json::from_str(&query.targets).map_err(|_| Error::invalid())?; + let valid_id = |id: &str| id.len() == 64 && id.bytes().all(|c| c.is_ascii_hexdigit()); + if targets.is_empty() + || targets.len() > MAX_CONTEXTS + || targets.iter().map(|t| t.message_ids.len()).sum::() > MAX_CONTEXT_MESSAGES + || targets.iter().any(|t| { + t.message_ids.iter().any(|id| !valid_id(id)) + || t.target.root_id.as_deref().is_some_and(|id| !valid_id(id)) + }) + { + return Err(Error::invalid()); + } + let mut page = state + .db + .personal_read_contexts( + principal.tenant.community(), + &principal.actor, + state.config.buzz_v1_retention_seconds, + &targets, + ) + .await + .map_err(|_| Error::unavailable())?; + auth::recheck(&state, &headers, &principal).await?; + let channels: Vec<_> = targets.iter().map(|t| t.target.channel_id).collect(); + let allowed = state + .db + .personal_read_accessible_contexts( + principal.tenant.community(), + &principal.actor, + &channels, + ) + .await + .map_err(|_| Error::unavailable())?; + for (target, result) in targets.iter().zip(&mut page.contexts) { + if !allowed.contains(&target.target.channel_id) { + *result = ContextState::Unavailable; + } + } + auth::response(page) + }) + .await + .map_err(|_| Error::unavailable())? +} + +// Preserve earlier committed outcomes while distinguishing a definite denial +// before the transaction from an ambiguous storage/timeout failure. +pub(super) async fn write_intent( + state: &AppState, + headers: &HeaderMap, + principal: &auth::Principal, + intent: &ReadIntent, +) -> Value { + if let Err(error) = auth::recheck(state, headers, principal).await { + return if error.terminal_denial() { + json!({"status":"blocked"}) + } else { + json!({"status":"unknown","retryable":true}) + }; + } + match state + .db + .apply_personal_read_intent(principal.tenant.community(), &principal.actor, intent) + .await + { + Ok(outcome) => json!(outcome), + Err(_) => json!({"status":"unknown","retryable":true}), + } +} diff --git a/crates/buzz-relay/src/api/buzz_v1/mod.rs b/crates/buzz-relay/src/api/buzz_v1/mod.rs new file mode 100644 index 00000000000..63265f1c023 --- /dev/null +++ b/crates/buzz-relay/src/api/buzz_v1/mod.rs @@ -0,0 +1,26 @@ +//! Private accessory API. Conversation authority remains signed Nostr events. + +mod auth; +mod handlers; + +use crate::state::AppState; +use axum::{routing::get, Router}; +use std::sync::Arc; + +/// Public accessory prefix, shared by routing and capability discovery. +pub const BASE_PATH: &str = "/buzz/v1"; + +/// Narrow versioned router; unknown accessory paths never return SPA HTML. +pub fn router(state: Arc) -> Router { + Router::new() + .route("/me/sidebar", get(handlers::sidebar)) + .route( + "/me/read-state", + get(handlers::contexts).post(handlers::write), + ) + .fallback(|| async { auth::Error::new(axum::http::StatusCode::NOT_FOUND, "not_found") }) + .with_state(state) +} + +#[cfg(test)] +mod postgres_tests; diff --git a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs new file mode 100644 index 00000000000..58ed929bec2 --- /dev/null +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -0,0 +1,662 @@ +use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, +}; +use base64::Engine; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use tower::ServiceExt; + +fn proof(key: &Keys, host: &str, path: &str, method: &str, body: Option<&[u8]>) -> String { + let mut tags = vec![ + Tag::parse(["u", &format!("https://{host}{path}")]).unwrap(), + Tag::parse(["method", method]).unwrap(), + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(), + ]; + if let Some(body) = body { + tags.push(Tag::parse(["payload", &hex::encode(Sha256::digest(body))]).unwrap()); + } + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(key) + .unwrap(); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ) +} + +async fn request( + state: Arc, + host: &str, + path: &str, + method: &str, + auth: Option<&str>, + body: &[u8], +) -> (StatusCode, Value) { + let mut req = Request::builder() + .method(method) + .uri(path) + .header("host", host); + if let Some(auth) = auth { + req = req.header("authorization", auth); + } + let response = crate::router::build_router(state) + .oneshot(req.body(Body::from(body.to_vec())).unwrap()) + .await + .unwrap(); + let status = response.status(); + assert_eq!( + response.headers().get("cache-control").unwrap(), + "private, no-store" + ); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_router_signed_url_body_replay_and_actor_boundary() { + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.require_auth_token = true; + config.require_relay_membership = true; + config.buzz_v1_enabled = true; + state.nip98_replay = Arc::new(buzz_pubsub::RedisNip98ReplayGuard::new( + state.redis_pool.clone(), + )); + let state = Arc::new(state); + let host = format!("bff-{}.local", uuid::Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let actor = Keys::generate(); + let other = Keys::generate(); + let path = "/buzz/v1/me/sidebar?limit=1"; + let auth = proof(&actor, &host, path, "GET", None); + assert_eq!( + request(state.clone(), &host, path, "GET", Some(&auth), b"") + .await + .0, + StatusCode::FORBIDDEN + ); + for key in [&actor, &other] { + state + .db + .add_relay_member(community, &key.public_key().to_hex(), "member", None) + .await + .unwrap(); + } + assert_eq!( + request(state.clone(), &host, path, "GET", None, b"") + .await + .0, + StatusCode::UNAUTHORIZED + ); + let wrong_query = proof(&actor, &host, "/buzz/v1/me/sidebar?limit=2", "GET", None); + assert_eq!( + request(state.clone(), &host, path, "GET", Some(&wrong_query), b"") + .await + .0, + StatusCode::UNAUTHORIZED + ); + let wrong_host = proof(&actor, "other.invalid", path, "GET", None); + assert_eq!( + request(state.clone(), &host, path, "GET", Some(&wrong_host), b"") + .await + .0, + StatusCode::UNAUTHORIZED + ); + let auth = proof(&actor, &host, path, "GET", None); + let accepted = request(state.clone(), &host, path, "GET", Some(&auth), b"").await; + assert_eq!(accepted.0, StatusCode::OK, "{}", accepted.1); + assert_eq!( + request(state.clone(), &host, path, "GET", Some(&auth), b"") + .await + .0, + StatusCode::UNAUTHORIZED + ); + // Targeted refresh is exclusive with paging and bounded to unique IDs. + let id = uuid::Uuid::new_v4(); + let many = (0..21) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>() + .join(","); + for (query, status) in [ + (format!("channel_ids={id}"), StatusCode::OK), + (format!("channel_ids={id},{id}"), StatusCode::BAD_REQUEST), + (format!("channel_ids={id}&limit=1"), StatusCode::BAD_REQUEST), + ( + format!("channel_ids={id}&cursor={id}"), + StatusCode::BAD_REQUEST, + ), + ("channel_ids=".into(), StatusCode::BAD_REQUEST), + ("channel_ids=not-a-uuid".into(), StatusCode::BAD_REQUEST), + (format!("channel_ids={many}"), StatusCode::BAD_REQUEST), + ] { + let path = format!("/buzz/v1/me/sidebar?{query}"); + let auth = proof(&actor, &host, &path, "GET", None); + let (got, body) = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; + assert_eq!(got, status, "{query}: {body}"); + if status == StatusCode::OK { + assert_eq!(body["channels"], json!([]), "unjoined ID is simply absent"); + assert!(body["next_cursor"].is_null()); + } + } + let channel = state + .db + .create_channel( + community, + "boundary", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let event = EventBuilder::new(Kind::Custom(9), "read by one signer only") + .sign_with_keys(&Keys::generate()) + .unwrap(); + state + .db + .insert_event(community, &event, Some(channel)) + .await + .unwrap(); + let write_path = "/buzz/v1/me/read-state"; + let body = serde_json::to_vec(&json!({"intents":[{"type":"mark_channel_read", + "channel_id":channel,"message_id":event.id.to_hex()}]})) + .unwrap(); + let missing_hash = proof(&actor, &host, write_path, "POST", None); + assert_eq!( + request( + state.clone(), + &host, + write_path, + "POST", + Some(&missing_hash), + &body + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + let wrong_hash = proof(&actor, &host, write_path, "POST", Some(b"{}")); + assert_eq!( + request( + state.clone(), + &host, + write_path, + "POST", + Some(&wrong_hash), + &body + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + let auth = proof(&actor, &host, write_path, "POST", Some(&body)); + let applied = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await; + assert_eq!(applied.0, StatusCode::OK, "{}", applied.1); + assert_eq!(applied.1["outcomes"][0]["status"], "applied"); + // The frontier belongs to the signer alone. + let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]); + let targets: String = targets + .to_string() + .bytes() + .map(|b| format!("%{b:02X}")) + .collect(); + let path = format!("/buzz/v1/me/read-state?targets={targets}"); + for (key, status) in [(&actor, "read"), (&other, "unread")] { + let auth = proof(key, &host, &path, "GET", None); + let page = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; + assert_eq!(page.0, StatusCode::OK, "{}", page.1); + assert_eq!(page.1["contexts"][0]["messages"][0]["status"], status); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_context_get_signed_query_and_independent_batch_outcomes() { + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let mut state = (*fixture).clone(); + Arc::make_mut(&mut state.config).buzz_v1_enabled = true; + let state = Arc::new(state); + let host = format!("bff-context-{}.local", uuid::Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let actor = Keys::generate(); + let channel = state + .db + .create_channel( + community, + "context", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let event = EventBuilder::new(Kind::Custom(9), "message selector") + .sign_with_keys(&Keys::generate()) + .unwrap(); + state + .db + .insert_event(community, &event, Some(channel)) + .await + .unwrap(); + let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]); + let encode = |value: &str| { + value + .bytes() + .map(|b| format!("%{b:02X}")) + .collect::() + }; + let path = format!( + "/buzz/v1/me/read-state?targets={}", + encode(&targets.to_string()) + ); + let auth = proof(&actor, &host, &path, "GET", None); + let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; + assert_eq!(result.0, StatusCode::OK, "{}", result.1); + assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "unread"); + let auth = proof(&actor, &host, "/buzz/v1/me/read-state", "GET", None); + assert_eq!( + request(state.clone(), &host, &path, "GET", Some(&auth), b"") + .await + .0, + StatusCode::UNAUTHORIZED + ); + let bad_path = "/buzz/v1/me/read-state?targets=invalid"; + let auth = proof(&actor, &host, bad_path, "GET", None); + assert_eq!( + request(state.clone(), &host, bad_path, "GET", Some(&auth), b"") + .await + .0, + StatusCode::BAD_REQUEST + ); + let write_path = "/buzz/v1/me/read-state"; + let body = serde_json::to_vec(&json!({"intents":[ + {"type":"unknown"}, + {"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event.id.to_hex()}, + {"type":"mark_through","target":{"channel_id":channel},"message_id":event.id.to_hex()}, + {"type":"mark_through","target":{"channel_id":channel},"message_id":"not an event id"} + ]})).unwrap(); + let auth = proof(&actor, &host, write_path, "POST", Some(&body)); + let result = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await; + assert_eq!(result.0, StatusCode::OK, "{}", result.1); + assert_eq!( + result.1["outcomes"], + json!([{"status":"invalid"},{"status":"blocked"},{"status":"applied"},{"status":"invalid"}]) + ); + let auth = proof(&actor, &host, &path, "GET", None); + let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; + assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "read"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_write_revocation_is_terminal_before_persistence() { + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.buzz_v1_enabled = true; + config.require_relay_membership = true; + let state = Arc::new(state); + let host = format!("bff-revocation-{}.local", uuid::Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let actor = Keys::generate(); + state + .db + .add_relay_member(community, &actor.public_key().to_hex(), "member", None) + .await + .unwrap(); + let channel = state + .db + .create_channel( + community, + "revocation", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + let event = EventBuilder::new(Kind::Custom(9), "markable before revoke") + .sign_with_keys(&Keys::generate()) + .unwrap(); + state + .db + .insert_event(community, &event, Some(channel)) + .await + .unwrap(); + let intent = buzz_db::personal_read::ReadIntent::MarkChannelRead { + channel_id: channel, + message_id: event.id.to_hex(), + }; + let path = "/buzz/v1/me/read-state"; + let body = &serde_json::to_vec(&json!({"intents":[intent]})).unwrap()[..]; + let mut headers = axum::http::HeaderMap::new(); + headers.insert("host", host.parse().unwrap()); + headers.insert( + "authorization", + proof(&actor, &host, path, "POST", Some(body)) + .parse() + .unwrap(), + ); + let principal = + super::auth::authorize(&state, &headers, &path.parse().unwrap(), "POST", Some(body)) + .await + .ok() + .expect("admitted before revoke"); + state + .db + .remove_relay_member(community, &actor.public_key().to_hex()) + .await + .unwrap(); + // Exercise the same per-item function the batch handler uses after admission. + let result = super::handlers::write_intent(&state, &headers, &principal, &intent).await; + assert_eq!(result, json!({"status":"blocked"})); + let persisted: i64 = + sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(persisted, 0, "denied intent must not persist"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_discovery_is_host_bound_and_opt_in() { + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let host = format!("bff-discovery-{}.local", uuid::Uuid::new_v4()); + fixture.db.ensure_configured_community(&host).await.unwrap(); + for enabled in [false, true] { + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.buzz_v1_enabled = enabled; + config.buzz_v1_retention_seconds = 1234; + let state = Arc::new(state); + for known_host in [true, false] { + let request_host = if known_host { + host.as_str() + } else { + "unknown.invalid" + }; + for path in ["/", "/info"] { + let response = crate::router::build_router(state.clone()) + .oneshot( + Request::builder() + .uri(path) + .header("host", request_host) + .header("accept", "application/nostr+json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let doc: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + doc.get("buzz_v1").is_some(), + enabled && known_host, + "enabled={enabled} known_host={known_host} path={path}" + ); + if enabled && known_host { + let d = &doc["buzz_v1"]; + assert_eq!(d["version"], 1); + assert_eq!(d["base_path"], "/buzz/v1"); + assert_eq!(d["retention_seconds"], 1234); + assert_eq!(d["max_channels"], buzz_db::personal_read::MAX_CHANNELS); + assert_eq!(d["max_intents"], buzz_db::personal_read::MAX_INTENTS); + assert_eq!(d["max_contexts"], buzz_db::personal_read::MAX_CONTEXTS); + assert_eq!( + d["max_context_messages"], + buzz_db::personal_read::MAX_CONTEXT_MESSAGES + ); + assert_eq!(d["max_thread_summaries"], 5); + assert_eq!(d["eligible_kinds"], json!([9, 40002, 45001, 45003])); + } + } + } + // Disabled means unmounted: indistinguishable from a path that never existed. + let mut statuses = Vec::new(); + for path in ["/buzz/v1/me/sidebar", "/buzz/v1-never-existed"] { + let request = Request::builder().uri(path).header("host", host.as_str()); + let response = crate::router::build_router(state.clone()) + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + statuses.push(response.status()); + } + assert_eq!(statuses[0] == statuses[1], !enabled, "{statuses:?}"); + } +} + +// Pins Off parity under NIP-FI Shadow. These error bodies carry a per-request +// id, so they cannot ride the router's byte-for-byte Shadow rows: Shadow keeps +// Off's status and code and leaves exactly one verdict. +// Mutation: binding the tenant without `bind_tenant` leaves the unseeded row +// with no record; answering a failed proof with the NIP-FI response whenever +// the mode is not Off changes the seeded row's code. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_shadow_matches_off() { + use crate::nip_fi_core::tests::ScriptedVerifier; + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + for (seeded, status, code) in [ + (false, StatusCode::NOT_FOUND, "not_found"), + (true, StatusCode::UNAUTHORIZED, "unauthorized"), + ] { + let host = format!("bff-shadow-{}.local", uuid::Uuid::new_v4()); + if seeded { + fixture.db.ensure_configured_community(&host).await.unwrap(); + } + for (mode, records) in [ + (buzz_auth::NipFiMode::Off, 0), + (buzz_auth::NipFiMode::Shadow, 1), + ] { + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.buzz_v1_enabled = true; + config.nip_fi.mode = mode; + config.nip_fi.communities = crate::nip_fi_config::NipFiCommunities::for_test( + &format!("https://{host}"), + &["https://issuer.test"], + ); + state.nip_fi_verifier = Some(Arc::new(ScriptedVerifier::new(Ok(Some( + Keys::generate().public_key(), + ))))); + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + // An assertion the guard accepts, and no NIP-98 proof. + let req = Request::get("/buzz/v1/me/sidebar") + .header("host", &host) + .header(buzz_auth::CLIENT_ATTACHED_HEADER, "Bearer a.b.c") + .body(Body::empty()) + .unwrap(); + let response = crate::router::build_router(Arc::new(state)) + .oneshot(req) + .await + .unwrap(); + let got = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap_or_default(); + let recorded: u64 = snapshotter + .snapshot() + .into_vec() + .iter() + .filter(|(key, ..)| key.key().name() == "buzz_nip_fi_shadow_total") + .map(|(.., value)| match value { + metrics_util::debugging::DebugValue::Counter(n) => *n, + _ => 0, + }) + .sum(); + assert_eq!( + (got, &body["error"]["code"], recorded), + (status, &json!(code), records), + "seeded={seeded} {mode:?}" + ); + } + } +} + +// Exercise the real signed HTTP ingest path, including deletion side effects, +// rather than directly tombstoning an event in the database. +async fn signed_sidebar_deletion(deletion_kind: u16) { + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.require_auth_token = true; + config.require_relay_membership = true; + config.buzz_v1_enabled = true; + let state = Arc::new(state); + let host = format!("bff-deletion-{}.local", uuid::Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let admin = Keys::generate(); + let author = Keys::generate(); + let reader = Keys::generate(); + for key in [&admin, &author, &reader] { + state + .db + .add_relay_member(community, &key.public_key().to_hex(), "member", None) + .await + .unwrap(); + } + let channel = state + .db + .create_channel( + community, + "deletion", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &admin.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + for key in [&author, &reader] { + state + .db + .add_member( + community, + channel, + &key.public_key().to_bytes(), + buzz_db::channel::MemberRole::Member, + None, + ) + .await + .unwrap(); + } + let message = EventBuilder::new(Kind::Custom(9), "unread message to delete") + .tags([Tag::parse(["h", &channel.to_string()]).unwrap()]) + .sign_with_keys(&author) + .unwrap(); + let mut tags = vec![Tag::parse(["e", &message.id.to_hex()]).unwrap()]; + let signer = if deletion_kind == 5 { + &author + } else { + tags.push(Tag::parse(["h", &channel.to_string()]).unwrap()); + &admin + }; + let deletion = EventBuilder::new(Kind::Custom(deletion_kind), "") + .tags(tags) + .sign_with_keys(signer) + .unwrap(); + let path = format!("/buzz/v1/me/sidebar?channel_ids={channel}"); + for (event, key, count) in [(&message, &author, 1), (&deletion, signer, 0)] { + let body = serde_json::to_vec(event).unwrap(); + let auth = proof(key, &host, "/events", "POST", Some(&body)); + // /events has a different response contract from the accessory helper. + let response = crate::router::build_router(state.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri("/events") + .header("host", &host) + .header("authorization", auth) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let result: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["accepted"], true, "{result}"); + assert_eq!(result["event_id"], event.id.to_hex(), "{result}"); + + // This distinct member never writes a frontier. Reads alone must not + // erase unread state; only the accepted signed deletion changes it. + let auth = proof(&reader, &host, &path, "GET", None); + let (status, sidebar) = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await; + assert_eq!(status, StatusCode::OK, "{sidebar}"); + let channels = sidebar["channels"].as_array().unwrap(); + assert_eq!(channels.len(), 1, "{sidebar}"); + assert_eq!(channels[0]["channel_id"], channel.to_string()); + assert_eq!( + channels[0]["unread"], + json!({"status":"exact", "value":count}), + "kind {deletion_kind}, after kind {}: {sidebar}", + event.kind.as_u16() + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_signed_kind5_deletion_clears_another_readers_sidebar_count() { + signed_sidebar_deletion(5).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accessory_signed_kind9005_deletion_clears_another_readers_sidebar_count() { + signed_sidebar_deletion(9005).await; +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 45297908df0..5e068f5fdf6 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod bridge; +pub mod buzz_v1; pub mod events; pub mod gifs; pub mod git; diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index a9b300d9f27..1acbf01c700 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -181,6 +181,10 @@ pub struct Config { /// Whether REST API requests must present a valid token. Independent of /// WebSocket protocol auth, which is *always* required by REQ/EVENT/COUNT. pub require_auth_token: bool, + /// Opt-in private accessory API; disabled until explicitly deployed. + pub buzz_v1_enabled: bool, + /// Author-time unread tracking duration, independent of NIP-RS retention. + pub buzz_v1_retention_seconds: u32, /// Comma-separated list of allowed CORS origins. /// If empty, permissive CORS is used (dev mode). /// Example: "tauri://localhost,http://localhost:3000" @@ -1332,6 +1336,15 @@ impl Config { )); } + let buzz_v1_enabled = std::env::var("BUZZ_V1_ENABLED").is_ok_and(|v| v == "true"); + // Read only when enabled: a disabled relay ignores every v1 setting. + let buzz_v1_retention_seconds = match std::env::var("BUZZ_V1_RETENTION_SECONDS") { + Ok(v) if buzz_v1_enabled => v.parse().ok().filter(|s| *s > 0).ok_or_else(|| { + ConfigError::InvalidValue("BUZZ_V1_RETENTION_SECONDS must be positive".into()) + })?, + _ => buzz_db::personal_read::DEFAULT_RETENTION_SECONDS, + }; + Ok(Self { bind_addr, database_url, @@ -1351,6 +1364,8 @@ impl Config { slow_client_grace_limit, auth, require_auth_token, + buzz_v1_enabled, + buzz_v1_retention_seconds, cors_origins, relay_private_key, uds_path, @@ -1909,6 +1924,33 @@ mod tests { assert!(matches!(admin.auth, crate::config::AdminAuth::Nip98)); } + #[test] + fn buzz_v1_retention_is_validated_only_when_enabled() { + let _guards = env_guards(); + const KEYS: [&str; 2] = ["BUZZ_V1_ENABLED", "BUZZ_V1_RETENTION_SECONDS"]; + let previous = KEYS.map(std::env::var_os); + std::env::set_var("BUZZ_V1_RETENTION_SECONDS", "0"); + std::env::remove_var("BUZZ_V1_ENABLED"); + let disabled = Config::from_env(); + std::env::set_var("BUZZ_V1_ENABLED", "true"); + let enabled = Config::from_env(); + for (key, value) in KEYS.into_iter().zip(previous) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + assert!( + disabled.is_ok(), + "a disabled relay ignores it: {disabled:?}" + ); + assert!(matches!( + enabled, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_V1_RETENTION_SECONDS") + )); + } + #[test] fn malformed_relay_owner_pubkey_is_a_startup_error_not_warn_and_ignore() { let _guards = env_guards(); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index d8f8d83c6ea..3ef9524887c 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -34,6 +34,9 @@ pub struct RelayInfo { /// Host-bound atomic read-state snapshot capability; absent on unresolved hosts. #[serde(skip_serializing_if = "Option::is_none")] pub read_state_snapshot: Option, + /// Opt-in, host-bound private accessory API; separate from legacy NIP-RS. + #[serde(skip_serializing_if = "Option::is_none")] + pub buzz_v1: Option, /// NIP-AR artifact query transport and enforced resource limits. pub artifacts: serde_json::Value, /// Relay operator's public key (hex), if published. @@ -78,6 +81,30 @@ pub struct RelayInfo { pub federated_identity: Option, } +/// Private read-state accessory contract and enforced client request limits. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuzzV1Descriptor { + /// Accessory contract version, not a Nostr protocol number. + pub version: u32, + /// Relay-relative API prefix; callers retain the requesting origin. + pub base_path: String, + /// Author-time unread horizon; does not expire messages or frontiers. + pub retention_seconds: u32, + /// Maximum joined channels per sidebar page. + pub max_channels: usize, + /// Maximum independent write intents per request. + pub max_intents: usize, + /// Maximum explicit contexts per read request. + pub max_contexts: usize, + /// Maximum message selectors across one context request. + pub max_context_messages: usize, + /// Maximum unread-thread summaries per sidebar channel row. + pub max_thread_summaries: usize, + /// Message kinds that count as unread and as latest activity. Clients + /// classify live arrivals with this set instead of keeping a copy. + pub eligible_kinds: [i32; 4], +} + /// Public capability descriptor for relay-proxied GIF search. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GifDescriptor { @@ -250,6 +277,7 @@ impl RelayInfo { description: "Buzz — private team communication relay".to_string(), icon: icon.filter(|s| !s.is_empty()).map(|s| s.to_string()), read_state_snapshot: None, + buzz_v1: None, artifacts: serde_json::json!({ "version": 1, "revision_kind": 45010, "removal_kind": 45011, "query": "/query", "count": "/count", @@ -360,6 +388,23 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st state.config.klipy.as_ref().map(|_| "klipy"), ); if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { + if state.config.buzz_v1_enabled { + use buzz_db::personal_read::{ + ELIGIBLE_KINDS, MAX_CHANNELS, MAX_CONTEXTS, MAX_CONTEXT_MESSAGES, MAX_INTENTS, + MAX_THREAD_SUMMARIES, + }; + info.buzz_v1 = Some(BuzzV1Descriptor { + version: 1, + base_path: crate::api::buzz_v1::BASE_PATH.to_owned(), + retention_seconds: state.config.buzz_v1_retention_seconds, + max_channels: MAX_CHANNELS, + max_intents: MAX_INTENTS, + max_contexts: MAX_CONTEXTS, + max_context_messages: MAX_CONTEXT_MESSAGES, + max_thread_summaries: MAX_THREAD_SUMMARIES, + eligible_kinds: ELIGIBLE_KINDS, + }); + } info.read_state_snapshot = Some(serde_json::json!({ "version": 1, "community_id": tenant.community().as_uuid(), diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 17501fbaa98..6349f15c663 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -306,6 +306,13 @@ pub fn build_router(state: Arc) -> Router { let admin_router = admin_enabled .then(|| Router::new().nest("/api/admin/v1", api::admin::router(state.clone()))); + // Unmounted when disabled, so every /buzz/v1 path answers exactly as it + // did before the accessory API existed. + let accessory_router = state + .config + .buzz_v1_enabled + .then(|| Router::new().nest(api::buzz_v1::BASE_PATH, api::buzz_v1::router(state.clone()))); + let api_router = Router::new() // WebSocket + NIP-11 .route("/", get(nip11_or_ws_handler)) @@ -407,8 +414,8 @@ pub fn build_router(state: Arc) -> Router { .merge(media_router) .merge(git_router) .merge(git_policy_router); - if let Some(admin_router) = admin_router { - merged = merged.merge(admin_router); + for optional in [accessory_router, admin_router].into_iter().flatten() { + merged = merged.merge(optional); } // Serve both bundles from one fallback. The admin host is checked first so diff --git a/docs/buzz-v1-extension-design.md b/docs/buzz-v1-extension-design.md new file mode 100644 index 00000000000..51a6448b3b9 --- /dev/null +++ b/docs/buzz-v1-extension-design.md @@ -0,0 +1,21 @@ +# Read-state accessory extension constraints + +Design guidance for follow-up work, not implemented wire capabilities. +The current [API contract](buzz-v1-read-state.md) remains authoritative. + +Thread summaries are implemented (see `threads` in the API contract). Richer +previews that carry signed-event content should be opt-in and preserve the base sidebar's work/byte budgets; +fetching detail separately remains valid. Reuse canonical classification and +read frontiers, not a second definition of unread. Historical-mention eligibility +or mute-aware attention requires an explicit policy contract, not an unnoticed +change to today's `attention` field. + +Channel-content revisions, personal-state revisions and synchronized preferences +are separate from read frontiers. Future revisions must describe the same +snapshot as their payload, and are invalidation tokens, not history cursors or +access grants. Count reuse also needs time/configuration validity because the +unread horizon moves without writes. Mutes and manual-unread overrides must not +be encoded by advancing or rewinding a read frontier. + +These are extension constraints, not implemented capabilities: this version does +not advertise revision-based reuse or synchronized overrides. diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md new file mode 100644 index 00000000000..b6210a75b44 --- /dev/null +++ b/docs/buzz-v1-read-state.md @@ -0,0 +1,311 @@ +# Private read-state accessory API + +`BUZZ_V1_ENABLED=true` opts into `/buzz/v1`; it is disabled by default. +Conversation history, live events, edits and deletion remain Nostr-authoritative. +This API neither replaces Nostr reads nor writes artificial signed events. +Legacy NIP-RS continues unchanged, but does not synchronize with these tables. + +## Discovery and identity + +On a known community host, NIP-11 (`GET /` with `Accept: +application/nostr+json`, or `GET /info`) includes `buzz_v1` only when enabled: + +```json +{"buzz_v1":{"version":1,"base_path":"/buzz/v1","retention_seconds":2592000, +"max_channels":20,"max_intents":100,"max_contexts":20,"max_context_messages":100, +"max_thread_summaries":5,"eligible_kinds":[9,40002,45001,45003]}} +``` + +Use the requesting origin plus this relative prefix. Discovery is a configured +capability, not a promise that the next request cannot fail. Unknown hosts and +disabled deployments omit it, and a disabled deployment does not mount +`/buzz/v1` at all. Absence means read state is unavailable, not that everything +is read: show no count rather than zero, and keep unsent intents. A client may +also speak NIP-RS, which the relay still serves, but the two never synchronize; +buzz-app uses v1 only. + +Every API request requires NIP-98, including on development relays. Sign the +exact externally addressed URL, including the encoded query, and method. POST +also requires the SHA-256 payload tag for the exact body bytes. Each retry needs +fresh authorization; replay protection is shared with the bridge. Applicable +NIP-FI admission is enforced and its asserted key must match the request signer. +The host chooses the community; the signer chooses `/me`. NIP-OA admission does +not grant access to the owner's personal state. Relay membership, bans and +resource access are enforced; moderation timeouts do not prohibit reading. + +Responses produced by the v1 handlers are `Cache-Control: private, no-store`. +Application errors (including NIP-98 failures with NIP-FI Off or Shadow) use +`{"error":{"code":"invalid_request","request_id":"..."}}`, with 400 invalid, +401 unauthorized/replay, 403 forbidden, 404 unavailable capability/host/path, +429 rate limited or 503 temporarily unavailable. Application 429/503 errors +include `Retry-After`. When NIP-FI restricts (Enforce or DenyProtected), +admission failures instead preserve +the shared [NIP-FI HTTP denial contract](nips/NIP-FI.md): status, fixed plaintext +body, `Content-Type` and (for 401) `WWW-Authenticate: Nostr`. The v1 handler adds +`private, no-store` without changing those fields, unlike the bridge's direct +passthrough. Denials from the outer shared router middleware use its common +response policy, not the v1 handler's cache or JSON policy. +Unknown request fields are rejected. Never turn a transport failure into read. + +## Sidebar + +`GET /buzz/v1/me/sidebar?limit=20&cursor=` returns +`account`, `channels`, and `next_cursor`. Omit the cursor on the first request. +Each channel includes identity/name/type, archived and hidden flags, `unread`, +`attention`, `latest_message_id` (the last eligible message to arrive: the +row's read anchor, see [Order](#order)), `latest_message_at` (the greatest +author time among eligible messages, in seconds: display activity, not +necessarily that message's own time; null exactly when the ID is null), +`latest_message_complete`, and `threads`. Only joined, nondeleted channels are +listed. Hidden/archived presentation remains client-owned. Each page has a +writer-consistent snapshot; separate pages do not share a snapshot, and an +unfinished traversal cannot prove channel removal. + +`GET /buzz/v1/me/sidebar?channel_ids=,` refreshes 1–20 unique +channels in one snapshot, ordered by ID with `next_cursor: null`. It cannot be +combined with `limit` or `cursor`. A requested ID absent from the result was not +a joined, nondeleted, accessible sidebar row at that snapshot: remove its row. +Absence says nothing else about access to an open channel. + +`threads` lists unread threads in the row, newest unread reply first: + +```json +{"items":[{"root_id":"<64-hex>","unread":{"status":"exact","value":2}, + "latest_reply_id":"<64-hex>","latest_reply_at":1700000000}],"complete":true} +``` + +Items are canonical roots with unread replies that count (see below), ordered +by `latest_reply_at` descending, then `root_id`; at most 5. `latest_reply_id` is +the last such reply to arrive (equal arrivals prefer the smaller ID), so a +thread `mark_through` at it reads every reply listed; `latest_reply_at` is its +author time. Replies that do not count are filtered out before the count, the +latest reply and the cap are chosen. `unread` uses the row's +definition; every counted reply is also attention, so items carry no separate +attention count. `complete=true` means the unread window was exhausted, no +evidence had unresolved ancestry, unusable tags or undecided membership, and no +thread was omitted; then item unread counts sum to the row's unread replies. +Otherwise the list is a cut of observed evidence and counts may be lower bounds +or unknown. No message bytes are included. + +Counts have exactly three representations: + +```json +[{"status":"exact","value":0},{"status":"at_least","value":7},{"status":"unknown"}] +``` + +Only exact zero proves absence. Unknown has no numeric value. A message is +eligible when it is non-own, nondeleted, of the advertised `eligible_kinds` and +inside the horizon. The same kinds alone define latest activity, so an edit, +reaction or diff (40008) neither makes a channel unread nor moves it. Classify +live arrivals with the advertised set, not a client copy. + +An eligible message beyond the matching context frontier counts as unread for +the first reason that holds: + +| `reason` | Holds when | +|---|---| +| `direct` | its channel is a DM | +| `mention` | it tags the actor with `p` | +| `conversation` | it is a reply, and the actor wrote its direct parent or has a reply to that same parent, in that channel | +| `broadcast` | it carries `broadcast=1` | +| null | it is top-level | + +A reply with no reason does not count: it is not unread and appears in no +thread list. `unread` counts the messages that count; `attention` is the subset +with a reason. Conversation membership uses only the actor's live eligible +messages (a deleted parent proves nothing; a surviving reply to it still does), +looks at the direct parent only (owning the root or replying elsewhere in the +thread proves nothing), and is independent of read progress and retention. A +reply whose membership is undecided is left out, and the row's counts become +lower bounds or unknown. The sidebar counts a broadcast reply without asking +which of the two reasons applies. This is not Desktop notification policy: +follows and mutes do not affect these counts. + +The unread horizon defaults to 30 days (`BUZZ_V1_RETENTION_SECONDS`) and is +measured in author time (`created_at`): a message counts while its author time +is at or after `account.cutoff_ms`. It filters unread/attention, not latest +activity, event storage or frontier state. Frontiers use a different clock, +relay arrival (see [Order](#order)). Three consequences: + +- A message accepted late with an author time beyond the horizon (an import, a + backfill, a long-offline sender) is excluded under the current horizon, + however recently the relay accepted it. It can still be latest when the + horizon holds no message. +- Unread expires at author time plus the horizon, so a future-dated author + time extends how long a message counts. +- A context's unread set is two tests, not one range: arrived after the + frontier, and author time at or after the cutoff. + +A later configuration expansion can change counts without having lost progress. +Latest activity is independent of actor and frontiers. A null latest ID proves +an empty eligible history only when `latest_message_complete=true`. + +## Explicit contexts + +`GET /buzz/v1/me/read-state?targets=` accepts up to 20 +contexts and 100 total concrete message selectors. It is not event history or a +global export of frontiers. Example decoded `targets`: + +```json +[{"target":{"channel_id":"","root_id":"<64-hex-root>"}, + "message_ids":["<64-hex-event>"]}] +``` + +Omitting `root_id` selects the channel timeline. The result contains `account` +and one `contexts` entry per request entry, in order. Context status is +`available` (with `messages`), `unknown`, or `unavailable`. A thread context's +frontier includes any whole-channel cut. Message status is `read`, `not_counted`, +`unread` (with `reason`), `unknown`, or `unavailable`. Wrong-context, missing +and forbidden selectors share unavailable. Status is decided in this order: +ancestry and context; eligibility (`not_counted` for own, deleted, other kinds +and outside the horizon); the frontier (`read`, with no membership lookup); then +the reason. A reply past the frontier with no reason is `not_counted` when it is +proven outside the actor's conversations and `unknown` when membership is +undecided. A broadcast reply whose membership is undecided is `unread` with +reason `broadcast` and may report `conversation` on a later request. +Conversation bytes must still come from the existing Nostr path. + +## Fixed-operand writes + +`POST /buzz/v1/me/read-state` accepts 1–100 independent intents: + +```json +{"intents":[ + {"type":"mark_through","target":{"channel_id":""},"message_id":"<64-hex-event>"}, + {"type":"mark_through","target":{"channel_id":"","root_id":"<64-hex-root>"},"message_id":"<64-hex-event>"}, + {"type":"mark_channel_read","channel_id":"","message_id":"<64-hex-event>"} +]} +``` + +Each intent commits atomically and returns its own `applied`, `blocked` or +`invalid` outcome. An ambiguous timeout/storage failure returns +`{"status":"unknown","retryable":true}`. Earlier committed outcomes survive +later failures. `projection_status` is `not_requested`; a successful write does +not assert a client has refreshed. Retry the same operands, never substitute +latest. Keep pending intent durably on the client until its outcome is resolved. + +A mark-through validates a fixed message and advances its context's monotone +frontier to that message's relay arrival (see [Order](#order)). Channel and +thread frontiers never inherit in either direction. Opening a view is not +itself a reading action; client dwell/focus policy determines when to send an +actual observed anchor. Old or deleted valid anchors may advance a frontier. + +`mark_channel_read` is the one whole-channel cut: it advances the channel +timeline and every thread in that channel, including unlisted ones, through the +anchor's arrival. The anchor must be an accessible eligible-kind message in +the channel, top-level or reply, deleted or not; ancestry is not checked. A +reply is read at or below the greater of its thread frontier and this cut. An +anchor that no longer exists is `blocked`. `latest_message_id` is the anchor +that reads the whole row. A null ID with `latest_message_complete=false` does +not prove empty history; it only leaves the client without an anchor. Thread +marks and channel `mark_through` never set the cut. + +### Order + +A frontier is the relay arrival time (`events.received_at`) of the message a +context was read through, never its author time, which the sender chooses and +the relay accepts up to 15 minutes either way. Everything that arrived at or +before the anchor is read, whatever its author time. A message that arrives +later is unread even when backdated, and a future-dated anchor reads nothing +that arrives after it. + +The order is the relay's and is not exposed: no response carries a frontier, +and no field lets a client compute what a mark will cover. Send the anchors the +user actually saw, let the relay take the greatest, and ask a context which +messages are read. Do not compare author times, IDs or local receipt order to +drop one pending anchor in favor of another. + +Arrival is the accepting relay process's clock, read just before the insert, at +microsecond resolution. Three limits follow, none of which strands a badge: + +- It is not commit order. An insert that commits after a later-stamped message + was already marked read lands read. +- Relay processes with different clocks can stamp out of true order by their + skew. +- Messages with the identical stamp are read together. + +`latest_message_id` is the last message to arrive among those the unread count +examined: the 4,096 most recent events by author time inside the horizon. So +marking through it reads everything counted. When the horizon holds no message, +it is the last to arrive among the channel's 256 most recent events. + +There is no import of earlier client read state: an account starts with no +frontiers, and the horizon bounds what that can show as unread. Manual unread +remains device-local. + +## Bounds and deployment + +- 20 sidebar rows, 100 intents, 20 contexts / 100 selectors per request. +- 64 KiB write body; 16 KiB context URL; 1 MiB serialized API response. +- 4096 raw events per channel inside the horizon plus one exhaustion sentinel, + before eligibility. +- Latest activity probes 256 events plus a sentinel; long ineligible tails may + leave latest incomplete even when unread is exact. +- Tag documents over 8192 bytes or malformed relevant tags yield uncertainty. + Compact boolean facts cross the database boundary, never raw tag payloads. +- Conversation membership: at most 1024 unique parents per request, with a + 500 ms savepoint budget. The lookup is exact, so its work grows with the + replies under each parent. Past either bound a reply is undecided, never + absent: counts become lower bounds or unknown and a context reports `unknown`. +- DB statement/lock deadlines and HTTP read deadlines bound work; writes use a + shared eight-second intent-processing deadline after admission. Limits are + containment, not a production capacity claim. + +Apply migration 0056 (or the equivalent desired schema). It creates two empty +private tables and no index on `events`: both sidebar scans are served by the +existing `idx_events_community_channel_created`. No new per-message ingest write +path or stored unread counters are introduced. + +Use existing HTTP route/status/latency metrics for `/buzz/v1/me/sidebar` and +`/buzz/v1/me/read-state`, plus database pool/statement metrics. Inspect exact / +lower-bound / unknown proportions in controlled acceptance captures; no payload, +actor, channel or frontier values should become metric labels. The measured +local seed is not a DAU/concurrency or p95/p99 production acceptance result. + +## Compatibility and extension rules + +Within `/buzz/v1`, clients must ignore unknown response object fields. Existing +required fields, status variants and their meanings remain stable; additive +fields do not authorize silently changing `attention` or frontier semantics. +Breaking changes require an explicitly negotiated contract or a new API version. +Requests remain strict: send new parameters or intent types only after the relay +advertises the corresponding capability. Missing optional data means unsupported +or not requested, never an empty list, zero count or unchanged revision. + +Follow-up design constraints are recorded in +[the extension design note](buzz-v1-extension-design.md); they do not advertise +additional capabilities. + +## Privacy and lifecycle + +These typed relational frontiers are signer-private application state, **not +self-encrypted**. Database operators can see reading progress; ordinary Nostr +queries, search and moderator interfaces do not expose it. No public receipts +are emitted. Storage grows by touched contexts, not observed messages, and has +no fixed context-count ceiling. + +Leaving/rejoining does not erase progress; revoked access hides it. Soft-deleted +channels are inaccessible, while hard channel deletion cascades their frontiers. +Deleting an account row cascades that actor's frontiers in the same community; +community erasure inventories both tables under the existing write fence. There +is no new public account export/reset endpoint. Operator-assisted erasure/export +must use the established authenticated operational process and explicitly scope +both community and actor; never equate the read-time horizon with data erasure. + +Migration 0056 must be applied before this relay serves, enabled or not: started +without it and with auto-migration off, the relay stops before readiness. There +is no down migration, and disabling the API is not a rollback. A relay built +before 0056 that restarts with `BUZZ_AUTO_MIGRATE=true` (the Helm default) +refuses to start on the migrated schema. Whole-community deletion run from a +build before 0056 rejects the two new tables. A deletion approved on the +earlier schema and not yet fenced fails structural revalidation after 0056: +take pending approvals back through operator review before rollout, and do not +rewrite them. + +Roll out disabled-by-default to controlled accounts after agent and human live +acceptance. Disabling the API unmounts it and leaves both tables in place: v1 +clients lose access to read state and keep their unsent intents until it +returns. Neither setting changes NIP-RS state or how NIP-RS requests are +processed. While enabled, v1 requests count against the signer's existing +API-call quota and share the existing writer database pool with other relay +work. diff --git a/migrations/0056_personal_read_state.sql b/migrations/0056_personal_read_state.sql new file mode 100644 index 00000000000..41889e8890e --- /dev/null +++ b/migrations/0056_personal_read_state.sql @@ -0,0 +1,33 @@ +-- Private accessory read progress. Never included in Nostr event queries. +-- A frontier is the relay arrival time (events.received_at) of the message a +-- context was read through, never signed event time, which the sender chooses. +-- An empty root_id covers only the channel timeline; a root-specific frontier +-- covers that thread, without inheritance. +-- threads_through_timestamp is the only cross-context cut: an explicit +-- whole-channel read that also covers every thread in that channel. +CREATE TABLE personal_read_accounts ( + community_id UUID NOT NULL REFERENCES communities(id), + actor BYTEA NOT NULL CHECK (octet_length(actor) = 32), + PRIMARY KEY (community_id, actor) +); + +CREATE TABLE personal_read_frontiers ( + community_id UUID NOT NULL, + actor BYTEA NOT NULL, + channel_id UUID NOT NULL, + root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)), + through_timestamp TIMESTAMPTZ NOT NULL, + -- Whole-channel cut covering every thread; channel rows only. + threads_through_timestamp TIMESTAMPTZ + CHECK (threads_through_timestamp IS NULL OR root_id = ''::bytea), + PRIMARY KEY (community_id, actor, channel_id, root_id), + FOREIGN KEY (community_id, actor) + REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE, + FOREIGN KEY (community_id, channel_id) + REFERENCES channels (community_id, id) ON DELETE CASCADE +); + + + +SELECT attach_community_write_fence('personal_read_accounts'); +SELECT attach_community_write_fence('personal_read_frontiers'); diff --git a/schema/schema.sql b/schema/schema.sql index fb4b06fb31f..d1114885050 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -193,6 +193,32 @@ CREATE UNIQUE INDEX idx_users_nip05 ON users (community_id, lower(nip05_handle)) CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) WHERE okta_user_id IS NOT NULL; +-- Private accessory read progress. Never included in Nostr event queries. +-- A frontier is the relay arrival time (events.received_at) of the message a +-- context was read through; the unread cutoff alone is signed event time. An +-- empty root_id denotes a channel frontier. +CREATE TABLE personal_read_accounts ( + community_id UUID NOT NULL REFERENCES communities(id), + actor BYTEA NOT NULL CHECK (octet_length(actor) = 32), + PRIMARY KEY (community_id, actor) +); + +CREATE TABLE personal_read_frontiers ( + community_id UUID NOT NULL, + actor BYTEA NOT NULL, + channel_id UUID NOT NULL, + root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)), + through_timestamp TIMESTAMPTZ NOT NULL, + -- Whole-channel cut covering every thread; channel rows only. + threads_through_timestamp TIMESTAMPTZ + CHECK (threads_through_timestamp IS NULL OR root_id = ''::bytea), + PRIMARY KEY (community_id, actor, channel_id, root_id), + FOREIGN KEY (community_id, actor) + REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE, + FOREIGN KEY (community_id, channel_id) + REFERENCES channels (community_id, id) ON DELETE CASCADE +); + -- ── Events (partitioned by month on created_at) ────────────────────────────── -- Conformance: "Channel-less global events and DMs". `community_id` leads the -- PK and every hot-path index. Partition stays BY RANGE (created_at) — the @@ -1789,6 +1815,8 @@ SELECT attach_community_write_fence('join_policy_acceptances'); SELECT attach_community_write_fence('moderation_actions'); SELECT attach_community_write_fence('moderation_reports'); SELECT attach_community_write_fence('parameterized_event_watermarks'); +SELECT attach_community_write_fence('personal_read_accounts'); +SELECT attach_community_write_fence('personal_read_frontiers'); SELECT attach_community_write_fence('pubkey_allowlist'); SELECT attach_community_write_fence('push_leases'); SELECT attach_community_write_fence('push_match_queue');