Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,18 @@ jobs:
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: DM resurface causal fence
# Verifies unhide_dm_recipients only clears hides causally older than the
# triggering message: an older hide resurfaces, a newer re-hide from
# another device survives a delayed resurface, and the sender's own hide
# is never rewritten. See buzz-db dm::tests.
run: |
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-db) and test(/dm::tests::unhide_recipients_/)' \
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: Upload relay log
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Expand Down
46 changes: 46 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,27 @@ pub const fn is_workflow_execution_kind(kind: u32) -> bool {
kind >= KIND_WORKFLOW_TRIGGERED && kind <= KIND_WORKFLOW_APPROVAL_DENIED
}

/// Returns `true` for channel events that clients render as new message content.
///
/// Reactions, edits, deletions, and system events are deliberately excluded:
/// they must not resurface a direct message without a new human-visible message.
///
/// `KIND_HUDDLE_STARTED` is included: clients render the huddle-start card as
/// visible timeline content and desktop treats it as a notifiable DM invitation,
/// so a hidden DM must resurface to deliver the (time-sensitive) invite. The
/// other huddle lifecycle kinds (joined/left/ended/reaction) are not message
/// content and stay excluded.
pub const fn is_human_visible_message_kind(kind: u32) -> bool {
matches!(
kind,
KIND_STREAM_MESSAGE
| KIND_STREAM_MESSAGE_V2
| KIND_FORUM_POST
| KIND_FORUM_COMMENT
| KIND_HUDDLE_STARTED
)
}

/// Returns `true` if `kind` is a NIP-43 relay membership admin command (9030–9032)
/// or the Buzz workspace-profile admin command (9033).
pub const fn is_relay_admin_kind(kind: u32) -> bool {
Expand Down Expand Up @@ -933,6 +954,31 @@ mod tests {
}
}

#[test]
fn human_visible_message_kind_matches_client_message_sets() {
for kind in [
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_FORUM_POST,
KIND_FORUM_COMMENT,
KIND_HUDDLE_STARTED,
] {
assert!(is_human_visible_message_kind(kind), "kind {kind}");
}

for kind in [
KIND_REACTION,
KIND_STREAM_MESSAGE_EDIT,
KIND_DELETION,
KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT,
KIND_HUDDLE_ENDED,
KIND_HUDDLE_REACTION,
] {
assert!(!is_human_visible_message_kind(kind), "kind {kind}");
}
}

// ── event_is_shared / is_unshared_gated_event ────────────────────────

fn make_event_of_kind(kind: u32, tags: &[&[&str]]) -> nostr::Event {
Expand Down
229 changes: 229 additions & 0 deletions crates/buzz-db/src/dm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,57 @@ pub async fn unhide_dm(
Ok(())
}

/// Clear the hidden state for every active recipient of a DM message.
///
/// The sender is deliberately excluded so sending from another surface does
/// not rewrite their sidebar preference. Returns only viewers whose hidden
/// state changed, allowing the relay to publish targeted visibility snapshots.
///
/// `message_received_at` is the relay-assigned receive time of the triggering
/// message (`events.received_at`). Only hides that are causally *older* than
/// the message are cleared: a recipient who re-hides the DM on another device
/// after the message was accepted keeps their newer choice. Both `hidden_at`
/// and `received_at` are set from the server clock (`NOW()`), so the comparison
/// is against a single monotonic authority. Because the fence is by receive
/// time (not identity), the update is idempotent — replaying the same message
/// clears the same set and never a newer hide.
pub async fn unhide_dm_recipients(
pool: &PgPool,
community_id: CommunityId,
channel_id: Uuid,
sender_pubkey: &[u8],
message_received_at: DateTime<Utc>,
) -> Result<Vec<Vec<u8>>> {
let rows = sqlx::query(
r#"
UPDATE channel_members cm
SET hidden_at = NULL
FROM channels c
WHERE cm.community_id = $1
AND cm.channel_id = $2
AND cm.pubkey != $3
AND cm.removed_at IS NULL
AND cm.hidden_at IS NOT NULL
AND cm.hidden_at < $4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use one clock for the resurface fence

When PostgreSQL and the relay process have any clock skew—or a hide commits after the application timestamp is captured but before the message insert commits—this comparison does not preserve causal order. hidden_at is assigned by PostgreSQL NOW(), while events.received_at is assigned with Rust's Utc::now() in event.rs; therefore a DB clock ahead can leave a DM hidden after a later message, and a relay clock ahead can let replay clear a genuinely newer re-hide. Generate both timestamps from the database or use an ordering value established transactionally.

Useful? React with 👍 / 👎.

AND c.community_id = cm.community_id
AND c.id = cm.channel_id
AND c.channel_type = 'dm'
AND c.deleted_at IS NULL
RETURNING cm.pubkey
"#,
)
.bind(community_id.as_uuid())
.bind(channel_id)
.bind(sender_pubkey)
.bind(message_received_at)
.fetch_all(pool)
.await?;

rows.into_iter()
.map(|row| row.try_get::<Vec<u8>, _>("pubkey").map_err(Into::into))
.collect()
}

/// Return the channel IDs of all DMs the given user currently has hidden
/// (`hidden_at IS NOT NULL`) while still being an active member. Used to build
/// the relay-signed NIP-DV visibility snapshot.
Expand Down Expand Up @@ -554,4 +605,182 @@ mod tests {
let h = compute_participant_hash(&[&a, &b]);
assert_eq!(h.len(), 32);
}

// -- Postgres-backed fence tests ------------------------------------------
//
// `unhide_dm_recipients` must only clear hides that are causally older than
// the triggering message (Jude review #1). These verify the receive-time
// fence directly against real timestamps.

const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";

/// Postgres `timestamptz` stores microsecond precision, so a raw
/// `Utc::now()` (nanoseconds) will not round-trip equal. Truncate to
/// microseconds up front so equality assertions compare like for like
/// while the seconds-apart fence arithmetic stays intact.
fn now_micros() -> DateTime<Utc> {
use chrono::SubsecRound;
Utc::now().trunc_subsecs(6)
}

async fn setup_pool() -> PgPool {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());

PgPool::connect(&database_url)
.await
.expect("connect to test DB")
}

