mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
## Problem
`buzz channels create --template` fails with a bare-pubkey
duplicate-instance error when a persona has more than one live instance.
When duplicate instances share the same display name and avatar, the
error gives the operator no signal to identify which instance is stale.
## What changed
**`crates/buzz-cli/src/commands/channels.rs`** (boundary) + one
visibility line in **`crates/buzz-cli/src/commands/users.rs`** (`fn
presence_subject` → `pub(crate)`), plus a relay behavior change in
**`crates/buzz-relay/src/api/bridge.rs`** and a supporting test in
**`crates/buzz-pubsub/src/presence.rs`**.
### Relay: presence-lookup failure is now surfaced as a failure
`synthesize_presence` previously collapsed a Redis `get_presence_bulk`
error into `unwrap_or_default()` → HTTP 200 `[]`, making a backend
outage indistinguishable from an authoritative all-offline snapshot. It
now returns `Option<Result<Vec<Value>, (StatusCode, Json<Value>)>>`:
`None` means the request is not a presence query (fall through),
`Some(Ok)` is a real snapshot (an empty vec is an authoritative
all-offline result), and `Some(Err)` is a backend failure surfaced as a
non-2xx response. The event tag-parse and sign paths were converted from
`.ok()?` fall-throughs to explicit `Some(Err(...))` for the same reason
— a silent fall-through would have reintroduced the fake-empty-success
anti-pattern. The function takes `pubsub` and `relay_keypair` directly
rather than the whole `AppState`, which keeps the Redis-error seam
unit-testable without live infrastructure; its single production caller
was updated accordingly, and no other caller consumes
`synthesize_presence`.
### Hint fetch architecture
**`assemble_roster_resolution<F, Fut>`** — post-fetch stage extracted
from `build_roster_resolution` as an injectable-fetcher async generic.
It contains: archive-filter-based duplicate detection, the conditional
`fetch_hints` call, and `finalize_roster_resolution` delegation.
Accepting the fetcher as a closure makes the wiring directly testable
without a relay.
`build_roster_resolution` reduces to: gather slugs → `tokio::join!(scan,
archive)` → delegate with the real `fetch_candidate_hints` closure.
**Zero hint queries on the happy path**: `assemble_roster_resolution`
only invokes the fetcher when duplicate live instances exist after
archive filtering. Untrusted archive snapshot (`Err`) conservatively
treats all found instances as live.
When duplicates exist, `fetch_candidate_hints` runs the presence
(kind:40902) and profile (kind:0) queries concurrently, bounding **each
independently** through `join_bounded_queries`: a per-query 3-second
timeout maps to `Err`, so a lookup that completes is never discarded
because its sibling hung. On total failure or timeout, bare pubkeys are
printed promptly.
### Trusted-snapshot boundary — `hints_from_results`
The relay drops the Redis presence key when an identity goes offline, so
the bulk presence snapshot never returns an event for an offline
instance — precisely the stale duplicate an operator needs flagged.
`hints_from_results` therefore treats a *trusted* presence snapshot as a
**complete snapshot** for the requested pubkeys: it seeds all of them
`offline`, then overlays returned statuses.
A response is trusted only when `trusted_presence_snapshot` validates
**every** array element as the actual snapshot contract: each must parse
as a complete signed `nostr::Event` of the presence-update kind carrying
exactly one `p` tag whose subject is one of the requested pubkeys.
Requiring the *sole* `p`-tag subject — the exact value `build_hint_map`
later reads via `presence_subject` — is what stops a mixed-tag event
(`[["p","<unrequested>"],["p","<requested>"]]`) from passing the gate
yet overlaying a different subject downstream. Any element that is not
such an event — a vacuous object like
`{"pubkey":"…","content":"online"}`, `[{}]`, `[null]`, an event of the
wrong kind, one for an unrequested subject, or one carrying more than
one `p` tag — makes presence enrichment untrusted: nothing is seeded
`offline`, so absence is never falsely inferred as offline. A failed,
timed-out, or non-array presence response is likewise untrusted. In
every untrusted case the successful profile sibling still contributes
its hints.
### Response-to-map conversion — `build_hint_map`
Sync production function taking the offline seed plus raw
presence/profile event slices. Relay-signed presence events carry the
agent pubkey in the `p` tag (not the event author); `build_hint_map`
calls `presence_subject` from `users.rs` (now `pub(crate)`) to resolve
the correct key.
### Hint display — `format_candidate`
Appends `[online/offline, profile updated YYYY-MM-DD]` (or subset) to
each candidate pubkey in the error. `profile_updated_at` names the field
correctly: kind:0 is replaceable state that desktop republishes on
rename and profile reconciliation, so `created_at` reflects the last
profile update, not provisioning time. Date formatted via
`chrono::DateTime::from_timestamp`; out-of-range timestamps omit the
date. Missing entries fall back to bare pubkeys.
### Cardinality rule
`apply_cardinality_rule` remains pure and relay-free; zero/one/many
semantics unchanged.
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
96 lines
3.1 KiB
TOML
96 lines
3.1 KiB
TOML
[package]
|
|
name = "buzz-cli"
|
|
version.workspace = true
|
|
edition.workspace = true
|
|
rust-version.workspace = true
|
|
license.workspace = true
|
|
repository.workspace = true
|
|
description = "Agent-first CLI for Buzz relay"
|
|
|
|
[lib]
|
|
name = "buzz_cli"
|
|
path = "src/lib.rs"
|
|
|
|
[[bin]]
|
|
name = "buzz"
|
|
path = "src/main.rs"
|
|
|
|
[dependencies]
|
|
# CLI argument parsing — derive macros + env var support (BUZZ_API_TOKEN auto-wired)
|
|
clap = { version = "4", features = ["derive", "env"] }
|
|
|
|
# HTTP client — async REST calls to the relay
|
|
reqwest = { workspace = true, features = ["json"] }
|
|
|
|
# Async runtime — tokio macros + multi-thread for reqwest
|
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
|
|
|
# Serialization — JSON body building and response passthrough
|
|
serde = { workspace = true }
|
|
serde_json = { workspace = true }
|
|
|
|
# Structured error types with exit code mapping
|
|
thiserror = { workspace = true }
|
|
|
|
# Nostr event signing — used in `buzz auth`, auto-mint, and signed event writes
|
|
nostr = { workspace = true }
|
|
|
|
# UUID parsing for validate_uuid + event building
|
|
uuid = { workspace = true }
|
|
|
|
# RFC3339 observer timestamps for owner-reviewed agent draft requests
|
|
chrono = { workspace = true }
|
|
|
|
# Typed event builders for all write operations
|
|
buzz-sdk = { workspace = true }
|
|
buzz-core = { workspace = true }
|
|
|
|
# Base64 encoding — NIP-98 event serialization for Authorization header
|
|
base64 = "0.22"
|
|
|
|
# SHA-256 — NIP-98 payload hash tag, Blossom file hash, mem patch base-hash
|
|
sha2 = "0.11"
|
|
|
|
# Unified-diff parser and strict applier — `mem patch`
|
|
diffy = "0.5"
|
|
|
|
# Hex encoding — SHA-256 hash output for Blossom uploads
|
|
hex = { workspace = true }
|
|
|
|
# Byte buffers returned by authenticated media downloads
|
|
bytes = "1"
|
|
|
|
# MIME type detection via magic bytes — file upload validation
|
|
infer = "0.19"
|
|
|
|
# URL parsing — extract server domain for Blossom auth tag
|
|
url = { workspace = true }
|
|
|
|
# Persona pack parsing, validation, and resolution
|
|
buzz-persona = { path = "../buzz-persona" }
|
|
|
|
# Platform app-data dir resolution — locates the desktop app's
|
|
# channel-templates.json store for `channels create --template`
|
|
dirs = "6"
|
|
|
|
# WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay)
|
|
buzz-ws-client = { path = "../buzz-ws-client" }
|
|
|
|
# Explicit rustls dep with ring provider — required to install the process-level
|
|
# CryptoProvider at startup. Without this the standalone `buzz` binary panics when
|
|
# a multi-package release build (buzz-acp + buzz-dev-mcp + buzz-cli in one cargo
|
|
# invocation) unifies both ring and aws-lc-rs features, leaving rustls unable to
|
|
# auto-select a provider. See crates/buzz-acp/Cargo.toml for the same dependency.
|
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
|
|
|
# Random number generation — full jitter for exponential backoff in with_retry
|
|
rand = { workspace = true }
|
|
|
|
[dev-dependencies]
|
|
# Scratch files for channel-templates.json fixtures in tests
|
|
tempfile = "3"
|
|
# Minimal HTTP test server for retry/policy integration tests
|
|
axum = { workspace = true }
|
|
# `test-util` enables paused-time control for deterministic timeout tests
|
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] }
|