Commit Graph

21 Commits

Author SHA1 Message Date
Will Pfleger
65e864301d feat: send a User-Agent on relay WebSocket upgrades (#9246)
Relay WebSocket clients in this repo now send a `User-Agent` of the form
`<name>/<version>` on the upgrade request. The version comes from the
build.

| Client | User-Agent |
|---|---|
| `buzz-acp` | `buzz-acp/<crate version>` |
| `buzz-cli` | `buzz-cli/<crate version>` |
| `buzz-ws-client` (default) | `buzz-ws-client/<crate version>` |
| `buzz-pairing-cli` | `buzz-pairing-cli/<crate version>` |
| `buzz-audio-client` (default) | `buzz-audio-client/<crate version>` |
| mobile (relay and huddle sockets) | `Buzz-Mobile/<app version>` |

- **`buzz-ws-client`**: every connect path sends the default. A
`User-Agent` in the caller's `connect_with_headers` headers replaces it,
so other consumers can identify themselves without any new API.
`buzz-cli` uses this to send its own name.
- **`buzz-audio-client`**: keeps a `User-Agent` already on the
host-supplied request, and adds its default otherwise.
- **Mobile**: `openRelayWebSocket` sets the HTTP client's `userAgent`,
which also covers the huddle transport. Startup waits up to 500 ms for
the app version from `package_info_plus` before the app runs, so the
first socket carries it; if the lookup fails or times out, the value is
`Buzz-Mobile`.

Each client has a test asserting the header on the upgrade request.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-11-08 20:43:55 -05:00
Will Pfleger
c8d736642e fix(cli): clearer desktop pairing errors, paired default relay, and auth desktop list (#9196)
Follow-ups to the CLI's Buzz desktop credential support, for a person
running the CLI with a desktop pairing.

- **The "no credentials" error names the relay.** It says which relay
the CLI tried and which relays this CLI is paired for, and tells you to
pass `--relay`. When a pairing exists for that relay but no running
desktop accepts it, it says so instead.
- **Default relay for a paired person.** With no `--relay`, no
`BUZZ_RELAY_URL`, no inherited agent credential and no explicit key, the
CLI uses the one relay that a running desktop still accepts this CLI's
pairing for, confirmed by `describe` on a same-user connection. Stored
pairings alone never count. With zero or several such relays, the
default stays `http://localhost:3000`. A desktop's refusal fails the
command with its message. When another paired desktop's check fails (its
same-user check times out or can't confirm the owner, or it stays rate
limited), the command fails rather than pick a relay, even if one other
relay was accepted, since that desktop may accept a relay of its own; a
timeout or exhausted throttle fails with a retryable error. `auth
desktop unpair` never runs discovery, and `auth desktop pair` never asks
other desktops for their pairings, so one desktop's refusal can't block
pairing another. Help text updated.
- **`buzz auth desktop list`.** Lists each pairing's instance, relays
and pubkey, with `accepted`, `not accepted` (revoked or no longer
bound), `not running`, `unconfirmed`, `rate limited` or `refused` (the
last three with an `error`), using the same check as the default relay.
Desktops are asked at once, without waiting out throttling. It never
prints tokens, needs no relay, prints `[]` with no pairings, and
`--format compact` drops the pubkey.
- **Retryable same-user timeout.** When a paired desktop's same-user
check times out and nothing else serves, the command now fails with a
retryable `unavailable` error (exit 2) instead of "no credentials" (exit
3).
- **Regression tests** for the NIP-42 AUTH event naming the relay with
its trailing slash, for skipping a paired desktop whose user can't be
confirmed, without sending it anything, and for relay selection never
asking desktops for pairings with an explicit key, `auth desktop pair`
or `auth desktop unpair`, or over an inherited agent relay.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-11-06 19:11:49 +00:00
Will Pfleger
b329e05c5b 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>
2026-11-06 11:20:47 -05:00
Luke Tornquist
e3a0f3d59d feat(cli): send NIP-FI evidence via credential chain and Buzz desktop (#8999)
## Summary

Relays that enforce federated identity (NIP-FI) reject every protected
`buzz` CLI request with `401`. The CLI never sends
`Nostr-Federated-Identity`. This PR gives the CLI a credential chain,
lets it present NIP-FI evidence, and lets it take its identity from Buzz
desktop. Relays without NIP-FI behave exactly as before.

**Credential chain** (`crates/buzz-cli/src/credentials.rs`)
- Raw `Keys` are replaced by `Credentials`, which provide the public
key, signing through nostr's `NostrSigner`, NIP-44, and the NIP-OA auth
tag. NIP-FI evidence comes through `evidence(relay)`.
- The explicit key (`--private-key` / `BUZZ_PRIVATE_KEY`) is the first
link. A later link can add a signer that holds no local key (a desktop
provider, for example) without reshaping callers.
- Operations that need key material rather than a signature are
purpose-bound methods instead of a key accessor:
  - `engram_d_tag` (the NIP-AE HMAC over the conversation key)
- the relay-scoped GIF customer id (random and fixed for the process
when there is no local key)
- `buzz mem` now encrypts, decrypts and derives the `d` tag through the
provider. Invalid ciphertext skips an entry; a signer failure aborts, so
an unavailable credential never reads as empty memory.
`buzz_core::engram` gains the composable steps behind it: `encode_body`,
`event_builder`, `validate_envelope` and `check_d_tag`. `build_event`
and `validate_and_decrypt` are unchanged wrappers, so `buzz-acp` and
desktop callers don't move.
- Agent-management drafts (`agent_management.rs`) encrypt and sign
through the provider.

**Evidence on every protected request**
(`crates/buzz-cli/src/client.rs`)
- A single `authorize` helper gets the NIP-FI assertion first, then
signs the proof just before sending, so a slow mint cannot age it past
NIP-98's 60 s window. It sets these on every protected route:
`Authorization` (NIP-98 or Blossom kind:24242), `x-auth-tag`, and
`Nostr-Federated-Identity: Bearer …`. The routes are `/query`, `/count`,
`/events` (stored and moderation), `/upload`, `/media/upload`,
`/media/*`, `get_authed`, `post_json_authed` and
`post_json_once_authed`.
- Exempt routes (`get_public`) are unchanged.
- The WebSocket publish sends the header on the upgrade via
`connect_authenticated_with_headers`. NIP-42 AUTH signs through the
signer (`buzz-ws-client` now takes `NostrSigner`; `&Keys` callers
compile unchanged).
- An upgrade denial is an HTTP response under NIP-FI.
`WsClientError::upgrade_rejection` returns its status and body, which
map like any other relay error, so a `403` still says whether evidence
or authorization was refused. A denial after NIP-42 (such as a key
mismatch) is a 1008 close; `WsClientError::PolicyClose` keeps its reason
and the CLI reports it as an `auth_error`.
- The WebSocket publish caps each network phase as a whole, writes
included: connect, AUTH challenge (with any Pongs), AUTH send plus OK,
EVENT send plus OK, and a best-effort close. NIP-42 signing between
phases is bounded by the signer. The relay drops a socket not signed in
within 5 s, so a sign-in that is throttled or comes back after the
socket closed reconnects and signs the new challenge, at most 3
connections. Each connection fetches current NIP-FI evidence for its
upgrade, and a throttled sign-in is waited out between connections, so a
dead challenge is never signed. EVENT is signed before connecting and
sent once, after AUTH succeeds. Running out is `unavailable` (exit 2,
`retryable:true`).
- Relay traffic follows no redirects: reqwest strips `Authorization`
across origins but not `x-auth-tag` or `Nostr-Federated-Identity`, so a
3xx is returned as a relay error. Credential header values are marked
sensitive.

**Headless evidence source**
(`crates/buzz-cli/src/federated_identity.rs`)
- Set `BUZZ_FEDERATED_IDENTITY_TOKEN` (your own NIP-FA sign-in session:
a human session for your key, not an agent grant) and
`BUZZ_FEDERATED_IDENTITY_ADAPTER` (the adapter base) to enable it. Both
are env only: command lines are visible to other local users.
- The CLI requests an assertion and the adapter mints it, through `POST
{adapter}/v1/identity/assertions` with a NIP-98 proof in
`Nostr-Authorization` (NIP-FA §Relay assertion).
- It checks the returned pubkey and expiry, parses the assertion as a
compact JWS (three base64url segments, JSON header with `alg`, JSON
payload), and requires its `exp` to equal `expires_at`. It caches one
assertion per process, bound to the relay when credentials are resolved,
and re-mints at the earlier of half-life and 30 s before expiry; the
desktop link caches and refreshes its assertion by the same rule.
Concurrent callers share one mint. An HTTP proof is signed again, after
current evidence, when the assertion changed while it was signed or the
evidence check aged it 30 s or more, up to 3 rounds, then fails as
`unavailable` (retryable).
- Compared with the separate rules before: a 300 s assertion now
refreshes at 150 s (was 270 s) on both links, a 40 s adapter assertion
at 10 s (was 20 s), and a 20 s desktop assertion is kept for 10 s
instead of fetched on every lookup. Desktop assertions now need a
readable JWS header with `alg` and a non-empty signature segment. An
unsupported headless relay URL now fails when credentials are resolved.
- The token goes only to the adapter origin (https, or http on
loopback), never follows a redirect, and never appears in errors or
`Debug`.
- A mint retries network errors, `429` and any `5xx` up to three
attempts with jittered backoff (or the adapter's `Retry-After` on
`429`/`503`, as delta-seconds or any HTTP-date form, capped at 30 s), on
every path (reads, post-once, moderation, stored events, WebSocket). It
then fails as `CliError::Adapter` (`adapter_error`, exit 2,
`retryable:true`). Relay retry loops never repeat it. A stored write
keeps `delivery_unknown` when any earlier attempt may have been
delivered, so a later adapter failure never invites a re-sign. Relay
mutation retries are otherwise unchanged.
- An explicit port 80 relay URL (read numerically from the authority, on
any scheme) is a usage error: `wss://host` means port 443, and NIP-FA
has no relay on port 80.
- A refusal is never retried: a non-transient status with an unreadable
or truncated body is still a refusal.
- Each NIP-FA denial code maps to an `auth_error` with a specific
message.
- These settings belong to the explicit key: without one they are not
read, and when the desktop link wins a warning names them.
- If an enforcing relay answers `401` and no assertion was sent, the CLI
returns an `auth_error` that names both settings, or, for a desktop
user, says to sign in to the community in Buzz. A desktop user's `403`
hint no longer mentions `BUZZ_AUTH_TAG`.

**What got simpler**
- About ten request sites built `Authorization` and `x-auth-tag`
themselves. Five copies of the 401/403 error-mapping block are now one
`relay_error`.
- The `sign_nip98` / `sign_blossom_*` helpers are gone. Proofs are a
three-case `Proof` enum consumed by `authorize`.
- `BuzzClient::keys()` (raw secret access from every command) is
removed. Commands use `public_key()`, `sign_event` or a purpose-bound
credentials method.

**Desktop credential provider**
(`crates/buzz-cli/src/desktop_provider.rs`,
`docs/local-credential-provider.md`)

Buzz desktop is a credential provider in the chain, after an explicit
key:

1. An inherited `BUZZ_CREDENTIAL_PROVIDER` token (set by the desktop for
the agents it launches). It decides the identity: when it is revoked,
not bound to the relay or unreachable, the command fails. A paired
token, which acts as the human, is never tried. That keeps a
well-behaved CLI from acting as the owner by accident; it is not a
boundary against an agent that can run its own commands as the owner's
OS user, and the contract says so with the mitigations.
2. Otherwise, a paired token stored in the OS keychain (`keyring`; macOS
native keychain, Linux Secret Service; no file fallback). A desktop that
answers `denied` or `unavailable` fails the command; only an unreachable
or unconfirmed advertisement is skipped. Revoked entries are forgotten.

New commands: `buzz auth desktop pair` and `buzz auth desktop unpair`.
The help no longer marks `BUZZ_PRIVATE_KEY` as required.

The CLI talks to the desktop over the loopback contract in
`docs/local-credential-provider.md` (NIP-46-shaped envelopes,
`Buzz-Credential-Provider` version header, same-user check on the
listening socket for paired and inherited tokens, via `/usr/sbin/lsof`
on macOS, given 5 s; a check that runs out of time is a retryable
`unavailable`, a wrong owner is refused). Every response's `id` must
match its request, error envelopes included. Paired tokens live only in
the keychain, survive desktop restarts, and are harness-neutral for
agents.

- **Relay binding:** tokens bind the whole relay URL (`http`/`https`
read as `ws`/`wss`, lowercase host, no default port, `/` for an empty
path; path and query count). The CLI sends that URL in `pair` and
`federated_identity`; NIP-FA audience canonicalization is the desktop's.
- **Rate limits:** a `rate_limited` answer refuses before acting, so the
CLI re-sends after `retry_after` (capped at 30 s), up to four times,
except a NIP-42 sign-in, whose wait goes to the WebSocket reconnect. A
re-sent NIP-42, NIP-98 or Blossom proof is re-dated (new id, Blossom
`expiration` moved with it); content events are re-sent exactly. If it
is still throttled, the command fails as `rate_limited` (exit 2,
`retryable:true`), not `auth_error`, whether the throttled call was
NIP-98, event signing, NIP-42 or encryption. Transport failures are
never re-sent, and relay commands are never replayed. A `buzz mem` list
that is throttled partway through completes.
- **Memory:** a `nip44_decrypt` answered with `invalid_request` is
invalid ciphertext, so `buzz mem` skips that entry (NIP-AE filtering);
any other provider failure aborts.
- **Keychain changes:** each change reloads the map under a short
`flock` on `~/.buzz-foundation/cli-pairings.lock` (directory forced to
`0700`, lock opened with `O_NOFOLLOW`) and changes only its own
instance; the lock is never held across the approval wait. An unreadable
entry is refused, never overwritten; `unpair` with no instance clears
it.

### Related

Related:
[block/buzz-app#2260](https://github.com/block/buzz-app/pull/2260) (the
desktop side of the provider contract)

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-11-06 00:26:22 +00:00
John Matthew Tennant
f9ef721e68 fix(media): keep transparency in image thumbnails with WebP (#9003)
## Summary

This fixes transparent images showing a black background. Image
thumbnails were always JPEG, which can't store transparency, so a
transparent PNG or GIF upload, such as an agent avatar, rendered on
black wherever a client showed its thumbnail.

The relay now writes a lossy WebP thumbnail at `{sha256}.thumb.webp`,
which keeps transparency, alongside the existing `{sha256}.thumb.jpg`:

- **Encoding:** the WebP comes from libwebp through the `webp` crate,
built from vendored C source, so the relay needs no system library. A
GIF uses its first frame.
- **Upload responses:** the upload descriptor's `thumb`, and its
per-tenant rewrite, point to the WebP.
- **JPEG kept:** installed clients derive `.thumb.jpg` themselves, so
the relay keeps writing it until clients move to WebP with a JPEG
fallback. Existing images still have only their JPEG thumbnail.
- **Serving:** `/media/` serves both variants, each with its own content
type. Other `.thumb.*` extensions still return 404.
- **imeta:** a `thumb` may name either variant, and the relay checks
that the named object exists.
- **Storage sweep:** the bucket taxonomy counts both variants as the
image's thumbnail bytes instead of classifying `.thumb.webp` as unknown.

| 320px thumbnail | JPEG | PNG | lossy WebP (q80) |
|---|---|---|---|
| Transparent avatar (starter-team `fizz.png`) | 11.3 KB, black
background | 65.1 KB | 13.8 KB |
| Opaque avatar (`sprout-agent.png`) | 23.6 KB | 156.5 KB | 12.3 KB |
| Screenshot (`channel-thread.png`) | 13.4 KB | 62.5 KB | 6.4 KB |

Encoding a WebP thumbnail takes 3 to 20 ms.

### Related issue

Supersedes #8772, which served a PNG at the `.thumb.jpg` path.

### Testing

Red/green against a local relay on `:3000`, built from the branch under
test, with Postgres, Redis and MinIO from `docker compose`:

```bash
cargo test -p buzz-test-client --test e2e_media test_transparent_png_thumbnail_is_webp_with_alpha -- --ignored
cargo test -p buzz-test-client --test e2e_media_extended test_ws_imeta_webp_thumb -- --ignored
```

- **On `main`:** both tests fail. The descriptor's `thumb` is
`.thumb.jpg`, and the WebSocket send is rejected with `invalid: imeta
thumb must be a local .thumb.jpg path`.
- **On this branch:** both tests pass:
- Uploading a half-transparent PNG returns a `thumb` ending
`.thumb.webp`.
  - HEAD and GET on that URL return `Content-Type: image/webp`.
- The decoded thumbnail keeps alpha 0 on the transparent half and alpha
255 on the opaque half.
  - `.thumb.jpg` still returns `image/jpeg`.
- A kind 9 message whose imeta `thumb` names the `.thumb.webp` is
accepted.

## Screenshots

The starter-team `fizz.png` avatar uploaded to a relay built from this
branch. Both thumbnails are shown as round avatars beside the original
PNG. `.thumb.jpg` fills the transparent area with a solid disc, and
`.thumb.webp` keeps the transparency.

![Transparent avatar thumbnails: .thumb.jpg, .thumb.webp and the
original PNG on light and dark
backgrounds](https://raw.githubusercontent.com/block/buzz/9ee1c633d8158ba401efcf738eb5676070ebd9cf/pr-9003--transparent-avatar-thumbnails.png)

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

---------

Signed-off-by: John Matthew Tennant <jtennant@squareup.com>
Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:11:00 -05:00
Bradley Axen
6c388bde62 feat(relay): accept typing indicators over HTTP POST /events (#8207)
## Summary

Agents hosted inside the Buzz app sign their own events but don't hold a
WebSocket, so they couldn't show "typing…" in a channel. `POST /events`
rejected kind 20002 (typing indicator) with `restricted: unknown event
kind`.

Now, when `POST /events` receives a kind 20002 event, the bridge passes
it to the same channel broadcast the WebSocket path uses. Live
subscribers in the channel get the pulse. Nothing is written to
Postgres.

Before broadcasting, the relay checks, in order:
- the event has an `h` tag naming the channel
- the serialized event is at most 2 KiB (real typing events are ~600
bytes). Measuring the whole JSON, not just content and tag values, means
piling on empty tags can't get around the cap
- the signature and id are valid
- bans and timeouts
- the community write fence (over HTTP, a quiescing community's host
already fails to bind with a 404, so this catches quiescing that starts
after the tenant is bound)
- the sender is a channel member, or the channel is open (same rule as
WebSocket)
- the signer is the pubkey that authenticated the request (checked
first; a mismatch returns 403)

The existing HTTP admission rate limit and relay-membership gate still
run before any of this. Other ephemeral kinds are still rejected over
HTTP. `publish_channel_ephemeral` is the old WebSocket channel-broadcast
code moved into a shared helper, so both paths use the same membership
check and fan-out.

Unlike the WebSocket path, HTTP requires the `h` tag. Over WebSocket, a
20002 with no channel goes to every subscriber.

Points left for review:
- HTTP typing pulses count against the same per-pubkey HTTP quota as
message posts (default 300/min). One pulse every 3 s is 20/min per
active channel.
- A failed Redis publish is logged and the POST still returns `accepted:
true`, same as the WebSocket path. A pulse is resent every 3 s and
expires after 8 s, so a retry would only resend a stale pulse.

The `sadscan:disable` comment on an existing test URL in `ingest.rs` is
there because the secret scanner reads every file a commit touches. It
uses the same form as the other local test fixtures in the repo.

### Related issue

Needed for Agents2 typing indicators in buzz-app. The client PR depends
on this one and will follow as a draft.

### Testing

- 9 new Postgres-backed tests in `api/typing_postgres_tests.rs` cover
these cases: a member's pulse is published and not stored; a non-member
gets a 400; a timed-out member gets a 403; a signer mismatch gets a 403;
a missing `h` tag gets a 400; a tampered event gets a 400; kind 20001 is
still rejected; oversized events get a 400 (2 KiB of content, one 2 KiB
tag value, or `h` + 10k empty tags); a community that starts quiescing
after the tenant is bound gets a 400 `community writes are fenced`, and
nothing is published. Two mutation checks: with the fence removed, the
fence test fails; with the old content-plus-tag-values cap, the
empty-tags case is accepted and the test fails.
- The full buzz-relay Postgres lane on 27b5a294: 386/387 pass. The one
failure is
`boot_lifecycle::hanging_redis_peer_times_out_before_the_health_listener_binds`:
it went over its timing slack under full-suite load (8.03 s) and passed
when rerun alone.
- buzz-relay unit tests pass (1476). One mesh demo test timed out once
under load and passed when rerun alone.
- `cargo clippy -p buzz-relay -p buzz-cli -p buzz-acp --all-targets -D
warnings` is clean.
- Live test against a local relay built from this branch (27b5a294), run
by ART at baxen's request as the sign-off test. alice subscribed over
WebSocket to kind 20002 in a private channel; bob (a bot member with no
WebSocket) and carol (outsider) used `POST /events` with NIP-98. 16/16
checks passed:
- bob's top-level pulse → 200 `accepted: true`, and alice received it
live
- bob's thread pulse (`h` + root `e` + reply `e`, the buzz-acp shape) →
delivered with tags intact
- 2 KiB content, a 2 KiB tag value, or `h` + 10k empty tags → 400
`typing indicator too large`; alice received nothing
- carol → 400 `restricted: not a channel member`; alice received nothing
  - carol posting an event signed by bob → 403
  - no `h` tag → 400; kind 20001 → 400 `only accepted via WebSocket`
  - 0 kind-20002 rows in Postgres afterwards

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

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
2026-10-09 22:44:31 +00:00
Will Pfleger
f7ff4294fa feat(nip-fi): harden Blossom kind-24242 verifier to NIP-FI spec (#7288)
Brings `buzz-media`, `buzz-relay`, all first-party kind-24242 minters,
and desktop token minting into compliance with [NIP-FI
§kind-24242](https://github.com/block/buzz/blob/main/docs/nips/NIP-FI.md).

## What changed

**`buzz-media/src/auth.rs`**
- Add `BlossomStrictness` enum (`Strict` | `Permissive`). `Strict`
applies full NIP-FI rules (active modes); `Permissive` preserves
pre-NIP-FI Off-mode behavior byte-identical [FI-INV-15].
- Rewrite `verify_blossom_auth_event_for_verb` with count-based
cardinality: exactly one `t`/`expiration`/`server` in `Strict`;
duplicate `x` also rejected. `Permissive` keeps main's boolean semantics
exactly: any valued `t` other than the verb (including `""`) rejects,
valueless `t` is ignored.
- `Strict`: mandatory `server` tag on all proofs (upload + read); absent
or mismatched → `evidence_rejected` (`ServerMismatch`).
- `Strict`: 60-second proof window (`now - created_at <= 60s`,
`expiration <= created_at + 60s`). `Permissive`: 3600s / optional
server.
- **[F1]** Permissive server validation uses any-match semantics: a
proof with multiple `server` tags is accepted if any tag matches the
bound host. This restores origin/main base behavior. Strict
duplicate-rejection unchanged.
- **[F1 compat]** Permissive server gate uses `server_values.is_empty()`
(valued tags only) rather than `server_count > 0` (includes valueless
tags). A lone valueless `["server"]` tag is treated as absent in
Permissive/Off mode, matching pre-NIP-FI base behavior [FI-INV-15].
- **[F4]** Valueless `x` tags in Strict get reads are tracked before
`filter_map` so they cannot become absent scope (host-wide
authorization). Any `x` tag present in Strict must contain the exact
requested sha256.
- **[F3 unit]** Cases A–D in `auth.rs` are helper-level unit tests: they
call `verify_blossom_upload_auth` and `verify_upload_hash_only`
directly. They do not enter the upload pipeline
(`process_upload`/`process_video_upload`). Retained as helper evidence.

**`buzz-media/src/error.rs`**
- Add `DuplicateTag(&'static str)` variant.
- Add `blossom_denial_kind()` method: classifies each auth error as
`MissingEvidence`, `EvidenceRejected`, or `AuthorizationDenied`.
Non-auth errors return `None`.
- `IntoResponse` retains the legacy JSON 401 shape [FI-INV-15].

**`buzz-relay/src/api/media.rs`**
- Add `MediaDenial(MediaError, BlossomStrictness)` wrapper mapping to
byte-exact NIP-FI fixed responses in `Strict`; falls through to legacy
JSON in `Permissive`.
- `blossom_strictness_from_state()` selects `Strict` when
`state.config.nip_fi.is_enforce()`, otherwise `Permissive`. Strictness
is threaded through the `admit_nip_fi_http_on_state` NIP-98 closures on
upload and read, and through the post-admission X-SHA-256, hash-binding,
and membership denials.
- `extract_blossom_auth` keeps first-value `Authorization` extraction;
Enforce rejects repeated fields at the `admit_nip_fi_http` cardinality
gate, and Off keeps legacy behavior [FI-INV-15].
- Both membership-denial call sites use
`media_denial(RelayMembershipRequired, strictness)` so Strict mode
produces the correct fixed-text response.
- `upload_blob` serving-write fence conversion (`:406`) uses
`map_err(|e| media_denial(e, strictness))` — a community fence committed
between auth admission and lease acquisition no longer resets Strict to
Permissive.
- Mode-driven regressions in `mod postgres_tests` (selected by the
nextest `postgres-ci` profile via `test(/postgres_tests::/)`, `#[ignore
= "requires Postgres"]`), run through the full relay router with a real
Enforce verifier and assertion: Enforce rejects Permissive-only proofs
(300s lifetime, missing `server` tag) on both upload routes and reads
with `evidence rejected`; Off accepts the same proofs with pre-NIP-FI
responses; DenyProtected answers 503 before any proof check; membership
denials are `authorization denied` text/plain in Enforce and exact
legacy JSON 403 in Off, with a member positive control. GET and HEAD are
each discriminated (HEAD with a suppressed body). Both upload routes pin
the handler's `X-SHA-256` missing/malformed/unsigned and post-body
hash-mismatch denials in both modes. Off rejects an empty-valued `t`
beside a valid one with legacy 401 JSON in either order, and ignores a
valueless one. Exact status, Content-Type, challenge, and body bytes are
pinned.

**`buzz-media/src/upload.rs`** (`mod minio_tests`)
- **[F3 pipeline]** Four tests exercising the actual post-body call
sites (`process_upload` `:85`, `process_video_upload` `:413`) with real
MinIO I/O: buffered/streaming × expired-proof-accept +
mismatched-hash-reject. The positive cases use
`fresh_strict_upload_auth` (`created_at = now-58`, `expiration = now+2`,
lifetime = 60s) — Strict-admissible at sign time — followed by a real 3s
sleep so the proof is expired when the upload completes.
`nostr::Timestamp::now()` reads the OS wall clock directly; paused Tokio
time does not advance it. The negative cases use a pre-expired proof
with a mismatched `x` tag. All four are `#[ignore = "requires MinIO"]`;
no CI lane runs them automatically.

**`buzz-media/src/validation.rs`**
- `minimal_valid_mp4()` promoted to `#[cfg(test)] pub(crate)` — a
618-byte pre-computed H.264/avc1 fast-start MP4 available to all
`buzz-media` test modules; smoke-tested by
`test_minimal_valid_mp4_passes_validate_video_file`.

**`desktop/src-tauri/src/commands/media.rs`**
- `sign_blossom_upload_auth`: `server` tag mandatory; upload expiry 60s
(was 3600s/300s).
- `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s).

**`desktop/src-tauri/src/media_proxy.rs`**
- Single re-mint+retry on 401/403 for range requests; non-auth failures
do not retry.

**`crates/buzz-cli/src/client.rs`**
- Read expiry: 60s (was 600s). Upload expiry: 60s. `server` tag
mandatory on upload.

**`crates/buzz-dev-mcp/src/view_image.rs`**
- `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s). Test updated.

**`mobile/lib/shared/relay/media_auth.dart`**
- `_mediaGetAuthLifetimeSeconds`: 60 (was 600). Refresh margin updated.

**`mobile/lib/shared/relay/media_upload.dart`**
- `_uploadAuthLifetimeSeconds`: 60 (was 300). `server` tag mandatory.

**`mobile/lib/features/channels/media_viewer_page/video_viewer.dart`**
- **[F2]** Remove Android-specific `VideoPlayerController.networkUrl`
streaming path. `video_player_android` 2.9.5 freezes headers into static
`DefaultHttpDataSource` request properties — a 60s proof minted at
controller creation time becomes stale on seeks. All platforms use the
authenticated local-file download path.
- **[F2 transport]** `unawaited(request.sink.close())` before
`client.send()`. `AbortableStreamedRequest` carries no request body;
without closing the sink, `IOClient.send()` awaits
`stream.pipe(ioRequest)` which blocks until the sink ends — every video
download hung in loading indefinitely on every platform.
- **[F2 pending-ctrl]** `pendingController` ref set before the first
`await (initialize())`, cleared in all exit paths. Effect cleanup
disposes `pendingController.value` so a close-during-init releases the
native player even if the initialized event never arrives.
- **[F2r(a)]** On native init failure (post-create,
pre-initialized-event), the inner catch starts disposal detached via
`unawaited(localController.dispose().catchError(...))` — the
`.catchError` handler absorbs any disposal exception so it does not
reach the test error zone — then rethrows the original load error.
`video_player` 2.11.1 completes the init future with an error without
disposing the native player.
- **[F2r(b)]** Non-2xx error body cancelled via `_cancelVideoResponse()`
(subscribe+cancel) instead of `drain()`. `drain()` waits for the
upstream to close; a stalled server body blocked `initializeVideo`
indefinitely.
- **[F2r(d)]** `unawaited(localController.dispose().catchError(...))` in
the catch — not `await`. Two failure mechanisms exist: (1) when
`createWithOptions()` throws, `_creatingCompleter` is never completed
and `await dispose()` deadlocks at `:682-683`, blocking the outer catch
from setting error state. (2) when `createWithOptions()` succeeds but
initialization fails, `dispose()` runs but its thrown exception becomes
an unhandled async error reaching the test zone. Both are fixed:
`unawaited` lets the outer catch run immediately and set `error.value`;
`.catchError(...)` on the detached disposal future absorbs any disposal
exception.


**`mobile/test/features/channels/media_viewer_page/video_viewer_test.dart`**
- `_FinalizingFakeClient`: calls `request.finalize().drain()` — a test
without the sink fix times out because the drain never completes,
validating the transport contract.
- F2r(a): `forceInitError=true` fake emits `PlatformException` on
subscribe; `disposeCallCount >= 1` verifies disposal even on init
failure.
- F2r(b): `stalledBody` `StreamController` with `onCancel` Completer
(bounded completion signal, not a sleep). Asserts: (1)
`bodyStreamCancelled=true` while body stream is still open (impossible
with `drain()`); (2) error UI visible while body remains open. Restoring
`drain()` breaks both.
- F2r(c): `neverInitialize=true` fake never sends initialized event;
unmount asserts `disposeCallCount >= 1` via `pendingController`.
Distinct from the event-error case.
- F2r(d): `forceCreateError=true` fake throws `PlatformException` from
`createWithOptions()` itself (before any player ID is returned). Asserts
error UI visible within a bounded deadline. With the old `await
dispose()` the outer catch is blocked — `error.value` is never set and
the error UI assertion fails at the deadline; with
`unawaited(dispose())` the error text appears immediately.


**`mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart`**
(separate file)
- Real-IO loopback probes using `flutter_test` (not `package:test`) to
avoid `depend_on_referenced_packages` lint. No
`TestWidgetsFlutterBinding` — no HttpClient override interfering with
`dart:io`.
- Probe 1: `HttpServer.bind(loopbackIPv4, 0)` reads the full request
body before replying. Without `sink.close()`, `req.drain()` hangs and
the test times out.
- Probe 2: server signals request arrival via a Completer (not a sleep);
abort fires after confirmed arrival; `client.send()` must throw
`RequestAbortedException` (typed assertion) within a bounded 5s
deadline.

**`scripts/test-video-upload.sh`**
- Proof minted with `+60` expiry and mandatory `server` tag.

**`buzz-test-client/tests/e2e_media*.rs`** +
**`buzz-relay/src/api/media.rs` test fixtures**
- All fixtures updated to 55s expiry + `server` tag.

**`RELEASING.md`**
- New "NIP-FI upload proof freshness (relay floor)" section documenting
that 60s proofs require the first relay release carrying this PR's
post-body hash-only check (the first tag > `v0.2.1` — update once cut)
for reliable large-file uploads. Operators on older self-hosted relays
(≤ `v0.2.1`) must upgrade before receiving these clients.

## Finding 3: 60s upload proofs vs old-relay compatibility

Desktop/CLI/mobile upload proofs are now 60s. Older relays re-run full
expiry verification after the body is transferred. A valid upload whose
body transfer takes >60s will fail on an old relay.

**Resolution (relay floor prerequisite):** `RELEASING.md` requires that
verified supported deployments (relay releases confirmed to carry the
post-body hash-only check, commit `75e9bef748`) be in place before
distributing 60s-proof client builds. Unknown or unverified self-hosted
relay deployments are excluded from distribution until they upgrade.
This makes the verified-deployment status a prerequisite for release
distribution, not a post-distribution advisory. The concrete tag (`>
v0.2.1`) is to be recorded once cut.

## Sequencing note

Builds on #7264 (merged): Blossom strictness is derived from its
`config.nip_fi` mode in this PR, so enabling `BUZZ_NIP_FI_MODE=enforce`
activates the strict verifier with no further wiring.

## GIF compatibility exception

An unauthenticated Off-mode request on a configured tenant with no GIF
provider changes from 404 to 401 (base `gifs.rs:269-275` checked an
unconfigured provider first; the current code authenticates first). All
other Off-mode Blossom responses are byte-identical to pre-NIP-FI
behavior.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-28 14:46:56 -04:00
Wes
6146c4fd16 feat(git): add default-branch management to relay and CLI (#7562)
Authored by Brain and opened on behalf of Wes (`wesbillman`).

## Summary

Add `buzz repos default-branch get/set` and the relay operation it
needs. Git push changes branch tips but cannot select the server's
symbolic HEAD. This selects an existing branch without deleting
branches, moving refs, or changing packs.

- Reuse immutable Git manifests and captured-ETag CAS; concurrent
pushes/default changes conflict, including stale no-ops.
- Require request-specific NIP-98, body binding, fail-closed replay
protection, host tenancy, current channel membership and
repository-management authority. Push/project roles alone are
insufficient.
- Send the CLI mutation once, without redirects; ambiguous delivery
retains the observed digest and is non-retryable.
- Document the narrow HTTP exception to Nostr-first, delegation scope
and admission-time ACL revocation. Kind:30618 remains derived; the
manifest CAS is authoritative.
- Register the Postgres/Redis/MinIO route and fresh-clone tests in the
existing Backend Integration lane.

## Review fixes — `7457bda50afa6b52e36315572fa96928b2e21db6`

- Reject signed valueless `payload` tags when verifying a body; preserve
the shared verifier's intentional absent-payload compatibility. The real
settings route now proves 401, unchanged manifest pointer, and unchanged
kind:30618 event IDs for malformed and other invalid credentials.
- Require a nonempty returned branch and `head == refs/heads/{branch}`;
SET additionally requires the returned branch to equal the attempted
branch. Malformed/mismatched mutation responses remain non-retryable
`DeliveryUnknown`, retaining the attempted branch and original digest.
Invalid reads fail before any POST.
- Strengthen the later-push/fresh-clone regression to select
`release/v1` while `main` exists.

## Verification

At `7457bda50afa6b52e36315572fa96928b2e21db6`:
- Full pre-push passed (all 14 Rust test groups, serialized Tauri
tests/clippy, file-size and branch-skew gates; 522 seconds). No hooks
disabled.
- All four isolated Postgres/Redis/MinIO settings tests pass, including
real Smart HTTP `ls-remote --symref` and fresh clone on `release/v1`.
- All 15 focused NIP-98 verifier tests and three default-branch CLI
tests pass; malformed credentials and missing-branch responses were
reproduced failing before the fixes.
- Five targeted mutants are killed: restoring the valueless-payload
bypass, weakening canonical HEAD validation, accepting an empty branch,
dropping requested-branch confirmation, and omitting hydrated HEAD for
the selected non-main branch. Mutations ran in a separate worktree and
target directory; original patch bytes were verified restored.
- `cargo clippy -p buzz-auth -p buzz-cli -p buzz-relay --all-targets --
-D warnings`, formatting, diff and file-size checks pass on the fixed
tree.

Earlier validation at `bb195b140`: seven Git authorization database
regressions and 28 existing transport tests passed (three infrastructure
cases in that module not selected); four mutations killed
management/replay/CAS/redirect regressions. Full pre-push passed with
`RUST_TEST_THREADS=1` (all 14 Rust groups, Tauri clippy and 3,173 tests,
size/branch-skew gates). Serial execution avoids an unrelated
process-global Desktop discovery counter flake; no hooks disabled or
Desktop changes.

Carl and Jude's review findings are addressed in the new head; **fresh
reviewer and exact-head security confirmation remain required**. The
earlier source review is not approval of this revision. No claim of
green new-head CI.

## Rollout

Merge and **deploy the relay support first**, then build/use the updated
CLI and explicitly set/verify the desired default. No schema migration.
This PR does not deploy anything or change a live repository.

Originating conversation (channel
`8e2fda80-9142-4df8-8e14-da288bea6471`):

buzz://message?channel=8e2fda80-9142-4df8-8e14-da288bea6471&id=8a7a9cf12737abc1e16605f241300be940a1691ef0992b35197afe26c563108d

---------

Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-09-11 09:10:43 -06:00
Will Pfleger
47d068e210 feat(cli): add buzz gifs command group and NIP-30 emoji tags on messages (#7259)
## What

Two new agent-facing capabilities in `buzz-cli`:

### 1. `buzz gifs` command group (agent KLIPY picker path)

Agents can now search and share GIFs via the relay's authenticated KLIPY
proxy without holding a provider credential.

```bash
buzz gifs search                         # trending GIFs
buzz gifs search --query "celebration"   # search GIFs
buzz gifs share --slug <slug>            # report selection to provider Recents
```

Output is a JSON array of GIF objects. Paste the `cdn_url` field
directly into `buzz messages send --content` — sending a GIF is a plain
message containing the CDN URL, no special send-path handling.

**Implementation details:**
- Gates on NIP-11 `supported_extensions` containing `buzz-gif` and
`gif.provider == "klipy"`
- Uses relay-relative paths from the NIP-11 `gif` descriptor — no
hardcoded paths; safe-path validation mirrors
`desktop/src/features/gifs/api.ts`
- New `post_json_authed` helper in `BuzzClient` handles NIP-98-signed
JSON POSTs and 204 No Content responses
- `customer_id` derived as `SHA-256(secret_key_bytes || '\0' ||
relay_url_bytes)[..16]` → 32 hex chars: stable, relay-scoped, not
computable from public data, no storage needed
- `locale` defaults to `$LANG` (stripped of encoding suffix) or `en_US`

### 2. NIP-30 custom emoji tags on outgoing messages

`buzz messages send` now automatically attaches `["emoji", shortcode,
url]` tags for any `:shortcode:` patterns in the content that resolve in
the workspace palette — identical to the desktop composer behavior.

```bash
buzz messages send --channel <uuid> --content "hello 👋 everyone 🎉"
# → event carries ["emoji", "wave", "..."] and ["emoji", "tada", "..."] tags
```

**Implementation details:**
- Hand-rolled single-pass scanner (no new dependency) implementing
`:([a-z0-9_-]+):` case-insensitively with canonical lowercase output —
mirrors `desktop/src/shared/lib/customEmojiTags.ts` exactly
- Zero extra relay round-trips when content contains no `:` character;
one `query` when candidates exist but none match
- Palette fetch reuses the existing `union_custom_emoji` logic from
`commands/emoji.rs`
- `build_message` in `buzz-sdk` gains a new `emoji_tags: &[Vec<String>]`
parameter (additive — all existing callers pass `&[]`); NIP-30 tag
attachment lives in the SDK alongside `imeta` tags
- MCP send path (`buzz-acp`) continues to pass `&[]` and is not
affected; the MCP gap is noted in a comment

## Files changed

| Crate | File | Change |
|-------|------|--------|
| `buzz-cli` | `src/commands/gifs.rs` | New — search + share handlers,
NIP-11 gating, tests |
| `buzz-cli` | `src/commands/mod.rs` | `pub mod gifs` |
| `buzz-cli` | `src/lib.rs` | `Gifs(GifsCmd)` variant, dispatch arm,
inventory test update |
| `buzz-cli` | `src/client.rs` | `post_json_authed` helper |
| `buzz-cli` | `src/commands/emoji.rs` | `scan_shortcodes` +
`resolve_emoji_tags_for_content` + tests |
| `buzz-cli` | `src/commands/messages.rs` | Emoji scan + tag injection
in `cmd_send_message` + seam tests |
| `buzz-cli` | `README.md` | `buzz gifs` section + emoji-in-messages
note |
| `buzz-sdk` | `src/builders.rs` | `build_message` gains `emoji_tags`
param + tests |
| `buzz-acp` | `src/pool.rs` | Update `build_message` call site (`&[]`)
|
| `buzz-acp` | `src/setup_mode.rs` | Update `build_message` call site
(`&[]`) |
| `countdown-bot` | `src/main.rs` | Update `build_message` call site
(`&[]`) |

Relates to: https://buzz.block.builderlab.xyz — buzz-team channel thread
on agent GIF/emoji support

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-02 20:03:38 -04:00
thomaspblock
de188ebf06 feat(projects): add agent and CLI project-home support (#6590)
## Summary
- inject bounded project-home identity and repository context into
managed agent sessions
- add project-aware CLI flows for creating projects, repositories,
issues, and related channels
- match project homes through the existing relay query surface, then
filter channel metadata client-side

This is Part 1 of the channel-first Projects stack. Part 2 contains
project creation and model foundations.

## Testing
- `cargo fmt --all -- --check`
- `cargo clippy -p buzz-cli -p buzz-acp --all-targets -- -D warnings`
- `cargo test -p buzz-cli -p buzz-acp` — 1,184 tests passed, 1 doc test
ignored
- full pre-push gate passed

## Post-Deploy Monitoring & Validation
- validate project-home agent context and project-aware CLI commands
against a staging relay
- healthy signals: project context matches the active channel, explicit
repo coordinates remain stable, and normal channels receive no project
block
- failure signals: cross-channel project context, duplicate project
creation, or commands targeting an unrelated repository; mitigate by
reverting this PR

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
2026-08-26 12:09:06 -07:00
Wes
583af02299 fix(cli): preserve signatures in event reads (#6884)
## Summary
- preserve string Nostr `sig` values in normal `messages get`, `messages
thread`, `messages search`, and `feed get` output
- omit absent or non-string signatures instead of fabricating empty
values
- keep compact output at exactly `{id, content, created_at}` and update
CLI/managed-agent contract docs

## Validation
- `cargo test -p buzz-cli --lib` (367 passed at final head
`a2498faa0e07f90efc0ab0165606ba3b048327c7`)
- release candidate live-relay matrix: 49/49 normal events independently
recomputed to their NIP-01 IDs and passed BIP-340 verification; all four
compact commands preserved exact three-key output and ordering
- `git diff origin/main...HEAD --check`
- independent adversarial review: clear, no findings

## Compatibility
The normal JSON change is additive for valid relay events. In-repo
consumers use tolerant field lookup. Compact output is unchanged and
locked by regressions for both message and feed formatting.

Generated by Carl, an automated contributor working with Wes.

---------

Signed-off-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
2026-08-26 09:13:11 -06:00
thomaspblock
a1d78f2959 feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues (#4695)
## Summary

Gives Buzz-hosted git entities the same "GitHub-style" chat experience
GitHub links already get: rich preview cards, real titles, and
click-through — except clicks navigate **in-app** to the Projects view
instead of a browser.

- **Spec**: `docs/buzz-entity-links.md` — link scheme, slices, and
deferred work (`buzz://project`, OS deep links, web routes).
- **Canonical `buzz://` deep links**: new
`desktop/src/shared/lib/entityLink.ts` with builders + strict parser for
`buzz://pr?id=…&owner=…&d=…`, `buzz://issue?…`, and
`buzz://repo?owner=…&d=…`, mirrored by a Rust module
(`crates/buzz-cli/src/links.rs`) with a shared golden-format test so the
two implementations can't drift.
- **Preview cards**: `linkPreview.ts` recognizes `buzz://` entity links
*and* HTTPS relay clone URLs (`{origin}/git/<pubkey>/<repo>`, the shape
agents paste today). Both normalize onto the canonical `buzz://` href,
so the two spellings of a repo dedupe to one `Buzz`-provider card
(`BuzzMark` logo) rendered by `link-preview-attachment.tsx`.
- **Title enrichment**: PR/issue cards fetch the real subject from the
relay event (`subject` tag or first content line) via
`useResolvedLinkPreviews.ts`; the cache is community-scoped and reset in
`resetCommunityState()`.
- **In-app navigation**: clicking a card or inline anchor (including
HTTPS relay clone URLs whose origin matches the active relay) routes to
the canonical `30617:<owner>:<d>` coordinate via `goProject()`
(`markdown/entityLinks.tsx`). **Merge dependency: #4671 must merge
first** — route resolution for `30617:` coordinates is implemented on
that branch (`feat/multi-repository-projects`). Entity-link and
external-anchor logic were extracted out of `markdown.tsx` to stay under
the file-size ratchet.
- **Agent side**: `buzz pr open`, `buzz issues create`, and `buzz repos
create` now return a ready-made `link` field (omitted when the relay
returns `accepted: false`), and `base_prompt.md` instructs agents to
paste it verbatim when announcing work.

## Test plan

- [x] Desktop unit tests: pass, including new `entityLink.test.mjs` and
`linkPreview.test.mjs` coverage (golden formats, malformed-link
rejection, clone-URL/`buzz://` dedupe, origin-gated anchor behavior,
label-must-win invariant, cache epoch)
- [x] Rust: `cargo test -p buzz-cli` golden-format test +
accepted/rejected link guard assertions, clippy + fmt clean
- [x] Biome + `tsc --noEmit` clean; pre-push hooks
(desktop-tauri-checks, rust-tests, desktop-test) pass
- [ ] Manual: paste a relay clone URL and a `buzz://pr` link in a
channel — verify one card each, real PR title, and in-app navigation to
the Projects view

Related: [#4671](https://github.com/block/buzz/pull/4671)

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-04 17:54:14 -04:00
Will Pfleger
7b25950fca fix(cli): retry transient relay failures and raise timeouts (#2196)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-07-21 12:17:44 -04:00
Will Pfleger
7d7992067b feat(cli): add agents archive/unarchive/archived subcommands (#2173) 2026-07-20 23:33:11 -04:00
Tyler
03fe19d603 fix(cli): paginate channel directory queries (#2181)
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
2026-07-20 19:22:11 -04:00
Tyler
5cfd69cb0c Strip media metadata on clients and reject it at the relay (#2006)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Atish Patel <atish@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
2026-07-17 13:39:10 -04:00
Tyler
f308762852 feat(media): require auth for relay media reads (#1926)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-15 21:06:04 -04:00
Tyler
863aeb79f3 Community moderation Phase 1: reports, bans/timeouts, audit, tombstones, relay-DM notices (#1616)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
2026-07-08 13:36:46 -04:00
Tyler
73cc31cc52 chore: remove LLM-slop comments across the codebase (#1277)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-25 11:03:05 -04:00
tlongwell-block
762ca43d26 fix(cli): use relay workflow id on create (#872)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
2026-06-18 10:46:44 -04:00
Will Pfleger
d99ad131f1 refactor: rename sprout backend to buzz (#958)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
2026-06-10 19:29:51 -04:00