Files
buzz/crates/buzz-cli/src/commands/sidebar_sync.rs
Blake McAnally 859cb02ec1 feat(cli): sidebar stars, mutes, and project pins (#8781)
> Stacks on #8759 (`bmcanally/cli-sidebar-sections`). The diff shows
only this PR's commits. Merge #8759 first.

## Summary

This change adds three command groups to `buzz sidebar`:

- `buzz sidebar stars list|add|remove --channel <uuid>...`
- `buzz sidebar mutes list|add|remove --channel <uuid>...`
- `buzz sidebar projects list|add|remove --project <address>...`

They edit the same synced documents that Buzz desktop and mobile edit.
The documents are kind:30078 events with the `d` tags `channel-stars`,
`channel-mutes`, and `project-sidebar-membership`. Each document is
encrypted to your own key.

This PR stacks on #8759 (`buzz sidebar sections`).

## Design

**One flag-map module.** `sidebar_flags.rs` serves all three documents.
A small `Spec` value holds the differences: the `d` tag, the collection
name, the flag name, the timestamp unit, and the entry cap.

**One shared read and write path.** `sidebar_sync.rs` holds the head
read, the verification, the decryption, the encryption, the signing, and
the publish. `sidebar sections` now uses it too. No code is duplicated.

**Safety rules, the same as for sections.**
- One head read and one publish for each command.
- The CLI never publishes over a head that it cannot verify or decode.
- `created_at` is `max(now, head + 1)`.
- A relay `duplicate:` reply gives a conflict (exit 5).
- Channel UUIDs and project owners are stored in lowercase.
- A command that changes nothing prints `"message": "unchanged"` and
publishes nothing.
- The ciphertext must not exceed 65535 bytes.

**Wire format.** Desktop is the reference:
- Stars and mutes: `{version: 1, channels: {<id>: {starred|muted,
updatedAt}}}`. `updatedAt` is in seconds. The newest 500 entries stay,
ordered by `(updatedAt, id)`.
- Projects: `{version: 1, projects: {<address>: {selected,
updatedAt}}}`. `updatedAt` is in milliseconds. There is no entry cap.
- `remove` writes a `false` entry. It does not delete the entry. This is
how desktop does it.
- Entries that fail desktop's validation are dropped, as desktop drops
them.

**Differences from the task text.** Desktop does these things
differently, so the CLI follows desktop:
- Project `updatedAt` is in milliseconds (`Date.now()`), not seconds.
- Project membership has no 500-entry cap. The size limit bounds it.
- When two project entries tie, desktop picks "not selected". The CLI
never creates a tie (see below).
- Desktop accepts a legacy array of addresses for projects. The CLI
reads it.
- Mobile does not evict entries. Desktop does. The CLI follows desktop.

**Two rules that are stricter than desktop.**
- A changed entry gets `updatedAt = max(now, old updatedAt + 1)`.
Desktop merges a tie in favor of its local value. A plain `now` could
lose to a same-second entry on another device.
- `remove` of an id that is not set does nothing. Desktop writes a new
`false` entry. The CLI skips it so that it does not publish or use an
entry slot.

A command that would evict one of its own ids (more than 500 new ids)
fails with a usage error.

## Testing

**Ran:**
- `cargo test -p buzz-cli`: 518 passed.
- `cargo clippy -p buzz-cli --all-targets -- -D warnings`: clean.
- `cargo fmt --all --check`: clean.
- `just file-size-check`: passes.

**Test design.** The tests call the production `run` function against an
axum fake relay. The fake relay checks the query filter (kind, `limit:
1`, `consistency: strong`, `#d`). Tests cover:
- the stars and mutes journey (one publish for many ids, canonical ids,
`unchanged`, `false` entries, invalid input)
- an entry newer than the clock, and entries that desktop would drop
- unreadable heads, a forged signature, a foreign author, and a
`duplicate:` conflict
- the 500-entry cap and the refusal to evict own ids
- projects: milliseconds, address checks, the legacy array, no eviction,
and the size cap

**Interop evidence.** I wrote CLI documents and ran desktop's own
TypeScript functions on them with node (`parseStarPayload`,
`parseMutePayload`, `parseProjectSidebarMembershipPayload`, and the
three `merge` functions). Desktop kept every entry. A merge of the
document into itself changed nothing, so desktop sees it as settled. A
device with the older value lost to the CLI edit, both as local and as
remote. Script: `interop-flags.mjs`, output `INTEROP OK`.

**Mutation checks.** I broke each guard once and confirmed that a test
failed. The guards were: the newer-stamp rule, `created_at` after the
head, the 500 cap, the self-eviction refusal, the no-op skip, flag type
validation, negative `updatedAt`, the strict collection check, the
project unit, the project kind and owner checks, the project cap, the
ciphertext cap, signature verification, the author check, UUID
lowercasing, the legacy array, the version check, and keeping `false`
entries. Three mutants first survived (signature, author, negative
`updatedAt`). I added a relay mode that returns foreign and forged
events, and a test for each. Then all were killed.

## Manual test steps

Use a test identity on a staging relay. Set `BUZZ_RELAY_URL` and
`BUZZ_PRIVATE_KEY` first. Pick two channel UUIDs from `buzz channels
list`.

1. Star two channels:
   `buzz sidebar stars add --channel <uuid1> --channel <uuid2>`
Success: JSON with `"accepted": true`. Both channels show a star in
desktop within a few seconds.
2. Run the same command again.
   Success: `"message": "unchanged"` and `"event_id": null`.
3. `buzz sidebar stars list`
   Success: `{"channels": ["<uuid1>", "<uuid2>"]}`.
4. `buzz sidebar stars remove --channel <uuid1>`
Success: the star for `<uuid1>` is gone in desktop. `stars list` shows
only `<uuid2>`.
5. Star a channel in desktop. Run `stars list`.
   Success: the channel is in the list.
6. `buzz sidebar mutes add --channel <uuid1>`
Success: the channel shows as muted in desktop. `mutes remove` unmutes
it.
7. Copy a project address from a project
(`30621:<owner-pubkey>:<d-tag>`). Run
   `buzz sidebar projects add --project <address>`
Success: the project appears in the desktop sidebar. `projects remove`
hides it. `projects list` shows the pinned projects.
8. Run `buzz sidebar stars add --channel not-a-uuid`.
Success: exit code 1 and an "invalid channel UUID" error. Nothing is
published.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: bmcanally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:02:11 -06:00

282 lines
10 KiB
Rust

//! Read and write path shared by every synced sidebar document (kind:30078,
//! NIP-44 encrypted to yourself): one verified head read, one signed publish.
use std::time::SystemTime;
use buzz_core::kind::KIND_READ_STATE;
use nostr::nips::nip44;
use nostr::{EventBuilder, Kind, Tag, Timestamp};
use serde_json::{json, Value};
use super::parse_write_response;
use crate::client::BuzzClient;
use crate::error::CliError;
/// Relay event size ceiling shared with desktop's sidebar blobs.
const MAX_CIPHERTEXT_BYTES: usize = 65_535;
/// One synced document: its `d` tag and the name used in messages.
#[derive(Clone, Copy)]
pub(crate) struct Lane {
pub d_tag: &'static str,
pub label: &'static str,
}
/// The decoded head. `doc` is `None` when the relay holds no event yet.
pub(crate) struct Head<T> {
pub created_at: u64,
pub doc: Option<T>,
}
pub(crate) fn now() -> std::time::Duration {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
}
/// Channel ids in the lowercase hyphenated form desktop keys entries by.
pub(crate) fn channel_ids(channels: &[String]) -> Result<Vec<String>, CliError> {
channels
.iter()
.map(|channel| {
uuid::Uuid::parse_str(channel.trim())
.map(|id| id.hyphenated().to_string())
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel}")))
})
.collect()
}
/// The relay's current event for `lane` and this identity, decrypted and
/// passed to `decode` with the event's `created_at`. An absent head is
/// `doc: None`; an unreadable one is an error, never an empty document. The
/// read is pinned to the writer (`consistency: strong`): a write derives a
/// full snapshot from it, and a lagging replica would drop the caller's own
/// just-accepted edits.
pub(crate) async fn fetch_head<T>(
client: &BuzzClient,
lane: Lane,
decode: impl FnOnce(&Value, u64) -> Option<T>,
) -> Result<Head<T>, CliError> {
let me = client.keys().public_key();
let filter = json!({
"kinds": [KIND_READ_STATE],
"authors": [me.to_hex()],
"#d": [lane.d_tag],
"limit": 1,
"consistency": "strong",
});
let raw = client.query(&filter).await?;
let events: Vec<Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse {} query: {e}", lane.label)))?;
let returned = events.len();
let head = events
.into_iter()
.filter_map(|event| serde_json::from_value::<nostr::Event>(event).ok())
.filter(|event| {
event.pubkey == me
&& event.tags.identifier() == Some(lane.d_tag)
&& event.verify().is_ok()
})
.min_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| a.id.to_hex().cmp(&b.id.to_hex()))
});
let Some(event) = head else {
if returned > 0 {
return Err(CliError::Other(format!(
"the relay returned a {} event that is not yours or fails verification; refusing to overwrite it",
lane.label
)));
}
return Ok(Head {
created_at: 0,
doc: None,
});
};
let unreadable = |detail: String| {
CliError::Other(format!(
"your {} event {} is unreadable ({detail}); refusing to read or overwrite it",
lane.label,
event.id.to_hex()
))
};
let plaintext = nip44::decrypt(client.keys().secret_key(), &me, &event.content)
.map_err(|e| unreadable(e.to_string()))?;
let json: Value = serde_json::from_str(&plaintext).map_err(|e| unreadable(e.to_string()))?;
let created_at = event.created_at.as_secs();
let doc = decode(&json, created_at).ok_or_else(|| {
unreadable("unsupported format; a newer client may have written it".into())
})?;
Ok(Head {
created_at,
doc: Some(doc),
})
}
/// Encrypts `plaintext` to yourself and publishes it as the new head, one
/// second after `head_created_at` at the earliest. A relay `duplicate:` reply
/// is a conflict.
pub(crate) async fn publish(
client: &BuzzClient,
lane: Lane,
plaintext: String,
head_created_at: u64,
) -> Result<Value, CliError> {
let keys = client.keys();
let too_large = || {
CliError::Usage(format!(
"{} would exceed the relay's event size limit",
lane.label
))
};
// NIP-44 rejects longer plaintexts outright.
if plaintext.len() > u16::MAX as usize {
return Err(too_large());
}
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
plaintext,
nip44::Version::V2,
)
.map_err(|e| CliError::Other(format!("failed to encrypt {}: {e}", lane.label)))?;
if content.len() > MAX_CIPHERTEXT_BYTES {
return Err(too_large());
}
let created_at = now().as_secs().max(head_created_at + 1);
let builder = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content)
.tags([Tag::identifier(lane.d_tag), Tag::hashtag(lane.d_tag)])
.custom_created_at(Timestamp::from(created_at));
let raw = client.submit_event(client.sign_event(builder)?).await?;
let normalized = parse_write_response(
&raw,
&format!("a newer {} update won; run the command again", lane.label),
)?;
serde_json::from_str(&normalized)
.map_err(|e| CliError::Other(format!("relay response is not JSON: {e}")))
}
/// The response of a command that changed nothing and published nothing.
pub(crate) fn unchanged() -> Value {
json!({"event_id": null, "accepted": true, "message": "unchanged"})
}
/// Shared fake relay and signing helpers for the sidebar command tests.
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use axum::{body::Bytes, extract::State, routing::post, Json, Router};
use nostr::Keys;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
#[derive(Default)]
pub struct Relay {
pub events: Vec<Value>,
pub duplicate: bool,
/// Answer every query with all stored events, whoever authored them.
pub leaky: bool,
}
pub type Shared = Arc<Mutex<Relay>>;
/// Fake relay: `/query` checks the head filter and returns every stored
/// event matching its author and requested d-tag (more than the relay's
/// `limit`, so head selection is exercised); `/events` stores one.
async fn fake_relay(relay: Shared) -> String {
let app = Router::new()
.route(
"/query",
post(|State(relay): State<Shared>, body: Bytes| async move {
let filters: Vec<Value> = serde_json::from_slice(&body).unwrap();
let [filter] = filters.as_slice() else {
panic!("one filter expected: {filters:?}")
};
assert_eq!(filter["kinds"], json!([KIND_READ_STATE]));
assert_eq!(filter["limit"], json!(1));
assert_eq!(filter["consistency"], json!("strong"));
let author = &filter["authors"][0];
let d_tag = &filter["#d"][0];
let relay = relay.lock().unwrap();
let matching: Vec<_> = relay
.events
.iter()
.filter(|e| {
relay.leaky || (&e["pubkey"] == author && &e["tags"][0][1] == d_tag)
})
.cloned()
.collect();
Json(json!(matching))
}),
)
.route(
"/events",
post(|State(relay): State<Shared>, body: Bytes| async move {
let event: Value = serde_json::from_slice(&body).unwrap();
let mut relay = relay.lock().unwrap();
let message = if relay.duplicate {
"duplicate: newer head"
} else {
""
};
if !relay.duplicate {
relay.events.push(event.clone());
}
Json(json!({"event_id": event["id"], "accepted": true, "message": message}))
}),
)
.with_state(relay);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
pub async fn setup() -> (BuzzClient, Shared) {
let relay = Shared::default();
let url = fake_relay(relay.clone()).await;
let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap();
(client, relay)
}
pub fn seed_at(
client: &BuzzClient,
relay: &Shared,
lane: Lane,
plaintext: &str,
created_at: u64,
) -> Value {
let keys = client.keys();
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
plaintext,
nip44::Version::V2,
)
.unwrap();
let event = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content)
.tags([Tag::identifier(lane.d_tag)])
.custom_created_at(Timestamp::from(created_at))
.sign_with_keys(keys)
.unwrap();
let event = serde_json::to_value(event).unwrap();
relay.lock().unwrap().events.push(event.clone());
event
}
/// The decrypted document of the newest published event.
pub fn published(client: &BuzzClient, relay: &Shared) -> (nostr::Event, Value) {
let raw = relay.lock().unwrap().events.last().cloned().unwrap();
let event: nostr::Event = serde_json::from_value(raw).unwrap();
let keys = client.keys();
let plaintext =
nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content).unwrap();
(event, serde_json::from_str(&plaintext).unwrap())
}
pub fn published_count(relay: &Shared) -> usize {
relay.lock().unwrap().events.len()
}
}