Merge branch 'main' into new-dm-avatar-flicker

This commit is contained in:
Tom Brow
2026-11-08 13:47:57 -08:00
committed by GitHub
29 changed files with 2431 additions and 66 deletions

1
Cargo.lock generated
View File

@@ -1443,6 +1443,7 @@ dependencies = [
"base64 0.22.1",
"buzz-audit",
"buzz-auth",
"buzz-cli",
"buzz-conformance",
"buzz-core",
"buzz-datastore-tracing",

View File

@@ -680,9 +680,9 @@ test-integration:
./scripts/run-tests.sh integration
# Enforce-mode upload/publication/read journey (requires PostgreSQL and Redis).
# Uses the isolated PostgreSQL runner, including emoji entry replacement checks.
# Uses the isolated PostgreSQL runner, including CLI edits and the target index.
test-media-access:
./scripts/postgres-test-run.sh -p buzz-relay --lib -E 'test(=api::media_access_postgres_tests::private_media_revocation_http_journey)'
./scripts/postgres-test-run.sh -p buzz-relay -p buzz-db --lib -E 'test(/^api::media_access_postgres_tests::/) | test(effective_edit_uses_target_index) | test(historical_edit_preflight)'
# Regenerate the model-capability normative corpus from the production Rust
# resolver. The corpus is a golden snapshot, never hand-edited: this runs the

View File

@@ -235,6 +235,9 @@ buzz messages search --query "CLI test" --limit 5 | jq .
# messages edit
buzz messages edit --event "$EVENT_ID" --content "Edited by CLI test" | jq .
# Explicit removal stays removed in a subsequent text edit.
buzz messages edit --event "$EVENT_ID" --content "Remove attached files" --clear-attachments | jq .
buzz messages edit --event "$EVENT_ID" --content "Still no attachments" | jq .
# messages delete
buzz messages delete --event "$REPLY_ID" | jq .

View File

@@ -1,3 +1,5 @@
mod edit;
use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection};
use nostr::PublicKey;
use uuid::Uuid;
@@ -895,29 +897,6 @@ pub async fn cmd_delete_message(
Ok(())
}
/// Edit a message you previously sent.
pub async fn cmd_edit_message(
client: &BuzzClient,
event_id: &str,
content: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
validate_content_size(content)?;
// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;
let builder = buzz_sdk::build_edit(channel_uuid, target_eid, content)
.map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?;
let event = client.sign_event(builder).await?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Vote on a forum post or comment.
pub async fn cmd_vote_on_post(
client: &BuzzClient,
@@ -1014,7 +993,11 @@ pub async fn dispatch(
)
.await
}
MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await,
MessagesCmd::Edit {
event,
content,
clear_attachments,
} => edit::edit_message(client, &event, &content, clear_attachments).await,
MessagesCmd::Delete {
event,
action_id,

View File

@@ -0,0 +1,132 @@
//! Resolve replacement attachment state before publishing an edit.
use super::{channel_id_from_event, fetch_event};
use crate::{
client::BuzzClient,
error::CliError,
validate::{parse_event_id, validate_content_size, validate_hex64},
};
use nostr::{Event, Timestamp};
pub(super) async fn edit_message(
client: &BuzzClient,
event_id: &str,
content: &str,
clear_attachments: bool,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
validate_content_size(content)?;
let target = parse_event_id(event_id)?;
let raw = fetch_event(client, &target.to_hex()).await?;
let channel = channel_id_from_event(event_id, &raw)?;
let original: Event = serde_json::from_value(raw)
.map_err(|e| CliError::Other(format!("invalid edit target: {e}")))?;
if original.id != target {
return Err(CliError::Other(
"relay returned the wrong edit target".into(),
));
}
// Keep this boundary aligned with media_reference_query and the relay lookup.
// Other channel-tagged events retain the historical plain-edit behavior.
if !buzz_core::media::EDIT_GOVERNED_KINDS.contains(&original.kind.as_u16()) {
if clear_attachments {
return Err(CliError::Usage(format!(
"attachments on kind {} are not edit-governed",
original.kind.as_u16()
)));
}
let builder = buzz_sdk::build_edit(channel, target, content)
.map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?;
let event = client.sign_event(builder).await?;
let resp = client.submit_event(event).await?;
println!("{}", crate::client::normalize_write_response(&resp));
return Ok(());
}
let raw = client
.query(&serde_json::json!({
"kinds": buzz_core::media::EDIT_GOVERNED_KINDS, "ids": [target.to_hex()], "#h": [channel.to_string()],
"effective_edit": 1, "consistency": "strong",
}))
.await?;
let snapshot: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("invalid effective edit response: {e}")))?;
if snapshot.get("effective_edit") != Some(&serde_json::json!(1))
|| snapshot.get("edit").is_none()
{
return Err(CliError::Other(
"relay does not support effective_edit v1; upgrade the relay before editing".into(),
));
}
let original: Event = serde_json::from_value(snapshot["original"].clone())
.map_err(|e| CliError::Other(format!("invalid effective original: {e}")))?;
if original.id != target
|| !buzz_core::media::EDIT_GOVERNED_KINDS.contains(&original.kind.as_u16())
|| channel_id_from_event(event_id, &snapshot["original"])? != channel
{
return Err(CliError::Other(
"effective edit response has the wrong original".into(),
));
}
let edits: Vec<Event> = if snapshot["edit"].is_null() {
vec![]
} else {
vec![serde_json::from_value(snapshot["edit"].clone())
.map_err(|e| CliError::Other(format!("invalid effective edit: {e}")))?]
};
let effective = latest_edit(&original, &edits);
if !edits.is_empty() && effective.id == original.id {
return Err(CliError::Other(
"effective edit response has the wrong replacement".into(),
));
}
let media = if clear_attachments {
Vec::new()
} else {
effective
.tags
.iter()
.filter(|t| t.as_slice().first().map(String::as_str) == Some("imeta"))
.map(|t| t.as_slice().to_vec())
.collect()
};
let now = Timestamp::now().as_secs();
// Make successive edits effective even when issued in the same second.
// Refuse an unexpectedly future timeline instead of extending it forever.
let head = effective.created_at.as_secs();
if head > now.saturating_add(60) {
return Err(CliError::Other(
"latest edit is too far in the future; retry when the clock catches up".into(),
));
}
let created_at = now.max(head + 1);
let builder = buzz_sdk::build_edit_with_media(channel, target, content, &media)
.map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?
.custom_created_at(Timestamp::from(created_at));
let event = client.sign_event(builder).await?;
let resp = client.submit_event(event).await?;
println!("{}", crate::client::normalize_write_response(&resp));
Ok(())
}
fn latest_edit<'a>(original: &'a Event, edits: &'a [Event]) -> &'a Event {
let channel = |event: &Event| {
event
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some("h"))
.and_then(|t| t.content())
.and_then(|id| uuid::Uuid::parse_str(id).ok())
};
edits
.iter()
.filter(|edit| {
edit.kind.as_u16() == 40003
&& channel(edit) == channel(original)
&& buzz_core::media::first_edit_target(edit).as_deref()
== Some(original.id.as_bytes().as_slice())
})
.max_by_key(|edit| (edit.created_at, edit.id))
.unwrap_or(original)
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,309 @@
use super::*;
use axum::{extract::State, routing::post, Json, Router};
use nostr::{EventBuilder, Keys, Kind, Tag};
use std::sync::{Arc, Mutex};
use uuid::Uuid;
fn message(keys: &Keys, channel: Uuid, media: &[Vec<String>]) -> Event {
EventBuilder::new(Kind::Custom(9), "original")
.tags(
std::iter::once(Tag::parse(["h", &channel.to_string()]).unwrap())
.chain(media.iter().map(|t| Tag::parse(t.clone()).unwrap())),
)
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 20))
.sign_with_keys(keys)
.unwrap()
}
fn edit(keys: &Keys, original: &Event, at: u64, extra: &[Vec<String>]) -> Event {
let channel = original
.tags
.iter()
.find(|t| t.as_slice()[0] == "h")
.unwrap()
.content()
.unwrap();
EventBuilder::new(Kind::Custom(40003), "edit")
.tags(
std::iter::once(Tag::parse(["h", channel]).unwrap())
.chain(extra.iter().map(|t| Tag::parse(t.clone()).unwrap())),
)
.custom_created_at(Timestamp::from(at))
.sign_with_keys(keys)
.unwrap()
}
fn imeta(name: &str) -> Vec<String> {
vec![
"imeta".into(),
format!("url https://example.com/{name}"),
"m image/png".into(),
"alt original label".into(),
"blurhash complete metadata".into(),
]
}
#[test]
fn latest_uses_server_order_target_and_channel() {
let keys = Keys::generate();
let original = message(&keys, Uuid::new_v4(), &[imeta("original")]);
let target = vec!["e".into(), original.id.to_hex()];
let now = Timestamp::now().as_secs();
let older = edit(&keys, &original, now - 1, &[target.clone(), imeta("older")]);
let cleared = edit(&keys, &original, now, std::slice::from_ref(&target));
let tied = edit(&keys, &original, now, &[target.clone(), imeta("tie")]);
let winner = if cleared.id > tied.id {
&cleared
} else {
&tied
};
assert_eq!(
latest_edit(&original, &[older.clone(), cleared.clone(), tied.clone()]).id,
winner.id
);
// First valid e wins; a later matching tag must not select this edit.
let decoy = edit(
&keys,
&original,
now + 1,
&[
vec!["e".into(), "f".repeat(64)],
target.clone(),
imeta("decoy"),
],
);
let wrong_channel_original = message(&keys, Uuid::new_v4(), &[]);
let wrong_channel = edit(
&keys,
&wrong_channel_original,
now + 2,
std::slice::from_ref(&target),
);
assert_eq!(
latest_edit(&original, &[older, cleared.clone(), decoy, wrong_channel]).id,
cleared.id
);
let mixed = original
.id
.to_hex()
.chars()
.enumerate()
.map(|(i, c)| {
if i % 2 == 0 {
c.to_ascii_uppercase()
} else {
c
}
})
.collect::<String>();
let valid_after_invalid = edit(
&keys,
&original,
now + 3,
&[vec!["e".into(), "invalid".into()], vec!["e".into(), mixed]],
);
assert_eq!(
latest_edit(&original, std::slice::from_ref(&valid_after_invalid)).id,
valid_after_invalid.id
);
assert_eq!(latest_edit(&original, &[]).id, original.id);
}
#[derive(Clone)]
struct Relay {
events: Arc<Mutex<Vec<Event>>>,
submitted: Arc<Mutex<Vec<Event>>>,
fail_history: bool,
effective_queries: Arc<std::sync::atomic::AtomicUsize>,
ignore_extension: Arc<std::sync::atomic::AtomicBool>,
}
async fn relay(events: Vec<Event>, fail_history: bool) -> (String, Relay) {
let state = Relay {
events: Arc::new(Mutex::new(events)),
submitted: Default::default(),
fail_history,
effective_queries: Default::default(),
ignore_extension: Default::default(),
};
let app = Router::new()
.route("/query", post(|State(state): State<Relay>, Json(filters): Json<Vec<serde_json::Value>>| async move {
let filter = &filters[0];
if filter.get("effective_edit").is_some() {
state.effective_queries.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
if state.fail_history && filter.get("kinds").is_some() {
return (axum::http::StatusCode::FORBIDDEN, Json(serde_json::json!({"error":"denied"})));
}
if filter.get("effective_edit").is_some() && !state.ignore_extension.load(std::sync::atomic::Ordering::SeqCst) {
assert_eq!(filter["consistency"], "strong");
assert_eq!(filter, &serde_json::json!({
"kinds":[9,40002,40008,45001,45003], "ids":filter["ids"],
"#h":filter["#h"], "effective_edit":1, "consistency":"strong"
}));
let events = state.events.lock().unwrap();
let original = events.iter().find(|event| serde_json::json!(event.id.to_hex()) == filter["ids"][0]).unwrap();
let latest = events.iter().filter(|event| event.kind.as_u16() == 40003 && buzz_core::media::first_edit_target(event).as_deref() == Some(original.id.as_bytes().as_slice()))
.max_by_key(|event| (event.created_at,event.id));
return (axum::http::StatusCode::OK, Json(serde_json::json!({"effective_edit":1,"original":original,"edit":latest})));
}
let events: Vec<Event> = state.events.lock().unwrap().iter().filter(|event| {
filter["ids"].as_array().is_none_or(|ids| ids.contains(&serde_json::json!(event.id.to_hex())))
&& filter["kinds"].as_array().is_none_or(|kinds| kinds.contains(&serde_json::json!(event.kind.as_u16())))
}).cloned().collect();
(axum::http::StatusCode::OK, Json(serde_json::json!(events)))
}))
.route("/events", post(|State(state): State<Relay>, Json(event): Json<Event>| async move {
event.verify().unwrap();
state.events.lock().unwrap().push(event.clone());
state.submitted.lock().unwrap().push(event.clone());
Json(serde_json::json!({"event_id":event.id.to_hex(), "accepted":true}))
})).with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(url, state)
}
fn client(url: String, keys: Keys) -> BuzzClient {
BuzzClient::new(url, crate::credentials::Credentials::from_keys(keys)).unwrap()
}
#[tokio::test]
async fn command_retains_latest_full_set_then_keeps_clear_across_same_second_edits() {
let keys = Keys::generate();
let channel = Uuid::new_v4();
let original = message(&keys, channel, &[imeta("removed"), imeta("kept")]);
let replacement = edit(
&keys,
&original,
Timestamp::now().as_secs(),
&[vec!["e".into(), original.id.to_hex()], imeta("kept")],
);
let (url, state) = relay(vec![original.clone(), replacement.clone()], false).await;
let client = client(url, keys);
for index in 0..4 {
edit_message(
&client,
&original.id.to_hex(),
&format!("edit {index}"),
index == 2,
)
.await
.unwrap();
}
let submitted = state.submitted.lock().unwrap();
let mut previous = replacement.created_at;
for (index, event) in submitted.iter().enumerate() {
let tags: Vec<_> = event
.tags
.iter()
.filter(|t| t.as_slice()[0] == "imeta")
.map(|t| t.as_slice().to_vec())
.collect();
assert_eq!(
tags,
if index < 2 {
vec![imeta("kept")]
} else {
vec![]
}
);
assert!(event.created_at > previous);
previous = event.created_at;
}
}
#[tokio::test]
async fn command_aborts_on_unreadable_history_or_future_head() {
for fail_history in [true, false] {
let keys = Keys::generate();
let original = message(&keys, Uuid::new_v4(), &[imeta("kept")]);
let poisoned = edit(
&keys,
&original,
Timestamp::now().as_secs() + 1000,
&[vec!["e".into(), original.id.to_hex()]],
);
let (url, state) = relay(vec![original.clone(), poisoned], fail_history).await;
let client = client(url, keys);
assert!(edit_message(&client, &original.id.to_hex(), "new", false)
.await
.is_err());
assert!(state.submitted.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn command_refuses_an_older_relay_that_ignores_the_extension() {
for kind in [9, 40002, 40008, 45001, 45003] {
let keys = Keys::generate();
let channel = Uuid::new_v4();
let original = EventBuilder::new(Kind::Custom(kind), "original")
.tags([
Tag::parse(["h", &channel.to_string()]).unwrap(),
Tag::parse(imeta("kept")).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let (url, state) = relay(vec![original.clone()], false).await;
state
.ignore_extension
.store(true, std::sync::atomic::Ordering::SeqCst);
let client = client(url, keys);
let error = edit_message(&client, &original.id.to_hex(), "new", false)
.await
.unwrap_err();
assert!(
error.to_string().contains("upgrade the relay"),
"kind {kind}"
);
assert!(state.submitted.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn other_channel_kinds_keep_plain_edits_on_older_relays() {
for kind in [40003, 7, 45002, 12345] {
let keys = Keys::generate();
let channel = Uuid::new_v4();
let original = EventBuilder::new(Kind::Custom(kind), "original")
.tags([
Tag::parse(["h", &channel.to_string()]).unwrap(),
Tag::parse(imeta("original")).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let (url, state) = relay(vec![original.clone()], false).await;
state
.ignore_extension
.store(true, std::sync::atomic::Ordering::SeqCst);
let client = client(url, keys);
for content in ["first edit", "second edit"] {
edit_message(&client, &original.id.to_hex(), content, false)
.await
.unwrap();
}
let error = edit_message(&client, &original.id.to_hex(), "clear", true)
.await
.unwrap_err();
assert!(error
.to_string()
.contains(&format!("attachments on kind {kind} are not edit-governed")));
assert_eq!(
state
.effective_queries
.load(std::sync::atomic::Ordering::SeqCst),
0
);
let submitted = state.submitted.lock().unwrap();
assert_eq!(submitted.len(), 2, "clear must not publish");
for (event, content) in submitted.iter().zip(["first edit", "second edit"]) {
assert_eq!(event.kind.as_u16(), 40003);
assert_eq!(event.content, content);
assert_eq!(
buzz_core::media::first_edit_target(event).as_deref(),
Some(original.id.as_bytes().as_slice())
);
assert!(!event.tags.iter().any(|tag| tag.as_slice()[0] == "imeta"));
}
}
}

View File

@@ -674,7 +674,7 @@ pub enum MessagesCmd {
#[arg(long)]
reply_to: Option<String>,
},
/// Edit a previously sent message
/// Edit a previously sent message, retaining its current attachments
Edit {
/// Event ID of the message to edit (64-char hex)
#[arg(long)]
@@ -682,6 +682,9 @@ pub enum MessagesCmd {
/// New message content
#[arg(long)]
content: String,
/// Remove all attachments on kinds 9/40002/40008/45001/45003 (later edits keep them removed)
#[arg(long)]
clear_attachments: bool,
},
/// Delete a message by event ID
Delete {

View File

@@ -2,6 +2,10 @@
use nostr::Event;
use std::collections::BTreeSet;
/// Original event kinds whose latest live edit replaces attachment references.
/// Shared by clients, the effective-edit lookup and media-read authorization.
pub const EDIT_GOVERNED_KINDS: [u16; 5] = [9, 40002, 40008, 45001, 45003];
/// Parse a local media URL, returning its parent hash (also for thumbnails).
/// Only the tenant's canonical base and root-relative paths count.
pub fn local_hash(value: &str, base: &str) -> Option<String> {

View File

@@ -15,10 +15,22 @@ pub(crate) const MEDIA_EDIT_TARGET_SQL: &str = r#"decode(lower(jsonb_path_query_
pub(super) const MEDIA_LOOKUP_SETTINGS_SQL: &str =
"SET LOCAL statement_timeout = '2s'; SET LOCAL jit = off";
fn edit_governed_kinds_sql() -> String {
buzz_core::media::EDIT_GOVERNED_KINDS
.iter()
.map(u16::to_string)
.collect::<Vec<_>>()
.join(",")
}
fn edit_target(alias: &str) -> String {
MEDIA_EDIT_TARGET_SQL.replacen("(tags,", &format!("({alias}.tags,"), 1)
}
pub(super) fn latest_message_edit_sql() -> String {
format!("SELECT e.* FROM events e WHERE e.community_id=$1 AND e.channel_id=$2 AND e.kind=40003 AND e.deleted_at IS NULL AND {}=$3 ORDER BY e.created_at DESC,e.id DESC LIMIT 1", edit_target("e"))
}
/// Result of a complete live-reference lookup, for bounded denial metrics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaAccess {
@@ -32,7 +44,69 @@ pub enum MediaAccess {
Restricted,
}
/// Original message and its latest live edit in one primary-database snapshot.
#[derive(Debug)]
pub struct MessageEditState {
/// Live original, authorized for the reader's explicitly named channel.
pub original: nostr::Event,
/// Complete replacement, if a live edit exists.
pub edit: Option<nostr::Event>,
}
impl Db {
/// Read effective edit metadata using the normalized-target
/// index. Channel membership, source liveness and edit selection share a
/// repeatable-read primary snapshot; no replica or membership cache is used.
pub async fn message_edit_state(
&self,
community: CommunityId,
channel: uuid::Uuid,
target: &[u8],
reader: &[u8],
) -> Result<Option<MessageEditState>> {
let mut tx = self.pool.begin().await?;
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
.execute(&mut *tx)
.await?;
sqlx::raw_sql(MEDIA_LOOKUP_SETTINGS_SQL)
.execute(&mut *tx)
.await?;
// Mirrors channel_members::{get_accessible_channel_ids,
// get_readable_named_only_channels}.
// The channel is explicitly named by this read, so open unlisted and
// named-only channels follow the normal explicit-channel read policy.
let original_sql = format!("SELECT e.* FROM events e JOIN channels c ON c.community_id=e.community_id AND c.id=e.channel_id WHERE e.community_id=$1 AND e.channel_id=$2 AND e.id=$3 AND e.kind IN ({}) AND e.deleted_at IS NULL AND c.deleted_at IS NULL AND (c.visibility='open' OR EXISTS (SELECT 1 FROM channel_members cm WHERE cm.community_id=c.community_id AND cm.channel_id=c.id AND cm.pubkey=$4 AND cm.removed_at IS NULL))", edit_governed_kinds_sql());
let original = sqlx::query(sqlx::AssertSqlSafe(original_sql))
.bind(community.as_uuid())
.bind(channel)
.bind(target)
.bind(reader)
.fetch_optional(&mut *tx)
.await?;
let Some(original) = original else {
tx.commit().await?;
return Ok(None);
};
let original = super::event::row_to_stored_event(original)?
.ok_or_else(|| DbError::InvalidData("unreadable original message row".into()))?
.event;
let sql = latest_message_edit_sql();
let edit = sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(community.as_uuid())
.bind(channel)
.bind(target)
.fetch_optional(&mut *tx)
.await?
.map(|row| {
super::event::row_to_stored_event(row)?
.map(|stored| stored.event)
.ok_or_else(|| DbError::InvalidData("unreadable latest edit row".into()))
})
.transpose()?;
tx.commit().await?;
Ok(Some(MessageEditState { original, edit }))
}
/// Record possession after the upload pipeline verified the actual bytes.
/// Independent of optional audit and moderation records; admission fences deletion.
#[datastore_span(name = "record_media_upload", system = "postgresql")]
@@ -242,12 +316,13 @@ pub(super) fn media_reference_query(
);
query
.push_bind(pubkey)
.push(
.push(format!(
")))) AND (e.kind <> 40003 OR EXISTS (SELECT 1 FROM events original \
WHERE original.community_id=e.community_id AND original.channel_id=e.channel_id \
AND original.deleted_at IS NULL AND original.kind IN (9,40002) \
AND original.deleted_at IS NULL AND original.kind IN ({}) \
AND ",
)
edit_governed_kinds_sql(),
))
.push(edit_target("e"))
.push(
"=original.id AND NOT EXISTS (SELECT 1 FROM events newer \
@@ -260,11 +335,11 @@ pub(super) fn media_reference_query(
if probe == 3 {
// The renderer retains snapshots unless the original or any live
// edit suppresses them; later body edits never undo suppression.
query.push("AND e.kind IN (9,40002) AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) marker(value) WHERE marker.value='[\"link-preview\",\"none\"]'::jsonb) AND EXISTS (SELECT 1 FROM events edit WHERE edit.community_id=e.community_id AND edit.channel_id=e.channel_id AND edit.kind=40003 AND edit.deleted_at IS NULL AND ")
query.push(format!("AND e.kind IN ({}) AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) marker(value) WHERE marker.value='[\"link-preview\",\"none\"]'::jsonb) AND EXISTS (SELECT 1 FROM events edit WHERE edit.community_id=e.community_id AND edit.channel_id=e.channel_id AND edit.kind=40003 AND edit.deleted_at IS NULL AND ", edit_governed_kinds_sql()))
.push(edit_target("edit")).push("=e.id) AND NOT EXISTS (SELECT 1 FROM events suppressed WHERE suppressed.community_id=e.community_id AND suppressed.channel_id=e.channel_id AND suppressed.kind=40003 AND suppressed.deleted_at IS NULL AND ")
.push(edit_target("suppressed")).push("=e.id AND EXISTS (SELECT 1 FROM jsonb_array_elements(suppressed.tags) marker(value) WHERE marker.value='[\"link-preview\",\"none\"]'::jsonb))");
} else {
query.push("AND (e.kind NOT IN (9,40002) OR NOT EXISTS (SELECT 1 FROM events edit WHERE edit.community_id=e.community_id AND edit.channel_id=e.channel_id AND edit.kind=40003 AND edit.deleted_at IS NULL AND ")
query.push(format!("AND (e.kind NOT IN ({}) OR NOT EXISTS (SELECT 1 FROM events edit WHERE edit.community_id=e.community_id AND edit.channel_id=e.channel_id AND edit.kind=40003 AND edit.deleted_at IS NULL AND ", edit_governed_kinds_sql()))
.push(edit_target("edit")).push("=e.id))");
}
query

View File

@@ -0,0 +1,309 @@
use super::media::latest_message_edit_sql;
use crate::{
channel::{ChannelType, ChannelVisibility},
Db,
};
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
#[tokio::test]
#[ignore = "requires Postgres"]
async fn effective_edit_uses_target_index_and_fresh_channel_authorization() {
let pool = sqlx::PgPool::connect(&crate::test_support::database_url())
.await
.unwrap();
let db = Db::from_pool(pool.clone());
let community = db
.ensure_configured_community("effective-edit.example")
.await
.unwrap()
.id;
let owner = Keys::generate();
let outsider = Keys::generate();
db.ensure_user(community, owner.public_key().as_bytes())
.await
.unwrap();
let channel = db
.create_channel(
community,
"edit",
ChannelType::Stream,
ChannelVisibility::Private,
None,
owner.public_key().as_bytes(),
None,
)
.await
.unwrap()
.id;
let original = EventBuilder::new(Kind::Custom(45001), "original")
.tags([Tag::parse(["h", &channel.to_string()]).unwrap()])
.sign_with_keys(&owner)
.unwrap();
db.insert_event(community, &original, Some(channel))
.await
.unwrap();
let tags = vec![
Tag::parse(["h", &channel.to_string()]).unwrap(),
Tag::parse(["e", &original.id.to_hex().to_uppercase()]).unwrap(),
];
let edit = EventBuilder::new(Kind::Custom(40003), "replacement")
.tags(tags)
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() + 1))
.sign_with_keys(&owner)
.unwrap();
db.insert_event(community, &edit, Some(channel))
.await
.unwrap();
// More unrelated edits than the old channel-wide cap; they must not
// affect lookup cost or prevent editing this message. Synthetic IDs are
// intentional: these rows are unrelated fixtures and must never be read.
sqlx::query("INSERT INTO events (community_id,id,pubkey,created_at,kind,tags,content,sig,channel_id) SELECT $1,decode(lpad(to_hex(n),64,'0'),'hex'),$2,now(),40003,$3,'unrelated',$4,$5 FROM generate_series(1,10001) n")
.bind(community.as_uuid()).bind(owner.public_key().as_bytes().as_slice())
.bind(serde_json::json!([["h",channel.to_string()],["e","f".repeat(64)]]))
.bind(edit.sig.serialize().as_slice()).bind(channel).execute(&pool).await.unwrap();
sqlx::query("ANALYZE events").execute(&pool).await.unwrap();
let snapshot = db
.message_edit_state(
community,
channel,
original.id.as_bytes(),
owner.public_key().as_bytes(),
)
.await
.unwrap()
.unwrap();
assert_eq!(snapshot.original.id, original.id);
assert_eq!(snapshot.edit.unwrap().id, edit.id);
assert!(db
.message_edit_state(
community,
channel,
original.id.as_bytes(),
outsider.public_key().as_bytes()
)
.await
.unwrap()
.is_none());
db.ensure_user(community, outsider.public_key().as_bytes())
.await
.unwrap();
db.add_member(
community,
channel,
outsider.public_key().as_bytes(),
crate::channel::MemberRole::Member,
Some(owner.public_key().as_bytes()),
)
.await
.unwrap();
assert!(db
.message_edit_state(
community,
channel,
original.id.as_bytes(),
outsider.public_key().as_bytes()
)
.await
.unwrap()
.is_some());
db.remove_member(
community,
channel,
outsider.public_key().as_bytes(),
owner.public_key().as_bytes(),
)
.await
.unwrap();
assert!(db
.message_edit_state(
community,
channel,
original.id.as_bytes(),
outsider.public_key().as_bytes()
)
.await
.unwrap()
.is_none());
let sql = format!("EXPLAIN (FORMAT JSON) {}", latest_message_edit_sql());
let plan: serde_json::Value = sqlx::query_scalar(sqlx::AssertSqlSafe(sql))
.bind(community.as_uuid())
.bind(channel)
.bind(original.id.as_bytes().as_slice())
.fetch_one(&pool)
.await
.unwrap();
let plan = plan.to_string();
let child_indexes: Vec<String> = sqlx::query_scalar("SELECT child.relname FROM pg_inherits inheritance JOIN pg_class child ON child.oid=inheritance.inhrelid WHERE inheritance.inhparent='idx_events_media_edit_target'::regclass")
.fetch_all(&pool).await.unwrap();
assert!(
child_indexes.iter().any(|index| plan.contains(index)),
"{plan}"
);
assert!(
plan.contains("decode(lower("),
"target must be an index condition: {plan}"
);
assert!(!plan.contains("Seq Scan"), "{plan}");
db.soft_delete_event_and_update_thread(community, edit.id.as_bytes(), None, None)
.await
.unwrap();
assert!(db
.message_edit_state(
community,
channel,
original.id.as_bytes(),
owner.public_key().as_bytes()
)
.await
.unwrap()
.unwrap()
.edit
.is_none());
db.soft_delete_event_and_update_thread(community, original.id.as_bytes(), None, None)
.await
.unwrap();
assert!(db
.message_edit_state(
community,
channel,
original.id.as_bytes(),
owner.public_key().as_bytes()
)
.await
.unwrap()
.is_none());
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn historical_edit_preflight_matches_selected_live_edit() {
let sql = include_str!("../../../../docs/sql/private-media-edit-preflight.sql");
// The checked-in operator query uses the exact normalized target expression.
assert!(sql.contains(super::media::MEDIA_EDIT_TARGET_SQL));
let pool = sqlx::PgPool::connect(&crate::test_support::database_url())
.await
.unwrap();
let db = Db::from_pool(pool.clone());
let community = db
.ensure_configured_community("preflight.example")
.await
.unwrap()
.id;
let owner = Keys::generate();
db.ensure_user(community, owner.public_key().as_bytes())
.await
.unwrap();
let mut channels = Vec::new();
for name in ["originals", "decoys"] {
channels.push(
db.create_channel(
community,
name,
ChannelType::Stream,
ChannelVisibility::Private,
None,
owner.public_key().as_bytes(),
None,
)
.await
.unwrap()
.id,
);
}
let now = Timestamp::now().as_secs();
for (index, kind, scenario, denied) in [
(1, 45001, "empty", true),
(2, 45003, "later-retained", false),
(3, 40008, "deleted", false),
(4, 45001, "mixed", true),
(5, 45003, "cross-channel", false),
(6, 9, "control", true),
(7, 45001, "deleted-original", true),
] {
let hash = format!("{index:064x}");
let media = [vec![
"imeta".into(),
format!("url https://preflight.example/media/{hash}"),
format!("x {hash}"),
]];
let original = EventBuilder::new(Kind::Custom(kind), scenario)
.tags([
Tag::parse(["h", &channels[0].to_string()]).unwrap(),
Tag::parse(media[0].clone()).unwrap(),
])
.custom_created_at(Timestamp::from(now - 20))
.sign_with_keys(&owner)
.unwrap();
db.insert_event(community, &original, Some(channels[0]))
.await
.unwrap();
let channel = if scenario == "cross-channel" {
channels[1]
} else {
channels[0]
};
let target = if scenario == "mixed" {
original.id.to_hex().to_uppercase()
} else {
original.id.to_hex()
};
let empty = EventBuilder::new(Kind::Custom(40003), "empty")
.tags([
Tag::parse(["h", &channel.to_string()]).unwrap(),
Tag::parse(["e", &target]).unwrap(),
])
.custom_created_at(Timestamp::from(now - 2))
.sign_with_keys(&owner)
.unwrap();
db.insert_event(community, &empty, Some(channel))
.await
.unwrap();
if scenario == "later-retained" {
let retained = EventBuilder::new(Kind::Custom(40003), "retained")
.tags([
Tag::parse(["h", &channels[0].to_string()]).unwrap(),
Tag::parse(["e", &original.id.to_hex()]).unwrap(),
Tag::parse(media[0].clone()).unwrap(),
])
.custom_created_at(Timestamp::from(now - 1))
.sign_with_keys(&owner)
.unwrap();
db.insert_event(community, &retained, Some(channels[0]))
.await
.unwrap();
}
if scenario == "deleted" {
db.soft_delete_event_and_update_thread(community, empty.id.as_bytes(), None, None)
.await
.unwrap();
}
if scenario == "deleted-original" {
db.soft_delete_event_and_update_thread(community, original.id.as_bytes(), None, None)
.await
.unwrap();
}
let access = db
.media_access(
community,
&hash,
"",
"https://preflight.example/media",
owner.public_key().as_bytes(),
false,
|_, _| true,
)
.await
.unwrap();
assert_eq!(
access == super::media::MediaAccess::Allowed,
!denied,
"{scenario}"
);
}
let counts: Vec<(i32, i64)> = sqlx::query_as(sql)
.bind(community.as_uuid())
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(counts, vec![(45001, 2)]);
}

View File

@@ -119,3 +119,5 @@ pub mod media;
#[cfg(test)]
mod media_postgres_tests;
#[cfg(test)]
mod message_edit_state_postgres_tests;

View File

@@ -95,6 +95,7 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] }
dev = ["buzz-auth/dev"]
[dev-dependencies]
buzz-cli = { path = "../buzz-cli" }
# 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

View File

@@ -3,6 +3,7 @@
//! These endpoints provide HTTP access to the relay's Nostr protocol,
//! authenticated via NIP-98 signed events.
mod effective_edit;
mod membership_snapshot;
mod read_state_snapshot;
pub(crate) mod search_slots;
@@ -1788,6 +1789,9 @@ async fn query_member_events(
})?
.map(|events| Json(Value::Array(events)));
}
if effective_edit::requested(&raw_filters) {
return effective_edit::query(state, tenant, &pubkey, &raw_filters).await;
}
if read_state_snapshot::requested(&raw_filters) {
return read_state_snapshot::query(state, tenant, &pubkey, &raw_filters).await;
}

View File

@@ -0,0 +1,88 @@
//! Explicit, versioned effective-edit lookup; generic #e matching stays exact.
use crate::{
api::{api_error, db_read_error},
state::AppState,
};
use axum::{http::StatusCode, Json};
use buzz_core::TenantContext;
use nostr::{EventId, PublicKey};
use serde_json::{json, Value};
use uuid::Uuid;
pub(super) fn requested(filters: &[Value]) -> bool {
filters.iter().any(|f| f.get("effective_edit").is_some())
}
fn request(filters: &[Value]) -> Result<(Uuid, EventId), &'static str> {
let filter = filters.first().ok_or("missing effective_edit filter")?;
let channel = filter["#h"][0]
.as_str()
.and_then(|v| Uuid::parse_str(v).ok())
.ok_or("invalid channel")?;
let target = filter["ids"][0]
.as_str()
.and_then(|v| EventId::from_hex(v).ok())
.ok_or("invalid target")?;
if filters
!= [
json!({"kinds":buzz_core::media::EDIT_GOVERNED_KINDS, "ids":[target.to_hex()], "#h":[channel.to_string()], "effective_edit":1, "consistency":"strong"}),
]
{
return Err("effective_edit requires one exact version-1 original-message filter with strong consistency");
}
Ok((channel, target))
}
pub(super) async fn query(
state: &AppState,
tenant: &TenantContext,
reader: &PublicKey,
filters: &[Value],
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let (channel, target) =
request(filters).map_err(|message| api_error(StatusCode::BAD_REQUEST, message))?;
let snapshot = state
.db
.message_edit_state(
tenant.community(),
channel,
target.as_bytes(),
reader.as_bytes(),
)
.await
.map_err(|error| db_read_error("effective edit lookup", &error))?
.filter(|snapshot| {
crate::handlers::req::event_visible_to_reader(&snapshot.original, reader.as_bytes())
&& snapshot.edit.as_ref().is_none_or(|edit| {
crate::handlers::req::event_visible_to_reader(edit, reader.as_bytes())
})
})
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "message not found"))?;
Ok(Json(
json!({"effective_edit":1, "original":snapshot.original, "edit":snapshot.edit}),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_versioned_shape_is_required() {
let channel = Uuid::new_v4();
let valid = json!({"kinds":[9,40002,40008,45001,45003],"ids":["a".repeat(64)],"#h":[channel.to_string()],"effective_edit":1,"consistency":"strong"});
assert!(request(std::slice::from_ref(&valid)).is_ok());
for (key, value) in [
("effective_edit", json!(2)),
("consistency", json!("eventual")),
("#h", json!([])),
("ids", json!([])),
("kinds", json!([40003])),
("limit", json!(1)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(request(&[invalid]).is_err(), "{key}");
}
assert!(request(&[valid.clone(), valid]).is_err());
}
}

View File

@@ -173,6 +173,7 @@ async fn post(
.await
}
mod cli_edit_postgres_tests;
mod emoji_reference_postgres_tests;
mod reference_budget_postgres_tests;
mod reference_formats_postgres_tests;

View File

@@ -0,0 +1,734 @@
//! Exercise the real CLI, HTTP bridge, ingest, and media reads in enforce mode.
use super::*;
// Keep the large ingest future off the enclosing test thread's stack.
async fn publish(
state: &Arc<AppState>,
tenant: &buzz_core::TenantContext,
keys: &Keys,
event: nostr::Event,
) -> Result<crate::handlers::ingest::IngestResult, crate::handlers::ingest::IngestError> {
Box::pin(post(state, tenant, keys, event)).await
}
async fn lookup(
state: &Arc<AppState>,
host: &str,
keys: &Keys,
channel: Uuid,
target: nostr::EventId,
) -> (StatusCode, serde_json::Value) {
let body = serde_json::to_vec(&serde_json::json!([{"kinds":[9,40002,40008,45001,45003],"ids":[target.to_hex()],"#h":[channel.to_string()],"effective_edit":1,"consistency":"strong"}])).unwrap();
let proof = EventBuilder::new(Kind::Custom(27235), "")
.tags([
Tag::parse(["u", &format!("http://{host}/query")]).unwrap(),
Tag::parse(["method", "POST"]).unwrap(),
Tag::parse(["payload", &hex::encode(Sha256::digest(&body))]).unwrap(),
Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
let request = Request::builder()
.method("POST")
.uri("/query")
.header(header::HOST, host)
.header(header::CONTENT_TYPE, "application/json")
.header(
header::AUTHORIZATION,
format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(proof.as_json())
),
)
.body(Body::from(body))
.unwrap();
let response = crate::router::build_router(state.clone())
.oneshot(request)
.await
.unwrap();
let status = response.status();
(
status,
serde_json::from_slice(&to_bytes(response.into_body(), 1_000_000).await.unwrap()).unwrap(),
)
}
async fn cli_edit(url: &str, keys: &Keys, id: nostr::EventId, clear: bool, content: &str) {
let mut args = vec![
"buzz".to_owned(),
"--relay".into(),
url.into(),
"--private-key".into(),
keys.secret_key().to_secret_hex(),
"messages".into(),
"edit".into(),
"--event".into(),
id.to_hex(),
"--content".into(),
content.into(),
];
if clear {
args.push("--clear-attachments".into());
}
assert_eq!(
Box::pin(buzz_cli::run_from_args(args)).await,
0,
"CLI edit failed"
);
}
async fn readable(
state: &Arc<AppState>,
host: &str,
hashes: &[String],
keys: &[&Keys],
expected: &[StatusCode],
) {
for (hash, status) in hashes.iter().zip(expected) {
for key in keys {
for method in ["GET", "HEAD"] {
let response = router(state.clone())
.oneshot(get(key, host, hash, hash, method, None))
.await
.unwrap();
assert_eq!(response.status(), *status, "{method} {hash}");
assert_eq!(
response.headers()[header::CACHE_CONTROL],
"private, no-store"
);
}
}
}
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn cli_successive_edits_preserve_and_revoke_attachments() {
let bucket = Bucket::default();
let s3_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", s3_listener.local_addr().unwrap());
let service = Router::new()
.route("/{*key}", any(object))
.with_state(bucket);
let s3_server = tokio::spawn(async move {
axum::serve(s3_listener, service).await.unwrap();
});
let state = fixture(
&endpoint,
super::super::media_access::MediaReadMode::Enforce,
)
.await;
let http_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let host_string = http_listener.local_addr().unwrap().to_string();
let host = host_string.as_str();
state.db.ensure_configured_community(host).await.unwrap();
let tenant = crate::tenant::bind_community(&state.db, host)
.await
.unwrap();
let owner = Keys::generate();
let member = Keys::generate();
let outsider = Keys::generate();
for key in [&owner, &member, &outsider] {
state
.db
.ensure_user(tenant.community(), key.public_key().as_bytes())
.await
.unwrap();
}
let channel = state
.db
.create_channel(
tenant.community(),
"cli-edit",
ChannelType::Stream,
ChannelVisibility::Private,
None,
owner.public_key().as_bytes(),
None,
)
.await
.unwrap()
.id;
state
.db
.add_member(
tenant.community(),
channel,
member.public_key().as_bytes(),
MemberRole::Member,
Some(owner.public_key().as_bytes()),
)
.await
.unwrap();
let mut media = Vec::new();
let mut hashes = Vec::new();
for bytes in [
b"cli retained file".as_slice(),
b"cli removed file".as_slice(),
] {
let hash = hex::encode(Sha256::digest(bytes));
let upload = Request::builder()
.method("PUT")
.uri("/upload")
.header(header::HOST, host)
.header(header::AUTHORIZATION, proof(&owner, host, &hash, "upload"))
.header("X-SHA-256", &hash)
.body(Body::from(bytes))
.unwrap();
let response = router(state.clone()).oneshot(upload).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let descriptor: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 100_000).await.unwrap())
.unwrap();
media.push(vec![
"imeta".into(),
format!("url {}", descriptor["url"].as_str().unwrap()),
format!("x {hash}"),
"m application/octet-stream".into(),
format!("size {}", bytes.len()),
"alt original label".into(),
]);
hashes.push(hash);
}
let original = EventBuilder::new(Kind::Custom(9), "two files")
.tags(
std::iter::once(Tag::parse(["h", &channel.to_string()]).unwrap())
.chain(media.iter().map(|t| Tag::parse(t.clone()).unwrap())),
)
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 20))
.sign_with_keys(&owner)
.unwrap();
publish(&state, &tenant, &owner, original.clone())
.await
.unwrap();
let url = format!("http://{host}");
let http_app = crate::router::build_router(state.clone());
let http_server = tokio::spawn(async move {
axum::serve(http_listener, http_app).await.unwrap();
});
for index in 0..2 {
cli_edit(
&url,
&owner,
original.id,
false,
&format!("typo fix {index}"),
)
.await;
readable(
&state,
host,
&hashes,
&[&owner, &member],
&[StatusCode::OK; 2],
)
.await;
readable(
&state,
host,
&hashes,
&[&outsider],
&[StatusCode::NOT_FOUND; 2],
)
.await;
}
// Model a desktop/SDK edit removing just the second file. Keep its URL in
// the body to prove text cannot grant access or revive removed imeta.
let body = format!("retained one, removed URL {}", media[1][1]);
let partial = buzz_sdk::build_edit_with_media(channel, original.id, &body, &media[..1])
.unwrap()
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() + 5))
.sign_with_keys(&owner)
.unwrap();
publish(&state, &tenant, &owner, partial.clone())
.await
.unwrap();
for _ in 0..2 {
cli_edit(&url, &owner, original.id, false, &body).await;
readable(
&state,
host,
&hashes,
&[&owner, &member],
&[StatusCode::OK, StatusCode::NOT_FOUND],
)
.await;
}
// A newer edit that restores both files is then deleted. Resolve the
// latest live edit, not this deleted replacement or the original.
let deleted =
buzz_sdk::build_edit_with_media(channel, original.id, "deleted replacement", &media)
.unwrap()
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() + 20))
.sign_with_keys(&owner)
.unwrap();
// Seed a historical deleted replacement without publishing a new live
// restoration. The uploader still has authority to republish these bytes.
state
.db
.insert_event(tenant.community(), &deleted, Some(channel))
.await
.unwrap();
state
.db
.soft_delete_event_and_update_thread(tenant.community(), deleted.id.as_bytes(), None, None)
.await
.unwrap();
cli_edit(&url, &owner, original.id, false, &body).await;
readable(
&state,
host,
&hashes,
&[&owner, &member],
&[StatusCode::OK, StatusCode::NOT_FOUND],
)
.await;
cli_edit(&url, &owner, original.id, true, &body).await;
cli_edit(&url, &owner, original.id, false, &body).await;
readable(
&state,
host,
&hashes,
&[&owner, &member],
&[StatusCode::NOT_FOUND; 2],
)
.await;
// Mixed-case removal must stay removed from this message even when a
// second live reference would authorize republication of the same bytes.
let second = EventBuilder::new(Kind::Custom(9), "another reference")
.tags(
std::iter::once(Tag::parse(["h", &channel.to_string()]).unwrap())
.chain(media[..1].iter().map(|t| Tag::parse(t.clone()).unwrap())),
)
.sign_with_keys(&owner)
.unwrap();
publish(&state, &tenant, &owner, second.clone())
.await
.unwrap();
let restored = buzz_sdk::build_edit_with_media(channel, original.id, "both", &media)
.unwrap()
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() + 30))
.sign_with_keys(&owner)
.unwrap();
publish(&state, &tenant, &owner, restored).await.unwrap();
let mixed = original
.id
.to_hex()
.chars()
.enumerate()
.map(|(i, c)| {
if i % 2 == 0 {
c.to_ascii_uppercase()
} else {
c
}
})
.collect::<String>();
let removal = EventBuilder::new(Kind::Custom(40003), "only second")
.tags([
Tag::parse(["h", &channel.to_string()]).unwrap(),
Tag::parse(["e", &mixed]).unwrap(),
Tag::parse(media[1].clone()).unwrap(),
])
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() + 31))
.sign_with_keys(&owner)
.unwrap();
publish(&state, &tenant, &owner, removal).await.unwrap();
cli_edit(&url, &owner, original.id, false, "after mixed-case removal").await;
let current = state
.db
.message_edit_state(
tenant.community(),
channel,
original.id.as_bytes(),
owner.public_key().as_bytes(),
)
.await
.unwrap()
.unwrap()
.edit
.unwrap();
let carried: Vec<_> = current
.tags
.iter()
.filter(|t| t.as_slice()[0] == "imeta")
.map(|t| t.as_slice().to_vec())
.collect();
assert_eq!(
carried,
vec![media[1].clone()],
"must not resurrect the first file"
);
readable(
&state,
host,
&hashes,
&[&owner, &member],
&[StatusCode::OK; 2],
)
.await;
state
.db
.soft_delete_event_and_update_thread(tenant.community(), second.id.as_bytes(), None, None)
.await
.unwrap();
readable(
&state,
host,
&hashes,
&[&owner, &member],
&[StatusCode::NOT_FOUND, StatusCode::OK],
)
.await;
// An agent owner outside this private channel has no attachment read or
// publication authority. A direct retaining edit must also be rejected.
state
.db
.set_agent_owner(
tenant.community(),
owner.public_key().as_bytes(),
outsider.public_key().as_bytes(),
)
.await
.unwrap();
let owner_edit =
buzz_sdk::build_edit_with_media(channel, original.id, "owner edit", &media[1..])
.unwrap()
.sign_with_keys(&outsider)
.unwrap();
let Err(error) = publish(&state, &tenant, &outsider, owner_edit).await else {
panic!("owner edit was accepted");
};
assert!(
matches!(error, crate::handlers::ingest::IngestError::Rejected(ref reason)
if reason == "restricted: attachment is not accessible"),
"{error:?}"
);
assert_ne!(
Box::pin(buzz_cli::run_from_args(vec![
"buzz".to_string(),
"--relay".into(),
url.clone(),
"--private-key".into(),
outsider.secret_key().to_secret_hex(),
"messages".into(),
"edit".into(),
"--event".into(),
original.id.to_hex(),
"--content".into(),
"owner edit".into()
]))
.await,
0
);
// Knowing a private hash never lets another member attach it to their own
// message/edit. This exercises the common publication check on kind 40003.
let open = state
.db
.create_channel(
tenant.community(),
"attacker",
ChannelType::Stream,
ChannelVisibility::Open,
None,
outsider.public_key().as_bytes(),
None,
)
.await
.unwrap()
.id;
let harmless = EventBuilder::new(Kind::Custom(9), "no files")
.tags([Tag::parse(["h", &open.to_string()]).unwrap()])
.sign_with_keys(&outsider)
.unwrap();
publish(&state, &tenant, &outsider, harmless.clone())
.await
.unwrap();
let copied = buzz_sdk::build_edit_with_media(open, harmless.id, "stolen", &media)
.unwrap()
.sign_with_keys(&outsider)
.unwrap();
assert!(publish(&state, &tenant, &outsider, copied).await.is_err());
readable(
&state,
host,
&hashes,
&[&outsider],
&[StatusCode::NOT_FOUND; 2],
)
.await;
let additional_targets = Box::pin(governed_kinds_journey(
&state, &tenant, host, &url, channel, open, &owner, &member, &outsider,
))
.await;
for kind in [40003, 7, 45002] {
let unsupported = EventBuilder::new(Kind::Custom(kind), "unsupported lookup target")
.tags([Tag::parse(["h", &open.to_string()]).unwrap()])
.sign_with_keys(&outsider)
.unwrap();
state
.db
.insert_event(tenant.community(), &unsupported, Some(open))
.await
.unwrap();
assert_eq!(
lookup(&state, host, &member, open, unsupported.id).await.0,
StatusCode::NOT_FOUND
);
}
let forbidden = lookup(&state, host, &outsider, channel, original.id).await;
let missing = lookup(
&state,
host,
&outsider,
channel,
nostr::EventId::from_byte_array([0x42; 32]),
)
.await;
assert_eq!(
forbidden, missing,
"inaccessible and missing states must be indistinguishable"
);
assert_eq!(forbidden.0, StatusCode::NOT_FOUND);
state
.db
.remove_member(
tenant.community(),
channel,
member.public_key().as_bytes(),
owner.public_key().as_bytes(),
)
.await
.unwrap();
assert_eq!(
lookup(&state, host, &member, channel, original.id).await,
missing,
"fresh snapshot must reject a removed member"
);
for target in additional_targets {
assert_eq!(
lookup(&state, host, &member, channel, target).await,
missing,
"removed member must not read added target kind"
);
}
assert_eq!(
lookup(&state, host, &member, open, harmless.id).await.0,
StatusCode::OK,
"open-channel reader needs no membership"
);
http_server.abort();
s3_server.abort();
}
// Keep this larger journey's future separately allocated on the default stack.
#[allow(clippy::too_many_arguments)]
async fn governed_kinds_journey(
state: &Arc<AppState>,
tenant: &buzz_core::TenantContext,
host: &str,
url: &str,
channel: Uuid,
open: Uuid,
owner: &Keys,
member: &Keys,
outsider: &Keys,
) -> Vec<nostr::EventId> {
let mut targets = Vec::new();
for kind in [9, 40002, 40008, 45001, 45003] {
// Each original has isolated A/B bytes. Only the signer uploaded them;
// the member reader has no upload grant or alternate live reference.
let mut tags = Vec::new();
let mut hashes = Vec::new();
for label in ["A", "B"] {
let bytes = format!("kind {kind} {label} {}", Uuid::new_v4()).into_bytes();
let hash = hex::encode(Sha256::digest(&bytes));
let response = router(state.clone())
.oneshot(
Request::builder()
.method("PUT")
.uri("/upload")
.header(header::HOST, host)
.header(header::AUTHORIZATION, proof(owner, host, &hash, "upload"))
.header("X-SHA-256", &hash)
.body(Body::from(bytes.clone()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let descriptor: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 100_000).await.unwrap())
.unwrap();
tags.push(vec![
"imeta".into(),
format!("url {}", descriptor["url"].as_str().unwrap()),
format!("x {hash}"),
"m application/octet-stream".into(),
format!("size {}", bytes.len()),
]);
hashes.push(hash);
}
// Creation schemas differ by surface; seed signed originals, then use
// the real HTTP lookup, CLI, ingest and media handlers for every edit.
let source = EventBuilder::new(Kind::Custom(kind), "original A")
.tags([
Tag::parse(["h", &channel.to_string()]).unwrap(),
Tag::parse(tags[0].clone()).unwrap(),
])
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 20))
.sign_with_keys(owner)
.unwrap();
state
.db
.insert_event(tenant.community(), &source, Some(channel))
.await
.unwrap();
targets.push(source.id);
let denied = lookup(state, host, outsider, channel, source.id).await;
let absent = lookup(
state,
host,
outsider,
channel,
nostr::EventId::from_byte_array([0x43; 32]),
)
.await;
assert_eq!(denied, absent, "kind {kind} leaks private state");
assert_eq!(denied.0, StatusCode::NOT_FOUND);
let retaining =
buzz_sdk::build_edit_with_media(channel, source.id, "unauthorized owner", &tags[..1])
.unwrap()
.sign_with_keys(outsider)
.unwrap();
let Err(error) = publish(state, tenant, outsider, retaining).await else {
panic!("owner gained media access for kind {kind}")
};
assert!(
matches!(error,crate::handlers::ingest::IngestError::Rejected(ref reason) if reason=="restricted: attachment is not accessible"),
"{error:?}"
);
readable(
state,
host,
&hashes,
&[member],
&[StatusCode::OK, StatusCode::NOT_FOUND],
)
.await;
let replacement =
buzz_sdk::build_edit_with_media(channel, source.id, "replace A with B", &tags[1..])
.unwrap()
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 1))
.sign_with_keys(owner)
.unwrap();
publish(state, tenant, owner, replacement).await.unwrap();
for (clear, content, expected) in [
(false, "retain B", &tags[1..]),
(true, "clear", &tags[0..0]),
(false, "still empty", &tags[0..0]),
] {
cli_edit(url, owner, source.id, clear, content).await;
let (status, snapshot) = lookup(state, host, member, channel, source.id).await;
assert_eq!(status, StatusCode::OK);
let edit: nostr::Event = serde_json::from_value(snapshot["edit"].clone()).unwrap();
assert_eq!(edit.content, content);
let carried: Vec<_> = edit
.tags
.iter()
.filter(|t| t.as_slice()[0] == "imeta")
.map(|t| t.as_slice().to_vec())
.collect();
assert_eq!(carried, expected, "kind {kind} metadata");
readable(
state,
host,
&hashes,
&[member],
&[
StatusCode::NOT_FOUND,
if expected.is_empty() {
StatusCode::NOT_FOUND
} else {
StatusCode::OK
},
],
)
.await;
}
if kind == 45001 {
// A is no longer referenced by the attachment journey. Give only
// this forum original a snapshot reference, with no reader grant.
let image_url = tags[0][1].strip_prefix("url ").unwrap();
let preview = EventBuilder::new(Kind::Custom(kind), "forum preview")
.tags([
Tag::parse(["h", &channel.to_string()]).unwrap(),
Tag::parse([
"link-preview",
"snapshot",
"1",
"https://example.org/page",
"title",
"site",
"description",
image_url,
&hashes[0],
"",
"",
])
.unwrap(),
])
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 20))
.sign_with_keys(owner)
.unwrap();
state
.db
.insert_event(tenant.community(), &preview, Some(channel))
.await
.unwrap();
readable(state, host, &hashes[..1], &[member], &[StatusCode::OK]).await;
cli_edit(url, owner, preview.id, false, "keep forum preview").await;
readable(state, host, &hashes[..1], &[member], &[StatusCode::OK]).await;
let suppressed =
buzz_sdk::build_edit_with_media(channel, preview.id, "hide preview", &[])
.unwrap()
.tags([Tag::parse(["link-preview", "none"]).unwrap()])
.sign_with_keys(owner)
.unwrap();
publish(state, tenant, owner, suppressed).await.unwrap();
readable(
state,
host,
&hashes[..1],
&[member],
&[StatusCode::NOT_FOUND],
)
.await;
cli_edit(url, owner, preview.id, false, "preview stays hidden").await;
readable(
state,
host,
&hashes[..1],
&[member],
&[StatusCode::NOT_FOUND],
)
.await;
}
let public = EventBuilder::new(Kind::Custom(kind), "public original")
.tags([Tag::parse(["h", &open.to_string()]).unwrap()])
.sign_with_keys(outsider)
.unwrap();
state
.db
.insert_event(tenant.community(), &public, Some(open))
.await
.unwrap();
assert_eq!(
lookup(state, host, member, open, public.id).await.0,
StatusCode::OK
);
}
targets
}

