Commit Graph

3286 Commits

Author SHA1 Message Date
Brad Seiler
a8dd685bde test(auth): match Git controls to handler response contracts
Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:22 -05:00
Brad Seiler
5285894bd3 test(auth): bind route controls to exact outcomes and merged routers
Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:22 -05:00
Luke Tornquist
cf8b6b26d7 fix(test): consume MembershipWrite in ban-matrix seeds
add_relay_member returns a #[must_use] MembershipWrite on main, so
clippy -D warnings rejected the three ban-matrix seed calls. Map to the
outcome before expect, as the neighboring tests do.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:22 -05:00
Luke Tornquist
bb5b0343f9 fix(test): pass relay_keypair to add_relay_member in ban-matrix tests
Main (#8482) added a relay_keypair parameter to Db::add_relay_member.
The merge in 60df78770 kept three ban-matrix test call sites at four
arguments, so the buzz-relay lib tests did not compile. Pass a
generated key, as the other tests do.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:22 -05:00
Luke Tornquist
fd380fcfd8 fix(test): pin unrouted registrations and reject repeated nests
The route inventory allow-listed every `.fallback_service` inside
`router::build_router`, and did not scan `.fallback` at all, so a new
sub-router fallback could add an unclassified handler without changing
the inventory. Allow-list entries now name the builder, call, and exact
whitespace-normalized argument, and each may appear once. The existing
buzz_v1 404 fallback is allow-listed explicitly.

`builder_prefixes` kept one prefix per builder, so nesting a builder at
a second prefix silently overwrote the first. A repeated nest is now a
hard error.

Five new fixtures cover a non-SPA fallback service, a repeated SPA
fallback, a handler fallback inside and outside build_router, and a
builder nested twice. All five fail against the previous scanner.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
Luke Tornquist
6565a0f107 fix(test): fail the route inventory on every shape it cannot classify
The scanner keyed `&str` route constants by bare name, so main's
`api::profiles::SEARCH_PATH` collided with `api::gifs::SEARCH_PATH` and
reported a false duplicate. Key constants by module and resolve qualified
references against that module.

Unclassified registrations are now hard errors instead of silent passes:
`.route_service`, `.nest_service`, and `.fallback_service` (except the
allow-listed SPA fallback in `build_router`), `.nest` outside
`router::build_router`, and nest targets that do not resolve to exactly
one router builder. A fixture self-test pins each shape and runs in
`just test-unit` and `scripts/run-tests.sh unit`.

Classify the three routes main added: `POST /profiles/search` (member
gate, now in the HTTP ban matrix) and the operator-plane
`GET /operator/communities/joined` and
`GET /operator/communities/nip-fi-binding`.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
Local Builder
67c2369a90 docs(testing): distinguish invite policy from implementation
Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
Local Builder
71898a869d docs(testing): distinguish pending sibling ban contracts
Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
Brad Seiler
49b497d345 test(relay): enable accessory routes in the ban matrix
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
Brad Seiler
b52d8f6033 test(relay): cover current community entry points
Keep invitation and webhook-owner gaps explicit until their implementations are integrated.

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
seiler
9855d86430 test(relay): align timeout admin regression
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit 31968772ebe1b30c2862966e46b75a9ad4b967b6)
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
seiler
b1f1dc4de1 docs(testing): clarify webhook ban classification
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit 2374e1c05352db37eafa42d502a148b57dc21080)
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
seiler
d18981d85b docs(testing): clarify invite ban regression contract
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit 5bbf2b64a82115c40fdadeb981dbfdf7122241f2)
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
seiler
d402ba6f42 test(relay): map community ban routes and regressions
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit dcb83eaec669ed044ab2f16c8b7612bd54ed1d9c)
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
Brad Seiler
7c29360d7d test(db): warm the reader floor before stalling new dials
Follow production boot order so the partial warm-pass assertion does not depend on three cold debug-build authentications completing in 150ms. Keep the production budget and exact stalled-dial accounting.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>

