Commit Graph

195 Commits

Author SHA1 Message Date
Logan Johnson
3255e910ef fix(relay): route live REQ events by each filter's own channel scope
A live REQ took one request-wide routing scope from its filters' aggregate
`#h`. If any filter lacked `#h`, the whole subscription became global, so:

- `{kinds:[39000], "#d":[ch]}` got history and EOSE but never a later
  replacement, because relay-signed group-state events are stored under
  their channel and global subscriptions never see channel events;
- in a mix of named and unnamed filters, the named filter lost live
  delivery too.

Each filter now carries its own server-resolved live scope. A filter
with `#h` routes under the channels it names and may read. A filter
without `#h` keeps the global topic, and a group-state filter also routes
under the readable channels its `#d` names. Fan-out delivers only through
a filter that both matches the event and holds its channel, so a channel
one filter names never reaches a `#P` or `#t` sibling (the confinement
from #8469). The subscription retains each topic in its filters' union
once; CLOSE, same-ID replacement and revocation release it once.

Also runs the subscription registry unit tests in the unit lanes; no
lane selected them before.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-21 10:54:56 -04:00
Luke Tornquist
039e1c8d59 feat(relay): write the NIP-43 member list in the membership transaction (#8482)
## Summary

The relay-signed NIP-43 member list (kind:13534) was published after
each membership change had already committed. If that publish failed, or
if a change never republished at all (startup `bootstrap_owner`), the
list stayed wrong until the background reconcile sweep found and fixed
it. #8467 moved that sweep off the startup path. This PR removes the
reason the sweep exists.

Now every `buzz-db` function that changes `relay_members` writes the new
list **in the same transaction**, as its last statement before commit:
- It takes the per-community list lock last, reads the members, signs
the list, retires the old one and inserts the new one.
- If the list write fails, the membership change rolls back and the
caller gets an error.
- A call that changes nothing (already a member, role mismatch, host
exists, limit reached) writes no list.

The functions return `MembershipWrite<T> { outcome, snapshot }`. Callers
push `snapshot` to live subscribers after commit. That push is the only
thing left outside the transaction; if it fails, live clients see the
change on their next fetch, and the stored list is already correct.

Write paths covered (RPR #2, every write path): `add_relay_member`,
`claim_relay_membership`, `claim_relay_invite`, `remove_relay_member`,
`remove_relay_member_if_role`, `update_relay_member_role`,
`bootstrap_owner` / `provision_owner`, `transfer_ownership`,
`backfill_from_allowlist`, and the owner insert in
`create_community_with_owner[_and_nip_fi_issuers]`. Community deletion
removes the members together with the community, so it writes no list.

The relay keypair is passed in explicitly instead of being stored on
`Db`, so the compiler finds every caller.

### Behavior changes

- **Open relays get an accurate list too.** Provisioning and ownership
transfer used to publish only when `require_relay_membership` was on.
The write is now unconditional.
- **Same-second changes can't reuse an id.** The list's `created_at` is
one second past the newest earlier list for that community, including
retired ones, or the current time if that is later. Before, adding and
then removing a member in the same second could produce an event with
the same id as an earlier list. The insert then failed, and the stale
list stayed live. A burst of changes can push `created_at` ahead of the
clock by up to one second per change; it returns to the clock once the
burst ends.
- **`buzz-admin add-member` / `remove-member`** no longer build their
own list (`publish_membership_list_with_bump` is deleted). They push the
list the database wrote. Concurrent CLI runs now serialize on the
database lock rather than depending on `run.sh` running one at a time.
- **Owner mutations lock the community row `FOR NO KEY UPDATE`.**
`transfer_ownership` and `bootstrap_owner` used `FOR UPDATE`. The list
insert takes `FOR KEY SHARE` on that row through `events.community_id`'s
foreign key, so a repair holding the list lock, or a removal or role
update holding a member row, deadlocked with a concurrent owner change.
`FOR NO KEY UPDATE` still conflicts with archive, unarchive and deletion
admission, so the lifecycle ordering is unchanged.
- **Startup delivers its list to other replicas.** When backfill or
`bootstrap_owner` changes members at startup, the relay fans the newest
list out through pub/sub once its state is ready, so clients connected
to other replicas during a rolling deploy see an owner rotation without
a refetch.
- **New `buzz-admin republish-members`.** Rewrites the community's list
from the stored members in one community-write transaction and pushes it
live. This replaces the sweep as the repair for a list that is missing
or stale: a `relay_members` change made directly in SQL, a restore, or a
deployment that turns on `BUZZ_REQUIRE_RELAY_MEMBERSHIP` after running
without it.

## Rollout

Nothing rewrites a list automatically once the sweep is gone. A
community whose list drifted before this deploys stays wrong until its
next membership change or until an operator runs `buzz-admin
republish-members`. Either deploy #8467 and let its sweep complete one
pass first, or run `republish-members` for each community after this
deploys. The command repairs only the community that `RELAY_URL` maps
to, so on a relay hosting many communities, run it once per community
host.

## What got simpler

Deleted:
- `reconcile_nip43_membership_snapshots`,
`spawn_nip43_membership_reconciler`, its leader lock key and #8467's
standby retry
- the three `nip43_membership_snapshot_needs_reconciliation*` drift
reads (one already deprecated)
- `publish_nip43_membership_list` / `publish_nip43_membership_locked`,
the separate after-commit republish at every call site, and
`buzz-admin`'s duplicate publisher
- the reconcile metrics `buzz_nip43_membership_reconciliations_total`
and `buzz_nip43_membership_reconciliation_failures_total`
- the after-commit publish metrics
`buzz_nip43_membership_publications_total` and
`buzz_nip43_membership_publication_seconds`, and the
`publish_nip43_membership_locked` transaction metric label. A failed
list write is now a failed membership write and shows up in the caller's
existing error handling and the transaction metrics of its write path.
- the `BUZZ_NIP43_RECONCILE_INTERVAL_SECS` setting

There is now one way the list gets written, and it can't disagree with
`relay_members` after commit.

**Unused deploy config (outside this repo):** hosted deployments that
set `BUZZ_NIP43_RECONCILE_INTERVAL_SECS` can drop it. It is now ignored.
Dashboards or alerts on the four deleted metrics will stop receiving
data.

## Testing

New Postgres tests (`buzz-db`):
- `relay_members::every_membership_write_commits_its_list`: for each
write path, the returned list is the single live kind:13534 and names
exactly the committed members. No-op calls return no list and leave the
live list alone.
- `relay_members::a_failed_list_write_rolls_back_the_membership_change`:
holds the list lock, waits for the join to block on it, cancels that
statement, and checks that the member was not added and the previous
list is still live.
- `relay_members::concurrent_joins_all_land_in_the_list`: 8 concurrent
joins; the final list names all 8.
-
`relay_members::same_second_return_to_an_earlier_membership_writes_a_new_list`:
add, remove, add, remove within a second; every list is newer than the
last and no id repeats.
- `relay_members::owner_transfer_and_list_repair_both_commit` and
`relay_members::owner_transfer_and_member_removal_both_commit`: hold a
repair (list lock) or removal (member row) open, wait until a transfer
blocks behind it, then finish it. Both transactions commit; with `FOR
UPDATE` back in place, one of them fails.
- `community::create_community_with_owner_is_atomic_and_create_only` and
`relay_invite::bounded_claim_exhausts_and_existing_member_retry_does_not_consume`
now also check the list.

Each new test was checked to fail against a mutation of the new code
that brings back the old behavior (details in the thread).

Stacked on #8467. Merge order: #8467, then #8468, then this.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-20 11:39:13 -04:00
klopez4212
c9e35f8ac7 fix(mobile): restore Solo chats from channel labels (#8466)
Restore Solo chats and project placement on mobile using `buzz.solo`
labels and creator metadata. Invalidate stale channel details when
identity changes so opening and sending preserve refreshed Solo
identity; shared chats retain ordinary sending. New mobile Solo chats
publish the discovery label, and the personal send guard enforces
ownership even when route hints or legacy placement suggest Solo.

The current desktop producer lives in `block/buzz-app`. [Contract
documentation](0201ca0572/docs/solo-mobile-contract.md)
links its production creation call to relay identity generation and
mobile regression coverage. Public metadata fields are documented.

Validation: clean analysis; 3,542 mobile tests (5 skipped), 4 push
checks, and 280 core unit/doc tests passed. Removing the cache fix
reproduces the send failure. Local push hooks were skipped with owner
approval after an unrelated terminal timeout and disk exhaustion; full
local CI is not green. GitHub CI must validate the pushed commit. The
initial build was installed/launched on iPhone; follow-up native and
live cross-client confirmation remain pending.

The ownership regression supplies a valid owned member recipient and
checks the exact rejection. A New chat UI test captures the real signed
kind-9007 command. Both regressions were mutation-verified: removing the
ownership guard or the production creation label causes the
corresponding test to fail.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-20 11:10:24 -04:00
Wes
edbfbf08db test(mobile): journey-first testing guidance and suite cleanup (#8397)
## Summary

This PR does two things. It updates the mobile testing conventions to
favor a few journey tests over many small logic tests. It also applies
those conventions to the existing mobile suite, one commit per step.

### Testing conventions (`AGENTS.md`, `mobile/README.md`)

- Start with one widget journey per user-visible flow, driven through
the real widgets, notifiers and coordinators. Assert what the user sees
and what is durably saved.
- Cover retry, cancel and resume as steps inside the journey, not as
separate tests.
- Fake only external boundaries (network, relay, OS, browser, storage).
- Use focused, table-driven tests only for security and data-loss
invariants, protocol or crypto vectors, and pure functions with many
input combinations.
- When review finds a defect, extend an existing journey or table
instead of adding a test per finding.
- Delete a lower-level test only once a journey demonstrably catches the
same failures.
- Every test must fail when the production code it guards is broken.

These conventions keep the Review-Proven Rules' safety bar and change
only how coverage is organized.

### Suite inventory and plan

The suite has 234 Dart test files, about 110k lines and about 2,600
declared tests. Native tests are separate: BuzzPushKit, RunnerTests,
NativeControlTests and Android. Line-saving estimates are hypotheses,
not a deletion budget. Each deletion needs a replacement that rejects a
representative production mutation.

1. **CI and reliability.** Run the native tests CI skipped, make
time-dependent tests deterministic, and make tests that could not fail
falsifiable. *(done, see below)*
2. **Shared harness.** Add one fake relay (socket plus HTTP boundary,
with the real `RelaySessionNotifier`) in `test/helpers/`. Today 38 files
each write their own `RelaySessionNotifier` fake. *(done, see below)*
3. **Per-area journeys.** Convert page tests that fake the flow's own
notifiers (for example, `channel_detail_page_test` overrides about 60
providers). Fold near-duplicates into tables. Remove constant-pinning,
pixel-geometry and call-count tests once a journey covers the outcome.
Split `channel_detail_page_test.dart` (17.7k lines). Each area commit
reports before/after counts and the mutation it rejects.
4. **CI speed.** Shard by file instead of by test, since every shard
currently compiles the whole suite. *(done, see below)*

Keep as-is: relay protocol and NIP handling, crypto vectors, auth, push
security, persistence and journal invariants, recipient routing, and
real-socket transport and cancellation tests.

### Step 1 changes

- **Android CI** runs the 23 JVM unit tests, which were never run
before. It retries the APK build once, only on SDK or plugin download
failures (the cause of 4 recent `main` failures).
- **iOS CI** runs all RunnerTests (previously 2 of 99) and the
previously uncalled Me-agent accessibility script. The Swift Native lane
builds the simulator app once, in the background while the simulator
boots and runs the native-control scripts, instead of twice. The lane
stays non-required.
- **Stale RunnerTests.** Two reaction-details tests still expected the
filter list that #8280 replaced; they now cover the toggle list. #8393
moved navigation bar controls onto a shared glass surface and made
unknown Tabler symbols assert. The avatar and members-activity tests now
find controls on that surface, three tests use real symbol names, and a
240 pt title width pinned to the old control sizes is removed (its gap
assertions remain).
- **Known bug, not fixed here.** Since #8393 the DM title avatar image
is nil, because `makeItem` returns avatar data as a custom-view item. No
screen passes a title avatar yet. The test marks it `XCTExpectFailure`.
- **Typing retry** and **sections backoff** tests use fake time instead
of about 37 s of real sleeps and a `Stopwatch < 100ms` threshold.
- **Manage-channel sheet tests** previously asserted inside callbacks
that the sheet's `catch (_)` swallows. A wrong saved description made
the test loop until killed. The tests now record arguments, assert
outside the caught code, and cancel instead of re-presenting endlessly.
- **`video_viewer_transport_test` removed.** It exercised only
`package:http`. Removing the production `request.sink.close()` already
fails `video_viewer_test`.

### Step 2 changes

- **`test/helpers/fake_relay.dart`** fakes only the websocket (through
the session's socket factory) and the HTTP bridge (through its client).
The real `RelaySessionNotifier` still connects from `build()`, handles
OK and CLOSED, batches events, backs off and replays subscriptions.
- The fake serves the NIP-CW channel window. It can hold OK and live
echo independently, reject a chosen publish, and drop the connection.
Any request it cannot answer faithfully fails the test at teardown,
because the app often catches relay errors and falls back.
- **`channel_messages_journey_test`**: history through the channel
window, a live message, a send that is pending until OK and stays one
row after the echo, a rejected send rolled back, then drop and reconnect
with missed and later messages.
- **`channel_send_journey_test`**: type and send on `ChannelDetailPage`
with no feature-notifier overrides. The row and sending spinner show at
once, OK settles them, and a rejected send puts the text back in the
composer and shows a snackbar.
- Existing per-file relay fakes and `SidebarRelay` are unchanged. Step 3
migrates them area by area.

### Step 4 changes

- `scripts/mobile-test-shard.sh` splits the 235 test files across the 3
shards, balanced by line count. `flutter test --total-shards` split
tests, so each shard compiled and loaded every file. Locally at 4
workers, one shard took 63 s instead of 73 s and used 40% less CPU.
`channel_detail_page_test.dart` (17.7k lines) is now the long pole;
splitting it in step 3 shortens the slowest shard.

Out of scope here: requiring branches to be up to date before merge (a
repository setting), and the onboarding tests in #8375.

## Validation

- Mutation checks:
  - Typing retry bound: test fails.
  - Sections backoff reset: test fails.
- Wrong saved description: fails in about 11 s. The old tests hung
instead.
  - Removed sink close: `video_viewer_test` fails.
- Changed Dart files pass locally, and `flutter analyze` is clean.
- Android `testDebugUnitTest` passes locally (23 tests). The Me-agent
accessibility script passes on a local simulator.
- Step 2 mutation checks, each made a journey fail: accepted OK ignored,
rejected row not rolled back, optimistic row not added, draft not
restored, local message left pending after OK, channel window replaced
by the fallback, live subscriptions not replayed after reconnect.
- Both journeys run in about 1 s on fake time.
- All 99 RunnerTests pass on an iPhone 16 iOS 26.5 simulator, the CI
destination, after rebasing on #8398.
- The shard script partitions all 235 test files exactly once.

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

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:19:07 -06:00
Will Pfleger
91e7b469b8 fix(relay): wait for the peer's Close before dropping any WebSocket (#8426)
🤖 A NIP-42 login refused for a NIP-FI key mismatch could reach the
client as an abnormal close (`1006`, no reason) instead of the `NOTICE
"restricted: authorization denied"` followed by close `1008` that every
other authorization denial produces.

The relay did write the NOTICE and the `1008` Close, but the connection
task dropped TCP as soon as the writer finished, before the peer
finished the close handshake. The client could then lose frames it
hadn't read yet. It depends on timing, so it showed up intermittently
and is not specific to key pairing.

## Relay route

- `recv_loop` now hands back the read half even while a frame handler is
still running. Frames are handled one at a time while the connection is
live. Once it is cancelled, an in-flight handler (for example a Nostr
`CLOSE` waiting on the subscription lock behind a REQ stuck in Redis) is
spawned to finish instead of holding the reader. It is never dropped
partway, so subscription retirement stays atomic, and cleanup joins it
before the expiry barrier and application cleanup.
- As soon as the writer finishes, `await_client_close` keeps the
transport open until the client's Close arrives or the stream ends. It
runs concurrently with joining the heartbeat, auth-timeout, expiry and
auth-cancel tasks and the connection's application cleanup
(subscriptions released, registry entry removed, presence cleared).
Cleanup never waits on the peer, so a nonresponsive client cannot delay
registry counts, presence or `has_admitted_connection`. The socket never
waits on cleanup, so a stalled Redis (the pool has no wait timeout) or
an expiry join blocked on its quiescence barrier cannot hold the socket
and its file descriptor open past the bounded wait. The
active-connection gauge and the connection permit are released after
both finish.
- The connection permit (`BUZZ_MAX_CONNECTIONS`) is now taken at the
start of `handle_connection`, before the NIP-FI gate and the
community-active bootstrap check, and held through the active handler or
the refusal drain and its bounded wait. Previously a socket in
bootstrap, or refused there, held no permit, so a burst of refusals
could open sockets beyond the limit. Over the limit, the socket is still
dropped with no frames. `buzz_ws_connections_active` still counts only
connections that reached the active handler.

## Huddle-audio route

- The relay and audio bootstrap drains share one bounded helper. A
socket refused before admission waits after its community registry entry
is released.
- Every exit of the audio handler gets the bounded peer wait. Exits
whose cleanup follows their terminal frames (`room_full`, `room_ended`,
version mismatch, archived channel, owner rejection or unreachable,
failed commit confirmation) and the normal teardown hand the socket to a
background task that waits for the client's Close, so lease releases,
`48102` writes and room archiving on Postgres and Redis never hold the
socket open. The remaining exits send their frames last, and
`handle_active_audio_connection` waits once they return. The wrapper
joins the background task before returning, so the audio connection
permit is held until the socket ends, as on the relay route.
- Audio error exits now follow their existing JSON error with a Close
frame:

  | Exit | Close code |
  | --- | --- |
  | `huddle_relay_draining` | `1012` service restart |
| `join_rejected` (owner resolution failed), `huddle_owner_unreachable`,
failed channel lookup | `1011` internal error |
| `huddle_audio_unavailable`, `unsupported_version`, `upgrade_required`,
`room_full`, `room_ended`, archived channel, owner rejection, NIP-FI
authorization denial | `1008` policy |

Audio login failures and authorization refusals outside NIP-FI
`authorization_denied` still send only their existing error frame, then
get the bounded wait.
- The audio read half is an enum, so the hand-off has no `expect()` that
can panic.

## Timing

- The peer wait is bounded by `WS_TERMINAL_FLUSH_TIMEOUT` (1s), and
drains to end-of-stream so a client-initiated Close gets its reply. The
terminal writer flush has its own 1s budget, so the worst case for
terminal transport work is about 2s per refused or closed socket.
- Terminal frame writes (`flush_terminal_frames`,
`send_exit_frames_bounded`) still give up on the first failure, but a
failed or timed-out send is now logged at `debug` instead of being
discarded.
- The graceful-shutdown comment in `main.rs` now describes per-stage
budgets (restart jitter, restart-Close acknowledgement, terminal flush,
peer wait) rather than a per-socket total. The 30s hard drain bounds
every socket, and the 35s process shutdown budget fits within the
chart's 60s `terminationGracePeriodSeconds`.

## Tests

Real-socket tests in the infra-free relay lane of `just test-unit` and
`scripts/run-tests.sh` unless marked PostgreSQL:

- The key-mismatch login and a session-expiry denial (as a control) both
assert NOTICE then `1008` / `authorization denied` on the wire.
- A raw-TCP client that never answers Close: the relay holds the socket
for at least the bounded wait, has deregistered the connection by the
time the socket ends, and ends it on its own under a 10s watchdog.
- A raw-TCP client that withholds its Close until the connection has
deregistered: the relay ends the socket in under the 1s cap, which fails
if cleanup waits on the peer.
- A raw-TCP client that never answers while final cleanup is stalled at
a test pause point: the socket still ends after the bounded wait.
- A raw-TCP client that never answers Close on the relay bootstrap
drain, the audio bootstrap drain and an early audio-handler exit: the
relay holds each socket for the bounded wait.
- On the relay route with a one-slot budget through the real
`handle_connection`: a refused bootstrap holds the slot until its socket
ends, and a bootstrap stalled on the community check already holds the
slot before the AUTH challenge is sent.
- An established relay session whose reader is blocked in a Nostr
`CLOSE` handler on a held subscription lock when the session expires:
the socket ends within the bounded wait while the lock is held, and
cleanup completes with the subscription retired once it is released.
- An established relay session whose expiry task is stalled behind a
held session effect permit: the socket still ends within the bounded
wait.
- PostgreSQL: an established audio session whose teardown is stalled
after its Close, and a stalled archived-channel exit, both end within
the bounded wait from the hand-off.
- PostgreSQL: a fast refusal (unsupported protocol version) through the
real `handle_audio_connection` with a one-slot permit budget keeps the
permit taken until that socket ends.
- PostgreSQL: `huddle_audio_unavailable` sends its error and then Close
`1008` as the very next frame.

---------

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-10-19 16:13:27 -04:00
Tom Brow
5730c83f7d fix(release): keep mobile candidates pinned as branches advance (#8454)
Mobile candidate publication currently fails if the source branch
advances after selection, and the operator can report failure even after
the correct immutable tag was created. It also prevents releasing an
explicitly selected historical baseline.

Add `--commit FULL_SHA` and independently verify in the operator and
publisher that the selected commit belongs to the selected source
branch's history. Compare immutable SHAs, keep the candidate pinned
through publication, and verify the resulting annotated tag instead of
requiring the branch to remain stationary. Stable-branch review/check
protection and immutable-tag permissions remain enforced. Without
`--commit`, selection still defaults to the current source tip.

### Validation

- Updated operator and publisher contracts pass: historical main/stable
selection, branch advancement during publication, unrelated or missing
commits, ancestry lookup failure, and unchanged tag/sequence checks.
- Removing ancestry enforcement causes the unrelated-history regression
to fail.
- Live read-only ancestry verification accepts the original 0.24
baseline. Local publication stops at the existing App-permission check;
no tag or build was created.
- Full local `just ci` passed.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-19 16:21:11 +00:00
Will Pfleger
5b433d9292 feat(relay): resolve NIP-FI issuer bindings from the database (#8377)
Stack: #8372 (merged) → this PR → #8399 → #8401

Makes relay login checks read the per-community NIP-FI issuer bindings
that #8372 stores. Without this, the relay can't keep up with bb-public,
where new communities are created at every signup and a static
`BUZZ_NIP_FI_COMMUNITIES` list won't do.

**Source switch.** `BUZZ_NIP_FI_COMMUNITY_SOURCE=database` resolves each
community's trusted issuers from `community_nip_fi_issuers`, filtered by
the issuer registry, instead of the static list. Each community gets its
own policy ID: the base ID no longer hashes every community audience,
and the final ID hashes the base plus this community's audience, so
policy IDs change once (see `docs/operator-nip-fi-bindings.md`).
`Config::from_env` refuses to start, in every mode, on an unknown
source, on `database` together with `BUZZ_NIP_FI_COMMUNITIES`, and on
`database` without `BUZZ_NIP_FI_PROVISION_BINDING`.
`BUZZ_NIP_FI_RESOLVER_*` and `BUZZ_NIP_FI_SNAPSHOT_*` are validated in
every mode, so a bad value fails the deploy that sets it. In `off` mode
no resolver is built and no snapshot is loaded.

**Resolver (`nip_fi_resolver.rs`).**
- **Snapshot:** each pod loads every binding into memory, capped at
`BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` (going over is a failed load). It
refreshes every 30 s, start to start, with jitter that only shortens the
delay and backoff after failures. Each attempt is limited by
`BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS`, which must be shorter than
the interval. A refresh whose deadline passes before it publishes
publishes nothing. The old snapshot is dropped off the runtime.
- **Freshness:** a host in a snapshot loaded under two minutes ago is
answered from memory. Any other host, and every host once the snapshot
is stale, goes to a live lookup. Every answer carries a deadline two
minutes after the read behind it started, and WS, HTTP and audio recheck
it just before admitting.
- **Live lookups:** bounded and single-flight. In-flight lookups are
capped by `BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT` (default 8, clamped below
the database pool size), and the pending hosts and their waiters have
fixed caps. Positive answers are used for that admission only. A "not
found" is cached for 5 s, up to
`BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX` hosts, and only when no
snapshot was published and no binding was created while it ran. A lookup
that panics still leaves the pending map.
- **Removal:** a removed binding stops admitting new connections within
30 s plus the refresh timeout (40 s by default) while refreshes succeed,
and within two minutes of the last good load while they fail. Open
WebSocket sessions are not rechecked when a binding is removed; they end
at their own session deadline.

**Shadow mode** resolves through the same path as enforce, including the
database lookup and the freshness rules. It keeps the resolved binding's
deadline and checks it at the same point as enforce; a binding that goes
stale during the tenant lookup is recorded as `stage=deadline,
outcome=unavailable`, without changing the legacy response or closing
the socket. With `source=database` its `community` metric label is only
`bound` or `unmapped`; with `source=env` it keeps the existing
per-community audience label, so current shadow dashboards are
unchanged. Each request resolves its Host once: the router guard's
resolution, failures included, is reused by shadow recording and
admission.

**Provisioning.** Both operator provisioning paths drop this pod's
cached "not found" for the new host after a commit that bound issuers,
so the creator's first login looks it up.

**Startup and readiness.** With `source=database` and a mode other than
`off`, the first snapshot loads before any listener binds, under its own
startup phase `nip_fi_snapshot_load`. A failed load exits non-zero, so a
pod is never ready without a snapshot. **Operational consequence:** boot
already fails when the database is unreachable; what is new is that a
snapshot over `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` or a load past its timeout
also crash-loops new pods. After two minutes the old pods' snapshots go
stale and most logins fall through to the 8-slot live lookup, which
sheds the rest with 503. The way out is raising the cap or timeout, or
`mode=off`.

**Metrics.** `buzz_nip_fi_binding_snapshot_*` covers loads, failures,
rows and freshness.
`buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds` is written on
every refresh attempt, so alert on `time() - x > 120` together with
`absent(x)`. `buzz_nip_fi_binding_snapshot_rows` against
`buzz_nip_fi_binding_snapshot_max_rows` gives the headroom to the row
cap. `buzz_nip_fi_resolver_*` covers live lookups and the negative
cache. The env vars and alerts are documented in `.env.example`, the
chart README and `docs/operator-nip-fi-bindings.md`, which also has the
staged off → shadow → enforce rollout for `source=database` with
go/no-go signals at each step, and the over-cap and load-timeout failure
modes with their ways out.

**Regression coverage.** Router-level tests run the database resolver
through the real `build_router` for root WS, audio and protected HTTP,
in enforce and shadow mode. Over PostgreSQL and Redis, two pods using
the env community source (two exact bindings) replay assertions retained
from before a disconnect through a real root NIP-42 session, the audio
deny-set check and signed NIP-98 requests: they are denied on both pods,
and a root session upgraded before the disconnect is refused by the
post-registration deny-set check. Postgres tests in `buzz-db` check that
archived and quiescing communities, and issuers missing from the
registry, never reach the snapshot or the live lookup. Process-level
`boot_lifecycle` tests spawn the real relay with `source=database`: an
over-cap or timed-out startup load exits non-zero with no health
listener bound, and a loadable snapshot lets readiness turn ready.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-19 10:32:18 -04:00
Tom Brow
30ca980b49 feat(release): support version-matched mobile stable branches (#8430)
Mobile candidates currently require the current `main` commit, so a
stable release branch cannot publish through the existing protected tag
workflow.

Add optional `--source-branch mobile-vMAJOR.MINOR` selection while
preserving `main` as the default. Both the command and App-backed
publisher reject a stable branch whose major/minor differs from the
requested version. Candidates retain exact-tip checks and immutable
annotated tags; the private build pipeline is unchanged.

Document the temporary stable-only distribution policy and explicit
per-fix backport decisions. No candidate, build, or upload is triggered
by this change. No duplicate implementation was found among open release
PRs.

Stable publication now checks active review and status-check protection
in both the operator and publisher. The process permits approved
backport PRs and stable-only corrections for regressions caused by
earlier backports. Administrator activation and verification of the
no-bypass ruleset remain outstanding; non-admin API responses hide the
bypass actor list.

### Validation

- Full local `just ci` passed during preparation; the final changes
affect shell release tooling, its workflow inputs, CI path detection,
and documentation.
- Publisher contract passed, including version-matched stable sources
and mismatched branch rejection.
- Operator contract covers default main, a divergent stable commit,
missing or mismatched sources, immutable sequencing, and publication
races. The final operator contract run passed.


- Updated publisher and operator contracts pass, including 17
weakened-policy cases and rejection of unavailable protection. The live,
currently unprotected stable branch is rejected.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 18:04:04 -07:00
Will Pfleger
6286a54321 feat(relay): store per-community NIP-FI issuer bindings (#8372)
Stack: this PR → #8377 → #8399 → #8401

bb-public creates communities all the time, so a hand-edited
`BUZZ_NIP_FI_COMMUNITIES` map can't list them. This PR stores an
explicit NIP-FI issuer allowlist for each community in the database and
writes it when the community is created. Login checks don't read the
table yet; that comes in #8377 together with the database-backed
resolver. Operator steps are in
[`docs/operator-nip-fi-bindings.md`](docs/operator-nip-fi-bindings.md).

**Storage.** Migration `0066_community_nip_fi_issuers` adds
`community_nip_fi_issuers (community_id, issuer, created_at)`, with
primary key `(community_id, issuer)` and the community write fence
attached. The assertion audience is `https://<communities.host>`, so the
table has no `aud` column. Zero rows means no binding. The table is
included in whole-community deletion and purge.

**One bindable rule.** `BUZZ_NIP_FI_PROVISION_BINDING` (`{"host_suffix":
..., "authorized_issuers": [...]}`) is parsed in `buzz-auth` with
`BUZZ_NIP_FI_ISSUERS`, whatever `BUZZ_NIP_FI_MODE` is set to, and both
the relay and `buzz-admin` use the same parser. Each authorized issuer
must be a configured issuer (1 to 2048 bytes), and the suffix must not
cover any host declared in `BUZZ_NIP_FI_COMMUNITIES`; an unreadable
declaration refuses startup even in mode `off`.
`ProvisionBindingPolicy::is_bindable(host)` is true for a host exactly
one label under the suffix that is not reserved. It decides
creation-time binding, explicit `bind`, and what the lookup route
reports.

**Binding on creation.** Rows are written in the same transaction as the
community insert on all three creation paths (`POST
/operator/communities` `create_only`, its legacy convergence branch, and
startup bootstrap), and only when the call actually created the
community, so existing rows are never rewritten.

**Reserved host.** A host whose `https://<host>` equals a configured
`command_audiences` value never holds a binding. Operator provisioning
of it is rejected with a 400 and the `reserved_host` error code,
bootstrap creates it unbound, and the availability route reports it
unavailable with an error code. `main.rs` calls
`verify_reserved_hosts_unbound` at startup; if a reserved host has rows
the relay refuses to start and names the fix (`buzz-admin nip-fi unbind
--host <h> --issuer <i>`, or revert the command audience). A database
failure during that check is a separate `Check` error.

**Operator commands.**
- `buzz-admin nip-fi backfill` pages unbound communities by host (1000
per page) and binds each page with one `INSERT ... SELECT` limited to
active communities. It skips communities being deleted, always prints
`{rows_bound, unbound, unbound_hosts, truncated}`, and exits non-zero
while any bindable community is still unbound. Archived communities are
bound on purpose. Both bind paths take the shared deletion lock and then
lock the community row (`FOR NO KEY UPDATE`, in id order for a batch),
the same order deletion uses, and the backfill insert also requires the
community to still have no binding, so an operator `bind` that lands
after the backfill listed a community wins and the default issuer is not
added beside it.
- `buzz-admin nip-fi bind --host --issuer` refuses a reserved host, a
host outside the suffix, or an unconfigured issuer.
- `buzz-admin nip-fi unbind --host --issuer` refuses to remove a
community's last issuer (archive or delete the community instead), with
one exception: when a valid provisioning policy shows that backfill
can't bind the host (it's reserved, or outside the suffix), the last
issuer can be removed. That is the reserved-host recovery path in the
runbook. A missing or malformed policy doesn't block `unbind`; it just
means the exception never applies.

**Lookup route.** `GET /operator/communities/nip-fi-binding?host=`
(NIP-98, `RELAY_OPERATOR_PUBKEYS`) reads the database and returns
`{host, active, issuers[]}`. `host` is the stored host. It returns 404
for an unknown host, 200 with `active:false` for an archived or
quiescing community, and `issuers:[]` when no provisioning policy is
set. It lists only issuers that are configured and on a bindable host.
kgoose depends on this contract; it is also documented in the runbook.

**NIP-FA.** The `relay_url` bullet now also says: "An adapter MAY obtain
its exact relay list from a configured, authenticated source; it MUST
still compare `relay_url` exactly."

**Deployment dependency.** The kgoose config side is already handled by
[cash-server#137671](https://github.com/squareup/cash-server/pull/137671).
In public-community mode it requires the command audience not to be a
community audience.

---------

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-10-17 13:13:49 -04:00
Tom Brow
d79446316b fix(mobile): support DeviceHub in mobile-dev (#8404)
`just mobile-dev` fails on Xcode 27 machines that have DeviceHub instead
of Simulator. Resolve the simulator frontend inside the selected Xcode
installation: DeviceHub for Xcode 27, or Simulator for Xcode 26. Launch
by full path so an older installed or running app cannot determine the
choice.

This extracts the still-needed DeviceHub portion of Wes's
[#8330](https://github.com/block/buzz/pull/8330). The CocoaPods
deployment-target work already landed in
[#8379](https://github.com/block/buzz/pull/8379); this PR does not
change Podfile or its lockfile.

CI and pre-push mobile selection include the shared Justfile and mobile
recipe regression script, so edits to either run the regression in the
mobile gate.

### Validation

- Nine production-recipe checks pass, covering side-by-side Xcode 26/27
selection with spaces in paths, missing apps, launch failure, and
gateway argument forwarding. Mutations reject unqualified Launch
Services lookup and removal of DeviceHub selection.
- All 39 tests of the pinned paths-filter action pass, including the
Justfile and recipe-test selection cases. Mobile formatting, analysis,
and the full mobile suite pass.
- Native macOS smoke checks exercised launch from the selected Xcode
installation and reached the Flutter boundary. Flutter and identity
setup were stubbed, so no app was built or installed.
- The initial full local CI gate stopped on an unchanged ACP
signal-cancellation test; that exact test passed in isolation. The
initial pre-push run also hit missing dependencies and two unchanged
desktop agent-discovery tests. Desktop lint and TypeScript checks passed
after installing pinned dependencies. Publishing used the authorized
local-hook exception.
- Current-head
[CI](https://github.com/block/buzz/actions/runs/38963831198) passed,
including all nine recipe cases and native Swift validation. Owner
approval and Codex code/security reviews are complete with no unresolved
findings.


buzz-review-completed

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 14:06:53 -07:00
Brad Seiler
2d0f78c6e1 feat(auth): support explicit mesh JWKS transport (#8382)
## Summary

Allow an issuer to opt into mesh HTTP for its JSON Web Key Set (JWKS).
Public HTTPS remains the default. This supports deployments whose
service mesh authenticates and protects the key endpoint.

The sealed source contract selects transport and contributes to policy
identity. Mesh mode requires a DNS hostname and checks every resolved
address against an explicit allow-list before pinning one address. Both
modes disable proxies, reject redirects, cap responses, and enforce a
whole-fetch deadline. The relay cannot verify mesh security: operators
must enforce mutual TLS, endpoint authorization, and egress
restrictions.

### Related issue

No duplicate mesh-JWKS issue or PR found. Existing NIP-FI relay
enforcement: #7224. No deployment change is included.

### Testing

- At `eccb8cb19420abda44cf6e5e62b1361e2843bd1d`: full auth package and
auth/relay all-target clippy passed. Full relay package passed: 1,457
library tests, 37 binary tests, and 9 boot tests; 446 tests were
ignored.
- At prior head `113939838cd81d5e8d0b0f8783c42ffaae44a4d3`: the full
relay library suite failed the demo echo test (1,455 passed, 1 failed,
440 ignored). Independent checks reproduced that timeout at base
`d61fe2071d2b3d8e56dd931947f16bfe0d6173fe`; the receive-cancellation
defect is outside this change. A later pass does not establish that
defect is fixed.
- Three falsifying mutations at `eccb8cb194` were caught: mixed DNS
answers, proxy bypass, and whole-fetch deadline. The full locked auth
package passed after restoring the exact source.
- At `eccb8cb194`, `cargo build --locked --release -p buzz-relay -p
buzz-cli -p buzz-admin` passed with command-local
`CARGO_PROFILE_RELEASE_STRIP=none`. This avoids a macOS loader error
without a source, lockfile, or project-profile change.
- Earlier hooks failed the ACP signal test and Tauri login-shell PATH
test. The ACP failure also reproduced on unmodified main `93ffc37c`.
Main merged without conflict. The full Tauri package then passed with
`RUST_TEST_THREADS=1`: 3,422 library tests and 3 integration tests, 19
ignored. All pre-push hooks passed with that same command-local setting;
no hook or test was skipped.
- At `eccb8cb194`, isolated `just test` passed all 107 runner steps. The
monolithic `just ci` run timed out after 1,120 seconds; all recipes
passed across that run and separate `just desktop-tauri-test`, `just
web-build`, and `just mobile-test` invocations. No monolithic pass is
claimed.
- Exact-head release live-local checks passed: loopback HTTP mesh JWKS
warm; valid assertion 101; expired/wrong issuer/wrong audience 403;
valid signed disconnect 200; wrong command audience 403. Redirect-only
JWKS returned assertion 503 without a request to its target. Both
omitted/default and explicit public transport rejected HTTP at startup.
- A separate NIP-FI-off release CLI smoke test created an open channel,
sent an accepted message, read it back, and fetched its thread. The
assertion probe closes after upgrade; it does not prove a complete
NIP-42 session. Local loopback tests do not prove deployment mesh
authentication, endpoint authorization, egress controls, or real `240/4`
routing.

Generated with Codex

## Human Testing
valid assertion / configured community audience: HTTP 101 (expected 101)
expired assertion: HTTP 403 (expected 403)
wrong issuer: HTTP 403 (expected 403)
wrong community audience: HTTP 403 (expected 403)
valid command JWT / configured command audience: HTTP 200,
body=b'{"disconnected": true}' (expected 200)
valid command signature / wrong audience: HTTP 403 (expected 403)

---------

Signed-off-by: seiler <seiler@squareup.com>
Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
2026-10-16 14:26:08 -04:00
Luke Tornquist
c0ab789499 feat(buzz-db): retire the channel-TTL trigger (#8210)
## Summary

This drops the channel-TTL event trigger. The application already
refreshes ephemeral-channel deadlines in the event transaction
(`event_follow_up::refresh_channel_ttls`, from #8135), so the trigger
only repeats that work.

This PR originally retired both follow-up triggers. #8352 has since
retired the push-match trigger in migration 0063, so after the rebase
this PR covers only the TTL half.

Migration `0065_retire_channel_ttl_trigger.sql`:
- `SET LOCAL lock_timeout = '5s'` (the same bound as 0063)
- drops `events_refresh_channel_ttl` and
`refresh_channel_ttl_after_event_insert()`

Dropping the trigger on the partitioned parent also drops its partition
clones. Published migrations, including 0022 and 0024, stay
checksum-frozen.

The same objects are removed from `schema/` (`schema/functions/…`,
`schema/triggers/events.sql`, `schema/schema.sql`) and from the pgschema
reconcile script. After this, migrated and desired-state databases have
no event follow-up triggers.

Tests:
- `follow_up_retirement_tests.rs` (renamed from
`push_retirement_tests.rs`): the push-retirement upgrade test now stops
at 0063. A new test upgrades 0064 → 0065 and checks that the TTL trigger
is gone from the parent and every partition, that the function is gone,
and that the published checksums haven't changed.
- `event_follow_up_postgres_tests.rs`: the contract now has two arms.
`AppOnly` is the shipped schema, where both triggers are absent. `Dual`
reconstructs the historical overlap of both triggers. The `PushRetired`
arm (the push trigger dropped, the TTL trigger kept) was the shipped
state before this PR. Its tests are removed because `Dual` and `AppOnly`
together cover it.
- `embedded_migrator_contains_consolidated_initial_schema` pins the 0065
statements and checks that both objects are absent from the desired
schema.

**Deploy gate:** do not run 0065 against a database still served by a
relay older than #8135. After 0065, raw SQL writers no longer refresh
TTL deadlines. `docs/push-gateway-deployment.md` is updated to match.

### What got simpler

The trigger and the app no longer both refresh TTL deadlines; the app is
now the only producer. One plpgsql function and one trigger are gone
from both schema paths, and 8 per-partition drops are gone from the
reconcile script. The follow-up contract drops from three arms to two.

### Related issue

BUZZ-313 (Linear), part of the trigger-retirement work.

### Testing

Head is `4f7d0baf0`. Results below are at `678927730` unless noted.
`678927730..4f7d0baf0` changes only one test in
`crates/buzz-db/src/store/event.rs`, described in the first bullet.

- **Fixed test,
`permanent_channel_event_commits_do_not_lock_the_channel_row`**
(`4f7d0baf0`).
- **Why it changed:** after 0065, its raw-pool inserts no longer ran any
TTL refresh, so the test passed whatever `refresh_channel_ttls` did.
This was raised in review.
- **What it does now:** it inserts through the production `AdmittedTx`
path and keeps the barrier and the tuple-lock probe. It commits all N
transactions while an external transaction holds `FOR SHARE` on the
channel row. It also asserts that no
`buzz_db_channel_ttl_refresh_failures_total` is counted.
- **Mutation check:** with `SELECT … FOR UPDATE` injected into
`refresh_channel_ttl`, the new test fails (`must not wait on the channel
row`, timeout at 10s). The old test passes under the same mutation.
  - **Blox:** fmt and `buzz-db` clippy pass.

- **GitHub CI at `4f7d0baf0`:** every required check passes (56 pass, 0
fail). CI builds the merge ref with `main` `93ffc37cb`.
- **New and renamed tests, checked by name in the CI logs at
`4f7d0baf0`.** These pass in PostgreSQL Domain / PostgreSQL Tests
([117033725006](https://github.com/block/buzz/actions/runs/38915998692/job/117033725006)):
  - `permanent_channel_event_commits_do_not_lock_the_channel_row`
-
`migration_schema_ttl_retirement_drops_every_partition_trigger_and_keeps_checksums`
-
`migration_schema_push_retirement_preserves_ttl_and_published_checksums`
-
`event_insert_ttl_refresh_handles_permanent_ephemeral_duplicate_and_activation_race`
- `{desired,migration_schema}_event_follow_up_contract_{dual,app_only}`
(four tests)

`embedded_migrator_contains_consolidated_initial_schema` passes in Rust
/ Unit Tests
([117024125423](https://github.com/block/buzz/actions/runs/38915998692/job/117024125423)).
No `*_push_retired` test runs anywhere.
- **Blox gates at `678927730`** (private PG16 + Redis, on a workstation
shared with other sessions):
- `cargo fmt --check`, workspace `clippy -D warnings` and `buzz-db
--lib` all pass.
- The `buzz-admin`, `buzz-audit`, `buzz-deletion`, `buzz-push-gateway`,
`buzz-search` and `buzz-workflow` Postgres packages pass.
- `buzz-db`: 517 of 519 tests passed. The 2 that hung, both
`writer_pool_*` tests, were killed. `buzz-relay`: 413 of 426 passed. The
13 failures are covered below.
- **Uncontended reruns at `4f7d0baf0`** on a fresh, idle workstation
(PG16.15, `competing=0` for every arm):
- **`writer_pool_rejects_non_read_committed_database_default` predates
this PR.** It timed out 3/3 at head and 3/3 at `main` `93ffc37cb`, with
a 300s timeout per run.
- **`writer_pool_metrics_stop_after_session_timeout_setup_failure` also
predates this PR.** It timed out 2/3 at head and 2/3 at `main`, and
passed in the remaining run on each side.
- Both tests pass in CI. The gate accepts them as a named exception for
this PR, as a pre-existing hang on the PG16 test environment.
- **`buzz-db`:** 518 of 519 tests passed. The single failure is
`writer_pool_rejects_non_read_committed_database_default`, killed after
it hung (the exception above).
- **`buzz-relay`: still open.** 413 of 426 tests passed. The same 13
`handlers::group_state_postgres_tests` failed as on the shared box, each
panicking at once in its fixture's `ensure community` step with
`Sqlx(PoolTimedOut)`. Contention does not explain them. The fixture's
community setup is in `buzz-db`, which this PR changes. A head/`main`
comparison of the full `buzz-relay` package on the same idle box is
running to find out whether this predates the PR. All 13 pass in CI at
this head.
- **E2E (Gimli, Blox)** at `050e593af`. `050e593af..678927730` changes
only tests and docs; the migration, schema and runtime code are
byte-identical. Gimli's audited verdict: **EXERCISED AND WORKS**, for DB
and relay runtime only. Gimli ran it at `050e593af`; this head's runtime
is byte-identical but was not freshly executed.
- **Upgrade:** a real relay startup took the schema from 0064 to 0065.
That removed the TTL function and the trigger from the parent table and
from all 8 partitions. Checksums for migrations 1–64 are unchanged.
- **Lock-timeout rollback:** with a real lock held, the migration failed
with `55P03` at 5.0007s and the schema rolled back fully to 64. Once the
lock was released, a retry applied 65.
- **Fresh desired schema:** after pgschema plus reconcile, the retired
objects are absent.
- **Real signed kind-9 sends:** on both schema paths, these refreshed
ephemeral-channel deadlines to about +600s. Permanent channels stayed
NULL.
- **Raw SQL insert:** at 64 it refreshed the deadline. After 65 it left
the deadline unchanged, on both the upgraded and the fresh schema.
  - **Not covered:** native or device testing, and deploy.
- **Review:** Legolas found 0 P0 and 0 P1. His three optional P2s are
fixed in `678927730`.
- **No human app test:** this is a DB-only change.
- **Migration numbering:** #8237 and #8372 also add a `0065_*`
migration. Whichever PR merges second must renumber.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-16 11:22:21 -04:00
Logan Johnson
f148c0f22c feat(relay): add profile search over name and display_name only (#8301)
🤖
> **Review as a pair with block/buzz-app#1084** (the app change that
uses this endpoint). Either can merge first: the app falls back to the
old search on relays without this endpoint.

## Summary
Searching for a person by name over the relay's general search returns
profiles that only *mention* the text somewhere else: in an avatar image
embedded as text, in the bio, or in the verification address. A search
for "wes" could fill every result page with profiles whose avatar data
happens to contain "Wes", and never return the person actually named
Wes.

This adds a relay endpoint, `POST /profiles/search`, that looks only at
a profile's **name** and **display name**. Every other profile field is
ignored.

**Matching and paging are the same as the relay's existing full-text
profile search** (NIP-50 search over kind:0 profiles with `search_mode:
"prefix"`). A client can switch to this endpoint without a visible
change in which people a query finds:
- Each query word must match a whole word of the name; the last word can
be a prefix. Words can be in any order, so "johnson wes" finds "Wes
Johnson". Case is ignored.
- Pages work the same way: up to 100 results per page, up to page 1000.
- The results are exactly the old results minus profiles that match only
outside their names.

The order is better for people search: exact name first, then names that
start with the query, then names with a later word that starts with it,
then other matches. Ties are ordered by `ts_rank_cd` over the two name
fields only (not the whole profile, as in NIP-50), then newest first,
then event id.

The response is a JSON array of the original signed profile events
(Nostr kind:0), so clients can verify them as they do today. The
buzz-app client change is block/buzz-app#1084.

## Details
- Request: `{"query": "wes", "limit": 20, "page": 1}`. `query` is 1–100
characters after trimming; `limit` is 1–100 (default 20); `page` is
1–1000 (default 1); unknown fields are rejected with 400.
- The search reuses the full-text search's own query builder
(`buzz-search`), so the word rule cannot drift from it. The existing
full-text index on events stays the access path; the name check runs
only on rows the index already matched. There is no new index. One small
migration (`0061`) adds the function `profile_name_fields()`; it reads
no table data, so it runs instantly.
- Only current profiles count: a replaced profile stops matching.
Profiles that are not a JSON object, or whose name fields are not
strings, never match. Content that PostgreSQL cannot read as `jsonb`
(for example a `\^@` escape or very deep nesting) also never matches,
and it cannot make the search fail for everyone else.
- The handler loads the matched events through the normal routed event
read and applies the same reader visibility check as other reads, in
search order.
- Access uses the same checks as the relay's GIF search endpoint: a
signed HTTP request (NIP-98), identity admission (NIP-FI), rate limit,
replay protection, and relay membership. That code moved out of
`gifs.rs` into one shared helper in `bridge.rs`; GIF behavior is
unchanged.
- The route is added to the existing protected-route and NIP-FI
shadow/enforce route tables. Docs: `docs/profile-search.md`.
- Two test-only lines get `sadscan:disable` comments, as other fixtures
already do: an existing public ES256 test key in `router.rs`, and the
local Postgres test URL in the search integration test.

### Related issue
None found.

### Testing
- Postgres test for the search query
(`profile_names_match_name_words_only`): only name fields match (avatar
data URL, `about`, `nip05`, `banner`, non-string names and non-object
content are excluded); words in any order and across both name fields;
whole words only for completed words; ranking order; pages split without
overlap or gaps; replaced profile; other community; `%` literal.
- Postgres end-to-end test through the router: an admitted member gets
only the name-matching signed event, and the returned event still
verifies.
- Request validation unit tests; existing GIF admission tests and router
route-table tests pass.
- Local end-to-end run with buzz-app#1084 against a relay built from
this head, with about 195 seeded profiles. For every query tried, the
endpoint's results were a subset of the full-text search's results; the
only profiles missing were avatar-only matches.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:35:33 -04:00
klopez4212
b1d60f98e2 Bring synced Solo chats and projects to mobile (#8337)
## Summary
Bring desktop Solo chats and project organization to mobile, with synced
collapsible sections, shared unread highlights and tab badges, agent
selection, shared search, native iOS creation sheets, and inline typing
bubbles that transition into replies.

Resolve Solo placement for every chat entry route, retain encrypted
organization snapshots for offline reading, and let users resume or
discard unfinished chat and project setup without deleting existing
messages.

### Related issue
Related: #6565; expandable agent transcripts remain separate work.

### Testing
- Fresh Solo read state seeds each active chat from its captured initial
last-message timestamp before exposing row/section/tab unread state.
Baselines survive intervening channel updates and reset across
identity/community boundaries while surviving read-state readiness
changes. Regressions cover multiple chats, events before read-state
readiness and during/after seeding, and identity changes; removing
either the seed or the display gate fails them.
- Chat completion reconciles a retained Solo-save journal even when
placement is already confirmed. False/throwing cleanup regressions
ensure the outer intent remains until reconciliation and only one
placement is published. Typing terminal-close loops remain bounded; the
retry budget resets after an uninterrupted eight-second health interval
or transport reconnect.
- Typing retry budgets reset after sustained recovery; invalid Solo
updates retain cached rows with a retry warning that survives failed
refreshes. Project names use the editors’ 120-grapheme limit, including
emoji and combining characters. Regression coverage exercises repeated
recovery, invalid live/refresh heads, and character-limit boundaries.
- Rebased onto current main, preserving the newer Search controls,
Huddle timeline filtering, and message-media layout.
- Failed project journal writes reload storage before claiming recovery
and reopen the entered name for retry. Typing subscription rejections
are caught; terminal closures clear stale indicators and retry with a
bounded backoff, canceled on disposal. Regression tests cover both
false/throwing storage writes and initial/late subscription rejection.
- Explicit ordinary destinations skip optional Solo lookup waits.
Generic destinations perform one bounded lookup in the destination
(search no longer duplicates it); an unavailable classification falls
back to ordinary sending per the product owner's explicit choice.
Positively identified Solo destinations use Solo providers.
- Terminal Solo subscription closure preserves cached rows and exposes
live-update Retry. Page coverage verifies warning visibility, usable
rows during recovery, and a subsequent terminal close. Removing the
warning or the immediate ordinary-route guard fails its regression.
- Generic channel entries share an ordinary default when Solo
classification is unavailable, per the requested product behavior. Known
Solo placement takes precedence; callers can explicitly require
unresolved placement to remain blocked. Bounded initial lookup preserves
known Solo deep-link routing.
- Dirty newest snapshots and offline-save warnings survive transport
reconnects; Retry persists that same event even if the relay returns an
older head. Community changes replace the service, and agent-directory
reconnect refresh remains intact.
- Pending-save discard reloads failed removals. Repeated reconnect
testing confirms typing subscriptions are released on provider rebuild
and disposal; removing the existing cleanup fails the regression.
- Ordinary Home/create routes explicitly preserve private-channel
sending during Solo failures; identified Solo sends use the Solo sender
and explicit unresolved routes remain fail-closed. Solo unread replies
beyond the initial 200-event page are loaded through the oldest-unread
boundary.
- Chat/project/agent cleanup reloads failed removals, retains
same-process recovery, and protects replaced records. False/throw
regressions verify retries do not duplicate creation or membership.
Route, unread, and cleanup guard-removal mutations each fail their
regression.
- Ordinary channels, DMs, and forums remain sendable when optional Solo
sync fails; explicit unresolved placement stays blocked. Controlled
cache races retain the newest verified snapshot, keep usable chats
visible, and retry offline persistence without stale writes. Both
protection-removal regressions fail as expected.
- Confirmed journal cleanup checks false/throwing removal and
exact-record identity. Interrupted agent choices are visible
immediately, identify the saved agent, and cover Resume/Later/Discard
with accessible name/key-fallback controls.
- Notification deep links preserve the contiguous history cursor;
same-second messages infer the newest agent consistently. Failed
offline-cache writes surface Retry, with false/throwing storage failures
and offline restart covered.
- Composite history cursors cover 350 same-second events plus older
history; removing the ID cursor makes the regression fail. Agent changes
now journal before granting access, with restart/failure and
Resume/Later/Discard widget coverage. Signed malformed profiles remain
isolated.
- Native iOS avatar tests passed on the simulator: visible-row loading,
three-request concurrency, streamed byte limits, response validation,
downsampling/pixel limits, cancellation, and visible avatar updates.
- Solo and Home share observed-event/read-marker logic. Widget coverage
verifies unread rows, collapsed-section indicators, the tab badge before
first Solo visit, message/thread reads, forced unread, and filtering
archived/nonmember/unplaced chats.
- Chat intent is saved before ownership and organization preflight;
tests cover interrupted ownership lookup and a disconnect while the
native creation form is open.
- Mobile analysis, formatting, file-size checks, and full mobile tests
passed at `1d4c41354`.
- Project creation persists its encrypted name/UUID before connectivity
checks or relay reads. Restart tests cover Resume/Later/Discard, scope
isolation, stale-intent rejection, and lost acknowledgements. Removing
the pre-network save makes all four production-flow regressions fail.
- Regression coverage includes notification/Activity routing, inline
replies, unknown-placement send protection, restart recovery/discard,
and encrypted offline cache isolation/refresh.
- Earlier feature build exercised on a physical iPhone with existing
account/community data; review follow-ups have not been reinstalled on
the device. UIKit agent-row accessibility checks passed on an iOS
simulator.
- Earlier cross-platform `just ci` stopped on the existing `buzz-acp`
test `signals_cancel_startup_turn_and_pending_stdin_without_hanging`
(process exit-code assertion); no Rust production changes are included
in this PR.

### Remaining review limitation
Agent discovery is bounded to 10 pages / 1,000 profiles with automatic
retries disabled. Owner-scoped discovery remains open because the
current relay ignores `#auth` filters; supporting that query requires a
relay change.

### Snapshots
Sample data, shared Flutter/Android surface. Native iOS uses platform
sheets and controls.

| Solo: projects and chats | New chat | Choose an agent |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--01-solo.png"
width="240" alt="Solo with collapsible projects and chats" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--02-new-chat.png"
width="240" alt="New chat form" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--03-agent-picker.png"
width="240" alt="Agent selection list" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:07:23 -04:00
Kalvin C
cdfc38ecbd feat(search): add newest-first sort to bridge search (#8371)
## Summary

Adds an opt-in newest-first order to bridge search, so clients can offer
"Most recent" alongside relevance.

- `buzz-search`: `SearchQuery` gains `sort: SearchSort`. `Relevance`
keeps the existing `ts_rank_cd` ordering. `Newest` orders hits by
`created_at DESC, id`, and `page` steps through that order. Newest skips
the exact-profile-lexeme prioritization, which only applies to relevance
ranking.
- Bridge `POST /query`: search filters accept `search_sort: "newest"`.
When the field is missing or has another value, results stay in
relevance order. Community scope, channel intersection and per-hit
authorization are unchanged.
- NIP-11 lists `buzz-search-sort` in `supported_extensions`. Clients
only send the field to relays that advertise it, so they never get
relevance results labelled as newest.
- WebSocket NIP-50 search stays relevance-only. `NOSTR.md` documents the
bridge extension.

### Related issue

N/A

### Testing

- New Postgres integration test
`newest_sort_pages_by_time_then_id_within_scope`. It pages a channel in
newest order, including an equal-timestamp tie split across a page
boundary and a newer hit outside the channel scope. It checks that the
pages join up in `(created_at desc, id asc)` order and that relevance
still ranks the strongest match first.
- Unit tests:
  - `bridge_search_sort_extension_selects_newest_only_when_named`
  - `advertises_nip_cl_and_search_sort_extensions`
- Ran the buzz-search Postgres integration suite, the relay `nip11` and
`bridge_search` unit tests, `cargo fmt --check`, and `cargo clippy -p
buzz-search -p buzz-relay --all-targets -D warnings` on Linux.
- Local relay exercised through signed `POST /query`:
  - NIP-11 lists `buzz-search-sort`.
  - Relevance and newest return different orders.
- Newest pages 1–3 return 25 hits with no gaps or duplicates, including
same-second ties across page boundaries.
- A non-member gets no hits from a private channel; a member gets all of
them.
- Client integration is in a companion block/buzz-app change. It was
exercised end to end against this relay through the production broker:
switching sort, paging, scope changes and capability fallback.

Deployment note: clients only offer newest sorting after a relay with
this change advertises `buzz-search-sort`.

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:27:49 -07:00
klopez4212
d61fe2071d Use native UIKit scrolling for iOS photo carousels (#8361)
Builds on the mobile polish merged in #8359; this PR now targets `main`.
On iOS, inline photo galleries now use a UIKit collection view for
dragging, deceleration, and snapping. Photo taps retain the existing
full-screen viewer and reply/more actions; gallery scrolling continues
to fade the message avatar. The final photo stops at the full message
gutter rather than the smaller gap between photos. Android keeps the
current Flutter carousel.

Visible cells subscribe to continuing frames from Flutter's existing
authenticated image providers, preserving animated GIF/APNG/WebP media.
Request IDs fence late frames and cancel subscriptions on
reuse/disappearance/disposal. A four-slot bound includes retired
unresolved loads until their transport terminates. Shared media fetches
now use abortable HTTP requests with a 30-second deadline through
response-body completion, so stalled requests release their slots
without leaving live transports behind; native eager precaching is
disabled so it cannot bypass that bound. Frame conversion coalesces to
the newest pending frame. Native cells expose photo labels and positions
to VoiceOver, support RTL, and preserve the selected photo during
resizing.

Validation:
- Mobile formatting and analysis passed.
- Native simulator regression checks passed for geometry, RTL, snapping,
taps, frame replacement, cancellation, and resizing. The native CI lane
now runs the carousel executable.
- Full mobile suite and pre-push gates passed, including two-frame GIF
delivery, delayed-provider bounds/cancellation, real HTTP
stalled-header/body recovery with four requests maximum, disposal, and
unconfigured-push tests.
- Signed iPhone Release build of the frame/subscription implementation
passed. Physical iPhone testing covered the earlier carousel/gutter
revision; the new animation/cancellation changes have local Flutter and
native simulator coverage, not a new physical-device journey.
Preview-only signing overrides preserve its bundle/Keychain group;
production entitlements are unchanged. Local provisioning excludes
production push/App Attest capabilities.
- Full `just ci` was started but stopped after unrelated Rust/desktop
compilation consumed about 5 GB; broad CI is incomplete.

Device build required local Xcode 27 workarounds: iOS 16 deployment
target and a generated swift-secp256k1 cache compatibility edit. Neither
changes repository source.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-15 13:22:12 -04:00
Tom Brow
6766174673 chore(push): retire push notification database trigger (#8352)
Buzz must schedule mobile push notifications when it saves an eligible
message. The relay, the server that receives and stores messages, now
does this in application code. An older database trigger still exists.
This trigger is a rule that runs automatically when a message is saved.

This PR removes the old trigger and its supporting database function.
The relay remains responsible for scheduling notifications. Migration
0063 makes this change in existing databases; updated setup scripts
apply it to new databases.

The change does not turn on push notifications. Production migration and
deployment need separate approval. Before deployment, verify the release
images and all pending database migrations. If push needs to be stopped
after release, disable it with the feature flag and keep the qualified
application version and database schema. Do not downgrade the
application image.

### Validation

- Local checks passed: 519 isolated database integration tests, 166
database unit tests, 17 source-policy tests, and the relay push-runtime
tests.
- Tests verify that the migration removes the old trigger, preserves
earlier migration records, and lets the application schedule push work
correctly. A concurrency test exercises the real application path;
deliberately removing its lock makes the test fail.
- [Current-head CI passed on
retry](https://github.com/block/buzz/actions/runs/38752849383/attempts/2).
The first attempt had one database-connection timeout during test setup;
the failed jobs passed when rerun on the unchanged commit.
- Initial local CI and push checks hit unrelated ACP test failures,
which passed when run separately. Only the Rust-test push hook was
skipped under the repository's local-check exception; other applicable
hooks passed.

### Before merge

- [x] Migration 0063 is available and the push trigger remains on [main
at
5397ea4](5397ea49e2).
Recheck if main advances before merge.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-15 08:37:55 -07:00
Logan Johnson
d28f6b2f70 feat(relay): let channel-state filters opt in to system channels (#8182)
🤖
## Summary
After part 2, a client can read a system channel only if it already
knows the ID. This PR lets a client find system channels on purpose,
with an ordinary Nostr filter and no new filter key.

A filter on the channel-state kinds (39000–39003) now also returns the
system channels the reader can read when it has either:

- `#t:["system"]`, the channel type tag that every channel-state event
already carries; or
- any `#P`, the creator tag from #8174. Asking for the channels of a
named creator returns a small result, so it does not fill generic
channel lists.

The filter then matches as usual. Existing clients send neither on these
kinds, so they still never see a system channel.

| Reader wants | Filter |
|---|---|
| My system channels | `{kinds:[39002], #p:[me], #t:["system"]}`, then
their kind:39000 by `#d` |
| Open system channels | `{kinds:[39000], #t:["system"]}` |
| One creator's labeled channel, system or not | `{kinds:[39000],
#P:[creator], #t:[label]}` |

Private system channels stay hidden from non-members, as for any private
channel. Values in one tag filter combine with OR, so `#t:["system",
label]` means `system` **or** the label; use `#P` plus the label
instead.

Part 3 of 3, stacked on part 2.

### Related issue
None found.

### Details
- The opt-in is decided per filter in `handlers/system_channels.rs` and
applies on REQ, COUNT and the HTTP bridge. It applies only when every
kind in the filter is 39000–39003.
- A label alone is not an opt-in.
- `NOSTR.md` documents the system channel query rules.
- **Spec.** NIP-CL says how a filter asks for system channels.
- The relay information document (NIP-11) now lists `"nip-cl"` in
`supported_extensions`. Clients use this to tell if a relay supports
labels and system channels, and refuse those calls on a relay that does
not. NIP-CL documents this.

### Testing
- End-to-end relay test: `#t:["system"]` finds both system channels for
a member and only the open one for a non-member; 39002 `#p:[me]` with
`#t:["system"]` lists the member's system channels; `#P` includes a
creator's system channels; creator plus label returns exactly one
channel with `limit: 1` and excludes another creator's channel with the
same label; a label alone does not return the system channel; COUNT and
HTTP `/query` and `/count` agree.
- The test fails when the opt-in is disabled.
- Unit tests for which filters opt in.

### Stack
These are two stacks. Merge them in this order. Client support
(creating, labeling and finding these channels) will be in
block/buzz-app, not the buzz SDK and CLI.

**Stack 1: channel labels and creator lookup (#8174–#8176)**

1. #8174 channel type and creator tags, and the channel creation time
2. #8175 channel labels
3. #8176 database-side label and creator match, and the rule for
choosing one channel

**Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because
it uses labels and the creator tag

4. #8180 the system channel type
5. #8181 leave system channels out of reads that do not name them
6. #8182 channel-state filters opt in to system channels ← this PR

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 08:40:16 -04:00
Logan Johnson
cdf03f618a feat(relay): leave system channels out of reads that do not name them (#8181)
🤖
## Summary
After part 1, a system channel is still returned by every query that
returns ordinary channels, so existing clients would show it as a chat.
This PR makes the relay leave system channels out of every read that
does not name them. Clients do not need to filter, so clients that know
nothing about system channels stay correct.

| Query | Before | After |
|---|---|---|
| A query that does not name a channel (no `#h`), COUNT, full-text
search, the HTTP `/query` and `/count` routes, artifact queries
(`{"artifact": ...}`), the sidebar | Includes system channels | Leaves
them out |
| "Channels I belong to" (kind:39002 `#p:[me]`) | Includes them | Leaves
them out |
| A filter that names the channel with `#h`, or with `#d` on kinds
39000–39003 | Ordinary read rules | Unchanged: members can read it, and
anyone can read it if it is open |

Push notifications and "you were added to a channel" notifications
(kinds 44100/44101) skip system channels, because a client that received
one would show the channel.

A client that is given a system channel's ID, for example in a pasted
link, can still open it. Naming the ID is the opt-in. This keeps system
channels ordinary NIP-29 groups.

Part 2 of 3, stacked on part 1.

### Related issue
None found.

### Details
- The rule lives in one place: `get_accessible_channel_ids`, the set of
channels a query may read when it does not name one. It is the union of
the reader's memberships and every open channel; both halves now exclude
`channel_type = 'system'`. Because the filter is in SQL before `LIMIT`,
pages stay full.
- A filter that names a system channel adds it to that filter's scope
only (`handlers/system_channels.rs`). A sibling filter in the same
request keeps the implicit set, so naming one system channel cannot
widen another filter.
- Thread windows and the workflows API name their channel, so they
follow the same named-channel rule.
- Artifact queries have their own read check in SQL. It reads a system
channel only when the query's `#h` names it.
- When a request re-checks membership for a named channel that its first
lookup missed, a confirmed system channel joins only the filters that
name it. Before, a reader who joined a system channel during the request
could have it added to a sibling filter's scope.
- The system-channel lookup runs only when a filter names a channel
outside the implicit set, so ordinary queries do not pay for it.
- **Spec.** NIP-CL lists the reads that leave system channels out, and
says when a filter names one. `AGENTS.md` notes this for `get_channels`.

### Testing
- End-to-end relay test (Postgres and Redis) with a private system
channel, an open system channel and an ordinary channel, as a member and
as a non-member: REQ without `#h`, NIP-50 search, COUNT, HTTP `/query`,
HTTP search, HTTP `/count`, the channel list, and 39002 `#p:[me]` return
only the ordinary channel. Named reads by `#h` and `#d` follow
member-or-open on REQ, COUNT, search and HTTP.
- Relay test: adding a member to a system channel sends no kind:44100;
an ordinary channel still does.
- Both tests fail when the SQL exclusion or the named-channel scope is
removed.
- Database test: an artifact in an open system channel is missing from
`{"artifact":"current"}` and present with `#h:[<system channel>]`, for a
member and a non-member. It fails without the channel-type condition.
- Relay test: the membership re-check confirms a system channel that the
system-channel lookup missed. The channel goes to the filter that names
it, and a sibling filter's scope does not include it. It fails without
the fix.
- Unit tests for the per-filter scope. They now run in `just test-unit`
and `scripts/run-tests.sh`.

### Stack
These are two stacks. Merge them in this order. Client support
(creating, labeling and finding these channels) will be in
block/buzz-app, not the buzz SDK and CLI.

**Stack 1: channel labels and creator lookup (#8174–#8176)**

1. #8174 channel type and creator tags, and the channel creation time
2. #8175 channel labels
3. #8176 database-side label and creator match, and the rule for
choosing one channel

**Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because
it uses labels and the creator tag

4. #8180 the system channel type
5. #8181 leave system channels out of reads that do not name them ← this
PR
6. #8182 channel-state filters opt in to system channels

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 08:40:15 -04:00
Logan Johnson
a3909acc67 feat(relay): match label and creator filters before the page limit (#8176)
🤖
## Summary
After parts 1 and 2, a client can ask for "channels with label X" or
"channels created by key K". But the relay applied these tag filters
after it read one page of the newest channel events. So a request such
as `{kinds:[39000], #t:["workspace"], limit:10}` could return fewer than
10 channels, or none, while matching channels existed. This PR moves the
label and creator match into the database query, before the page limit.

Part 3 of 3, stacked on part 2.

### Related issue
None found.

### Details
- Applies only when every kind in the filter is a relay-signed
channel-state kind (39000–39003). Other kinds keep today's behavior.
- The database match uses JSONB containment on `tags`, which is
GIN-indexed. Containment can match a little too much (for example, a tag
that contains both strings in another position), never too little. So
the relay still checks each returned event with the normal Nostr filter
rules, and `COUNT` requests keep that check too.
- The same match is applied to the query path and the count path.
- **Spec.** NIP-CL now says that `#t` and `#P` match before `limit`.
- **Choosing one channel.** The relay does not make labels unique, so
two creates for one label can both succeed, for example when two
processes of one agent start at the same time. NIP-CL gets a "Choosing
one channel" section: a client that needs one channel per creator and
label looks it up with `#P` + `#t`, creates it only if none exists, and
otherwise uses the oldest one (the lowest `created_at` tag, then the
lowest channel ID). Every reader that follows this rule picks the same
channel, even after an edit.

### Testing
- Relay test: many channels, one match, small `limit`. The request
returns the match for both `#t` and `#P`. This test fails without the
change.
- Unit test: `#t` and `#P` reach the database query only for
channel-state kinds.
- Relay test: one creator sends two creates for the same label at once.
Both channels exist, a `#P` + `#t` lookup finds both, and the
oldest-channel rule picks the same one in either order, even after the
older channel's metadata is re-published by an edit.

### Stack
These are two stacks. Merge them in this order. Client support
(creating, labeling and finding these channels) will be in
block/buzz-app, not the buzz SDK and CLI.

**Stack 1: channel labels and creator lookup (#8174–#8176)**

1. #8174 channel type and creator tags, and the channel creation time
2. #8175 channel labels
3. #8176 database-side label and creator match, and the rule for
choosing one channel ← this PR

**Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because
it uses labels and the creator tag

4. #8180 the system channel type
5. #8181 leave system channels out of reads that do not name them
6. #8182 channel-state filters opt in to system channels

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-14 19:03:34 -04:00
Luke Tornquist
99c5fc8bba ci(docker): publish qualified, sortable push gateway main tags (#8305)
## Summary

This PR makes two changes to the push gateway image job
(`push-gateway-merge` in `docker.yml`). Together they let staging
auto-promote the gateway the same way it already auto-promotes the
relay:

1. **Publish only after same-SHA CI qualification.**
`push-gateway-merge` now needs `qualify` as well as
`push-gateway-build`. This is the same gate the relay's `merge` job
already uses: a successful `ci.yml` push run on the exact source SHA.
Before this change, the gateway image was published even when main CI
was red. That was safe while staging pinned the gateway by hand. It
isn't safe once the newest main image deploys automatically.
2. **Publish the sortable `:main-<YYYYMMDDHHmmss>-<sha7>` tag on main
pushes.** It's the same `type=raw` line the relay matrix gained in
#8240. It uses the same `enable=` guard, so it applies only to `push` on
`refs/heads/main`. A Lexical selector on `^main-\d{14}-[0-9a-f]{7}$` can
then pick the newest gateway build from the tag list alone. `:sha-<7>`
has no ordering.

`:main`, `:sha-<7>`, and the release semver tags don't change. The
gateway has no debug variant, so no `debug-main-*` tag is published.

**Tradeoff:** a gateway image now appears only after `qualify` finishes,
which takes as long as main CI. If main CI fails, no gateway image is
published for that commit, and that includes `:main` and `:sha-<7>`. The
relay already behaves this way, and it's the behavior we want for
anything that auto-deploys.

**What got simpler:** the relay and gateway images now share the same
publication gate and the same tag set. Downstream tooling no longer
needs a special case for the gateway's unqualified, unordered tags.

**What stays different:** the relay's `merge` job also attests the
`deployment-eligibility/v1` predicate (`docs/deployment-identity.md`);
the gateway publishes no such attestation. That's intentional for now:
the staging promotion guard selects the gateway by package and `main-*`
tag and does not verify attestations, so the gateway warehouse must not
be wired to require one. If promotion ever starts verifying eligibility,
the gateway needs the same attestation first.

### Related issue

N/A (staging rollout tooling). The downstream promotion-guard and
warehouse changes land separately. The warehouse switches on only after
this tag has been published.

### Testing

- `scripts/test-relay-image-eligibility-workflow.sh` now pins both
properties of the `push-gateway-merge` job (it runs in CI under "Relay
image eligibility contract"). Both checks match whole active lines, so a
commented-out copy can't satisfy them.
  - At `9b8c49600` it passes.
- With the live `needs` line replaced by `needs: [push-gateway-build]`
and a commented-out `# needs: [push-gateway-build, qualify]` left
behind, it fails.
  - With the gateway `main-*` line commented out, it fails.
- With `needs` reverted to `push-gateway-build`, it fails with `push
gateway image must publish only after same-SHA CI qualification`.
- With the `main-*` tag line removed, it fails with `push gateway image
must publish the sortable main-<commit UTC>-<7> tag on main pushes`.
- `scripts/test-release-ref-contract.sh` now counts the sortable
`main-*` tag line on all three image legs (relay, relay-debug, gateway)
instead of two. It passes at `9b8c49600`.
- `bin/actionlint .github/workflows/docker.yml` is clean at `f6c03b6b5`.
- PR runs don't push images. The first real `main-*` gateway tag appears
on the first main push after merge, and I'll check the registry for it
on that manifest.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-14 14:36:00 -04:00
Jarrod Sibbison
8599efb6a3 feat(relay): allow HTTP operator listener delivery URLs (#8287)
## Summary

Operator listener configuration currently rejects HTTP URLs, preventing
delivery to sibling services over internal HTTP connections. Accept HTTP
and HTTPS delivery URLs while retaining the existing pubkey,
duplicate-route, and URL restrictions.

Add configuration coverage for internal hostnames and custom ports,
verify signed delivery through the real HTTP client, and include the
listener suite in both unit-test runners. Shared NIP-98 signing and push
gateway code are unchanged.

### Related issue

Related: #7934 refactors operator listener delivery tests. No matching
open issue found.

### Testing

- `cargo test -p buzz-relay --lib operator_listener`: 13 passed,
including signed HTTP delivery and redirect rejection.
- `cargo fmt --all -- --check`, shell syntax, Justfile parsing, and diff
whitespace checks passed.
- `just test`: passed.
- `just ci`: stopped at mobile analysis with an existing undefined
`buzzDevPushAppProfile` in
`mobile/test/features/settings/notification_toggle_test.dart:81`.
Workspace and native desktop formatting/Clippy, desktop frontend checks,
and web checks passed. The mobile file is unchanged by this PR.

Locally verified with a spiked listener client

Signed-off-by: jsibbison-square <jsibbison@squareup.com>
2026-10-14 08:30:20 +11:00
Ian Oberst
85b7331784 fix(relay): partition audit observability follow-ups (#7994)
> [!IMPORTANT]
> **The alert rules changed in c3a314e71.** If you reviewed the alert
guidance earlier, please look again.
> - **The two range-based rules now apply only to live pods.** A range
selector such as `[30m]` keeps reading a terminated pod's samples until
they leave the window. So `BuzzPartitionAuditNotRunning` could fire for
about 10 minutes after normal pod turnover, and
`BuzzPartitionAuditFailing` could keep firing for up to an hour after
the pod was gone. Each rule now adds `and <counter>`, which requires the
counter to be exported right now:
>   ```yaml
>   expr: |
> sum without (outcome) (increase(buzz_partition_audit_runs_total[30m]))
== 0
>       and sum without (outcome) (buzz_partition_audit_runs_total)
>   ```
>   ```yaml
>   expr: |
>     increase(buzz_partition_audit_failures_total[1h]) >= 2
>       and buzz_partition_audit_failures_total
>   ```
> - **The audit counters start at 0.** When the relay starts, it exports
every `buzz_partition_audit_runs_total{table, outcome}` series and
`buzz_partition_audit_failures_total` at 0, before the first audit.
`increase()` ignores a new series' first sample. Before this change, it
could miss the first periodic failure, or read a table's first
`degraded` run as a stopped loop. This change is also what makes the
`and <counter>` gate work: the counters now exist for a relay's whole
life.
>
> The doc's old claim that pod turnover can't leave an alert firing was
true only of the instant-selector stale rule. The doc now explains the
difference.

## Why

These are the observability follow-ups deferred from #6515 ("fix(db):
audit partition catalog before creation"):

- **Timestamp-age alerts go silent** ([review
comment](https://github.com/block/buzz/pull/6515#discussion_r4127212141)).
Partition gauges are set only when a table audits successfully, and the
exporter evicts idle gauges after 2,700 s by default. After sustained
failures, `buzz_partition_audit_last_success_timestamp_seconds`
disappears. A table that has never audited successfully never has it. An
alert on `time() - last_success` then has no input.
- **Startup fallback failures are not counted** ([review
comment](https://github.com/block/buzz/pull/6515#discussion_r4127212158)).
When the startup fallback audit fails, it logs an error but does not
increment `buzz_partition_audit_failures_total`. Periodic failures do.
- **Repaired environments are always `degraded`**.
`PartitionTableAudit::degraded()` counts every legacy leaf, even one
wholly in the past. The `bb-block-staging` audit of the repaired catalog
reports `degraded` only because of `events_p2026_07_08_legacy`
`[2026-07-01, 2026-09-01)`. With this rule, every repaired environment
stays `degraded`, so nobody can alert on the outcome.
- **Partition config tests can see a poisoned env** ([review
comment](https://github.com/block/buzz/pull/6515#discussion_r4137416297)).
Two partition config tests take only `ENV_MUTEX`. A NIP-FI test running
in the same process can make them see its deliberately invalid
environment.

Partition health is still diagnostic only, through
`/_status.partition_catalog` and metrics. Nothing here affects
`/_readiness`. Handling concurrent creation (#6515 item 4) is out of
scope and stays with the catch-all advancement work.

## What

- **Success timestamp survives failures.** Each failed table audit
refreshes `buzz_partition_audit_last_success_timestamp_seconds` with
`increment(0.0)`, which leaves the value unchanged. It still resets the
exporter's idle tracking, the same mechanism as
`refresh_legacy_active_gauge_recency`. Sustained failures therefore keep
the last success exported and its age rising. A table that has never
succeeded exports `0`. A failed audit still never makes the last success
look fresh.
- **Alert guidance.** New `docs/partition-catalog-monitoring.md` covers
the metrics, how long each series lives, and the audit outcomes. It
notes that `runs_total` has no `unsafe` outcome. It gives three alert
rules:
- timestamp age, which also covers never-successful tables and sustained
failures;
- `sum without (outcome)
(increase(buzz_partition_audit_runs_total[30m])) == 0` for a stopped
audit loop. Counters are never idle-evicted, and this rule fires before
the stopped loop's timestamp is evicted, so there is no false resolve;
  - an aggregate failure-counter rule.

Both range-based rules also require their counter to be exported right
now (`and <counter>`), so a terminated pod's leftover samples can't
raise or hold an alert.
- **Zero counter baselines.** `try_install` exports each
`buzz_partition_audit_runs_total{table, outcome}` series (`ok`,
`degraded`, and `error` for both tables) and
`buzz_partition_audit_failures_total` at 0 before the startup audit, the
same way the auth series already start at 0. A failure in the startup
audit itself can still happen before the first scrape. The doc says so;
the stale rule covers that case.
- **Failure counter.** `buzz_partition_audit_failures_total` now counts
failed explicit audit attempts: the startup fallback audit and each
periodic audit. Both paths go through one helper,
`counted_partition_audit`. A failed `ensure_future_partitions` call,
such as a DDL error that the fallback then recovers, is not counted by
itself. A single attempt never counts twice.
- **Historical legacy leaves.** A legacy leaf is historical when its
finite upper bound is at or before the current month start. It is still
reported under `children` as `legacy_leaf`, but it no longer degrades
the table. A legacy leaf that still covers the current month or any
lookahead month stays degraded. All other degradation causes are
unchanged. The verdict now lives in `PartitionAuditReport::outcome()`,
which `buzz-admin partition-audit` uses. The JSON outcome names and exit
codes are the same as before.
- **Test hygiene.** Both partition config tests now use `env_guards()`.

## How it was tested

- `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay -p buzz-admin --all-targets -- -D warnings`.
- New unit tests:
- The repaired incident layout with an injected clock: `degraded` in
August 2026 and `ok` on 2026-09-01T00:00Z, when the upper bound equals
the current month start, and on 2026-09-15. The legacy leaf is still in
the serialized report. The `outcome` label on the run counter follows
the verdict.
- A table test of the legacy-leaf predicate: the upper bound before, at,
and one second after the month start; lookahead and beyond-horizon
leaves; historical leaves with trigger drift; anomalous and `DEFAULT`
children; no audited month.
- Outcome precedence (error, then unsafe, then degraded) and the
`buzz-admin` exit-code mapping.
- Startup: a failed fallback counts once, a successful fallback after a
DDL failure counts nothing, and a successful ensure never runs the
fallback. Each periodic failure counts once. Against an unreachable
database, startup counts one aggregate failure while each table still
records two `error` runs.
- On the production exporter, never-successful audits leave run counters
and a `0` success timestamp for both tables.
- On the production exporter, all six run-counter series and the failure
counter read 0 before any audit. After one failed audit, `error` reads 1
and `ok`/`degraded` stay at 0. Removing the initialization fails this
test.
- PostgreSQL lane (`scripts/postgres-test-run.sh`) with new or extended
tests:
- The repaired layout is not degraded in September. The same catalog is
degraded in August.
- With a 1 s idle timeout, a failed audit leaves the success timestamp
unchanged, and sustained failures keep it exported past the idle
timeout. When audits stop, the timestamp is evicted but the run counters
remain. The next success exports a fresh timestamp.
- Mutation check: removing the failure-path refresh fails both alert
fixtures.
- These fixtures check the emitted series. They do not evaluate the
rules themselves, including the `and <counter>` gate. `promtool` isn't
available locally or in CI, and the doc points to `promtool test rules`
for that.
- `scripts/test-postgres-test-discovery.sh`.
- `config::` and `nip_fi_config::` tests run together in one process
five times.

## Related

This PR conflicts textually with #7993 (catch-all advancement) in
`crates/buzz-db/src/store/partition.rs` and
`crates/buzz-relay/src/main.rs`. Whichever PR merges second will be
rebased onto the other.

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

---------

Signed-off-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:57:54 -07:00
Luke Tornquist
d6dc813290 ci(docker): publish a sortable main-<commit UTC>-<sha7> relay tag (#8240)
## Summary

Each push to main adds one more tag to the relay image manifest that
`docker.yml` already publishes:

- `:main-<YYYYMMDDHHmmss>-<sha7>` (UTC commit time)
- `:debug-main-<YYYYMMDDHHmmss>-<sha7>` on the debug manifest

`:main`, `:sha-<7>`, and the release tags don't change.

**Why:** `:sha-<7>` has no ordering, so a deploy tool that wants "the
newest main build" has to fetch metadata for every tag before it can
sort. Our staging promotion warehouse (Kargo, `NewestBuild`) does that
on every discovery cycle, against about 1,700 `sha-*` tags (4,800 total)
in `ghcr.io/block/buzz`. That's slow, it hits registry rate limits, and
one failed fetch aborts the whole cycle. The timestamp prefix makes the
tag sort lexically in build order. A `Lexical` selector with
`^main-\d{14}-[0-9a-f]{7}$` can then pick the newest build from the tag
list alone and fetch only the tags it keeps.

Details, checked against the pinned `docker/metadata-action` v6.1.0
(`80c7e94d`):

- `{{sha}}` is the 7-char short sha (`defaultShortShaLength = 7`), the
same value as `:sha-<7>`.
- `{{commit_date}}` formats in UTC by default. On a push it uses the
event's `commits[].timestamp`, so rerunning a commit produces the same
tag.
- `type=raw` inherits the flavor prefix, so the merge job's debug leg
emits `debug-main-…`. That's the same mechanism that produces
`debug-main` today.
- `enable=` limits the tag to `push` on `refs/heads/main`. Release tags,
rescue dispatches, and PRs never get it.

On main's last 40 commits, author time and committer time are the same
instant, and both increase monotonically. A commit pushed with a
backdated timestamp would sort below newer builds and just wouldn't be
auto-picked; the next push supersedes it.

**What got simpler:** this adds one tag line in each of the two places
that already mirror the tag matrix, plus header docs. In return,
downstream tooling no longer needs per-tag metadata fetches to find the
newest build.

### Related issue

N/A (staging rollout tooling). The downstream warehouse and tag-guard
changes land separately and don't switch over until this tag has been
published.

### Testing

- `scripts/test-relay-image-eligibility-workflow.sh` and
`scripts/test-release-ref-contract.sh` pass at `9ba1d8b7f`.
- PR runs don't push images (`push: false`), so the first real tag
appears on the first main push after merge. I'll check the registry for
`main-<14 digits>-<sha7>` and `debug-main-…` on that manifest.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-13 15:47:53 -04:00
Will Pfleger
40c1c474e7 fix(relay): hide deletions of author-only events from other readers (#8197)
🤖 Opened by an agent (Hayt) for Will Pfleger.

The relay keeps `AUTHOR_ONLY_KINDS` (event reminders `30300`, push
leases `30350`, private managed agents `30179`) readable only by their
author, but a NIP-09 deletion request (`kind:5`) for one of those events
was readable by any community reader. The deletion carries no `h` tag,
so ingest stores it with its target's channel (none), and every read
gate classified it by its own kind, `5`. Another reader could read and
count it by author, by id, live, or over HTTP, learning the deleted ids,
how many, and when.

A deletion is now exactly as private as the event it deletes:

- **Classification by the deletion's own `k` tag.**
`buzz_core::kind::is_author_only_event_kind` treats a `kind:5` that
names an author-only kind in a `k` tag as author-only. Only the
canonical decimal spelling counts (`"30300"`, not `"030300"` or
`"+30300"`), so Rust classification, ingest and the admin SQL (which
matches the tag text) always agree. Because it reads the deletion, not a
target lookup, the deletion stays private after its target is
soft-deleted or gone. It is generic over `AUTHOR_ONLY_KINDS`, so a kind
added to the list later (for example `44300`) is covered automatically.
- **Every read surface.** The shared per-event gate
(`is_author_only_event`, used by WS REQ historical and lookup by id,
COUNT fallbacks, HTTP `/query`, `/count`, search hydration and the
thread window), live fan-out in `handlers/event.rs`, the COUNT fast-path
guard (`filter_can_match_author_only_kinds` now treats any filter that
can match `kind:5`, including unfiltered and id-only filters, as needing
per-event filtering), the channel-window aux closure in the bridge, and
admin event preview plus report list and detail (SQL suppression of
`kind:5` rows with a tag whose first two elements are `k` and an
author-only kind, matching the Rust classifier; soft-deleted rows
included).
- **Enforcement still reaches the author.** The admin report query takes
the private-deletion `k` list from its caller next to the hidden kinds.
HTTP-facing reads pass both; `admin_get_report_for_enforcement` passes
neither, so report-directed bans, timeouts and action recovery still
resolve the stored author of a reported private deletion.
- **Ingest agreement.** `validate_standard_deletion_event` rejects a
deletion of a stored (including soft-deleted) author-only event, by `e`
or by `a` coordinate, unless it carries the matching canonical
`["k","<kind>"]`. It also rejects an author-only `k` on a deletion of
any other kind, so a public deletion can't hide itself from the readers
who need it. A deletion whose target is itself a private deletion is
rejected with `invalid: cannot delete a private deletion request`: it
would otherwise be stored as a public event revealing the private
deletion's id and timing, and NIP-09 gives a deletion of a deletion no
effect. A deletion of a public deletion is unchanged. The `k` tag only
narrows who can read the request; it grants no one new deletion
authority.
- **Unauthorized deletions learn nothing about the target.** On every
delete-by-id path, no target-dependent error (its kind, the artifact and
`k`-marker errors, the artifact-removal-marker error, or where it lives)
is returned until every access check that could block the deletion has
passed. A sender who fails any of them gets `invalid: deletion target
not found or not deletable by you`, the same text and status as for a
missing target. Errors that depend only on the request, never on the
target, are unchanged: for example a `kind:9005` whose own `h` channel
is archived still gets `channel is archived`, for a missing target and a
real one alike. "May not delete" includes an author who has since been
removed from the target's private channel, an author whose token is
scoped to a different channel, and anyone deleting in an archived
channel. The single-target rule (exactly one `e` or `a` tag) now runs
before any target lookup, so every target faces the gates. For `kind:5`
with an `e` target, ingest decides authority (effective author or the
owning human of the authoring agent) first, then takes the channel from
that same lookup, soft-deleted targets included, so tombstones face the
same gates; an authorized re-deletion of a soft-deleted target now keeps
the target's stored channel instead of being treated as global, so
historical access checks, `#h` filtering and live routing all use that
channel: readers outside a private channel no longer see it, and that
channel's members and subscribers do. The token channel scope,
scoped-token global-event, membership and archived checks then reject
with the shared denial, all before `validate_standard_deletion_event`
returns its artifact and `k`-marker errors. Other kinds, and `kind:5`
with only `a` coordinates, keep their existing errors. For NIP-29
`kind:9005`, a target outside the `h` channel is denied like a missing
one, so the relay no longer returns "different channel" or "no channel";
`may_delete_channel_event` (the author while still a member or the
channel is open, a channel owner/admin, or the owning human of the
author agent) is then checked in the `h` channel before the
artifact-removal-marker error. No access restriction is removed and
nothing becomes newly deletable. An authorized sender who names the
wrong `h` channel for a `kind:9005` now gets the shared denial.

Whether kind:5 content is full-text indexed depends on how the database
was built. The desired-state schema (`schema/schema.sql`, which CI
applies) and databases created before migration `0008` index it; an
empty database initialized through `0008` gets a positive allowlist that
doesn't. Where it is indexed, the per-event gate on search hydration is
what keeps a private deletion out of other readers' search results.

No current client is affected by the new rejection. `buzz-app`'s signer
only allows `kind:5` for channel messages (with `h`) and managed-agent
`30177` coordinates, and neither `buzz-app` nor this repo's desktop or
CLI publishes deletions for `30300`, `30350` or `30179` today. NIP-ER
already requires `k=30300` on reminder deletions.

Coverage:
- `crates/buzz-test-client/tests/e2e_author_only_deletion.rs` (Backend
Integration job): a non-author gets nothing from WS REQ, WS COUNT, HTTP
`/query` and `/count` (by author, mixed kinds, and id), search, or live
delivery, while the author gets the deletion. Search runs WS and HTTP
against a private deletion and a public control deletion sharing a
reason word; with `BUZZ_TEST_SEARCH_INDEXES_DELETIONS=1` (set by the CI
step) the author must get exactly both and another reader exactly the
control; ingest rejects a missing, wrong, non-canonical, or
public-target marker, and an untagged deletion of an already
soft-deleted target.
- `e2e_author_only_deletion.rs` also sends one non-owner's deletions
over HTTP and WS for a missing, live, soft-deleted, private-channel and
global target, plus the `kind:9005` equivalents including a target in
another channel and one with no channel, and requires the identical
rejection text and HTTP status in every case; another member's deletion
of a private event never names its kind.
- `e2e_author_only_deletion.rs`
`removed_author_deletion_does_not_reveal_target`: an author removed from
a private channel gets the missing-target rejection over HTTP and WS for
`kind:5` and `kind:9005` (with `h` set to a channel they can write to)
against their retained message; the channel owner can still delete the
removed author's message, and the author can still delete in a channel
they can write to.
- `e2e_author_only_deletion.rs`
`archived_target_deletion_does_not_reveal_target`: over HTTP and WS,
`kind:5` against a message, the same message with a wrong `k`, and an
artifact in an archived channel, and `kind:9005` against the
archived-channel message with `h` set to an active channel, all match
the missing-target rejection; a `kind:9005` for an artifact-removal
marker in the archived channel matches a missing target with the same
`h`; in an active channel the owner still gets the `k`-mismatch and
artifact errors.
- `e2e_author_only_deletion.rs` `deletion_of_private_deletion_rejected`:
over HTTP and WS, the author's deletion of their private deletion, with
no `k` and with `k=5`, gets the rejection and stores nothing; a
non-author's gets the missing-target rejection; a `kind:9005` against it
gets the missing-target rejection; the private deletion stays hidden
from another reader in WS REQ and COUNT, HTTP `/query` and `/count`, and
live delivery; a deletion of a public deletion is still accepted.
- `e2e_author_only_deletion.rs` `soft_deleted_target_keeps_its_channel`:
an author removed from a private channel, and an author in an archived
channel, re-deleting their own soft-deleted message get the
missing-target rejection; a distinct successful re-deletion is readable
by a channel member and not by an outsider, by id and by `#h`, and is
delivered live to the member's `#h` subscription but not to the
outsider's.
- `e2e_author_only_deletion.rs`
`multi_target_deletion_error_ignores_targets`: deletions with two `e`
tags, or an `e` and an `a` tag, get the identical single-target error
whether the first target is missing, live, in another private channel, a
private reminder or an artifact.
-
`handlers::ingest::postgres_tests::scoped_token_deletion_rejection_does_not_reveal_target`:
a non-owner and the target's author, each with a token scoped to another
channel, get the same rejection for a missing, live, soft-deleted and
global target, and the author's `kind:9005` naming the token's channel
gets it for a missing target, a live target in another channel and a
global target; a token scoped to the target's channel still deletes, and
its re-deletion of the soft-deleted target is stored with that target's
channel.
- `thread_window::postgres_tests`: stored private deletions never reach
a non-signer through the thread-window or channel-window aux closures,
or by id once their target is gone.
- `api::admin::postgres_tests`: ban and stranded-ban recovery on a
reported private deletion reach the stored author while report detail
hides it; `admin_moderation` tests treat non-canonical markers as
public.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-13 14:45:59 -04:00
Tom Brow
868e2f8f32 refactor(push): one app profile per gateway (#8206)
Buzz push currently selects an application profile, such as `dogfood`,
during client registration. We want each gateway to serve one Apple app
configured by the operator instead. This removes profile selection from
the client, relay, and gateway, while allowing internal and public
gateways to use their own Apple identities.

This PR implements that model in the Buzz source and upstream deployment
chart. Discovery, enrollment, grants, and leases no longer carry an
application-profile selector. Each gateway has one configured Apple
application and APNs transport.

The protocol changes are incompatible with the old registration format,
so relays advertise `buzz-push-v1` instead of `nip-pl`. Old clients do
not discover the new push capability. Updated clients use separate
Keychain records and register again. The [push
notes](https://github.com/block/buzz/blob/push-capability-v1/docs/buzz-push.md)
describe the experimental contract.

Landing this PR is the first step. Publish the matching gateway image
and upstream chart, then land the [shared chart
update](https://github.com/squareup/cash-helm-charts/pull/5740) and
[gateway deployment
update](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/1085).
Before cutover, stop old gateway instances and check legacy registration
state. Migrations refuse to proceed if old gateway installations or
active relay leases remain; they do not delete that state.
Physical-device verification remains necessary before production
activation.

### Validation
Gateway and focused Flutter push tests pass, as do the Swift package,
simulator build, and native notification/enrichment tests. A local relay
advertises `buzz-push-v1` without `nip-pl` or `app_profiles`. The
PostgreSQL suite passed 868 tests; its two migration-test failures were
corrected and pass a focused rerun together with the gateway cutover
guard. Chart rendering and release-contract checks pass.

Desktop JavaScript tests, desktop and web builds, native desktop
compilation, mobile analysis/tests, and desktop native tests pass. The
full Rust unit lane and prescribed `just test` command pass, including
workspace integration tests against task-isolated local services. The
production overlay now requires an explicitly supplied APNs topic, with
omission and rendered-value regression coverage. The corrected Swift
checksum assertion passes the complete package suite: 78 XCTest tests
and 42 Swift Testing tests. No physical-device enrollment or production
cutover is claimed.

Local merge validation: the embedded migration test and formatting pass
after reserving profile migration 0060 behind rollback migration 0059.
The local pre-push Rust lane failed ACP timing/steering tests
(`idle_resets_on_stdout_activity`,
`keepalive_resets_idle_past_deadline`, and the failed/missing-outcome
steering tests). ACP and its dependency manifests are unchanged by this
branch; all four pass in an isolated single-thread rerun. The documented
local-check exception skips only the Rust pre-push lane for publication;
required CI remains mandatory. Mobile and desktop pre-push lanes passed.

Combined migration validation: after rollback 0059 landed, the main
merge preserves profile migration 0060 and both embedded migration
assertions. The focused migration test and formatting pass. The next
local push again failed only the unchanged ACP idle/keepalive timing
tests (1053 passed, 2 failed, 3 ignored); other lanes passed.
Publication uses the same documented local Rust-lane exception; required
CI remains mandatory.

Follow-up review fix: removed the legacy profile column from the
copied-lease fixture. All four event-follow-up contract tests pass on
isolated PostgreSQL databases, covering desired and migrated schemas in
dual and app-only modes. Formatting passes. Publication retains the
prior passing local-lane evidence and uses the authorized pre-push
exception for the known unrelated ACP timing failures; required CI
remains mandatory.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-09 15:09:53 -07:00
Tom Brow
5b907b838a fix(push): isolate push enqueue and stop workers on rollback (#8216)
Push enqueueing previously ran inside the message transaction, so a push
lock or database error could block or reject a message. Configured relay
writers now submit bounded best-effort work only after commit. A
dedicated connection and bounded queue isolate enqueue waits and
failures from message-write connections. Overload, errors, process loss,
or shutdown may drop a notification; the committed message remains
authoritative.

`BUZZ_PUSH_ENABLED=false` disables the application producer and workers.
Updated writer connections always bypass any surviving enqueue trigger.
The overlap migration updates an existing function without resurrecting
retired objects. Apply it before relying on isolation with a surviving
trigger, and replace every old enabled process and writer connection:
missing settings preserve legacy behavior during overlap.

SIGTERM/Ctrl-C cancels enqueue, matcher, and delivery work before
listener drain. Durable queues, claims, leases, preferences, and
delivery configuration remain intact. Claims expire normally; gateway
requests already accepted may still deliver. Reactivation requires an
explicit operator decision about retained work. There is no automatic
purge, startup interlock, or backfill of messages written while
disabled.

Built on merged [#8135](https://github.com/block/buzz/pull/8135),
including [#8178](https://github.com/block/buzz/pull/8178). Trigger
retirement [#8210](https://github.com/block/buzz/pull/8210) remains
open; this change is tested with the trigger present and after removal.
Operational details are in [the rollback and reactivation
guide](https://github.com/block/buzz/blob/relay-push-rollback/docs/push-gateway-deployment.md#relay-rollback-and-reactivation).

### Validation


[9d3d710](9d3d710dc1)
changes documentation and Rust comments only, with no executable or
assertion changes. Formatting and diff checks passed;
[12f1d1e](12f1d1e814)
additionally restores the reviewer-requested caveat that active lease
renewal may suppress a notification. This is also documentation-only;
Owner approval was confirmed on 12f1d1e.
[a4f602d](a4f602df49)
subsequently merges main to resolve a migration-count conflict after
main added migration 0058. Both 0058 and rollback migration 0059 are
retained; the focused embedded-migrator test, formatting, and applicable
desktop pre-push checks passed. Current-head [owner
approval](https://github.com/block/buzz/pull/8216#pullrequestreview-5474958334)
is confirmed. [CI attempt 2
passed](https://github.com/block/buzz/actions/runs/37985516452/attempts/2),
with required checks passing or appropriately skipped. The first attempt
failed only the unchanged replica-floor probe test with `MaskedActivity
{ masked: 1 }`; one failed-jobs retry passed without code changes. All
four push contract tests passed. The current-head [advisory security
review](https://github.com/block/buzz/actions/runs/37985514346/job/114006457769)
has remained in its review step since 20:18:51 UTC without a published
result for over 25 minutes. It is stalled under the advisory-review
policy, not a clean security review.

[Required CI checks passed or were
skipped](https://github.com/block/buzz/actions/runs/37964035783) on
[3164b25](3164b25cde).
The advisory security review was cancelled without a published result.
Codex's durable-retry suggestion was [rebutted and
resolved](https://github.com/block/buzz/pull/8216#discussion_r4232674980)
under the explicit best-effort notification contract. The [lease
activation-order
observation](https://github.com/block/buzz/pull/8216#discussion_r4233051799)
is correct. Under the clarified product requirement, either notification
outcome is acceptable during overlapping registration and message
arrival. Exact commit ordering is not required; deliberate historical
backfill remains prohibited. The bot thread is resolved against that
clarification; [Owner review accepted the clarified overlap
contract](https://github.com/block/buzz/pull/8216#pullrequestreview-5474720278)
on 9d3d710.

All 488 PostgreSQL tests passed. The final four-way producer contracts
passed on desired/migrated schemas, with/without triggers. Added
coverage verifies message commit under push-gate and queue-table locks,
recovery after enqueue failure, in-flight enqueue cancellation, the
lease timestamp filter (not production activation ordering),
serving-lease writers, rollback preservation, reconnects, and
reactivation without backfill. Queue saturation and all three relay
worker shutdown tests passed. Rust lint and all 17 source-policy checks
passed.

Full local CI/repository runs encountered the unchanged ACP
`keepalive_resets_idle_past_deadline` timing failure. Earlier desktop
subprocess timing tests also failed under parallel load and passed as an
isolated module. Under Tom's local pre-push exception, this update
excluded `rust-tests` and `desktop-tauri-checks`; remaining hooks stayed
enabled. Required CI validation remains necessary before merge. A
focused run competing with the full database suite hit wall-clock
deadlines; tests now use a short database lock timeout to distinguish
unwanted push dependencies, and their final isolated run passed.

A local process-replacement rehearsal on 3164b25 used the real relay
binary and a mock gateway holding a delivery request open. SIGTERM
closed that request in under 1 ms; the relay exited in 5.02 seconds.
Restarting the same binary with push disabled produced no further
gateway requests over 35 seconds, beyond the 30-second claim expiry. The
outbox row, lease, and its subscriptions remained byte-for-byte
unchanged. This used a seeded outbox and did not exercise APNs or a
mobile device.

Production writer inventory, shutdown deadline, and retained-queue
disposition still require operational validation before rollout. The PR
is marked ready for review. The overlap behavior is accepted by the
product requirement; owner review accepted that contract. No production
deployment has occurred.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-09 13:48:06 -07:00
klopez4212
7e8aac4403 fix(mobile): polish channel management and iOS Settings (#8171)
## Summary

Use native iOS sheets for channel editing and Add Members, with profile
avatars, matching search, and destructive removal dialogs. Canvas
content becomes a tappable preview with Add/Remove actions.

Fix the Settings availability menu and clipped copy confirmation. Align
channel action messages with chat styling and keep agent identities
distinguishable across selection, attribution, and profile surfaces.

### Related issue

No duplicate found in the existing mobile Settings/channel issues and
PRs.

### Testing

- Flutter analysis, formatting, file-size checks, focused collision
regressions, and the full mobile test suite passed.
- Production UIKit picker simulator check passed: distinct
visible/accessibility labels, avatar updates, selected rows, and pubkey
callbacks. Wired into Mobile Swift CI.
- Original UI was built and tested on the physical iPhone with author
confirmation; profile/community data preserved.
- Review fixes await renewed author device confirmation before restoring
`buzz-review-completed` and ready-for-review status.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-08 14:32:55 -04:00
Will Pfleger
836e7d0d06 feat(desktop): browse and moderate every community in the Admin Console (#8027)
The Admin Console only ever saw the community the desktop happened to be
connected to. With #8011's community reads, the console can now browse
every community on the relay and, for signed staff, moderate any of them
by name without switching connections. 🤖

## What changes

- **Communities tab.** Lists every live community on the relay,
searchable by host prefix with "Load more" paging. The connected
community is pinned at the top by exact host match. Report rows get a
community badge that opens that community.
- **Community page.** Reports, Restrictions, Members and Actions, each
scoped to the community on the page. The Actions form has no host field:
it always acts on the page's community, and the TypeScript host
normalization is gone from the Actions tab.
- **Native reads** (`admin_list_communities`, `admin_search_members`,
`admin_get_member`, `admin_get_event`). Every request validates the
explicit community host and NIP-98-signs the real method and URL.
Failures come back as a structured error (`status`, `bodyComplete`,
`bodyEmpty`, `code`), so the UI can tell an older relay's empty 404, a
coded `event_not_found` and a transport failure apart, and never says
"not found" unless the relay did.
- **Explicit-host lifts.** `admin_lift_restriction` sends the page's
community host, not the active relay's. It freezes the relay and signer
the intent was reviewed under, and refuses to send if either has
changed.
- **Pending actions survive navigation.** A panel-level
`DirectActionsProvider` holds the frozen intent, so switching
communities or tabs mid-confirm doesn't lose it. Only Discard drops it.
A signer change remounts the controller and drops it.
- **Fenced lookups.** Member and event lookups are keyed by origin,
signer, community, target and a generation counter, so a late answer for
one community can't unlock Review in another.
- **Shared member picker** (`MemberSearchResult`, also used by Add
Member), showing the truncated npub so members with the same name can be
told apart. Delete accepts a note link and previews the message before
Review. Relay staff are blocked before Review.
- **Disabled admin auth is read-only.** With `BUZZ_ADMIN_AUTH=disabled`
the Communities tab and community pages still render, and report badges
still open them, but every ban, timeout, delete, add-member and lift
control is disabled. The settings badge says the console is read-only
and that acting needs `nip98`. Staffing stays hidden.
- #7904's protections in the Actions tab are kept: frozen intent,
`requestId`, Retry, `notSent` and the key-backup guard.
- **Readable load errors.** Admin list and detail loads show the relay's
error message (for example "request is not authorized"), never the raw
JSON envelope.
- **Failed kicks reload the report.** A resolve that fails with
`enforcement_failed` reloads the report, so its failed action and
"Cancel & reopen" appear immediately.
- **Key backups never leave in a search.** Every admin API request URL
passes the NIP-49 key-backup guard before it is signed or sent:
community and legacy reads, mutations, the connection probe (before its
first, unsigned request) and feedback-attachment fetches. The community
and member search boxes, including the directory's Load more, refuse a
pasted `nsec` or `ncryptsec` anywhere in the input without calling the
relay.
- **`just admin-seed` for multi-community testing.** Besides the local
community, it seeds `beta.localhost:3000`, `gamma.localhost:3000` and 50
filler communities, with named members, a ban and a timeout in each
named community, a report with Kick available, and a message for the
delete preview. Rerunning it restores all of that to the starting state.
It refuses, writing nothing, while a moderation action is unfinished on
a seeded report.
- The bodyless `GET /events/{id}` preview is added to the events-URL
egress inventory. It sends no body, so it carries no key material.

## Tests

Native (Rust):
- `empty_404_and_405_read_as_complete_and_empty`,
`coded_404_carries_the_relay_code`,
`truncated_and_over_cap_bodies_are_incomplete`,
`transport_failure_has_no_status`: the structured read error tells an
older relay, a coded refusal, a cut-off body and a transport failure
apart.
- `a_401_retry_is_signed_by_the_same_keys`,
`lift_signs_the_send_and_401_retry_with_the_checked_keys`: the 401 retry
never switches keys.
- `read_urls_validate_the_explicit_host_and_encode_the_query`,
`lift_url_sends_the_page_host_not_the_active_relay`,
`list_url_carries_host_and_cursor_and_refuses_a_changed_relay`,
`lift_refuses_before_sending_on_signer_change_or_bad_host`: requests
target the explicit host and refuse to send after a relay or signer
change.

jsdom:
- `actions-pending-nav`: a pending intent survives a community change,
and only Discard drops it.
- `actions-fenced-lookup`, `fenced-load`: a late lookup for an old key
never shows under, or unlocks, the new one.
- `actions-same-ids`: the same pubkey and event id are read separately
in each community.
- `actions-community-change-drops-member`, `actions-page-host`,
`actions-success`, `actions-identity`: the form acts only in the page's
community, and confirm sends the frozen intent.
- `actions-preview-errors`, `actions-delete-link`,
`actions-staff-target`, `actions-disabled-auth`: preview errors, delete
by note link, the staff block, and community pages rendering read-only
with every action control disabled under disabled admin auth.
- `communities-directory`, `communities-unsupported`, `community-badge`,
`community-badge-disabled-auth`: the directory, older relays, and report
badges, which open community pages under disabled admin auth too.
- `restrictions-lift-signer-change`. The existing restriction tests now
run on the community page and use `admin_lift_restriction`.

Mutation checks: removing the pending-navigation guard turns
`actions-pending-nav` red, and dropping the host from the fence key
turns `actions-fenced-lookup`, `actions-same-ids` and `fenced-load` red.
Ungating restriction lifts on `canMutate` turns `actions-disabled-auth`
red.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-08 13:58:07 -04:00
Luke Tornquist
d34c9c2c9b ci: shorten the PR critical path (desktop lanes, smoke shards, parallel relay artifacts) (#8156)
## Summary

The PR critical path on main push [run
37635224099](https://github.com/block/buzz/actions/runs/37635224099)
(`e097c4299`) was about 42 minutes:

- **Desktop Core** took 41 min. It ran frontend checks, two clippy
graphs, the Tauri suite, the mesh-llm suite and every compiled-flag
state one after another. The compiled-flag step alone took 16m51.
- **Relay artifact producer → Desktop E2E Integration** took about 31
min. The producer built the relay binaries (5m07), the backend
integration archive (4m41) and the PostgreSQL archive (8m18) one after
another.
- **Desktop Smoke E2E** took 19–24 min per shard across 4 shards, with
82 min of tests in total.

This PR attacks all three.

### 1. Run each compiled-flag state once (`Justfile`)

| Previous step | Cost | Why it's redundant |
|---|---|---|
| Clean state: full `buzz_lib` suite (`--lib`) | 3m27 run |
`desktop-tauri-test` runs the same suite in the same compile state one
step earlier. When `BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY` is
unset, the expected value already defaults to `false` in non-owner-only
builds (`runtime/test_fixtures.rs`, `agents_tests.rs`). |
| Internal state: auto-connect and owner-only built separately | 1m22 +
1m06 + 56s across three recompiles | Internal release packaging always
sets both capabilities together, so building owner-only without
auto-connect tests a configuration that never ships. The extra 56s
happens because a bare `cargo test <filter>` builds every target, not
just the `--lib` harness. |
| Demo state: `cargo test --workspace` | ~30s more than `--lib` |
`buzz-terminal` and the `tests/*.rs` integration tests don't read any
`BUZZ_BUILD_*` variable or `buzz_lib`, so they ran the same binaries a
second time. |

A fourth cost was hidden: every recipe invocation spent ~1m20
recompiling `buzz-desktop` to the *same* unit hash `desktop-tauri-test`
had just built. `_ensure-sidecar-stubs` ran `touch` on every sidecar
stub on each run. `tauri-build` emits `rerun-if-changed` for each
`externalBin`, so the new mtimes reran `build.rs` and recompiled the
crate. Stubs are now created only when they're missing. This also speeds
up any local `desktop-tauri-*` recipe run back-to-back.

After this change:
- **Clean state:** runs only the two compiled-flag assertions
(`compiled_flag_matches_expected`, `compiled_policy_matches_expected`)
with `expected=false`. No recompile.
- **Internal state:** one compile with
`BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1` and
`BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1`. It runs both assertions with
`expected=true`, then the full `buzz_lib` suite. The suite now runs with
auto-connect enabled as well, which matches the shipped internal build
more closely than before.
- **Auto-connect-only state (assertions only):** auto-connect `true`,
owner-only `false`. The two capabilities are documented as independent.
Without this state, a `build.rs` block that read the other capability's
variable would pass, because every other state sets both or neither.
- **Demo state:** one compile with only `BUZZ_BUILD_DEMO_SLUG`. It
asserts the slug and asserts both capabilities stay `false`, then runs
the full `buzz_lib` suite.
- The recipe clears the three toggled `BUZZ_BUILD_*` capability
variables and any caller `BUZZ_TEST_EXPECTED_*` values up front. Each
state exports its own values in a subshell. Every invocation uses
`--lib`, so each state compiles one test harness.
- Each ignored-assertion run requires exactly one passing test per name
filter. Previously, a renamed or un-ignored assertion matched nothing
and passed silently.
- The 32-character demo-name rejection check is unchanged.

The full-suite reruns in the internal and demo states are kept on
purpose. They are the only coverage that the whole suite holds up in
those two shipped build shapes.

Estimate: the compiled-flag work goes from about 17 min to about 11.

### 2. Split Desktop Core into parallel, path-gated lanes
(`_ci-desktop.yml`)

- **Desktop JS:** `desktop-check`, `desktop-test` and `desktop-build`.
These compile no Rust, but some node:test cases read relay and DB crate
sources to pin shared constants (for example
`relayReconnectReplay.test.mjs`). So the job runs on desktop *and* Rust
changes, in parallel with the Tauri lanes. `lefthook.yml` notes this as
a deliberate local deviation.
- **Desktop Tauri (core | mesh | compiled-flags):** a matrix of parallel
Rust lanes.
- `core` and `compiled-flags` share the existing `desktop-core` Rust
cache key, so they start warm. `compiled-flags` restores it read-only.
- `mesh` keeps its own cache because `mesh-llm` pulls a different
dependency graph.
- **Smoke E2E and the Windows frontend build** compile no Rust and read
no crate sources, so they no longer run for Rust-only changes.
- **Smoke E2E goes from 4 to 10 shards.** Playwright shards by test
count, not duration. A simulation from the per-test durations in run
37635224099 reproduces the observed 4-shard split exactly. With 10
shards, the slowest shard runs about 11 min of tests.
- **The `Desktop` gate is stricter.** Each selected job must succeed and
each unselected job must be skipped, so a mis-gated job can't pass
silently. The required check context (`Desktop`) is unchanged.
`scripts/ci-required-gates.test.mjs` pins each job's `if:` to the gate's
selection expression, and checks which jobs a Rust-only, desktop-only,
Tauri, push, and empty selection pick.
- **Path-filter fixes:**
- The `desktop` filter now includes `_ci-desktop.yml`, `ci.yml` and
`Justfile` (which holds the desktop recipes), so edits to them run the
frontend lanes.
- The `rust` filter now matches `Justfile`. It still named the
pre-rename `justfile`, so a `Justfile`-only change selected no Rust
suites.
- `scripts/ci-selection.test.mjs` gains `Justfile` and desktop-workflow
scenarios, and the shared-workflow scenario now expects `desktop`.
- **Dead step removed:** the failure-only Playwright artifact upload in
the old Core job never had anything to upload, because that job never
ran Playwright.

### 3. Build relay artifacts in parallel (`_ci-relay.yml`)

- **The producer is now a 3-way matrix** (`relay-binaries`,
`backend-integration-tests`, `postgres-tests`).
- Each lane has its own exact-source cache entry and uploads
`desktop-e2e-relay-<artifact>`.
- Consumers download `desktop-e2e-relay-*` into `target/ci`, so their
paths are unchanged.
- The required `Desktop E2E Relay` context still reads the producer
job's aggregate result.
- **Rust cache saving:** all lanes restore the existing Rust cache key,
but only the widest lane (`postgres-tests`) saves it. Otherwise the
fastest lane could overwrite it with a narrower target dir.
- **Desktop E2E Integration goes from 2 to 3 shards.** Shard labels now
use `strategy.job-total`.

### 4. Bound degraded apt steps (`scripts/ci-apt-retry.sh`)

- With more shards, more runs hit the occasional degraded Ubuntu mirror.
On this PR, a Tauri lane ran `apt-get install` for 27 minutes: apt's
`Acquire` timeouts kept firing, but apt kept re-queuing the requests and
never failed. Separately, three Smoke shards stalled silently in
`playwright install-deps` (no output after `InRelease`) until the job
timeout. Healthy apt steps across four recent runs finished in at most
238s.
- Every apt-backed step in Desktop and Integration now runs through
`scripts/ci-apt-retry.sh`. The script writes the apt options to
`apt.conf.d`, so Playwright's own apt-get calls get them too. It kills
each attempt after 7 minutes (90s for `apt-get update`). Between
attempts it stops any `apt-get` or `dpkg` the attempt left running,
repairs dpkg under its own 60s deadline, and retries up to 3 times. The
stop matters for `playwright install-deps`, which runs `sudo sh -c
"apt-get update && apt-get install ..."`: sudo puts its command in a new
process group, so the deadline's signal stops `sh` and apt-get survives
holding the dpkg lock (seen on Smoke shard 8 of run 37696815557, and
reproduced with real sudo).
- Downloaded `.deb`s are kept between attempts, so a retry resumes where
the last one stopped. Against a mirror that stays degraded, this bounds
each wrapped command at about 25 minutes rather than recovering it. The
Tauri lanes wrap two commands, `apt-get update` (about 8.5 minutes worst
case) and `apt-get install` (about 25), so about 34 minutes together.
Job timeouts are sized to fit that bound plus the job's own work: Tauri
55m (lanes take up to ~15m), Integration 40m, and Smoke 45m. A degraded
mirror therefore ends with the wrapper's error instead of a silent job
timeout.
- **The retry has already fired on this PR.** In [run
37703795342](https://github.com/block/buzz/actions/runs/37703795342),
`playwright install-deps` on Integration shard 1/3 hit the 420s attempt
deadline. The second attempt started about 1.4s later, downloaded and
configured the real packages, and succeeded. The step took 526s (8m46),
and that shard took 17m24 while the other two took about 5m. Healthy
installs have taken at most 238s, so 420s leaves headroom, and a slow
mirror costs one attempt instead of the job.
- Between attempts the script stops **every** `apt-get`/`dpkg` on the
machine, not only the ones it started. That's intentional: on a hosted
runner any other one would hold the same dpkg lock. It assumes an
ephemeral runner, and the script says not to use it on a self-hosted
runner or a developer machine.
- `scripts/ci-apt-retry.test.mjs` covers the following with a fake
`sudo`: success, retry, the attempt budget, a stalled attempt, a stalled
dpkg repair, an apt-get that escapes the attempt's process group (on a
retry and on the final attempt), and usage. It needs Linux (GNU
`timeout`, `flock`, `setsid`, procps), so it skips on other platforms.
CI runs it on Ubuntu.

### What got simpler

- **Compiled-flag recipe:** two full-suite runs instead of three, six
cargo invocations instead of eight, and sidecar stubs no longer
invalidate the desktop build cache.
- **Path selection:** each desktop job declares only the paths that
actually affect it. The stale `justfile` filter and a dead artifact step
are gone.
- **Apt options:** set once in `apt.conf.d` by one script, instead of as
repeated `-o Acquire...` flags on each apt call.
- **Shard counts:** the shard count is written once per matrix, not
repeated in names and commands.

### Cost

- **More runners:** a full run uses more runner jobs. Smoke E2E goes
from 4 to 10 jobs, Desktop Core from 1 to 4, the relay producer from 1
to 3, and Integration from 2 to 3.
- **Fewer runners on single-area PRs:** desktop-only PRs skip the Rust
lanes, and Rust-only PRs skip Smoke E2E and the Windows build.
- **One extra Rust cache entry:** `desktop-mesh`.

### Related issue

None found.

### Testing

Blox, compiled-flag recipe at `77b9874b`:
- [x] Recipe passes with output redirected to a file. The log keeps all
4 state headers, the ignored assertions ran 2/2/2/3, and both full
suites passed (`3423 passed`).
- [x] A second back-to-back run shows no clean-state `Compiling
buzz-desktop`.
- [x] Mutation tests all fail as required:
  - `build.rs` auto-connect reads the owner-only var: rc=101.
  - The reverse swap: rc=101.
  - Rename `compiled_policy_matches_expected`: rc=1.
  - Wrong owner-only expectation: rc=101.
- [ ] `just ci`

Workflows:
- [x] `actionlint` on `_ci-desktop.yml`, `_ci-relay.yml` and `ci.yml`:
clean.
- [x] `scripts/test-ci-required-context-isolation.sh`: passes.
- [x] GitHub Actions: runs
[37701003713](https://github.com/block/buzz/actions/runs/37701003713)
and
[37703795342](https://github.com/block/buzz/actions/runs/37703795342)
took 27m09 and 28m51. Before this PR, PR runs had a median of 40.0m
(n=21) and main pushes took about 42m. These are observations across
mixed runner images, not a measured causal speedup.
- [x] Selection, gate and apt suites (74/74 on Linux), `actionlint` and
`bash -n` on Blox at tree `db9725fe`.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-08 11:33:37 -04:00
Luke Tornquist
745501962e refactor(schema): fan desired-state schema out to one file per table (#8131) 2026-10-08 08:47:44 -04:00
Luke Tornquist
1972b7d256 feat(admin): add community archive commands (#7107) 2026-10-07 18:31:00 -04:00
Luke Tornquist
e097c42996 feat(relay): time post-metrics-bind startup steps (#8132)
## Summary

Relay pods on large deployments can take over 5 minutes to open their
health port, and right now nothing shows which startup step is slow.
Startup timing ends at `metrics_bind`, and the slow work all happens
after it.

This PR times every startup step from Postgres connect through public
listener bind. It does not change startup behavior.

- Each step logs `Startup phase started` and `Startup phase finished`,
with `phase`, `elapsed_ms`, and `status` (`succeeded` / `degraded` /
`failed`, the same values `buzz_process_lifecycle` records use).
- Gauge `buzz_startup_phase_current{phase}` is `1` while a step runs and
`0` after it ends. A pod that is still starting shows exactly which step
it is on.
- Gauge `buzz_startup_phase_seconds{phase}` records each finished step's
duration.
- The startup NIP-43 membership reconcile logs `NIP-43 startup
reconciliation progress` every 5,000 communities, with `done`, `total`,
`republished`, `failed`, and `elapsed_ms`.

These are gauges rather than counters on purpose. Each value is written
once per boot, and a monotonic-counter scrape drops a series' first
sample, so a one-shot counter never shows up.

## Contract change

`deploy/charts/buzz/README.md` ("Early-startup telemetry contract") said
the early lifecycle phases are log-only. That still holds: `crypto_init`
through `metrics_bind` are unchanged and emit no metrics.

The README now also documents the post-bind steps. The guard in
`boot_lifecycle.rs` now allows exactly the two `buzz_startup_phase_*`
families. It still rejects any other startup, boot, or lifecycle family,
and it also rejects any exported label value that names an early phase.

## Design

- `crates/buzz-relay/src/startup_steps.rs` adds a closed `StartupStep`
vocabulary (18 steps) and a `StepTimer` guard.
- A step ends exactly once:
  - `finish()` → `succeeded`
  - `degrade()` then `finish()` → `degraded` (a non-fatal error branch)
- dropped without finishing → `failed` (covers `?` early returns and
panics)
- The NIP-43 and large-roster reconciles now return `ReconcileSummary {
repaired, failed }`. A sweep where any item fails ends its step
`degraded`, and the final log line includes the `failed` count. NIP-FI
warm ends `degraded` when any issuer did not warm. The deprecated
`reconcile_nip43_membership_snapshots` wrapper still returns `repaired`.
- Steps that are disabled by config (migrate, audit connect, NIP-FI
warm, git probe, NIP-43 reconcile) are not timed, so their series never
appear.
- The gauges are set once, so they age out after the existing gauge idle
timeout (2700 s with default intervals; logged as `idle_timeout_secs`).
A step that runs longer than that loses its `_current` series. The
README says so.
- Progress logging runs only for the `Bootstrap` reconcile. The 60 s
maintenance sweep stays quiet.

## What got simpler

Little got simpler in the code: this mostly adds instrumentation. One CI
gap closed along the way: the 54 `config` tests and the 9 non-ignored
`boot_lifecycle` tests, which ran in no CI lane, now run in `test-unit`.
The instrumentation is scoped as small as I could make it, and it is the
evidence needed for the follow-up that should delete code: moving the
per-community NIP-43 reconcile off the pre-bind path, after which the
900 s startup-probe override can go back to the chart default.

## Testing

- `startup_steps` unit tests (`DebuggingRecorder`):
  - a running step is current and has no duration
  - a finished step clears current and records its elapsed time
  - dropped and degraded steps clear current and record a duration
- log capture: `finish`, `degrade`+`finish`, drop, and panic each emit
exactly one `Startup phase finished` record with the right `status`.
Mutating `degrade()` to a no-op and removing the ends-once guard makes
these tests fail.
- the vocabulary is unique and shares no names with the early lifecycle
phases
- Tests that capture logs or check callsite interest now share
`test_support::tracing_dispatch_lock()`. These are the `startup_steps`
helpers, two `telemetry` tests, and the `config` and `bridge`
log-capture helpers. tracing-core caches each callsite's interest across
the whole process. Without the lock, the new panic test lost its record
in 60 of 150 parallel runs of `telemetry:: startup_steps:: config::`.
With the lock it failed 0 of 300. Removing the lock from `gauges()`
brings the failures back (53 of 100 runs).
- `nip43_progress_logs_every_interval_inside_the_startup_sweep_only`
checks the cadence at 79,942 communities (15 progress lines, none at the
start or end) and table-tests that the maintenance sweep never logs
progress.
-
`boot_lifecycle::successful_main_emits_complete_lifecycle_and_reports_current_startup_step`
boots the real relay binary against a TCP peer that never speaks
Postgres. It asserts that `/metrics` shows
`buzz_startup_phase_current{phase="db_connect"} 1` and no finished-step
durations, and that no early phase leaks into metrics.

- **CI routing.** `just test-unit` picks relay tests by hand-maintained
filter. Before this PR, CI's filters selected none of the
`startup_steps`, `telemetry`, `config` or non-ignored `boot_lifecycle`
tests above; the NIP-43 cadence test is picked up by the existing
`handlers::side_effects::tests::` clause. Its infra-free relay stanza
now selects `startup_steps::tests::`, `telemetry::tests::` and
`config::tests::` (anchored so `nip_fi_config` is not selected twice),
and a new `cargo nextest run -p buzz-relay --test boot_lifecycle` line
runs the non-ignored `boot_lifecycle` tests (they spawn the real binary
and need no Postgres or Redis; the Postgres cases stay `#[ignore]`d).
`scripts/run-tests.sh` mirrors both, running `telemetry` and `config`
single-threaded because they mutate process env.

### Manual check

1. `just relay` (or run `buzz-relay` against local Postgres and Redis).
2. Once the relay is up: `curl -s localhost:9102/metrics | grep
buzz_startup_phase`. Every step that ran has a `_seconds` value, and
every `_current` value is 0.
3. In the relay logs, `"Startup phase finished"` lines show each step's
`elapsed_ms`.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-07 10:15:43 -04:00
Luke Tornquist
d0fd96fca6 BUZZ-175: Route serving event writes through the tenant lock (#7828)
## Summary

Route every serving event write through one community-admitted
transaction entry point. `Db::begin_event_write_transaction(community)`
is now the only `Db` constructor for event-write transactions: it takes
the shared community admission lock before returning, so callers that
use it cannot take domain or row locks ahead of tenant admission, and a
quiescing community rejects the write at entry. Database triggers and
commit-time fences remain the authoritative backstop.

This migrates normal, replaceable, roster, relay membership, reaction,
reminder, admin-delete, push-owned, artifact-accept, huddle-join
(48101), workflow-deletion, and command-executor event writes. Coupled
mention writes stay in the same transaction; pool-level
`insert_mentions` now opens its own admitted transaction.

**Review fixes (since `950d5f6`):**
- The unguarded event-write constructor is gone. Removed with it: the
deprecated `begin_transaction` alias, the test-only replica-floor
constructor, and `begin_community_write_transaction` (now identical to
the single constructor). Artifact accept, the huddle join, and both
workflow-deletion paths now admit before any domain or row lock;
`persist_command_event` no longer hand-rolls `guard_transaction`;
read-only `query_artifacts` no longer opens an event-write transaction.
- `insert_channel_head_checked` is now `insert_canvas_head_checked`: it
rejects non-canvas kinds before opening a transaction and always takes
the coordinate lock.
- Source policy now also scans `buzz-relay`: any function that hands a
transaction to an event-write helper must have opened it through the
admitted constructor. The scan drops each `#[cfg(test)]` item instead of
stopping at the first one: it tracks brace depth and ends the item on a
line ending in `;` or `}` (ignoring a trailing `//` comment, even after
a string that contains `//`), with a column-0 `}` as fallback. Fixtures
pin a unit struct, a multi-line static, a `const { … }` block, a struct
with fields, a module, and one-line items with trailing comments,
including after `"https://…"` and `"ws://…"` strings; a raw opener after
each must still be flagged. Known limit: braces inside string or char
literals or comments are still counted, and only `//` trailing comments
are recognized, so a test-only item with unbalanced braces in a literal
or comment, or with a trailing `/* */`, would hide what follows; none
exists today. The scan checks that admission is present; the per-path
PostgreSQL tests pin that it comes before domain locks.
- `just test-unit` (and the non-nextest fallback in
`scripts/run-tests.sh`) now runs `buzz-db --test observability_source`.
Before this, no CI job ran these source-policy tests: the unit lane ran
`buzz-db --lib` only and the PostgreSQL lane runs only ignored tests.
- Renamed or removed since `950d5f6`: `begin_transaction`,
`begin_community_write_transaction`, and the replica-floor test
constructor are gone; `insert_channel_head_checked` →
`insert_canvas_head_checked`; test helper `quiesce_community_for_tests`
now delegates to `main`'s `set_deletion_state`, which reads the real
`deletion_fence_generation` instead of hard-coding `'0'`;
`runtime::insert_mentions` is pinned to the admitted constructor by
`pool_level_insert_mentions_opens_the_tenant_local_chokepoint`.

**What got simpler:** four transaction constructors collapse to one, and
the hand-rolled admission guard is deleted. The compiler rejects callers
of the removed constructors, but a transaction opened from `Db::pool()`
can still reach the public `*_in_transaction` helpers; only the
source-policy scan and the database fences catch that until BUZZ-254.

**Behavior note:** #7932 made `insert_event` skip the transaction for
kinds that are not listener mentions. Every serving event write now has
to run in a transaction that holds the community admission lock, so
`insert_event` always opens the admitted transaction again. `main`'s
`soft_delete_event_and_update_thread` / `_in_tx` split and its
removal-marker guard are kept; the outer function now opens the admitted
transaction.

### Related issue

[BUZZ-175](https://linear.app/squareup/issue/BUZZ-175). Caller-migration
stage following #7706 (merged); rebased onto `main` at `5173fad66`.
Follow-up: [BUZZ-254](https://linear.app/squareup/issue/BUZZ-254) makes
event-write helpers require a type-level admitted transaction.

**Known debt, out of scope:** `create_channel`, `update_channel`, and
`insert_thread_metadata` write channel and thread rows (not events)
outside the admitted constructor.

### Testing

At `efe4a94623dd69973780a3c39f16dd972cf7cb76` on a cloud workstation:
fmt; `buzz-db` clippy (all targets, `-D warnings`);
`observability_source` 14/14; `just --dry-run test-unit` includes the
new step. Reverting just the end-of-item check to an earlier rule makes
a trailing-comment fixture fail.

At `eaf190ae59d1595741c3d19dc4c23ce492b5f4d4` (the commit after it only
changes this test file, `Justfile`, and `scripts/run-tests.sh`): full
GitHub CI green; on the workstation, clippy for `buzz-db` and
`buzz-relay`, ordinary `buzz-db` 154/154, `just test-unit` green, and
the PostgreSQL lane 805 of 806 (exception below). An end-to-end run
against a local relay and the built desktop app covered artifact accept,
huddle join, and workflow deletion for both a healthy and a quiescing
community, plus p-tag reaction rollback and replay of the same signed
event.

New tests: one fenced-community test per migrated path (artifact accept,
huddle join, both workflow deletions) puts the community into
`quiescing`, holds the lock the path takes next, and asserts rejection
at entry with nothing stored; a DB-free test pins the non-canvas
rejection.

Exceptions:
-
`runtime::postgres_tests::writer_pool_rejects_non_read_committed_database_default`
hangs on that workstation's local Postgres 16 at both this head and an
earlier `main` base, `f0eb5575f` (2/2 runs each). The test, `Db::new`,
and the pool setup are unchanged by this PR, and the test passes in
GitHub CI. Live traces put the hang in the client, not the database: the
1 s acquire timeout stops firing after sqlx's `after_connect` rejection
path under tokio's `current_thread` runtime. No database lock waits were
observed.
- Two `observability_source` assertions were already failing on `main`,
unnoticed because no CI job ran that test binary: a stale
`fetch_optional` pin after #8005, and lease-seam attribution that now
lives in the `bounded_serving_lease_sql` helper. Both are corrected
here, and the binary now runs in `just test-unit`.

Generated with Codex

---------

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>
2026-10-06 10:24:41 -04:00
klopez4212
f0eb5575ff fix(mobile): polish photo carousels and video viewer controls (#8079)
## Summary
- Keep neighboring carousel photos visible, add page-change haptics, and
reuse appearance-style dots with native iOS glass and grouped actions.
- Refine full-screen video controls with fading transport, cycling
speeds, pinch-to-zoom, and double-tap reset before swipe-to-dismiss.

### Related issue
N/A. Checked recent open PRs; repository issue search was unavailable.

### Testing
- Mobile analysis/format checks, file-size guards, and full Flutter
suite passed (2,680 tests; 4 skipped).
- Built, installed, and manually verified on the connected iPhone.
- `just ci` was attempted but blocked fetching a Rust dependency because
this Mac could not connect to GitHub.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: klopez4212 <klopez4212@gmail.com>
2026-10-04 17:17:46 +01:00
Will Pfleger
df3f28a2c4 feat(relay): add NIP-FI shadow mode for HTTP, WebSocket and admin (#8034)
Stack: this PR → #8062

🤖 Follows #8028 (merged).

Adds `BUZZ_NIP_FI_MODE=shadow`. The relay loads and validates the full
enforce configuration (issuers, communities, lifetimes, command
issuers), evaluates NIP-FI evidence wherever enforce would decide, and
records what enforce would have done, while every request, upgrade and
session behaves exactly as in `off`. The one approved exception is admin
disconnect: shadow verifies the command and returns `200`, where Off has
no verifier and returns `503`. It lets an operator measure the
would-deny rate, including Hosts missing from `BUZZ_NIP_FI_COMMUNITIES`,
before switching to `enforce`.

- **Mode predicates.** `NipFiMode` gains `is_off`, `restricts`,
`evaluates`, `enforces`, `denies_unconditionally` and `observes_only`,
built on two exhaustive `match` bases (`restricts`, `evaluates`);
`is_off` is a `matches!` and the rest derive from the bases, so a new
mode cannot silently fall into one side. Every call site uses them,
including the Git membership-lookup failure from #8005, which uses
`restricts()` so shadow keeps Off's body. A test scans each crate's
`src/` and fails on any `NipFiMode::<variant>` in production code
outside the predicate definitions and the env parser, which catches
`==`, `matches!` on any number of lines, `if let` and or-patterns.
- **Startup.** Shadow goes through enforce's path, so every enforce
startup refusal applies, including the required connection lifetime.
Missing-setting errors name the configured mode through one formatter
(`mode_requires`); the enforce text is byte-identical. Shadow builds the
assertion verifier and claims command replays under its own Redis
prefix, both through the production `AppState::new`.
- **HTTP and Blossom.** `admit_nip_fi_http` keeps Off's result in shadow
and additionally replays enforce in enforce's order: first the router
guard's steps (community, then assertion), then the handler's steps
(NIP-98, pairing, deny set) on the same NIP-98 proof, through
`evaluate_enforce_steps`, which enforce shares. A Host mapped in config
but missing from the communities table, with a valid assertion, records
an admit, because enforce's guard passes and its 404 is not a NIP-FI
denial. A dev-mode `X-Pubkey` identity is marked unsigned and is never
an enforce proof, so shadow records it as a `nip98` would-deny. On
bridge and Blossom routes, the strict proof check (payload tag, Blossom
`Strict`) runs as a separate pure check that consumes no replay entry
and records on its own series, so it never counts as an admission
verdict. Shadow records exactly one verdict for every identity,
community or credential refusal enforce would make, with Off's status,
body and challenge unchanged: bridge, workflows, Blossom, Git transport
and settings, GIF search and share, and invite minting record the
`community` would-deny when the Host is unmapped. Git's missing or
malformed credentials still reject before any database work, and shadow
records the enforce verdict for that failed proof. The router guard
stays transparent in shadow.
- **WebSocket, root and audio.** The upgrade check evaluates the
attached assertion once. An upgrade enforce would refuse records that
would-deny; otherwise the assertion goes into a shadow-only session
object and the upgrade returns as in Off. The assertion never reaches
the connection, the identity registries, the admission gate, terminal
frames or cancellation. Each session records at most one admission
verdict. **Pairing** is recorded on both ingresses right after a valid
NIP-42 proof, before ordinary relay policy, where enforce pairs: a key
mismatch or claimless assertion is a `pairing` denial. **The deny-set
check** runs where enforce runs it, after registration: a hit is a
`deny_set` denial, and a clean read keeps the session pending and
registered. **The admit** is recorded only when the connection is
actually admitted: on root at the AUTH commit, after ordinary policy; on
audio when the participant join transaction commits, after relay
membership, channel membership, the final ban and membership decision,
mesh routing and the join's own checks, and before publication. A NIP-42
failure, which comes before pairing, and any refusal between pairing and
the admit (ordinary policy, mesh, or a join refusal inside the join
transaction) retires the observer when the refusal is decided, before
any refusal frame, rollback or cleanup is awaited, so it leaves no
record even if the deadline passes during that cleanup. A record-only
deadline task, armed at upgrade with enforce's
`compute_session_deadline`, records `expired` when enforce would have
closed an admitted session. If the deadline passes while the session is
still pending, that is its single admission verdict, a denial with
`stage=deadline`, and a later AUTH records nothing. A deny for the key
(admin or cross-pod) records `revoked` on an admitted session; if it
arrives after the deny-set check but before the admit, including a deny
refused for capacity, the session keeps a pending-revocation mark and
records the admit followed by exactly one `revoked` end, matching
enforce's phase-independent close, and the deadline cannot take that
end. The phase, the mark and retirement change together under one
session lock. Each session records at most one end. The session holds
the socket's cancellation token for recording only: once the socket is
cancelled, no transition records anything, even while connection
references remain (a cancel fence; the token never feeds a close
action). Upgrade would-denies carry the ingress route, `ws` or `audio`.
- **Admin disconnect.** Shadow verifies the command and records the deny
entry, so later admissions record would-denies, but closes no session;
the response stays `{"disconnected": true}`. Shadow publishes cross-pod
on its own channel, `buzz:nip-fi:shadow-disconnect`, which only shadow
pods subscribe to, so no enforce pod (including older builds that know
only `buzz:nip-fi:disconnect`) acts on a shadow command. Shadow pods
also keep listening on `buzz:nip-fi:disconnect`, so their deny record
includes enforce's real disconnects. Shadow claims command replays under
`buzz:nip-fi:shadow-command:{hash}`: replays are still rejected within
shadow, and a shadow accept never uses up the enforce claim. Enforce and
Off keep the same channel, keys and close behavior.
- **NIP-11.** Shadow serves the same document as enforce, including
`limitation.federated_identity`, because clients only attach evidence to
a relay that advertises it.

## Recording

| Series | Labels | One increment per |
|---|---|---|
| `buzz_nip_fi_shadow_total` | `route` (`http`, `ws`, `audio`), `stage`,
`outcome`, `community` | decision enforce would make: an HTTP admission,
a refused upgrade, a session's NIP-FI AUTH decision, or its deadline
passing before AUTH (`stage=deadline`) |
| `buzz_nip_fi_shadow_strict_proof_total` | `route` (`bridge`,
`blossom`), `outcome` (`pass`, `rejected`), `community` | strict NIP-98
side check |
| `buzz_nip_fi_shadow_session_end_total` | `route` (`ws`, `audio`),
`reason` (`expired`, `revoked`), `community` | admitted session enforce
would have ended (admitted sessions only) |
| `buzz_nip_fi_shadow_disconnect_total` | `route` (`admin`,
`cross_pod`), `outcome` | disconnect-path event, in place of the real
disconnect, lag, capacity and poison counters |

`community` is the configured canonical URI or `unmapped`, never the raw
`Host`; an unmapped or empty Host records `stage=community,
outcome=unavailable`. A shadow pod never moves an enforce disconnect or
failure counter. Strict-proof rates use their own `pass + rejected`
total, and session-end rates divide by the `admit` count for the same
route. There is no per-verdict `info` log; a would-deny writes a `debug`
line with no token, claim or issuer. The pre-existing enforce `debug`
line for the proven NIP-98 key is shared and also fires in shadow. Admin
commands rejected for a bad header, body or proof, a replay, or a
dependency error record no shadow verdict; only capacity rejection is
counted.

## Not observed

- Handler-side path-validation exits: an invalid Git owner or repo name
on default-branch, a media path that is not a valid hash, and a Git pack
URL with a query string record nothing from the handler. The router
guard still records what enforce would refuse at the guard (for example
a missing assertion), because enforce refuses those requests before it
validates the path.
- A refusal that is not a NIP-FI decision, such as a tenant-binding 404
behind a passing guard, or an ordinary WebSocket refusal after pairing
(a ban, membership or join refusal before admission).

## Follow-ups

- A bounded `route` label per HTTP surface (bridge, Blossom, Git, …) on
`buzz_nip_fi_shadow_total`; today every HTTP verdict carries
`route=http`.
- One shared helper for the ten copied `observe_unbound` recording
hooks, stacked on this PR.

The shadow recorder and session suites and the root pairing witness run
in `just test-unit` and the `scripts/run-tests.sh` fallback; the
`AppState::new` shadow witness, which exercises the command verifier
against real Redis, runs in the `external_infra_redis` lane. NIP-FI env
tests recover a poisoned env lock so one panic cannot cascade.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-02 18:32:17 -04:00
Luke Tornquist
fe9e2409a0 BUZZ-175: Add application-owned writer lock foundations (#7706)
## Summary

Buzz is moving database invariants from triggers and foreign keys into
application-owned transaction protocols. This PR adds the first
transition foundations while preserving all current database backstops.

- Add a shared community-write lock for single-community writer
transactions, and make deletion lifecycle transitions take the matching
exclusive lock. A supported serving transaction never spans communities.
- Add shared event-writer and exclusive replica-probe lock ordering
around the existing heartbeat publication model.
- Document the supported writer contract, reviewed backfill path, and
transition gates.
- Keep one replica-floor publication concept and remove an unrelated
reconciliation-script edit found during review. This narrows the change
rather than adding parallel protocols.

The blast radius is limited to new opt-in writer helpers plus lock
acquisition in deletion and replica probing. Existing trigger and GUC
enforcement remains authoritative, so callers can migrate in later pull
requests.

### Related issue

[BUZZ-175](https://linear.app/squareup/issue/BUZZ-175). This is the
first implementation slice from the OSC migration plan.

### Testing

No manual testing. CI covers the Rust checks and database tests added
here.

Generated with [Codex](https://openai.com/codex)

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-02 16:53:09 -04:00
Alex Rosenzweig
4f51b9e101 fix(mcp): support Goose discovery handshake (#8037)
## Summary

Goose 1.52 starts MCP connections with `server/discover`. `buzz-dev-mcp`
rejects that request with `ExpectedInitializeRequest`, preventing Goose
from loading its Buzz tools.

Upgrade the tool server to rmcp 3.4.1 and adopt the renamed SDK types.
Subprocess tests exercise discovery and legacy initialization, tool
listing, file reads/edits, invalid arguments, connection survival, and
EOF shutdown. Include the server in both Rust unit-test runners and make
the cancellation regression approve its shell call before testing
cancellation.

The native agent retains its independent rmcp 1.x client. Cargo also
unifies the existing 3.x dependency used by the mesh smoke harness.

## Validation

- Normal `git push` passed all hooks, including Rust tests, Tauri
checks, and repository policy checks.
- `just ci` passed on the final stack: repository checks, Rust and Tauri
tests/lint, desktop tests/build, web build, and mobile checks/tests.
- MCP: 78 existing tests and two subprocess lifecycle tests passed; the
discovery regression fails with the old SDK.
- All 54 native-agent regressions passed on the final stack, including
cancellation of a real MCP shell process.
- Previously exercised unmodified Goose 1.52 and 1.47 with Sandpit and
the real tool server. Allowed workspace operations and permitted network
traffic succeeded; disallowed file and network operations were denied.
- Live staging desktop check with Goose 1.52: the reply appeared in
Buzz, an allowed request returned HTTP 200, and a disallowed request
returned CONNECT 403.

## Stack

1. **#8042 → `main`**: deterministic handoff regression.
2. **This PR → #8042**: Goose MCP discovery compatibility.

Merge the lower PR first, then retarget this PR to `main`. This diff
contains only the compatibility change and its tests; the relay build
repair is already on `main` in #8036.

---------

Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
2026-10-02 11:24:25 -05:00
Will Pfleger
a38707d35c fix(relay): close community ban gaps across audio, agents and HTTP (#8005)
🤖 Community bans had gaps. Audio sockets never checked them: the audio
join path didn't look up ban state, and a ban closed only the member's
root sockets, so a banned member could join a huddle or stay in one. An
agent's access comes from its owner, but the ban and timeout checks only
looked at the pubkey that signed the event. Several write paths returned
before those checks ran, admission could miss a ban that landed
mid-AUTH, and removing or losing a member left live sockets open. This
PR closes those gaps.

## Audio

- `handlers/auth.rs`: the root socket's ban lookup, including the NIP-OA
owner cascade and fail-closed DB handling, is `community_ban_outcome`.
Root auth and audio join both call it, so the two checks can't drift
apart.
- `audio/handler.rs`: audio join runs the ban check after the write-free
channel membership check and before any huddle lease or durable write. A
ban is refused with `AuthorizationDenied` and a DB error with
`AuthorizationUnavailable`. With no FI assertion, the refusal is the
root route's text (`blocked: you are banned from this community` /
`error: internal error checking restriction state`).
- `state.rs`: `CommunityConnectionControl` holds the authenticated
pubkey. `CommunityConnectionRegistry::disconnect_pubkey` closes bound
audio sockets for one pubkey in one community with a 1008 `access
revoked` close. `AppState::disconnect_pubkey_local` closes everything a
pubkey has open in a community on this pod (root and audio); the
clusterwide disconnect and the conn-control Redis subscriber in
`main.rs` both use it, so remote pods close audio sockets too. Root
sockets are still closed through `ConnectionManager`, so nothing is
closed twice. A socket that proved a NIP-FI identity instead gets the
standard NIP-FI denial (the restricted frame, then 1008 `authorization
denied`), and a ban never overwrites a close reason already chosen.

## Behavior

- **Owner restrictions carry to agents.** `restriction_state` folds in
the restriction row of the agent's owner (`users.agent_owner_pubkey`) at
read time. Nothing is copied onto the agent, so unbanning or
un-timing-out the owner restores the agent immediately. Every hot-path
caller (AUTH, ingest, audio, HTTP) picks this up.
- **Admission is fenced against a concurrent ban.** Root AUTH and audio
admission bind the proven pubkey to the socket first, then make the
final ban and relay-membership decision
(`handlers::auth::final_admission_denial`), then refuse if a disconnect
already cancelled the socket. A ban or removal whose disconnect ran
before the bind is visible to those fresh reads; one that runs after it
finds and closes the bound socket. Both reads come from the writer,
never the bounded-stale replica, so a removal that committed before the
bind cannot be undone by a lagging replica. Both fail closed, and the
check covers a delegated agent's owner. Each admitted socket records the
agent's owner, from the NIP-OA tag or the stored link.
- **Agents are always linked to their owner before admission.** Root
AUTH and audio both record `users.agent_owner_pubkey` for a NIP-OA agent
before admitting it, and refuse admission (`error: internal error
recording agent owner`) if that write fails, because revoking the owner
finds the agent's sockets through that link.
- **Revoking live access covers agents without a database read.**
`AppState::revoke_live_access` sends one clusterwide disconnect that
closes, on every pod, each root and audio socket in the community whose
principal **or recorded owner** is the target. An agent socket admitted
before its owner link existed carries no recorded owner, so recording an
owner for the first time (`materialize_nip_oa_owner`, the single writer
used by root AUTH, audio admission and HTTP NIP-OA) closes that agent's
ownerless root and audio sockets clusterwide; they reconnect with the
owner attached. That disconnect skips owner-bound sockets. Admission
with a NIP-OA owner records it on the socket before binding the pubkey,
so the socket survives its own link; admission without one binds the
pubkey first and then reads the stored owner, so a concurrent link
either closes the socket or is seen by the read. A
`users.agent_owner_pubkey` sweep still runs as a secondary path; if its
lookup fails, the error is returned and there is no retry task. The
kind-9040 ban, the report-action ban (left non-terminal so recovery
re-runs it), relay-admin removal (9031), and NIP-43 self-leave all use
it and report an incomplete revoke instead of claiming success. Each
revokes right after its change commits; the ban no longer waits on its
audit insert, whose error is still returned.
- **NIP-43 self-leave works over WebSocket.** Kind 28936 is in the
ephemeral range, so `handle_event` used to fan it out and answer `OK
true` without removing membership. It is now routed to ingest; no other
ephemeral kind changes.
- **The write gate runs before every kind-specific branch.**
`enforce_write_restriction` runs ahead of command routing (DM open, DM
add member, DM hide, workflow definitions and triggers, approvals),
product feedback, reports, and moderation or relay-admin commands.
Ephemeral events and agent observer frames return before ingest in
`handle_event`, so they go through the same gate there.
  - A ban exempts nothing.
- A timeout admits only reports (kind 1984), unban (9041) and untimeout
(9043). A timed-out admin cannot send relay-admin commands (9030–9033)
until the timeout ends.
- **HTTP refuses banned principals.** `enforce_relay_membership` runs a
ban check after the membership decision, including on open relays: 403
`blocked: you are banned from this community` for a ban, 503 for a
failed lookup. Media upload, media GET/HEAD and Git transport keep that
503 instead of reporting it as "not a relay member": in NIP-FI Enforce
mode they send the canonical `AuthorizationUnavailable` response
(`text/plain; charset=utf-8`, `authorization unavailable\n`), and in Off
mode each keeps its legacy unavailable response. Media storage failures
are not reclassified. Git transport checks membership and then runs its
own ban check once per request. The NIP-OA owner is resolved from
`x-auth-tag` when present. HTTP checks **bans only**: a timeout blocks
writes, HTTP writes already reach the ingest gate, and reading stays
allowed under a timeout.

Unchanged by design: a lost Redis fan-out can leave another pod's socket
open until it reconnects, and there is no recurring ban recheck on open
sockets.

## Path table

| Path | Category | Ban check |
|---|---|---|
| Root WebSocket AUTH and audio admission, including NIP-OA owner
binding | community admission | final fenced check after bind; owner
link required |
| `POST /events`, `/query`, `/count` (`bridge.rs`) | community HTTP |
existing `enforce_relay_membership` caller |
| GIF search and `POST /gifs/share` (`gifs.rs`), media upload and fetch
(`media.rs`), workflows (`workflows.rs`) | community HTTP | existing
caller; media keeps 503 on lookup failure |
| Git transport (`git/transport.rs`), git settings (`git/settings.rs`) |
community HTTP | existing caller; transport keeps 503 on lookup failure
|
| `GET /moderation/reports`, `/moderation/audit`,
`/moderation/restricted` | community HTTP | **newly covered** |
| `POST /api/invites` (invite mint) | community HTTP | **newly covered**
|
| NIP-43 self-leave (WebSocket and `POST /events`, both via ingest) |
roster change | revokes live access like admin removal |
| Invite claim, accept-policy, `/api/join-policy*` | pre-membership
invite and policy flows | none; ban state is checked when the caller
authenticates |
| `/`, `/info`, `/.well-known/nostr.json`, `/health`, `/_liveness`,
`/_readiness` | public metadata and health | none |
| `/hooks/{id}` | secret-authenticated webhooks | none; no community
pubkey |
| `/operator/*`, `/api/admin/v1`, `/internal/git/policy` | operator,
admin and internal routes | none; separate auth plane |
| `/_mesh/demo/echo` | demo only (mesh plus `mesh_demo_echo`) | none |

## Known limits

- Cross-pod closure depends on the Redis fan-out; the tests prove the
pod-local close by principal and recorded owner.
- Live delivery to existing subscriptions depends on the disconnect.
Events are not rechecked one by one.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-01 23:24:56 +00:00
Will Pfleger
9b083957f6 refactor(buzz-relay): share NIP-FI assertion evaluation across adapters (#7990)
🤖 Stack: #7990 → #8028

The three production NIP-FI assertion-verification sites (the HTTP
assertion guard in `router.rs`, `check_nip_fi_at_upgrade` in
`nip_fi_upgrade.rs`, and `admit_nip_fi_http` in `nip_fi_http.rs`) each
carried their own extract → verify → map-to-denial sequence, and HTTP
admission and `enforce_nip_fi_key_pairing` each carried their own
key-equality check. The admin disconnect route (`POST
/api/nip-fi/disconnect`) kept a second copy of the header parser and
hand-typed its denial bodies. Community binding and shadow mode both
need to change exactly those decisions, so they now live in one place.

**No behavior change.** Every denial class, status code, body, header
and log line is preserved, including `deny_protected` (503), the
startup-race 503, transport-before-verifier precedence,
NIP-98-before-assertion ordering, and guard + handler double
verification.

- New relay-private `nip_fi_core` owns `extract_bearer_token` and
`http_denial` (moved from `nip_fi_http`), `evaluate_attached_assertion`
(the single `verify_assertion` call for all three adapters, returning a
typed `AssertionRejection`), and `asserted_key_matches` (the pairing
predicate; a claimless assertion never matches). `http_denial` and the
disconnect route's plain-text renderer fall back to the bare denial
status instead of panicking or answering an empty 200; both fallbacks
are unreachable because every status and header value is a constant.
- Adapters keep their own mode short-circuits, exemptions, ordering,
metrics and `code()` debug logs; the guard still logs nothing.
- The disconnect route parses its header with `extract_bearer_token`,
renders header failures with `http_denial`, and renders every other
rejection (malformed body, bad pubkey, no command verifier, every
`CommandError`) through `CommandError::http_status()`/`response_body()`.
Its check order (header → body → pubkey → verifier present → verify),
`DenySetFull` warning and metric, success body and cross-pod publish are
unchanged. The old Unicode whitespace check and the shared ASCII one
agree on every reachable input, because `HeaderValue::to_str` only
admits visible ASCII and tab.
- The 12 HTTP admission callers, 3 WS upgrade placements, deny-map
checks, expected-URL constructors and the command/admin proof path are
untouched.
- Characterization tests land before each refactor, green against the
unmodified code, and pass unchanged after it. A routed test counts both
verifications on one request (guard, then handler admission) and shows
admission's own failure decides the response. Disconnect-route tests pin
exact status, content type, challenge and body for every rejection, the
observable check order, and that only `DenySetFull` increments
`buzz_nip_fi_disconnect_capacity_rejections_total`. The test modules
share one counting `ScriptedVerifier` from `nip_fi_core::tests`, and the
session tests build connections through `connection::tests::test_conn`.
`nip_fi_core::tests::` is added to the unit selectors in `Justfile` and
`scripts/run-tests.sh`.

🤖 Implemented by Hayt.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-01 18:52:48 -04:00
Will Pfleger
24c1e702a6 fix(relay): gate owner-only kinds in shared fan-out access filter (#8006)
DM visibility snapshots (kind:30622) and agent turn metrics (kind:44200)
must reach only their owner, meaning the pubkey in their `p` tag.
History reads and same-pod live delivery already enforced this. Events
arriving from another pod over Redis went only through
`filter_fanout_by_access`, which had no owner check. So a subscription
on another pod could still match one of these events, for example a
subscription that names only event IDs and no kind.

The owner check now lives in `filter_fanout_by_access`
(`crates/buzz-relay/src/handlers/event.rs`), which both same-pod
dispatch and Redis delivery go through. It reuses the read path's
predicate, `buzz_core::filter::reader_authorized_for_event`. The
separate copy inside same-pod dispatch is removed, so there is one check
instead of two.

Behavior changes:
- An owner-only event with no `p` tag now reaches no one live. Before,
on the same pod, it reached every matching subscriber.
- Any `p` tag now counts as the owner, matching history reads. The old
same-pod check used only the first `p` tag.

Tests:
- `owner_only_kinds_keep_only_the_owner` covers the shared filter.
- `pubsub_owner_only_kinds_reach_only_the_owner` covers Redis delivery
end to end, for both kinds. It uses subscriptions that name only event
IDs, and includes an event with no `p` tag.
- `dispatch_owner_only_kinds_reach_only_the_owner` covers same-pod
dispatch the same way, checking the `EVENT` frames actually queued.
- Each test fails with its filter call removed and passes with it.

Hook note: this branch was pushed with `LEFTHOOK=0`, so the pre-push
hook did not run on it. Earlier push attempts failed under heavy machine
load, in crates this diff doesn't touch: the `buzz-acp` timing tests
`idle_resets_on_stdout_activity` and
`keepalive_resets_idle_past_deadline`, then `rust-tests` and
`desktop-tauri-test` (`buzz-desktop`). CI is the gate for the full
suite.

Also fixes a timing flake in #7977's
`deny_set_full_leaves_command_retryable` test: its filler deny entry
could expire before the full-set request, so the test now gives it an
`until` offset of 2 seconds.

🤖 Implemented by Duncan (agent).

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-01 18:40:17 -04:00
Will Pfleger
16839a0773 feat(nip-fi): admin disconnect/deny API with in-memory deny-until-TTL map (S4) (#7977)
This adds the NIP-FI S4 admin command surface. An authorized issuer
principal can deny an `(issuer, pubkey)` pair until a TTL expires. When
that happens, the relay records the entry in a bounded in-memory deny
map, closes matching live sockets on every pod, and refuses new
admissions while the entry is live.

Supersedes #7265

## Breaking

In `BUZZ_NIP_FI_MODE=enforce`, every entry in `BUZZ_NIP_FI_ISSUERS` now
has to carry `maximum_command_age_seconds` (1–60) and a non-empty
`authorized_principals` list, and startup fails if any issuer is missing
either one. That means an S3-only enforce config that boots on `main`
won't boot with this change. No deployment sets `BUZZ_NIP_FI_*` yet. The
per-issuer field table and a working example are in the
`nip_fi_config.rs` module docs.

## What changes

- **`buzz-auth`**:
- `nip_fi/command.rs` verifies signed admin commands. It checks issuer
policy, command age, the authorized principal (exact, case-sensitive
`sub` match), and a `jti` of at most 512 bytes.
- `nip_fi/deny_map.rs` is a per-issuer, capacity-bounded deny-until-TTL
map on top of `DashMap`. Expired entries and `jti` reservations are only
swept when a write would otherwise hit capacity. The sweep is O(n) and
holds the lock every admission check takes, so this keeps it off the
admission path. Reads compare against `now`, so an expired entry that
hasn't been swept yet never counts as live.
- **Cross-pod command replay fence**: `nip_fi/command_replay.rs` adds a
`CommandReplayGuard` trait, and `buzz-pubsub` implements it in Redis
(`RedisCommandReplayGuard`), following the NIP-98 replay guard. Once a
command is authenticated, the verifier takes an atomic `SET
buzz:nip-fi:command:{sha256(len(iss)‖iss‖len(jti)‖jti)} 1 NX EX <ttl>`
claim, with the TTL set to the command's remaining validity rounded up
to whole seconds plus skew, clamped like NIP-98, so the claim outlives
acceptance by a verifier whose clock trails by the full skew. Before the
claim, the verifier reserves a pending `jti` and deny-entry slot in this
pod's deny map under the issuer lock, so a full deny set returns `503
deny set full` without touching Redis and the same signed command can be
retried. The claim is taken only after the signature and claims verify,
so a forged token cannot burn a real `jti`. A `jti` already claimed on
any pod returns `403`. A Redis error fails closed with `503
authorization unavailable`: no deny entry, no session close, no publish.
A replayed claim, a Redis error, or a cancelled request drops the
reservation. After a successful claim the reservation commits to the
`jti` record and deny entry, which cannot fail on capacity. A claim can
still remain with no deny entry if Redis applied `SET` but the reply was
lost, or the pod stops between claim and commit; that fails closed, and
a retry gets `403` until the TTL expires.
- **`api/nip_fi.rs`**: the admin disconnect/deny endpoint. It verifies
the command, inserts the deny entry, disconnects locally, and publishes
over `buzz-pubsub` `conn_control` so every other pod runs the same
disconnect. A receiving pod clamps `until` to its own ceiling instead of
rejecting it. Otherwise a pod whose clock trails the origin would drop a
command the origin already answered with 200, and the target's sockets
on that pod would stay open. Malformed pubkeys, unknown issuers, and
unrepresentable timestamps are still rejected.
- **Admission and live enforcement**:
- WS upgrade (`router.rs`) and HTTP (`nip_fi_http.rs`) refuse keys that
have a live entry with `403 authorization_denied`.
- Root NIP-42 auth and audio auth register the proven identity (pubkey +
admitting issuer) first, and only then check the deny map. With that
order, a concurrent disconnect either finds the socket in its close scan
or the check finds the entry. `OK(true)` isn't sent if a concurrent
disconnect already cancelled the session.
- `ConnectionManager::disconnect_nip_fi` and
`CommunityConnectionRegistry::disconnect_nip_fi` are scoped to the
issuer.
- **One wire form for `authorization_denied`** (FI-TRACE-DENIAL-ORACLE):
every root and audio WebSocket `authorization_denied` denial now goes
through one first-writer-wins transition on
`CommunityConnectionControl`. That covers the S4 deny-set hit and admin
disconnect, and also the existing S3 key-mismatch, lease-expiry, and
membership refusals. The transition sets the `AuthorizationDenied`
reason only if nothing else has set a reason, and only the winner
enqueues the denial frame. So every one of these denials is the class's
denial frame followed by `Close(1008, "authorization denied")`, and a
concurrent registry scan can't queue a second one. Before this, S3 key
mismatch and expiry closed with `Close(None)`, which made them
distinguishable from a deny-set hit on the wire. `evidence_rejected` and
`authorization_unavailable` keep the bare close.
- **Config**: see Breaking. `deny_set_capacity` is optional and defaults
to 50000. Setting any command field without a command age fails closed.
- **Boot** (`main.rs`): installs the command verifier and deny map. It
subscribes to cross-pod disconnects before the Redis subscriber starts,
so disconnects relayed during boot are buffered instead of dropped.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-01 19:40:09 +00: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
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
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
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
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
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
Will Pfleger
8f8c4dfade feat(desktop): relay admin console for the /api/admin/v1 operator surface (#4768)
Adds a relay admin console under Settings — the single surface for
relay-level operator work — replacing the previously unreachable
moderation section. Operators authenticate with their own Nostr key over
NIP-98 — no browser extension, no bearer token — to view deployment-wide
moderation reports and product feedback, resolve/dismiss/escalate/reopen
reports, update feedback status, and manage the operator/moderator
staffing roster.

## Nav reachability

The old `moderation` section id was defined in `settingsSections` but
never wired into any group in `settingsNavGroups`, so the sidebar never
rendered it — unreachable since #1617. This PR replaces it with a
`relay-admin` section wired into the Communities nav group, pointing at
the relay admin console, and drops the separate `admin-console` section
id so a single surface owns relay-level trust & safety.

The Relay admin nav entry is always visible. Auth gates the panel
itself; the nav entry is a door, not a credential — hiding it behind
discovery state would strand an operator whose relay transiently fails
NIP-11 discovery with no path back to the connection settings.

## Discovery and connection

On mount the console reads the connected relay's NIP-11 document for the
optional `admin_api` field. When a valid origin is discovered **and its
host matches the connected relay's host** (case-insensitive, host
identity only), it is automatically saved and probed — the panel renders
immediately with no Save step required. A cross-host advertisement is
pre-fill only: it populates the manual origin field under Advanced and
is never saved or probed without an explicit Save, so a relay cannot
direct an unconsented NIP-98 signature at a third-party origin; residual
exposure on the same-host path is bounded by NIP-98's URL/method/payload
binding. Saving is only required for manual origin changes. The
advertised origin is untrusted input: revalidated through
`AdminOrigin::parse`, falling back to manual entry when absent or
invalid. A saved manual origin always takes precedence over discovery.

## Auth and transport (Rust, `src-tauri/src/commands/admin/`)

- `AdminOrigin` value object: validates scheme + host + optional port,
rejects credentials/path/query/fragment; `http://` only for loopback.
The relay side normalizes its configured admin host at config load and
compares inbound `Host`/`Origin` hosts case-insensitively.
- `AdminRoute` closed enum: no IPC surface accepts arbitrary URLs, so
the NIP-98-signed URL is byte-identical to the fetched URL.
- Dedicated no-redirect `reqwest` client as an SSRF guard: a relay `3xx`
is an error and the NIP-98 header never crosses origins.
- `admin_probe` signs `GET /probe` and returns a typed state
(`nip98Authorized` / `nip98Denied` / `disabled` / `notAdminApi` /
`networkOrIntercepted`). The response body is validated against the full
relay contract — `nip98Authorized` only when the complete NIP-98
invariant holds, `disabled` only on a coherent disabled-mode body; any
incoherent or unrelated JSON classifies as `notAdminApi`, so the console
never fails open at the network boundary. Unknown fields are tolerated
for forward compatibility.
- NIP-98 signing via `AppState::signing_keys()`, one retry on `401` with
a fresh signed event; response size bounded by a `Content-Length`
preflight and a streaming byte counter; per-pubkey origin storage with
atomic `0o600` writes.

## Relay admin console

The console is gated behind the relay `OPERATOR`/`MODERATOR` roles from
#3777; every `/api/admin/v1/*` route returns `403` to anyone else.
Community self-administration (`community-members` / Invites) and
in-channel enforcement (the 9040–9043 signed commands) are a separate
axis and are untouched. The previously-duplicate community report queue
(`ModerationQueueCard`, `moderationQueue.ts`, and three dead hooks) was
unreachable dead code and is deleted in its own commit;
`useBanMemberMutation` and the rest of `features/moderation/hooks.ts`
remain in use by the members sidebar and message menu.

## Console UI (TypeScript, `src/features/admin-console/`)

- `AdminConsoleSettingsCard`: discovery + probe flow, honest copy for
every probe state, manual origin under an `Advanced` disclosure at the
bottom. The role (operator/moderator) is rendered in the connection
status line as plain text ("Connected as operator") — no badge-shaped
elements above the tab row. The origin provenance (relay config /
database) appears inside the Advanced card as small muted text. Section
title changed to `Admin`.
- `AdminConsolePanel` with Reports / Feedback / Staffing tabs (Staffing
gated on `role === "operator"`); reports and feedback grouped by
community for cross-community triage.
- **Reports**: always requests `scope=all` so the existing
resolve/cancel/reopen controls can reach every status (`open`,
`processing`, `resolved`, `dismissed`, `escalated`). The relay's
omitted-scope default is `escalated`-only (the platform-safety
backstop); `scope=all` is explicit and scoped to this console. Full
action matrix per `target_kind` (event →
delete/kick/ban/timeout/dismiss/escalate; pubkey →
ban/timeout/dismiss/escalate; blob → dismiss/escalate); kick is
suppressed when the report carries no `channelId`. A `processing` row
stays navigable — enforcement state lives in the detail view. A `failed`
action (always pre-mutation) offers a single **Cancel & reopen** via
`POST /reports/{id}/cancel` with `{actionId}` fencing; there is no
client-side retry. `pending`/`enforcing` actions belong to the relay
recovery worker and offer no button; a `409` reloads detail. Lists
refetch on back-navigation after a mutation.
- **Report failure UX**: `reportErrorMessage()` strips 4xx/5xx status
prefixes and surfaces the relay's own error reason in the toast (e.g.
relay returns `"400: you cannot report this content"` → toast shows
`"you cannot report this content"`). Self-report blocking UI removed —
the relay has no self-report gate, and the failure was purely
client-side error surfacing.
- **Reason audience disclosure**: the Reason (optional) field shows
exact copy under the input based on selected action — Delete discloses
that reason is sent verbatim to the affected user and posted publicly in
the room; Kick/Ban/Timeout disclose affected-user only; Dismiss/Escalate
disclose reporter only. These mirror the actual relay notice paths.
- **Enforcement history**: the report detail carries the governing
action even after the report leaves `processing` — a report enforced
then reopened is `open` yet still shows its `succeeded` action as
executed history alongside the resolve form; a reopen does not un-happen
a ban. The resolve form gates on `open` status alone.
- **Reopen**: terminal reports (`resolved`/`dismissed`/`escalated`) can
be returned to `open` for re-triage via `POST /reports/{id}/reopen`.
Reopen never reverses applied enforcement, and the copy says so.
- **Feedback**: list, detail, and status control
(`new`/`reviewed`/`archived`) with a generation-fenced attachment
viewer. A purged source community severs provenance to `null` rather
than deleting the row; severed rows bucket under a "source community
removed" heading.
- **Staffing**: Add is create-only with display names resolved via
`useUsersBatchQuery` and npub cross-fade on hover
(`HoverStaffingIdentity`). An entered key that already appears in the
authoritative roster (config, owner-fallback, or DB) is rejected locally
with an inline duplicate-key message and no request is issued; a `409
Conflict` from add, role change, or remove surfaces the relay's parsed
error body via `adminErrorMessage(e)` — distinguishing config-backed key
conflicts from last-operator conflicts without HTTP-status inference.
Roster with `config`/`owner_fallback`/`db` source labels; config-backed
entries disable remove client-side. In-place role change via `<select>`
calling `putAdminOperator`; the relay responds with the effective
`OperatorEntry` (`pubkey`, `effectiveRole`, `sources`) in the same shape
as the roster list. Read-only badge for config-managed entries.
- **Timeout expiry**: `useTimeoutState` clears the store when derived
state is `INACTIVE` but the store is still active; the 1s tick is scoped
to entries with a known `expiresAtMs` and owned by
`ComposerTimeoutBanner` (mounted only while a timeout is active), while
`ChannelPane` subscribes to the boolean `useTimeoutActive()` — the pane
re-renders when a timeout starts or ends, not every second.
`useMembersSidebarModeration` ticks `nowMs` only while the sidebar is
open, the viewer can moderate, and some member has a future
`mutedUntil`; the tick after the last expiry stops the interval.
- Staffing refreshes the principal after a self-demotion or
self-removal: `AdminConsolePanel` passes `onSelfMutation` to
`StaffingTab`, which re-probes the saved origin so the role badge and
tab visibility update without a manual re-probe.
- Staffing mutation failures, including both 409 variants (config-backed
key vs. last operator), surface the relay's parsed error via
`adminErrorMessage`, so the last-operator recovery guidance is shown.
- Mutations confirm via `sonner` toasts; failures surface the relay's
parsed error `message`, not raw JSON. The `disabled` auth mode renders
read-only.

## Mutation idempotency

Resolve and reopen carry a per-attempt `requestId`. On first submit the
whole command (`{requestId, action, reason, expirationSecs}`) is frozen;
after an ambiguous failure (409, 5xx, transport error, truncated
response) the snapshot is retained, action/reason/duration controls are
locked, and the retry sends the exact same payload byte-for-byte. A
definitive pre-commit rejection (non-409 4xx with `bodyComplete: true`)
discards the snapshot, unlocks controls, and the corrected submit uses a
fresh payload. On success the toast derives from the authoritative
`AdminReportResolution` (`activeAction.action` when present; otherwise
`status` for decision-only outcomes) — never from the mutable form
selection.

The native mutation commands — including operator delete — reject with a
typed `AdminMutationError` carrying the relay's HTTP status
(`relayStatus`, `null` when no verdict exists) and a `bodyComplete` flag
that is `true` only when the full response body was read.

## Restriction routes contract change

`GET /members/restrictions` and `DELETE /members/{pubkey}/ban|timeout`
now take `communityHost` instead of `communityId`; the relay resolves
the tenant itself and returns `400 unknown_community_host` for an
unmapped host. This is a breaking contract change with no compatibility
shim: the relay must run this build before a desktop that uses it,
scripts sending `communityId` must switch, and rolling the relay back
breaks the desktop Restrictions UI. `docs/admin/README.md` documents the
three routes in the route inventory and carries the migration note.
Restriction calls also carry the relay the list was loaded from, and the
native side rejects a mismatch before building any URL.

## Hardening

- Saved admin origins are stored per pubkey and per relay host. The host
slug parses the relay URL, keeps the port-drop for DNS names, and
encodes IPv6 hosts canonically, so two IPv6 relays never share a file
and no IPv6 slug can match a DNS slug.
- NIP-11 admin discovery has its own 10s request deadline and reads
`/info` success and error bodies through the bounded reader with a 64
KiB cap (`Content-Length` is only a pre-check).
- `PUT /operators` builds its `OperatorEntry` response from the
validated body (`effectiveRole = role`, `sources = ["db"]`) instead of
re-reading the roster, so a concurrent change can't turn a committed
write into a `403`.

Related: #3777 (relay `OPERATOR`/`MODERATOR` role model + NIP-98 auth +
`admin_api` NIP-11 advertisement — provides the runtime and the
discovery field this console consumes)
Related:
[builderbot-platform-core-infrastructure#140](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/140)
(prod admin ingress allowlist and NIP-98 rollout)


## Screenshots

Captured headless at `197f0ba0c` against the mock bridge (1280×720).

### Setup

**Origin setup — Advanced disclosure open, idle state**
![Relay admin panel with Advanced: admin origin disclosure open before
probe](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/02-origin-setup.png)

**Authorized console — all three tabs**
![Authorized relay console showing Connected as operator status and
Reports/Feedback/Staffing
tabs](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/03-origin-authorized.png)

### Reports

**Reports queue — open, processing, resolved across two relay groups**
![Reports tab showing community-grouped list with spam/open,
harassment/processing, illegal/resolved
rows](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/04-reports-queue.png)

**Open report detail — reporter, target, message snapshot, resolve
form**
![Open spam report detail with reported message content and
Delete/Kick/Ban/Timeout/Dismiss/Escalate action
buttons](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/05-report-detail-open.png)

**Processing report — PROCESSING/HARASSMENT, ban enforcing state**
![Processing harassment report detail showing enforcement state with ban
action in enforcing
status](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/06-report-enforcement.png)

**Resolve form — Delete selected, audience disclosure copy**
![Resolve form with Delete action selected showing "Sent verbatim to the
affected user and posted publicly in the room" audience
copy](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/07-resolve-form.png)

**Resolved report — enforcement history block**
![Resolved/illegal report detail showing Resolved by, Resolved at, and
Act ID fields from completed
enforcement](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/08-resolved-history.png)

### Feedback

**Feedback list — community grouping with status badges**
![Feedback tab showing relay.example.com group with new/reviewed items
and source-community-removed
group](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/09-feedback-list.png)

**Feedback detail — mutable status control (operator mode)**
![Feedback detail with new/reviewed/archived status tab control visible
for
mutation](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/10-feedback-mutable.png)

**Feedback detail — passive status badge (read-only/disabled mode)**
![Feedback detail in disabled mode showing passive "Status NEW" badge
with no mutation
control](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/11-feedback-readonly.png)

### Staffing

**Staffing roster — config/owner\_fallback/db source labels**
![Staffing tab showing three operators with config, owner_fallback, and
db source
labels](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/12-staffing-roster.png)

**Add form — new pubkey filled, ready to submit**
![Staffing add form with a new 64-hex pubkey entered and moderator role
selected](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/13-staffing-add.png)

**Duplicate rejection — inline error for existing principal**
![Staffing add form showing "Already an operator: operator. Use Remove
to revoke before re-adding with a different role." error for an existing
config-backed
operator](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/14-staffing-dup.png)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
2026-09-24 17:34:25 -04:00