View File

@@ -425,16 +425,43 @@ pub fn build_diff_message(
}
/// Build an edit event targeting an existing message (kind 40003).
///
/// This compatibility helper removes all attachments. Use
/// [`build_edit_with_media`] with the latest effective imeta to retain them.
pub fn build_edit(
channel_id: Uuid,
target_event_id: nostr::EventId,
new_content: &str,
) -> Result<EventBuilder, SdkError> {
build_edit_with_media(channel_id, target_event_id, new_content, &[])
}
/// Build an edit with the complete replacement attachment set (kind 40003).
///
/// Pass the latest live edit's imeta tags, or the original's when no edit
/// exists, to retain attachments. An empty set explicitly removes attachments;
/// URLs in the content alone do not retain media access. The relay still
/// checks the signer's publication rights for every local attachment.
pub fn build_edit_with_media(
channel_id: Uuid,
target_event_id: nostr::EventId,
new_content: &str,
media_tags: &[Vec<String>],
) -> Result<EventBuilder, SdkError> {
check_content(new_content, 64 * 1024)?;
let tags = vec![
let mut tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["e", &target_event_id.to_hex()])?,
];
// Only attachment metadata belongs here: a caller must not inject a
// competing target/channel or authorization tag through media_tags.
if media_tags
.iter()
.any(|t| t.first().map(String::as_str) != Some("imeta"))
{
return Err(SdkError::InvalidTag("expected imeta attachment tag".into()));
}
imeta_tags(media_tags, &mut tags)?;
Ok(EventBuilder::new(Kind::Custom(40003), new_content).tags(tags))
}
@@ -3309,6 +3336,34 @@ mod tests {
assert!(has_tag(&ev, "e", &eid.to_hex()));
}
#[test]
fn edit_replaces_complete_media_set_and_rejects_non_media_tags() {
let cid = uuid();
let eid = event_id();
let media = vec![vec![
"imeta".into(),
"url https://example.com/a.png".into(),
"x ".to_owned() + &"a".repeat(64),
"alt full label".into(),
"image https://example.com/poster.png".into(),
]];
let event = sign(build_edit_with_media(cid, eid, "new", &media).unwrap());
assert_eq!(
event
.tags
.iter()
.find(|t| t.as_slice()[0] == "imeta")
.unwrap()
.as_slice(),
media[0]
);
let cleared = sign(build_edit_with_media(cid, eid, "clear", &[]).unwrap());
assert!(!cleared.tags.iter().any(|t| t.as_slice()[0] == "imeta"));
assert!(build_edit_with_media(cid, eid, "bad", &[vec!["e".into(), eid.to_hex()]]).is_err());
assert!(build_edit_with_media(cid, eid, "bad", &[vec![]]).is_err());
assert!(build_edit_with_media(cid, eid, &"x".repeat(64 * 1024 + 1), &media).is_err());
}
#[test]
fn edit_content_too_large() {
let cid = uuid();

View File

@@ -0,0 +1,61 @@
# Effective message edits
For kinds 9/40002/40008/45001/45003, text edits replace a message's complete attachment set. To retain files, a
writer needs the latest live edit's imeta, or the original's when no edit exists.
A plain `#e` query is an exact tag match and can miss valid historical targets
whose hex casing differs. This extension leaves generic NIP-01 matching intact.
Other channel-tagged kinds keep the historical plain-edit behavior, without the
lookup or a relay-version dependency. The CLI rejects `--clear-attachments` for
those kinds because their attachments are not edit-governed.
## Request and response
An authenticated `POST /query` accepts one exact filter:
```json
[{"kinds":[9,40002,40008,45001,45003],"ids":["<original event id, lowercase hex>"],"#h":["<channel UUID>"],"effective_edit":1,"consistency":"strong"}]
```
The version-1 envelope is:
```json
{"effective_edit":1,"original":{"id":"...","kind":9,"tags":[],"content":"...","pubkey":"...","created_at":0,"sig":"..."},"edit":null}
```
`original` and a non-null `edit` are complete signed Nostr events. `edit` is the
latest live kind 40003 in the same channel targeting the live original of kind
9, 40002, 40008, 45001 or 45003. Targets use the first valid 64-character hex `e` tag in signed order,
ignoring casing. The greatest `(created_at,id)` wins. Upload proof can still authorize
republication by the uploader after removal; correct state resolution prevents
accidental restoration even when publication would be authorized. An empty imeta set on that
edit is authoritative; it never falls back to original attachments.
Original liveness, fresh channel authorization, and edit selection share a
repeatable-read transaction on the primary. Ordinary per-event visibility gates
also apply. Missing and inaccessible targets both return the same 404. Lookup
failure returns an error, never an empty state. The normalized-target index from
migration 0086 bounds the latest-edit probe; unrelated channel edits are not read.
The CLI requires the versioned envelope, checks the returned target/channel, and
aborts without signing or publishing when an older relay returns an ordinary
array. Deploy the relay first, then release the CLI. Old clients retain their
existing behavior against the new relay. No schema change is needed beyond 0086.
The CLI stamps its replacement after the observed state, including same-second
edits. A head more than 60 seconds ahead of its clock causes an error until the
clock catches up. Concurrent writers still use `(created_at,id)` ordering; the
lookup does not create a compare-and-swap precondition on publication.
The same five kinds govern the lookup, CLI attachment preservation and media
read authorization. A superseded original no longer grants access; the latest
live edit's full imeta set controls that reference. A file remains readable if
another live readable reference or upload authority grants access. An empty
replacement set does not fall back to the original.
The v1 request uses the exact five-kind filter above.
Deploy the relay before releasing the CLI or paired MCP producer. Edits of any
of these five kinds fail closed on community relays that lack the lookup. The
CLI and MCP both preserve the latest metadata, including an empty set; the CLI's
`--clear-attachments` covers all five kinds.

View File

@@ -90,17 +90,51 @@ media access through the ordinary media endpoint.
4. Repair unsupported/historical URLs or republish them through an authorized
uploader before restarting every serving pod in `enforce`.
**Enforce blocker: SDK/CLI attachment edits remain unsupported.**
`buzz_sdk::build_edit` and `buzz messages edit` currently omit attachment imeta.
Editing an attachment message through these clients can make its file unreadable,
including to its sender. This review item is deferred, not fixed. Enforce stays blocked unless the operator can rule out SDK/CLI attachment edits
in the community. The agent harness edit producer is unverified and remains
inside this blocker until its metadata behavior is traced and tested. To remove
the blocker, update both SDK/CLI paths to preserve the
latest effective attachment metadata across successive edits without restoring
files removed by an earlier edit. Require an upload → successive edits → GET/HEAD
regression proving retained files stay readable and removed files stay denied.
Keeping a URL in the edited body does not preserve access.
**Enforce prerequisite: upgrade every edit producer.** The updated
`buzz messages edit` resolves the latest live attachment state on the primary and
carries its complete imeta set for kinds 9/40002/40008/45001/45003. For those kinds,
`--clear-attachments` explicitly removes all attachments; later text edits
preserve that empty set. Other channel-tagged kinds keep their original media references after edits,
and the CLI rejects `--clear-attachments` for them. SDK
integrations must use `buzz_sdk::build_edit_with_media` with the latest
effective metadata. The compatibility `build_edit` helper still emits an empty
attachment set and therefore removes attachments on kinds 9/40002/40008/45001/45003. A URL in the
body does not retain access.
Deploy the relay with the [versioned effective-edit
lookup](effective-message-edits.md) before releasing the new CLI. When editing
kinds 9/40002/40008/45001/45003, a new CLI against an older relay aborts without publishing; an
old CLI against a new relay emits empty imeta and revokes attachments on all
five kinds, including diff/forum attachments that previously remained readable.
Other kinds use plain edits without a lookup or a relay-version dependency. The
`buzz-dev-mcp` multicall named `buzz` delegates to the same CLI entrypoint. The
PostgreSQL regression `cli_successive_edits_preserve_and_revoke_attachments`
exercises that entrypoint through HTTP and checks GET/HEAD, removal, and
mixed-case edit targets.
**Remaining enforce blockers include the separate `block/buzz-app` MCP edit producer
and the active producers listed below.**
Its direct publisher at `src/buzz-mcp/tools.ts` copies the original event's
imeta rather than resolving successive edits. It must adopt effective state
and pass the same removal regression before enabling enforce for its users.
Audit other third-party SDK/direct publishers too; upgrading the CLI alone
cannot change their signed events.
The relay rejects a retaining edit when its signer cannot publish any carried
attachment. Agent ownership does not grant private-channel media access: an
owner outside that channel cannot retain its files. Restore access or explicitly
remove the attachments with `--clear-attachments`; the CLI itself also requires
read access to the original. Concurrent edits remain ordered by `(created_at,id)`;
coordinate writers when attachment changes overlap. There is no compare-and-swap
precondition on message edits.
This client change has no feature flag. Observe
`buzz_media_publication_access_total{mode="shadow",reason="denied"}` before
enforcement. Roll back the CLI by reverting its release; retain the additive
relay lookup. Rolling the relay back first causes new CLI/MCP edits on all five kinds to
abort without publishing. Already-published imeta edits remain valid; rollback
restores the older relay's media-reference rules, so recheck access before
resuming enforcement.
There is no grant backfill: existing stored references are evaluated directly,
and signed event tags are never rewritten. Legacy unreferenced/orphan blobs
@@ -189,3 +223,58 @@ instance has no usable signing key. The error names affected instances; restore
their keys, or remove/unlink those instances, then retry. The previous persona
and instance avatars remain unchanged, so the same requested avatar is retryable.
Keys may be permanently lost; waiting alone does not repair a lost key.
The relay folds effective attachment state for the same five kinds accepted by
the lookup: 9/40002/40008/45001/45003. CLI and MCP text edits preserve that state;
`--clear-attachments` replaces it with an empty set on all five. After removal,
access through that reference is revoked; another live reference or upload
proof may independently authorize the blob. An older community relay without
the lookup refuses these edits through the new CLI/MCP, so upgrade relays first.
## Historical diff and forum edits
**Readiness blocker:** before enforcement, audit existing kind 40008/45001/45003
originals and every active edit producer. Expanding read folding applies to
stored events, including old CLI edits that emitted no imeta. Upgrading clients
does not repair those signed historical edits. An authorized writer must review
and republish the intended full set; never infer that an empty edit meant to
retain the original's files. No automatic historical rewrite is included.
Run the read-only [preflight query](sql/private-media-edit-preflight.sql), binding
`$1` to a community UUID. For psql, prepare its SELECT as
`PREPARE private_media_edit_preflight(uuid) AS ...;`, then execute
`EXECUTE private_media_edit_preflight('<community UUID>');` inside a read-only
repeatable-read transaction. Use a bounded statement timeout. Each row counts
live added-kind originals with imeta whose selected live edit omits imeta. The
selection uses the same normalized first-valid target, same channel/community,
and descending `(created_at,id)` ordering as the read path. Missing kinds have
zero matches. These are affected references, not necessarily inaccessible blobs:
another live reference or upload proof can independently authorize the bytes.
Partial attachment changes require separate review. No production count is
claimed by this PR.
| Producer | Current behavior and rollout requirement |
| --- | --- |
| Old CLI (through `96af488d`) | Plain edits emit no imeta; on the new relay they revoke diff/forum attachments that previously remained readable. |
| Updated CLI | Resolves the full effective set for all five kinds; explicit clear stays empty. Release after the relay. |
| SDK | `build_edit` emits no imeta. `build_edit_with_media` copies the supplied set; callers must resolve latest state. Third-party callers remain a prerequisite. |
| Legacy desktop chat | Carries rendered attachment state through `submitMessageEdit.ts` and `events.rs`; its local edit selection does not provide the primary lookup's full ordering guarantees. Audit deployed versions. |
| Legacy native generic edit | Copies supplied media, defaulting to empty; no effective lookup. Its forum readers keep showing original metadata and only fold preview suppression. Audit any direct generic callers before enforcement. |
| Mobile editor | `message_actions.dart` passes only custom emoji tags to `editMessage`; no imeta is retained. This remains an uncorrected producer blocker. |
| App MCP | Current main copies original metadata; the paired MCP fix adopts latest state. The fix must ship before enforcement for its users. |
| App UI | `fold.ts` selects the latest edit having imeta, falling back to the original. The UI editor can restore older files after an empty removal; it remains an uncorrected producer blocker. |
| ACP | Edit routing consumes events; generic publication forwards already-built signed events. It does not resolve attachments for producers. |
Source paths are relative to this repository, or to `block/buzz-app` for app
entries. App audit snapshot: `cae3922574`; paired MCP implementation is tracked
in [the counterpart PR](https://github.com/block/buzz-app/pull/2937). This audit
identifies source behavior, not installed versions.
Mode effects are precise: `off` skips reference checks for media reads and
publication. `shadow` evaluates the five-kind policy and records its metrics,
but does not deny reads/publication for policy failures; it also records sticky
publication boundaries best effort. `enforce` denies reads without a current
readable reference and rejects inaccessible carried attachments. Thus historical
empty edits change shadow observations immediately, and deny their superseded
original reference in enforce. Storage and tenant admission still apply in all
modes. The lookup and new CLI behavior apply independently of read mode.

View File

@@ -0,0 +1,22 @@
-- Bind $1 to the community UUID. Counts newly edit-governed originals whose
-- selected live edit omits imeta; independent grants can still authorize blobs.
SELECT original.kind, count(*) AS affected_originals
FROM events original
JOIN channels c ON c.community_id = original.community_id
AND c.id = original.channel_id AND c.deleted_at IS NULL
JOIN LATERAL (
SELECT e.tags
FROM events e
WHERE e.community_id = original.community_id
AND e.channel_id = original.channel_id
AND e.kind = 40003 AND e.deleted_at IS NULL
AND decode(lower(jsonb_path_query_first(tags, 'strict $[*] ? (@[0] == "e" && @[1] like_regex "^[0-9A-Fa-f]{64}$")[1]', '{}'::jsonb, true) #>> '{}'), 'hex') = original.id
ORDER BY e.created_at DESC, e.id DESC
LIMIT 1
) latest ON true
WHERE original.community_id = $1
AND original.kind IN (40008,45001,45003) AND original.deleted_at IS NULL
AND EXISTS (SELECT 1 FROM jsonb_array_elements(original.tags) t WHERE t->>0 = 'imeta')
AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements(latest.tags) t WHERE t->>0 = 'imeta')
GROUP BY original.kind
ORDER BY original.kind;

View File

@@ -31,6 +31,8 @@ import '../../shared/me/me_sync.dart';
import '../../shared/me/me_search_placement.dart';
import 'me/me_assistant_picker.dart';
import 'me/me_chat_assistant.dart';
import 'me/me_chat_settings.dart';
import 'me/me_settings_sheet.dart';
import 'me/me_workspace_picker.dart';
import 'me/me_messages_provider.dart';
import '../../shared/huddle/huddle.dart';
@@ -683,6 +685,52 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
}
}
// Picker changes rebuild the conversation while its modal route is open.
// Read the latest controls whenever returning to settings.
final chatSettingsControls = useRef((
assistant: assistant,
workspace: workspace,
));
chatSettingsControls.value = (assistant: assistant, workspace: workspace);
Future<void> openChatSettings() async {
await showBuzzModalBottomSheet<void>(
context: context,
showCloseButton: false,
isScrollControlled: true,
builder: (_) => MeSettingsSheet(
builder: (present, refresh) {
final controls = chatSettingsControls.value;
Future<void> open(bool agent) async {
if (agent) {
await controls.assistant.openPicker?.call(present: present);
} else {
await controls.workspace.openPicker?.call(present: present);
}
await WidgetsBinding.instance.endOfFrame;
refresh();
}
return SafeArea(
child: MeChatSettings(
agentName: controls.assistant.name,
agentAvatarUrl: controls.assistant.nativeAction.imageUrl,
agentInitial:
controls.assistant.nativeAction.avatarInitial ?? '?',
hasAgent: controls.assistant.selected != null,
workspaceName: controls.workspace.name,
onAgentTap: controls.assistant.openPicker == null
? null
: () => open(true),
onWorkspaceTap: controls.workspace.openPicker == null
? null
: () => open(false),
),
);
},
),
);
}
final retention = ephemeralChannelDisplay(resolvedChannel);
final nativeHeaderSubtitle = isOneToOneDm
? dmHeader?.presenceLabel
@@ -775,9 +823,12 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
messagesNotifier.isCatchingUp,
onNativeTitlePressed: openChannelDetails,
nativeActions: [
if (personalChat) workspace.nativeAction,
if (personalChat)
assistant.nativeAction
IosNavigationAction(
label: 'Chat settings',
icon: BuzzIcons.ellipsis,
onPressed: openChatSettings,
)
else if (resolvedChannel.isDm ? showsHuddleAction : showsComposer)
_huddleNavigationAction(context, ref, resolvedChannel, [
...messagesState.value ?? const [],
@@ -796,9 +847,13 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
onTap: openChannelDetails,
),
actions: [
if (personalChat) workspace.action,
if (personalChat)
assistant.action
IconButton(
key: const ValueKey('me-chat-settings-button'),
tooltip: 'Chat settings',
icon: const Icon(BuzzIcons.ellipsis),
onPressed: openChatSettings,
)
else if (resolvedChannel.isDm ? showsHuddleAction : showsComposer)
_HuddleButton(
channel: resolvedChannel,

View File

@@ -15,10 +15,12 @@ class MeAssistantList extends ConsumerWidget {
super.key,
required this.choices,
required this.selected,
this.onSelected,
});
final List<AgentDirectoryEntry> choices;
final String? selected;
final ValueChanged<String>? onSelected;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -70,7 +72,9 @@ class MeAssistantList extends ConsumerWidget {
trailing: agent.pubkey == selected
? const Icon(BuzzIcons.check)
: null,
onTap: () => Navigator.pop(context, agent.pubkey),
onTap: () => onSelected != null
? onSelected!(agent.pubkey)
: Navigator.pop(context, agent.pubkey),
),
),
],

View File

@@ -7,14 +7,23 @@ import 'me_assistant_list.dart';
/// Opens immediately with cached assistants; first-load progress stays in-sheet.
class MeAssistantPickerSheet extends ConsumerWidget {
const MeAssistantPickerSheet({super.key, required this.selected});
const MeAssistantPickerSheet({
super.key,
required this.selected,
this.onSelected,
});
final String? selected;
final ValueChanged<String>? onSelected;
@override
Widget build(BuildContext context, WidgetRef ref) {
final assistants = ref.watch(meAssistantsProvider);
if (assistants.value case final choices?) {
return MeAssistantList(choices: choices, selected: selected);
return MeAssistantList(
choices: choices,
selected: selected,
onSelected: onSelected,
);
}
return SizedBox(
height: 240,

View File

@@ -16,6 +16,7 @@ import '../../../shared/widgets/modal_presentation.dart';
import '../channel_management_provider.dart';
import '../channel_event_order.dart';
import 'me_assistant_picker_sheet.dart';
import 'me_settings_sheet.dart';
/// Resolves a remembered choice or the assistant already used in a synced chat.
/// Ambiguous chats require a choice instead of silently picking another agent.
@@ -51,6 +52,8 @@ String? resolveMeAssistant({
/// Header avatar and persistent mobile selection for a personal chat.
({
String? selected,
String name,
Future<void> Function({MeSettingsPresenter? present})? openPicker,
IosNavigationAction nativeAction,
Widget action,
Widget? pendingNotice,
@@ -128,7 +131,7 @@ useMeChatAssistant(
: selected == null
? 'Choose an agent'
: 'Change agent: ${name ?? 'Agent'}';
Future<void> choose() async {
Future<void> choose({MeSettingsPresenter? present}) async {
if (busy.value || sync == null) return;
busy.value = true;
try {
@@ -165,13 +168,21 @@ useMeChatAssistant(
}
if (resume != 'resume') return;
} else {
final picked = await showBuzzModalBottomSheet<String>(
context: context,
title: 'Agent',
isScrollControlled: true,
showDragHandle: true,
builder: (_) => MeAssistantPickerSheet(selected: selected),
);
final picked = present != null
? await present(
'Agent',
(onSelected) => MeAssistantPickerSheet(
selected: selected,
onSelected: onSelected,
),
)
: await showBuzzModalBottomSheet<String>(
context: context,
title: 'Agent',
isScrollControlled: true,
showDragHandle: true,
builder: (_) => MeAssistantPickerSheet(selected: selected),
);
if (picked == null || !context.mounted) return;
intent = MeAgentChange.create(sync, channelId, picked);
}
@@ -216,6 +227,8 @@ useMeChatAssistant(
return (
selected: selected,
openPicker: busy.value ? null : choose,
name: pendingName ?? name ?? 'Choose an agent',
pendingNotice: pendingName == null
? null
: Semantics(

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import '../../../shared/theme/theme.dart';
import '../../../shared/theme/buzz_icons.dart';
import '../../../shared/widgets/app_list.dart';
import '../../../shared/widgets/app_list_card.dart';
import '../../../shared/widgets/avatar_image.dart';
/// The current Solo chat's agent and workspace, using standard settings rows.
class MeChatSettings extends StatelessWidget {
const MeChatSettings({
super.key,
required this.agentName,
required this.workspaceName,
this.agentAvatarUrl,
this.agentInitial = '?',
this.hasAgent = false,
this.onAgentTap,
this.onWorkspaceTap,
});
final String agentName;
final String? agentAvatarUrl;
final String agentInitial;
final bool hasAgent;
final String workspaceName;
final VoidCallback? onAgentTap;
final VoidCallback? onWorkspaceTap;
@override
Widget build(BuildContext context) => AppListCard(
verticalPadding: Grid.twelve,
dividerIndent: Grid.xs + 32 + Grid.xs,
children: [
AppListRowRaw(
leading: AvatarImage(
imageUrl: agentAvatarUrl,
radius: 16,
isAgent: hasAgent,
backgroundColor: context.controlSurface,
fallback: hasAgent
? Text(agentInitial)
: const Icon(BuzzIcons.plus, size: 20),
),
title: Text('Agent', style: context.textTheme.bodyLarge),
subtitle: Text(
agentName,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
trailing: Icon(
BuzzIcons.chevronRight,
size: 18,
color: context.colors.onSurfaceVariant,
),
onTap: onAgentTap,
),
AppListRowRaw(
leading: SizedBox.square(
dimension: 32,
child: Icon(
BuzzIcons.workspace,
size: 22,
color: context.colors.onSurfaceVariant,
),
),
title: Text('Workspace', style: context.textTheme.bodyLarge),
subtitle: Text(
workspaceName,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
trailing: Icon(
BuzzIcons.chevronRight,
size: 18,
color: context.colors.onSurfaceVariant,
),
onTap: onWorkspaceTap,
),
],
);
}

View File

@@ -0,0 +1,92 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../shared/widgets/buzz_titled_sheet_layout.dart';
typedef MeSettingsPresenter =
Future<String?> Function(
String title,
Widget Function(ValueChanged<String>) builder,
);
/// Keeps the modal route, native surface and scrim alive across picker pages.
class MeSettingsSheet extends StatefulWidget {
const MeSettingsSheet({super.key, required this.builder});
final Widget Function(MeSettingsPresenter present, VoidCallback refresh)
builder;
@override
State<MeSettingsSheet> createState() => _MeSettingsSheetState();
}
class _MeSettingsSheetState extends State<MeSettingsSheet> {
String? _title;
Widget? _picker;
Completer<String?>? _result;
Future<String?> _present(
String title,
Widget Function(ValueChanged<String>) builder,
) {
if (!mounted) return Future.value();
final result = Completer<String?>();
setState(() {
_result = result;
_title = title;
_picker = builder(_finish);
});
return result.future;
}
void _finish([String? value]) {
final result = _result;
setState(() {
_result = null;
_title = null;
_picker = null;
});
result?.complete(value);
}
@override
void dispose() {
_result?.complete();
super.dispose();
}
@override
Widget build(BuildContext context) {
final content =
_picker ??
widget.builder(_present, () {
if (mounted) setState(() {});
});
return PopScope(
canPop: _picker == null,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _picker != null) _finish();
},
child: BuzzTitledSheetLayout(
title: _title ?? 'Chat settings',
showDragHandle: true,
onClose: () {
if (_picker != null) {
_finish();
} else {
Navigator.of(context).pop();
}
},
child: MediaQuery.disableAnimationsOf(context)
? content
: AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: content,
),
),
);
}
}

View File

@@ -5,12 +5,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../shared/me/me_sync.dart';
import '../../../shared/me/me_recovery.dart';
import 'me_recovery_dialog.dart';
import 'me_settings_sheet.dart';
import '../../../shared/theme/buzz_icons.dart';
import '../../../shared/widgets/feedback_snackbar.dart';
import '../../../shared/widgets/frosted_app_bar.dart';
import '../../../shared/widgets/modal_presentation.dart';
import '../../../shared/widgets/app_list.dart';
import '../../../shared/widgets/app_list_card.dart';
import '../../../shared/theme/theme.dart';
/// The workspace control shared by draft and existing Solo conversations.
({Widget action, IosNavigationAction nativeAction, bool busy}) useMeWorkspace(
({
Widget action,
IosNavigationAction nativeAction,
bool busy,
String name,
Future<void> Function({MeSettingsPresenter? present})? openPicker,
})
useMeWorkspace(
BuildContext context,
WidgetRef ref, {
required bool enabled,
@@ -76,6 +88,54 @@ import '../../../shared/widgets/frosted_app_bar.dart';
(groups != null || groupState?.hasError == true);
return (
busy: busy.value,
name: current == null ? 'No Workspace' : name ?? 'Unavailable workspace',
openPicker: !available
? null
: ({MeSettingsPresenter? present}) async {
Widget content(ValueChanged<String> onSelected) => Material(
color: Colors.transparent,
child: SafeArea(
child: SingleChildScrollView(
child: AppListCard(
verticalPadding: Grid.twelve,
children: [
AppListRow(
title: 'No Workspace',
trailing: current == null
? const Icon(BuzzIcons.check)
: null,
onTap: () => onSelected(''),
),
for (final group
in groups?.groups ?? <Map<String, dynamic>>[])
AppListRow(
title: group['name'] as String,
trailing: current == group['id']
? const Icon(BuzzIcons.check)
: null,
onTap: () => onSelected(group['id'] as String),
),
if (groupState?.hasError == true)
AppListRow(
title: 'Reload workspaces',
onTap: () => onSelected('+reload'),
),
],
),
),
),
);
final picked = present != null
? await present('Workspace', content)
: await showBuzzModalBottomSheet<String>(
context: context,
title: 'Workspace',
showDragHandle: true,
builder: (sheetContext) =>
content((value) => Navigator.pop(sheetContext, value)),
);
if (picked != null && context.mounted) await choose(picked);
},
action: PopupMenuButton<String>(
key: const ValueKey('me-workspace-button'),
tooltip: label,

View File

@@ -247,6 +247,7 @@ NostrEvent _edit({
);
Widget _buildTestable({
List<AgentDirectoryEntry> soloAgents = const [],
String? bestie,
bool useRealChannelProviders = false,
ProfileNotifier? profileNotifier,
@@ -351,7 +352,7 @@ Widget _buildTestable({
),
),
meMessagesProvider(_channelId).overrideWith(() => fakeMessagesNotifier),
meAssistantsProvider.overrideWith((ref) async => []),
meAssistantsProvider.overrideWith((ref) async => soloAgents),
],
channelMessagesProvider(_channelId).overrideWith(
() => personalChat
@@ -497,6 +498,7 @@ Widget _buildTestable({
savedPrefsProvider.overrideWithValue(_testPrefs),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
navigatorKey: navigatorKey,
theme: chatColor != null
? mobileAppearanceTheme(
@@ -608,6 +610,170 @@ const _directoryAlice =
const _directoryBob =
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
void main() {
testWidgets('Solo ellipsis opens agent and workspace settings directly', (
tester,
) async {
await _loadPresenceFonts(tester);
if (Platform.environment['PRESENCE_SCREENSHOTS'] != null) {
await (FontLoader(
'BuzzTabler',
)..addFont(rootBundle.load('assets/fonts/TablerIcons.ttf'))).load();
}
final avatarBytes = await rootBundle.load('assets/images/buzz-icon.png');
final avatarUrl =
'data:image/png;base64,${base64Encode(avatarBytes.buffer.asUint8List())}';
await tester.pumpWidget(
RepaintBoundary(
child: _buildTestable(
personalChat: true,
messages: const [],
soloAgents: const [
AgentDirectoryEntry(pubkey: 'agent', displayName: 'Terp'),
],
members: [
ChannelMember(
pubkey: 'self',
role: 'owner',
joinedAt: DateTime(2025),
),
ChannelMember(
pubkey: 'agent',
role: 'bot',
joinedAt: DateTime(2025),
),
],
users: {
'agent': UserProfile(
pubkey: 'agent',
displayName: 'Terp',
avatarUrl: avatarUrl,
),
},
organization: Stream.value(
const MeGroups(
channels: [_channelId],
groups: [
{'id': 'buzz', 'name': 'Buzz', 'defaultTemplateId': ''},
],
assignments: {_channelId: 'buzz'},
),
),
),
),
);
await tester.pumpAndSettle();
await _capturePresence(tester, 'solo-header-after');
await tester.tap(find.byKey(const ValueKey('me-chat-settings-button')));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Chat settings'));
await tester.pumpAndSettle();
expect(find.text('Chat settings'), findsOneWidget);
expect(find.text('Workspace'), findsOneWidget);
expect(find.text('Buzz'), findsOneWidget);
expect(find.byType(ChannelDetailsPage), findsNothing);
expect(
find.byWidgetPredicate(
(widget) =>
widget is AvatarImage &&
widget.imageUrl == avatarUrl &&
widget.isAgent,
),
findsOneWidget,
);
final avatarImage = find.descendant(
of: find.byWidgetPredicate(
(widget) => widget is AvatarImage && widget.imageUrl == avatarUrl,
),
matching: find.byType(Image),
);
await tester.runAsync(
() => precacheImage(
tester.widget<Image>(avatarImage).image,
tester.element(avatarImage),
),
);
await tester.pump();
await _capturePresence(tester, 'solo-settings-after');
final settingsRoute = ModalRoute.of(
tester.element(find.text('Chat settings')),
)!;
final barrier = tester.element(find.byType(ModalBarrier).last);
Future<void> expectStableMask() async {
final sheet = find.byType(BottomSheet);
final before = tester.getSize(sheet).height;
await tester.pump();
expect(tester.getSize(sheet).height, closeTo(before, 0.01));
await tester.pump(const Duration(milliseconds: 100));
final middle = tester.getSize(sheet).height;
expect(barrier.mounted, isTrue);
expect(settingsRoute.isCurrent, isTrue);
expect(settingsRoute.animation!.value, 1);
await tester.pump(const Duration(milliseconds: 250));
final after = tester.getSize(sheet).height;
expect(middle, greaterThan(before < after ? before : after));
expect(middle, lessThan(before > after ? before : after));
expect(barrier.mounted, isTrue);
expect(settingsRoute.isCurrent, isTrue);
expect(settingsRoute.animation!.value, 1);
}
await tester.ensureVisible(find.text('Workspace'));
await tester.tap(find.text('Workspace'));
await expectStableMask();
await tester.pumpAndSettle();
expect(find.text('No Workspace'), findsOneWidget);
expect(find.text('Buzz'), findsOneWidget);
expect(
find.ancestor(
of: find.text('No Workspace'),
matching: find.byType(AppListCard),
),
findsOneWidget,
);
await _capturePresence(tester, 'solo-workspace-section');
await tester.tap(find.byTooltip('Close sheet'));
await expectStableMask();
await tester.pumpAndSettle();
expect(find.text('Chat settings'), findsOneWidget);
await tester.tap(find.text('Agent'));
await expectStableMask();
await tester.pumpAndSettle();
expect(find.text('Chat settings'), findsNothing);
await tester.tap(find.byTooltip('Close sheet'));
await expectStableMask();
await tester.pumpAndSettle();
expect(find.text('Chat settings'), findsOneWidget);
expect(find.text('Workspace'), findsOneWidget);
await tester.tap(find.byTooltip('Close sheet'));
await tester.pumpAndSettle();
expect(find.text('Chat settings'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpWidget(
_buildTestable(
personalChat: true,
disableAnimations: true,
messages: const [],
organization: Stream.value(const MeGroups(channels: [_channelId])),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('me-chat-settings-button')));
await tester.pumpAndSettle();
final settingsHeight = tester.getSize(find.byType(BottomSheet)).height;
await tester.tap(find.text('Workspace'));
await tester.pump();
await tester.pump();
final reducedHeight = tester.getSize(find.byType(BottomSheet)).height;
expect(reducedHeight, isNot(settingsHeight));
await tester.pump(const Duration(milliseconds: 100));
expect(tester.getSize(find.byType(BottomSheet)).height, reducedHeight);
await tester.tap(find.byTooltip('Close sheet'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close sheet'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
for (final fromLauncher in [false, true]) {
testWidgets(
'Bestie DM preserves thread replies and ordinary sends: launcher=$fromLauncher',
@@ -11776,7 +11942,10 @@ void main() {
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('me-assistant-button')), findsOneWidget);
expect(
find.byKey(const ValueKey('me-chat-settings-button')),
findsOneWidget,
);
await expectLater(
tester
.widget<ComposeBar>(find.byType(ComposeBar))
@@ -11977,7 +12146,10 @@ void main() {
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('me-assistant-button')), findsOneWidget);
expect(
find.byKey(const ValueKey('me-chat-settings-button')),
findsOneWidget,
);
expect(findRichText('Agent answer'), findsOneWidget);
expect(find.byType(ThreadDetailPage), findsNothing);
final container = ProviderScope.containerOf(

View File

@@ -323,7 +323,7 @@ void swipeReplyTests() {
void swipeReplyLinkTests() {
for (final onLink in [true, false]) {
testWidgets(
'right drag starting ${onLink ? 'on a link' : 'beside a link'} replies',
'left drag starting ${onLink ? 'on a link' : 'beside a link'} replies',
variant: const TargetPlatformVariant({
TargetPlatform.iOS,
TargetPlatform.android,
@@ -346,8 +346,8 @@ void swipeReplyLinkTests() {
onLink ? find.text('the doc') : find.textContaining('carefully'),
),
);
await gesture.moveBy(const Offset(25, 0));
await gesture.moveBy(const Offset(100, 0));
await gesture.moveBy(const Offset(-25, 0));
await gesture.moveBy(const Offset(-100, 0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();