async fn make_test_community(pool: &PgPool) -> CommunityId {
let id = Uuid::new_v4();
let host = format!("dm-test-{}.example", id.simple());
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(id)
.bind(host)
.execute(pool)
.await
.expect("insert test community");
CommunityId::from_uuid(id)
}

/// Force a recipient's `hidden_at` to an explicit timestamp so the fence can
/// be exercised deterministically without racing the wall clock.
async fn set_hidden_at(
pool: &PgPool,
community_id: CommunityId,
channel_id: Uuid,
pubkey: &[u8],
hidden_at: DateTime<Utc>,
) {
sqlx::query(
"UPDATE channel_members SET hidden_at = $4 \
WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3",
)
.bind(community_id.as_uuid())
.bind(channel_id)
.bind(pubkey)
.bind(hidden_at)
.execute(pool)
.await
.expect("set hidden_at");
}

async fn current_hidden_at(
pool: &PgPool,
community_id: CommunityId,
channel_id: Uuid,
pubkey: &[u8],
) -> Option<DateTime<Utc>> {
sqlx::query(
"SELECT hidden_at FROM channel_members \
WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3",
)
.bind(community_id.as_uuid())
.bind(channel_id)
.bind(pubkey)
.fetch_one(pool)
.await
.expect("read hidden_at")
.try_get::<Option<DateTime<Utc>>, _>("hidden_at")
.expect("hidden_at column")
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn unhide_recipients_clears_hides_older_than_the_message() {
let pool = setup_pool().await;
let community_id = make_test_community(&pool).await;
let sender = [1u8; 32];
let recipient = [2u8; 32];
let dm = create_dm(&pool, community_id, &[&sender, &recipient], &sender)
.await
.expect("create dm");

// Recipient hid the DM before the message was received.
let hidden_at = now_micros();
set_hidden_at(&pool, community_id, dm.id, &recipient, hidden_at).await;
let message_received_at = hidden_at + chrono::Duration::seconds(1);

let cleared =
unhide_dm_recipients(&pool, community_id, dm.id, &sender, message_received_at)
.await
.expect("unhide");

assert_eq!(
cleared,
vec![recipient.to_vec()],
"recipient must resurface"
);
assert!(
current_hidden_at(&pool, community_id, dm.id, &recipient)
.await
.is_none(),
"older hide must be cleared"
);
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn unhide_recipients_preserves_a_hide_newer_than_the_message() {
let pool = setup_pool().await;
let community_id = make_test_community(&pool).await;
let sender = [3u8; 32];
let recipient = [4u8; 32];
let dm = create_dm(&pool, community_id, &[&sender, &recipient], &sender)
.await
.expect("create dm");

// Recipient re-hid the DM on another device AFTER the message arrived
// (e.g. a delayed replay of that message races the newer user action).
let message_received_at = now_micros();
let hidden_at = message_received_at + chrono::Duration::seconds(1);
set_hidden_at(&pool, community_id, dm.id, &recipient, hidden_at).await;

let cleared =
unhide_dm_recipients(&pool, community_id, dm.id, &sender, message_received_at)
.await
.expect("unhide");

assert!(
cleared.is_empty(),
"a hide newer than the message must not be reported as changed"
);
assert_eq!(
current_hidden_at(&pool, community_id, dm.id, &recipient).await,
Some(hidden_at),
"the newer hide must survive the delayed resurface"
);
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn unhide_recipients_never_touches_the_sender() {
let pool = setup_pool().await;
let community_id = make_test_community(&pool).await;
let sender = [5u8; 32];
let recipient = [6u8; 32];
let dm = create_dm(&pool, community_id, &[&sender, &recipient], &sender)
.await
.expect("create dm");

// Both participants have an old hide; only the recipient may be cleared.
let hidden_at = now_micros();
set_hidden_at(&pool, community_id, dm.id, &sender, hidden_at).await;
set_hidden_at(&pool, community_id, dm.id, &recipient, hidden_at).await;
let message_received_at = hidden_at + chrono::Duration::seconds(1);

let cleared =
unhide_dm_recipients(&pool, community_id, dm.id, &sender, message_received_at)
.await
.expect("unhide");

assert_eq!(cleared, vec![recipient.to_vec()]);
assert_eq!(
current_hidden_at(&pool, community_id, dm.id, &sender).await,
Some(hidden_at),
"the sender's own hide must never be rewritten from another surface"
);
}
}
Loading
Loading