Files
buzz/crates
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
..