fix(cli): list DMs, attach any file, validate kinds, default the relay (#9168)

Fixes five defects in the `buzz` CLI.

- **`dms list` returned `[]`.** It queried kind 41001, which the relay
no longer publishes. It now reads the viewer's DM group metadata (kind
39000, `t=dm`, `p=viewer`) and drops every DM listed in the viewer's
newest relay-signed visibility snapshot (kind 30622), the same hidden
set the desktop uses. It keeps paging until it has the requested number
of visible DMs or the history runs out, so hidden DMs never crowd out
visible ones. Hidden DMs stay hidden; reopening one brings it back.
- **Uploads accepted only images and MP4.** The CLI now leaves type
acceptance to the relay, as the desktop does: unsniffable files go up as
`application/octet-stream`, and the relay's own allow and deny rules
apply. Local size caps match the relay's defaults (50 MB images, 500 MB
MP4, 100 MB other files). `messages send --file` adds an imeta
`filename` (the basename, with control characters and both path
separators replaced by spaces) and writes non-media files as a plain
link (`[name](<url>)`) instead of `![image](…)`; inline media still
embeds.
- **`messages get --kinds` silently ignored bad values.** Any
non-numeric or out-of-range (above 65535) kind now fails with a
bad-input error, exit 1.
- **Upload errors lost their category.** A missing or unreadable file is
bad input (exit 1), and `messages send --file` returns the upload's own
error instead of wrapping every failure as `Other` (exit 4). The "auth
tag may be stale" hint on every 403 is gone: the relay answers 403 for
ordinary permission denials, such as a non-moderator reading moderation
data, so the hint misled.
- **Agents had to pass the relay by hand.** On macOS and Linux, with no
`--relay` and no `BUZZ_RELAY_URL`, the CLI uses the relay an agent's
inherited `BUZZ_CREDENTIAL_PROVIDER` token acts on, from its `describe`
answer. If that lists several relays, it fails with exit 1 and asks for
`--relay`. An explicit flag or env var still wins and is still checked
against the credential. Paired tokens never choose a relay, `auth
desktop unpair` never asks the desktop, and otherwise the default stays
`http://localhost:3000`. Help text says so.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: wpfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-11-06 11:20:47 -05:00
committed by GitHub
parent 962b3fdd88
commit b329e05c5b
5 changed files with 546 additions and 96 deletions

View File