Co-authored-by: McKeon <f87db83c2e79e370e2349ac850d9f04afe307b90f25a2830f50e825ba75e3cc5@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
Brad Seiler
393d144b5c fix(relay): stagger authorization outage disconnects
Spread failed-lookup closes across scans with a stable per-socket offset, keeping the conservative revocation bound at 30 seconds. Share the database query deadline and verify sibling sockets close across three sweeps.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
Brad Seiler
3bf280a668 fix(db): bound live authorization statements and cancellation
Apply a transaction-local server deadline below the caller timeout, preserve tighter operator limits, and close cancelled or failed checkouts. Verify blocked-query cancellation, pool recovery, and writer timeout restoration on the same backend.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
Brad Seiler
1e4368fc73 test(relay): run live revalidation accounting regression in CI
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
Brad Seiler
548952b87d fix(relay): preserve refusal accounting across live admission
Restore audio stored-owner failure accounting and keep live policy revalidation separate from deny-set revocation. Preserve denied-only priority over weak removal claims; cover denied and unavailable root closes.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:21 -05:00
Brad Seiler
8b8321c280 test(relay): run live revalidation contracts in the unit lane
Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:20 -05:00
Brad Seiler
9e798cf498 fix(relay): preserve token-owner admission during revalidation
Keep NIP-FI owner delegation independent of the NIP-OA flag. Adapt membership fixtures and session identities to current main, and cover final admission through owner removal for both socket routes.

Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:20 -05:00
seiler
c5e74c9de1 fix(auth): log captured owner membership failures
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:20 -05:00
seiler
690f9da0ce fix(relay): prioritize stale authorization targets
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:20 -05:00
seiler
8c2dbd1c0d fix(desktop): retry transient relay auth failures
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:20 -05:00
Brad Seiler
40500a2fd3 test(relay): admit live agents through owners before revalidation
Keep a clear audio participant present through policy eviction so the huddle remains active for the database-failure phase. Reject bare closes with a labeled diagnostic.

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:20 -05:00
Brad Seiler
b3870a6fe3 test(relay): classify live revalidation as external network infrastructure
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:20 -05:00
Brad Seiler
ab8aecd687 test(relay): bound live workflow handshake and control reads
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:20 -05:00
Brad Seiler
0ca5a04447 test(relay): measure live revocation without Redis delivery
Exercise signed root and audio admission against an isolated running relay, durable direct-member and owner bans, a responsive bystander, and bounded database-failure closure.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:20 -05:00
Brad Seiler
a5e5661010 test(relay): import root fixture shared ownership type
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:19 -05:00
Brad Seiler
8382528486 test(relay): retain root lifecycle registration during revalidation
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:19 -05:00
Brad Seiler
1b1f5a23f4 test(relay): bind durable revocation to socket admission
Authenticate through root and audio handlers before a durable-only ban. Removing either production enrollment call must fail its regression.

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:19 -05:00
seiler
4aaf747ed2 refactor(relay): address revalidation review
Restore CommunityDisconnectReason near the top of state.rs and document scan deadline and reconnect-load limitations. Authorization behavior is unchanged.

Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit a4d1ab288d761d44eba25cc62d25b7d2858788dc)
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:19 -05:00
seiler
1b81b01b01 Add durable live member authorization checks
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit 92f13229ed3038d63f2a2d9c889bfd4c212e38e2)
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:19 -05:00
Brad Seiler
988a9b4de4 docs(db): align invitation repair with schema 0075
Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:19 -05:00
Brad Seiler
c3f4c01946 Add invitation history repair as a separate maintenance change
Revoke historical legacy candidates and clear examined admission markers
under the deletion/admission locks. Require separate approval before core
deployment and a controlled rollout that prevents new timeout/mute history.

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: seiler <seiler@squareup.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
2026-11-04 17:29:19 -05:00
Tom Brow
ec21753e1b Make mobile New DM and Add members pick recipients like mention autocomplete (#9065)
The mobile New DM picker listed the first 50 relay profiles
alphabetically, mostly identities the viewer doesn't know. It ran its
own debounced directory search with no cache, and its rows disappeared
on every keystroke until the search returned. Mention autocomplete in
the composer already solves the same problem well, so New DM and Add
members now use its implementation. Add members was reusing the same New
DM directory search.

### What changes

- **Same candidates and order as mention autocomplete.** The chooser's
candidate building, ranking, row stability, 200 ms debounced search and
search cache now back both surfaces. New DM is treated like a stream
with no members yet: the viewer's own agents and agents shared with them
appear up front, people are found by name, and agents that couldn't
answer the viewer are left out. Typing a name shows the same people in
the same order as `@name` in a stream.
- **Results stay on screen while a search runs.** A query searched
before shows its people immediately, and a longer query starts from the
shorter query's results. A failed search keeps its rows and adds a retry
row below them.
- **Presentation matches the mention rows.** Rows show the name, and
agents show "agent · managed by …". Npub lines are gone.
- **Sheet chrome.** The title is "New DM", like the menu item that opens
it. Both pickers keep their grouped cards but drop the "People and
agents" and "Selected" section titles and the empty header bands above
them (selected recipients stay in their own card at the top with
checkmarks on iOS, as chips on Android). The native iOS picker now
focuses search once it appears, which iOS used to ignore. In New DM a
pick clears the query, as an inserted mention ends its query.
- **Add members keeps what is specific to it.** Existing members and
members just added are never offered, names are still compared with the
channel roster so a same-name candidate is told apart, the query stays
after a pick so several matches can be added in a row, and agents that
don't answer the viewer can still be added, since a channel can hold
them.

While the WebSocket reconnects, the relay agent directory and its owners
keep their last read in the community instead of going empty, so shared
agents already found stay eligible in both New DM and the composer
chooser.

The debug-only offline preview directory and the old directory listing
and search providers are removed, since no picker reads them any more.

### Validation

Added tests: the New DM widget journey now drives the real mention
search through a fake relay session. It covers the empty-query state,
the debounce, rows kept while a longer query searches, cached results
shown again at once, selection, chips and submit. The layout journey
covers the 50-row cap, and the native-bridge journey covers title, rows
without key detail lines, the selection limit and failure handling. The
full `mobile/test/features/channels` suite passes.

Automated app run (iPhone 17 Pro simulator, iOS 27, debug build, Remote
Agents Pilot community): opened New DM from the menu, confirmed the
keyboard comes up with only the viewer's own agents listed, searched for
people and an agent, selected both and confirmed the query cleared after
each pick. Also exercised the composer mention chooser (same results and
order for the same query) and the shared Add members sheet (plain style,
autofocus, search, query kept after a pick). Captures show real
community members, so they're shared internally rather than here. The DM
was not actually created, to avoid a stray conversation; submit is
covered by the widget tests.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:55:26 -08:00
Tom Brow
f09956179d fix(mobile): keep the composer focused through iOS inactive spells (#9055)
On iPhone, opening Control Center or the app switcher while the composer
is focused can leave the keyboard up over a collapsed composer. The
orphaned keyboard then stays put while you navigate, for example back
from a thread to its channel, and it clips the composer pill.

### Cause

iOS reports a zero keyboard inset while the app is inactive and restores
the keyboard natively when the app returns. The composer treated any
inset drop as a user dismissal: it collapsed, unfocused and removed its
text field. When the keyboard came back, it was attached to no Flutter
field, so nothing could dismiss it. Flutter's own report of the native
side is
[flutter/flutter#189051](https://github.com/flutter/flutter/issues/189051).

### Change

The composer's keyboard observer ignores metrics while the app is not
resumed. iOS reports `resumed` with the inset still at zero and brings
the keyboard back a few hundred milliseconds later, so when a keyboard
that was up hasn't returned at resume, the composer waits a one-second
grace before treating it as dismissed. Any nonzero inset in that window
cancels the wait. A restored keyboard leaves the composer expanded and
focused; a keyboard that really stayed down still collapses the composer
once the grace passes.

### Validation

- Extended the existing "returns to the compact capsule when the
keyboard drops" widget journey with both inactive cases in the order iOS
reports them: resumed at zero with the keyboard returning about 400 ms
later (composer stays focused), and resumed with the keyboard staying
down (composer collapses after the grace). It fails without the fix and
against the first revision of this PR.
- Full mobile check and test suite pass.
- iOS 27 simulator (iPhone 17 Pro, debug build of the first revision):
focused the composer, typed, switched to Settings and back, opened and
closed Control Center, sent a message, and left the channel. The
composer stayed focused through each interruption, sending kept focus,
and leaving dismissed the keyboard. Limitation: the simulator keeps the
keyboard inset through these interruptions, so it reproduces neither the
original bug nor the resume grace; the on-device inset drop is from the
reporter's screen recording and the Flutter issue.

Human app testing is waived under the mobile testing exception; the
simulator pass above is the automated full-app run.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:54:12 -08:00
Tom Brow
7157a55f5f fix(mobile): keep the iOS 27 header blur in long chats (#8971)
On iOS 27, the frosted header in a long conversation could flicker
between blurred and sharp, most visibly right after returning to the
app.

### Cause

The native header mirrors Flutter's scroll depth into a hidden 10,000 pt
`UIScrollView` so UIKit can draw its scroll-edge effect. For a reversed
chat that depth is the loaded history above the viewport
(`extentAfter`), which can exceed the mirror's range in a long channel.
Past the end of that content, UIKit finds nothing under the bar and
drops the blur and top fade. While the timeline reconnects and catches
up, the lazy list's depth estimate moves back and forth across that
point, so the header flips between blurred and sharp.

### Fix

Cap the depth sent to the mirror at 1,000 pt. Only the first few hundred
points change the header (scroll-under material alpha and large-title
collapse), so pages behave the same below the cap.

### Validation

- Reproduced in an iOS 27 simulator (iPhone 17 Pro) with a debug-only
patch that cycled the reported depth through 0, 9,500, 10,500 and 20,000
pt. Without the fix, the header dropped its blur and top fade at the
deep values, matching the device recording. With the fix, the blur
stayed steady across the same cycle.
- Backgrounded and resumed a channel in the simulator with the fixed
build. The header stayed blurred through the reconnect and catch-up.
- Not yet checked on a physical iOS 27 device; the report came from a
device recording.

Captures stay in Buzz because they show internal conversations.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-04 13:52:53 -08:00
Tom Brow
041956ea44 fix(ios): saturate the push dedupe cutoff so extreme timestamps can't crash (#9010)
## Context

Follow-up to [#8973](https://github.com/block/buzz/pull/8973), from
Codex's
[review](https://github.com/block/buzz/pull/8973#discussion_r4458876285)
of its final head. The push dedupe cutoff subtracts the 15 minute
reorder window from the newest shown time. A signed event can carry any
`created_at`. One within 900 seconds of `Int.min` made that subtraction
overflow and trap, crashing the Notification Service Extension. The
claim was never saved, so every later push crashed the same way while
the relay kept returning that event. The relay's own drift check
normally rejects such timestamps, but the extension shouldn't depend on
that.

## Change

The cutoff now uses saturating subtraction
(`BuzzNotificationCache.presentationCutoff(after:)`), in both the claim
check and retention pruning.

## Validation

- New test `testExtremeTimestampsDoNotCrashTheClaim` claims messages
dated `Int.min`, `Int.min + 1`, now, and `Int.max`. It checks that each
behaves like any other message and nothing crashes. With the plain
subtraction restored, it crashes with signal 5 (overflow trap).
- Full BuzzPushKit suite: 108 XCTest and 66 Swift Testing tests passed.
- Automated app test: not run. This is a two-line arithmetic change
inside the extension's cache. The live APNs run on
[#8973](https://github.com/block/buzz/pull/8973) covers the flow.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:49:54 -08:00
ravarora2
7b3c2f95f2 fix(mobile): bound notices to loaded message history (#9062)
## Summary

Mobile reads system notices older than its loaded message window, even
though those notices cannot yet appear. Bound notice reads by the oldest
loaded message's timestamp to avoid fetching history outside the loaded
time range.

A short or empty bounded response finishes that time range, rather than
all notice history. Preserve the full boundary second, resume when older
message pages load, and keep notices before the first message available
at the start of history. Enforce the 2,000-notice cap before fetching
reactions. Reuse cached reactions and deletions when older pages reveal
their notices.

For channels with fewer than 500 notices, scrolling back can now require
one bounded notice read per older message page. Previously, one
unbounded read covered the channel.

### Related issue

Related: #8962, which changed how mobile loads and displays membership
notices. No duplicate notice-bound PR found in the open mobile-notice
title search.

### Testing

Mutation checks: removing the production `since` argument or restoring
the old unconditional history-end flag each made all four notice-range
cases fail. Command: `cd mobile && flutter test --no-pub
--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
test/features/channels/channel_notices_journey_test.dart --name 'notice
ranges'`.

Replacing the captured message cursor with a fabricated maximum ID also
fails the empty-range progress regression. Command: `cd mobile &&
flutter test --no-pub
--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
test/features/channels/channel_messages_provider_test.dart --name 'an
empty notice range'`.

A read-only prepared-query check on local Postgres 17.10 used a
representative kind-40099 query. It held 22 event-related relation locks
with `force_custom_plan` and 99 with `force_generic_plan`. The generic
plan pruned seven of eight partitions during execution but retained the
broader locks. These results do not establish lock reduction or
throughput improvement under the relay's actual cached-plan workload; a
relay load test remains pending.

Native app scrolling against a live relay and human confirmation remain
pending.

For the device check, run `just mobile-dev` on a host with an iOS
Simulator runtime. Connect to a local test relay and open a channel with
more than 150 messages and interleaved topic notices. Scroll back
through at least three message pages, then to the start. Check that
visible notices remain in order and appear once. Disconnect during a
page read, reconnect, and continue scrolling to check recovery.

Generated with Codex

---------

Signed-off-by: BuzzPRReviewAssistant1 <a581a7e8115222acdc32ffb14bfce55d48c24478d98e155f8eba126b69b83c7b@buzz.block.builderlab.xyz>
Co-authored-by: BuzzPRReviewAssistant1 <a581a7e8115222acdc32ffb14bfce55d48c24478d98e155f8eba126b69b83c7b@buzz.block.builderlab.xyz>
2026-11-04 16:26:35 -05:00
klopez4212
99873459e9 Keep sign-in toasts usable at large text sizes (#9057)
At large text sizes, organization sign-in prompts could clip their
message and push recovery actions off-screen. Stack actions below the
message above 1.3× text, use fixed card corners, and skip the Dynamic
Island morph for that layout.

Follow-up to #9050, addressing [Wes’s large-text
blocker](https://github.com/block/buzz/pull/9050#discussion_r4469530660).
Normal-size styling and recovery callbacks are unchanged.

Validation: extended existing sign-in/cancel/error journeys at 1×, 2×
and 3× using Inter on a 320×568 viewport; assert text lies inside the
painted clip and the card stays on-screen. Removing the fix fails all
six enlarged-text cases. Mobile checks and full tests run through the
push hook. Human device testing of this follow-up is pending.

Local Flutter previews at 3× text (320×568):

| Before | After |
| --- | --- |
| ![Before: clipped text and action extending off
screen](https://github.com/user-attachments/assets/89b62c46-173c-4a1e-960a-f402f6d0346a)
| ![After: readable message above reachable Sign in
action](https://github.com/user-attachments/assets/ae3de975-3505-48ee-b9f3-b60aa710600f)
|

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-04 20:33:08 +00:00
klopez4212
ce94656b0d fix(mobile): use join template for community creation (#9072)
Community creation now uses the same mobile template, borderless iOS
input, and keyboard-coordinated transition as joining, replacing the old
welcome screen and boxed field. Name validation, separate form drafts,
reduced motion, and accessible error announcements are preserved.

Validation: analysis and 4,407 Flutter tests pass (45 skipped, four
workers); independent review passed. Kenny approved the simulator
preview. Agent verification of the full native transition remains
pending; the after snapshot below is a Flutter widget render.

| Before | After |
| --- | --- |
| ![Previous create
screen](https://github.com/user-attachments/assets/353b3b41-57b4-4a3a-9c21-10ea1c0becf2)
| ![Create using the join
template](https://github.com/user-attachments/assets/5a0d9d8d-cace-44b1-a3c8-bbe65f04a2e0)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-04 20:32:55 +00:00
Tom Brow
90de03aa63 feat(mobile): back up nsec to org builderlab account (#9029)
The Block tenant links a member's existing key to their account the
first time it issues them a relay assertion
([#8700](https://github.com/block/buzz/pull/8700)). Mobile never backed
that key up. Desktop FI builds back up to the tenant, but on mobile a
Block key that lives on just one phone can't be recovered once Block
turns backups on.

Mobile now backs up the organization relay's key to the organization
account:

- After every organization assertion, it reads
`/v1/buzz/nsec-backups/status`. If the key is already listed, there's
nothing to do.
- If the bound key isn't listed, it enrolls, seals with HPKE and
uploads, using the existing `HostedBackupService`. That service now uses
its account client's own origin, so organization and hosted backups
never share a pending envelope. Hosted storage keys and receipts don't
change.
- If the upload's outcome is unknown, the saved envelope is replayed
exactly at the next assertion.
- A tenant without backups answers 404 (Block production today). That,
and other definitive refusals, stop attempts for that account and key
until the next launch. Any other failure is retried at a later
assertion, after a delay that doubles from 1 minute to at most 1 hour.
Assertions renew while the app runs, so retries continue until the
tenant lists the key.
- Concurrent assertions share one attempt.
- iOS 16 can't seal, so it sends nothing. Backup failures never affect
the relay connection.

There's no UI. As in mobile's hosted onboarding, the backup happens
automatically. Desktop asks for an explicit backup choice first, so
whether mobile should too is a product call.

### Validation

Added tests (`organization_key_backup_test.dart`), which run through
real `HostedRelayAuth` assertion issuance with only HTTP faked:

- An uncertain upload is replayed with the same envelope at the next
assertion, then nothing more is sent in that launch.
- A key that's already listed sends no upload and drops a leftover
envelope.
- A tenant without backups is asked once per launch.
- A device that can't seal sends nothing.
- A failed status read is retried after the delay, not at the immediate
re-assertion. A key the tenant bound to someone else is never uploaded,
and its checks keep going at growing intervals.
- Two concurrent assertions make one attempt.
- A failing seal-capability check sends nothing.

Removing the assertion hook fails 4 of the 6 tests. Removing the shared
attempt or the retry delay fails its test. Full `mobile-check` and
`mobile-test` pass.

Device (iPhone 12 mini, release build) against bb-block staging, where
backups are configured and the relay enforces NIP-FI, with an existing
Block SSO session:

- On launch the app asserts, reads backup status and finds this key
listed, so it uploads nothing. The relay connection and channel list are
unaffected.
- A test-only build that ignored the listed shortcut enrolled, sealed
with the staging tenant's real KMS-backed key and uploaded, with a
stored response in about 360 ms. That re-stored my own test key.
- The final build at this head was then reinstalled and relaunched with
no backup failure logged.

The device's staging community was added in an earlier run, because
adding an enforcing organization relay by link is still blocked
([#8791](https://github.com/block/buzz/issues/8791)).

An independent agent review ran; its should-fix items are addressed in
the second commit. Codex's retry finding is addressed in the third,
which changes only failure-path timing; the device-tested success path
is unchanged. Wes's optional review notes are addressed in the last
three: an exact-timing retry test, backup queues per account origin (a
stalled organization backup no longer delays hosted onboarding's backup,
with a test), and a subject check on each enrollment and upload request.
Full `mobile-check` and `mobile-test` pass at the head. Human testing is
waived under the mobile automated-testing policy; the device run above
covers the at-risk flows (organization assertion, relay connection,
backup status, enrollment, sealing and upload).

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:11:31 -08:00
Brad Seiler
a1f79e3aba feat(relay): persist NIP-FI upgrades (BUZZ-510) (#8985)
## Summary

Record that a community requires NIP-FI and provide an idempotent
operator API to enable it and recover the original receipt. Acceptance
checks the current owner, host, lifecycle and existing issuer trust in
one transaction. It never creates trust. Receipts survive community
deletion so an operation UUID cannot be reused.

New acceptances default to disabled through
`BUZZ_NIP_FI_UPGRADES_ENABLED`. Disabled calls return HTTP 503
`upgrades_disabled`; identical retries and authenticated readback still
recover existing receipts. Policy is permanent: stopping new calls or
rolling back code does not clear it. Last-issuer removal is refused for
required communities, and bulk backfill leaves missing bindings for
explicit operator repair.

Apply additive migration 0082 before code. Existing communities remain
optional. This layer records acceptance and reports only `pending` or
`unavailable`; deploy and verify [per-request
enforcement](https://github.com/block/buzz/pull/8988) and [session
activation](https://github.com/block/buzz/pull/8991) before enabling
acceptance. The global enforce floor remains. See [the operator
contract](https://github.com/block/buzz/blob/seiler/BUZZ-510/relay-auth-upgrade/docs/community-auth-upgrades.md)
for signing, recovery and containment.

### Related issue


[BUZZ-510](https://linear.app/squareup/issue/BUZZ-510/relay-persist-community-nip-fi-requirements-and-expose-idempotent).
Base of native stack `8989`; retains trunk's enforcement telemetry from
[#8974](https://github.com/block/buzz/pull/8974). Includes a separate
commit that repairs an observer timing flake in a desktop test required
by the repository gate.

### Testing

Controlled local regressions detect the old disabled error code and
removal of the last issuer with `allow_last=true`; intended source is
restored. Full PostgreSQL validation and the updated live relay/admin
exercise remain pending. Human acceptance remains open. No deployment or
production activation.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-04 19:56:07 +00:00
Luke Tornquist
4813b8cd15 relay: time every WebSocket read and every stored EVENT outcome (#9052)
## Summary

Two gaps in relay telemetry: WebSocket reads (`REQ`, NIP-AR sync,
`COUNT`) have no latency or outcome metric, and
`buzz_event_processing_seconds` only measures stored EVENTs that
succeed.

**What this does**
- **`buzz_ws_read_seconds{type, outcome, reason}`** (new histogram).
It's recorded from `access::ReadLog::drop`. Every WebSocket read already
finishes through that one seam, whether it ends `served`, `denied`,
`error`, or `aborted` (future dropped mid-read), so this adds no new
exit paths and changes no handler. Duration runs from the moment the
handler takes the frame until `EOSE` is queued (`REQ`), the initial set
ends (sync), or the total is sent (`COUNT`). `reason` is the existing
fixed denial/error code (`query_timed_out`, `database_error`,
`not_channel_member`, …), or `none`. The histogram's `_count` doubles as
the outcome counter, so one metric gives both read latency and read
failure rate.
- It's recorded *before* the refusal budget check, because the budget
only exists to suppress log lines. When `refused_read` is over budget,
it records the sample without building the log line, so its cost under a
refusal flood stays the same.
- **`buzz_event_processing_seconds{outcome}`**. Before this change it
was recorded only when `ingest_event` returned `Ok`. A store that was
slow *and failing* (statement timeouts, pool exhaustion) produced no
samples, so latency looked better while the store was degrading. It's
now recorded on every persistent-ingest outcome: `accepted`,
`not_accepted` (for example a duplicate), `invalid`, `auth`, `error`.
Queries that sum across all outcomes still work.

**Cardinality.** Every label value is a `&'static str` from a
code-defined set: 3 types × 4 outcomes, with about 25 reasons that only
appear alongside `denied`/`error`. On purpose, there's no `kind` label:
`bounded_kind_label` still admits around 200 values, which is too many
to cross with histogram buckets.

**What got simpler:** `ReadOutcome::parts()` replaces the inline
outcome→label match in `Drop`, so the log line and the metric share one
mapping. There's no new instrumentation seam: the read metric reuses the
access log's lifecycle.

### Related issue

None found. This came out of a monitoring-gap review: without these
signals, alerts can't catch a slow or failing WebSocket read path, or a
failing EVENT store.

### Testing

-
`access::tests::every_read_is_measured_even_when_its_line_is_suppressed`
(unit). It drives the real `ReadLog` constructors and `Drop`: 20
admission refusals against a 16-line budget, a handler denial past the
budget, a `COUNT` error, a served sync and an aborted `REQ`. It asserts
the exact `(type, outcome, reason, samples)` rows. Two mutants each fail
it: removing the over-budget branch in `refused_read`, and moving the
measurement after the budget check in `Drop`.
-
`handlers::event::tests::postgres_tests::event_processing_is_timed_for_accepted_and_refused_ingest`
(Postgres lane). It sends one accepted text note, then a refused one
from a cached-ban sender, through `handle_event`, and asserts one sample
each under `accepted` and `auth` (a ban maps to the `auth` class).
Removing the `Err`-arm measurement fails it.
- Blox gate at `b314df43f2` (tree `4ca9ebc1`): `cargo fmt --check`
clean. The new Postgres test passes, and removing the `Err`-arm
measurement fails it. buzz-relay unit lane (`cargo nextest run -p
buzz-relay --lib --bins`): 1708/1708 passed.
- At `539796bd51` (the same code; only the second commit's test
assertion differs): workspace clippy (`--all-targets -D warnings`)
passed. The new unit test passed, and each of its two mutants failed it.
buzz-relay Postgres lane: 604/605 passed. The one failure was this
test's old `invalid` expectation, which `b314df43f2` corrects.
- CI at `b314df43f2`: both new tests appear in the CI logs as passing
(Rust / Unit Tests, PostgreSQL Domain / PostgreSQL Tests).

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:15:51 -05:00
Luke Tornquist
25abfc4117 fix(loadgen): bound desktop reads at stop, keep timely EOSEs, model presence and probes (#9016)
## Summary

Follow-up to #9005, addressing the review findings that merged
unaddressed. None of them affect a relay. `buzz-ws-client` gains one
method. Each one skews what the desktop client measures when the relay
is slow, which is the case the load generator exists for.

- **Reads stop at the stop.** A desktop HTTP read (including NIP-11) now
races the run's stop and is abandoned there without being recorded, as
EOSE waits already were. Before, a read that outlived the 15 s shutdown
grace got its client cancelled and failed an otherwise healthy run.
`Fetcher::finish` also keeps the read task's handle until the task ends,
so a client cancelled during the join still aborts the task instead of
detaching it.
- **Root lookups are bounded.** The desktop's seeded-root lookups, for
thread recovery and replies, now run under the desktop's 30 s read
timeout and stop, not reqwest's 60 s. `roots()` takes an optional bound,
and the basic human passes none. If the stop cuts a reply's lookup
short, the reply isn't published. That applies to the basic human too: a
reply whose root lookup finishes after the stop is dropped.
- **An EOSE that arrives in time stays in time.** While a publish waits
for its OK, `NostrWsConnection` buffers every other frame. The session
loop used to judge EOSE deadlines before reading that buffer, and read
one frame per pass. It now handles every frame already in hand (at most
256 per pass) before judging deadlines. Each frame is judged by when it
arrived, not when it is read. `NostrWsConnection` keeps each buffered
frame's arrival time, and the new `next_event_received` returns it. So
an EOSE that arrived after its deadline while an OK wait held it counts
as a timeout, and so does a frame that answers the probe late. Past that
cap, a pass defers due deadlines, for at most one timeout from the first
pass that defers them. While it is deferring, the pass only drains, so
another OK wait can't expire an EOSE that's already in hand, and
sustained traffic can't postpone a missing EOSE forever.
- **Presence and the silence probe.** Both modes now open the app's
live-only `presence` route
(`{"kinds":[20001],"authors":[…],"since":…,"limit":0}`, up to 256
co-members) alongside the profile and membership routes. As in the app,
presence takes no part in establishment: it isn't resumed or carried,
and if it fails it doesn't cut a renewal's carry for the other routes.
After a tick with no frame since the previous one, they send the app's
probe, `REQ probe-N {"kinds":[44100],"#p":[viewer],"limit":0}`, which
shows up as `desktop_probe` and is closed at its EOSE. Only frames
actually received count, including a publish's OK when it arrives before
the probe's deadline. A reaction with no target or a publish whose OK
timed out doesn't count. Expiry is judged after the drain against the
probe still outstanding. If no frame arrives within the EOSE deadline,
the session ends as failed and the client reconnects with backoff.
Before this, desktop runs left out presence fan-out and idle-client
probes, so they understated load.
- **Seed log.** "memberships added (N people)" now reads "memberships
ensured", since a rerun adds nobody.
- **Docs.** `perf/LOADGEN.md` notes that the six-read cap and flat 30 s
timeout are simplifications (the app has neither), and that `legacy` is
a lower bound. The pre-fix app also opened a per-channel route for each
open or non-member channel and re-sent every route each tick, and the
model leaves both out.

Not done: an explicit count of REQs abandoned at a renewal (report
polish; planned cancellations still aren't errors).

### What got simpler

Reads abandoned at the stop now go through one helper, `run::bounded`,
which the desktop reads and the root lookup share. Before, each read had
its own timeout and its own stop check. `global_reqs` returns the routes
the client actually opens, not a fixed pair. The probe and presence
routes add one `Route` variant, one op and about 40 lines of session
code. `Route::establishes` is now the one place that says which routes
take part in establishment, replacing the two `Replay` special cases.
"Heard" used to be inferred by comparing activity schedules before and
after each step. It now comes from frames only, so `publish` reports
whether the relay answered and that comparison is gone.

## Testing

On a Blox workstation, at the tree of this PR's head:
- `cargo fmt --all -- --check`, `cargo clippy -p buzz-test-client -p
buzz-ws-client -p buzz-cli --all-targets -- -D warnings`
- `cargo test -p buzz-test-client --lib` (parallel) and `cargo test -p
buzz-ws-client`, plus the `loadgen::desktop` tests five more times

New tests, each confirmed to fail when its fix is reverted:
- `a_read_in_flight_at_the_stop_is_abandoned_not_waited_for`: an 8 s
roster read at the stop. Without the stop race the client outlives
`drive`'s 5 s stop limit.
- `a_cancelled_finish_still_aborts_the_read_task`: aborting `finish()`
mid-join ends the read task. With the old `take()`, the task is
detached.
- `an_eose_in_time_is_not_expired_behind_a_slow_ok`: EOSEs at 1.2 s,
publish OKs at 3.5 s, deadline 2.5 s. Without the drain, all four routes
time out.
- `a_silent_tick_probes_and_an_unanswered_probe_reconnects`: the first
connection never answers its probe, so the client reconnects and its
next probe is answered.
- `a_probe_answered_behind_a_late_ok_does_not_reconnect`: the probe's
EOSE arrives in time, but it sits behind a heartbeat whose OK comes
after the probe's deadline. With expiry judged against a deadline
captured before the drain, the session fails and reconnects.
- `a_publish_ok_in_time_answers_an_outstanding_probe`: no probe EOSE
ever arrives, and heartbeat OKs answer each probe.
- `a_reaction_with_no_target_is_not_heard_as_a_frame`: targetless
reactions several times a second still leave the tick silent, so it
probes.
- `a_reply_whose_root_lookup_the_stop_cut_short_is_not_published`: the
stop cuts an 8 s root search short, and OKs are withheld. No `kind 9`
EVENT reaches the relay, and the client returns promptly.
- `a_backlog_defers_deadlines_for_at_most_one_timeout`: the deferral
bound and its reset.
- `a_long_backlog_behind_slow_oks_keeps_its_timely_eoses`: 600 replayed
events per REQ behind 3.5 s OKs, with a 1.5 s deadline and heartbeats
every second. The test fails if the deferral is timed from the REQ
instead of from the first pass that defers, and it fails if a second OK
wait can run between the deferred passes.
- `presence_is_a_live_only_global_only_while_someone_is_watched`: the
route's order and its live-only filter in both modes, and that it's
absent with nobody watched.
- `a_late_eose_is_not_answered_behind_a_slow_ok`: the timely case above
with EOSEs at 3 s instead, so all four routes time out, not succeed.
- `a_probe_answered_late_behind_a_slow_ok_reconnects`: a probe's EOSE
arrives 0.2 s after its deadline while a heartbeat waits for its OK, so
the session fails and reconnects. The test also checks that a heartbeat
followed that probe on the same connection, so the test can't pass on a
probe that simply went unanswered.
- `an_answered_probe_is_closed_at_its_eose`: `CLOSE probe-1` goes out
before any next probe.
- `routes_establish_once_and_a_renewal_holds_them_until_a_cut` gains a
case: a renewal where presence times out keeps the other routes carried
and establishes nothing.

Each new test fails when its fix is reverted (seven mutants in the first
round and six in the review round, run on Blox). The test harness now
has a tick override that can go below the scenario's 1 s floor, and
records EVENT frames by kind. The fake relay sends a REQ's replayed
events after the REQ's delay, not before, and the fake API's slow reads
sleep in `block_in_place`. No existing test changed behavior.

Existing expectations moved for the presence route (one more live EOSE
per client) and for reads now abandoned at the stop.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:57:38 -05:00
klopez4212
5b738eb647 Restyle mobile organization sign-in prompts as toasts (#9050)
Replace the mobile organization sign-in bottom card with the existing
top toast treatment. Use shorter status copy, a solid-red Cancel button
with white text in both modes, and stacked Retry / Use a different
account buttons for denied access with 12 pt side and bottom padding.
The account-switch action uses a dark secondary treatment. Recovery
callbacks and visibility rules are preserved.

Validation: `just mobile-install mobile-check mobile-test`; widget
journeys cover sign-in/cancel/completion, saved-session errors, account
switching at 200% text size, and transient-toast coordination. The
cancel regression was verified to fail with its callback disabled.

Local Flutter previews using production widgets, with synthetic state;
not device captures. Manual test: sign in, cancel, retry, and switch
accounts after denied access; verify each action remains reachable and
the toast clears after successful sign-in.

Before:

![Previous organization
banner](https://github.com/user-attachments/assets/4aceabd1-c9bc-491f-888b-7ee5cf005afa)

| Light mode · 375 pt | Dark mode · 375 pt |
| --- | --- |
| ![Light mode
states](https://github.com/user-attachments/assets/b00bc956-8d27-4b99-ac1e-1f68349c2aa3)
| ![Dark mode
states](https://github.com/user-attachments/assets/930d84d7-b11f-4585-b5d7-77d1c78367ec)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-04 16:02:07 +00:00
klopez4212
0bdad8ea96 mobile: simplify verified profiles and prioritize full names (#9051)
Simplifies mobile identity presentation: removes verification checkmarks
from messages, threads, mentions and profiles, plus
issuer/time/disclaimer copy. Profiles show the community-verified full
name as the title and the display name below, with fallback when no full
name exists.

Validation: mobile formatting/analysis, full Flutter suite (4,401
passed, 45 skipped), all 4 unconfigured-push checks, and pre-push hooks
passed. Independent review found no blockers. Device and human visual
checks pending.

<details>
<summary>Before / after snapshots</summary>

Real Flutter profile widget with synthetic data, light/dark themes;
widget previews, not device screenshots. Images are PR attachments only.

| Theme | Before | After |
| --- | --- | --- |
| Light | ![Before,
light](https://github.com/user-attachments/assets/d615c010-7f04-4480-ae5e-e11539abc860)
| ![After,
light](https://github.com/user-attachments/assets/5726e504-349c-4c74-afc5-5557b03061bb)
|
| Dark | ![Before,
dark](https://github.com/user-attachments/assets/29204e7c-840b-41b4-b916-784976e6cbc9)
| ![After,
dark](https://github.com/user-attachments/assets/6c399553-4d95-47e8-9a5d-a2d351598ab3)
|

</details>


<details>
<summary>Inline messages — before / after</summary>

Actual Flutter channel and thread widgets with synthetic verified-user
data; local widget previews, not device screenshots.

| Screen | Before | After |
| --- | --- | --- |
| Channel · light | ![Before: channel,
light](https://github.com/user-attachments/assets/e482b562-5b44-4e48-8a24-ea52e78a8936)
| ![After: channel,
light](https://github.com/user-attachments/assets/2dd3689c-3206-4bc0-84be-0828e0fcca84)
|
| Channel · dark | ![Before: channel,
dark](https://github.com/user-attachments/assets/3c848519-3b96-453f-8751-78c573211f13)
| ![After: channel,
dark](https://github.com/user-attachments/assets/cc781417-e7ef-4988-98b4-b562c075fff6)
|
| Thread · light | ![Before: thread,
light](https://github.com/user-attachments/assets/4117bd8b-de7e-45c7-8e5a-3b25ae600405)
| ![After: thread,
light](https://github.com/user-attachments/assets/b2f8bddb-3023-448b-88fa-a9bc1d3b2ec2)
|
| Thread · dark | ![Before: thread,
dark](https://github.com/user-attachments/assets/4b9fd1fc-0a53-41b0-804c-3f06cd7a2e03)
| ![After: thread,
dark](https://github.com/user-attachments/assets/b407c3c6-c67a-445f-8867-feff60ca5973)
|

</details>

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-04 16:00:52 +00:00
John Matthew Tennant
f9ef721e68 fix(media): keep transparency in image thumbnails with WebP (#9003)
## Summary

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

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

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

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

Encoding a WebP thumbnail takes 3 to 20 ms.

### Related issue

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

### Testing

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

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

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

## Screenshots

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

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

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

---------

Signed-off-by: John Matthew Tennant <jtennant@squareup.com>
Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
mobile-v0.28.0-rc.1
2026-11-04 10:11:00 -05:00
Luke Tornquist
d21f0c0fdb perf(relay): keep 1 s read caches warm when the pool is saturated (#9012)
## What

The relay's 1-second read caches (restriction state, tenant host, open
channels) now date each entry from when the read checked out its pool
connection, not from when the cache miss began. Concurrent misses on the
restriction and tenant-host caches now share one database read. The
open-channels cache already did this.

## Why

Each of these caches serves an entry for 1 s after "the read that
produced it began". Today that start time is taken before waiting for a
pool connection. Once pool waits reach about a second (for example, many
clients reconnecting at once), every entry is already expired by the
time it lands. The cache hit rate falls toward zero exactly when the
pool is saturated, so every request does its own read. Those reads queue
for the same connections, which stretches the waits further. Requests
that time out in the pool then fail closed (`internal error checking
restriction state`, `community active check failed; refusing the
socket`), and their clients reconnect and add more load.

In a local synthetic reconnect load test with a 50-connection pool:
- the restriction cache served fewer than one request in five from
cache;
- the tenant-host cache, which has a single key for the whole
deployment, missed more often than it hit;
- the open-channels cache missed hundreds of thousands of times on
expired entries.

## How

- **Stamp at checkout.** `buzz_db::ReadAt<T>` carries a value plus the
instant its connection was checked out. The statement's snapshot is no
older than that instant, so serving the value for 1 s after it keeps the
documented staleness bound, however long the read queued. Three
`*_read_at` methods return it: `moderation_restriction_state_read_at`,
`lookup_community_by_host_read_at` and
`get_listed_open_channel_ids_read_at`. The existing methods now call
these and drop the value's timestamp. Each operation keeps one datastore
span under its existing name, carried by the `*_read_at` method.
- **Share concurrent misses.** `SharedReads<K>` is the per-key gate the
open-channels cache already used, moved into its own module. The
open-channels cache, the restriction cache and the tenant-host cache now
all use it. The first miss reads; the others wait for it, then recheck
the cache. A waiter waits for at most the writer pool's acquire timeout
plus `SHARED_READ_STATEMENT_WAIT` (1 s). That covers the leader's own
wait for a connection, which is what grows in a storm, so waiters keep
sharing its read instead of queueing duplicate reads behind it. A failed
or cancelled leader releases its waiters at once. A leader whose
statement hangs stalls them for at most 1 s past the acquire timeout,
never the statement timeout.

- **Reject hosts no community can have.** `bind_community` rejects a
host longer than `communities.host VARCHAR(255)` can hold
(`buzz_core::tenant::MAX_HOST_LEN`, counted in characters) as unmapped,
next to the empty-host check. An unauthenticated caller's arbitrarily
long host therefore never reaches a resolver, cache, gate or the
database.

No staleness bound changes: every entry is still served for at most 1 s
after its read's snapshot. Invalidation paths are unchanged. A read in
flight at an invalidation can still refill the old row for at most that
bound, as documented today.

## What got simpler

The open-channels gate's hand-rolled `try_lock`/`timeout` block is
replaced by `SharedReads::turn`. One gate implementation now serves
three caches instead of one implementation that only one cache had. The
gate's wait is set once per gate rather than passed at each call site.
Provisioning and host binding share one `MAX_HOST_LEN` instead of a
private constant.

## Tests

- `shared_reads::tests`: one caller reads while the rest wait; a waiter
gives up on a hung read after its bound.
- `concurrent_restriction_and_host_misses_share_the_in_flight_read`:
waiters on both caches use the leader's entry; with an unreachable
database, any waiter that read on its own would fail.
-
`postgres_tests::cache_misses_behind_a_held_pool_share_one_fresh_read`:
with a one-connection pool held for longer than every max age, eight
concurrent misses on each of the three caches cost one database read,
and each entry still lands fresh.
- `tenant::tests::an_overlong_host_fails_closed_before_the_resolver`: a
host over the limit fails as unmapped with no gate entry and no lookup;
a host at the limit, including one in multibyte characters, is still
looked up.
- Mutation-checked: each of the following turns these tests red:
stamping any of the three entries before the pool wait, dropping either
new waiter path, letting waiters give up after the cache's max age,
dropping the host length check, or counting the host length in bytes.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:52:21 -05:00