mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
9aa70124bae4f6763babd9daaacbc5157ce1fc56
3276 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9aa70124ba |
perf(mobile): validate each verified profile once, not on every publish (#9101)
Opening or resuming a big channel spent about 210 ms of UI-thread CPU validating verified profiles, measured in Dart CPU samples on an iPhone 12 mini (Profile build) across two separate channel-resume captures (213 ms and 211 ms in `VerifiedProfilesNotifier._snapshot`, nearly all of it in `parseVerifiedProfile`). Every publish rebuilt the profile map by validating every known head again, and a channel with many members publishes once per subscription batch as each lands, so the work grew with members times batches. Each subject's parse is now kept while its head and the authority stand. Only `checked` changes between publishes (rebatching unchecks and rechecks subjects), so it is applied to the kept parse without validating again. ### Validation - Added `a later batch reuses profiles already parsed`: after a second subject rebatches the first, each head is validated exactly once. It fails when the kept parse is ignored. - `flutter analyze` is clean and the full mobile suite passes. - Same phone and scenario after this change: `parseVerifiedProfile` no longer appears in the resume CPU samples, and main-isolate message handling over the 15 s after resume dropped from 604 ms to 491 ms (one run each). Frame build and layout time on resume is unchanged; that is separate work. - Automated app run on the same phone with this change: Home, search, the large channel and its resume after the reconnect grace period. 🤖 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> |
||
|
|
c848b6dcad |
fix(mobile): offer a new identity beside saved keys on unclaimed accounts (#9107)
## Summary This fixes a dead end in mobile Builderlab sign-in. A new account (unbound, with no backups) on a device that already had saved identities could only pick one of those identities. Any saved key forced the picker, and creating a key required the device to have none. If the service then definitively rejected binding the chosen key, the app showed "Let's try that again" with no way forward: the chosen key stayed in the account's slot, so every retry and every launch re-sent the same rejected bind. For an unclaimed account, the picker now also offers **Create a new identity**: - Creation writes only this account's empty identity slot, so every saved and device key is kept. - Creation is offered only when every current device key was among the choices shown, so a new identity never silently passes over a key the user didn't see. - Bound accounts, accounts with backups, pairing and pending import never offer it. When binding a chosen key is definitively rejected, the blocked screen offers **Choose a different identity**: - It appears only for a 400, 404, 409 or 422 from `/verify` in the current attempt, for the current account and pubkey, and never during pairing, whose transferred key is the only choice. Evidence from an earlier attempt is cleared before each bind. A challenge rejection, a 403, a 503 or any other ambiguous failure offers nothing. - Before releasing the slot, it rechecks the account: still unbound (`/current` and status), no backups, the same subject, and a slot that still holds that pubkey without a receipt. - It releases the slot only when another durable copy of the same key exists (the device inventory or another account slot), then verifies the slot is empty. No key is deleted: only the account's pointer to the rejected choice is cleared. A failed or refused release keeps the slot and the choice, so it can be retried in place or after a restart. - After release, the picker runs again and offers the saved keys plus creation. The recovery code lives in `hosted_flow_identity_recovery.dart`, a part of `hosted_flow.dart`, to stay within the mobile file-size limit. ### Related issue None found. ### Testing **Physical iPhone, a signed release build of `1fb633d55` installed over the existing app (keychain and saved identities kept):** a saved identity was rejected, then Choose a different identity, the picker, Create a new identity and Join your community. Joining then completed. This head differs from `1fb633d55` only in the guards for leaving, pairing and a failed or refused release listed below, which this journey does not reach, and in no UI code. The status of that phone's original rejection was not captured, so it is not confirmed to be one of the rejections above. The 409 case is covered by tests. **Tests (red on `main`, green here):** - Identity service and store: creation beside offered keys; refusal for unseen or partly offered device keys; release when another copy exists; refusal for a sole copy, a binding, a backup, a receipt, another account or an unusable recheck; failed delete and retry; a cancelled attempt. - Onboarding journeys: create beside saved keys; select, rejected bind, restart, choose a different identity, create and bind; ambiguous 503 and 403 failures; challenge rejection and stale evidence. - Release boundaries: an account switch at `/current`, at status, or between the recheck and the delete; cancelling between the recheck and the delete; leaving the picker before a late creation; an unreadable device inventory; leaving while a release is in flight; a failed delete retried in place and after a restart; a release refused because the inventory is unreadable, then retried in place; a rejected paired key. Each keeps every key, leaves the new account's slot untouched and leaves a cancelled flow idle, and a retry converges. - Mutating the creation guard, the generation check, the binding recheck, the store's receipt check, the account and cancellation checks before the delete, or the device-inventory error handling fails a test. #### Screenshots Physical iPhone at `1fb633d55` (frames from a screen recording; saved public keys redacted): | Rejected bind | After Choose a different identity | After Create a new identity | | --- | --- | --- | |  |  |  | Widget-test renders of the same screens (the real `HostedFlow` against a mocked account service, with a fixture key; the square at the top left is the test renderer's placeholder for an asset): | Picker on an unclaimed account | Rejected bind | | --- | --- | |  |  | 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Xander <ff30c859b96bd5233ffaf5d071200458f1274bd0dab03589214cf1055198491d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8a113ed99e |
fix(desktop): show upgrade guidance (BUZZ-517) (#9033)
## Summary
Old Buzz desktop clients now check a community’s public requirement
before each connection. An explicit `requires_nip_fi: true` shows an
upgrade screen with a fixed https://buzz.xyz download action and manual
fallback. Missing or false requirements, and failed discovery, preserve
normal connection behavior.
Retry, community navigation and address editing preserve keys and saved
communities. Changing an address cancels the old onboarding transaction
and fences pending profile checks. Native discovery sends no
credentials, follows no redirects, and limits requests to five seconds
and 256 KiB. Wire fixtures are exported from main `6b8a72e835` with
reproducible provenance.
### Related issue
[BUZZ-517](https://linear.app/squareup/issue/BUZZ-517/v0-clients-show-an-upgrade-screen-for-communities-requiring-nip-fi).
Related: [#8984](https://github.com/block/buzz/pull/8984) contains
mobile parser preparation;
[#8920](https://github.com/block/buzz/pull/8920) repairs relay rejection
messages.
### Rollout
There is no client flag. The upgrade screen appears only when the relay
advertises `requires_nip_fi: true`. Public auto-update delivery requires
a desktop release containing this change and promotion to the [OSS
auto-update
feed](
|
||
|
|
dce37f74d7 |
fix(mobile): skip the read-state snapshot when nothing needs publishing (#9093)
Every mobile resume re-syncs read state and then schedules a read-state publish, whether or not anything changed. Each publish starts by downloading the full read-state snapshot (about 1 MB compressed on a heavy account) and only then finds nothing to send. Together with the re-sync's own snapshot, that is two full downloads per resume, and a third when a publish from before backgrounding retries. The publish now checks first, using the same packing it would publish, and returns before the snapshot when nothing is pending and no slot differs from what was last published. When something did change it behaves exactly as before, snapshot included. Today the snapshot fails before downloading on gzip-serving relays, so this only pays off once [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) lands; without this PR, [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) roughly doubles resume download on heavy accounts. It has no merge-order dependency. ### Validation - Added test: `a publish with nothing new skips the snapshot`. It publishes a change (one snapshot), publishes again with nothing new (no snapshot, no event), then publishes another change (snapshot and an event carrying both marks). It fails without the early return. - Existing read-state suite passes. - iPhone 12 mini, Profile build, Home backgrounded 12 s and resumed, all socket bytes received in the 15 s after resume: | Build | Download per resume | Snapshot fetches | | --- | --- | --- | | main (snapshot broken) | 3.07 MB | 0 completed | | [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) | 6.85 MB | 3 | | [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) + [#9090 no read-state replay](https://github.com/block/buzz/pull/9090) | 6.08 MB | 3 | | [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) + [#9090 no read-state replay](https://github.com/block/buzz/pull/9090) + this | 2.71 MB | 1 | These are single runs on one account with 28 read-state slots, so treat the numbers as indicative. 🤖 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> |
||
|
|
cf8fbbba55 |
fix(mobile): keep verified names on screen in channels with over 1,024 members (#9111)
In a channel with more members than the verified-name interest limit (1,024), returning to the app made member names flip: a verified name changed to a profile name with a collision suffix (for example "Ankit · ykn9") or to an npub, then back once a refetch landed. Seen on an iPhone 12 mini in a 2,004-member channel; instrumented logging showed the verified-name map dropping from 956 entries to 0 on opening that channel. Tracking a large roster evicts the oldest-tracked subjects, and eviction dropped their records too. On resume, member and profile preloads track the evicted people again, which evicts the ones on screen, so their verified names disappear until the next fetch. Evicted records now stay as unchecked cache (up to 4,096, oldest untracked dropped first) and are rechecked when tracked again. Only tracked subjects accept new records, so the cache never grows from relay traffic. ### Validation - Added `a subject evicted by a large roster keeps its last known name`: it fails when eviction drops the record. - `flutter analyze` is clean. The full mobile suite passes except 10 push and notification-toggle tests that fail identically on unchanged main on this machine. - Same phone, 2,004-member channel, after this change: the verified-name map held steady (978 entries, growing to 1,828) through opening the channel and two resumes after 12 s and 20 s in the background, and the "ankit, lhoward" and "sfoo, Dani" rows never changed in the screen recording. Before, both flipped (to "Ankit · ykn9" and an npub) and back after each resume. - Automated app run on the same phone with this change: Home, search, the large channel, and its resume after the reconnect grace period. This touches the same `_snapshot` line as [#9101](https://github.com/block/buzz/pull/9101), so whichever lands second needs a one-line rebase. 🤖 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> |
||
|
|
244038b495 |
mobile: reuse channel message rows that did not change (#9115)
Opening the keyboard in a busy channel janked: the composer's bottom inset changes on every keyboard animation frame, and each change rebuilt the message list, which rebuilt every visible message row. On an iPhone 12 mini that came to 8 frames over 16 ms per keyboard open, with 290 to 360 ms spent in build. The same cascade runs on every page rebuild, such as the dozen or so while a large channel settles after returning to the app. Flutter skips a rebuild only when it gets the identical widget object back, so the message list now keeps each row it built and returns it while nothing the row is built from has changed. Shared inputs (the message list, membership, channel names, send animations, the focus callback, today's date and so on) clear the cache when they change. Each row also compares its neighbors, thread summary, typing and working state, and send-animation and handoff flags. Rows still rebuild through their own provider watches. The composer's focus callback is now stable so the page's rebuilds don't clear the cache. ### Validation - Added `a growing composer reuses the message rows on screen`: it types a four-line message and checks that the visible rows are the same widget objects. It fails without the cache. - `flutter analyze` is clean. The full mobile suite passes except the 10 push and notification-toggle tests that fail identically on unchanged main on this machine. - iPhone 12 mini, Profile build, a 2,000-member channel, one matched run each before and after (the after column is an earlier revision of this change with the same mechanism): | | Before | After | | --- | --- | --- | | Keyboard open, build time | 359 ms, 290 ms | 64 ms, 43 ms | | Keyboard open, frames over 16 ms | 8, 4 | 1, 0 | | Return to the app, build time | 953 ms | 881 ms | | Return to the app, frames over 16 ms | 21 | 18 | Most of what remains on return comes from rows' own watches (for example the agent display-name map is a new object on every agent directory reload), which this change doesn't address. - Automated app run on the same phone with this exact branch: Home, opening the large channel, opening the keyboard twice (58 and 69 ms of build, one frame over 16 ms each), scrolling back through several days of history, and returning to the app after 12 s in the background. Day dividers, reply counts, reactions and system rows rendered correctly throughout. Typing is covered by the widget test rather than the device run, to avoid drafting in a real channel. - Simulator QA of this branch (Debug build, a private test channel): typing a four-line message, sending, scrolling to the top and back, a reaction, an edit, a thread reply, a delete, sending again, and backgrounding and returning. Every row rendered and updated correctly, with no replayed send animations. The jump-to-latest button didn't respond to synthetic simulator taps before or after resume; it's a native iOS view this change doesn't touch. https://github.com/user-attachments/assets/04909261-81fe-4514-98aa-8d7fdfdcace5 🤖 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> |
||
|
|
3ff4154ca6 |
fix(mobile): back off push bootstrap retries (#9092)
When push setup fails, the app retried every 5 seconds forever. On an iPhone 12 mini whose push gateway enrollment is refused (a CDN 403 on the installation challenge), each retry refetched the relay's NIP-11 document over a new TLS connection and logged the whole HTML refusal. That is about 720 attempts and roughly 14 MB of traffic an hour for as long as the app stays open, with no way to succeed. Push registration, lease publication and tombstone retries now back off: each consecutive failure of the same attempt doubles the delay from 5 seconds up to 15 minutes, with jitter between half and all of it so devices that failed together don't retry together. A success (including a renewal), a different attempt, or a channel-grammar retry (which keeps its 5-minute delay) ends the streak. The first retry still comes within 5 seconds, so a brief blip recovers as quickly as before. ### Validation - Added tests: `repeated failures back off exponentially up to a cap` checks every delay's bounds at both jitter extremes over 12 failures, `a success ends the failure streak`, and `a different attempt starts its own backoff`. The first fails when the gate goes back to a fixed delay. - Existing push suite passes. - On the 12 mini before this change: `Push lease bootstrap failed` with the 403 page and a fresh NIP-11 fetch every 5 seconds for the whole session. 🤖 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> |
||
|
|
e34acb0ac5 |
fix(mobile): keep reply counts on screen while a reconnect catches up (#9100)
Every time the app comes back from the background, the "N replies" links under messages in an open channel vanish and reappear a moment later. On an iPhone 12 mini in a busy channel this happened on nearly every resume, since the session drops its socket five seconds into the background and the channel reconnects. A reconnect discards the channel window and refetches its newest page. Until that page lands, thread summaries came only from replies already loaded, which in a windowed channel is usually none. The discarded window's summaries now stand, below every fresher source (live summaries, thread queries, overflow floors), until the replacement history lands, which then decides, including dropping a summary the relay no longer reports. ### Validation - Added `reply counts stay on screen while a reconnect catches up`, which fails when the retained summaries are left out. - `flutter analyze` is clean and the full mobile suite passes. - iPhone 12 mini, Profile build, a 2,000-member channel backgrounded 12 s and resumed (same scenario, one run each). Before: the "6 replies" link vanished for about a second and the timeline shifted up, then back. After: the link and the scroll position stay put through the catch-up. - Automated app run on the same phone with this change: Home, search, the large channel and its resume after the reconnect grace period. ### Accepted gap If you had scrolled back past the first page, those older rows still lose their reply counts once the reconnect catch-up finishes, as they do on main today. Their counts return when pagination refetches their page or the channel is reopened. 🤖 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> |
||
|
|
5e36d65c9e |
fix(mobile): inflate gzip read-state snapshot responses (#9089)
On relays behind a CDN that gzips responses (the Builderlab relay does), mobile read state never synced. Every read-state snapshot fetch, and therefore every read-mark publish, failed with `FormatException: Unexpected extension byte`, so read marks made on the phone never reached other devices and each reconnect retried and failed again. The relay query transport deliberately leaves gzip bodies compressed so they can be inflated off the UI isolate (see `RelayHttpQueryClient`), but the snapshot path decoded the raw bytes with `utf8.decode`. It now uses `relayResponseText`, like the other query callers, for both the discovery and the snapshot responses. Heads-up for reviewers: with the snapshot working, a resume also pays for the full snapshot download on top of the existing read-state replay on the live subscription. A follow-up PR removes that replay. ### Validation - Added test: `inflates gzip discovery and snapshot bodies` serves both responses gzipped. Without the fix it fails with the same `Unexpected extension byte` error seen on the device. - Full mobile suite: all pass except the known stale `fullscreen_onboarding_test` case, which fails on main too. - iPhone 12 mini (Profile build, Builderlab community): before, every resume logged `snapshot failed` and `publish failed (journal retained)`. After, the snapshot on resume and the snapshot inside a publish both completed with no errors. - Automated app run on the same phone with this change: Home backgrounded past the reconnect grace period and resumed, then search and a large channel opened. No read-state errors logged. Read marks were not compared across devices. 🤖 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> |
||
|
|
50c10dc7b2 |
perf(relay): cache the community-active check on socket admission (#9109)
## What Socket admission (root and audio) and the ephemeral write fence now share one cache of "is this community active?". An entry is served for 1 s after its read checked out a pool connection, and concurrent misses for one community share one read. Archive and deletion drop the entry before they close the community's sockets. ## Why Every new socket runs `is_community_active` before it can authenticate. In a reconnect storm that means thousands of identical reads for one community. A read that times out in the pool refuses the socket (`community active check failed; refusing the socket`), and the client then reconnects and adds more load. On a local cold-start reconnect rig, this refusal count roughly tripled once #9012 freed pool capacity for other reads. It is now one of the top causes of connect failures. ## How - `buzz_db::Db::is_community_active_read_at` returns the answer together with the instant its connection was checked out, following the #9012 `ReadAt` pattern. `is_community_active` now calls it, and the datastore span stays under its existing name. - `community_active::CommunityActiveCache` holds an entry per community and a `SharedReads` gate. An entry is served for `COMMUNITY_ACTIVE_MAX_AGE` (1 s) after its read. Both answers are cached. A cached "inactive" fails safe and lasts at most 1 s after an unarchive. Errors are not cached. - **The archive sweep stays exact.** Admission registers a socket before it checks, so the sweep closes every socket registered before it. A socket that registers after the sweep must see the community as inactive. `apply_community_disconnect` invalidates the entry before the fence and sweep. Invalidation bumps an epoch, and a fill whose read began before the bump is not published. A post-sweep socket therefore reads afresh. It cannot take a pre-archive answer from the cache or from a shared in-flight read. Host invalidations (archive and unarchive writes, local and received) also drop the entry. - **Bound when the notification is missed.** A pod that misses the Redis disconnect admits sockets from a cached "active" for at most 1 s after the archive commits. Lifecycle revalidation then closes them. This matches the 1 s host-routing bound in `docs/community-auth-upgrades.md` ("archive and deletion … cached tenant binding can lag those changes by up to one second"). Deletion keeps its own write fence (`guard_community_write`). - Lifecycle revalidation and persistent ingest still read uncached. ## What got simpler The ephemeral fence's separate `serving_active_cache` is gone. It ran the same SQL predicate through `is_serving_active`, used a 10 s TTL and had no invalidation. The connect path, the WebSocket ephemeral path and HTTP typing now share one cache with one 1 s bound and one invalidation path. That leaves one fewer cache concept, and the ephemeral fence goes from 10 s stale to at most 1 s. ## Tests - `community_active::tests` (unit): - 16 concurrent misses cost one read, and an entry past its max age is re-read. - An invalidation during an in-flight read fences that fill. A waiter that joined the read reads afresh and gets "inactive". - Errors are not cached. - `connection::tests::bootstrap_admits_from_the_cached_community_answer`: with the database unreachable, a cached "active" admits a socket through the real `handle_connection`, and the socket gets its AUTH challenge. - `postgres_tests::cache_misses_behind_a_held_pool_share_one_fresh_read` now also covers this cache. With a one-connection pool held past every max age, eight concurrent admission checks cost one DB read, and the entry lands fresh. - `postgres_tests::archived_community_is_refused_after_invalidation_or_max_age`: after an applied archive, the next check refuses at once. An archive this pod never hears about refuses once the entry passes 1 s. - The ephemeral and typing fence tests now seed the new cache. - Mutation-checked on Blox. Each of these turns a test red: dropping the epoch fence, the max-age filter, the single-flight gate, the connect path's cached read, or the disconnect invalidation; stamping `read_at` before the pool wait; and calling the uncached read on the ephemeral path. 🤖 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> |
||
|
|
ff15c347c2 |
fix(db): auto-migrate prebuild-guarded indexes on small databases (#8802)
## Problem Migrations `0076`, `0078` and `0081` raise an exception on any table with at least one row unless its index was prebuilt with `CREATE INDEX CONCURRENTLY`. A relay that runs migrations at startup (`BUZZ_AUTO_MIGRATE`, on by default in the Helm chart) and has data in those tables therefore crash-loops on upgrade: ``` migration error: while executing migration 81: error returned from database: prebuild idx_nip_fi_profile_heads_names concurrently; see docs/profile-search.md ``` `0081` (#9019) hits every auto-migrating relay that has published NIP-FI verified profiles. `0076`/`0078` (#8523) hit any relay with artifact data or channel members. Each of these tables is small enough on such relays that a plain index build takes milliseconds. The root cause is `docs/database.md` Rule 6. It told authors that an index on a populated table MUST be built concurrently by an operator ahead of time, so migrations were written to refuse populated tables. With auto-migration that refusal is a crash loop, not a safe stop. ## Fix The migrations are already applied on some databases, so they can't be edited: sqlx would reject the changed checksums. Instead, `run_migrations_locked` builds their missing indexes first. For each entry in `INLINE_INDEX_BUILDS` (`0076`, `0078`, `0081`), it runs migrations through the version before it, then builds any missing index. Each build is a plain `CREATE INDEX` in its own transaction, under the same 1s `lock_timeout` and 5s `statement_timeout` the migrations use. That is the bounded inline build `0049` already does: - **Small populated databases** upgrade the same way empty ones do. - **Large databases** still fail closed. Only a lock or statement timeout (`55P03` / `57014`) gets the "prebuild concurrently" hint, pointing at that migration's own doc. Other errors pass through unchanged. The blocked migration is not recorded. - **Prebuilt indexes** are found with `to_regclass` and skipped before `CREATE INDEX`, so deployments that prebuilt concurrently never request the writer-conflicting lock. - **Invalid prebuilt indexes** are left for the migration to reject, as before. `CONCURRENTLY` is not used in this path. The runner holds the migration advisory lock, and another replica blocked on that lock holds a snapshot that a concurrent build would wait on. ### Rules - `docs/database.md` Rule 6: with `BUZZ_AUTO_MIGRATE` on, every migration must apply its own change on a populated database and must never refuse one because it has rows. A new index is built inline under the migration's budgets. Only deployments that apply migrations out of band (auto-migration off) prebuild concurrently. - Root `AGENTS.md` (step 4) and `migrations/AGENTS.md` repeat the never-refuse rule. - `docs/artifact-sync-deployment.md` and `docs/profile-search.md` describe the startup behavior first and the concurrent prebuild as the out-of-band path. ## Tests - `populated_database_auto_migrates_through_every_migration` (Postgres) migrates to `0075`, seeds artifact rows, and checks four phases: 1. **Lock held:** while a writer lock is held, the `0076` build fails closed. 2. **Index prebuilt:** with the `0076` index prebuilt, the build is skipped. 3. **Partial `0078`:** a blocked `0078` build keeps the builds before it committed. 4. **Verified profile seeded:** with a verified profile head inserted before `0081`, a final startup succeeds. It asserts that the applied versions equal every embedded migration and that all six indexes are valid, ready and live. - `frozen_prebuild_guards_have_inline_builds` (no database) scans `migrations/`. It fails if any migration refuses a populated table (`RAISE EXCEPTION 'prebuild …`) without an inline build, and if a new guard is added. - `migration_execution_cannot_bypass_schema_destruction_lock` allows exactly one production `run_to`, inside `run_migrations_locked`. ## What got simpler - Auto-migrating relays no longer need a manual, operator-only prebuild step for small deployments. That restores the chart README's contract that `helm upgrade` is the whole upgrade procedure. - Rule 6 no longer steers authors toward refusing populated tables, and a test enforces that. - The change adds one table-driven step for three frozen migrations and no new configuration. 🤖 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> |
||
|
|
ab399231c2 |
docs: mark desktop/ as the deprecated Buzz 0.X app (#8840)
## Summary `desktop/` is the Buzz 0.X desktop app. Buzz 1.0 lives in [block/buzz-app](https://github.com/block/buzz-app), and its README already says so. Nothing in this repo says it, so contributors and agents keep opening feature PRs against `desktop/`. This PR adds the rule in three places: - **`AGENTS.md`**: a notice at the top of "Desktop App" and a note in the repo layout. Agents read this file first. The notice also says that CLI PRs do not update the 0.X agent guidance in `nest_skill.md`. - **`CONTRIBUTING.md`**: a new item in "PRs We're Unlikely to Merge". - **`desktop/README.md`**: a notice at the top. The rule: build desktop features in block/buzz-app, or as a Buzz plugin. Change `desktop/` here only to fix a 0.X bug, or to keep it compiling after a shared crate changes. ## Testing - This PR changes only documentation. I checked the diff and the two block/buzz-app links. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Blake McAnally <bmcanally@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
bc99726f5c |
docs(agents): add Review-Proven Rule 9, design client paths for the whole fleet (#9106)
## What Adds Review-Proven Rule 9 to `AGENTS.md`: **design every client-driven path for the whole fleet running it at once.** Before an author adds or changes a connect, reconnect, renewal, poll, or catch-up path, they: - count the relay reads it costs per client, and keep reconnect to a delta from the client's cursor - jitter its start and back off with a cap - prefer an event-driven refresh with a long backstop over a short poll - on the relay, check every cache on a connect or auth path at a 0% hit rate, with single-flight misses that survive a saturated pool ## Why Relay load tests keep finding the same class of defect. A path is cheap for one client, but when a rollout drain, node eviction, relay restart, or network blip makes every client run it in the same few seconds, it collapses the relay. Each bullet cites a merged PR that fixed an instance: #4542, #8846, #9012, block/buzz-app#2215 and #2308. Unlike rules 1–8, this one comes from load tests rather than review mining, and the rule text says so. The section intro and the Reviewing bullet now say the rules list includes non-review sources. ## What got simpler Nothing in code. This is one rule added to the existing list. It turns a recurring load-test finding into a check made at planning and review time, so it doesn't keep resurfacing as a late load-test result. ## Validation Docs-only. I checked the diff, the Markdown rendering, and every cited PR number and title. 🤖 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>mobile-v0.28.0-rc.3 |
||
|
|
aa749a862e |
perf(db): serve single-root thread reply reads from thread_metadata (#9075)
## Summary
A plain thread-reply read, `{kinds:[9], #h:[channel], #e:[root],
limit:100}`, goes through the generic `query_events` path. That path
gathers every event tagging the root into a `MATERIALIZED` CTE, then
sorts it to keep one page. On a root with 500k replies, each page costs
about 514k buffer reads plus a temp-file spill.
This PR serves single-root reply reads from `thread_metadata` instead.
It walks `idx_thread_metadata_window` newest-first, fetches each reply
by primary key, and applies every filter predicate before `LIMIT`. Page
cost scales with the page size, not with the thread.
## What changed
- **Routing (`crates/buzz-db/src/store/event.rs`).** A `query_events`
read takes the new route when it has:
- one channel,
- one lowercase-hex `#e` root,
- kinds limited to 9 and/or 40002,
- no `#p` filter (any number of values; the relay marks such filters
`exact_match`),
- no `authors`, `pubkey` or `ids` selector. The route tests those per
reply, so an author with no reply in a hot thread would walk the whole
thread; the generic read narrows by author first.
Every other `#e` read stays on the fenced path from #7854.
- **Root check before routing.** `#e` must name a thread root. A cheap
probe looks up the target in `thread_metadata` first. If the target is
itself a reply, the read takes the generic path, which returns its
direct children the NIP-01 way. A reply counts if its row has a root, or
if it is a rootless legacy stub whose own NIP-10 markers resolve to a
reply.
- **COUNT unchanged.** COUNT post-filter fallbacks (`count.rs`,
`bridge.rs`) set the new `EventQuery::exact_match` flag through
`prepare_count_fallback`. Routing is skipped for them, so COUNT still
counts every event that matches under NIP-01.
- **Same predicates on both paths.** The predicate block moved out of
`build_query_events_sql` into `push_event_filters`, which both paths
call. Filters on the route therefore match the generic path, including
the `#e` tag match, `since`/`until`/`before_id` and deletion.
- **Unnamed statements.** Routed reads run as unnamed statements, so
Postgres plans each one with its root and never caches a generic plan.
The benchmark below shows the routed SQL is also fast under a forced
generic plan, so this is a second safeguard.
- **Narrowed semantics, documented.** Messages that name the root only
in a bare `e` tag, a `mention` tag or a lone `root` marker are top-level
under NIP-10. They have no `thread_metadata` row, so the routed read
omits them. Live subscriptions still deliver them. `docs/nips/NIP-CW.md`
gains a "Plain Thread-reply Filters" section describing this. A client
that needs those messages can add another kind to the filter, which
sends the read down the generic path.
## Benchmark (synthetic, Postgres 16, 500k-reply thread)
Median of 5, newest page of 100. "Rare" is a root with 3 replies.
"Generic" forces `plan_cache_mode = force_generic_plan`.
| | main | this PR |
|---|---|---|
| hot root, custom plan | 2802 ms (513k buffers, 46,750 temp blocks
written) | 1.6 ms (407 buffers) |
| hot root, generic plan | 1779 ms | 0.6 ms |
| rare root, custom plan | 1.2 ms | 0.8 ms |
| rare root, generic plan | 61 ms | 0.08 ms |
Author-filtered reads (`authors:[x], limit:1`) on the hot root: an
author with no reply there takes 1.4 ms on the generic path but 1.7 s on
the route, so author and id selectors stay generic.
On both roots, the PR's SQL returns the same rows as main.
## Tests
-
`e_tag_filter_shape_tests::thread_reply_route_admits_only_single_root_message_reads`
checks which reads take the route. Two roots, mixed kinds, no kinds, no
channel, an uppercase root, a `#p` join, an `exact_match` read, and
`authors`/`pubkey`/`ids` selectors all stay generic. The routed SQL is
checked for the `thread_metadata` walk, the `#e` match and the window
ordering, with no `MATERIALIZED`.
- `postgres_tests::thread_reply_reads_use_thread_metadata` (Postgres
lane) covers:
- mixed 9/40002 replies, a nested reply and a soft-deleted reply,
- a bare-`e` message that the route omits and the generic read (kinds
`[9,40002,7]`) still returns,
- a lone-`root` message, which the route omits too,
- an `exact_match` read, which returns the same rows as the generic
read,
- a nested-parent target, which gets its direct child the generic way,
- root stubs and a legacy parent with no row of its own, checked through
the probe,
- keyset paging,
- no named prepared statement left behind after repeated runs.
-
`handlers::req::tests::postgres_tests::multi_p_thread_filter_keeps_nip01_results`
(Postgres lane) runs a two-value `#p` thread filter through the real
filter-to-query adapter and checks that a bare reference to the root
still comes back.
- `handlers::req::tests::count_fallback_fetches_one_extra_candidate`
checks that COUNT fallbacks set `exact_match`. It is DB-free and was
missing from the `buzz-relay --lib` allowlist, so this PR adds it to
`just test-unit`.
- Mutation-checked: the Postgres test fails if any of these is broken:
- routing disabled,
- persistent statements,
- the nested-target probe dropped,
- stubs treated as all replies or as no replies,
- COUNT allowed to route,
- the deleted filter dropped.
- Blox gate: `cargo fmt`, clippy (`-p buzz-db -p buzz-relay
--all-targets`), buzz-db lib (nextest and plain parallel `cargo test`),
buzz-relay lib, and the buzz-db and buzz-relay Postgres lanes. One
unrelated artifact-sync scale test timed out under load; it passes alone
and in CI.
## What got simpler
- One predicate builder replaces the inline block, so the two read
shapes cannot drift apart.
- One targeted route replaces the planned extra fence variant (a
keys-only `MATERIALIZED` CTE). That variant was dropped because it
reduced the spill but still scanned every reply.
## Identified debt (not in this PR)
- The bridge `page` extension (`crates/buzz-relay/src/api/bridge.rs`,
`extract_page_offset`, built for the kind:0 directory listing) still
maps to SQL `OFFSET`. The routed read honours it too, for parity. Deep
pages there should move to keyset (`until` + `before_id`).
buzz-review-completed
🤖 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>
|
||
|
|
6b8a72e835 |
fix(mobile): send hosted relay auth with iOS custom emoji downloads (#9056)
On iOS, custom emoji in the native emoji picker often render as the sparkle fallback icon instead of the image. The native picker downloads custom emoji with `URLSession` and asks Flutter for request headers over the `mediaHeaders` channel call. That call returned only the media read `Authorization` header. Hosted relays also require the `Nostr-Federated-Identity` assertion, which the Flutter image path gets from `HostedRelayHttpClient`, so relay-hosted emoji get a 401 and the picker falls back to sparkles. Emoji hosted elsewhere still load, which is why only some are missing. The `mediaHeaders` bridge now also includes the hosted relay assertion via a new `HostedRelayAuth.headersForUrl`, scoped to the relay's own origin like `HostedRelayHttpClient`. The native loader refuses redirects for requests that carry the assertion, matching the Dart client, so the token can't be forwarded to another host. The iOS reactions sheet had the same gap: it passes avatar and custom emoji URLs to native views with only the `Authorization` header. A new `nativeMediaHeadersProvider` combines both headers for any native media consumer, and the reactions sheet uses it. ### Validation - Added tests: the native picker's `mediaHeaders` call returns the relay assertion for relay media and no headers for an external emoji URL; `headersForUrl` returns the assertion only for the relay origin (not another host, subdomain or port). The reactions sheet passes the combined headers for avatars and custom emoji, and `nativeMediaHeadersProvider` merges both headers only for relay URLs. - Focused existing validation: the emoji picker and organization relay auth suites pass, and `flutter analyze` is clean. In the full mobile suite, the only failures are 10 in `notification_toggle_test`, `push_bootstrap_test` and `push_bridge_test`, and they fail the same way on `main`. - Automated full-app simulator run (iPhone 17 Pro, iOS 27, debug build of this branch with hosted auth enabled, signed in to two hosted communities through a cloned simulator keychain, driven with AXe). Covered: launch signed in, channel and DM lists, opening a channel and a DM, the native emoji picker (its Custom section shows the emoji as an image, not the sparkle fallback), sending a custom emoji message and seeing it render, reacting with the custom emoji (shown in the quick-reaction row and as a reaction pill), opening a thread, switching communities, avatars and community icons, background and resume, and a cold relaunch. Result: no crashes or regressions seen. Screenshots are omitted because they show real account and community names. - Limitations: this run does not reproduce the original bug. The hosted community's custom emoji also loaded in a temporary build with the relay assertion removed from the `mediaHeaders` bridge, so its emoji URL evidently doesn't need the assertion, and the run doesn't prove the fix. The reactions detail sheet could not be reached because simulated long presses on the pill opened the message menu, and inline image attachments were not exercised. A device build against a community whose emoji are relay-hosted is still needed to confirm the fix. 🤖 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> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
920b1e64bd |
perf(mobile): keep covered pages still while the keyboard moves (#8941)
## Summary A keyboard raised by a route above (the Add members sheet, search, a native picker) changes the app's `MediaQuery` on every animation frame. Covered pages that read `MediaQuery` directly or through a `Scaffold` rebuilt and relaid out all their content for keyboard motion they cannot show. On #4things, typing in Add members rebuilt every visible message of the channel page underneath for each keyboard frame. `CoveredRouteKeyboardInsets` holds a page's `viewInsets` and `padding` at their last values from when its route was current, while the route is covered. Every other `MediaQueryData` field passes through. A covered page still follows the keyboard down, so when a covering route takes focus and the page's own keyboard closes, the page lowers behind the scrim. A size change (rotation) drops the held values so safe-area padding stays right. When the route becomes current again with the keyboard still above the held value, the page keeps the held values rather than jumping up and riding the keyboard back down. It switches to live values once the keyboard reaches the held value, or as soon as focus is inside the route (the page raised its own keyboard). Otherwise the live values return. It always returns the same widget shape, so covering a page never remounts it. Home, search, the channel page and channel details use it. ## Measurements iPhone 12 mini, Profile build, Builderlab #4things (1,361 members). Flow: open the channel, open its details, tap Add members, tap the search field, then type "chris" one character per second. The window is the 10 s of typing, keyboard included. Both builds also contain the fixes from [#8928](https://github.com/block/buzz/pull/8928), [#8929](https://github.com/block/buzz/pull/8929) and [#8931](https://github.com/block/buzz/pull/8931). | | Before | After | | --- | --- | --- | | UI frame time, total | 3,344 ms | 383 ms | | Frames over 16.7 ms | 101 of 232 | 2 of 297 | | Median / p90 frame | 14.1 / 26.1 ms | 0.6 / 2.0 ms | | UI isolate CPU samples (1 ms) | 2,793 | 868 | | Samples in `MessageContent.build` | 621 | 0 | Single runs per side, not repeated. ## Testing - New widget tests: a covered page doesn't rebuild for keyboard frames and gets the live insets back when it's current again. A current page follows the keyboard. Covering a page keeps its state. A page uncovered with the keyboard still up does not jump, and follows the keyboard once it reaches the held value. A page whose own field takes focus after being uncovered follows the keyboard immediately. A covered page follows its own keyboard closing and takes new padding when rotated. The last test fails (3 mounts instead of 1) against a version that returned a different widget tree when covered. - `just mobile-check mobile-test` passes. - On the phone: Add members search, typing and results behave as before. The channel page and search still move above the keyboard when they're current. 🤖 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> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>mobile-v0.28.0-rc.2 |
||
|
|
700e690193 |
feat(db): enforce unique listed channel names with an index (#8787)
## Summary Stacked on https://github.com/block/buzz/pull/8782 (merged), which makes the relay refuse a second open channel with a name that another channel in the community already has. That check lives in relay code, so an older relay, a direct database write or a missed code path can still create a duplicate. This PR adds the same rule to the database: a unique index on channel name, ignoring case, for the channels that show in the channel list. With the index in place, a duplicate fails at the write, whatever wrote it. The relay's own name check from https://github.com/block/buzz/pull/8782 is no longer needed, so this PR removes it. Clients still get the same refusal, `invalid: an open channel named "…" already exists`. The migration builds the index itself, also on a populated `channels` table, so an auto-migrating relay upgrades with no operator index step (the rule from https://github.com/block/buzz/pull/8802). It still refuses to run while duplicate names remain, because the index cannot exist over them. `docs/channel-name-uniqueness-deployment.md` gives the full rollout. ## Details - **The index** (migration `0083_channels_listed_name_unique.sql`): ```sql CREATE UNIQUE INDEX idx_channels_listed_name ON public.channels (community_id, lower(name)) WHERE visibility = 'open' AND NOT unlisted AND channel_type IN ('stream', 'forum') AND deleted_at IS NULL; ``` It covers open, not unlisted streams and forums that are not deleted, archived ones included. Private channels, DMs and unlisted channels may still repeat names. - **The migration builds the index inline, as `0049` does.** When `to_regclass` finds no index, it runs a plain `CREATE UNIQUE INDEX` under `lock_timeout` 1s and `statement_timeout` 5s. On a busy or very large table it fails on those budgets, records nothing, and the relay retries at its next start. When the index already exists (a concurrent prebuild), it skips `CREATE` so it never requests the write-blocking lock. In both cases it then checks that the index is valid, ready and has the exact expected definition. - **One refusal stays: duplicate names.** The migration refuses to run while listed duplicates remain, and its error names the rename script. The renames must go through the relay so clients get the new kind:39000, and a migration must not change rows (`docs/database.md` Rule 3). This is the "fails closed until its repair has run" case in `migrations/AGENTS.md`, so the writer release from https://github.com/block/buzz/pull/8782 and the rename are a required stop. - **Relay.** A write that takes the name of a concurrent, uncommitted write waits for it, and fails if that write commits. So the post-write check and the per-name lock from https://github.com/block/buzz/pull/8782 are removed. The store already maps the index violation to the same "name taken" error. - **Desired-state schema.** `schema/tables/public/channels.sql` has the same index, and the schema parity test compares its live shape between the desired-state schema and the migrations. - **Rollout** (`docs/channel-name-uniqueness-deployment.md`), in order: 1. Deploy https://github.com/block/buzz/pull/8782 and wait until no older relay pod serves. 2. Rename the existing duplicates in each community with that PR's script, while relays keep serving. The script needs `psql` and `python3`, and the operator key must be a member of each community. The doc covers archived communities and communities being deleted. 3. Confirm that no duplicate remains. 4. Only for a deployment that migrates out of band and is too large for the budgets: build the index with `CREATE UNIQUE INDEX CONCURRENTLY` and verify it. 5. Deploy this release. With auto-migration on, the relay applies 0083 at startup. With it off, run `buzz-admin migrate` first, never the file through `psql`. Rollback goes to the https://github.com/block/buzz/pull/8782 release only. Keep the index, and keep auto-migration off. - **Test fixtures.** Tests that inserted two open channels with one name now use distinct names. Tests that migrate a populated database through 0083 no longer prebuild the index. ### Related issue None found. Follows https://github.com/block/buzz/pull/8782. ### Testing - Migration tests (`listed_channel_name_postgres_tests.rs`) start from a database migrated through 0082: - With duplicates, 0083 fails and changes nothing. - After the rename plan is applied, a channel writer that holds its lock makes the inline build fail on the 1s lock budget, with nothing recorded. - With no writer, the populated table migrates with no prebuild, and a raw duplicate insert fails on the index. - An index with a different definition, and an invalid one, are refused. A valid prebuilt index is accepted, and not replaced, while another transaction is writing to `channels`. - The store and relay name tests from https://github.com/block/buzz/pull/8782 pass with the relay check removed: the index refuses the duplicate, and the client still gets `invalid:`. - The rename plan and script tests from https://github.com/block/buzz/pull/8782 run on a database at 0082, the state the script runs in. - Local Postgres runs on `108737dc0`, one fresh database per test as the nextest wrapper does: 38 of 40 buzz-db migration-lane tests pass (both listed-name tests, the migration parity test, the thread-window and invite-upgrade tests that migrate populated databases through 0083). The 2 `event_follow_up` contract tests fail locally the same way on main. All 49 buzz-db channel and channel-member store tests pass. ### Database review checklist - [x] Phase: contract. Before it: https://github.com/block/buzz/pull/8782 (writer release, required stop) and its rename script. Nothing after it. - [x] No trigger, function, procedure, rule or foreign key. - [x] No migration mutates rows. The renames happen before it, through the relay, with the script in https://github.com/block/buzz/pull/8782. - [x] Never refuses a table because it has rows. Fails closed only with duplicates (repair not run) or with an invalid or different index; then 0083 raises and the relay does not start. The release notes must name the release that contains https://github.com/block/buzz/pull/8782 as a required stop. - [x] Rule 4: `migrations/`, `schema/tables/public/channels.sql` and the parity test. A live catalog comparison covers the index. - [x] Deployed relay: the https://github.com/block/buzz/pull/8782 relay answers `invalid:` beside the index (its tests pass with the index present). A relay older than that still writes, but gets a database error, not `invalid:`, for a duplicate. That is why https://github.com/block/buzz/pull/8782 is a required stop. - [x] Timeouts and index build: `lock_timeout` 1s, `statement_timeout` 5s. The migration builds the index inline (plain `CREATE UNIQUE INDEX`, skipped when `to_regclass` finds a prebuilt one). Only out-of-band deployments too large for the budgets prebuild it with `CREATE UNIQUE INDEX CONCURRENTLY` (doc step 4). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
64d12e2975 |
BUZZ-511: Honor relay mode for community NIP-FI enforcement (#8988)
## Summary Apply relay mode consistently across protected HTTP, root/audio WebSocket, Git, media and V1 admission. Off requires no NIP-FI, including for stored required communities. Shadow enforces only stored community requirements and observes other communities. Enforce requires NIP-FI for every community. Durable requirements survive all mode changes. Off retains ordinary tenant routing and authentication. It skips FI policy/dependency reads for admission, uses cached WebSocket tenant routing, and loads no FI binding snapshot at startup. Discovery reports `requires_nip_fi=false` and no FI capability in Off. Evaluating modes retain same-row WebSocket binding and immutable HTTP policy tenants, including host remaps, pending deletion and unarchive races. Global Enforce discovery retains its existing compatibility behavior. Host-specific root NIP-11 and `/info` expose the additive `requires_nip_fi` field with no-store. In evaluating modes, an unavailable policy returns 503 and an unknown or non-serving host returns 404. Off uses ordinary routing; a discovery lookup failure returns 404. Stored requirements in Shadow require usable FI dependencies. Admission does not establish activation. BUZZ-512 owns existing-session application, drain and mode-aware readback; its separate revision must report stored requirements as suspended in Off. Production baseline verification and human acceptance remain open. Community status now shares the bounded host cache: 10,000 entries, one-second leases measured before checkout, coalesced fills, and no cached misses or errors. Git reuses one request policy. Root/audio retain the final primary binding check: two reads on a cold successful upgrade, one on a cache hit. Off creates no verifier or JWKS source and reads no FI policy. Proof metrics count stored-required Shadow enforcement. Acceptance locks and validates the community row, takes the matching host advisory lock, and drains optional leases for 1.1 seconds before commit. Reads begun after commit see required across participating relay instances, even without Redis invalidation. The row lock closes single and double remap races. Admissions already in flight remain subject to BUZZ-512 activation fencing. ### Related issue [BUZZ-511](https://linear.app/squareup/issue/BUZZ-511/relay-enforce-per-community-nip-fi-policy-and-publish-compatible). [BUZZ-510 / #8985](https://github.com/block/buzz/pull/8985) is merged; this PR now targets main. The restack retains main’s #8974 denial telemetry and artifact capabilities. Related work includes #8526 tenant lookup errors and #8444 media authorization. ### Rollout and containment Keep `BUZZ_NIP_FI_UPGRADES_ENABLED=false`. [The rollout plan](docs/community-auth-upgrades.md#staging-and-containment) starts in staging with zero required rows and a 30-minute before/after traffic comparison. Initial abort limits include any policy timeout/503, policy-read p99 above 50 ms, writer acquisition p99 above 20 ms or twice baseline, and a five-minute 503 increase above 0.1 percentage point. These are initial limits to validate against measured staging results, not production capacity claims. Verify signed/unsigned free controls, real required root/audio upgrades, dependency failures, restart behavior and discovery before expansion. Also watch binding timeouts/errors and their current generic 404 responses on known serving hosts alongside writer-pool pressure; evaluating root/audio binding has a 500 ms budget; Off uses ordinary cached routing. In Shadow, a pre-511 rollback loses enforcement of accepted community requirements. Stop new upgrade calls, retain accepted policy and receipts, and repair with an enforcing binary or operator-approved containment. Never clear durable policy to restore access. Off deliberately suspends FI enforcement and must be an explicit operator mode choice. No production activation is claimed. All acceptance paths must implement the lease protocol before upgrades are enabled. Keep upgrades disabled through a mixed-version rollout. For rollback, disable new acceptance before removing the protocol while caching pods remain. A cold policy fill uses a six-statement transaction and a 400 ms server timeout within the 500 ms lookup budget. A contended policy fill returns 503. During the drain, archive, deletion, ownership, issuer-binding and competing acceptance writers for that community may wait; ordinary serving reads and writes retain their existing behavior. Host-binding changes retain the existing one-second routing bound. Local and Redis invalidation accelerate refresh; safety does not depend on delivery. Deploy the verified Shadow revocation behavior from #8991 before accepting required communities in Shadow. ### Testing Published review-fix head: `39dfe79b1fc04f10ee3eb94d6ea42cf686bafe1e`, tree `1e2e8d3abd39ee1d6821defe8c8d34de73726e08`. McKeon cleared this cache delta. Full affected auth (291), DB (192), pubsub (31) and relay (1,682) package tests passed, including integration binaries. Two previously documented hardcoded-Redis audit tests remained excluded. All-target clippy, all 1,297 native PostgreSQL tests and the full pubsub Redis suite passed. The full ACP package and `just ci` passed after rebuilding cached test executables that embedded a removed checkout path. No ACP source changed. Tauri's generated lockfile delta was archived and restored; it is excluded from this candidate. Both positive lease regressions passed. Four deliberately broken variants failed at the intended assertions: removing the drain left an optional lease alive after 202.935667 ms; skipping the fill lock failed contention refusal; moving the timestamp after the read failed the anchor check; taking the host lock before the row failed the remap-blocking check. Exact source was restored after each mutation. These are local validation results, not a deployed-fleet acceptance claim. All applicable normal push hooks passed. GitHub reports the exact tested head and tree; the published code has no delta from the reviewed candidate. Earlier mode-correction evidence follows for context. Current mode-correction head: `aa9abf07284b56a88eb78a2014e590f39d705288`, on main `af54258f19ac32667c027f889652bdeb479c8e13`. Its code equals reviewed `f1e7da5fc5`, plus the independently reviewed operator documentation follow-up; all 19 earlier remote patches are preserved. McKeon verified the correction and its mapping to the tested candidate. The mode-correction candidate passed full auth (291) and relay (1,679) package tests, clippy, and four explicit native PostgreSQL policy workflows. There were 623 ignored relay tests and two excluded Redis tests. Real PostgreSQL checks cover an ordinary unsigned caller on a stored-required host in Off with no FI token or dependencies, discovery false, durable policy retained, root/audio upgrades, host remaps and the unarchive race in evaluating modes. The `STORED_OFF` mutation fails the enum truth table only; `OFF_PRIMARY` fails cached upgrade routing. Restored source hashes match. Earlier failed compile/fixture attempts remain recorded. The first normal push hook attempt passed Tauri checks but failed the gateway startup verifier test (82/83 gateway tests passed). The remote remained unchanged. The retry rebuilt gateway artifacts with separate root and Tauri caches, then passed every normal push hook and published this head. No gateway source changes or hook bypasses were needed. Current-head hosted CI is tracked separately. Human acceptance and the staging binding-timeout/404 workflow remain open. The following retained validation and controls are historical and describe behavior before the mode correction; their Off-enforcement statements are superseded by the Summary above. Published head: `9a717fa89725f9430ff4a8bb78cd600affb8836d`, on main `1055f79d048ef22c878d02a7afb04f49c753564f`. CI, three schema checks, 2,136 ordinary tests, all 291 auth tests, 12 Redis tests, all 1,300 native PostgreSQL tests and discovery passed. The required wrapper passed units, then exited 1 at unavailable Docker bootstrap. BUZZ-510 is complete at `95c8d31c835d6f7ff6e432fb1ed248771d4f06dd`: CI, three schema checks, 2,127 ordinary tests, 12 Redis tests, all 1,295 native Postgres tests and discovery passed; its wrapper passed units and failed at unavailable Docker bootstrap. The first native run at historical BUZZ-510 `5380cb72` passed 1,291 tests and failed two event-follow-up queue assertions and the scale-sync case. All three passed serially, then the full 1,294-test run passed with normal concurrency. The original cause remains unresolved; failed runs and resource samples are retained. Standalone BUZZ-511 checks exposed access-slot callers, a Clippy warning and early HTTP 503 attribution that had remained in the child. All are now corrected in the parent. The unchanged real-router refusal regression passed before the latest commit. BUZZ-512's final tree is unchanged by moving these parent prerequisites. The remaining 46-file child delta was audited; no additional parent prerequisite was found. Earlier compile and native failures remain archived. The following earlier-head results and controls are historical; the current-head validation is reported above. At `48388834f99b8d045e243b619904cb3e27b47146`, based on BUZZ-510 `130f14a675edd7cbe6a69d1c9d7818bd1a51f46c` and reconciled main `152441ae0a56b204b42b6faa56a36ad2b98218b1`: - `just ci`: passed. - Four-package ordinary suite: 2,069 passed; full `buzz-auth` package: 291 passed. - Native Redis: 12 passed. Native PostgreSQL: 1,261 passed. PostgreSQL discovery checks passed. - Required `just test`: units passed, then exited 1 because Docker service bootstrap was unavailable. Native service results above are separate. - Real-router remap checks reject an A-only member under B policy, avoid A data after B admission, and advertise B in discovery despite a stale A cache. Removing each tenant pin separately fails its intended witness; restored source passes. An unmapped host cannot reuse a cached tenant. - Deadline witnesses advance their clock during real HTTP signature verification and retain socket primary-lookup barriers. Removing either final enforced or shadow HTTP deadline check separately fails its intended witness; restored source passes both deadlines and the single-resolution control. - Telemetry regression: unsigned dev proofs now expect the earlier NIP-98 `missing` denial; signed mismatched-key proofs retain pairing denial and signer attribution. Removing unsigned-proof rejection fails the exact two-counter assertion. Restored full relay package: 1,654 passed. - Preserved failed runs: two prior bottom-layer desktop gates failed an immediate inventory assertion, repaired with a bounded wait for the same observable arrays and verified with delayed-notification and frozen-provider controls. An earlier BUZZ-511 native run passed 1,242 tests and failed two deadline fixtures that relied on the tenant lookup removed by the policy pin; the test-only adaptation above replaces that seam. These failures remain archived. The first native run on bottom-layer `130f14a6` passed 1,255 tests and failed partition contention; its focused original test and fresh full 1,256-test run passed without source changes. The cause remains unresolved. The first CI run at `d8d88433` failed the old telemetry expectation; the test-only correction above preserves exact denial counts and attribution. Earlier evidence at `dddf05d8be8195d567db0f989f70825b1246a6c1` (historical head): - `cargo nextest run -p buzz-auth -p buzz-db -p buzz-relay --lib --tests`: 2,139/2,139 passed. - `scripts/postgres-test-run.sh -p buzz-db -p buzz-relay --lib --tests --success-output final`: 1,236/1,236 passed with native PostgreSQL 16 and Redis. Docker's legacy `just test` wrapper was unavailable. - `just ci`: passed, including repository checks, tests and client builds. - **Pre-correction behavior (superseded):** Real ES256 assertions through TCP root/audio upgrades: valid evidence upgrades; missing evidence denies in Off/Shadow, including submitted/inventoried/approved deletion stages. Restoring the old query makes the regression fail on an assertion-less Off/submitted/root HTTP 101. - **Pre-correction behavior (superseded):** Deterministic unarchive race: Notify barriers pause an actual archived-host policy miss; the same required row becomes serving before binding. Both root/audio deny missing assertions and admit valid ES256 assertions in Off/Shadow. Ignoring bound-row policy makes this test fail on an assertion-less HTTP 101. - The full rerun exposed an existing cancellation fixture whose synthetic five-second replica fence expired between probes. Refreshing that test fence per operation preserves its timeout assertions; the full suite now passes. - Local probe: 100 serial uncached primary policy reads took 12.33 ms. Production latency is unmeasured. No packaged-app, human or deployed-fleet acceptance run. Live activation awaits BUZZ-512 and the staging verification tickets. Published with the three-layer stack in one atomic push through normal hooks. The published stack includes main `1055f79d048ef22c878d02a7afb04f49c753564f`, including access logging, storage reporting and mobile read-state changes. Access-slot integration and early HTTP refusal attribution belong to BUZZ-511; activation callback refusal close owners belong to BUZZ-512. Every changed layer requires review at its published head and new-head CI. Fresh trunk, remote membership, queue and immutable-lease guards passed before publication. All nine normal pre-push hook lanes passed; no hooks were bypassed. Remote heads, chained bases, membership, ready/draft states and native tracking were verified after publication. Trunk’s merged 0081 is unchanged. The parent upgrade expansion is 0082 and activation is 0083, with contiguous migrator versions, updated documentation and a populated-upgrade fixture starting at 81. Trunk’s storage-test repair supersedes our earlier local repair. Before publication, main advanced three commits from `1055f79d048ef22c878d02a7afb04f49c753564f` to `ba7d7825ea` (desktop deep-link settling and mobile pairing/sign-in). The normal branch-skew guard passed. Those later trunk commits are outside this validated published head; current-trunk CI and delta review remain required before merge. Generated with Codex --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> |
||
|
|
506a588f88 |
refactor(relay): remove database usage-metric queries (#9040)
## Summary The relay no longer runs database aggregate queries to emit usage gauges. Fleet and per-community counts of users, channels, relay members, workflows, git repos, communities, messages, and active users/channels are no longer relay metrics, and neither are the snapshot health gauges that came with them. ### Removed - **Gauges:** `buzz_total_users`, `buzz_total_channels`, `buzz_total_relay_members`, `buzz_total_workflows`, `buzz_total_git_repos`, `buzz_total_active_users`, `buzz_total_messages`, `buzz_total_active_channels`, `buzz_communities_estimated` / `buzz_communities_total`, and every DB-derived `buzz_community_*` series (users, channels, messages, relay members, workflows, git repos, active users, active channels). - **Per-community mode:** `BUZZ_USAGE_METRICS_PER_COMMUNITY=all` and everything it fed: the per-community in-memory gauges, `buzz_community_storage_bytes` / `buzz_community_storage_objects`, `buzz_storage_unmapped_community_bytes`, the community → host map query, and the emission-scope bookkeeping that cleared series for renamed or removed communities. No deployment set it. If one still does, the relay ignores it. - **Health signals:** `buzz_usage_snapshot_available`, `buzz_usage_snapshot_age_seconds`, `buzz_usage_query_skipped_total`, and the `usage_fleet_*` paths of `buzz_db_route_decision`. - **Code and config:** - `buzz-db/src/store/usage.rs`, including its aggregate queries. - The relay's `FleetUsageSchedule` and its cadence/deadline/retry machinery. - The replica-only telemetry route (`Db::route_usage_read`). - `BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS` and `DbConfig::usage_metrics_replica_max_age_ms`. If a deployment still sets the env var, the relay now ignores it; startup doesn't fail. - **Reader maintenance pool operation:** `ReaderOperation::Maintenance` and its `close_on_drop` release path existed only for those telemetry reads, so they're removed too. The pool-acquire contract drops from twelve to eleven valid pairs, which lowers the per-pod series ceiling from 216 to 198. - **Dead code:** `Db::active_community_hosts` had no callers. ### Kept - **Leader:** the usage poller's leader lock, plus the leader's invite reaping and fleet storage-snapshot gauges. The session advisory lock (`SessionLeader`, `Db::try_lock_session_leader`) moves to `buzz-db/src/store/leader_lock.rs`, since storage-usage delivery uses it too and it has nothing to do with usage queries. - **In-memory gauges:** pod-local connection, subscription, and online-user gauges. They read process state, not the database. - **Writer maintenance pool operation:** `WriterOperation::Maintenance` stays. Dozens of writer-side maintenance paths across `buzz-db` use it (reapers, retention, the leader lock). The reader variant only ever served the telemetry reads. - **Storage-usage delivery:** unchanged. It reads the storage snapshot for billing, not for metrics. `docs/usage-metrics.md`, `docs/storage-accounting.md`, `.env.example`, and the Helm chart README are updated to match. ## What got simpler This is net deletion (~3.6k lines). One telemetry subsystem is gone: two collection families with independent cadences, availability and age semantics, relay-side deadlines, a dedicated replica routing policy, two config knobs, a per-community emission mode, and a pool-operation variant. Leader election now does only leader work, and it lives in its own module. ## Operational note Dashboards and monitors that read the removed series will go to no data once this rolls out. ## Testing - `cargo fmt --check`, workspace `cargo clippy --all-targets -D warnings`. - `buzz-db` lib, `observability_source`, and doc tests. - `buzz-relay` lib tests. - The Postgres lane for `buzz-db` (lib + tests) and `buzz-relay` (lib). All of these run on a remote workstation at the PR head. 🤖 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> |
||
|
|
34174214fc |
fix(mobile): restore keysForNsec after the native key derivation change (#9085)
## Summary [#8965 native key derivation](https://github.com/block/buzz/pull/8965) removed `AccountIdentityStore._keys`. `keysForNsec`, added by [#9029 nsec backup](https://github.com/block/buzz/pull/9029), which merged first, still called it. The two PRs didn't conflict textually, so `main` now fails `flutter analyze` (`_keys` isn't defined at `account_identity_store.dart:356`) and mobile CI is red. This builds the keys through `keysForHex(_nsecHex(nsec))`, the same path `withKeys` uses since #8965. It applies the same bech32 decoding and secp256k1 scalar validation that `_keys` did. ## Testing - `just mobile-check`: no issues found. - `flutter test` (full mobile suite): 4460 passed, 49 skipped. No UI change, so no app run. 🤖 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> |
||
|
|
e558f15faa |
Mobile: one New chat screen (#9079)
Mobile had two New chat screens. [#8884](https://github.com/block/buzz/pull/8884) made the Flutter draft page (first send creates and names the chat) the New chat screen, but kept the older create form (a native iOS sheet, with a Flutter bottom sheet on Android) for one case: resuming a creation that an older build had saved with a chat name and no first message. Nothing creates that state anymore, so the old form only appeared for users with an interrupted creation from before that change. This removes the old form. Every New chat, including that resume case, now opens the draft page: - **New chat**: unchanged. - **Resuming an interrupted creation**: after the existing Resume / Discard setup / Later prompt, the draft page shows the saved chat with its agent and workspace locked and a Retry button, the same choices the old sheet offered when it locked its fields. A creation saved with a first message resumes exactly as before. One saved without a message finishes without one, because a saved creation can't change after its first relay write. The iOS editor's chat-only code (`presentChat`, `updateChatAssistants`, the agent list, and its avatar loader) goes too. The same editor still handles profile fields, channel details and workspace names. The native test and CI step that covered only the removed chat form are removed. ### Screenshots Resuming an interrupted creation saved without a first message: | Before: old native sheet (removed) | After: draft page (kept) | | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--01-before-resume-native-sheet.png" width="300"> | <img src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--02-after-resume-draft-page.png" width="300"> | A brand new chat already opened the draft page and is unchanged: <img src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--03-new-chat-draft-page.png" width="300"> ### Validation - Changed tests: `me_page_test` checks that Resume and Discard setup both open the draft page and that no native sheet is requested. `solo_creation_tags_test` checks that recovering an interrupted named creation through the draft page's Retry continues with member setup, keeps the saved name and agent, and adds no first message. - Full mobile Flutter suite (4,431 passed) and `flutter analyze` (clean). iOS debug simulator build succeeds. - Automated simulator run (iPhone 17 Pro, iOS 27, signed-in account): New chat opened the draft page, the agent picker selected an agent, and the first send created the chat, which then appeared under Solo. New workspace still opened the native editor, enabled Create once named, and showed the Discard changes prompt on cancel. The recording isn't attached here because it shows internal account content. - Resuming an interrupted pre-[#8884](https://github.com/block/buzz/pull/8884) creation can't be produced by the current build. For the screenshots above, a capture-only, uncommitted patch to a `main` build seeded one; the branch build installed over it resumed it on the draft page. The widget tests cover finishing it. - `solo_creation_tags_test` also covers a conflicting pending Solo organization edit during that Retry: the draft offers the Conflicting Solo changes dialog, keeps the saved creation, and the next Retry finishes it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) buzz-review-completed --------- 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> |
||
|
|
237d794acb |
perf(mobile): skip re-decrypting read-state blobs (#8966)
## Summary Read-state blobs are NIP-44 encrypted, and the app decrypts them with pure-Dart ChaCha20 and HMAC-SHA256 on the UI isolate. Most of those decrypts repeat earlier work: - Every live subscription (each launch, resume and reconnect) replays all of the account's read-state slots, and `_handleIncomingEvent` decrypts each one again. The measured account has 27 slots of about 55 KB ciphertext each. - Before every publish, `_publishOnce` fetches the read-state snapshot and decrypts it, and decodes our own slots a second time to check they're readable. - The relay echoes each publish back, and our own blob is decrypted again. `ReadStateCrypto` now keeps a bounded memo from ciphertext to plaintext, one per conversation key. `decrypt` checks it first, and `encrypt` records the ciphertext it produced. NIP-44 payloads carry a random nonce and a MAC, so under one key the same ciphertext always decrypts to the same plaintext. Failed decrypts aren't cached and still throw every time. The memo is least recently used: a hit moves the entry to the newest end, so our own superseded blobs are evicted before live ones. It holds at most 8,000,000 code units across keys and values (about 8 MB, about 80 full slots). The first version used oldest-first eviction with a 2M bound, which saved nothing on the device: the 27 slots total about 2.6M, and a replay in slot order evicted each entry just before its next use. ## Measurements iPhone 12 mini, Profile/AOT, Tom's Block community. Each run resumes the app twice from the home screen and samples UI-isolate CPU for 15 s from each resume. | Build | `ReadStateCrypto.decrypt` per resume | | --- | --- | | `main` |
||
|
|
9c5032b63b |
perf(mobile): derive keys natively and hash NIP-98 bodies faster (#8965)
## Summary Two pure-Dart crypto paths that run on the UI isolate during normal use: - **NIP-98 body hashes.** Every relay HTTP query (`RelaySessionNotifier._postForEvents`) hashes its request body for the NIP-98 auth header with pointycastle's `SHA256Digest`. This switches that hash, the media upload hash and the NIP-OA preimage hash to `package:crypto`'s `sha256`, which is already a dependency. The output is identical. - **Public key derivation.** `AccountIdentityStore.publicKeyForNsec` runs on every relay auth assertion and derived the key with the `nostr` package's pure-Dart curve math. It now uses the native `derivePublicKeyHex` (libsecp256k1) with the same validation. The default-agent writer now signs with the native `signNostrEvent` instead of `nostr.Event.from`. Left as they were: `nostr.Keys` construction for signing keys (nostr 2.0.0 always derives in Dart, with no way to pass a known public key), and `nostr.Keys.generate()` on the rare key-creation paths. ## Measurements - **Device** (iPhone 12 mini, Profile build, #4things captures): NIP-98 hashing took 17 to 24 ms of UI CPU per 10 to 20 s capture, mostly from channel notices queries. `publicKeyForNsec` took about 7 ms per relay auth assertion. - **Synthetic** (Mac, AOT, same output): SHA-256 of a 1 KB body takes 39.9 µs with pointycastle and 6.6 µs with `package:crypto`. 50 KB: 1,518 µs vs 293 µs. 1 MB: 29.6 ms vs 6.1 ms. - **Device A/B after the change** (iPhone 12 mini, Profile/AOT, Tom's Block community, two app resumes per build from the home screen, 15 s windows; A is `origin/main` |
||
|
|
5d44038ea5 |
mobile: invite new users to create their first agent with a bold card (#9036)
## Summary
New users had no cue that the slot at the bottom of Home is where they
make an agent. A one-time, deliberately loud "Create your first agent"
card now sits above the empty Bestie slot and points at it.
- The card spans the screen above the bottom bar, with agent pictures
(the same ones the empty slot cycles through) scattered around its edges
and the message centered as plain text. It's allowed to cover part of
the chat list.
- It waits three seconds on Home, then pops up from the agent slot with
the popover entrance (a quick fade, rise and scale). Leaving Home
restarts the wait, so it never pops over another screen. With reduced
motion it appears without animation after the same wait.
- Tapping the card starts agent creation. The close button dismisses it
for good, as does a screen reader's dismiss action, activating the slot,
or ending up with a Bestie. Dismissal is remembered per account and
community. Other taps on Home go through to the page and leave the card
up.
- It shows when remote agents are on, the Bestie preference has loaded
with none set, and the account owns no agents yet. The last check keeps
existing users who never picked a Bestie from being told to create their
"first" agent.
- It only shows while Home is the current route, so sheets and pushed
screens hide it and it comes back afterwards if it was never dismissed.
The card is a new `BuzzCallout.card` variant of the reusable
`BuzzCallout` in the mobile design system (the plain tip variant stays
available). It uses the inverse surface in light mode and the raised
dark surface in dark mode (matching toasts), with an arrow, existing
radius, spacing and popover elevation tokens, and stays phone-sized on
wider screens. It grows to fit large text below its close button, and it
has live-region semantics, so screen readers announce it.
## Screenshots
These come from the widget test harness with the real agent pictures, at
small (320), standard (393) and landscape phone sizes, plus a tablet.
The harness can't render the native iOS glass bar, so these use the
Flutter bar; the card is the same on both platforms. Pictures look a
little soft because the harness renders at 1x and scales up. In
landscape and on tablets the card stops at large-phone width so the
pictures don't spread too thin.
| Standard, light | Standard, dark | Small, light |
| --- | --- | --- |
| 
| 
| 
|
| Landscape, light | Landscape, dark |
| --- | --- |
| 
| 
|
| Tablet, dark |
| --- |
| 
|
## Testing
- Launcher tests cover first show and placement, Home staying tappable
underneath without dismissing, the card tap starting creation, the close
button, slot activation without a touch, screen reader dismiss, a Bestie
appearing, no reshow after dismissal, a separate community getting its
own first run, staying hidden for people with agents or without remote
agents, hiding under a sheet, the three-second wait and entrance,
reduced motion, and leaving Home during the wait (each
mutation-checked).
- Component tests cover the tip beside an edge target (320 and 1024
wide, 3x text) and the card: full width at 320, capped on wide screens,
growing at 3x text so the message stays clear of the close button
(mutation-checked), and the close button staying its own screen reader
control.
- The full mobile suite passes locally with CI's dart-defines, and
pre-push mobile checks passed at
|
||
|
|
af54258f19 |
fix(mobile): streamline identity setup and community selection (#9070)
Selecting a saved community now connects only that destination, and **Find my communities** remains available through **Switcher → Add Community**. Failed destinations are named; completing nested Add/Find opens the selected destination without resuming the outer batch. Fresh hosted account setup now creates and backs up an identity automatically through email sign-in, invites, community links and community creation when the resolver confirms there are no account-bound, backed-up, saved or device identities. Welcome has one email sign-in entry. Authenticated import, existing-key recovery and selection retain their explicit paths; pending import survives restart and attaching a destination. Backup retry retains the same identity. Web binding retains explicit identity creation and its browser-return behavior. Self-hosted setup is separate and explicit. New-key creation still requires platform sealing support; passive account restoration never creates, binds or backs up an identity. **Account tradeoff:** setup cannot discover a key stored only elsewhere and never associated with this account. Such an account receives a new backed-up, bound identity automatically. Importing a different old key afterward fails with a binding mismatch; this flow does not undo that account binding. There is no separate pre-authentication import button. Pending import now survives retained community/web-bind link decoding: internal import metadata is stripped before strict public-link parsing. Once an identity is saved and resolved, the import request is retired before binding or backup, so restart resumes the existing key. Passive restoration also repairs stale import intents written by earlier builds without creating, binding or backing up a key. Validation (`f114c0e228`): - All 30 hosted onboarding journeys pass. Import coverage includes invite-to-community-link retention, restart before import, failed-backup restart, successful-backup restart, and an older stale import intent. - Three isolated production mutations (decode filtering, import completion persistence, passive stale-intent handling) each fail the relevant journey; restored control passes. Earlier auto-creation and nested-selection mutations also failed their relevant journeys. - Carl reviewed both P2 fixes and the final commit with no blockers. Carl and Pinky previously cleared the shared setup behavior and Android parity. - Wes confirmed Android sign-in and fresh account creation on the preceding build `be5690c684`. Android/iOS builds and earlier manual acceptance do not establish native acceptance of the new import/restart fixes. Installed devices remain on that preceding build. **Remaining acceptance:** current-head CI and native validation of the import/restart edge cases. Required publication hooks passed, including mobile analysis/tests, branch-skew and file-size checks. The existing iOS 16 sealing limitation remains: it cannot create a fresh hosted identity. No membership/ownership repair, denial-classification change or floating sign-in-control fix is included. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 | | --- | --- | |  |  | Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
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 | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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:  | Light mode · 375 pt | Dark mode · 375 pt | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
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 |  |  | | Dark |  |  | </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 |  |  | | Channel · dark |  |  | | Thread · light |  |  | | Thread · dark |  |  | </details> Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
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.

🤖 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
|
||
|
|
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> |
||
|
|
49fcab3324 | mobile: refine message avatars, metadata and reply gestures (#9046) | ||
|
|
0dd778f800 |
mobile: start new agents on Sonnet at medium effort (#9034)
## Summary
The mobile create-agent screen opened with model and thinking effort
blank, so the easiest path was picking from the full catalog, expensive
models included. A new agent now starts on the first Sonnet model the
catalog offers, at medium effort when that model supports it. People can
still change either one.
- An agent link that suggests a model still wins when Builderlab offers
that model.
- A setup resumed after a failure keeps the choice it saved.
- If the catalog has no Sonnet model, both start blank like today. If
the Sonnet model has no medium effort, only effort is left blank.
Builderlab doesn't send a default or pricing, so the app matches on
"sonnet" in the model id rather than pinning a version. That keeps
working when the catalog moves to a newer Sonnet.
## Screenshots
Captured from the widget test harness with a catalog listing Opus first,
then Sonnet. The avatar preview is blank because the test doesn't load
network images.
| Narrow, light | Narrow, dark | Wide, light |
| --- | --- | --- |
| 
| 
| 
|
## Testing
- New unit tests cover the default, missing medium effort, no Sonnet,
draft model wins, and a draft model the catalog lacks.
- A new widget test checks the form opens on Sonnet at medium and only
needs a name before Create.
- Full mobile suite passes locally with the CI dart-defines
(`BUZZ_PUSH_GATEWAY_URL`, `BUZZ_AUTH_DOMAINS`) at
mobile-v0.27.0-rc.21
|
||
|
|
f04fa0904c |
fix(mobile): close the community switcher when the org relay refuses the key (#9042)
## Summary Follow-up to #9028 (merged). #9028 closes the community switcher before content loads when organization sign-in is needed, but it checks the session status once. When the session is signed in and the relay then refuses the paired key (`AccountFailure.denied`, which #9001 now saves on purpose), the Home banner offers **Retry** and **Use a different account**, yet the switcher keeps covering it until the 20s "taking longer" timeout. - `organizationSignInPromptVisible()` in `shared/hosted/organization_sign_in.dart` is now the single rule for when the banner has an action. The banner uses it, and so does the switcher. - The switcher listens to the sign-in session and relay auth while it waits for content, and closes as soon as the rule turns true. Listeners are removed on every exit, including the timeout, and the content wait stops listening once the prompt wins. - Relay failures stay scoped to the new community's relay and pubkey, so a stale failure from the previous community can't close the switcher. - `checking` no longer closes the switcher early (the banner is hidden then); a later transition to signed out still does. ### Related issue Related to #9001, #9009 and #9028. No duplicate PR found. ### Testing - New widget test: signed in, switch to an organization relay with content pending, then the relay refuses the key, and the switcher closes. It fails with #9028's one-shot check. - `flutter analyze` is clean. The full mobile suite passes except 10 push and notification tests, which fail the same way on plain `main` on this Mac (local environment; CI covers them). - Pushed with `--no-verify` because of those 10 local failures. 🤖 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> |
||
|
|
ba7d7825ea |
fix(mobile): show organization sign-in after pairing (#9028)
## Summary Show organization sign-in after pairing. The community chooser closes before it waits for content when the organization session needs sign-in. A long press on **Scan a QR code** reveals the pairing-code option. ### Related issue Related to [#9001](https://github.com/block/buzz/pull/9001) and [#9009](https://github.com/block/buzz/pull/9009). No duplicate PR found. ### Testing On an iPhone 17 Pro simulator, paired with the Block relay using a build that includes both related PRs. The app showed **Sign in** on Home. Completing sign-in remains to be verified.  Generated with Codex Signed-off-by: Daniel Rouleau <drouleau@squareup.com> Co-authored-by: Zerra <345e287bf58aae2b5a483f744e2e362b7f7eea918b4c4427f7a1b3494881d475@buzz.block.builderlab.xyz> |
||
|
|
14037e3278 |
fix(mobile): show progress after pairing QR scans (#9009)
## Summary After a pairing QR scan, the app shows connection progress until code entry or a connection error. A WebSocket handshake that remains pending stops after ten seconds. Cancel is available until storage starts. It resets pairing and restores the entry form. Pasted codes remain available after an error or cancellation. Back navigation stays blocked during identity transfer and storage. Cancel on Send identity to desktop returns to Settings, so another attempt starts through the authorization step. ### Related issue Related to [#9001](https://github.com/block/buzz/pull/9001), which covers organization sign-in after identity transfer. This change covers the earlier connection stage. ### Testing A simulator build with PR #9001 completes identity transfer and saves the community. The community chooser still blocks organization sign-in. [PR #9028](https://github.com/block/buzz/pull/9028) covers that step. Physical iPhone testing and successful real-relay checks in light and dark mode remain pending. On a build with this change, scan a desktop pairing QR code. Check that progress appears, Cancel returns to the entry form, and a fresh attempt reaches code entry. Paste a pairing code, cancel during connection, and check that the form keeps the code. Test a failed connection and retry. In Settings, open Send identity to desktop, start a recovery connection, and cancel. Check that Settings appears and another attempt asks for authorization. Camera recognition speed and the reported intermittent phone network failure remain unverified. Generated with Codex --------- Signed-off-by: Daniel Rouleau <drouleau@squareup.com> Co-authored-by: Sol <c35e7cc95f6fdbfb7e861dccc02bdca567b6d6b20f16b5115c3c6ae082bbeede@buzz.block.builderlab.xyz> |
||
|
|
069cb9a0fe |
fix(desktop): hold a deep-linked message against bottom settles (#8742)
## Summary A product fix for the most frequent unfixed desktop E2E flake, `message-target-highlight.spec.ts:79`. A deep-linked message now holds its place against bottom settles until the reader scrolls. This is one of six PRs split from the reviewed `flakey-tests` branch (siblings below). - **Runs:** it failed in all 7 main runs whose SHA carries every earlier flake fix (40969965981, 40970005965, 40970053997, 40980258899, 40980404335, 40980513346, 40983029157). It also failed in 26 jobs since those fixes landed, on main and on PRs #8235, #8239, #8241, #8666 and #8680. Only the two cases that pick `mounted[2]` fail: "same channel, row loaded off screen" (15 jobs) and "other channel, row further up" (11 jobs). The target scrolls in and flashes, then the timeline lands on the newest message. `stillOnScreen` at :179 reads false (twice it was `flashedOnScreen` at :153). ### Cause The failure videos (run 40983029157, Smoke shard 6) show that the thread panel opening is a precondition, not the trigger. A later floor report moved the target: `TimelineMessageList`'s `onScroll` reported within 32 px of the bottom, during or after the older-page load that only a `mounted[2]` target sets off. `onVirtualizerAtBottomStateChange(true)` reset the anchor to at-bottom. Then the split-panel settle, the viewport-resize settle or the next append settle pinned the timeline to the floor. Which Virtua layout pass emits the floor report is still unidentified. ### Change Accepting a virtual route target sets `targetHoldRef` in `useAnchoredScroll`. Only the two `targetMessageId` paths set it, via `holdTarget`. The public `scrollToMessage`, which the oldest-unread pill and find-in-channel use, doesn't hold, and it replaces any earlier hold. While the hold is set: - The split-panel and viewport-resize settles are skipped. - A floor report still updates `isAtBottom` and the new-message count, but keeps the message anchor, so live appends don't settle either. - A commit that changes the newest message sets `isAtBottom` false and clears `virtualizerAtBottomRef`. It adds the rows after the previous newest one to the new-message count. So the "Jump to latest" / "N new messages" pill still appears for rows below the fold (Review-Proven Rule 6). A history page that fills the gap below a cold target raises no pill, and neither does deleting the newest message. - Viewport reflow re-centres the target through `virtualScrollToMessage`. It runs from the resize observer and when the split panel opens or closes. The hold is released by reader scroll input: a wheel, a touch drag, a pointerdown on the scroller itself, or a scroll key. Those listeners already cancel bottom intent in `useVirtualizedBottomSettle`, and this PR shares them as `listenForReaderScroll`. Jump to latest, the reader's own send and a channel change also release it. A release at the floor restores the at-bottom anchor, so the timeline follows live messages again. `onTargetReached` does not release the hold. ### Known limits - Programmatic scrolls (focus moves, `scrollTop` writes) don't release the hold, but a find or unread-pill jump replaces it. A resize after such a scroll re-centres the target. - ChannelPane's composer-height settle still calls `scrollToBottom` within 32 px of the floor, which releases the hold. - Live messages that arrive under a hold near the floor aren't followed until reader input, Jump to latest or the reader's own send. The pill shows them, with their count. - Under the hold, only rows after the previous newest message count. A same-second row that sorts just ahead of it raises no pill until a later row lands. - Agent review raised two optional gaps, and scrolling recovers from both: - If the previous newest message is deleted and a new row arrives in the same deferred commit, 0 rows are counted. - A thread reply sent with "also send to channel" doesn't arm the bottom intent, so it shows as "1 new message" instead of being followed. ### Related issue None found. ### Testing `useAnchoredScroll.lifecycle.test.mjs` replays the CI sequence: accept a route target, clear it, open the split panel, report the floor, resize and append a live message. It asserts no bottom settle, a re-centre on the panel opening and on the resize, and the pill on the append. Further cases cover: - each release path; - a find jump on a held timeline; - a descendant pointerdown keeping the hold; - a gap-fill page and deleting the newest message, neither of which raises the pill. The split-panel and viewport-resize policy tables each gain a held row. Mutations were run over the lifecycle file and both policy tables (29 tests, all pass intact). Each of the 20 mutations fails at least one test: | Mutation | Tests that fail | |---|---| | Hold never set (the old behaviour) | 12 | | Split-panel settle ignores the hold | 13 | | Viewport-resize settle ignores the hold | 13 | | Floor report resets a held anchor | 12 | | No split-panel re-centre | 12 | | No viewport-resize re-centre | 12 | | Reader input doesn't release the hold | 6 | | Held append raises no pill | 2 | The commit message lists the other 12, each failing 1 or 2 tests. `pnpm typecheck` and `pnpm check` pass. All 46 tests in the files that import the touched modules pass. Only hosted CI can show whether the spec now holds in Smoke shard 6 and in Flake Check's repeats, because Playwright was not run locally. It also has to show that `scroll-history.spec.ts` and the other timeline specs keep bottom-follow working. Pushed with `git push --no-verify`, because the pre-push suites fail when the laptop is under load. ### Readiness and human check An agent review of the combined `flakey-tests` branch (b432453af) found no blockers. This changes desktop app behaviour, so it needs a human in-app check before `buzz-review-completed` is added. Suggested check, in `just dev` against a channel with a few pages of history: 1. Open a message link (a mention or search hit) to a message several screens up, once in the same channel and once from another channel. The message should flash and stay on screen after older history loads, including when the thread panel opens or closes, or the window is resized. 2. With the target still held, have someone post in the channel. The timeline stays put, and the pill reads "1 new message". 3. Wheel-scroll to the bottom, or click Jump to latest. The next message that arrives is followed. ### Siblings - #8739 ci: Hermit fetch deadline and sccache fallback - #8740 test(relay): thread_window aux-page hold and `api::media` Postgres tests - #8741 test(mobile): RunnerTests title fade and alert wait - #8743 test(desktop): four specs racing scroll settles and focus - #8744 test(desktop): five settle classes and remote-owned-mentions 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1055f79d04 |
fix(mobile): show a taken channel name plainly and keep invites working (#8807)
## Summary
The relay is starting to refuse a new open channel when another channel
in the community already has that name
(https://github.com/block/buzz/pull/8782). Mobile handled that refusal
badly in two places:
- **Creating a channel with a taken name** showed raw error text:
`Exception: invalid: an open channel named "Launch" already exists`. The
create sheet now shows `An open channel named "Launch" already exists`
and stays open, so the user can pick another name.
- **Joining a community by invite could fail.** After an invite join,
the app joins two starter channels, `#general` and `#welcome-everyone`,
and creates either one if it can't find it. If an archived channel or a
forum already holds one of those names, the relay now refuses the
create, and the whole join failed. Now the app skips a starter whose
name is held by a channel it can't use, and the join succeeds. If the
holder is a live channel the app didn't see at first, the app joins that
channel.
Screenshots from an Android emulator against a local relay built from
https://github.com/block/buzz/pull/8782 are in the Testing section.
## Details
- **Error text.** A new `relayErrorMessage` helper
(`lib/shared/relay/relay_closed_policy.dart`) removes the `Exception: `
wrapper and the relay's machine prefix (`invalid:`, `blocked:`,
`duplicate:` and the other standard prefixes), then capitalizes the
first letter. Only the create sheet uses it. The other error paths are
unchanged.
- **Starter channels** (`ensureStarterChannels` in `lib/app.dart`).
After a taken-name refusal, the app reloads the channel list and looks
for the channel that holds the name:
- A live open stream with that name: join it.
- An archived channel or a forum: skip that starter. An archived channel
can't be joined, and a forum is the wrong type for a starter.
- No visible holder: fail as before. The setup stays retryable, because
the holder may not be visible yet.
- **Which channel counts as the holder.** Only a channel that can hold
the name under the relay's rule counts: open, not unlisted, a stream or
forum. Channels now carry the relay's "unlisted" flag for this. An
unlisted channel with the same name (for example, an unlisted forum the
user is in) doesn't hold the name, so the app doesn't skip the starter
because of it.
- **A starter that was archived.** Desktop and mobile create each
starter at the same fixed id. When the starter at that id is archived,
the relay refuses a new create there with `invalid: channel is
archived`. The app treats that as an archived starter and skips it,
unless a live channel with the name exists. When the relay answers
`duplicate` and the reload doesn't show the channel, the app reads the
channel at the fixed id and skips the starter only if that channel is
archived.
- If `#welcome-everyone` is skipped, the join opens no particular
channel. Both callers already handle that.
### Related issue
None found. Client side of https://github.com/block/buzz/pull/8782.
### Testing
Android emulator, debug build of
|
||
|
|
38a5d9a1ff |
mobile: tap the settings version footer to copy it (#9021)
## Summary The version line at the bottom of mobile Settings (for example `v0.27.0 (36)`) couldn't be copied, which made it awkward to paste into bug reports. Tapping it now copies the whole string and shows a "Version copied" toast, matching the existing "Copy public key" row. It's keyboard-focusable and has a 48dp tap target. Partial selection isn't supported; it's whole-string only.  ### Related issue N/A ### Validation - Added test: tapping the footer, or reaching it with Tab and pressing Enter, puts `v0.16.0 (432)` on the clipboard and shows the toast. The test also checks that the tap target is at least 48dp tall. - `just mobile-check` and `just mobile-test` pass. - Automated simulator run (debug build, iOS 27): opened Settings, tapped the version, confirmed via `simctl pbpaste` that the clipboard held the exact footer text and the toast appeared; then confirmed the neighboring "Copy public key" row still copies the npub and Settings still closes. Human testing waived per the mobile testing exception. A full recording was posted in Buzz instead of here because it shows internal channel names. 🤖 Generated with [Claude Code](https://claude.com/claude-code) buzz-review-completed --------- 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> |
||
|
|
3d26ed7dfd |
feat(relay): match verified names in profile search (#9019)
## Summary `POST /profiles/search` only matches kind:0 `name` and `display_name`. In communities that publish NIP-FI verified profiles, a person's surname or directory username often appears only in their relay-signed kind:30382, so typeahead cannot find them by it. - Where the community advertises verified profiles, the search also matches the `display_name`, `username` and `full_name` tags of each current kind:30382 head signed by the relay key. - Verified names need a trailing prefix of at least three characters, as in NIP-50, so a short prefix doesn't make the relay rank every verified head. kind:0 keeps one-character typeahead. Lookups from heads to `events` also match `created_at`, so Postgres prunes to one partition. - Results are paged by person. Each person returns their current kind:0, when they have one, then their current kind:30382, when they have one, whichever matched. A person is ranked by their best match across both sources, using the existing tiers. - NIP-11 `federated_identity.public_profile` gains `searchable: true`, so clients can rely on the response instead of a separate kind:30382 read. - Migration `0081` adds a GIN expression index over the head's name tags on `nip_fi_profile_heads` (built-in `jsonb_path_query_first` and `to_tsvector`, no new functions). The table holds one row per verified person. Like `0078`, the migration builds the index only while the table is empty; populated databases prebuild it with `CREATE INDEX CONCURRENTLY` (`docs/profile-search.md`), and the migration then checks it is valid and has the expected definition. Compatibility: the request schema is unchanged. Existing clients that keep only kind:0 events work as before and also find people who match only by verified name. Trust stays pinned to the relay `self` key; the event shape follows NIP-85 user assertions, but discovery is relay-advertised rather than kind:10040. ## Testing - `buzz-search` Postgres integration: new `profile_names_match_verified_names` covers forged-key heads, replaced heads, other communities, people without kind:0, per-person grouping, paging, and words split across sources; the existing profile-name tests pass unchanged. The test fails when the author filter is inverted. - `buzz-relay` end to end in enforce mode: `profile_search_matches_verified_names_where_advertised` publishes through the real writer and finds the person by surname only where verified profiles are advertised. - Desired-state and migration index definitions match. - `profile_names_use_verified_name_index` asserts the search scans the new index. Migration `0081` checked against an empty table, a rerun, a populated table without the index (raises), the documented concurrent prebuild, and an index with a different definition (raises). - Latency, 50k people each with a kind:0 and a verified head, release build, local Postgres, best of 5, 30 people per page: 11–22 ms with verified names versus 1–8 ms kind:0 only. `EXPLAIN` shows a bitmap scan on the new index. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
83d0bd3203 |
fix(mobile): stop QR pairing to organization relays from hanging (#9001)
## Problem Pairing a phone to an organization relay by QR (for example Block's `buzz.block.builderlab.xyz`) never finishes. After the codes match and the phone confirms, it sits on the loading surface. About 2 minutes later it returns to the welcome screen with "Lost connection to pairing relay." The desktop stays on "Finishing pairing on your phone…". This reproduces every time on Buzz 0.27.0, iOS 27.2, with desktop 0.7.7 (internal tracking: AIDA-2420). There are two causes: 1. **Validation can never pass for organization relays.** After the payload arrives, `_validateCredentials` opens a plain `RelaySocket` without a `Nostr-Federated-Identity` assertion. Organization relays refuse that upgrade with HTTP 401 until organization sign-in, which a freshly pairing phone hasn't done. 2. **The failure hangs instead of erroring.** `RelaySocket.connect()` catches the failed `ready` but keeps `_channel`. The validator's `finally` then awaits `disconnect()` → `sink.close()` on a channel that never opened, and that never completes. The phone waits until the pairing relay's 120 s connection cap closes the socket, and `_handleDisconnected` reports that as a lost connection. Standalone repro with `web_socket_channel` 3.0.3 against the real relay: ``` ready failed: ... was not upgraded to websocket, HTTP status code: 401 sink.close() DID NOT COMPLETE within 30s (hang) ``` ## Change - `RelaySocket.connect()` drops a channel whose upgrade failed. - `disconnect()` waits at most `RelaySocket.closeTimeout` (2 s). An upgrade that is still pending (connect timeout 10 s) can outlive pairing's 8 s validation deadline, and its close never completes either. - `PairingNotifier` skips the pre-sign-in check for `OrganizationEnvironment.protects` relays. Admission is left to the saved community's session and the existing organization sign-in banner, which matches how the hosted branch already defers admission. The predicate is injectable (`organizationRelay`) for tests. Other relays are still validated before saving. Trade-off: an identity that the organization relay would reject is now saved, and that rejection only surfaces after organization sign-in. The hosted path has the same property. ## Related issue No existing GitHub issue covers this failure; searched open issues and PRs for pairing relay, lost connection and `_validateCredentials`. Closest: - #8043 / #8060 cover the same `_validateCredentials` path from the other side: a failed connect currently returns normally. They're complementary. With #8060 merged, organization relays would fail pairing with an explicit 401 instead of hanging, and this PR keeps them out of that pre-check. Whichever merges second needs a small rebase in `_validateCredentials`. - #3181 is a different failure in the same flow. ## Testing - New `relay_socket_liveness_test` cases check that `disconnect()` completes in three situations: after a refused (401) upgrade, after a timed-out handshake, and while the upgrade is still pending. - New `pairing_provider_test` case: a malformed key is rejected locally before anything is saved, an organization relay is saved without the relay check, and a non-organization relay is still validated and rejected. - Each new test fails with its fix removed. The failures were "disconnect() hung on a channel that never opened", "disconnect() hung on a pending upgrade", and `validated` being true. - `just mobile-check` and `just mobile-test` pass: 4229 passed, 40 skipped. - **Simulator run (iOS 27.0 simulator, iPhone 18 Pro, Block organization dart-defines) against desktop 0.7.7 on `buzz.block.builderlab.xyz`:** pairing completed (desktop reported the phone paired; identity transferred; community saved) instead of hanging until the pairing relay closed. The app then showed the organization sign-in banner and opened browser sign-in for block.builderlab.xyz, as expected. Okta in the simulator requires FastPass, so the final relay connection after sign-in was not exercised there. - Human device testing is still pending. ## Follow-up - Test on a real phone with an organization build. The simulator couldn't complete Okta FastPass, so the relay connection after organization sign-in still needs checking. --------- Signed-off-by: Spencer Lewis <slewis@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>mobile-v0.27.0-rc.20 |