Commit Graph

2773 Commits

Author SHA1 Message Date
Logan Johnson
d56ed75424 fix(acp): add edit field to native-steer fallback test event (#8022)
🤖
## Summary
The Rust test build on `main` is broken. Two PRs each passed on their
own, but together they do not compile: #4741 added a required `edit`
field to queued relay events, and #7568 (merged later) added a test that
builds a queued event without that field. Every Rust CI job (Unit Tests,
Rust Lint, Windows Rust) now fails on `main` and on any PR that rebases
onto it.

This adds `edit: None` to that one test event. `None` means "not an edit
of an earlier message", which is what the test intends. No production
code changes.

### Related issue
Breakage introduced by #7568 on top of #4741. Searched open PRs for an
existing fix: none found.

### Testing
- `cargo clippy -p buzz-acp --all-targets -- -D warnings` passes; `cargo
fmt --check` passes.
- CI on this PR is the authoritative test run.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-01 17:01:51 +00:00
Matthew Boston
036215636f Allow following channel messages before their first reply (#7692)
## Summary

Channel messages only offered Follow thread after receiving a reply. Add
Follow/Unfollow to delivered messages immediately, so users can opt into
future replies. Keep the same follow state when broadcast replies gain
replies of their own.

Threads you authored, participated in, or explicitly followed offer
Unfollow. A mention alone leaves Follow available. These controls apply
to channel timelines; DM notification behavior remains unchanged.

### Related issue

None found in existing issues or PRs.

### Testing

- Browser regressions cover keyboard follow/unfollow, persistence after
reload, first-reply unread and notification delivery, suppression after
unfollow, pending-message exclusion, broadcast reply transitions after
revealing buffered arrivals, and immediate unfollow for your own
messages.
- Additional regressions cover following after a mention and the absence
of Follow/Unfollow controls in DM timelines.
- Desktop unit tests, typecheck, lint, and production build pass.
- Browser notification routing is covered; native OS banner appearance
was not tested.

### Screenshots

| Before | After |
|--------|--------|
| <img width="979" height="301" alt="before"
src="https://github.com/user-attachments/assets/1a739187-5bc3-4e23-a334-723ca69672f5"
/> | <img width="979" height="337" alt="after"
src="https://github.com/user-attachments/assets/57df4ff9-bac4-4b9d-a39f-5664e0f9ece0"
/> |

---------

Signed-off-by: Matthew Boston <mboston@squareup.com>
2026-10-01 10:15:45 -06:00
klopez4212
0ee2ad7fbf Fix rounded macOS icon on older Macs (#8018)
Older macOS versions display Buzz’s opaque icon as an oversized square.
Bake rounded corners and transparent padding into the macOS `.icns`,
preserving the original bee artwork and leaving other platforms
unchanged.

Generation uses Hermit-pinned uv, Python 3.14.3, and Pillow 12.2.0.
`scripts/macos-icon.sh --check` regenerates temporarily and compares all
decoded representations; macOS CI runs it before packaging. Pixel
reproducibility is verified independently of Apple’s ICNS container
encoding.

Validated native AppKit preview, all 10 representations, fresh-cache
generation, one-pixel/missing-representation drift rejection, workflow
lint, and pre-push checks. Full local `just ci` is running. Packaged
Dock checks on older/current macOS remain outstanding.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-01 17:00:57 +01:00
Logan Johnson
514a9a86d3 fix(acp): interrupted frame writes close the sole writer; fixed native-steer refusal reasons (#7568)
🤖

## Summary

- **A partly written message can no longer corrupt the stream between
Buzz and an agent.** Buzz runs each coding agent as a child process and
sends it one JSON message per line over the agent's stdin pipe.
Previously, if such a write was cut off mid-line — a concurrent control
operation won the race, its deadline expired, the task was aborted, or
the pipe failed partway — the partial line stayed in the pipe and the
next message was written directly behind it, leaving a single corrupted
line on the agent's input that no parser can read. Now the connection's
single stdin writer is owned by the write itself: it is handed back only
after the whole line, its newline, and a flush have all completed. An
abandoned or failed write closes the pipe instead, so nothing can ever
append to a partial line — later operations get a clean broken-pipe
transport error, which the existing retry and retirement policy already
handles.
- Intentionally unchanged: cancelling a prompt whose message was fully
written works exactly as before, and an ordinary application-level error
reply from the agent (for example, a JSON-RPC "bad input" error) does
not close the transport — the next prompt reuses the same connection.
- **Refused mid-run steers now log why, in fixed words.** While an agent
task is running, Buzz can try to steer it — hand additional instructions
to the running task — and falls back to the existing cancel-and-merge
behavior when that is not possible. When a steer is refused before
anything is written to the agent, the log now records one fixed reason
instead of an opaque dump of the whole error: `task_absent` (no running
task owns the scope), `sender_absent` (the task has no steer channel
installed), `mailbox_full` (the one-slot steer mailbox already holds a
steer), or `mailbox_closed` (the task's steer receiver is gone).
Before-write refusals are now distinguishable from failures after a
steer was accepted, which keep carrying the underlying transport error.
Behavior is unchanged — same fallback, request ownership, and return
values; only the log field changes.

### Related issue

N/A

### Testing

- **19 new tests.** In `crates/buzz-acp/src/acp_frame_writer.rs` (8):
interrupting the write at the body or the newline boundary, or hitting
an I/O failure, must close the writer and make every later write fail; a
completed write keeps the writer; a real OS-pipe test interrupts a 300
KB prompt mid-body and asserts that cleanup and later requests fail with
no frame appended; another real-pipe test asserts a completed prompt
still cancels normally; an application-error reply leaves the connection
reusable. In `crates/buzz-acp/src/pool.rs` (7): each fixed refusal label
is produced by `send_steer` for its real cause, an admitted steer
reaches the read loop, the labels round-trip without drift, and
post-admission failures never classify as admission refusals. In
`crates/buzz-acp/src/lib.rs` (4): the real `try_native_steer` is driven
through each refusal branch with a real pool, queue, and signed event,
and the emitted fallback log is captured and pinned — the exact fixed
reason, exact message and channel, no request or error content, and the
unchanged fallback return; forcing the production reason to a wrong
label or deleting it fails all four. A mutation check (removing the
writer-ownership transfer) fails the frame tests.
- **Full local `just ci` at `45e00ec82` — all lanes passed**, including
the buzz-acp 947 unit + 9 integration tests, desktop and mobile suites,
fmt, clippy, web, security, and build lanes.
- **Previous head `5276ef375` added only a one-line test fix.** Windows
Clippy flagged the `wait_for` test helper as dead code because its only
callers are Unix-only tests; it now carries the matching `#[cfg(unix)]`
guard. No production code or assertions changed. Re-verified locally at
this head: `cargo fmt --check`, `cargo clippy -p buzz-acp --all-targets
-- -D warnings`, and the 8 frame-writer tests pass. No full local rerun
— the full matrix ran remotely at this head.
- **Remote CI at `5276ef375` was green** — [run
34530106246](https://github.com/block/buzz/actions/runs/34530106246): 42
jobs passed, 20 conditionally skipped, 0 failed, including the Windows
Rust lane (the guard's purpose) and the desktop core lane; Docker image,
desktop release-candidate, and DCO checks pass. The automated security
review over `00209076c…5276ef375` reported no findings.
- **Current head `2224ec228` adds the fallback-log regression tests.**
Rebased onto `main` at `44c1cc7df` and extended with a test-only commit:
the four `lib.rs` tests above now pin the production fallback log.
Verified locally at this head: full `cargo test -p buzz-acp` (952 unit +
9 integration, 0 failed), `cargo clippy -p buzz-acp --all-targets -- -D
warnings`, and `cargo fmt --check`, all clean; the DCO check passed
remotely. Fresh remote CI is running on this head.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-01 11:45:44 -04:00
Will Pfleger
bd0896f5c0 fix(desktop): read channel metadata and member lists from writer after writes (#7999)
🤖 This fixes two problems with the same cause. Since #3268, the relay
can answer an unpinned `/query` from the Aurora read replica, as long as
the replica has proved it's within `BUZZ_REPLICA_READ_MAX_AGE_MS` (1000
ms) of the writer. That's fine for display reads, but it doesn't
guarantee a client sees its own write.

**Problem 1: channel creation (Wes's report).** Right after creating a
channel, the desktop read back the channel's kind:39000 metadata by
`#d`. If that read landed on a replica that hadn't replayed the event
yet, it failed with `channel created but metadata not yet available`.
The channel existed, but the chosen template (canvas, agents) was never
applied.

**Problem 2: member lists.** After any membership change, the
member-list refetch could also hit a lagging replica. The relay returned
the list from before the change, and the desktop cached it as fresh for
five minutes (`CHANNEL_MEMBERS_STALE_TIME_MS`). Someone just removed
could still show as a member, or someone just added could be missing.

## Fix 1: metadata read-backs go to the writer

`channel_metadata_filter` is a shared builder that adds `"consistency":
"strong"`, so the relay serves the read from the writer. It follows the
same pattern as the canvas head reads. All four metadata read-backs that
follow a write use it: `create_channel`, `update_channel`,
`fetch_starter_channel_metadata` (its retry and fallback are unchanged)
and the open-DM refetch in `dms.rs`. A unit test fails if the flag is
removed from the builder.

The template's agent-add step also reads channel members (kind:39002)
right after create. `get_channel_members` takes an opt-in
`read_your_writes` flag that sends `"consistency": "strong"`. Only
`createChannelManagedAgents` sets it on that path, so the agent-add step
sees the creator's membership.

## Fix 2: a 31-second writer-read window after membership changes

Every membership write the desktop makes calls
`noteChannelMembershipChange` for each channel it changed:

- the native write wrappers `createChannel`, `openDm`, `joinChannel`,
`leaveChannel`, `addChannelMembers`, `removeChannelMember` and
`changeChannelMemberRole`
- the Bestie conversation open
- huddle agent add and remove, for both the huddle channel and its
parent
- `ensure_starter_channels` and `sync_agents_to_active_huddle`. Both
return every channel they created, joined or changed in
`changed_channel_ids`, including ones that landed before a later step
failed. The original error text is rethrown unchanged.
- live join, leave, removed and `admin_kick` system messages, and
member-added and member-removed notifications
- a report kick resolved from the Admin Console, once the relay confirms
it succeeded and only when the report's channel is in the active
community. The kick goes through the admin HTTP API, not a channel
event. Ban, timeout and their lifts only change `community_bans`, not
channel membership, so they need no record.

For 31 seconds after a change, measured on `performance.now()`,
`getChannelMembers` for that channel sends `readYourWrites`. Why 31
seconds: the relay caps its replica staleness budget at
`FENCE_STALENESS` (30 s), so once the window closes, no supported
configuration can serve the pre-change list. The constant's comment ties
it to `FENCE_STALENESS`. One app-level listener cancels any roster fetch
already in flight and refetches. When it registers, it also refreshes
every channel whose window is still open. All other member-list reads
still use the replica.

**Community switch.** The window map is cleared when the community
changes. Each awaited write captures a generation through
`beginChannelMembershipWrite()` before its first native call, and the
reset advances it. So a write that settles after a community switch
records nothing in the new community. Live events don't need this fence,
because the reset disconnects the relay client first and frames from the
old connection are dropped.

## Coverage

- A Rust test drives `ensure_starter_channels` against a stub relay that
accepts the creates but never serves metadata, and checks that both
created starter channels are still reported.
- Desktop tests cover the writer-read window, the Admin Console kick,
and writes that settle after a community switch.
- The desktop E2E test bridge (`desktop/src/testing/e2eBridge.ts`)
mirrors the strong reads and `changed_channel_ids`, so E2E runs exercise
the fixed behavior.

This doesn't address the smaller race in the relay's per-pod
accessible-channels cache, and it doesn't change mobile.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-01 11:32:16 -04:00
klopez4212
6b5bb6f5a3 feat(mobile): refresh onboarding and smooth QR scanner motion (#8019)
## Summary
Refresh mobile onboarding with light/dark gradients, an animated fuzzy
Buzz wordmark and tagline, and bottom-aligned glass/ghost actions.
Remove the dot grid and idle scroll bounce, and smooth the QR scanner's
continuous corners and spring transitions by deferring camera teardown
until dismissal finishes.

### Related issue
N/A — no matching mobile onboarding issue or PR found.

### Testing
- Mobile formatting, analysis, and full test suite passed at
`0bb357f7a`.
- Regression coverage includes shader animation/reduced motion, keyboard
reachability, scanner interruption, deferred camera disposal, and
rendered error contrast in both themes (mutation-checked).
- Signed iOS release build installed on iPhone Air; Kenneth tried the
onboarding and scanner during iteration and approved the final blur.
- Agent review completed with no correctness blockers. Android device
validation was not performed; hosted checks run separately.

buzz-review-completed

### Preview
Production-widget renders at 420 × 912; before: `5fdb2e536`, after:
`443858a96`.

| Before | After — light | After — dark |
|---|---|---|
|
![Before](https://raw.githubusercontent.com/block/buzz/52fb09e3c7e536f3a365d61401a077ce03ac47d9/pr-8019--buzz-before-light.png)
|
![Light](https://raw.githubusercontent.com/block/buzz/52fb09e3c7e536f3a365d61401a077ce03ac47d9/pr-8019--buzz-blur-only-light.png)
|
![Dark](https://raw.githubusercontent.com/block/buzz/52fb09e3c7e536f3a365d61401a077ce03ac47d9/pr-8019--buzz-blur-only-dark.png)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-01 15:17:04 +01:00
Logan Johnson
5fdb2e5365 fix(acp): preserve routing for edited messages (#4741)
🤖
## Summary
When someone edits a message to add an @mention, the app does not change
the original message. It publishes a separate hidden "edit" event that
points back at it. An agent that handles the edit must work on the
**original message**: it reacts, replies, and posts any error notice on
that message or in its thread. Before this PR, an agent treated the
hidden edit as a new message. It replied in the wrong place, and with
per-thread sessions it started a new, empty conversation.

This is the first of three PRs. It sets where agents reply to edits. It
does not yet make edits wake agents by default (#6131 does that).

- The agent looks up the original message once, when it accepts the
edit. Every later step uses that result: the session, reactions, typing
indicator, reply target, and failure notices.
- **Per-thread sessions** (the `thread` session policy, where each
thread has its own agent conversation): the edit joins the original
message's thread session. It does not start a new session.
  - The original is a reply in a thread: the edit joins that thread.
- The original is a top-level message: the edit joins the thread that
starts at the original.
- **Channel sessions** (the default policy) and DMs: nothing changes.
The whole channel shares one conversation.
- Setup mode (the reply that asks the owner to finish configuring the
agent) now accepts edits. It replies at the original message or its
thread. If the original could not be fetched, the reply posts at the top
of the channel.
- Error notices for an edit (sign-in failure, hard timeout, retries used
up) now appear in the original's thread, or as a reply to a top-level
original. Before, they appeared at the top of the channel. If the
original could not be fetched when the edit was accepted, the notice
looks it up once more. If it is still unknown, the notice posts at the
top of the channel.
- If a newer request interrupts a turn that is working on an edit, the
next turn treats the edit as earlier work that the new request replaces.
The old edit does not run again.

## Details
- The lookup has a 2-second limit. It runs only for edits that already
matched a subscription rule (by default, edits that newly mention this
agent). It must finish before the edit is queued, because the session is
chosen at that point.
- If the lookup fails, the agent still targets the edited message's ID,
never the hidden edit event. Notices and setup replies never claim that
message as a thread root, because the relay rejects a root that does not
match the message's real thread. **Known gap:** if that message is a
reply in a thread, the agent cannot learn the thread root. So under
per-thread sessions it uses a separate session rooted at that reply, and
it loses the thread's earlier context. Its reply still goes to the right
message. Closing this gap needs a second lookup, which is left out on
purpose.
- The original is fetched with an explicit kind (stream message or its
edit) and a channel filter, because the relay rejects lookups without a
kind.
- Each queued event carries its own route, so an edit shares a batch
with other events like any message. Batch order is unchanged from
`main`.

### Stack
**1 of 3**, based on `main`. Next: #6131 (edits wake agents by default),
then #6132 (edits join a turn that is already running).

### Related issue
Part of #2540. The issue is fixed when #6131 merges.

### Testing
- `cargo test -p buzz-acp --all-targets` passes. One existing test,
`session_new_forwards_complete_git_block_without_duplicate_names`, fails
in a shell where the git setting `nostr.keyfile` is set. It passes when
the setting is removed. This PR does not touch that test.
- Session tests: an edit uses the original's thread for a threaded
original, a top-level original, and an original that could not be
fetched. It never uses the edit event.
- Tests check the signed events that the agent publishes: setup-mode
replies and failure notices, each for a threaded original, a top-level
original, and an original that could not be fetched (including a notice
whose second lookup finds the original). If either routing fix is
reverted, these tests fail.
- A queue-to-prompt test interrupts a turn on an edit with a newer
request. The next prompt has the edit as earlier work and the new
request with the "replaces earlier work" framing, and nothing is left to
run again.

---------

Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-30 21:36:59 -04:00
Logan Johnson
cf5e83829a fix(mobile): distinguish unknown presence on identity surfaces (#7383)
<!-- Draft PR body for #7383 · fix(mobile): distinguish unknown presence
on identity surfaces · https://github.com/block/buzz/pull/7383 -->

🤖

## Summary

When mobile didn't know someone's presence — right after opening the
app, or when the relay simply had no data — DM headers, DM-list avatars
and profiles showed that person as **Offline**, with the same gray dot
as someone genuinely offline. This PR separates "we don't know" from "we
know they're offline": unknown presence now reads **Unknown** with no
dot or notch, while confirmed Online/Away/Offline look exactly as
before.

- Applies to the DM header, DM-list avatars, and the profile sheet.
Avatar geometry is unchanged — the unconfirmed indicator is hidden, not
reshaped.
- Confirmed Online/Away/Offline presentation is untouched.
- The profile exposes one presence accessibility label, with no
duplicate screen-reader announcements.

### Related issue

- Fixes: N/A. No separate issue; the related work is the stack below.
- Depends on #7382 (declared base), which supplies the presence data —
this PR is the visible half of that change.
- Draft — not requesting merge yet; the security advisory doesn't run on
this stacked base (it reviews main-based ranges).

### Testing

- Widget regressions cover unknown/online/away/offline transitions,
fixed avatar bounds, DM-list colors, and profile semantics.
- At the branch head: `just mobile-check` and the full mobile test suite
pass independently, and every broader CI gate has successful evidence
after isolated recovery runs (no monolithic local `just ci` run) —
receipts in the [exact-head evidence
comment](https://github.com/block/buzz/pull/7383#issuecomment-5573113048).
- Verification is widget-test level; no native device or simulator run
is claimed.

To see it: open a DM whose presence isn't confirmed yet (e.g. right
after a cold open) — the header and profile now read Unknown instead of
Offline.

### Screenshots

Flutter production-widget test renders — not native-device screenshots
or acceptance captures.

| Scenario | Before | After |
|---|---|---|
| Profile sheet for a person whose presence isn't confirmed yet (e.g.
right after a cold open) | ![Before: the profile reads Offline with a
gray dot — indistinguishable from actually
offline](https://github.com/user-attachments/assets/45364547-e198-45a4-ae68-777a58844009)
| ![After: the profile reads Unknown with no
dot](https://github.com/user-attachments/assets/2585d011-30b9-4df1-a0f2-6d59eec14282)
|

Confirmed Online/Away/Offline presentations are unchanged by this PR.

<details>
<summary>Capture provenance</summary>

Rendered by the Flutter widget engine in a `flutter test` run
(production widgets, production theme; no device or simulator). Before:
this PR's declared base `a8e8028bfb9bb67de2db03ed448839e2f3ab2b0d`.
After: its head `dd955a0c2ac80ada39b416a2f8b7481afb8773bf`.

</details>

Signed-off-by: Logan Johnson <loganj@squareup.com>
2026-09-30 21:35:48 -04:00
Logan Johnson
965fe1dffa fix(mobile): hydrate and refresh relay presence snapshots (#7382)
<!-- Draft PR body for #7382 · fix(mobile): hydrate and refresh relay
presence snapshots · https://github.com/block/buzz/pull/7382 -->

🤖

## Summary

Mobile showed every person in a DM as **Offline** until they happened to
publish a presence update while the app was connected. Presence was
never loaded when the app opened, never refreshed, and a missing or
failed read looked identical to actually being offline. This PR makes
presence load and stay current: on connect or foreground, the app
fetches the relay's presence snapshot for everyone visible, keeps the
live subscription open while it does, and re-checks every 60 seconds
while the app is in the foreground. A missing or failed read shows
**Unknown** (no dot), not Offline.

- Snapshot reads are authenticated like the rest of the session and
batched by exact key (at most 100 per batch), instead of relying on live
events only.
- The live subscription opens before the snapshot, so updates published
during the read are not dropped. Per-key revisions fence stale **query
completions**: a snapshot that finishes after newer live evidence for
that key does not overwrite it.
- Refreshing every 60 foreground seconds observes relay lease expiry and
retry failures; missing or failed evidence simply stays absent — it is
never reported as Offline.
- Disconnects, backgrounding, and account/community changes reset the
cache cleanly. The app's existing subscription/recovery machinery is
reused rather than duplicated.

Mirrors Desktop's presence hydration
(`desktop/src-tauri/src/commands/profile.rs::get_presence`,
`desktop/src/features/presence/hooks.ts`).

### Presence contract: best effort between polls

Presence is a best-effort estimate that the next foreground poll
corrects. This PR does not give strict causal order between snapshots
and live events:

- Snapshots carry relay time. Live kind:20001 events carry the signer's
time. These clocks are not comparable.
- A live event at or below a snapshot's relay second is not applied
directly. The client confirms it with a fresh snapshot. This works
because the relay persists presence before fan-out.
- An empty (all-Offline) snapshot carries no relay second. If the relay
stored an Online event, then its fan-out was delayed past lease expiry
and an empty snapshot, the delayed event can show Online/Away until the
next 60-second poll. Base `main` has the same stale-delivery exposure
with no correction at all.

Strict ordering needs relay-issued ordering metadata on both paths, or a
fresh confirmation for every live event. Both are protocol or latency
tradeoffs and are out of scope here.

### Related issue

- Fixes: N/A. Closest open duplicates: #4417 (larger presence bootstrap
+ UI patch), #5617. Related: #6200, #7295.
- First slice of the mobile remote-agent parity port. This PR includes
the Unknown rendering at all three consumers (DM header, channel tile,
profile sheet). #7383 and #7526 stack on it and add test coverage only.

### Testing

- Rebased on `main` `95b018c87` (2026-09-30). At stack tip `7c8a93081`:
`just mobile-check` clean; full `just mobile-test` **2,593 passed**.
Earlier receipts below apply to the pre-rebase head.

- Regressions cover cold-open hydration, live/snapshot races, lease
expiry, failed reads and retries, disconnect/background/scope changes,
and >100-key batching.
- At the branch head: `just mobile-check` and the full mobile test suite
pass independently, and every broader CI gate has successful evidence
after isolated recovery runs (no monolithic local `just ci` run) —
receipts in the [exact-head evidence
comment](https://github.com/block/buzz/pull/7382#issuecomment-5573112882).
- Verification is automated-test level; no native device or simulator
run is claimed. No layout change in this slice.

### Screenshots

No layout change in this slice — the renders below show the same
existing profile sheet, with the presence status now following hydrated
relay data. Flutter production-widget test renders — not native-device
screenshots or acceptance captures.

| Scenario | Before | After |
|---|---|---|
| Existing profile opened cold, with a signed relay snapshot saying
Online | ![Before: the profile stays Offline — the snapshot is never
fetched](https://github.com/user-attachments/assets/4b4899c8-364c-4621-8d59-949189860eb3)
| ![After: the profile shows Online once the snapshot
hydrates](https://github.com/user-attachments/assets/7b1a9ecd-a7a8-4e04-b007-b86565a39cfc)
|

<details>
<summary>Capture provenance</summary>

Rendered by the Flutter widget engine in a `flutter test` run
(production widgets, production theme; no device or simulator). Before:
this PR's declared base `3c7f288c60d67df78577b237e27c3dfc8831aaa1`.
After: its head `a8e8028bfb9bb67de2db03ed448839e2f3ab2b0d`.

</details>

Signed-off-by: Logan Johnson <loganj@squareup.com>
2026-09-30 20:10:24 -04:00
Will Pfleger
f001ebbb6e feat(desktop): add Admin Console Actions tab for direct staff actions (#7904)
Builds on #7883 (merged).

Adds an **Actions** tab to the Admin Console so relay staff can ban,
time out, or delete directly through the #7883 endpoints (`POST
/members/{pubkey}/ban`, `POST /members/{pubkey}/timeout`, `POST
/events/{id}/delete`), with no report required.

## Design

- **Frozen intent.** Review freezes the whole request (origin, relay,
signer, community, action, target, reason, duration, `requestId`).
Confirm and Retry resend it unchanged, so a manual retry reuses the same
`requestId` and stays idempotent on the relay. The intent is cleared
only on a definitive non-409 4xx (`preserveRequestIdOnError`). A 409 or
a 202 pending keeps it, except `409 request_id_conflict`, which is final
for that id and drops the intent so no futile Retry is offered. A 202
pending renders as a neutral notice, not an error.
- **Bound to signer and relay.** The intent carries `expectedPubkey` and
`expectedRelay`. Native `admin_direct_action` refuses before sending any
request if either is blank or no longer matches
(`assert_expected_signer` / `assert_expected_relay_scope`). The tab is
keyed on pubkey + origin, so an identity or relay switch remounts it and
drops any frozen intent.
- **Survives tab switches.** `ActionsTab` stays mounted while the panel
is open and is only hidden on other tabs, with the same pubkey + origin
key, so a pending, ambiguous or in-flight action (and its `requestId`)
is still there on return.
- **Nothing-sent refusals.** Every native refusal before the first
request leaves (malformed host, changed relay or signer, bad target or
duration, the key-backup guard on Reason, a missing HTTP client, the
first request build, the signing-key lookup) returns
`AdminMutationError` with `notSent: true`; the tab drops the intent and
offers no Retry. The request build for the retry after a 401 stays
ambiguous, because the first request already went out. The changed-relay
refusal says "active relay changed since the action was confirmed;
nothing was sent".
- **Disabled auth.** With `canMutate=false`, Review, Confirm and every
field are disabled.
- **Community host.** The host is prefilled from the active relay URL by
`communityHostFromRelayUrl` (URL host, lowercased, default port and
trailing dot stripped, matching the relay's `normalize_host`) and shown
read-only as "Community: <host>" with a Change link. It follows the
active community until the operator edits it; with no relay URL the
input shows directly. Review derives the host from the same
`getRelayWsUrl()` value it freezes into `expectedRelay`, so the two
can't disagree. The host is validated natively
(`validate_community_host`) and sent only as the `communityHost` query,
never in the body. The body is `{requestId, reason?, expirationSecs?}`,
built by the pure `direct_action_request`; only a timeout takes a
duration.
- **Member picker.** Ban and timeout pick the member the way
`AddMemberDialog` does: `useUserSearchQuery` name search,
`parsePubkeyInput` for a pasted npub or hex in any case, and a selected
chip. The target is always sent as lowercase hex. Name search is a
profile search on the active relay, not a member list, so it runs only
when the host is the connected community; for another host the field
takes npub or hex only, and the hint stays visible even with a key
selected. Editing the host to a different community clears the pick and
the search query, so returning to the original host doesn't bring it
back; pressing Change alone keeps it. Each named result shows a short
npub so same-name profiles can be told apart, the selected chip opens
the full key, and the confirm step shows "Name (npub1…abcd)" plus the
full npub (`PubKey variant="full"`). A search result's name is frozen
for display only; a pasted key uses `useUserProfileQuery` on the active
community, otherwise the short npub. Result buttons follow the disabled
state. Changing the member or host drops a frozen intent. Delete keeps
its hex event id (native `Hex64` route type, shared with pubkeys). A
query that starts with `nsec1` or `ncryptsec1` in any case is never
searched; the picker shows "That's a secret key. Never paste it here."
instead.
- **Errors.** `target_is_staff` and `request_id_conflict` get dedicated
copy; other codes fall back to the relay message (`adminErrorCode`). An
empty 404/405 from a relay without these routes reads "This relay
doesn't support direct actions yet."; an uncoded 404/405 with a body
keeps its own text. Success shows a toast.
- **Key-backup guard.** Every admin JSON mutation body (the three direct
routes and the existing report-resolve routes) passes
`egress_guard::assert_no_key_backup_bytes` in `helpers.rs` before
signing. The egress inventory points at that guarded boundary.
- **One signer snapshot.** `admin_direct_action` reads `signing_keys()`
once, checks `expectedPubkey` against it, and signs the request and any
401 retry with that same `Keys`.
- **Serialized Review.** Review and its inputs lock while the relay URL
resolves; a late Review cannot replace a frozen or submitting intent.
- **Reason audience.** Before confirmation the tab states that the
reason is delivered to the affected user, and for channel deletes posted
publicly in the room tombstone (same copy as the Reports tab).
Confirmation shows the frozen reason.

## Fix → test → mutation

| Fix | Test | Mutation that turns it red |
|---|---|---|
| Manual retry reuses `requestId` | `actions-retry` | mint a new id per
attempt |
| Intent preserved across retryable outcomes | `actions-retry`,
`actions-errors` | always clear the frozen intent |
| Confirmation tied to signer + relay | Rust
`direct_action_refuses_a_changed_signer_or_relay` | skip the signer
check |
| Identity/origin change drops intent | `actions-identity` | drop the
tab key |
| Disabled auth keeps Submit off | `actions-disabled-auth` | remove the
`canMutate` gate |
| Key-backup bytes never leave (`ncryptsec1…` / `NCRYPTSEC1…`, zero
requests; ordinary reason sends) | Rust
`direct_action_refuses_key_backup_reason_before_any_request` | remove
the guard call → panics (request sent) |
| Checked signer is the signing key | Rust
`direct_action_signs_only_with_the_validated_snapshot` (identity swapped
after the signer snapshot is taken, before validation) | re-read
`signing_keys()` for signing → signed with the swapped key |
| Late Review cannot replace the intent | `actions-review-race` (Review,
Review, Confirm ambiguous, Retry → one `requestId`) | drop the Review
lock |
| Pending is a neutral notice | `actions-pending-neutral` | store
pending in the error state → `pending notice must render` |
| `request_id_conflict` offers no Retry | `actions-request-id-conflict`
| keep the intent on every 409 → `request_id_conflict`, `no Retry for a
spent request id` |
| Reason audience copy + frozen reason | `actions-audience` | drop the
audience copy; drop the frozen reason |
| Host prefilled read-only, Change reveals the input |
`actions-host-prefill` | drop the prefill → `host must be prefilled from
the active relay` |
| Name result sent as hex and named on confirm | `actions-member-search`
| — |
| npub and uppercase hex sent as lowercase hex | `actions-member-keys` |
drop the direct key result → `no direct result for npub1…` |
| Another community turns name search off with a hint |
`actions-foreign-host` | — |
| A host change clears the pick, even after returning to the first host;
hint stays with a key selected | `actions-host-change-drops-member` |
hide the pick by host instead of clearing it → `returning to the first
host must not restore the old pick` |
| Same-name results differ by key; confirm shows the chosen full npub
and sends that key | `actions-same-name` | drop the full npub from
confirm → `confirm must show the full npub` |
| Results can't be selected once auth is disabled |
`actions-results-disabled` | — |
| Secret key never reaches search, warning shown | `actions-secret-key`
| remove the check → `a secret key reached search_users` |
| Pending action survives a tab round-trip, Retry reuses `requestId` |
`actions-tab-roundtrip-pending` | render the tab conditionally →
`pending notice survives` |
| Ambiguous failure survives a tab round-trip |
`actions-tab-roundtrip-ambiguous` | render conditionally → fails |
| In-flight failure on another tab shows on return |
`actions-tab-roundtrip-inflight` | render conditionally → fails |
| Pre-send refusal drops the intent, no Retry | `actions-not-sent`, Rust
`direct_action_marks_a_pre_send_refusal_not_sent` | ignore `notSent` →
`intent dropped; no Retry` |
| Key-backup refusal is nothing-sent; Review editable again | Rust
`direct_action_refuses_key_backup_reason_before_any_request`,
`actions-backup-reason-not-sent` | drop `not_sent` on the guard → panics
at the `notSent` assert; stub `notSent: false` → `no Retry after a
pre-send refusal` |
| Only an empty 404/405 reads as an old relay | `actions-old-relay` |
drop the empty-body condition → `404 admin API error: Not Found` |

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-30 18:53:13 -04:00
Luke Tornquist
53a12100b3 fix(deletion): require sole owner at admission (#7966)
Follow-up to #7830: reject owner-origin deletion admission for a legacy
co-owned community before creating a request. Under the existing
community-row lock, admission locks all current owner memberships in
pubkey order and requires exactly one matching asserted owner.
Existing-request idempotent replay remains unchanged; completion retains
its independent post-lock revalidation.

**New optional operator field: `community_id`** (`ab0cd713`,
`766eb07f`). Owner-origin `POST /operator/communities/delete` now
accepts an optional `community_id` that binds the request to the host's
community:
- **Fresh submission:** checked after sole-owner authority is proven, so
a non-owner still gets `404 community_not_found`. A mismatch returns
`409 community_id_mismatch` and writes no request row.
- **Replay:** checked against the stored request, and only when the
stored request is for the same host. A mismatch returns `409
community_id_mismatch`, and the stored request is unchanged. A known
UUID sent with a different host goes through the existing convergence
check and returns `409 deletion_request_conflict`.
- **Omitted:** behaves as before. `admit_owner_request` takes
`expected_community_id: Option<Uuid>` rather than a second public entry
point, and existing callers pass `None`.
- `docs/operator-community-deletion.md` documents the ordering.

Also folds in the non-blocking review nits from #7969 (wpfleger96, at
`e21151f4`):
- **`limit_reached` has a stable `code`.** Create
(`provision_community`) and transfer-in now return `409` with `code:
"limit_reached"`, matching the other coded lifecycle errors. The `error`
message keeps its `limit_reached:` prefix, so clients that match on the
message still work. The provision limit test now asserts the code, and a
new PostgreSQL test covers a transfer to an owner at the limit (coded
409, no membership change).
- **Docs: an ack-version mismatch is a 400, not a 409.**
`docs/operator-community-deletion.md` and the `delete_community` handler
doc now say a changed host or owner under a known UUID is `409
deletion_request_conflict`, and an unsupported acknowledgement version
is rejected first with `400 unsupported_acknowledgement_version`.
- **Docs: active-cap overrides above the lifetime cap can't be
reached.** The `max_communities_per_owner` doc and the quota section now
say `MAX_LIFETIME_COMMUNITIES_PER_OWNER` (20) counts live ownership, so
`BUZZ_MAX_COMMUNITIES_PER_OWNER` above 20 can't be reached.
- **Legolas P2s:**
- The UUID-conflict docs now list every 409 case, including a stored ack
version that doesn't match and a UUID held by an operator-origin
request.
- The runbook now says a legacy co-owned community is rejected as `404
community_not_found` and should be converged with a transfer first.
- The preparation drift test now pins `DeletionSafety` plus "sole-owner
authority drifted".
- **Guard wording:** the `delete_community` handler doc now says "sole
current owner", matching the admission check.

What got simpler: admission and automatic preparation now enforce the
same sole-owner authority, so no accepted-but-unexecutable co-owned
request needs a new recovery path. `deletion_api_error` is renamed to
`coded_api_error`, since it now carries non-deletion codes too. There's
one helper for coded operator errors instead of a second one.

Rebased onto main after #7969 merged (`d7a35afa`). The sole-owner commit
applied cleanly.

Earlier focused Blox verification (at the pre-rebase head `161e9857`):
the new admission regression was red before the change and green after
it. Five temporary, restored mutants each failed its matching
production-seam regression. At the current head `766eb07f`, the relay
`api::operator::` suite passes 23/0 on Blox. Each new `community_id`
guard, removed one at a time, fails its own regression test. CI owns the
full suites.

Remaining debt: the separate P2-A purge/completion membership/community
lock-order cycle is unchanged.

Generated with Codex

---------

Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-09-30 15:04:23 -04:00
Will Pfleger
965e1997b5 fix(agents): stop built-in prompts from teaching sleep polling (#7992)
An @mention wakes an agent only once its running tool call returns, so
agents that loop `sleep` + `buzz messages get` (or poll CI) delay every
reply that arrives in the meantime. Buzz's own prompts taught that loop.

- `crates/buzz-acp/src/base_prompt.md`: replace "No push notifications —
poll with …" with a rule to never use a tool call (`sleep`, `--watch`)
to wait on CI, teammates, or other external events; hand off and end the
turn. `buzz messages get --since` stays as a one-shot catch-up. "keep
working until you publish the verified result" now reads "the result,
handoff, …" so agents stop reading it as wait-for-green-CI.
- `desktop/src-tauri/src/managed_agents/nest_skill.md`: the "Polling
Pattern" sleep-and-repeat loop becomes a "Catching Up" one-shot
`--since` read.

🤖 Opened by Duncan.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-30 18:28:54 +00:00
Will Pfleger
020193f8e3 feat(relay): add direct staff ban/timeout/delete with staff guard (#7883)
Relay staff (the `RELAY_OPERATOR_PUBKEYS` config roster, the owner
fallback, and DB operator/moderator rows) can now ban, time out, and
delete without a report through the admin API. Staff never join the
community transport: these are admin-host, NIP-98 signed routes that
drive the existing report enforcement state machine. Community
admission, bans, and right-click menus are unchanged from `main`.

Stack: this PR → #7904

## Routes

| Route | Body |
|---|---|
| `POST /api/admin/v1/members/{pubkey}/ban?communityHost=` |
`{requestId, reason?}` |
| `POST /api/admin/v1/members/{pubkey}/timeout?communityHost=` |
`{requestId, expirationSecs, reason?}` |
| `POST /api/admin/v1/events/{eventId}/delete?communityHost=` |
`{requestId, reason?}` |

Responses: `200 {actionId, state: "succeeded", replayed}`, `202 {state:
"pending"}` when the action is accepted but not yet converged (the
recovery worker finishes it; the same `requestId` replays), `409
request_id_conflict`, `409 target_is_staff`, `404
event_not_in_community`, `400 invalid_community_host` /
`unknown_community_host`, and `422 enforcement_failed`. `422` is now the
single failed-enforcement code for both the report and direct paths
(shared `enforcement_failed()` mapping).

## Design

- **Migration 0055.** `relay_admin_actions.report_id` becomes nullable.
A direct row has `report_id NULL`, and `report_community_id` stays the
tenant. New columns: `enforcement_target_event_id` and `timeout_secs`.
The `relay_admin_actions_direct_shape` CHECK spells out each direct
action: timeout needs both `timeout_secs` and `timeout_until`, ban and
delete need neither, and delete needs both the event id and the author.
A partial unique index on `(report_community_id, request_id) WHERE
report_id IS NULL` makes direct actions idempotent. `pgschema` drops
multi-column CHECKs, so `scripts/reconcile-schema-after-pgschema.sql`
re-adds `relay_admin_actions_direct_shape` on desired-state databases.
- **Atomic acceptance.** `claim_direct_action` inserts the action row
and its `moderation_actions` audit row (`ban` / `timeout` /
`delete_message`, `relay_operator` / `relay_moderator`) in one
transaction. `ON CONFLICT DO NOTHING` sends a concurrent same-key
request to the comparison. Retries compare actor, action, reason,
`timeout_secs`, and the client-named target: the event id for delete,
the pubkey otherwise. The event author, channel, and `timeout_until` are
derived at acceptance and never compared.
- **Retry racing its own first attempt.** Handler order: stored-action
lookup → validation → staff guard → accept. If validation fails (for
example B's event lookup runs after A already deleted the event), the
handler re-reads the stored action. It returns a replay or 409 rather
than 404.
- **Report-less enforcement.** `finalize_success(report_id:
Option<Uuid>)` skips the report CAS and the `reporter_notice` for direct
actions. Tombstone and affected-user notices are unchanged.
`recover_one` drives a direct action from its persisted row.
`deliver_reporter_notice` treats a missing `report_id` as a terminal
error instead of panicking.
- **Permanent bans clear old expiry.** The ban upsert shared by direct
and report-backed bans sets `ban_expires_at = NULL`, matching the
kind-9040 path, so a permanent ban over an active or lapsed timed ban is
in effect with no deadline. Timeouts are untouched.
- **Community purge removes admin-action rows.** `purge_postgres`
deletes the community's `relay_admin_outbox` rows (joined through their
action), then its `relay_admin_actions` rows by `report_community_id`,
before `moderation_reports`. That order satisfies both FKs and covers
report-backed and direct (`report_id NULL`) rows in one pass. The same
keyed row sources (`RELAY_ADMIN_TABLES`) feed the deletion inventory,
which now records row counts for both tables, and post-purge logical
verification, which now fails if either table still has a row for the
community. Before this, a community with any admin-resolved report could
never finish deletion (23503 on
`relay_admin_actions_report_community_id_report_id_fkey`, stuck at
`bindings_removed`). The `_operator_global_tables` registry reasons for
both tables (seeded in migration 0036) are updated in 0055 and
`schema.sql` to say community deletion purges them explicitly.
- **Staff bans disconnect clusterwide.** The shared enforcement driver
(`drive_enforcement`, used by the report and direct paths, fresh and
recovery) calls `disconnect_pubkey_clusterwide` for `ban` before
finalizing, matching the kind-9040 ban. A crash before finalize leaves
the action non-terminal, so recovery re-runs the disconnect (idempotent)
even when the ban step marker is already set. Timeouts do not
disconnect, matching in-community timeout.
- **Staff guard (D2).** `lookup_admin_principal` returns
`Result<Option<AdminPrincipal>>`, with the same precedence as
`resolve_admin_principal` (now a wrapper). `Ok(None)` means not staff. A
roster lookup error is a 500, so the guard fails closed. Ban and timeout
(direct) and ban/timeout/kick (via `resolve_report`) refuse any
effective-roster target with `409 target_is_staff`, including the actor.
The report path refuses before claim, so the report stays `open`. Delete
is not guarded on either path. The check runs only when an action is
first accepted: a replay or crash recovery completes an accepted action
even if its target became staff afterwards.
- **`communityHost` grammar (D4).**
`buzz_core::tenant::validate_community_host` accepts only DNS labels and
an optional port, and rejects schemes, paths, queries, userinfo, IP
brackets, and whitespace before tenant binding.

## Tests binding fixes (red under mutation → green)

Postgres tests are in `api/admin/mod.rs` `postgres_tests`, run against a
DB migrated through 0055. Every rejection test seeds an existing
restriction and asserts, through the shared `assert_no_effects` helper,
that restriction values and action/audit/outbox/restriction/event counts
are unchanged.

| Fix | Test | Mutation → red |
|---|---|---|
| Racing retry replays |
`direct_delete_retry_racing_first_attempt_replays_not_404`
(barrier-paused HTTP interleaving; same-intent → 200 replay, conflicting
reason → 409; the retry is checked with `assert_no_effects` against a
snapshot taken after the first attempt, with the event author's
restriction seeded) | drop the post-failure re-read → `left: 404 right:
200`; conflicting retry updates a restriction on the re-read path →
`raced retry` snapshot mismatch (`ban_reason: "mutated"` vs `"seeded
ban"`) |
| Per-action retry comparison |
`direct_ban_replays_same_request_and_conflicts_on_changed_intent` | drop
`reason` from the comparison → `left: (200, None) right: (409,
Some("request_id_conflict"))` |
| Lookup fails closed (helper-level, calls `refuse_staff_target`
directly) | `staff_lookup_failure_fails_closed` | `.unwrap_or(None)` on
lookup error → panics on `unwrap_err` (guard passed) |
| Direct staff guard | `direct_ban_and_timeout_refuse_staff_targets`
(config/self and DB staff over HTTP with seeded restrictions unchanged;
owner fallback checked at helper level via `refuse_staff_target`) |
remove `refuse_staff_target` → `left: (200, None) right: (409,
Some("target_is_staff"))` |
| Report-path guard incl. kick |
`resolve_report_refuses_kick_and_ban_of_staff_target` | remove
report-path guard → `left: (200, None) right: (409,
Some("target_is_staff"))` |
| Accept-time-only check |
`recovery_completes_accepted_ban_after_target_became_staff` | re-check
staff in `drive_direct_action` → `left: "pending" right: "succeeded"` |
| Timeout CHECK | `migration_0055_rejects_half_filled_direct_timeout`
(buzz-db, migrated schema) and
`direct_timeout_shape_check_holds_on_desired_state_schema` (buzz-relay,
`pgschema` + reconcile schema) | loosen timeout branch of the CHECK, or
drop the reconcile block → half-filled insert accepted |
| No reporter notice for direct | `direct_ban_replays_…` (outbox
assertion) | enqueue `reporter_notice` unconditionally → assertion at
the `reporter_notice` check fails |
| Delete unguarded | `direct_delete_removes_staff_authored_event` | —
(behavioral control) |
| Unauthorized direct requests have no effect |
`direct_routes_refuse_unauthorized_requests_without_effects` (nonstaff,
community owner, disabled auth, wrong host, wrong URL, altered body;
restrictions on the targeted member and on the delete target's author,
plus action/audit/outbox rows, unchanged) | roster bypass / drop
mutation-principal check / admit owner / skip host check → `200 vs 403`;
drop URL binding / payload hash → `200 vs 401` |
| Foreign-community delete |
`direct_delete_of_foreign_community_event_has_no_effect` | unscoped
event lookup → `(200, None) vs (404, "event_not_in_community")` |
| Banned staff actor still acts |
`community_banned_staff_actor_still_acts_under_membership_enforcement`
(membership enforcement on) | gate the actor on community bans → `403 vs
200` |
| Timeout idempotency |
`direct_timeout_keeps_expiry_on_retry_and_conflicts_on_changed_intent` |
compare derived `timeout_until` → `409 vs 200`; compare action only →
code `None vs Some("request_id_conflict")` |
| Competing claims | `competing_direct_claims_accept_once` (identical →
existing, conflicting → conflict; one action + one audit row each) |
join skips compare → `[claimed, existing] vs [claimed, conflict]`; join
always conflicts → the reverse |
| Aborted acceptance rolls back | `aborted_direct_acceptance_rolls_back`
| accept outside the transaction → effects `(1,0,0,0,0) vs (0,0,0,0,0)`
|
| Recovery exactly once |
`recovery_after_committed_mutation_finalizes_exactly_once` (one
recovery, `succeeded`, one `affected_user_notice`; each `recover_one`
bounded by a 30s timeout) | ignore the enforcement step marker →
`recovery did not converge: Elapsed(())` after 30s |
| Permanent ban over a timed ban | `direct_ban_clears_prior_ban_expiry`
(active and expired prior ban; asserts `ban_expires_at` NULL, admission
`moderation_restriction_state` banned, timeout unchanged) | drop
`ban_expires_at = NULL` → active: `left: Some(+1 day) right: None`;
expired: `left: Some(-1 day) right: None` |
| Rejections never rewrite restrictions |
`direct_ban_and_timeout_refuse_staff_targets`,
`resolve_report_refuses_kick_and_ban_of_staff_target` (value snapshot
via `assert_no_effects`) | staff guard updates the target's existing
restriction before returning 409 → counts equal but `ban_reason:
"mutated"` vs `"seeded ban"` |
| Purge with admin-action rows |
`purge_postgres_succeeds_with_relay_admin_action_rows` (buzz-db;
report-backed and direct rows with outbox rows; inventory counts both
tables; deleted community left with none, second community untouched; a
straggler direct row fails logical verification) | remove the new purge
deletes → `23503 … violates foreign key constraint
"relay_admin_actions_report_community_id_report_id_fkey"`; drop the
relay admin tables from verification → `straggler relay admin action
must fail verification` |
| Ban disconnects, timeout doesn't |
`direct_ban_disconnects_target_and_timeout_does_not` | remove the
disconnect call (run alone) → `ban must close the target's socket` |
| Recovery still disconnects |
`recovery_past_ban_step_still_disconnects_target` | remove the
disconnect call → `recovery must disconnect the banned target` |
| Purge result keyed by table |
`checkpointed_resume_is_idempotent_and_tombstone_blocks_name_reuse`
compares returned keys to the scoped tables plus the two relay admin
tables | — (existing regression, updated) |
| Failed replay |
`direct_replay_of_failed_action_returns_422_without_effects` | —
(coverage) |
| Pending under contention |
`direct_action_under_contention_returns_202_pending` | — (coverage) |
| Timeout/delete recovery |
`recovery_completes_accepted_direct_timeout_and_delete` (test-only
`claim_stranded` acquires just the requested action through
`acquire_action_lease`, then runs `recover_one`, so tests don't claim
each other's rows) | — (coverage) |

## Rollout note

Before rolling the relay back to a binary without this PR, drain
non-terminal direct rows (`report_id IS NULL AND state IN
('pending','enforcing')`). An older binary cannot decode `report_id
NULL`, and recovery batches containing such rows stall.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-30 14:23:54 -04:00
Will Pfleger
0ee6093790 fix(ci): gate security review on repo write access (#7986)
🤖 The Codex Security Review gate trusted GitHub's `author_association`
(`MEMBER`/`OWNER`), but GitHub only reports `MEMBER` to viewers who can
see the membership. The workflow `GITHUB_TOKEN` cannot see private
`block` org membership, which is GitHub's default, so most Block
employees read as `CONTRIBUTOR` and their PRs never reviewed
automatically. The same applied to the comment path, where private
members could not authorize a review with `@buzz-security-review <sha>`.

Trust is now repo write access, read from `GET
/repos/{owner}/{repo}/collaborators/{user}/permission` via
`getCollaboratorPermissionLevel`, which is independent of membership
visibility and works with the authorize job's existing `contents: read`
and `pull-requests: read` permissions. A user is trusted only when
`permission` is `write` (which includes `maintain`) or `admin`. A
successful lookup is not enough: on this public repository, an account
with no relationship to it returns `200` with `read`, not 404. Outside
contributors can only push to forks and come back `read`, so they still
need an explicit command from a trusted user.

The check covers the PR author on `pull_request_target`, the commenter
on `issue_comment` (the job-level `author_association` filter is removed
so the trusted script makes that decision), and the stale-marking path
on PR updates. Transient API errors are retried. Authorization fails
closed: any lookup error other than 404 fails the job instead of
authorizing. Stale-marking is conservative: an existing review is always
marked stale without consulting permissions, and a failed lookup counts
as untrusted, so a previous range's review can never appear current for
a new head. A commenter without write access is logged and ignored
instead of failing the run.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-30 12:19:56 -04:00
Alex Rosenzweig
95b018c871 feat(acp): wrap workers at the subprocess launch boundary (#7985)
## Summary

Replacing `BUZZ_ACP_AGENT_COMMAND` with a sandbox launcher hides the
real adapter from Buzz: for example, Goose loses its default `acp`
argument. Add optional `BUZZ_ACP_LAUNCH_PREFIX`, a JSON argument array
applied at the shared subprocess boundary, after normal worker
configuration.

On Unix, every spawn runs `prefix... worker args...` without shell
interpolation. Worker identity, environment setup, stdio and existing
process cleanup are preserved. Invalid or unavailable prefixes fail
launch without falling back to the worker; unset keeps direct launch on
every platform. Configured prefixes fail closed on non-Unix because
per-worker process-tree cleanup is unavailable. Desktop reserves the key
against saved user-environment overrides, and spawn failures name the
executable.

### Related issue

Prerequisite for [Buzz-App
#415](https://github.com/block/buzz-app/pull/415). One commit directly
on `main`, independent of #7942–#7944. No duplicate launch-prefix PR
found. Policy enforcement, verified launcher staging, protected paths
and the supporting runtime pin remain Buzz-App/plugin work.

### Testing

- Original head `9ae9d82f8`: complete ACP suite (990 passed, three
existing ignored), shipped local-task test, real-Goose probe, and full
repository-wide `just ci` passed.
- Review fixes: five launch tests and 44 Desktop environment-filter
tests pass locally, covering the real production platform gate, save
rejection, both merge layers, case variants, and executable error
context.
- Subprocess tests cover direct/wrapped argument defaults, Pi skills,
Hermes/Codex environment, adapter identity, repeated spawns and
invalid/missing prefix refusal.
- Shipped `buzz-acp run` entrypoint completes a task through the wrapper
and deterministic ACP peer.
- Real Goose 1.52.0: direct and wrapped `auth-methods` return identical
results; the wrapper receives `goose acp` with initially empty worker
args.
- Independent agent review: no blocking findings.
- Required push checks passed on the updated head. The existing native
Windows CI job now explicitly runs the platform-contract tests. Its
result is pending; the non-Unix refusal branch was not exercised on this
macOS host.

Repeated-spawn coverage is not a full lazy-pool wake/crash test.
Protected Goose in the Buzz-App UI and human acceptance remain pending.

To try the hook after building `buzz-acp`, use an installed Goose path:

```sh
BUZZ_ACP_LAUNCH_PREFIX='["/usr/bin/env"]' target/debug/buzz-acp auth-methods \
  --agent-command /absolute/path/to/goose --agent-args '' --json
```

Expect the same authentication methods as without the prefix. Changing
the prefix to `["/missing-launcher"]` must fail. Draft pending human
testing.

Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
2026-09-30 10:52:29 -05:00
Luke Tornquist
d7a35afa98 feat(buzz-relay): idempotent owner community deletion with quota reservation (#7969)
## Summary

This is the relay side of owner community deletion.

- **Idempotent delete is the recovery call.**
  - `POST /operator/communities/delete` checks the request UUID first.
- If the request already exists with the same host, owner and
acknowledgement version, it returns `202` with the request's current
`status`. That holds at any stage, even after membership is purged, and
no new work is admitted.
- The same UUID with a different host or owner returns `409
deletion_request_conflict`. An unsupported acknowledgement version is
rejected first, with `400 unsupported_acknowledgement_version`.
  - There's **no receipt endpoint**. Clients recover by resending.
- **Active quota reservation.** An incomplete owner deletion holds the
owner's slot until the deletion logically completes. Migration `0054`
adds the supporting index. Migrations 0052 and 0053 are unchanged.
- **Lifetime cap.** Deleted communities keep their hosts as permanent
tombstones.
- On create and on transfer-in, the relay counts live ownership plus
every non-aborted owner deletion, completed ones included.
- That total is capped at an absolute 20, regardless of the active
limit, and going over returns `limit_reached`.
  - This stops create-then-delete host squatting.
- **Stable lifecycle conflict codes,** plus `acknowledgement_version` in
the 202.
- **Owner-list quota projection:** `quota_used`, `quota_limit` and
`can_create`.
  - `can_create` reflects both caps.
  - The projection is advisory, and `limit_reached` is authoritative.
- **Operator doc** (`docs/operator-community-deletion.md`): the
acknowledgement version is a compile-time constant. Admission and the
executor's claim/lease both filter on it. A version bump must keep
replaying and executing old-version requests.

This follows on from #7830. The foundation PRs #7818, #7827 and #7830
are merged.

## Testing

New tests:
- `owner_delete_resubmission_reports_current_status_without_new_intent`
  - A replay returns 202 `submitted`, including after membership purge.
  - Changing any field returns 409 `deletion_request_conflict`.
  - A non-operator gets 403.
  - A replay after abort returns 202 `aborted`.
  - No request rows are added.
- `completed_owner_deletions_count_toward_lifetime_cap`
- After 20 created-then-deleted communities, the active count is 0 but
`can_create` is false.
  - The next create and a transfer-in both return `LimitReached`.
- `owner_quota_admits_only_under_active_and_lifetime_caps`: a unit test.

Fellowship gate: PASS at `59375c00`. The code review was clean. The E2E
run covered authenticated KGoose → relay → the real drain executor. The
later commits only change the operator doc.

## Rollout

- Deploy order: this relay first, then KGoose
squareup/cash-server#130634, then Web squareup/ext-builderbot-ui#241 and
App block/buzz-app#403.
- No shipped client ever called a receipt route, so removing it doesn't
break any existing client.
- The quota changes have no deploy-order requirement.
- Keep owner deletion off until this relay and the drain executor are
live.
- If you need to roll back, turn deletion off before rolling back the
relay.
- Recreate any database that ran the earlier version of migration 0054.
That only applies to disposable preview databases. Never apply this to a
database holding real data. The earlier draft 0054 has a different
checksum and index predicate.

## Complexity

Net simpler:
- One route, its handler and a writer-routed `get` are removed. Recovery
reuses the existing idempotent admission path.
- A single `OwnerQuota::admits()` backs create, transfer and the
projection.
- The stale "clients fail closed / strict deployment order" doc section
is replaced by the advisory quota contract.

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Codex <codex@openai.com>
Signed-off-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <codex@openai.com>
2026-09-30 10:49:42 -04:00
Logan Johnson
7a1910e700 feat(mobile): show contextual names in lists, Search and Pulse (#7896)
🤖
## Summary

This PR finishes the change on the remaining mobile screens. After it,
every place that names a person or agent uses a contextual name:

- The channel list, DM labels, and the new-DM picker
- The channel header and details, and the members and add-members sheets
- Huddle avatars, spotlight, and overlay
- The Activity inbox
- Search results for people and messages
- Pulse notes, agent cards, and reply previews

Screens that belong to a channel compare names against its members.
Search, Pulse, and the new-DM picker have no channel, so they compare
the identities shown together. In the example below, Search showed four
results all named "murderbot". Now the human keeps the plain name, and
baxen's three agents are labeled by owner and key suffix.

Part 3 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists,
Search, Pulse).

### Related issue

Related to #2910. The desktop search change in #6483 addresses the same
problem in a different app.

### Testing

- Widget tests cover the Activity inbox, Search, and Pulse notes with
colliding names.
- `flutter analyze` is clean, and the full `flutter test` suite passes.
- Checked on an Android emulator against the live relay by searching for
"murderbot". The after image also shows "classy-murderbot", a new
account that was created between the two captures.

| Before | After |
| --- | --- |
| <img width="300" alt="Before: Search shows four people all named
murderbot"
src="https://github.com/user-attachments/assets/21bbcc52-fea8-4b31-ab8c-ff3ec9b32a39"
/> | <img width="300" alt="After: three results show as baxen's
murderbot with a key suffix, and the human shows as murderbot"
src="https://github.com/user-attachments/assets/d59584d9-4a35-46da-b39a-83addfad3cd2"
/> |

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-30 10:46:14 -04:00
Shawn Hank
2664d14316 Add Kimi Code's default install path to managed-agent binary discovery (#5997)
## Summary

Buzz Desktop fails to detect a freshly-installed Kimi Code CLI, even
though `kimi --version` works fine in the terminal. Root cause is in
binary discovery, not in Kimi's install.

## Problem

After running Kimi Code's installer, Buzz Desktop's harness list doesn't
pick up the `kimi` binary — it stays undetected until the user manually
sources their shell config.

## Repro

1. Install Kimi Code: `curl ... code.kimi.com/kimi-code/install.sh |
bash` — installs to `~/.kimi-code/bin`, and the installer appends a PATH
export only to `~/.zshrc`.
2. Open a fresh terminal or restart Buzz Desktop without `source
~/.zshrc`.
3. Buzz Desktop does not detect `kimi`, even though it's on disk and
executable.

## Root cause

`find_via_login_shell()` in
`desktop/src-tauri/src/managed_agents/discovery.rs` resolves binaries by
spawning `zsh -l -c 'command -v <bin>'`. A login shell sources
`~/.zprofile`/`~/.zshenv`, but zsh only sources `~/.zshrc` for
*interactive* shells — so a PATH export that lives only in `~/.zshrc`
(which is where Kimi's installer puts it) is invisible to this probe.
`~/.kimi-code/bin` is also absent from the static fallback list in
`common_binary_paths()`.

This is the same root-cause class already handled for `nvm` in this file
(see the `find_nvm_default_bin` comment) — Kimi just doesn't have an
equivalent fallback yet.

## Fix

Add `~/.kimi-code/bin` to the Unix branch of `common_binary_paths()`,
alongside the existing `.volta/bin` / `.bun/bin` / `.asdf/shims`
entries.

## Scope note

This PR only fixes detection (finding the binary). It does not address
that Kimi shows a "Ready" badge regardless of login status — that's a
separate, larger gap affecting all five preset harnesses (Kimi,
OpenCode, Amp, Hermes, OpenClaw), tracked separately.

## Test plan

- [x] `just ci` passes
- [x] Manual: fresh Kimi install per repro steps above, confirm Buzz
Desktop shows it without sourcing `~/.zshrc`

Signed-off-by: Shawn Hank <shawn@shawnhank.com>
2026-09-29 19:36:01 -07:00
Diem Nguyen
12dbb114aa test(desktop): fix flaky forum empty-draft replacement e2e (#7981)
## Summary
The `replacement=` cases (`add` and `publish`) of `forum source visit …:
A→B→A retains source draft` in `forum-agent-invitation.spec.ts` are
flaky. They fail at line 449 because the source draft still reads
`@RemoteScout hello`.

Cause: Playwright's `fill("")` on a contenteditable sets a DOM selection
and then presses `Delete`. ProseMirror handles `Delete` with its own
selection, which sometimes hasn't synced from the DOM yet. When that
happens the key press does nothing, `onUpdate` never fires, and the
clear is never recorded. I confirmed this with temporary logging:
failing runs have no authored-content update after the fill. Non-empty
`fill` uses `insertText`, which goes through ProseMirror's DOM input
path, so it isn't affected.

Fix (test only):
- Clear the draft with `ControlOrMeta+a` followed by `Delete`, so
ProseMirror owns the selection.
- Assert the replacement text before navigating away.

### Related issue
None found. The spec hasn't changed since #7125. This test failed in 10
of the last 40 CI runs of `Desktop Smoke E2E (2)`, for example #3700.

### Testing
- `--repeat-each=100 --retries=0 --workers=4` on the two affected cases:
  - Before the fix: 14 of 200 runs failed at `:449`.
  - After the fix: 200 of 200 passed.
- Full `forum-agent-invitation.spec.ts`: 19 passed.
- Pre-push hooks passed (desktop typecheck and desktop tests).

Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
2026-09-29 15:59:30 -07:00
Will Pfleger
fccc07ebe3 chore(release): release Buzz Desktop version 0.5.26 (#7980)
## Buzz Desktop release v0.5.26

- **Frozen main:** `7ebf1beb44139264e9c5c7fee6970429ff66f934`
- **Reviewed candidate:** `2b4b138dc5cf2d9cc1a0ceb21d9063ff56fe8bf4`
- **Previous desktop release:** `desktop-v0.5.25`
- **Proposed immutable tag:** `desktop-v0.5.26`

This PR may be **squash merged** after the Desktop Release Candidate
check and all protected-branch checks pass. Merging authorizes
publication of the exact reviewed candidate; later or unrelated changes
on `main` cannot alter it.

The checked-in changelog accounts for every non-merge commit in the
release range. The Desktop tag points to the reviewed candidate commit,
not the later squash commit. Publication remains bound to that immutable
candidate tag.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
2026-09-29 18:31:44 -04:00
Jobi Armstrong
7ebf1beb44 fix(desktop): correct Grok Build setup guide URL (#4846)
## Problem

The **Grok Build** harness preset points its "Setup guide" link at
`https://build.x.ai/docs`, which is unreachable — the hostname has no
DNS record at all, so the button leads to a dead page rather than a 404.

```console
$ getent hosts build.x.ai
(no output — no record)

$ getent hosts docs.x.ai
2606:4700::6812:1350  docs.x.ai

$ curl -sS https://build.x.ai/docs
curl: (6) Could not resolve host: build.x.ai
```

This is user-facing: the link sits behind the **Setup guide** button in
the harness picker, so anyone trying to connect Grok Build hits it while
looking for install instructions.

## Fix

Point the preset at xAI's live Grok Build documentation.

```diff
-        install_instructions_url: "https://build.x.ai/docs",
+        install_instructions_url: "https://docs.x.ai/build/overview",
```

The preset's `command` and `args` are **unchanged and remain correct** —
Grok Build ships native ACP over stdio, which `grok agent stdio`
exposes:

```console
$ grok agent --help
Commands:
  stdio     Run the agent over stdio
  headless  Run the agent headlessly over the Grok WebSocket relay
  ...
```

## Testing

- `just ci` passes locally (fmt, clippy, desktop lint, Tauri clippy,
unit tests, mobile tests).
- Verified `https://docs.x.ai/build/overview` resolves and returns
`200`.
- Verified the harness itself still connects: installed Grok Build
0.2.118 via `irm https://x.ai/cli/install.ps1 | iex` and confirmed `grok
agent stdio` matches the preset's invocation.

No UI changes — this is a string constant, so no screenshots apply.

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

Signed-off-by: armstrongj2001 <110222228+armstrongj2001@users.noreply.github.com>
Co-authored-by: Diem Nguyen <ngthuydiem@users.noreply.github.com>
2026-09-29 13:58:25 -07:00
Ian Oberst
14a752fb24 fix(db): audit partition catalog before creation (#6515)
## Why

The partition manager checks partition existence by name only and treats
Postgres 42P17 "would overlap partition" errors as success. On databases
where a catch-all partition (`events_p_future` /
`delivery_log_p_future`) covers future months, the monthly creation path
silently does nothing while real coverage erodes toward a write outage.
There is currently no visibility into partition catalog health and no
signal before writes start failing.

## What

- **Read-only catalog audit** in `buzz-db` (`partition.rs`): classifies
the live partition catalog for `events` and `delivery_log` via
`pg_inherits` and bound parsing — coverage bounds, catch-all detection,
anomalous children, and trigger parity checked recursively down to
routable leaf partitions.
- **Creation gated on the audit verdict**: the 42P17-to-success swallow
is removed; monthly CREATE runs only for months the audit proves
uncovered, a canonical-name mismatch is an error, and per-month outcome
capture replaces first-error abort. New
`BUZZ_PARTITION_MANAGER_CREATE_ENABLED` kill switch (default `true`).
- **Prometheus metrics** per table: `buzz_partition_serving_safe`,
`buzz_partition_uncovered_months` (audit window is now through +3
months), `buzz_partition_catch_all_covered_months`,
`buzz_partition_anomalous_children`,
`buzz_partition_trigger_parity_missing`/`_extra`,
`buzz_partition_audit_duration_seconds`,
`buzz_partition_audit_runs_total{outcome}`,
`buzz_partition_audit_failures_total`,
`buzz_partition_create_attempts_total`, and
`buzz_partition_audit_last_success_timestamp_seconds` for staleness
alerting.
- **Cached diagnostics**: `/_status.partition_catalog` reports partition
serving-safety, audit age, and freshness. `/_readiness` stays
process-local, consistent with #7341; missing, unsafe, or stale audits
do not remove pods from service.
- **Periodic audit task** in the relay, plus config surface for the
audit interval and the monthly-create kill switch. Catch-alls are
detected from catalog bounds rather than configured names.
- Doc correction: the mesh heartbeat comment no longer claims readiness
gating (it never was gated; behavior unchanged).

## Behavior changes & risk

- No DETACH/DROP/ATTACH and no schema migrations. The only DDL is the
pre-existing monthly CREATE path, now strictly narrower
(audit-proven-uncovered months only) and behind a kill switch.
- Partition audit health does not gate readiness. Until the first
completed audit, read-only retries back off from 5 seconds to a
60-second cap. Any completed audit, including unsafe coverage, restores
the normal interval.
- Partition-overlap errors now surface as errors instead of being
swallowed as success.
- Rollback: revert the commit, or set
`BUZZ_PARTITION_MANAGER_CREATE_ENABLED=false` to disable the creation
path while keeping the audit and metrics.

## Validation

- New Postgres-backed regression tests for catalog classification and
trigger parity (disabled child trigger, `ENABLE ALWAYS` parent, disabled
nested leaf clone in a multi-level layout, healthy-catalog zero-parity
checks). Each new regression was verified to fail against the previous
logic before the fix.
- Local affected-target validation passed against a disposable
current-schema database, with the known mesh forwarded-echo and tracing
callsite failures excluded. This is not a claim that the full suite
passes.

Follow-up work (separate PRs): bounded catch-all advance (empty-only
golden path; non-empty reattach only behind an explicit operator flag,
default off), fresh-install convergence, and proactive coverage-runway
alerting.
---

## Update — 2026-08-21

- Fixed nested-leaf trigger parity auditing so child-only row triggers
are checked on every routable descendant leaf and reported as
`{leaf}:{trigger}` under the owning root child.
- Added a Postgres regression test for a child-only trigger on a nested
leaf; mutation-reverting to immediate-child-only extra-trigger scanning
makes it fail.


Generated with Codex

Signed-off-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz>
Signed-off-by: Ian Oberst <ioberst@squareup.com>
Signed-off-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:43:40 -07:00
Logan Johnson
4ef23609b7 feat(mobile): show contextual names in channel conversations (#7895)
🤖
## Summary

In a channel conversation, two identities with the same display name
looked identical. In the example below, the channel shows "Bad Janet"
twice, for two different identities. A reader cannot tell which one was
added and then removed.

This PR uses the contextual names from the previous PR everywhere a
conversation names someone:

- Message authors, threads, forum posts and replies
- Reactions, the typing indicator, and system rows (joined, added,
removed)
- Inline mentions and the agent activity sheet
- The profile sheet title, which now matches the row that opened it

Names are compared against the channel's members, so a label only gets
longer when a real collision exists in that channel.

The mention picker labels each choice against every candidate, so two
"Honey" choices look different. When you pick one, it still inserts the
identity's own name ("@Honey"), so the message text does not change.

Part 2 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists,
Search, Pulse).

### Related issue

Related to #2910.

### Testing

- A widget test checks that the profile sheet names an author the same
way the channel does.
- A unit test checks that two same-name mention choices get different
labels but insert the same wire name.
- `flutter analyze` is clean, and the full `flutter test` suite passes.
- Checked on an Android emulator against the live relay, in an archived
channel with two identities named "Bad Janet".

| Before | After |
| --- | --- |
| <img width="300" alt="Before: the channel shows two different
identities both as Bad Janet"
src="https://github.com/user-attachments/assets/c7f6c345-12d8-4e11-9434-bbd2c63722db"
/> | <img width="300" alt="After: the second identity shows as Bad Janet
· sqxl"
src="https://github.com/user-attachments/assets/51c6b9fc-8900-4200-8ce0-ad1887f41dba"
/> |

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-29 15:47:43 -04:00
Will Pfleger
8519db1532 feat(buzz-relay): NIP-FI stateless enforcement (S3) — upgrade gate, NIP-42 pairing, session lifetime, JWKS warm (#7224)
## What this PR does

Implements NIP-FI stateless enforcement at the relay WebSocket upgrade
point. Every connection attempt on a NIP-FI–enabled relay must carry a
valid federated identity assertion signed for the connecting Nostr
pubkey; the relay validates it before the first frame is read, pairs it
with NIP-42 AUTH, and enforces a bounded session lifetime without
persistent state.

### Core enforcement seam

- **Assertion at upgrade** (`nip_fi_upgrade.rs`): identity header is
parsed with the same `extract_bearer_token` and denied with the same
`http_denial` as HTTP ingress (`nip_fi_http.rs`), JWT verified
(RS256/ES256/EdDSA), expiry checked, and the Nostr pubkey embedded in
the sub claim must match the NIP-42 AUTH pubkey that arrives in the
socket within the AUTH timeout. Denial returns exact byte contracts —
`401 + WWW-Authenticate: Nostr + "authentication required\n"` for
missing evidence, `403 + "evidence rejected\n"` for malformed or invalid
evidence. The active HTTP/1.1 gate runs before `bind_community`, so
denied upgrades pay zero DB cost.
- **NIP-42 pairing** (`nip_fi_gate.rs`, `audio/handler.rs`): after the
assertion is validated at upgrade, a `SessionAdmissionGate` is created
with the assertion's expiry deadline and a `CancellationToken`. The gate
is passed to every effect-acquiring path so no new effect permit is
granted after the session expires. AUTH is verified against the
assertion pubkey; a mismatched AUTH key receives `NOTICE restricted:
authorization denied` and a socket close. Every other post-upgrade AUTH
denial on an FI connection uses the same terminal shape — the canonical
spec NOTICE on the terminal queue, then close — so frame type and close
behaviour reveal nothing about private policy: ban, allowlist, or
relay-membership denial → `restricted: authorization denied`;
ban/allowlist/membership lookup failure or an internal verifier failure
→ `restricted: authorization unavailable`; an invalid NIP-42 proof →
`restricted: evidence rejected`. Connections without an assertion keep
today's `OK false` replies.
- **Session lifetime** (`nip_fi_session.rs`): three independent
deadlines bound each session — token `exp`, max assertion age from
config, and relay max connection lifetime. The first to fire cancels the
gate and sends the terminal denied frame.
- **Effect admission at expiry** (`audio/join.rs`, `handlers/req.rs`):
no new effect permit is granted after expiry. Two paths carry calculated
budgets. The audio **lease resolver** holds its permit for at most
`worst_case::PERMIT_HOLD` (14.5 s), and cancellation **during that
resolver** reaches socket close within `worst_case::EXIT` (25 s:
resolver, release of the won lease, terminal flush). Redis checkout has
one total deadline and directory scripts are single-round-trip `EVAL`,
which is what makes those budgets hold. When post-write fence
verification fails after a landed CAS, the acquire keeps the reply and
attempts an exact owner+generation cleanup on the same connection
without a serving-write lease; if that cleanup fails or its reply is
uncertain, the lease expires by its 30 s TTL. Neither number bounds
audio close in general: other admission and teardown waits (for example
the channel lookup after the resolver) and the known limitations below
have no client-side deadline. REQ liveness, search and history reads
race gate cancellation: a lost race drops the read, releases the permit,
retires the claim through `close_if_owner`, and sends no further EVENT
or EOSE. A history statement timeout ends the read with a timed-out
outcome, and its owner-checked retirement (`CLOSED`, map, index and
topic release) runs after the race with the permit dropped, so
cancellation cannot drop it halfway. Subscription registration stays one
uncancellable unit, and the permit is still taken before the claim.
- **JWKS recovery** (`main.rs`): builds on main's
`warm_nip_fi_jwks_snapshots` / `nip_fi_jwks_refresh_loop`. Issuers warm
concurrently and the first refresh is seeded from the warm result (one
interval for a warm issuer, 5 s for a cold one). A refresh that finds no
live snapshot retries at the fast cadence (5 s after a warm issuer dies,
doubling to a 300 s cap) instead of waiting a full interval, and
`run_jwks_refresh_supervisor` restarts the loop after a panic (1 s → 60
s backoff); On shutdown it is cancelled and awaited before the drain
backstop is disarmed; cancel drops an in-flight fetch and starts no
further issuer, so the join stays inside the drain bound. Logs carry
`issuer_index` only.

### Known limitations

- **Known limitations: bounded only by the dependency.** No new effect
is admitted after expiry. Two awaits that are already admitted have no
client-side bound, so if a dependency stalls at expiry, teardown waits
for it:
- **Audio join commit:** its effect permit spans `tx.commit()`, room
publication and the `event_mentions` insert. During a DB or Redis stall,
audio cleanup and socket close wait for the dependency.
- **REQ subscription setup:** the first subscriber to a topic sends
`Subscribe` to the pubsub task through a bounded channel (capacity
4096). The send blocks only if that queue is full, which happens when
the pubsub task isn't keeping up (for example during reconnect backoff
or a burst). Registration holds the connection's `conn.subscriptions`
lifecycle lock across the send, so a blocked send also blocks other
claims on that connection, including search, and any cleanup waiting on
that lock. The send itself predates this PR; holding the REQ permit
across it is new here.

### Archive/join serialization

`commit_participant_join` now takes a row-level write lock (`SELECT ...
FOR NO KEY UPDATE`) on the channel row at step 3. This serializes all
join commits against concurrent `archive_channel` calls — the archive
UPDATE (`archived_at` non-key column) blocks on the row lock, closing
the READ COMMITTED race on both the `Existing` and `AutoAddRequired`
paths.

### HTTP/2 extended-CONNECT gate (latent path)

The root WebSocket handler uses a belt-and-suspenders approach. The
HTTP/1.1 path (currently the only live WebSocket shape — workspace Axum
does not enable `http2`) is gated pre-extractor via the `Upgrade:
websocket` + `Connection: Upgrade` header predicate. A second gate point
inside the `Ok(ws)` arm is structural future-proofing: if `http2` is
ever enabled, any h2 extended-CONNECT shape the extractor accepts but
the h1 predicate misses is caught there. The active HTTP/1.1 fire-point
sits before `bind_community` (zero DB cost on denial); the latent h2
fire-point runs after it.

### Mixed-version huddle control (rolling deploy)

Owners advertise the `huddle-commit-phase` mesh capability. The ingress
selects the mode after the owner stream opens: it sends the appended
`RegisterPeerCommitPhase` variant, and later `CommitConfirmed`, only
when the owner's exact runtime gossip record carries that flag at that
point, and latches the result for the session. A successful open implies
the owner's record is present, so a record that lands during acquisition
is not missed. Otherwise it sends plain `RegisterPeer`, which every
owner decodes. A new owner publishes a plain `RegisterPeer` once at
registration (pre-commit-phase behavior) and holds a
`RegisterPeerCommitPhase` peer until its confirm; duplicate or legacy
confirms publish nothing. Existing wire variant indices are unchanged.

### Off-mode compatibility

When `BUZZ_NIP_FI_MODE=off` (or unset), the relay reads no identity
header and installs no upgrade gate, NIP-42 pairing check, or session
deadline. Existing NIP-42 AUTH flows are unchanged. Huddle
commit-before-publish is not FI-gated and applies in every mode, so
huddle join publication timing differs from main even in Off mode.
External connection cancellation (socket close, archival, revoke) now
stops an in-flight liveness, search or history read at its next await;
an uncancelled connection's frames and payload bytes are unchanged.

## Test coverage

Every test this PR adds, or whose assertions it changes, runs in exactly
one CI lane: infra-free tests in `just test-unit`, Postgres-backed tests
in `postgres_tests` modules under the PostgreSQL lane. The `buzz-relay`
bin tests run whole in `just test-unit`. `scripts/run-tests.sh` mirrors
the effective relay unit selection for machines without nextest, except
main's pre-existing `api::admin::` module. Every `buzz-relay` test
fixture reads `Config` through `Config::for_test()`, which holds
`NIP_FI_ENV_LOCK`, so a single-process `cargo test` run cannot observe a
half-written FI environment
(`fixture_config_read_waits_for_fi_env_lock`). The stub pools in the
router and AUTH fixtures use a 100ms acquire timeout, which shortens a
database fallthrough rather than removing it.

- **Upgrade/router gate** (`router::tests`): Enforce-mode `nip_fi_*`
gate tests for h1 WS upgrade, NIP-11 content negotiation, and audio
ingress. Off-mode witnesses `nip_fi_off_root_passes_without_header`,
`nip_fi_off_root_ignores_malformed_header`,
`nip_fi_off_audio_passes_without_header`,
`nip_fi_off_audio_ignores_malformed_header` prove the gate is bypassed
(status outside 401/403/503, and no-header status equals
malformed-header status); they do not prove a successful upgrade.
- **NIP-FI units**: `nip_fi_config`, `nip_fi_upgrade`, `nip_fi_gate`,
`nip_fi_session`, covering assertion contracts, NIP-42 pairing, session
lifetime, and gate behavior.
- **Effect barrier at cancellation** (`postgres_tests`, PostgreSQL
lane):
`w3_b2_req_barrier_expiry_mid_flight_blocks_subscription_registration`,
`w4_b2_count_barrier_expiry_mid_flight_blocks_count_query`.
- **AUTH denial uniformity** (`handlers::auth::tests`): each
production-handler witness asserts the exact terminal frame, no denial
on the data or ctrl channel, and cancellation, plus the unchanged
Off-mode `OK false`. Unit:
`fi_invalid_nip42_proof_emits_terminal_evidence_rejected`,
`fi_ban_check_error_emits_terminal_authorization_unavailable`,
`handle_auth_pairing_mismatch_runs_full_root_denial_path`, and
`nip42_denial_class_separates_internal_failure_from_bad_evidence`
(internal verifier failure → `authorization unavailable`; each
client-evidence NIP-42 failure → `evidence rejected`). PostgreSQL lane:
`fi_ban_denial_emits_terminal_authorization_denied` (real
`community_bans` row; Off mode keeps `OK false` on ctrl, then close),
`fi_relay_membership_denial_emits_terminal_authorization_denied`,
`fi_allowlist_check_error_emits_terminal_authorization_unavailable`,
`fi_relay_membership_check_error_emits_terminal_authorization_unavailable`,
`fix_4a_allowlist_denial_with_fi_assertion_emits_canonical_restricted_frame`.
- **Bounded audio exits** (`audio::handler::tests`, `just test-unit`):
`check_cancel_exit_with_never_ready_sink_returns_within_flush_budget`
drives the production `check_cancel!` fence with a queued FI denial into
a never-ready sink and requires it to return within
`WS_TERMINAL_FLUSH_TIMEOUT`;
`challenge_send_to_never_ready_sink_exits_within_flush_budget_on_cancel`
requires the NIP-42 challenge send to yield to cancellation and exit
within the same budget; every pre-`send_loop` audio exit write goes
through the same `connection::send_exit_frames_bounded` helper.
- **Audio NIP-42 classification** (`audio::handler::tests`, `just
test-unit`):
`audio_bad_nip42_proof_with_fi_emits_one_evidence_rejected_then_cancels`
requires exactly one canonical `restricted: evidence rejected` frame and
cancellation; `audio_bad_nip42_proof_off_mode_keeps_auth_failed_frame`
pins the Off-mode frame.
- **Effect permit before pre-auth writes** (PostgreSQL lane):
`handlers::auth::tests::postgres_tests::expiry_before_nip_oa_materialize_writes_no_rows_and_commits_no_auth`
expires the session just before NIP-OA materialize and asserts no
`users` or agent-owner row and no committed auth.
- **Community admission** (`just test-unit`):
`state::tests::on_not_run_runs_once_on_each_deny_arm_and_never_on_admit`.
- **Auth metrics contract**:
`connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge`
records every terminal including `PairingMismatch`;
`metrics::contract_tests::*` derive the series count from
`AuthOutcome::ALL`.
- **JWKS** (`main.rs`): `hard_dead_warm_issuer_resets_to_fast_cadence`
and `jwks_refresh_success_waits_interval_failure_uses_fast_backoff`
drive the real loop with paused Tokio time; `cold_issuer_*` cover the
backoff helper;
`f1_supervisor_loop_drives_recovery_and_restores_admission` runs
`run_jwks_refresh_supervisor` over a real `ProductionJwksSource` through
injected panic → restart → hard-dead → 5 s retry → recovery, then checks
`key_set` admission and that the first post-restart fetch follows the
cold cadence (every issuer restarts cold);
`jwks_refresh_cancel_drops_in_flight_fetch_and_skips_remaining_issuers`
cancels during a pending fetch with a second issuer due.
- **Bounded lease acquire** (`audio::handler::tests::postgres_tests`,
PostgreSQL lane):
`failed_post_cas_verification_is_fenced_and_discards_the_won_lease`
requires a won-then-unverified lease to return fenced and be deleted by
exact owner+generation;
`stalled_lease_sql_and_stalled_exit_release_close_within_exit_bound`
stalls lease SQL and the exit release and requires socket close within
`worst_case::EXIT`. `audio::join::tests` pins `PERMIT_HOLD` at 14.5 s
and `EXIT` at 25 s.
- **REQ/search cancellation** (`handlers::req::tests`, `just
test-unit`):
`expiry_during_stalled_history_read_releases_permit_and_claim` and
`expiry_during_stalled_search_read_releases_permit_and_claim` stall the
read inside production `handle_req`, expire the gate, and require
`expire()` to return within 2 s, an empty subscription map, and no EVENT
or EOSE.
- **History timeout retirement**
(`handlers::req::tests::postgres_tests`, PostgreSQL lane):
`history_timeout_retirement_survives_expiry` drives a two-channel
history REQ into a real statement timeout, pauses retirement after the
index removal and before the topic release, expires the gate, then
resumes. It requires the map and index entries gone and both channel
topic refcounts at zero.
- **Archive/join serialization** (PostgreSQL lane):
`f2a_archived_channel_rejects_existing_join`,
`f2b_join_for_update_blocks_concurrent_archive_existing_path`,
`f2c_join_for_update_blocks_concurrent_archive_auto_add_path`.
- **Huddle room and handler**: `audio::room::tests` pending-peer tests,
including `roster_revisions_are_ordered_and_snapshot_is_authoritative`;
`fix_b_w1_w3_handler_pre_commit_cancel_emits_no_delta`,
`fix_b_w2_success_emits_exactly_one_delta_with_monotone_revision`.
PostgreSQL lane: `b1_cross_pod_bootstrap_precedes_buffered_owner_delta`,
`commit_confirm_timeout_at_seam_triggers_teardown_on_stalled_owner_stream`,
`confirm_failure_combined_outcome_no_orphan_join_exactly_one_48102`,
`confirm_send_pending_expiry_terminates_client_with_denial_within_1s`,
`c1_handler_ingress_sends_only_base_frames_to_legacy_owner`,
`f7a_cross_pod_joined_payload_includes_owner_pod_peers`.
- **Mixed-version control** (`audio::join::tests`):
`shared_control_variants_are_byte_identical_to_base_wire` (against
frozen `audio::join::base_wire`),
`c1_new_ingress_speaks_base_wire_to_legacy_owner`,
`c2_base_ingress_register_publishes_once_on_new_owner`,
`c3_capable_owner_record_selects_commit_phase_hold_back`,
`capability_record_installed_during_open_selects_commit_phase`,
`duplicate_or_legacy_commit_confirmed_is_a_no_op`,
`fix_b_commit_confirmed_arm_commits_peer_and_broadcasts_joined`,
`pending_remote_stream_close_emits_no_joined_no_left`.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-29 14:27:40 -04:00
Ian Alloway
b9d22a273f feat(web): add Browse releases link next to invite download (#2255)
Related to https://github.com/block/buzz/issues/2093

Invite pages offered a platform-resolved binary download with no path to
the GitHub releases listing for users who wanted information first.

### Changes
- Keep the smart **Download it now** CTA
- Add an always-visible **Browse releases** link pointing at
`https://github.com/block/buzz/releases`
- Extend invite smoke coverage for the new link

Note: the marketing-site “Get the App” button on buzz.xyz appears to
live outside this repository; this improves the in-repo web invite flow.

Signed-off-by: Ian Alloway <ianalloway@usf.edu>
Signed-off-by: Fizz <300bd4cedcdddb62f77935de51c301034bdb4cd97da9aaca46d59673a1ebc7cf@buzz.block.builderlab.xyz>
2026-09-29 10:27:08 -07:00
Zach Meyer
12423f2868 docs(nips): fix stray angle brackets in created_at clauses (#4486)
## Summary

The `<` in a `created_at<` condition is the comparison operator, but in
three places it had been paired with a closing `>` as if it opened a
`<placeholder>` bracket:

- **NIP-OA** clause list: `created_at<unix-timestamp>` →
`created_at<unix-timestamp`
- **NIP-OA** satisfaction rule: `created_at<t>` → `created_at<t`
- **NIP-GS** "Conditions in Git Context": `created_at<t>` →
`created_at<t` (line 437 of the same file already used the correct form)

This matches the `created_at>...` forms used alongside them, which never
carried a closing bracket. Also normalizes the NIP-OA headings to ATX
style and pretty-prints the signed-event JSON example for readability.

## Verification

Docs-only change. The NIP-OA test vectors are untouched and were
re-verified by recomputing them: the preimage SHA-256, `tag-bytes-hex`,
and the signed-event `id` all still match the documented values.

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

Signed-off-by: zmeyer44 <zmmeyer44@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-29 10:02:30 -07:00
Tom Brow
45c288c179 docs: specify desktop-driven mobile push suppression (#7809)
## Summary

Define when desktop activity suppresses otherwise eligible mobile
message notifications: a ten-minute inactivity window, short-lived
relay-owned suppression, and per-installation push preferences. Include
a shared glossary and renewal timing diagram. The gateway remains
unaware of suppression.

This is a design draft ready for review, not an implementation-ready
protocol. Wire format, synchronization, and several failure-handling
details remain explicitly open. Please also review the tension between
the agreed relay-confirmed preference changes and VISION_MOBILE.md's
requirement that stopping notifications must not depend on a relay
response.

### Related issue

Related: #3234 covers per-channel notification settings and push
filtering; this spec focuses on desktop-activity suppression. No
duplicate spec PR found.

### Testing

Documentation-only change. Local Markdown links and anchors resolve;
whitespace validation and commit hooks passed. The full local CI gate
could not complete: dependency compilation exhausted local disk space
(OS error 28). GitHub checks currently report no failures.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Diem Nguyen <ngthuydiem@users.noreply.github.com>
2026-09-29 09:54:08 -07:00
Logan Johnson
efe04288ea feat(mobile): add the contextual identity-name resolver (#7894)
🤖
## Summary

When two identities share a display name, Buzz mobile shows the same
label for both. A reader cannot tell a person from an agent, or one
person's agent from another's. For example, a channel can show three
agents and one human all named "murderbot".

This PR adds the naming rules that fix this, without changing any screen
yet. It ports the contextual identity-name contract (v1) from the
desktop app to Dart. Given the identities shown together, the resolver
gives each public key a distinct label:

- A human keeps the plain name. Agents that share it get a qualifier.
- The viewer's own agents are named before other people's agents.
- An agent is qualified by its owner's name first ("Wes's Honey"). A
short public-key suffix is added only if that is still ambiguous
("baxen's murderbot · xa8v").

It also adds two providers that screens will use:

- `channelIdentityNamesProvider` compares names against a channel's
members, plus any referenced non-member such as an old author or an
outside mention.
- `watchIdentityNames` compares a set of identities shown together
outside a channel, and loads their profiles.

The next PRs in this stack connect these to the screens.

Part 1 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists,
Search, Pulse).

### Related issue

Related to #2910 (default "Fizz" agents from different users collide in
one channel).

### Testing

- The contract's 31 portable fixture cases run against the production
resolver. The fixture file is copied unchanged from the contract, so
reviewers can skip it.
- Unit tests cover owner and suffix qualification, the viewer's own
identities, and invalid public keys.
- Provider tests check that a channel keeps its names while its roster
reloads, that an offline cached bot still counts as an agent, and that
changing relay or account never shows the old roster.
- `flutter analyze` is clean.

No UI change, so there are no screenshots.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-29 12:21:14 -04:00
Luke Tornquist
bba75a71eb Automate owner deletion preparation (#7830)
## Why

Owner deletion admission stops at `submitted`. Manual inventory and
approval still stand between owner intent and the existing deletion
executor, preventing self-serve deletion.

## What

The existing `buzz-admin deletions drain` process prepares and
automatically approves operator-attested owner requests. Operator-origin
requests retain manual approval.

## How

The drain prioritizes approved work, then claims one owner submission
with the existing generation lease. It freezes inventory and records
digest-bound `owner_automatic` approval in one transaction before
entering the unchanged executor.

Before approval, the transaction rechecks archived state and current
ownership. It locks the deletion advisory fence, community, owner
memberships, and request in that order. A regression test pins
privileged abort during a live preparation lease.

What got simpler: one drain still owns preparation and execution. There
is no second worker, queue, lifecycle, or retry authority. The rebased
branch contains only this feature and its review fixes.

## Risk

Automatic approval permits irreversible deletion without a human grace
period. Operator attestation is not a cryptographic owner signature.
Provenance, locked authority checks, generation fences, and digest-bound
approval constrain the operation.

This remains work in progress. Broad test failures and incomplete mobile
checks are disclosed below; publication is not a merge-ready verdict.

## Testing

Published head: `e059ae9056b447ca3b5d0c2893da86550ef16f5c`. All seven
commit trees match tested source
`5605f27eaece1e809f746cc1839b55c7ceef8d80`; only attribution metadata
changed. Source-SHA results below are not new-head execution receipts.

At source commit `5605f27eaece1e809f746cc1839b55c7ceef8d80`, isolated
PostgreSQL tests showed stale archive and ownership fixtures fail closed
after the fix. Before the fix both reached automatic approval. Removing
abort's generation increment or stage transition made the new live-lease
regression fail; restored production passed.

`just test` failed in two ACP native-git fixtures and one agent
body-timeout test. The failing source files match the pinned base, but
there is no exact-base execution comparison proving those failures
unrelated. `just test-unit` also failed in the ACP fixtures, leaving 18
tests unrun. `just ci` stopped in Flutter tooling package resolution;
separately run non-mobile lanes do not constitute a full `just ci` pass.

No live tenant deletion or human testing was performed. A dedicated
populated `0052` → `0053` approval-backfill fixture remains a coverage
gap. Final-head hosted CI and security review are required.

## Bigger picture

Rebased directly onto main `12670bd0f037c66a682272bb81c46c3f254fad74`;
prerequisite #7818/#7827 are already merged. Main owns
`0052_channel_artifacts`, so this feature's automatic-approval migration
is now `0053`. Client/KGoose and quota work are separate PRs and are not
part of this diff.

Generated with Codex

---------

Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-09-29 11:39:33 -04:00
Bradley Axen
12670bd0f0 feat(relay): implement NIP-AR channel artifacts (#7919)
## Summary

Implements NIP-AR channel artifacts (spec merged in #7791) on the relay,
after a simplicity pass over the earlier two-agent implementation.

An artifact is an editable record (kind 45010) with a stable identity
(`d`), one home channel (`h`), and full-snapshot revisions chained by
`prev`:

- **Writes:** one transaction per write. It locks the artifact's head
row, requires `prev` to name the current revision, records the revision
in a ledger that survives retention, and advances the head. Two
competing edits can't both win. A stale edit gets `conflict: artifact
head changed`. Resubmitting an accepted event succeeds without applying
it twice.
- **Permissions:** writes pass the same gates as a kind 9 message in
`h`. A move also passes them in the source channel. The move stores the
destination snapshot and a relay-signed kind 45011 removal marker for
the source in the same transaction, so both channels recover by replay.
The marker never names the destination.
- **Moderation:** redaction is the ordinary kind 9005 removal. The
ledger keeps the ID so `prev` checks still work. Redacting the current
revision retires the artifact.
- **Reads:** HTTP `/query` and `/count` accept explicit `artifact:
current|history` filters with exact multi-character tag matching (e.g.
`#assignee`). Generic REQ and search return only current revisions. Kind
5 against artifacts is rejected. NIP-11 advertises the limits.
- **Spec:** NIP-AR updated to match: kind 9 permission checks, 9005
redaction and retirement, no creator-only delete rule.

A separate small commit routes `insert_channel_head_checked` through the
metered writer pool (`acquire_writer`), like every other event write in
that module.

### Related issue

Follows #7791. No separate issue found.

### Testing

- Clippy clean.
- Unit lane passes. Failures seen along the way came from agent env
leaking into `buzz-acp` tests (they pass in a clean env) and known
timing flakes (`keepalive_resets_idle_past_deadline`, two `buzz-agent`
databricks tests that passed on retry). This diff doesn't touch those
crates.
- Postgres lane passes, including new `artifact_postgres_tests` in
`buzz-db` and `buzz-relay`. Three presence tests need `REDIS_URL` set.
- Docker smoke against a fresh local relay, 29/29: create and idempotent
resubmit, update and stale conflict, current, `#assignee` and history
queries, generic REQ, kind 5 rejection, delete and restore, move with
source marker, private-channel isolation, and 9005 retirement.
- A pasteable curl walkthrough of the same flows was shared with the
reviewer.

Draft until the human hands-on check (AGENTS.md step 3) is confirmed.
The pre-push hook was skipped because every lane above had already run
on this tree. CI is the gate.

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

---------

Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Signed-off-by: murderbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-authored-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Smartie <fb3185faacfc3760b6b4fe0085a8878d8dd537f7a7671c7d3184fe1aa1b04df4@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
Co-authored-by: murderbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
2026-09-28 16:52:09 -06:00
crgallego
c96553eb13 fix(desktop): resolve unlisted project channel requests (#7619)
## Summary
- resolve project channel-creation approvals through the authoritative
home-channel selector
- permit owner-visible unlisted project homes when their channel and
repository binding match
- add regression coverage for the unlisted authoritative-home path

## Validation
- `just ci`
- focused project-home regression tests (10/10)
- full Desktop suite (6,451/6,451)
- `pnpm check`
- `pnpm build`

## Manual verification
After merging into a Desktop build, approve the pending LBX Copywriting
workroom request from the Litbox OS project.

Signed-off-by: crgallego <chris.cokerconstruction@gmail.com>
Co-authored-by: crgallego <chris.cokerconstruction@gmail.com>
2026-09-28 15:46:07 -07:00
Luke Tornquist
6918dd3a83 Schedule the deletion drain safely (#7827)
## Why

The deletion engine already exposes a one-shot `buzz-admin deletions
drain` command, but operators have no safe way to schedule it. A generic
workload wrapper could expose arbitrary commands or inherit relay
credentials, which would add a second control plane and widen the blast
radius.

## What

This PR adds an opt-in Kubernetes CronJob for the typed deletion drain
command. It is stacked on #7818, which adds owner-request admission.

## How

The chart keeps the job disabled by default. The CronJob uses `Forbid`,
zero Kubernetes retries, bounded runtime and history, and only the
database, Redis, and object-store settings required by the executor.
PostgreSQL leases, checkpoints, and retry timing remain the sole durable
retry authority.

The closed `_operator-jobs.tpl` helper centralizes safe CronJob
mechanics without creating an arbitrary command or environment registry.
This leaves the touched system simpler than a general job framework
would.

## Risk

The deployment blast radius is low because the job is disabled by
default. When enabled, it can run destructive deletion work that the
database has already approved, so the chart limits concurrency, retries,
credentials, and execution time.

## Testing

Rendered every `deploy/charts/buzz/ci/*.yaml` fixture with `helm
template`; all passed at `e68cb753bb6673aa1baa60046969bdb2d4738b62`.

Ran the full independent Desktop, Tauri, and web lanes; all passed at
`e68cb753bb6673aa1baa60046969bdb2d4738b62`. Flutter package resolution
made no progress within three minutes, so the mobile lane is not claimed
green.

## Bigger picture

A later stack layer must inventory and approve admitted owner requests.
Until then, this scheduler cannot make a new owner request destructive.

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
chart-v0.1.10
2026-09-28 18:23:42 -04:00
Tom Brow
662014d544 fix(mobile): stale community selection during mobile invite setup (#7951)
Buzz sets up starter channels when a person accepts an invite. If the
phone already had a saved community, the first attempt could fail. Retry
could then succeed.

A delayed read could replace the new active community with the old one.
The read now leaves the saved selection unchanged. The fix also delays
channel-status updates until the channel provider finishes
initialization.

### Before and after

Controlled Flutter app captures against local relays, with the same
delayed storage read. The before capture restores the pre-fix
active-community provider in the test harness. The after capture uses
the fixed provider. These are separate from the native iOS validation
below.

| Before: first attempt fails | After: first attempt succeeds |
| --- | --- |
| ![Before: starter channel setup error with Retry setup
button](https://github.com/user-attachments/assets/14d8ba45-6e53-40ae-bc10-f219f08d2f92)
| ![After: joined community with Continue to welcome channel
button](https://github.com/user-attachments/assets/7dc32d9d-3aae-4378-913c-8ac68cc7d930)
|

### Validation
- Regression tests reproduce the delayed-read ordering and passing
timing control; removing the channel sequencing safeguards makes their
regressions fail.
- Production app against local relays passes the forced race on the
first attempt.
- Native iOS simulator with an existing saved community accepted the
original invite using a fresh identity, joined on the first attempt, and
opened the welcome channel without Retry.

Signed-off-by: Tom Brow <tomb@block.xyz>
mobile-v0.19.0-rc.1 mobile-v0.18.0-rc.2
2026-09-28 14:44:43 -07:00
Logan Johnson
f1e50be410 test(desktop): wait for channel head refresh before paging thread summary test (#7955)
🤖

## Summary
Main's Desktop Smoke E2E (4) job fails on `scroll-history.spec.ts`
("thread summary badge survives a retained older-history prepend"). This
change fixes the test's timing. It does not change app code.

When a channel opens, the app starts a live subscription. When that
subscription is ready, the app fetches the newest page again, and that
fetch replaces the whole loaded history. This test scrolled up before
that refresh happened. On slower CI runners, the refresh came after the
older page loaded but before it showed on screen. The older rows went
away, the reader stayed at the top, and no more pages loaded. All three
attempts failed that way.

The test now calls `waitForMockChannelHeadReady` before it scrolls, like
the other pagination tests in this file already do. That helper waits
for the live subscription and its refresh to finish.

### Related issue
Main CI failure at `15d44dcc`
(https://github.com/block/buzz/actions/runs/36470599751/job/109092076235).
The same failure was reported on #7827.

### Testing
- Added temporary logs to see the order of events. Locally, the refresh
ran after the older page had loaded: the loaded history went from 2
pages back to 1. The test passed only because it checked before the next
render. With the wait, the refresh runs first and nothing replaces the
loaded pages later.
- `playwright test --project=smoke tests/e2e/scroll-history.spec.ts`: 18
passed. This test with `--repeat-each=8`: 8 passed.

### Follow-up (not in this PR)
The app has the same race for real users. If someone scrolls up before
the live subscription is ready, or during a reconnect, the refresh can
drop the older history they loaded. If they are at the top of the list,
paging stops until they scroll down and back up.

Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-28 14:44:16 -06:00
Will Pfleger
9b1c4f98e2 🤖 perf: bound long-thread aux reads and make query deadlines terminal (#7854)
Long threads (over ~100 replies) time out in desktop. The aux hop that
loads reactions and other e-tag references builds its filter as one
`tags @> …` term per id, joined with `OR`. Past about 100 ids the
planner switches to walking each partition's primary key backward,
expecting to stop early. When few rows actually match, it scans the
whole community. With a dirty GIN pending list, a 188-reply thread never
finishes.

### What changes

- **Set-based e-tag reads (`buzz-db`).** The aux e-tag query now puts
the id set behind a `MATERIALIZED` CTE and matches with `ANY`. Each
e-tag read also runs under a transaction-local `statement_timeout` of 20
s.
- **Terminal timeout contract (`buzz-relay`).** When a read hits the
deadline, the client gets a terminal timeout (`CLOSED` for REQ, the
timeout error for COUNT and search), not a retryable failure.
- **Subscription lifecycle (`buzz-relay`).** The per-connection
subscription map lock is now the lifecycle lock. Claims, replacement,
client `CLOSE`, owner-checked timeout and search cleanup, revoke, and
connection teardown all change the map, the registry, topic refcounts
and terminal frames while holding it. As a result, a timed-out
subscription's `CLOSED` goes out before any replacement with the same id
can claim it, and claims made after teardown are refused.
- **Dropped terminal frame disconnects (`buzz-relay`).** If the `CLOSED`
for a retired subscription (owner-checked timeout or channel eviction)
can't be queued, the relay cancels the connection instead of leaving the
client holding a subscription that will never receive fan-out. A
client-initiated `CLOSE` ack is exempt.
- **COUNT under the same deadline (`buzz-db`).** The fast COUNT path
runs e-tag counts under the same transaction-local 20 s cap as SELECT,
so WS COUNT and HTTP `/count` produce the terminal timeout too.
- **Replica reads surface the deadline (`buzz-db`).** When a
replica-routed read is cancelled by `statement_timeout` (SQLSTATE
`57014`), the error is returned instead of re-running the query on the
writer, which would run the same slow statement. Other replica errors
still fall back to the writer.
`PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md` records the exception.
- **Deadline handling (desktop).** Only the relay's `query timed out`
counts as a query deadline. A client-side `request timed out` stays
retryable. For one-shot reads a deadline is terminal: the global query
retry (budget 1), thread-replies retry and reaction hydration don't
re-send the same slow query. Reaction ids held back by a deadline are
released when the channel is opened again, so reopening retries them
while same-channel re-renders don't. Live subscriptions, in both the
renderer and the native archive-sync socket, keep treating a deadline
`CLOSED` as transient: they back off and resubscribe with the same
filter instead of being dropped.
- **Deadline handling (mobile).** Mobile recognizes the deadline on both
transports: HTTP `503 {"error":"query timed out"}` and the WS `CLOSED`
reason.
- The root `ProviderScope` retry policy returns no retry for deadline
errors and keeps Riverpod's default backoff for everything else.
  - Background thread-summary recounts don't retry a deadline.
- Forum posts polling pauses while the last load settled on a deadline;
reopening the forum retries.
- A channel's newest-window load surfaces a deadline instead of falling
back to legacy WS history, and the channel list's batched queries return
"unavailable" on a deadline instead of fanning out to per-filter
fallbacks. An unavailable batch is not treated as empty. Known
last-message timestamps and unread state are kept, and unread catch-up
moves `lastMessageAt` forward (never backward) the same way live events
do, so a cold start with no known timestamp still gets one.
- A live subscription that gets a deadline `CLOSED` backs off and
resubscribes with the same filter. History requests still fail on it.
- Settled load failures show a Retry control, matching desktop's
thread-replies error card: a shared `LoadErrorView` on the channel
timeline, forum thread and forum posts list, and an icon button beside
the thread replies summary. While the thread query has no value, the
thread page shows the route snapshot plus live, cached and optimistic
replies, with the loading or error status still visible.
- Other re-sends (reconnect, foreground resume, live events, the unread
backstop, scroll) are not suppressed; each is bounded by the relay's 20
s statement deadline.
- **Long-thread seeder.** `just seed-long-thread [replies]` posts one
thread (default 187 replies) with some reactions to the local dev relay,
for reproducing long threads in the apps. It uses a throwaway key,
refuses non-loopback relays and ignores ambient `BUZZ_*` credentials.
Writes are paced to fit the stock relay rate limit.

**Lifecycle concurrency.** The lock serializes one connection's
subscription changes. Other connections can still be delayed by pressure
on the shared pubsub command queue, and by serial eviction loops waiting
behind a blocked connection. This PR does not claim strict isolation
between connections.

**Recovery vs. load.** Clients stop automatic retries of the same
one-shot read, but they don't remember deadlines across other triggers:
reconnect, resume, live events and the unread backstop can each send the
read again later, bounded by the 20 s statement deadline. A live
subscription that hits the deadline resubscribes with its original
filter under the existing 1–30 s backoff, and against a filter that
keeps timing out it retries at the 30 s cap indefinitely, so the worst
case is one bounded query per subscription every 30 s. `since` is
deliberately not advanced to now on resubscribe, because that would skip
events missed while the subscription was closed. Unchanged from `main`:
when mobile's channel history sync fails with cached messages available,
the cached messages are shown without the error or Retry control.


### Benchmark

Local reproduction on an isolated bench stack (not committed):
- PG16, migrations applied, one 188-reply thread plus 5M background
events in the same community.
- Each request replays the desktop thread-replies filter through the
relay, with `include_aux` on. Times are total ms for 3 runs, with a 60 s
client cap.

GIN pending-list state is controlled with `VACUUM` (clean) or by
refilling the pending list (dirty), and recorded per partition with
`pgstatginindex`. Dirty runs held 402 pending pages on every partition's
tags index at the start and end of each matrix. Clean runs held 0–1
pages (relay startup writes).

| build | GIN pending state | limit 10 | 50 | 100 | 200 (188 ids) |
|---|---|---|---|---|---|
| `main` | clean | 26 / 16 / 13 | 56 / 51 / 52 | 92 / 93 / 95 | 662 /
639 / 641 |
| `main` | dirty | 315 / 298 / 327 | 22141 / 15888 / 16769 | 28992 /
27819 / 28080 | timeout ×3 (>60 s) |
| this PR | clean | 35 / 21 / 14 | 57 / 49 / 48 | 82 / 80 / 75 | 153 /
150 / 135 |
| this PR | dirty | 290 / 254 / 251 | 1257 / 1204 / 1206 | 2432 / 2378 /
2382 | 4333 / 4706 / 5104 |

Baseline `origin/main` `b37e47721`; this PR at `5f2650f5f`, whose e-tag
read builders are identical to the current head. At head `1ad81d868`
against `origin/main` `15d44dcce`, limit 200 measured 145–179 ms clean
(`main` 683–715) and 4.41–4.51 s dirty (`main` timeout ×3).

Every completed cell returns the same signed event set: 209 events, 187
replies at limit 200. With `include_aux` off, every cell is ≤66 ms. The
thread-window loader (`select_aux`) is unchanged from `main`, and its
timings and captured plans match `main`'s.

Actual aux-hop statements captured from each relay binary with
`auto_explain` (analyze + buffers), execution ms:

| build | clean, 100 ids | clean, 188 ids | dirty, 100 ids | dirty, 188
ids |
|---|---|---|---|---|
| `main` (N-way `OR`) | 40 | 298 | 28785 | 62892 |
| this PR (`MATERIALIZED` fence + `ANY`) | 23 | 45 | 1992 | 3539 |

**What the fence changes**
- On `main`, the planner combines `ORDER BY created_at … LIMIT` with an
overestimated match count and walks each partition's primary key
backward, expecting to stop early. With few real matches it scans the
whole community: the dirty 188-id statement touches ~4.9M shared buffers
on `main` vs ~0.6M with the fence.
- The fence prevents that early-stop plan. Across the measured cases
(1–188 ids, both pending states), the planner used the GIN tags index.
The fence doesn't force GIN in general; a broad enough filter could
still get a different plan.

**Tradeoff:** the fence materializes every match before sorting. In this
bench small limits stayed within run-to-run noise of `main`. A single
hot id (5,000 reactions, limit 100) measured 30–34 ms unfenced vs 35 ms
fenced.

**Deadline:** each e-tag read (SELECT and COUNT) runs under a
transaction-local `statement_timeout` of 20 s. It only tightens: a
shorter operator timeout (`BUZZ_DB_STATEMENT_TIMEOUT_MS`) is kept. It's
a per-statement limit, not a per-request one, so a request that runs
several pages or hops can take longer than 20 s in total. The benchmark
does not force the deadline.

---------

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>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-28 16:36:40 -04:00
Jordan Mecom
cc36d25bec Add native HPKE encryption for nsec backups (#7849)
## Summary

Add a Rust API that encrypts an nsec into a backup envelope using RFC
9180 Base mode: DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, and AES-256-GCM.

---------

Signed-off-by: jm <jm@squareup.com>
Signed-off-by: Jordan Mecom <jm@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-28 12:46:43 -07:00
Bradley Axen
548718e042 test(desktop): scope video menu e2e probes to emitted messages (#7953)
## Summary

Fixes the `Desktop Smoke E2E (4)` failure on `main`. The test at
`desktop/tests/e2e/video-attachment.spec.ts` ("right-click menus expose
distinct selectors for links, relay video, and off-relay video") fails
3/3 attempts on main and on every PR branch since ~Sept 25. Because
"Desktop" is a required check, that blocks merges.

**Cause:** the relay-video and off-relay-video probes picked their
player with `getByTestId("video-player").last()`. When mock messages
share a second, timeline ordering can put a different video last. The
locator then resolves to the wrong player, and the "Download video" menu
detaches before the click, so the test times out.

**Fix:** scope each probe to the `data-message-id` of the message
`emitVideoMessage` just returned. Test-only change, 16 lines.

## Attribution

This is a cherry-pick of `dfd5db4e0` by @tornquist, originally part of
#7827 (the deletion-drain cron, which is stacked on another feature
branch). It's pulled out on its own so main can go green without waiting
on that stack. Authorship is preserved.

## Testing

- `pnpm build:e2e && playwright test tests/e2e/video-attachment.spec.ts
--project=smoke`: 13/13 passed locally, including the target test.

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

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: murderbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-authored-by: tornquist <tornquist@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-28 19:46:14 +00:00
Luke Tornquist
15d44dccee Add owner deletion admission control plane (#7818)
## Summary

Adds the relay and database admission boundary for owner-requested
deletion of archived hosted communities.

The operator endpoint records accepted owner intent with stable UUID
idempotency and immutable owner/operator provenance. Admission
serializes with unarchive and owner transfer, and accepted requests
disappear from actionable owner listings.

This keeps destructive execution out of the request path. The existing
durable deletion engine remains the only executor, so this change adds
no lifecycle state or second deletion mechanism.

Complexity stays bounded: one durable request is both the idempotency
record and the mutation fence.

### Related issue

None found.

### Testing

- `REDIS_URL=redis://127.0.0.1:6379
BUZZ_POSTGRES_ADMIN_URL=postgres://buzz:buzz_dev@localhost:5432/postgres
PGHOST=localhost PGPORT=5432 PGUSER=buzz PGPASSWORD=buzz_dev
PG_BIN_DIR=/tmp/buzz-pg-bin ./scripts/postgres-test-run.sh -p buzz-db -p
buzz-relay --lib --tests` — 385 passed.
- `cargo test -p buzz-db` — passed.
- `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay --all-targets -- -D warnings` — passed.
- `just desktop-test desktop-build desktop-tauri-check
desktop-tauri-test web-build` — passed; Desktop ran 6,589 tests.
- Full `just test` could not start uncached Keycloak dependencies
because `quay.io` reset connections. Full `just ci` reached Flutter
dependency resolution, then `pub.dev` reset connections.

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-09-28 15:12:15 -04:00
Will Pfleger
52c4c08c9e fix(sidebar): converge stale-at-open state across devices (sections/sort/stars/mutes) (#7805)
Adds bounded-backoff recovery reads to all four sidebar sync hooks
(sections, sort, stars and mutes). A stale relay read at app open now
converges without a user edit or a reconnect.

## What this does

**Shared recovery hook (`useStaleReaderRecovery`).** All four hooks use
one retry effect. It reads immediately on mount, then at 5, 10, 30 and
60 s, staying at 60 s after that. A change of `visibilityState` to
`visible` triggers an immediate read. Reads are single-flight. The
effect restarts whenever `pubkey` or `relayUrl` changes, so a hook that
stays mounted while switching relays drops the old relay's in-flight
reads.

**Guards that stop a recovery read from rolling back a local edit:**

1. **Skip the read while an edit is pending.** If an edit is pending
when a tick starts, the tick skips its read and just schedules the next
one. Without this, the read could return the pre-edit blob after the
edit published and pending cleared. At that point neither apply-time
guard could reject it.
2. **Pending check inside the state updater.** `hasPending()` and the
effect lifetime are re-read inside `setStore(prev => ...)`. That way the
check runs when React executes the updater, not when the response
arrives.
3. **Local-mutation revision.** Each hook counts its local edits. The
count is snapshotted when a read starts and checked again at apply time.
If an edit landed while the read was in flight, the response is
discarded.
4. **Head advanced only after a successful cache write.** A failed
`write*Store()` leaves the same head retryable on the next tick.

**Ownership-aware pending clear.** Each sync manager's
`doPublish(store)` now clears `pendingStore` only while it still holds
that same `store`, on both the identical-payload early return and the
success path. Before, an older publish that finished after a newer edit
had replaced `pendingStore` wiped the newer edit's pending marker, so a
recovery read before the newer debounce could apply the older relay head
over it. `destroy()` still clears unconditionally.

## Scope

Reader-side recovery plus the ownership-aware pending clear. No
outboxes, arbitration or replay changes. It applies to
`useChannelSections`, `useChannelSortPreference`, `useChannelStars` and
`useChannelMutes`.

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

---------

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 12:48:16 -06: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
Luke Tornquist
b37e477211 Make relay readiness process-local (#7341)
## Summary

Make Kubernetes readiness depend only on the relay process lifecycle, so
a shared Postgres or Redis slowdown cannot withdraw every pod at once.

Move dependency checks to one fixed-cadence loop per pod. The loop
permits only one evaluation at a time, emits metrics after each sample,
and caches a timestamped report. `/_status` reads that cache without
calling Postgres, Redis, or the deletion catalog. It reports
`not_yet_sampled`, `fresh`, or `stale`. Each completed sample also
writes a Unix timestamp gauge, so Datadog can compute sample age at
query time.

Fail closed when the community lifecycle lookup errors. An inconclusive
tenant-state lookup no longer admits a socket to AUTH or REQ handling.

### Related issue

None found. This addresses the elevated relay HTTP 500 and reconnect
incident investigated on 2026-09-03.

### Tradeoff

The readiness gauge now reports the latest private-probe observation,
not the shutdown transition itself. After shutdown starts, a scrape can
still see `1` until the next readiness probe refreshes the gauge to `0`.
Kubernetes routing is unaffected because it uses the readiness response
from the authoritative process state.

A terminal `0` was never guaranteed: even a transition-owned gauge could
update in process and then exit before Prometheus scraped it. If no
scrape occurs before exit, both designs miss the terminal sample. The
new design adds at most the interval until the next readiness probe when
a scrape occurs during shutdown.

Dependency status is now sampled every 30 seconds rather than on demand.
A slow evaluation delays the next sample instead of stacking more work.
The status payload exposes the cached report age. The completion
timestamp stays unchanged when sampling stops, so `time() - timestamp`
crosses the stale threshold without a server aging loop. Outcome
counters and duration histograms remain cumulative and cannot provide
no-data detection.

### Testing

Gimli exercised exact SHA `edff1ec4a5362e132e367bf7449d2b0a68f1e9a9` on
an isolated Blox stack. Repeated `/_status` bursts caused no dependency
calls, metrics advanced without status traffic, stale status remained
visible, and lifecycle lookup failures admitted no AUTH or REQ handling.
Follow-up verification at `fdbb394ed598c94cde85ed01cb11e9de0ee8f00d`
confirmed sampler-owned outcome and duration emission without status
traffic and bound the real startup seam. The final timestamp-gauge
change is covered by targeted red/green tests and the same boot
regression at `c0f36e22b527c2fd6a63a548d036d006ffa17171`.

### Complexity

Readiness remains one process-local sample per probe. Dependency I/O now
has one owner and one execution path; request volume cannot increase
dependency-check load. One completion timestamp replaces the prior
server-side age update path; Datadog computes age without another loop.
Community admission also returns to one fail-closed rule for inactive
and unknown tenant state.

Generated with [Claude Code](https://claude.ai/code)

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-09-28 14:02:12 -04:00
Will Pfleger
447951a19c 🤖 docs(nip-fi): remove implementation references from the spec (#7912)
This removes implementation references and implementation status from
`docs/nips/NIP-FI.md` so the spec holds normative text only. It drops
the Blossom compliance note, the known-gap notes, crate/file paths and
PR numbers, roadmap "supersedes" sentences, doc-history lines, and the
design-rationale note on session-only vs deny-until-TTL (its restart
re-push SHOULD remains stated normatively elsewhere).

Two wording changes carry normative weight: `PUT /media/upload` is
listed as a plain kind-24242 upload route instead of a temporary alias,
and the JWKS fetcher's SSRF controls become a MUST instead of a
description.

It doesn't depend on any enforcement PR and can merge at any time.

Related: #7265

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-28 16:56:52 +00:00
Jarrod Sibbison
ebe99a46e8 fix(relay): fail startup on invalid operator listener config (#7933)
Addresses point 3 of [previous
comment](https://github.com/block/buzz/pull/7793#pullrequestreview-5333444423)
"3. Fail fast on a bad BUZZ_OPERATOR_LISTENERS (config.rs:857)"

## Summary
- Propagate malformed `BUZZ_OPERATOR_LISTENERS` values from
`Config::from_env` as `ConfigError`, including invalid UTF-8.
- Update startup config tests to assert returned errors and restore the
original admin warning capture helper, removing the shared helper
introduced only for listener log assertions.
- Cover valid and absent listener values through `Config::from_env`.

## Context
Follow-up to the review of #7793 specifically [reverting some config
logging
parts](https://github.com/block/buzz/pull/7793/changes#diff-eaa0eeb209ff1ad6212ffa56d3a983bb732858647c9fb227ef7483a3363b5a71).
Invalid listener configuration currently logs an error and silently
disables listener delivery.

## Verification
- `cargo fmt --all -- --check` passed with Hermit.

Signed-off-by: Implementor <691cca7a870db1dad6990d5938d1b4a2a2ca9647ca81290b536c17b06b2e473f@buzz.block.builderlab.xyz>
Co-authored-by: Implementor <691cca7a870db1dad6990d5938d1b4a2a2ca9647ca81290b536c17b06b2e473f@buzz.block.builderlab.xyz>
2026-09-28 23:18:15 +10:00
Jarrod Sibbison
1846094b31 fix(db): limit event transactions to listener mention kinds (#7932)
## Summary

- Keep non-listener-mention event writes on the plain insert path
without opening a transaction.
- Preserve the transaction around listener mention event insertion and
outbox enqueue.

## Validation

- `cargo fmt --all -- --check` (Hermit)
- Tests not run.

Follow-up to Baxen’s review of #7793, point 1.

Signed-off-by: Implementor <691cca7a870db1dad6990d5938d1b4a2a2ca9647ca81290b536c17b06b2e473f@buzz.block.builderlab.xyz>
Co-authored-by: Implementor <691cca7a870db1dad6990d5938d1b4a2a2ca9647ca81290b536c17b06b2e473f@buzz.block.builderlab.xyz>
2026-09-28 23:17:40 +10:00
Jarrod Sibbison
c4c86006f2 feat(relay): deliver pubkey mentions to relay companions (#7793)
We are introducing a companion service to the relay that needs pubkey
event mentions for pubkeys it manages. This PR introduces an outbox for
pubkey mentions that will be sent to these "operator listener" services.

## Summary

Deliver mentions of registered pubkeys to deployment-global operator
listeners.

- Configure listeners with `BUZZ_OPERATOR_LISTENERS`; listeners manage
target pubkeys through NIP-98-authenticated `POST`/`DELETE
/operator/listener/pubkeys` requests.
- During supported event ingestion, match `p` tags to registrations and
insert one outbox row per listener in the event transaction.
- A background worker claims up to 10 rows, sends NIP-98-signed
notifications, and retries transient failures with exponential backoff
for up to 9 attempts (idle polling backs off from 250 ms to 2 s).
- Outbox rows are ineligible for delivery after 15 minutes and are
reaped every 5 minutes. Registered pubkeys live for 30 days;
re-registering renews them, and a daily reaper removes expired
registrations.
- Keep persistence app-managed: no database triggers, functions, foreign
keys, or separate match queue.

## Changed behavior

Listener outbox rows are enqueued inside the shared event-insert and
thread-metadata transactions; enqueue failure rolls back the event.
`event_mentions` behavior is unchanged: ordinary event/reaction mention
indexing remains post-commit best effort with warning-only failures.

## Testing

- `cargo fmt --all`
- `cargo test -p buzz-db --lib`
- `cargo test -p buzz-relay --lib
config::tests::operator_listener_routes`
- `cargo check -p buzz-db -p buzz-relay`
2026-09-28 13:04:40 +10:00
Bradley Axen
b0d6fb8ad2 docs(protocol): propose simplified channel artifacts (#7791)
## Summary

Adds a short, proposal-only **NIP-AR: Channel Artifacts** in
`docs/nips/NIP-AR.md`, as a simpler alternative to the closed #7771.

- Defines complete-snapshot artifact events with stable identity and
atomic, `prev`-based revisions.
- Uses channel access and posting permissions for reads, edits, moves,
and writer-authorized deletion.
- Keeps client-defined content opaque to the relay, with exact tag
filtering over current accessible state and bounded queries.
- Covers durable source removal on moves, terminal deletion, and safe
client fallbacks without defining task/project schemas or changing
application behavior.

### Remaining design work

This is a compact draft, not a complete implementation-ready wire
contract. Concrete query request/pagination/error/limit-discovery
formats, removal/replay wire details, and numeric query limits remain
follow-up work. Prefer extending shared WebSocket `REQ` / HTTP `/query`
surfaces rather than adding plugin-specific endpoints. No database
layout or scale guarantees are proposed.

### Related issue

Closest prior proposal: #7771 (closed). Searched existing PRs and issues
for channel artifacts; no matching issue found.

### Testing

- Verified the committed document is byte-for-byte identical to the
reviewed local draft.
- Parsed the example JSON and its embedded JSON content; checked unique
two-element example tags.
- Reviewed the entire one-file diff and ran `git diff --check`.
- Commit and push hooks passed (including DCO sign-off and differential
file-size checks).
- Full application CI was not run locally: this changes only a Markdown
proposal, with no implementation or UI changes.

---------

Signed-off-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
Signed-off-by: Smartie <fb3185faacfc3760b6b4fe0085a8878d8dd537f7a7671c7d3184fe1aa1b04df4@buzz.block.builderlab.xyz>
Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
Co-authored-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Smartie <fb3185faacfc3760b6b4fe0085a8878d8dd537f7a7671c7d3184fe1aa1b04df4@buzz.block.builderlab.xyz>
2026-09-26 09:07:57 -07:00
Tom Brow
781d39510c feat(push): support configurable HTTP(S) delivery URLs (#7877)
The relay can use a configurable HTTP(S) gateway delivery URL and signs
the same URL it sends. The gateway verifies NIP-98 against the received
request URL, including authority, port, path and query. App Attest,
scoped grants, replay checks and delivery quotas are unchanged.

Forwarding proxies must preserve Host and supply the original request
scheme through X-Forwarded-Proto. The NIP relay-delivery wording now
defines the configured HTTP(S) URL as the signed URL and labels the
public URL as an example.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-25 18:57:49 -07:00
Tom Brow
02c6309f53 fix(ci): select runtime suites from PR changes only (#7843)
A documentation-only update to #7809 selected desktop builds and E2E
tests because path detection compared an old PR base SHA with GitHub's
newer synthetic merge commit. The [failing run's path-detection
log](https://github.com/block/buzz/actions/runs/35789957773/job/106955810630)
includes four unrelated desktop files from `main`; `VISION_MOBILE.md`
did not match a runtime filter.

Use GitHub's PR file list for pull requests so unrelated changes in the
synthetic merge commit cannot select runtime suites. The existing
directory filters are unchanged: root and docs/ Markdown do not select
runtime suites, while Markdown under runtime directories still selects
its affected suites. Mixed code/documentation changes retain normal
coverage. Always-on security, policy, and source-contract checks and
full push-to-main coverage are unchanged.

Added regression coverage runs the pinned paths-filter action against
real fixture repositories, including a synthetic merge containing
unrelated upstream desktop code. All 35 selection scenarios and 26
required-check scenarios pass; the new regressions failed before the
fix. Existing required-context isolation, file-size policy, and
security-review contract checks pass, as do script lint and workflow
syntax validation. Full workflow lint reports the same two pre-existing
shell-quoting findings in the untouched dead-token guard. A desktop E2E
build was run to diagnose the unrelated required smoke failure.

Related: #7809 (incident, unchanged) and #5756 (shared-input path
coverage, separate scope). No matching issue found.

PRs with at least 3,000 changed files now select every runtime suite,
avoiding GitHub's PR-file-list ceiling. Boundary tests cover 2,999,
3,000, and 3,001 files, including a runtime file omitted beyond the API
cap; disabling the safeguard makes the latter two regressions fail.

Required checks now run and fail when path selection fails, is
cancelled, or is skipped. Regression coverage exercises the workflow
conditions and shell checks for all 13 required wrappers, plus an
API-denial case against the pinned action. Mutating either the
scheduling guard or the result check makes all 13 failure regressions
fail.

The required mention-settings smoke test expected a pin after explicitly
disabling automatic mentions. Waiting for its old avatar to exit
reproduced the CI failure 3/3; the test now checks that subsequent
mentions remain manual and retains outgoing recipient-tag assertions.
Corrected browser regression: 20/20 repeated runs; related picker unit
tests: 18/18. No desktop production behavior changed. The always()
requirement is now pinned in required-wrapper regression tests; deleting
it fails all 26 gate cases.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-25 23:19:32 +00:00
Bradley Axen
a1fb6645d9 test(desktop): synchronize upload edit smoke test (#7903)
## Summary
Fix the upload/edit smoke-test race observed on main at
`6410e685a80d42db0645fadc9bbe710559ef911e`.

- Hold the mock upload at the Tauri IPC boundary and release it
explicitly after checking edit rejection, rather than assuming menu
interaction finishes within one second.
- Wait for the Radix menu to unmount: edit dispatch happens in
`onCloseAutoFocus`, after the click. Without that wait, the negative
assertion can pass before the edit callback actually runs.
- Keep the existing attachment-preservation and subsequent
successful-edit assertions. No production code or CI coverage is
changed.

## Evidence and scope
- [Main
failure](https://github.com/block/buzz/actions/runs/36189213149/job/108250341388):
`opening edit during an immediate photo upload preserves the draft`,
including an upload-progress timeout on retry.
- Investigated from #7791, which changes only `docs/nips/NIP-AR.md`. Its
PostgreSQL failures and video-menu timeout are separate; PostgreSQL
passed on this main run, and the video spec passed locally. This PR does
not claim to fix those failures or the separate pointer-interception
failure seen in the main job.
- #7843 already addresses unrelated runtime suites being selected for
docs-only PRs.

## Validation
- Full desktop `pnpm test`: 6,676 Node tests + 92 jsdom tests passed.
- Upload/edit regression repeated 5 times: passed. Holding the upload
without waiting for menu teardown failed all 5 runs, demonstrating the
second race.
- Complete file-attachment and video-attachment Playwright specs: 30
passed.
- Pre-push desktop lint, typecheck, full desktop tests, and file-size
gate passed at `46e3d4392e5b56dcb74ea864aa19aa10287f27d3`.
- `just ci` attempted: initial formatting issue corrected; second run
exceeded the 5-minute local limit during Tauri clippy. Not claiming full
repository CI green.

## Review / human verification
Self-reviewed the diff against the test contract; no production behavior
changes. Draft pending human verification and CI.

To verify: activate Hermit, then `cd desktop && pnpm test:e2e:smoke
file-attachment.spec.ts --grep "opening edit during" --repeat-each=5`.
Expect all five runs to pass, retaining the upload in the draft and
allowing edit only after upload release.

Originating conversation:
buzz://message?channel=aa7f2b48-d367-4ca1-be09-116a2eb45b99&id=1b68585ba4483a87f5a453fd7bbdd11d434e332dcb4aff8acfe1f5d2601b6561

Signed-off-by: Smartie <fb3185faacfc3760b6b4fe0085a8878d8dd537f7a7671c7d3184fe1aa1b04df4@buzz.block.builderlab.xyz>
Co-authored-by: Smartie <fb3185faacfc3760b6b4fe0085a8878d8dd537f7a7671c7d3184fe1aa1b04df4@buzz.block.builderlab.xyz>
2026-09-25 22:52:02 +00:00
Will Pfleger
6410e685a8 feat(relay): enforce NIP-FI assertion+NIP-98 pairing on HTTP ingress (#7264)
Implements NIP-FI HTTP ingress enforcement per the merged spec (PR
#7254). Every protected HTTP surface in enforce mode requires a valid
`Nostr-Federated-Identity: Bearer <JWS>` assertion alongside its NIP-98
authorization, bound to the same key. The NIP-98 pubkey (proven actor)
must equal the assertion's `nostr_pubkey`; absent, mismatched, or
unverifiable assertions are denied fail-closed with byte-exact
text/plain responses per the spec's rejection table.

This is S5 of the `buzz-enterprise-identity` program. WS enforcement
(S3) is separate.

## What changed

**New `nip_fi_http` module:** `admit_nip_fi_http()` covers the full
admission sequence — `Nostr-Federated-Identity` extraction, offline JWKS
verification, NIP-98 key pairing [FI-INV-05], issuer-qualified deny-map
check via `(iss, pubkey, now)` [FI-INV-14], and byte-exact denial for
all four `DenialClass` variants. `admit_nip_fi_http_on_state()` is the
single call-site wrapper for all surfaces [FI-TRACE-AUTHORITY-UNIFORM].

**Protected surfaces** (all gated via the uniform authority):
- HTTP bridge: `POST /events`, `/query`, `/count`
- GIF proxy: `POST /gifs/search`, `/gifs/share`
- Workflow reads: `GET /workflows/{id}/runs`, `GET
/workflows/{id}/runs/{id}/approvals`
- Moderation reads: `GET /moderation/reports`, `/moderation/audit`,
`/moderation/restricted`
- Media/Blossom: `PUT /upload`, `/media/upload`
- Git smart-HTTP: `info/refs`, `upload-pack`, `receive-pack`
- Git settings control plane: `GET`/`POST
/git/:owner/:repo/default-branch` (now routed through
`admit_nip_fi_http_on_state` for full assertion key-pairing)
- Invites: `POST /api/invites` (mint only; claim exempt as
pre-membership enrollment door)

**NIP-98 strictness in NIP-FI mode:**
- When NIP-FI is not Off, bridge POST handlers force `require_auth_token
= true` — the `X-Pubkey` dev-mode fallback cannot satisfy the pairing
requirement [NIP-FI.md:547-567]
- POST bodies on `/events`, `/query`, `/count` require a matching
`payload` tag in enforce mode [NIP-FI.md:579-597]
- A payload tag present with no content (one-element `["payload"]` or
empty string) is rejected as a structurally invalid claim — NIP-98
requires the content to be a valid 64-char lowercase hex SHA-256 digest.
Malformed payload diagnostics use a static error message (no
attacker-controlled content in logs).
- Off mode preserves pre-NIP-FI behavior per [FI-INV-15]

**Off-mode compatibility changes:** Admission now runs before request
validation on every protected surface, so Off mode differs from
pre-NIP-FI behavior in these ways:
- **GIF routes:** NIP-98 authentication happens before the upstream
provider lookup. An unauthenticated request on a tenant with no GIF
provider now returns 401 instead of 404. The 401 goes through the legacy
`api_error()` path, so the body is JSON `{"error":"..."}` with no
`WWW-Authenticate` header.
- **Workflow runs** (`GET /workflows/{id}/runs`): the query string is
parsed after admission. A request with a missing credential and a
malformed query (for example `?limit=abc`) now returns 401 instead of
400.
- **Authorized malformed query:** once admitted, a malformed query
returns a JSON 400 `{"error":"invalid query: ..."}` from
`parse_query_or_400` instead of axum's `text/plain` `Query` rejection.
- **Moderation reads** (`/moderation/reports`, `/moderation/audit`):
same extraction change as workflow runs. Admission comes first, then a
JSON 400 for a malformed query.
- **Malformed `payload` tag:** shared NIP-98 verification now rejects a
malformed `payload` tag in every mode, including on bodyless requests.
That means a one-element `["payload"]`, an empty digest, or anything
other than 64 lowercase hex characters. Before this change, a bodyless
request ignored the tag.

**Auth header cardinality:** Enforce mode rejects requests with more
than one `Authorization` header with 403 `evidence rejected\n`.
DenyProtected returns 503 `authorization unavailable\n` before this
check (and before NIP-98 or assertion verification) runs. The
cardinality gate is enforced at the NIP-FI admission boundary
(`admit_nip_fi_http`), not via legacy auth boolean flags, so it applies
uniformly to all Enforce-mode surfaces including Git. Off mode passes
through the first header value unchanged, preserving pre-NIP-FI behavior
[FI-INV-15].

**Denial-class remapping:** In Enforce mode, NIP-98
extraction/verification failures from the closure are mapped to the
appropriate `DenialClass`: `MissingEvidence` when no `Authorization`
header is present, `EvidenceRejected` when the header is present but
invalid. Off mode propagates legacy responses unchanged [FI-INV-15]; for
example, the Git missing-Authorization and wrong-scheme 401s keep their
legacy bytes with no `content-type` header.

**JWKS refresh scheduling:** The refresh timer's `*last` checkpoint is
set after the fetch completes, not from the pre-fetch timestamp,
preventing interval drift under network latency. The loop is extracted
to `nip_fi_jwks_refresh_loop` for unit-testability.

**Log hygiene [NIP-FI.md:777-779]:** Issuer identity is never written to
logs or error messages. All startup/refresh diagnostics use an ordinal
index (`issuer_index = N`). Config parse errors for malformed issuers
use classify()/line/column codes rather than raw config values.

**Admin SPA document routes:** `/reports`, `/reports/{id}`, and
`/feedback` are exempt from the NIP-FI assertion guard only on the admin
host (host-qualified via `is_admin_host`). These paths are not added to
the broad `NIP_FI_EXEMPT_PREFIXES` list — tenant hosts remain fully
protected.

**Deny-map seam (`HttpDenyMap` trait):** signature is `(issuer, pubkey,
now)` matching `NipFiDenyMap::is_denied` from S4 (PR #7265) so
integration is a one-liner. Stub is `AlwaysAdmitStubDenyMap` — named to
accurately describe stub-phase semantics (fail-open intentionally
pending S4).

**Route inventory:** every authenticated HTTP route is classified as
Protected or Exempt with justification. Documented in the F4 seam-test
comment in `bridge.rs`.

**Outer-guard tests:** `#[ignore = "requires Postgres"]` tests in
`bridge.rs` drive the full router (`build_router`) in Enforce mode with
valid NIP-98 but no `Nostr-Federated-Identity` header. These exercise
the **outer `nip_fi_assertion_guard` middleware** in `router.rs` — the
layer that fires before any handler runs. They prove the guard's exact
response bytes (401 `authentication required\n`, `text/plain;
charset=utf-8`, `WWW-Authenticate: Nostr`) for each protected route.
Per-handler `admit_nip_fi_http_on_state` wiring is proven separately by
the cardinality test and the media/settings/git handler-level suites.

## Seams

**S3 seam:** `nip_fi_config.rs` is duplicated (S3 not landed;
session-deadline parts stripped). Removed when S5 rebases onto S3.

**S4 seam:** `AlwaysAdmitStubDenyMap` until S4 provides the real map.
Integration is one line at the `admit_nip_fi_http_on_state` wrapper.

## Evidence

At head `ee338ea9c` (branch `hayt/nip-fi-http-enforcement`), hosted CI
run
[36167450853](https://github.com/block/buzz/actions/runs/36167450853):
- **Rust / Unit Tests** ([job
108179338458](https://github.com/block/buzz/actions/runs/36167450853/job/108179338458)):
passed. The `buzz-relay` `test-unit` step ran 171 tests with 171 passes.
Its PASS lines include all 57 non-ignored tests this PR adds to the
relay: `nip_fi_http::tests` (25), `nip_fi_config::tests` (8),
`router::tests` (8), `api::parse_query_tests` (5),
`api::git::transport::off_mode_precedence_tests` (5),
`tests::jwks_refresh_*` (3), and `composition_tests` (3). The same job
ran the `buzz-auth` suite, which includes
`nip_fi::jwks::tests::key_set_serves_live_snapshot_under_reader_and_refresh_overlap`.
- **Rust / Rust Lint**: passed.
- **Relay and PostgreSQL / Backend Integration (relay e2e)**: passed.
- **PostgreSQL Domain / PostgreSQL Tests** ([job
108183303436](https://github.com/block/buzz/actions/runs/36167450853/job/108183303436)):
passed. 542 tests run, 542 passed.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
mobile-v0.18.0-rc.1
2026-09-25 21:01:31 +00:00