mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(relay): private read-state accessory API (#7906)
Buzz BFF: Private read-state accessory API (`/buzz/v1`)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1466,6 +1466,7 @@ dependencies = [
|
||||
"rand 0.10.1",
|
||||
"redis",
|
||||
"reqwest 0.13.4",
|
||||
"ring",
|
||||
"rust-s3",
|
||||
"rustls",
|
||||
"serde",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
468
crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs
Normal file
468
crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs
Normal file
@@ -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<Tag>,
|
||||
) -> 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<String> {
|
||||
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<u32> {
|
||||
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<u32> {
|
||||
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::<chrono::Utc>::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<chrono::Utc> =
|
||||
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<String>, Option<u32>) {
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
30
crates/buzz-db/src/store/personal_read/classification.rs
Normal file
30
crates/buzz-db/src/store/personal_read/classification.rs
Normal file
@@ -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<String>]) -> Option<Reason> {
|
||||
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
|
||||
}
|
||||
}
|
||||
257
crates/buzz-db/src/store/personal_read/context.rs
Normal file
257
crates/buzz-db/src/store/personal_read/context.rs
Normal file
@@ -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<ContextPage> {
|
||||
if queries.is_empty()
|
||||
|| queries.len() > MAX_CONTEXTS
|
||||
|| queries.iter().map(|q| q.message_ids.len()).sum::<usize>() > 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<DateTime<Utc>> = 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<Vec<u8>> = 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<String, _> = rows
|
||||
.into_iter()
|
||||
.map(|row| Ok((row.try_get::<String, _>("id")?, row)))
|
||||
.collect::<Result<_>>()?;
|
||||
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<serde_json::Value> = row.try_get("tags")?;
|
||||
let parsed =
|
||||
tags.and_then(|v| serde_json::from_value::<Vec<Vec<String>>>(v).ok());
|
||||
if let Some(tags) = parsed {
|
||||
let canonical: Option<Vec<u8>> = 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<Utc> = row.try_get("created_at")?;
|
||||
let received: DateTime<Utc> = 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::<String, _>("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<Vec<u8>> = 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<Vec<Uuid>> {
|
||||
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<bool>) {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
29
crates/buzz-db/src/store/personal_read/mod.rs
Normal file
29
crates/buzz-db/src/store/personal_read/mod.rs
Normal file
@@ -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;
|
||||
256
crates/buzz-db/src/store/personal_read/model.rs
Normal file
256
crates/buzz-db/src/store/personal_read/model.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<i64>,
|
||||
/// 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<ThreadReadSummary>,
|
||||
/// 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<ChannelReadSummary>,
|
||||
/// Exclusive UUID roster cursor. None means this roster scan exhausted.
|
||||
pub next_cursor: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<Reason>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<ContextMessage>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<ContextState>,
|
||||
}
|
||||
127
crates/buzz-db/src/store/personal_read/participation.rs
Normal file
127
crates/buzz-db/src/store/personal_read/participation.rs
Normal file
@@ -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<u8>)],
|
||||
) -> Result<HashMap<(Uuid, Vec<u8>), 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<u8>)]) -> Vec<(Uuid, Vec<u8>)> {
|
||||
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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Tag>) -> 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<Tag>,
|
||||
) -> 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<T: serde::Serialize>(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>) -> 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"));
|
||||
}
|
||||
1051
crates/buzz-db/src/store/personal_read/postgres_tests.rs
Normal file
1051
crates/buzz-db/src/store/personal_read/postgres_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
441
crates/buzz-db/src/store/personal_read/projection.rs
Normal file
441
crates/buzz-db/src/store/personal_read/projection.rs
Normal file
@@ -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<Uuid>,
|
||||
) -> Result<SidebarPage> {
|
||||
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<SidebarPage> {
|
||||
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<Uuid>,
|
||||
only: Option<&[Uuid]>,
|
||||
) -> Result<SidebarPage> {
|
||||
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::<i64, _>("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<Vec<u8>, 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<Vec<u8>, Replies>, root: Vec<u8>, 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<ThreadReadSummary>,
|
||||
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<ReadAccount> {
|
||||
let cutoff: DateTime<Utc> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<String>>> = 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<Vec<Vec<String>>> = 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
650
crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs
Normal file
650
crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs
Normal file
@@ -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<Tag>,
|
||||
) -> 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<Tag>,
|
||||
) -> 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<Tag>,
|
||||
) -> 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<u32> {
|
||||
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<Option<chrono::DateTime<chrono::Utc>>> = 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());
|
||||
}
|
||||
}
|
||||
281
crates/buzz-db/src/store/personal_read/writes.rs
Normal file
281
crates/buzz-db/src/store/personal_read/writes.rs
Normal file
@@ -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<Vec<u8>> {
|
||||
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<bool> {
|
||||
let visibility: Option<String> = 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<u8>>(
|
||||
"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<u8>,
|
||||
received_at: DateTime<Utc>,
|
||||
root: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// 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<Option<Message>> {
|
||||
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<Utc> = row.try_get("received_at")?;
|
||||
let id: Vec<u8> = row.try_get("id")?;
|
||||
let root: Option<Vec<u8>> = row.try_get("root_event_id")?;
|
||||
let tags: serde_json::Value = row.try_get("tags")?;
|
||||
let tags: Vec<Vec<String>> = 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<Option<DateTime<Utc>>> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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<Utc>,
|
||||
) -> 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<IntentOutcome> {
|
||||
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<IntentOutcome> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<Arc<crate::state::AppState>> {
|
||||
pub(crate) async fn bridge_handler_test_state() -> Option<Arc<crate::state::AppState>> {
|
||||
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!(
|
||||
|
||||
177
crates/buzz-relay/src/api/buzz_v1/auth.rs
Normal file
177
crates/buzz-relay/src/api/buzz_v1/auth.rs
Normal file
@@ -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<Response>),
|
||||
}
|
||||
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<Value>)) -> 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<u64>,
|
||||
}
|
||||
|
||||
pub(super) async fn authorize(
|
||||
state: &Arc<AppState>,
|
||||
headers: &HeaderMap,
|
||||
uri: &Uri,
|
||||
method: &'static str,
|
||||
body: Option<&[u8]>,
|
||||
) -> Result<Principal, Error> {
|
||||
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<Response, Error> {
|
||||
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())
|
||||
}
|
||||
226
crates/buzz-relay/src/api/buzz_v1/handlers.rs
Normal file
226
crates/buzz-relay/src/api/buzz_v1/handlers.rs
Normal file
@@ -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<Vec<Uuid>> {
|
||||
let ids = value
|
||||
.split(',')
|
||||
.map(|id| Uuid::parse_str(id).ok())
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
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<usize>,
|
||||
cursor: Option<Uuid>,
|
||||
/// Comma-separated channel UUIDs to refresh; exclusive with paging.
|
||||
channel_ids: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) async fn sidebar(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
query: Result<Query<SidebarQuery>, axum::extract::rejection::QueryRejection>,
|
||||
) -> Result<Response, Error> {
|
||||
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<Value>,
|
||||
}
|
||||
|
||||
pub(super) async fn write(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
body: Bytes,
|
||||
) -> Result<Response, Error> {
|
||||
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::<ReadIntent>(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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
query: Result<Query<ContextQuery>, axum::extract::rejection::QueryRejection>,
|
||||
) -> Result<Response, Error> {
|
||||
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<Target> =
|
||||
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::<usize>() > 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}),
|
||||
}
|
||||
}
|
||||
26
crates/buzz-relay/src/api/buzz_v1/mod.rs
Normal file
26
crates/buzz-relay/src/api/buzz_v1/mod.rs
Normal file
@@ -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<AppState>) -> 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;
|
||||
662
crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs
Normal file
662
crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs
Normal file
@@ -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<crate::state::AppState>,
|
||||
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::<Vec<_>>()
|
||||
.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::<String>()
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod admin;
|
||||
pub mod bridge;
|
||||
pub mod buzz_v1;
|
||||
pub mod events;
|
||||
pub mod gifs;
|
||||
pub mod git;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<serde_json::Value>,
|
||||
/// Opt-in, host-bound private accessory API; separate from legacy NIP-RS.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub buzz_v1: Option<BuzzV1Descriptor>,
|
||||
/// 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<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
|
||||
@@ -306,6 +306,13 @@ pub fn build_router(state: Arc<AppState>) -> 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<AppState>) -> 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
|
||||
|
||||
21
docs/buzz-v1-extension-design.md
Normal file
21
docs/buzz-v1-extension-design.md
Normal file
@@ -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.
|
||||
311
docs/buzz-v1-read-state.md
Normal file
311
docs/buzz-v1-read-state.md
Normal file
@@ -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=<exclusive-channel-uuid>` 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=<uuid>,<uuid>` 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=<URL-encoded-JSON-array>` 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":"<uuid>","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":"<uuid>"},"message_id":"<64-hex-event>"},
|
||||
{"type":"mark_through","target":{"channel_id":"<uuid>","root_id":"<64-hex-root>"},"message_id":"<64-hex-event>"},
|
||||
{"type":"mark_channel_read","channel_id":"<uuid>","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.
|
||||
33
migrations/0056_personal_read_state.sql
Normal file
33
migrations/0056_personal_read_state.sql
Normal file
@@ -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');
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user