@@ -65,8 +65,9 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
tag
}
/// MIME types accepted for upload.
const ALLOWED_MIMES: &[&str] = &[
/// MIME types the relay stores as inline media; everything else is a generic
/// file it validates, stores and serves as a download.
const MEDIA_MIMES: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
@@ -80,6 +81,9 @@ const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
/// Maximum file size for video uploads (500 MB).
const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
/// Maximum file size for generic file uploads (100 MB), the relay's default.
const MAX_FILE_BYTES: u64 = 100 * 1024 * 1024;
fn relay_server_tag(relay_url: &str) -> Option<String> {
let authority = buzz_core::tenant::relay_url_authority(relay_url);
if authority.is_empty() {
@@ -446,8 +450,21 @@ mod media_download_tests {
}
}
const QUERY_PAGE_SIZE: u32 = 500;
pub(crate) const QUERY_PAGE_SIZE: u32 = 500;
/// The most pages one limited query reads before failing, so a relay that
/// never runs out of events to skip cannot keep a command running forever.
pub(crate) const MAX_QUERY_PAGES: usize = 100;
fn stalled_query(detail: &str) -> CliError {
CliError::Relay {
status: 200,
body: format!("query pagination stopped: {detail}"),
}
}
/// Move the composite cursor to the page's last event, failing unless it
/// lands strictly older than the current cursor.
fn advance_query_cursor(
filter: &mut serde_json::Value,
page: &[serde_json::Value],
@@ -464,6 +481,20 @@ fn advance_query_cursor(
.and_then(serde_json::Value::as_str)
.filter(|id| id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()))
.ok_or_else(|| CliError::Other("query event missing valid id".into()))?;
if let (Some(until), Some(before_id)) = (
filter.get("until").and_then(serde_json::Value::as_u64),
filter.get("before_id").and_then(serde_json::Value::as_str),
) {
// The relay pages newest first, equal timestamps by ascending id:
// the next page lies at `created_at < until`, or at `until` past `before_id`.
let older = created_at < until
|| (created_at == until && id.to_ascii_lowercase() > before_id.to_ascii_lowercase());
if !older {
return Err(stalled_query(
"the relay did not move the cursor to older events",
));
}
}
filter["until"] = serde_json::json!(created_at);
filter["before_id"] = serde_json::json!(id);
Ok(())
@@ -701,8 +732,7 @@ impl BuzzClient {
///
/// The relay's `{"error": …}` / `{"message": …}` field becomes the
/// message. A 401 without a NIP-FI evidence source names the settings,
/// since an enforcing relay answers every request that way; a 403 with an
/// auth tag configured hints that the tag may be stale.
/// since an enforcing relay answers every request that way.
fn relay_error(&self, status: u16, body: String) -> CliError {
let message = extract_relay_message_field(&body).unwrap_or(body);
let message = message.trim_end().to_owned();
@@ -725,18 +755,6 @@ impl BuzzClient {
{FEDERATED_IDENTITY_ADAPTER_ENV} to its adapter, which mints the assertion"
));
}
if status == 403 && self.credentials.auth_tag().is_some() {
let hint = if desktop {
"the Buzz desktop's NIP-OA auth tag may be stale or revoked; check this \
identity's owner in Buzz"
} else {
"BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it"
};
return CliError::Relay {
status,
body: format!("{message} ({hint})"),
};
}
CliError::Relay {
status,
body: message,
@@ -805,15 +823,37 @@ impl BuzzClient {
async fn query_pages(
&self,
mut filter: serde_json::Value,
filter: serde_json::Value,
limit: Option<u32>,
) -> Result<Vec<serde_json::Value>, CliError> {
self.query_pages_keeping(filter, limit, None).await
}
/// Like `query_pages`, but with `keep`, only events it accepts count
/// toward `limit`: paging continues past the ones it drops, always in
/// full pages, for at most `MAX_QUERY_PAGES` pages.
async fn query_pages_keeping(
&self,
mut filter: serde_json::Value,
limit: Option<u32>,
keep: Option<&dyn Fn(&serde_json::Value) -> bool>,
) -> Result<Vec<serde_json::Value>, CliError> {
let mut events = Vec::new();
let mut pages = 0;
while limit.is_none_or(|limit| events.len() < limit as usize) {
let page_limit = limit
.map(|limit| (limit as usize - events.len()).min(QUERY_PAGE_SIZE as usize))
.unwrap_or(QUERY_PAGE_SIZE as usize);
if keep.is_some() && pages == MAX_QUERY_PAGES {
return Err(stalled_query(&format!(
"read {MAX_QUERY_PAGES} pages without reaching the end of the results"
)));
}
pages += 1;
let page_limit = match (limit, keep) {
(Some(limit), None) => {
(limit as usize - events.len()).min(QUERY_PAGE_SIZE as usize)
}
_ => QUERY_PAGE_SIZE as usize,
};
filter["limit"] = serde_json::json!(page_limit);
let raw = self.query(&filter).await?;
@@ -824,15 +864,30 @@ impl BuzzClient {
if !done {
advance_query_cursor(&mut filter, &page)?;
}
events.extend(page);
events.extend(page.into_iter().filter(|e| keep.is_none_or(|keep| keep(e))));
if done {
break;
}
}
if let Some(limit) = limit {
events.truncate(limit as usize);
}
Ok(events)
}
/// Query up to `limit` historical events that `keep` accepts, paging past
/// the rest until the limit is met or the history runs out.
pub async fn query_paginated_keeping(
&self,
filter: serde_json::Value,
limit: u32,
keep: impl Fn(&serde_json::Value) -> bool,
) -> Result<Vec<serde_json::Value>, CliError> {
self.query_pages_keeping(filter, Some(limit), Some(&keep))
.await
}
/// Query up to `limit` historical events, following the relay bridge's
/// composite `(until, before_id)` cursor across bounded result pages.
pub async fn query_paginated(
@@ -1387,28 +1442,26 @@ impl BuzzClient {
pub async fn upload_file(&self, file_path: &str) -> Result<BlobDescriptor, CliError> {
// 1. Read file — validate it exists and is a regular file
let metadata = std::fs::metadata(file_path)
.map_err(|e| CliError::Other(format!("cannot access {file_path}: {e}")))?;
.map_err(|e| CliError::Usage(format!("cannot access {file_path}: {e}")))?;
if !metadata.is_file() {
return Err(CliError::Usage(format!("{file_path} is not a file")));
}
let bytes = std::fs::read(file_path)
.map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?;
.map_err(|e| CliError::Usage(format!("failed to read {file_path}: {e}")))?;
// 2. Detect MIME from magic bytes
let mime = infer::get(&bytes)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
if !ALLOWED_MIMES.contains(&mime.as_str()) {
return Err(CliError::Usage(format!("unsupported file type: {mime}")));
}
// 3. Size check
let max = if mime.starts_with("video/") {
// 3. Size check. The relay decides which other types it accepts.
let max = if mime == "video/mp4" {
MAX_VIDEO_BYTES
} else {
} else if MEDIA_MIMES.contains(&mime.as_str()) {
MAX_IMAGE_BYTES
} else {
MAX_FILE_BYTES
};
if bytes.len() as u64 > max {
return Err(CliError::Usage(format!(
@@ -1423,7 +1476,7 @@ impl BuzzClient {
// 5. PUT request to the BUD-02 /upload endpoint with a generous timeout.
// Auth is signed per attempt — matches the per-attempt signing pattern in download_media.
let upload_timeout = if mime.starts_with("video/") {
let upload_timeout = if max > MAX_IMAGE_BYTES {
Duration::from_secs(600)
} else {
Duration::from_secs(120)
@@ -2597,6 +2650,27 @@ mod retry_policy_tests {
#[cfg(test)]
mod tests {
#[cfg(unix)]
#[tokio::test]
async fn an_unreadable_file_is_bad_input() {
use std::os::unix::fs::PermissionsExt;
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
let client = BuzzClient::new(
"http://127.0.0.1:9".into(),
crate::credentials::Credentials::from_keys(nostr::Keys::generate()),
)
.unwrap();
let err = client
.upload_file(file.path().to_str().unwrap())
.await
.unwrap_err();
assert!(
matches!(err, crate::error::CliError::Usage(ref m) if m.contains("failed to read")),
"{err:?}"
);
}
use super::{
advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field,
normalize_events, BuzzClient, Credentials, Proof,
@@ -3497,7 +3571,7 @@ mod desktop_throttle_tests {
let err = client.get_authed("/forbidden").await.unwrap_err();
let text = err.to_string();
assert!(
text.contains("in Buzz") && !text.contains("BUZZ_AUTH_TAG"),
!text.contains("stale") && !text.contains("BUZZ_AUTH_TAG"),
"{text}"
);
}