feat(relay): private read-state accessory API (#7906)

Buzz BFF: Private read-state accessory API (`/buzz/v1`)
This commit is contained in:
Tyler
2026-10-06 16:12:03 -04:00
committed by GitHub
parent 46dcc01873
commit 1a2ddf9eaa
32 changed files with 6416 additions and 80 deletions

1
Cargo.lock generated
View File

@@ -1466,6 +1466,7 @@ dependencies = [
"rand 0.10.1",
"redis",
"reqwest 0.13.4",
"ring",
"rust-s3",
"rustls",
"serde",

View File

@@ -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;

View File

@@ -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,

View File

@@ -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");
}

View File

@@ -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",

View File

@@ -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.

View 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}"
);
}
}

View 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
}
}

View 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:?}");
}
}
}

View 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;

View 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>,
}

View 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]
);
}
}

View File

@@ -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"));
}

File diff suppressed because it is too large Load Diff

View 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);
}
}

View File

@@ -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}"
);
}
}
}

View 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());
}
}

View 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)
}
}

View File

@@ -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

View File

@@ -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!(

View 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())
}

View 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}),
}
}

View 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;

View 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;
}

View File

@@ -2,6 +2,7 @@
pub mod admin;
pub mod bridge;
pub mod buzz_v1;
pub mod events;
pub mod gifs;
pub mod git;

View File

@@ -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();

View File

@@ -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(),

View File

@@ -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

View 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
View 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.

View 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');

View File

@@ -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');