mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
65e864301d2e51a93003816b45e77cae96be901f
3362 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
65e864301d |
feat: send a User-Agent on relay WebSocket upgrades (#9246)
Relay WebSocket clients in this repo now send a `User-Agent` of the form `<name>/<version>` on the upgrade request. The version comes from the build. | Client | User-Agent | |---|---| | `buzz-acp` | `buzz-acp/<crate version>` | | `buzz-cli` | `buzz-cli/<crate version>` | | `buzz-ws-client` (default) | `buzz-ws-client/<crate version>` | | `buzz-pairing-cli` | `buzz-pairing-cli/<crate version>` | | `buzz-audio-client` (default) | `buzz-audio-client/<crate version>` | | mobile (relay and huddle sockets) | `Buzz-Mobile/<app version>` | - **`buzz-ws-client`**: every connect path sends the default. A `User-Agent` in the caller's `connect_with_headers` headers replaces it, so other consumers can identify themselves without any new API. `buzz-cli` uses this to send its own name. - **`buzz-audio-client`**: keeps a `User-Agent` already on the host-supplied request, and adds its default otherwise. - **Mobile**: `openRelayWebSocket` sets the HTTP client's `userAgent`, which also covers the huddle transport. Startup waits up to 500 ms for the app version from `package_info_plus` before the app runs, so the first socket carries it; if the lookup fails or times out, the value is `Buzz-Mobile`. Each client has a test asserting the header on the upgrade request. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> |
||
|
|
31fa53174d |
Record why relay connections close and quiet close warnings (#9245)
Since bb-public prod moved to `RUST_LOG=error,buzz_relay=info`, two connection-close warnings send about 1.9M lines a day to Datadog. This moves that information to the access log and a metric. - **`close_reason` on the access `close` line.** The first reason recorded wins. When the server has already decided to close (shutdown, a policy disconnect, or a cancellation), the reader defers to that decision, so a peer's reply to the server's Close is never logged as the cause. - Values: `auth_timeout`, `auth_failed` (an AUTH rejected and closed immediately, or a timeout after a rejected AUTH), `missed_pongs`, `client_closed`, `socket_error`, `frame_too_large`, `control_channel_full`, `shutdown`, `server_closed`, `refused` for connections refused before they were served, and `handler_dropped` for a handler dropped before normal cleanup. - A community disconnect logs its own value: `community_archived`, `community_deleted`, `auth_policy_upgraded`, `authorization_denied`, `access_revoked` or `removed`. - **The "NIP-42 auth timeout" and "3 missed pongs" warnings are now `debug!`**, with the same text and fields. - **New counter `buzz_ws_missed_pong_disconnects_total`**, next to `buzz_ws_auth_timeouts_total`. A heartbeat tick that races a close already decided is not counted. Timeouts, ping cadence, the frames sent and when connections close are unchanged. The new infra-free tests are added to the `test-unit` selection and `scripts/run-tests.sh`. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
4bfe60197e |
fix(relay): say "channel owner or admin" in the call_webhook save refusal (#9169)
## Summary
The SEC-006 save gate in `handle_workflow_def` refuses a `call_webhook`
workflow unless the saver is an owner or admin. It looks up the role
with `get_member_role(community, channel_id, …)`, so this is the saver's
**channel** role. The refusal text said only "require the owner or admin
role", which reads as the community owner or admin.
Clients now show the relay's refusal text when a save is refused
(block/buzz-app is about to pass it through as written), so a member
would be sent after the wrong role. The refusal now says:
> forbidden: workflows with call_webhook actions require the **channel**
owner or admin role
- The role check is unchanged.
- The `forbidden:` prefix is unchanged, so clients still treat it as a
permission refusal.
- This now matches the desktop helper copy under the Webhook URL field
("Webhook steps require a channel owner or admin").
No test, fixture, doc or client copy in this repo quoted the old text,
so this is a one-line change.
### Related issue
None found for the save-path wording. #5135 (open) rewrites the SEC-006
refusals on the **trigger** path (`handle_workflow_trigger`) but leaves
this save-path string alone, so the two don't overlap.
### Testing
Not verified locally: no tests, builds, clippy or rustfmt were run, and
the commit was pushed with `--no-verify`. CI is the first check. The
change only edits a string literal.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
8cce12f591 |
feat(mobile): consolidate Solo chat settings in one sheet (#9255)
## Summary Replace the Solo chat header controls with an ellipsis that opens Chat settings, showing the selected agent’s avatar and workspace. Agent and grouped Workspace pickers share one backdrop; closing either returns to settings without flickering. The sheet smoothly resizes to fit each page and respects Reduce Motion. ### Related issue No duplicate found. ### Testing Full pre-push checks pass without exclusions: analysis, formatting, file-size checks, all 4547 mobile tests (67 skipped), push-configuration tests, and secp256k1 tests. Navigation, backdrop continuity, smooth resizing, and Reduce Motion are covered. Updated four stale link-swipe tests to use the leftward reply gesture introduced in #9127. Installed on iPhone Air with saved state preserved. Flutter snapshots with sample data (attached only, not committed): | Chat settings | Workspace | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
8130cd9fc7 |
fix(cli): preserve attachments across successive edits (#9226)
## Summary Editing attachment messages through `buzz messages edit` can remove their files from the effective message. Preserve the latest live imeta set across successive edits and add `--clear-attachments` for explicit removal. Add an SDK builder for complete replacement attachment sets while preserving the existing API. The relay provides a versioned effective-edit lookup on `/query` for kinds 9/40002/40008/45001/45003; media reads fold the same set. It uses the existing normalized-target index, fresh channel authorization, and a primary snapshot. Generic `#e` matching is unchanged. Mixed-case historical edits cannot restore removed files, including when another reference would allow republication. ### Related issue Fixes #3022. Follows #9077. Related open attempts: #3062 and #3065. ### Testing Published head: `c630c4ebf0b20a9618f4844629eec1e666699cbe`, through all mandatory push hooks. The governed-five snapshot passed all four PostgreSQL/HTTP acceptance tests with `RUST_MIN_STACK` unset and 3,333 affected-package tests. The committed test-only array correction passed clippy and the preflight rerun. Independent mutations detected dropped read/lookup kinds, the CLI two-kind split, and preflight ordering, channel and live-edit errors. The published follow-up adds forum preview retention/suppression and a deleted-original preflight fixture; both surviving mutations now fail as intended. Independent review verified the fixes. All four acceptance tests pass; the full DB/relay run had two NIP-FI failures that passed in isolation. The actual standalone MCP workflow passed against this relay and clean local app `5b583b8c69596353bc37e9f3938eca1aabcd081e`: 15 MCP edits, 10 direct publications and 60 member GET/HEAD checks across five isolated blob pairs. Both tested heads are now published. Relay hosted checks report 80 successful and 31 skipped, with none pending or failed; app hosted checks and human upload → edit → clear → edit → GET/HEAD confirmation remain pending. No human acceptance is claimed. ### Rollout The lookup, CLI preservation/clear and media reads use kinds 9/40002/40008/45001/45003. Deploy the relay before releasing the CLI and paired [MCP producer](https://github.com/block/buzz-app/pull/2937). New clients abort without publishing against an unsupported relay. Old CLI edits emit empty imeta and now revoke diff/forum attachments that previously remained readable. Other channel-tagged kinds retain the historical plain-edit path. No additional schema change is needed beyond migration 0086. Readiness remains blocked by mobile edits that omit imeta, the app UI fold that can restore older files, unaudited SDK/direct callers and unmeasured historical exposure. Run `docs/sql/private-media-edit-preflight.sql` for each community; producer updates do not repair signed historical edits. No enforce activation is authorized. `off` skips policy checks, `shadow` changes policy metrics while allowing policy failures, and `enforce` denies inaccessible references. Observe `buzz_media_publication_access_total{mode="shadow",reason="denied"}`. Prefer rolling back the client release while retaining the additive lookup. A relay rollback makes new CLI/MCP edits fail closed; already-published imeta remains valid under the older reference rules. Concurrent edits still use `(created_at,id)` without compare-and-swap. Generated with Codex --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> |
||
|
|
d839d3731a |
fix(mobile): swipe left to reply so swipe-back keeps rightward drags (#9127)
Swipe-to-reply used a rightward drag, the same direction as iOS swipe-back. A rightward drag that started on a message armed a reply instead of going back, and the reply recognizer claimed horizontal drags in either direction once they started on a bubble. Now a message slides **left** to reveal Reply on its trailing side, and the reply recognizer only wins the gesture arena for leftward drags. Rightward drags that start on a message are left to back navigation. The threshold, haptic, rubber-band resistance and thread destination are unchanged. On bubbles that reach the row's trailing edge (your own messages), the Reply feedback is kept on screen beneath the bubble, so the slide uncovers it. ### Demo Before (main): a rightward drag that starts on a message arms Reply and slides the bubble, instead of being left to back navigation. https://github.com/user-attachments/assets/f149cd11-c326-466c-a17b-3e4698708af8 After (this PR): a rightward drag on a bubble does nothing, a leftward swipe reveals Reply and opens the thread, and edge swipe-back returns to the channel. https://github.com/user-attachments/assets/539a5682-1032-452e-a57b-6f27abfe2d64 ### Validation - Focused existing validation: the swipe-reply widget tests were mirrored to the new direction for channel and thread rows, own and incoming messages. They now also check that a rightward drag starting on a bubble reaches an outer horizontal drag handler (a stand-in for swipe-back) without moving the row or opening a reply. The Bestie DM test's reply drag was flipped too. - The full mobile suite (`just mobile-check` and `just mobile-test`) passes. - Automated simulator run (iPhone 17 Pro, debug build): swiped left on outgoing and incoming bubbles (feedback shows on the trailing side, thread opens), edge swipe-back from a thread and channel, and a rightward drag starting on a bubble (no reply). The demo above was recorded on a synthetic private channel (own messages only; incoming-message rows were covered in the earlier run and by the widget tests). Human testing is waived under the owner's standing exception. 🤖 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> |
||
|
|
a7a534d476 |
mobile: long-press a message link for open, copy and share (#9135)
Holding a link in a message used to open the whole-message actions sheet, so there was no way to copy or share just the link. Now a hold on a link opens a small **Link** sheet showing the URL with **Open link**, **Copy link** and **Share link**. Web links share as URLs so the share sheet can preview them; other links (such as `buzz://`) share as text. Open link appears only for destinations the app can open (web and `buzz://` links), so a `mailto:` link offers Copy and Share. Repo, PR and issue chips, which have no mobile destination, also offer Copy and Share on hold. While a finger is down, the link gets a light tint behind it, the same kind of press highlight the message row paints. A hold anywhere else in the message still opens the message actions, and a tap on a link still opens it directly. The link gets its own long-press recognizer with the same hold deadline as the message row. Pointer events reach the deeper recognizer first, so at equal deadlines the link wins the arena before the message row, the same mechanism reaction pills already use to claim their own hold. Rendering is otherwise unchanged: the link widget tree only gains a `RawGestureDetector`, a `Semantics` wrapper and a `DecoratedBox` for the tint, press state lives in the link widget so only the link rebuilds, and the sheet is built only when opened. Open reuses the existing tap path (external browser, authenticated relay-media download, or Buzz deep link). ### Validation Added tests: a journey in `message_content_test.dart`, run with both iOS (200 ms) and Android (default) hold timing, that holds surrounding text (message menu opens, link sheet doesn't), holds the link (link sheet, not the message menu), copies it (clipboard gets the URL), opens it from the sheet and by a plain tap, and checks the press tint appears on touch and clears when the finger moves away. Removing the link recognizer or the tint makes it fail. Another test holds a `mailto:` link and checks the sheet offers Copy and Share but not Open. Swipe-to-reply tests in `swipe_reply_tests.dart` drag a message starting on its link and beside it, with both hold timings, and both open the reply. Full `just mobile-check mobile-test` passes (after rebasing onto main, two `hosted_backup_service_test.dart` tests failed once under load and pass on rerun; this PR doesn't touch that code). Automated app run on an iPhone 17 Pro simulator (iOS 27) signed in to a Builderlab community, in a private QA channel: held message text (message actions sheet, dismissed and composer focus restored as before), held a link (Link sheet), Copy link (toast, simulator pasteboard holds the URL), held again and Share link (system share sheet with the page preview). The simulator's share sheet then stopped responding to dismissal, a known simulator share-extension quirk unrelated to this change. After adding the tint, a second simulator run held the link and captured the pressed state (below). Not covered: Android device run, VoiceOver. https://github.com/user-attachments/assets/6f5bda14-14af-450e-b021-f98ebc072f9b | Held | Link sheet | Copied | | --- | --- | --- | | <img src="https://github.com/user-attachments/assets/86262d85-b745-41b4-8c40-96372d0cec61" width="300" alt="Link held, tinted"> | <img src="https://github.com/user-attachments/assets/7c9fe05a-17a0-47c4-851e-e89456bc4520" width="300" alt="Link sheet"> | <img src="https://github.com/user-attachments/assets/d97c417a-a79b-4dd2-a43e-35ace7ea5552" width="300" alt="Link copied"> | 🤖 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>mobile-v0.28.0-rc.9 |
||
|
|
e86d0e23a3 |
feat(mobile): long-press a Solo chat to open its details sheet (#9163)
Long-pressing a team channel in the Messages list opens a bottom sheet of channel actions. Long-pressing a Solo chat did nothing, so its settings (star, mute, members, move, copy, leave/archive/delete) were only reachable from inside the chat. Now a long-press on a Solo chat row opens that chat's details screen as a sheet, with the same presentation as the team channel sheet: drag handle, close button, 640 pt max width, 70% max height. The content is the existing channel details page rendered without its app bar (`ChannelDetailsPage(inSheet: true)`), so it stays in sync with the full page. Tapping a chat still opens it as before. Solo chats are placed in Solo workspaces, not channel sections, so their details (sheet and full page) no longer show "Move to section…". That row wrote channel sections, which left the chat where it was. Moving a Solo chat between workspaces stays on the chat header's workspace control. ### Demo Long-pressing a Solo chat in the Messages list (Solo filter) opens its details sheet; Star toggles, the sheet scrolls to the lifecycle actions, and the close button dismisses it. Synthetic chat named "QA demo". https://github.com/user-attachments/assets/e8b8a799-be1f-4d4b-b47d-f2209e2b1920 ### Validation - Added test: long-pressing a Solo chat on the Me page opens the details sheet with the chat's name, the sheet scrolls to its lifecycle actions, and the channel-section move is absent. - The full mobile test suite and `flutter analyze` pass. - Full automated simulator run on the final code (iPhone 17 Pro, debug build, signed-in Builderlab account, light and dark mode). In the Solo long-press sheet: Star and Mute toggle and revert; Edit, a member's profile and Add members open and close back to the sheet; Copy channel name and Copy channel ID copy the right values and show a toast; the sheet scrolls; the Leave, Archive and Delete confirmations appear and were cancelled; the close button, scrim tap and drag down all dismiss it; "Move to section…" is absent. Tapping the chat still opens it, and its in-chat details page also omits the section move. Regression check: the team channel long-press sheet and team details page are unchanged, including a working "Move to section…". The recording is in Buzz rather than here because it shows internal channel names. Limitation: destructive actions were cancelled, not confirmed. 🤖 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> |
||
|
|
1a0dae77bf |
fix(mobile): shimmer loading photos in the iOS carousel (#9167)
On iOS, a message with several photos renders them in a native UIKit carousel. While a thumbnail downloads, each cell showed the words "Loading image". Single images and the Flutter carousel already show a shimmering skeleton, so the native carousel stood out. Native carousel cells now show the same shimmer as Flutter's `SkeletonShimmer`: a fill in the theme's primary color at 10% opacity, with a lighter band sweeping across every two seconds. Flutter passes the primary color to the native view along with the brightness. With Reduce Motion on, the fill stays still. Once the thumbnail arrives the shimmer hides. If the thumbnail fails, the cell shows its existing "Image unavailable" message on the plain surface. ### Demo A synthetic private chat with a four-photo message, reopened on a cold launch. To make the loading state easy to see, both builds were patched locally to delay thumbnail delivery by 4 seconds (the patch is not part of this PR). Before (main): each cell shows the words "Loading image" until the thumbnail arrives. https://github.com/user-attachments/assets/3724873d-4f33-4db7-af16-634dce3371ac After (this PR): each cell shimmers in the theme's primary color until the thumbnail arrives. https://github.com/user-attachments/assets/20f65789-de3f-4e13-892f-3b2d9a0461cc ### Validation - `just mobile-check` and the full `just mobile-test` suite pass. - Automated simulator run (iPhone 17 Pro, debug build): posted four photos to a synthetic private chat from the app's photo picker, relaunched, and opened the chat on both main and this branch. Before shows "Loading image"; after shows the shimmer, which gives way to the photo when it arrives. Limitations: light mode only, and the 4 second thumbnail delay was a local patch to make the state observable. Dark mode, Reduce Motion and the failed-thumbnail state were not exercised. 🤖 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> |
||
|
|
f3b127e3af |
fix(mobile): keep Home bell tappable while scrolled (#9241)
## Summary Keep the mobile Home bell tappable while scrolling. Reserve both header buttons’ space as soon as the filters overlap the toolbar, and clip filter overflow. ### Related issue No matching open issue or PR found. ### Testing - `just mobile-check` and `just mobile-test` passed. - Android/iOS regression coverage checks the bell at five scroll positions and Activity navigation. iOS uses platform-view hit testing plus its callback; no physical-device verification. - Pre-push checks passed. The additional `just ci` run passed workspace Rust lint and desktop source checks; stopped during desktop native dependency compilation, so full local CI is incomplete. Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
12f3d51001 |
feat(mobile): one Builderlab per build, internal and public distributions (#9224)
The mobile app can now be built as a public build or an internal build, set explicitly with `BUZZ_DISTRIBUTION=internal|public`. Each build trusts exactly one Builderlab (`BUZZ_BUILDERLAB_URL`), and sends that Builderlab's relay assertions only to the relays in `BUZZ_BUILDERLAB_RELAYS`. The three settings are set together or not at all; with none set, the app is a Nostr-only development build. The decision is recorded in `docs/adr/0001-internal-and-public-mobile-builds.md`, and the terms are defined in `CONTEXT.md`. Before this change, one build supported a public Builderlab and an organization Builderlab at the same time, with two sessions. An organization's members could reach public communities from the organization's app. ### Behavior - **Public build:** QR pairing, email sign-in and multiple communities are unchanged. It has no knowledge of any organization. An organization's relay already rejects clients that can't present its Builderlab's assertion, and the public build relies on that. - **Internal build:** the first screen has a single **Sign in** button that goes straight to the organization's single sign-on and then joins the organization's community. There is no QR pairing, community link or community creation, and the community switcher is hidden. Only the exact relay hosts listed in `BUZZ_BUILDERLAB_RELAYS` are reachable. - **Identity (internal):** a member whose account has no Nostr identity yet gets a new key on first sign-in; the first relay assertion binds it, and the app backs it up to the organization automatically. A member whose account is already bound uses that key from this device, or restores it from the organization's backup. If neither is possible, Sign in says it couldn't restore the identity and saves nothing. - **Sign-out (internal):** a destructive **Sign out** row at the bottom of Settings, the build's only way out. It removes the community, closes Settings and returns to Sign in. The Builderlab session is cleared from storage immediately and can never be reused to sign back in. The relay push-stop keeps the retired credential in memory only, for up to 10 minutes, and uses it only to get an assertion for a key that is already bound. It is a best-effort request. - **Upgrade to an internal build:** a saved public Builderlab session is cleared. Communities on other relays are hidden, not deleted, and their keys stay on the device. - `BUZZ_HOSTED_RELAY_AUTH` is unchanged here. ### Screenshots (internal build) | Sign in | Community menu | Settings | | --- | --- | --- | |  |  |  | Gateway-side push revocation, which needs no Builderlab session, is planned as a separate PR. ### Validation Added tests: - An internal-build journey: one Sign in creates and binds a key, and a retry after a cancelled join reuses it. The app uploads that key's backup itself. Sign out from Settings closes it and returns to the landing, which ends the session, and Sign in then restores the same identity from the backup. Communities on other relays stay stored but hidden. - Internal Sign in for a bound account: the key on this device, a key restored from backup, and no backup (clear error, nothing saved). Each guard was shown to fail when removed. - Organization relay assertions: assertions go only to exact listed hosts, not subdomains. After sign-out, only a push stop for a bound key gets an assertion, and nothing can sign back in. Proven to fail without the fix. - Builderlab build setting parsing. CI runs the internal and public journeys in their own lanes with those settings. The full suite passes in the default configuration. The full suite isn't meant to run under the internal configuration, because most fixtures use relays that an internal build hides. Automated full-app run on an iPhone 17 Pro simulator at the current head: - Public build, upgraded in place over a signed-in install: home loads, a channel opens and a message sends. - Internal build: a fresh install shows only Sign in. With a saved organization community, the community menu has no Switch community, and Settings → Sign out → confirm returns to Sign in. Real single sign-on can't run in the simulator. 🤖 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.8 mobile-v0.28.0-rc.7 |
||
|
|
794ee64183 |
fix(ios): mint hosted NIP-FI assertions in the notification extension (#9228)
Share the hosted Builderlab session with the iOS notification extension so it can mint hosted NIP-FI assertions. The extension queries a hosted relay without an assertion first and mints only after a NIP-FI challenge, for a key the account is already bound to. Credential headers never follow redirects, and flag-off builds clear a previously shared session. Signed-off-by: Tom Brow <tomb@squareup.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>mobile-v0.28.0-rc.6 |
||
|
|
b58ee08404 |
fix(mobile): request hosted relay assertions only when NIP-FI is required (#9225)
Port the desktop NIP-FI requirement rule to mobile: hosted relay connections request an account assertion only when the relay's NIP-11 document requires NIP-FI, so communities that have not enabled it keep connecting with BUZZ_HOSTED_RELAY_AUTH on. Signed-off-by: Tom Brow <tomb@squareup.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
96af488d96 |
fix(media): revoke private channel attachment reads (#9077)
## Summary Removed private-channel members can currently download attachments if they know the URL. With `BUZZ_MEDIA_READ_MODE=enforce`, fresh GET, HEAD and Range requests check current membership and readable references on the primary database. Removed members receive 404; remaining members retain access, and rejoining restores access. Thumbnail aliases use the parent attachment’s permissions. Publication checks also prevent copied private URLs from granting new access. They cover attachments, link previews, profile pictures, banners, group pictures and emoji. Persona creation and changed-avatar edits, plus team and individual snapshot imports, transfer animated posters and animations under the agent’s signer before profile publication. Failed persona transfers preserve existing local records; import failures appear in the results. Authorization uses bounded queries, a two-second statement deadline and transaction-local JIT suppression. An index on edit targets avoids broad scans. Publications have a 512-reference limit, and capacity waits up to 500 ms before refusal. Protection defaults off. Existing downloaded bytes cannot be recalled. All modes stop creating immutable HTTP cache entries. ### Related issue Related approaches: #7565, #5185, #8451 and #8444. This change evaluates live stored references directly. ### Testing Published head: `3d1aa7431cc495a001a0f13e95e2e422c5a77ab1`, rebased on main `2cd40badb85de059a6916206e133da7a0a88fb2e`. Push hooks were **SKIPPED** with explicit author approval. Current-head HTTP, full package, PostgreSQL migration/media and profile-filter mutation checks are pending; CI is pending. The earlier complete HTTP journey passed at `f20b449262` (identical source tree), with its evidence attributed separately. Earlier failing preview/report-fixture runs are preserved; they do not count as passes. The HTTP journey covers upload/publication, fresh GET/HEAD/Range revocation and rejoin, emoji replacement, edited previews, persona visibility and non-reference decoys. The migration regression forces an index timeout, verifies no 0086 history or media tables, then proves a migrator through the base's 0085 can restart. Human attachment removal/rejoin testing passed on the pinned `1f08a8e0fd2edf19dc94321f7258518149fe1bf6` trial. Native animated persona edits and team/individual import acceptance remain incomplete. That old-snapshot database stays intact and cannot run the reconciled binary; a new trial needs a separate fresh database. ### Rollout prerequisites - Build and verify the reference index before shadow mode. Small databases can auto-migrate through one atomic 0086 migration within the configured timeouts. It builds the index before creating the media tables, so failure records no new migration version. Large deployments must disable auto-migration and prebuild indexes concurrently. - **Enforce remains blocked unless the operator can rule out SDK/CLI attachment edits and the unverified agent-harness edit producer.** SDK/CLI edits still omit attachment metadata. That behavior is deferred, not fixed. Removing this blocker requires latest-effective attachment metadata across successive edits, no restoration of removed files, and an upload → successive edits → GET/HEAD regression. - Validate client signer compatibility and split emoji publications above 512 references before enforce mode. - Complete the human workflows above in an isolated community with enforce enabled. - Follow the [index deployment guide](https://github.com/block/buzz/blob/honor/private-channel-media/docs/private-media-index-deployment.md) and [operator guidance](https://github.com/block/buzz/blob/honor/private-channel-media/docs/operator-private-media.md) for rollout and rollback. The [prebuild timeout finding](https://github.com/block/buzz/pull/9077#discussion_r4486221478) and [avatar-transfer finding](https://github.com/block/buzz/pull/9077#discussion_r4486221458) are implemented and independently reviewed. ### Schema change checklist - [x] Additive expand and code deployment; no backfill or contract phase. Prebuild/verify before shadow under either schema deployment path. - [x] No SQL function, trigger, procedure, rule or foreign key added. Signed events are untouched. - [x] No migration mutates existing rows. - [x] Migration and desired-state schema match. Catalog parity, target parsing, future partition inheritance and upgrade refusal are covered. - [x] Old/new binaries coexist for serving. Suspend community deletion during expansion and while rolled back to a binary with the old deletion catalog. - [x] Keep auto-migration disabled on old binaries after schema expansion; sqlx rejects unknown versions. - [x] Migration lock timeout 1 s; statement timeout 5 s. Small populated databases build inline within those budgets; large deployments use concurrent child-index prebuild/attachment with auto-migration off. Missing, invalid or wrong indexes fail verification. Generated with Codex --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> |
||
|
|
2cd40badb8 |
Manage community NIP-FI policy with enable, disable and status (#9176)
## Summary Operators can enable, disable and inspect a community's stored NIP-FI requirement through signed POST/GET `/operator/communities/nip-fi-policy` and `buzz-admin nip-fi-policy`. This replaces the old enable-only endpoint and drops the `nip-fi-upgrade` command added earlier in this PR, along with obsolete compatibility code. Each saved intent binds its desired state and observed generation/state. Every accepted new intent advances the generation, including same-state changes. Committed retries recover history without reapplying policy; stale requests are refused. Current status exposes one pair of reusable `expected_*` preconditions. Historical receipts remain readable after policy changes or target deletion, including old rows with null preconditions. Their UUIDs cannot be reused for new requests. Migration 0085 stays additive; main’s default-channel recovery migration retains version 0084. It preserves old receipt rows and INSERT compatibility. Operator instructions cover use, recovery and client migration: [usage guide]( |
||
|
|
39d5159caf |
[buzz] Recover interrupted default-channel auto-joins (#9045)
**Bug** Default channels are channels that new community members should join automatically. If the database could not list those channels—for example, because its connection pool was busy—an invite claim or an in-app admin add still admitted the member to the community. That member could then remain outside the default channels permanently. **Cause** Community admission was saved before channel joins started. If listing the channels failed, the relay logged the error and stopped. Retrying the invite or admin add did not help: the member already existed, so the relay skipped the joins. **Fix** Save a marker for unfinished channel joins in the same database transaction as admission. If listing or joining channels fails, keep that marker. Retry when the member next authenticates, claims the invite again, or is added again by an admin. One channel's failure does not stop attempts to join the others. Retries preserve the existing membership rules: - A member who leaves a channel during recovery stays out of it. - Work from an earlier admission cannot add channels or clear pending work after the member is removed and admitted again. Each admission has a unique marker, checked under a database lock. - A reconnect still checks whether the member allows the original admin to add them to channels. - Once recovery finishes, later connections do not enroll the member in newly designated default channels. The operator command `buzz-admin add-member` continues to add community membership only. **Limits and rollout risks** Recovery needs one of those later requests; there is no background retry timer. This change does not repair members missed before it was installed. Channel announcements and notifications keep their existing best-effort behavior. A failure after channel membership is saved can still lose an announcement; this change does not replay it. Migration 0084 adds an optional UUID column to `relay_members`. Apply it before running the new code. Older relays ignore the column. The migration has a five-second lock timeout, so it fails rather than waiting indefinitely for its table lock. It adds no triggers, functions, procedures, rules, foreign keys, or data backfill. --------- Signed-off-by: Helen Tian <htian@squareup.com> Co-authored-by: Flipper <1ef64189596befbc8b93f9021bff35ae5d02b155d9e05fc124bca7349927d597@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com> |
||
|
|
38795ada87 |
fix(db): keep retained fence proofs when a probe fails (#9200)
## Summary
A failed replica fence probe no longer drops the proofs already
retained, so one failed probe no longer sends every read to the writer.
**Why.** `run_probe` called `fence.close()` on any probe error, which
cleared the ring. Until the next successful probe 500 ms later,
`fence.newest()` was `None` and every routed read went to the writer
with `reason="uninitialized"`. Under load this showed up as a steady
share of `bridge_query` reads labelled `uninitialized` on every pod,
while `heartbeat_age` stayed small. The module already documents that a
retained entry's proof never decays: a failed probe proves nothing new,
but it doesn't make an earlier proof wrong. So clearing the ring only
cost reader capacity.
**What changes.** A failed probe logs and records nothing. The retained
entries stand. A probe that keeps failing still closes the fence through
the existing 30 s staleness gate (`FENCE_STALENESS`), and
`heartbeat_age` remains the liveness signal. Regression handling is
unchanged: an epoch change or a same-epoch token regression still clears
the ring in `record()`. `close()` has no production caller left and
becomes a test hook.
**Likely trigger.** The masked-activity check
(`ProbeError::MaskedActivity`) counts a client backend whose state is
`active` with a `NULL` `xact_start` as masked. A backend that has just
started a statement can briefly show as `active` before its `xact_start`
is set, so busy writers hit this transiently. Under pgbench on Postgres
17, between 0.3% and 1.3% of scans saw such a backend. This PR doesn't
change that classification. See the debt below.
No protocol, schema, metric or client change.
### Testing
- `replica_fence::postgres_tests::failed_probe_keeps_retained_proofs`
(new, no database needed, so it runs in the unit lane like its
neighbours): records an entry, runs a probe tick against a closed writer
pool (the probe fails), then asserts `newest()` and `resolve()` still
return the entry, and that a rewound token recorded under the same epoch
after the outage is a `TokenRegression`. Mutation-checked on Blox:
putting `fence.close()` back on the error path fails it, including when
only the regression assertion is left.
- On Blox: the full `buzz-db` Postgres lane, the `buzz-db` unit tests,
`cargo fmt`, clippy for `buzz-db` and `buzz-relay`, and a `buzz-relay`
build.
### Rollout
There's no flag. Rollback is a revert. After deploy, watch:
- `buzz_db_route_decision{decision="writer",reason="uninitialized"}`
should drop to about 0 once a pod has booted, and the
`decision="reader"` share should rise.
- `buzz_db_replica_heartbeat_age_seconds` should stay small. Staleness
is now the only thing that closes the fence on probe failure, so this is
the liveness signal.
- `reason="stale"` may rise a little. `newest()` returns `None` for an
empty ring and for one older than the staleness gate, and both are
labelled `uninitialized`. Bounded reads can also miss the budget for one
tick when a probe beat is missed and fall back as `stale`. Some of
today's `uninitialized` share is expected to move to `stale` rather than
disappear.
## What got simpler
`ReplicaFence::close()` leaves the production API. A probe error now has
one effect (log and keep proving from what's retained) instead of a
second path that discards valid state. The probe loop is split into a
one-tick function that the test calls directly.
Identified, out of scope:
- `buzz_db` warnings, including this probe's failure warning, are
dropped by the relay's default log filter (`buzz_relay=info`), so
operators can't see why a probe fails.
- `MaskedActivity` misclassifies the transient
`active`-without-`xact_start` state. Treating it as not-open looks
sound, because such a backend's transaction starts after the scan, but
it touches the proof and deserves its own review.
- The probe's exclusive advisory lock waits behind channel writers'
shared locks, so a slow writer delays the probe.
- Probe failures have no metric. With retained proofs a failed probe no
longer shows up as `uninitialized` routes, and its warning is filtered
out of the logs (above), so intermittent failures are now invisible. A
probe-failure counter would fix that.
- An epoch rotation that fails still leaves an opening for a restore
that keeps the epoch. `TokenRegression` clears the ring, so if the
rotation `UPDATE` then errors, the next sample stores the rewound token
under the old epoch against an empty ring. This PR doesn't change that
path. A writer-side high-water token kept outside the ring would close
it.
- `newest()` gives the same `uninitialized` label to an empty ring and a
stale one, which hides a dead probe in route metrics.
🤖 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>
|
||
|
|
597a8d1970 |
feat(mobile): rename Activity and add header filters (#9213)
The UI-only part of #9186 by @klopez4212, split out so it can land without the Activity sync changes. - The Inbox page becomes Activity, with header controls instead of menus: a type menu (All, Threads, Mentions, DMs, Reminders, Drafts), a sender menu (Anyone, People, Agents) and an Unread toggle. All of them filter the feed the app already loads. - Activity and Search get a top edge fade. Bulk mark-as-read and the "New" divider are gone. - DM thread replies group with their DM row, so the DMs filter includes them. Left out, for a separate sync PR: the Activity history cache, subscriptions to every joined channel, the refresh coalescer, thread-follow evidence, Reactions and Solo rows, and the read-state changes. The Activity provider and read-state writes are unchanged from main. One fix on top: the iOS filter labels pass an unscaled font size, since the native button already applies Dynamic Type. Before, large text was scaled twice. ### Validation - Kenny's added tests for the header controls (`tabs_journey`) and `TopEdgeFade`, adjusted for the removed Reactions option. Added tests for the DM filter, marking a DM row with replies under two thread roots read, and sender filtering per event. - Full mobile suite: see the latest CI run. Locally, 11 push and notification-toggle tests fail identically on an unchanged checkout. - Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining this PR with its sibling split: Home rows measure 44 pt, the bell opens Activity and shows its dot after a row is marked unread, type/sender/Unread filters each narrow the feed, a row opens its conversation, Search results scroll under the fade, and filter labels fit at the largest text size. Screenshots are in Buzz rather than here because they show internal test data. Android was covered only by widget tests. ### Before merge - [x] Automated simulator run of Activity filters, row taps, read and unread, and Search, with screenshots. 🤖 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: kenny lopez <klopez4212@gmail.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>mobile-v0.28.0-rc.5 |
||
|
|
6c3ffd160b |
fix(relay): set received_at in the system-channel fan-out test helper (#9214)
## Summary `buzz-relay` lib tests fail to compile on `main`: `error[E0063]: missing field received_at in initializer of buzz_pubsub::ChannelEvent` at `crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs:1549`. This is a semantic merge conflict. #9195 added `ChannelEvent::received_at`, and #8519 merged a minute later with a test helper that builds a `ChannelEvent` without it. Neither PR's CI saw the other. This sets the field to `tokio::time::Instant::now()`, as every other initializer does. It breaks clippy (`Rust Lint`) and the backend-integration test archive for every open PR, because CI builds the merge with `main`. ### Testing - `cargo fmt --check` and `cargo clippy -p buzz-relay --all-targets -D warnings` on a cloud workstation at this head. ## What got simpler Nothing; a one-field compile fix. 🤖 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> |
||
|
|
beec055300 |
feat(mobile): move Activity to a Home bell and align Home spacing (#9212)
The UI-only part of #9193 by @klopez4212, split out so it can land without the Activity sync changes. - Activity moves from a row in the Home list to a bell button in the Home header, with an unread dot driven by the existing Activity feed and read state. - Home filter capsules match native iOS and Android widths, with a subtle outline on Android when the header collapses. - Home section spacing is tighter. Left out: the reconnect rewrite in #9193's last commit (live events applied in place, no rebuild on reconnect, fatal fallback reads). The Activity provider is unchanged from main. Channel and Solo rows keep their bottom padding so they stay at the 44 pt minimum tap height. ### Validation - Kenny's added tests: `inbox_unread_provider_test` (unread dot) and the Home page bell and filter width tests. - Full mobile suite: all pass except 11 push and notification-toggle tests that fail identically on an unchanged checkout in this environment. - Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining this PR with its sibling split: Home rows measure 44 pt, the bell opens Activity and shows its dot after a row is marked unread, type/sender/Unread filters each narrow the feed, a row opens its conversation, Search results scroll under the fade, and filter labels fit at the largest text size. Screenshots are in Buzz rather than here because they show internal test data. Android was covered only by widget tests. ### Before merge - [x] Automated simulator run of Home, the bell, Activity and channel navigation, with screenshots. 🤖 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: kenny lopez <klopez4212@gmail.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
446e9e0762 |
Allow operators to set initial NIP-FI policy on create (#9211)
Trusted operators can choose `initial_requires_nip_fi` on create-only community provisioning. True commits required policy (generation 1), validated issuer bindings and the owner with the new community in one transaction. Omitted, null or false preserves optional creation; a non-null field in legacy ensure mode returns coded 400. Missing effective bindings refuses required insertion without leaving a row. The response reports policy read from the stored row. Same-owner create-only replay now accurately reports `existed` instead of `created`; it stays successful and read-only, including after policy disable or issuer-config changes. Different-owner conflict, legacy ensure, bootstrap and the operator authorization gate retain their behavior. This adds no schema changes or server-wide default flag. Upgrade the whole serving relay fleet before callers request true. An old writer may ignore the field and insert an optional row: response checks detect it but cannot undo it, and later lookup remains read-only. Turn caller targeting off before relay rollback. Mode off stores policy but bypasses enforcement, independently of the upgrade gate. ### Related issue Replaces closed #9172. The separate lifecycle API remains in #9176. ### Validation Draft head: `bf16a1e0aba102c3be86f9621bd53d2cae98661c`. Independent source review covers this head. Formatting, diff sanity, Clippy, build, all 14 signed HTTP scenarios and full local `just ci` pass at this head. GitHub reports 59 successful and 38 skipped checks, with none pending or failing in the collected snapshot. At parent `b9d9423a6058387f42c310f5a7e4dbe0c8d8b40f`, all 1,978 package tests and 1,346 PostgreSQL tests passed (PostgreSQL concurrency 2). Four deliberate mutations were detected and restored. Clippy then found seven needless borrows in the new HTTP test; this head removes those borrows only. Earlier validation passed 1,344/1,346 PostgreSQL tests at concurrency 8; the replay assertion was corrected and the timeout disappeared at concurrency 2. Failed-run evidence is retained. At this head, the first mandatory push-hook attempt failed all nine boot-lifecycle tests on child startup deadlines. A subsequent serial run passed all nine (seven PostgreSQL-only cases skipped), with unchanged deadlines and source. The first diagnostic was interrupted during compilation without a result; all evidence is retained. A normal-hook retry was stopped when the author explicitly authorized one push with `--no-verify` for this exact commit. That exception skips local pre-push hooks for publication only; failed hooks and remaining validation still gate readiness. Explicit human runtime confirmation remains a separate acceptance step; agents have not attested it. Generated with Codex Validation update at the same draft head: Clippy, build and all 14 signed HTTP workflow scenarios passed, including atomic required creation, stored-policy replay after disable without a current binding, and refusal without leaving a row. The first live harness run stopped after 12 checks because its SQL helper fetched rows after an UPDATE without RETURNING; that harness query was corrected and the complete workflow passed. Relay source stayed unchanged. The full local `just ci` run failed in `mobile-check`: Dart formatting reported 266 changes among 1,068 files, with unresolved `flutter_lints` configuration warnings. The format check used `--output=none`; the worktree remains clean. Repository validation is therefore not green. That failure is retained as historical evidence; see the successful setup-corrected rerun below. Explicit human runtime confirmation remains separate. Final local validation: after `just mobile-install`, `mobile-check` passed without formatting edits, and full `just ci` completed successfully at `bf16a1e0aba102c3be86f9621bd53d2cae98661c`. Tracked files remained clean after installation and after the complete run. This confirms the earlier mobile formatting failure resulted from missing worktree dependencies. All failed runs remain preserved. GitHub's current ready-for-review state is preserved; no agent added `buzz-review-completed` or attested human runtime acceptance. --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@buzz.block.builderlab.xyz> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> |
||
|
|
e670b672b8 |
perf(loadgen): spread seed writes across channel creators (#9206)
## What `buzz-loadgen seed` signed every channel create, member add, and thread root with the community owner. In private channels without human members, the owner also signed all the history. The relay limits HTTP calls per pubkey (default 300 a minute), so a community with thousands of channels can't be seeded against a relay that keeps its default limits. The seeder doesn't retry a 429, so the seed fails partway, and a torn-down `run_id` can't be reused. Take one community with 20,000 private channels: the owner alone signs more than 40,000 writes, which is over two hours of its quota. Human authors had the same problem on a smaller scale. A human in hundreds of consecutive private channels signed hundreds of history posts within seconds. Now the community owner creates its first 8 channels, and each further block of 8 gets its own creator identity (`Role::Creator`, derived from `run_id`). A creator signs its channels' kind:9007 and kind:9000 events and their thread roots, and `run` looks roots up by the creator. That keeps every creator well under one minute of quota, whatever the community's channel count. History and replies still rotate across humans: a private channel's own human members, or its creator when it has none. Because only humans look roots up, the creator signs nothing but roots in their channels. No amount of history or replies can push a root out of `run`'s newest-100 lookup. Human signers can still bunch up, for example a human in hundreds of consecutive channels or a deep thread. To cover that, a seed write the relay throttles now waits out the `retry in <n>s` window the relay names and tries again, up to 10 times, before it counts as failed. Compatibility: a community with up to 8 channels is seeded as before, apart from the retries. An existing fixture with that shape keeps working with `run`, including its root lookups. ## What got simpler Nothing got simpler. This adds a creator role and a retry loop, which is the smallest change that makes large fixtures seedable against default limits. ## Validation - `cargo fmt --check`, `cargo clippy -p buzz-test-client --all-targets -D warnings`, and `cargo test -p buzz-test-client --lib loadgen`, run on a Linux workstation at the head commit. - `no_channel_creator_signs_more_seed_writes_than_one_minute_of_its_quota` seeds a private community of 400 channels against a fake relay. The community has wide channels, a tier of humans in 280 channels each, and runs at 2x. The test requires every creator to sign fewer than 300 writes. With the owner signing everything, it fails: the owner signs all 7,212 writes. - `throttled_writes_wait_out_the_quota_and_a_lasting_quota_fails_the_seed`: when every write gets two 429s it is retried and the seed passes. When the quota never clears, each write is tried 11 times and the seed fails with the quota error. - `private_channels_add_every_member_before_anything_is_posted` now uses 20 channels. It checks that every create, member add and root is signed by its channel's creator, that the owner keeps channels 0–7, and that every other post is by a human member. - `root_lookups_search_the_channel_creators_posts` checks that `run` searches for roots by the owner in a community's first block and by the block's creator after it, across two communities. 🤖 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> |
||
|
|
a17243d846 |
feat(relay): runtime-tunable history-read and AUTH concurrency caps via feature flags (#9170)
## Goal Give operators a way to relieve database pressure in seconds, without restarting relay pods. Today every concurrency limit is an environment variable, so changing one means a rollout. During an incident, that rollout's restart sets off a reconnect storm of its own. ## What changes **Two runtime-tunable caps.** Each is read from an integer feature flag in a new process-wide evaluation context, and re-read every 5 s: | Flag | Caps | Ceiling | |---|---|---| | `buzz.relay.concurrency.history-reads` | subscription-history reads holding a reader connection (the #9132 gate) | the existing `pool − pool/5` share, which keeps readers free for authorization | | `buzz.relay.concurrency.auth` | verified NIP-42 AUTH frames running their database checks | none (unbounded today) | - **Flags can only tighten.** A positive value is clamped to the ceiling. Zero, a negative value, no value, or a provider that never initialized leaves the gate at its ceiling, which is today's behavior. Once LaunchDarkly has delivered values, they stay in force through a later outage, so a cap tightened during an incident survives a provider blip. - **Lowering never cancels admitted work.** A lowered gate admits nothing new until the work in flight falls under the new limit. - **Visibility:** `buzz_concurrency_limit{gate}` reports the applied limit (`0` means no limit), and each change logs a line. **One resizable gate.** `HistoryReadGate` becomes `buzz_db::ConcurrencyGate`, a FIFO semaphore whose limit can move anywhere in `1..=ceiling`: - Lowering it forgets free permits and records any shortfall as debt. - A permit released while there is debt is retired instead of returned. - Raising it repays debt first, then adds permits. All of this, and admission itself, settles against the debt under one lock, so a newer limit always supersedes an older one; there is no background resize task to race. Tokio hands a released permit to a queued waiter before it runs; if that waiter is then cancelled, tokio returns the permit to the semaphore directly, so admission retires any permit it gets while debt is outstanding. The AUTH cap uses the same type. **AUTH shedding.** The permit is taken after signature verification, so only real logins queue for the database checks. The wait is bounded at 2 s. A shed login gets a bare close with no `OK`, exactly like an auth timeout. I chose this from what each client does on main today: - **`OK false`:** the desktop app retries on an unjittered 60 s ladder. Mobile parks the session until the app resumes. The agent harness fails at startup. - **Bare close:** the desktop app, mobile, the agent harness and the CLI all reconnect on their jittered, capped backoff. On the relay, the shed is visible as access line `denied/concurrency_limit` and as `buzz_auth_outcomes_total{outcome="concurrency_limit"}`. **The feature-flag provider is chosen at startup.** `BUZZ_FEATURE_FLAGS_PROVIDER` takes `static` (the default), `environment` or `launchdarkly`, with `BUZZ_LAUNCHDARKLY_SDK_KEY` and an optional `BUZZ_LAUNCHDARKLY_RELAY_PROXY_URL`: - **Exactly one provider.** The value is parsed into a single provider, so there is no stacking and no fallback, and the evaluator never changes for the life of the process. The variable sits outside the `BUZZ_FEATURE_FLAG_` namespace, so it can never be read as a flag. - **Startup failures.** Local misconfiguration fails startup: an unknown provider, a missing key, or a malformed proxy URL. LaunchDarkly being unreachable, or rejecting the key, does not. The relay starts on declared defaults, the SDK keeps connecting, and `buzz_feature_flags_ready{provider}` shows whether the provider has initialized. A revoked key must not crash-loop every pod over a tuning knob. - **Every build carries the LaunchDarkly adapter.** It stays inert unless selected, so one published image serves every deployment and each deployment's configuration pins its provider. ## Design-doc amendments (`crates/buzz-feature-flags/README.md`) - **Provider selection.** The compile-time "exactly one provider" contract becomes the startup selection above. The only-one guarantee now holds per process instead of per build. - **Startup.** Failing closed now covers local configuration only. - **Guardrails.** "Flags may choose between equivalent implementations only" gains one exception: a flag may set an operational limit as long as it can only tighten it, and shedding under that limit stays retryable. - **Evaluation context.** `EvaluationContext::for_process()` adds a process context, which LaunchDarkly sees as a single `relay`-kind context keyed `relay`. ## What got simpler - **Removed the compile-time provider contract:** the nested fixture crate (with its own 2.8k-line `Cargo.lock`), its 229-line contract test, and the exact-one `compile_error!` guards it required consumers to copy. One startup parse plus a unit test replaces them. - **One gate type serves both caps.** The history-read gate became that generic type instead of gaining a sibling. - **Net change:** about −2.3k lines. ## Review-Proven Rules applied - **#1:** shedding is retryable (`PoolTimedOut` → CLOSED for history, a bare close for AUTH) and never an empty success. Provider failures are logged and surfaced through a gauge. - **#2:** the gate settles every limit change and permit release under one lock. The table test covers lowering while idle, lowering below the count held, lowering then raising before holders leave, lowering then raising past the count held, and restoring the ceiling. - **#3:** the tests drive the production call sites: `Db::get_channel_window_with_session` under a lowered and raised cap (Postgres), and `handle_auth` under a full AUTH gate. The controller test runs the real refresh loop. - **#4:** values are clamped, waits are bounded, and the refresh runs on a fixed interval with skipped missed ticks. - **#9:** AUTH shedding sends clients down their jittered reconnect backoff rather than an unjittered or terminal path (see above). ## Review rework (`92a975c8`, `3f09cb33`) - **Keys renamed** to `buzz.relay.concurrency.*`: LaunchDarkly keys cannot be renamed later, and the flag project may be shared with other services. - **Shutdown:** LaunchDarkly's final flush has no deadline of its own, so `close()` now runs it on a detached thread and waits at most 2 s, after the audit drain. (The blocking pool would not bound it: the runtime waits for blocking tasks when it drops.) - **Refresh loop:** a panicking refresh is caught, logged and counted (`buzz_concurrency_limit_refresh_panics_total`), and the next tick retries, so the caps cannot freeze silently. - **Runbook:** `docs/operator-concurrency-caps.md`, covering how to tighten a cap and the cases in which a tightened cap returns to its ceiling (mistyped or deleted flag, pod started during a provider outage). - **AUTH test:** asserts the second login passes the gate and returns its permit; the gate permit is renamed so it no longer shares a name with the NIP-FI effect permit. Gated on a Linux workstation at `92a975c8` (`3f09cb33` is rustfmt only; `cargo fmt --check` passes there): clippy `-D warnings`, `buzz-feature-flags` (50), `buzz-relay --lib --bins` (1740), the new modules under parallel `cargo test`, and the Postgres AUTH tests (27). Each new test fails under its mutant: the refresh without `catch_unwind`, `close()` with no deadline (hangs until killed), and the AUTH handler with the gate skipped. ## Testing Re-run in full at `3c289134` after review rework, on a Linux workstation: - `cargo fmt --check`; `cargo clippy -p buzz-feature-flags -p buzz-db -p buzz-relay --all-targets --all-features -D warnings`. - `buzz-feature-flags` with and without `launchdarkly` (50 and 24 tests), `buzz-db --lib` (209), and `buzz-relay --lib --bins` (1738), plus a plain parallel `cargo test` of the new relay modules. - Postgres: the `buzz-db` routed-read and history-gate tests (55), and the `buzz-relay` AUTH and connection tests (38). - `a_cancelled_waiter_cannot_reopen_a_lowered_gate` fails with the debt check in `admit` removed. - The new infra-free relay tests are added to the CI unit lane (`Justfile` and `scripts/run-tests.sh`). - E2E against a local relay at `06c39d64`: cap gauges, sequential logins and history under cap 1, a 20-login burst (19 shed with a bare close, all reconnected), and LaunchDarkly misconfiguration and unreachability. 🤖 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> |
||
|
|
01d9778606 |
fix(relay): route live REQ events by each filter's own channel scope (#8519)
🤖 ## Summary **The problem.** A Buzz client that watches one channel's details (its name, topic, members) gets them once and then never hears about changes. If someone renames the channel, the client keeps showing the old name until it reloads. A related problem: when a client watches a channel's messages and something community-wide in the same request, the channel's new messages stop arriving. **The fix.** The relay (the Buzz server) now decides where to send new events separately for each part of a request, instead of once for the whole request. ### Background: how a client watches for events A client sends the relay a **subscription**: a request made of one or more **filters**. Each filter describes some events, for example "messages in channel X" or "the details of channel X". The relay answers in two phases: 1. **History.** It sends the stored events that match, then a marker that says "history is done". 2. **Live.** It keeps the subscription open and pushes each new matching event as it arrives. For the live phase, the relay must decide which new events a subscription may receive. It sorts new events into **topics**: one topic per channel, plus one community-wide topic for events that belong to no channel. A subscription only hears the topics it signed up for. This is the security boundary that keeps a private channel's events away from people who cannot read it. ### Why it broke The relay picked the topics **once for the whole subscription**, with one rule: if every filter names its channels with an `#h` tag, listen to those channels; otherwise, listen only to the community-wide topic. That rule fails in two cases: - **Channel details.** A channel's details are a special event (kind 39000) that the relay signs and stores under the channel itself. Clients ask for it with a `#d` tag, not `#h`: `{kinds:[39000], "#d":[X]}`. Because the filter has no `#h`, the rule chose the community-wide topic, which never carries channel X's events. The client got the current details in history, and then no updates. (NOSTR.md documented this gap as a future enhancement.) - **Mixed requests.** If a request had one filter for channel X's messages and a second filter with no channel, the rule chose community-wide for both. The channel filter silently lost its live messages. ### What changes Each filter now gets its own topics: | Filter | Live events it receives | |---|---| | Names channels with `#h` | New events in the channels it names and may read | | Channel details (kinds 39000–39003) that name channels with `#d` | Updates for the channels it names and may read, plus community-wide events | | Names no channel | Community-wide events, the same as before | The relay then sends a new event only through a filter that **both** matches the event **and** listens to that event's channel. ### Why this is safe - **No filter can see more than it already could.** A filter's channels come only from the channels it names, and each one must pass the same read-permission check the history phase uses. A private channel's outsider gets neither history nor updates (tested). - **One filter cannot widen another.** This is the reason for per-filter topics. The simple fix, "add the `#d` channels to the whole subscription", would leak. Some channels, such as system channels and future channel types, appear only to a filter that names them. With one shared set of topics, a sibling filter such as "all channel details created by person P" (`#P`) would start to receive live updates for such a channel, even though that channel never appears in its history. #8469 closed this leak for the history phase. This PR keeps the live phase consistent with it (tested). - **Requests that worked before behave the same.** A request whose filters all name `#h` channels got the union of their channels before. Each filter's own `#h` condition already rejected the other filters' channels, so per-filter topics deliver exactly the same events. A filter with no channel keeps the community-wide topic, as before. - **Recent joins are not missed.** Each server caches who may read which channel for a few seconds, so a server can briefly miss a new member. When a request names channels the cache does not list, the relay checks them against the database. Before, it did this only when every filter used `#h`. It now checks every channel any filter names, including `#d`. This matters more now, because a live scope is fixed when the subscription starts: a missed channel would stay missing until the client subscribes again. The check keeps the same token restrictions (tested). More than 2048 `#h` channels is still refused. More than 2048 named channels in total because of `#d` is not refused: the `#d` names then skip this check and live routing, so the request gets history from the cached access set and community-wide live updates, as before this PR. The legacy desktop app sends one unbatched `#d` query for every channel the user is in (tested). - **Clean up stays exact.** A subscription signs up for each topic once, even when several filters name the same channel. It releases each topic once on close, on replacement with the same ID, or when access to a channel is revoked. Revoking one channel removes only that channel; the subscription keeps its other channels and its community-wide topic. The tests check the counts on each path. ### Not in scope A filter that reaches a channel without naming it still gets that channel's events in history but not live. This is unchanged: before this PR, such filters listened only to the community-wide topic too. Examples are a channel-less filter, which history fills with every channel the reader can see, and the "all system channels" opt-in (`#t:["system"]` or `#P`). ### Related issue Follow-up to #8469. ### Testing - Two new Postgres tests drive the real request handler. (1) A channel-details subscription receives updates after history, from this server and through the handler that receives other servers' events from Redis. (The test calls that handler directly. It does not cover publishing or Redis transport.) A private channel's outsider receives nothing. (2) In a mixed request, the channel filter and the channel-less filter each keep their live events, and a sibling filter by creator (`#P`) or system label (`#t`) does not receive a channel it did not name. - A Postgres test with a stale access cache: a just-joined channel still reaches both a `#d` filter and an `#h` filter beside a channel-less one, in history and live. - New unit tests cover the per-filter topic rule and the subscription registry. --------- 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> |
||
|
|
27ab6a085a |
fix(relay): deliver multi-node fan-out concurrently and never drop silently (#9195)
## Problem
Each relay node runs one consumer for events that other nodes publish to
Redis. It delivered them one at a time and awaited the access gate
(visibility and membership lookups) for each event. Under load it fell
more than 4,096 events behind the pub/sub broadcast. Each `Lagged(n)`
then skipped `n` events for good: nothing replayed them, and plain
subscribers were never told.
In a 15-node load test, about 50% of cross-node events were skipped at
10x load and about 75% at 13x. Throughput at 13x was also *lower* than
at 10x. Loadgen didn't show this, because it only times events that
arrive.
## Change
- **Concurrent delivery.** A dispatcher reads the broadcast and only
routes. It drops local echoes and queues each event on one of 16 workers
by `(community, topic)`. Each worker delivers its queue in order, so a
channel's events keep their order while different channels deliver
concurrently. `handlers/multinode_fanout.rs`
- **Known loss closes the affected connections.** An event is lost when
its worker's bounded queue (1,024 events) is full, or when it isn't
delivered within 2 s of this node reading it from Redis, whether it
waited in the broadcast, behind a backlog, or in a stalled delivery.
`ChannelEvent` now carries `received_at`, stamped by the pub/sub
subscriber. For a lost event, the relay sends `CLOSED "resync: live
delivery lagged"` to every sync the event would have reached (NIP-AR
Sync rule 5). It also closes every connection the event would have
reached, so those clients reconnect and catch up from their cursor.
Matching runs before the access gate, so a recipient that would have
been filtered out may be closed too. That costs a reconnect, never a
leak.
- **A lost ephemeral event is only counted.** Presence, typing and
observer frames are never stored, so no reconnect can replay them.
Losing one fences and closes nothing; it is still never delivered past
its deadline. Without this, one stale global presence event would close
nearly every mobile connection in the community, since every mobile
client subscribes to presence.
- **Unknown loss closes every subscribed connection.** If the dispatcher
itself lags the broadcast, it can't know which events it skipped. It
resyncs every sync (unchanged) and closes every connection that has a
subscription.
- **Immediate fence.** Each affected connection is fenced as soon as the
loss is detected. The live fan-out send path (`send_to_text_bytes`,
local and cross-node) delivers it nothing from then on. The fence is
written under the connection map's write guard, and the send path checks
it and queues its frame under one read guard, so no frame slips in
between the check and the send.
- **Why 2 s.** The mobile client resumes from the newest `created_at` it
received, minus 5 s of slack. The desktop app (block/buzz-app) resumes
960 s before its connection last heard the relay, which already covers
the full 900 s `created_at` drift. While an event waits, local fan-out
and other workers keep moving that cursor. The fence stops the cursor,
and the deadline means no withheld event is more than 2 s older than the
fence, so a reconnect replays it. Without the deadline, an event stuck
behind a long backlog could be withheld past the slack and never
replayed.
- **Paced closes.** Only the close is delayed. One closer task closes at
most 50 connections per 10 ms (5,000/s per node). A drop in a hot
channel therefore can't become a reconnect storm (Review-Proven Rule 9).
- `fan_out_pubsub_event` is now split into `take_local_echo` and
`deliver_pubsub_event`, so the dispatcher can check echoes before
queueing. Its behavior is unchanged.
- **Limits (mobile):** replay works while the 2 s deadline, plus the
writer's clock skew and its publish delay, stays under the client's 5 s
slack. The relay accepts `created_at` up to 15 min off, so a writer
clock more than about 3 s slow can still put an event behind the cursor.
An ordinary reconnect has the same limit today; closing it needs a
client rewind instruction, which is out of scope here. The desktop app's
960 s resume window has no such gap. A dispatcher that falls behind
without overflowing the broadcast still never delivers a late event, but
it detects the loss, and fences, late. Events skipped by dispatcher lag
are never seen. Neither case is bounded by the deadline, which is a
cutoff for attempting delivery, not a guaranteed fence time.
- **What a load-test rerun should watch:** under sustained overload the
deadline turns backlog into steady closes (up to 5,000/s per node), and
reconnecting clients replay from the same database pool. Watch
`buzz_multinode_fanout_dropped_total{reason="stale"}`,
`buzz_multinode_fanout_lag_closed_total` and the reconnect REQ rate next
to pool wait. On dispatcher lag, the dispatcher scans the subscription
registry and fences each subscribed connection synchronously, so also
watch how long it stays behind after a `Lagged`.
Metrics:
- `buzz_multinode_fanout_lag_total` (dispatcher lag) and
`buzz_multinode_fanout_total` (delivered) keep their meaning.
- `buzz_multinode_fanout_dropped_total{reason}` counts lost events:
`queue_full`, or `stale` for one not delivered by its deadline. A lost
ephemeral event counts as `ephemeral` instead and closes nothing.
- `buzz_multinode_fanout_lag_closed_total` counts close attempts for a
loss, one batch at a time.
## Rollout
- Staging first. The load-test rerun at 1x, 10x and 13x happens after
merge, with the staging load tests planned once the current relay
changes land. At 1x, expect zero `dropped_total` for stored kinds and
zero `lag_closed_total`. Report delivered %, `dropped_total{reason}`,
`lag_closed_total`, reconnect REQ rate and pool wait.
- Then one production cluster before the rest.
- **Rollback trigger:** `lag_closed_total` climbing steadily, or
reconnect REQs pushing pool wait up, at normal load. Rollback is a plain
revert and redeploy: there are no schema, config or wire changes.
## Ordering
Order is kept within a `(community, topic)`. Events on different topics,
for example a channel event and a global event, can now reach one
subscriber in either order. Before this change they arrived in Redis
arrival order. Nothing in the relay depends on order across topics: the
access gate re-reads membership and visibility per event. Clients order
by `created_at`.
## What got simpler / known debt
- The fan-out loop in `main.rs` shrinks to one `spawn` call, and the lag
handling lives next to the delivery code.
- No new config knobs. The worker count and queue size are constants
until a load test shows they need tuning.
- **Known debt, not in this PR:** a Redis pub/sub reconnect can still
lose events silently for plain subscribers. Syncs are already resynced
in that case (`artifact_sync::watch_link`), but ordinary subscriptions
are not.
## Tests
`handlers::multinode_fanout::tests`, each checked with a mutant on Blox:
- events reach their subscriber through the workers, in publish order
- a full worker queue closes exactly the dropped event's recipients
- a lost ephemeral event (full queue or stale) is counted as `ephemeral`
and neither fences nor closes, while a stored event lost on the same
queue still closes
- a local echo on a full queue closes nothing
- dispatcher lag closes every subscribed connection
- after a known loss or dispatcher lag, a recipient gets no newer live
event while it waits to be closed
- the fence also withholds relay-local fan-out
- an event that waited out its deadline before dispatch is lost, never
delivered
- an event behind a backlog is delivered inside its deadline, and past
it is lost (its recipient fenced and queued for close), never delivered
late (paused clock)
- a delivery that stalls on a database that never answers is cut off at
the deadline and handled as lost
- the closer cancels in paced batches (paused clock), and counts each
batch as it closes
🤖 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>
|
||
|
|
0d5edb8bb3 |
perf(db): bound since-limited events reads with the month-floor literal (#9197)
## Summary A newest-first `events` read whose own `since` is at or above the recent-pass floor now runs as a single recent pass. Postgres prunes it to the recent monthlies at plan time instead of locking every partition. Results are unchanged. **Why.** `split_for` skipped any read whose `since` was at or above the floor (`since_prunes`), on the theory that its `since` already prunes. It does, but only as a bind parameter. Once a prepared statement switches to a cached generic plan, Postgres prunes bind-parameter bounds at execution, after `AcquireExecutorLocks` has already locked every `events` partition and its indexes. Live REQs with a recent `since` are this shape, for example `#p` mention reads from reconnecting agents. Under load they spill past the 16 fast-path lock slots and contend on `LWLock:LockManager`. The ceiling PR (#9157) named this gap as identified debt. **What changes.** Such a read now gets the same `Split` as other recent reads, so the recent pass carries the floor literal (plus the ceiling literal for regular-kind reads) on `e.created_at` and on `m.event_created_at` for `#p`. `Split` gains `older: bool`. It is false when the read's own `since` is at or above the floor, because no row below the floor can match, so no older pass follows a short recent pass. A read whose `until` falls below the floor stays unsplit, as before. No protocol, schema, index or client change. ### Related issue Follow-up to #9157 (its identified debt: bind-parameter `since` reads). ### Testing - `recent_pass::tests::split_eligibility`: the table now records not split / ceiled / open and whether an older pass follows. New rows: `since` at the floor, a recent `since` with regular, any and addressable-only kinds, and a recent `since` with `until` below the floor. - `recent_pass::tests` in-memory exactness: adds a `since`-at-floor split that runs exactly one pass and returns the rows at or above the floor. - `postgres_tests::since_bounded_mention_read_locks_only_recent_partitions` (new): runs partition maintenance, inserts `#p` rows at the floor, now, one second below the ceiling and one day below the floor, then reads with `since = floor`. The production path returns the three recent rows through the mention join. Under `plan_cache_mode = force_generic_plan` the recent pass locks only the partitions holding those rows. The unsplit read locks every partition. - The partition-lock helper from #9157's test is now shared by both lock tests. - Diagnostic on Postgres 17.11 with the 19-partition layout partition maintenance keeps, for a `#p` read with `since` at the floor (not committed): | | `events` partitions locked | Relation locks | Non-fast-path locks | |---|---|---|---| | Before (bind-only `since`, generic plan after five runs) | 19 | 26 | 10 | | After (floor and ceiling literals) | 2 | 9 | 0 | `EXPLAIN (GENERIC_PLAN)` before appends all 19 partitions. After, it appends only the previous and current monthlies. Mutation-checked on Blox. Each of these fails the new Postgres test or the unit tests: removing the `since_prunes` split, ignoring `older`, and dropping the `e.` literal. ## What got simpler Nothing got simpler. The change adds one flag to the existing `Split` type and no new concept. The duplicated partition-lock reader in the #9157 test became one shared helper. 🤖 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> |
||
|
|
a25f3cd1d7 |
fix(mobile): match Agents sign-in to Home’s reconnect capsule (#9204)
Replaces Agents’ inline sign-in row with Home’s reconnect capsule. It appears at the top of Agents, scrolls with the agent cards, and remains available until leaving the page or signing in. Home is unchanged. Validated with mobile checks, the full test suite, scrolling and sign-in/re-entry regression journeys, and an approved iPhone Air visual preview. The device preview forces the capsule visible while signed in; that override is excluded from this PR. | Before (widget render) | After (widget render) | | --- | --- | |  |  | <details> <summary>iPhone Air and large-text snapshots</summary> Physical iPhone Air, with preview-only visibility override:  3x text, 320×568 widget render:  </details> --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
c8d736642e |
fix(cli): clearer desktop pairing errors, paired default relay, and auth desktop list (#9196)
Follow-ups to the CLI's Buzz desktop credential support, for a person running the CLI with a desktop pairing. - **The "no credentials" error names the relay.** It says which relay the CLI tried and which relays this CLI is paired for, and tells you to pass `--relay`. When a pairing exists for that relay but no running desktop accepts it, it says so instead. - **Default relay for a paired person.** With no `--relay`, no `BUZZ_RELAY_URL`, no inherited agent credential and no explicit key, the CLI uses the one relay that a running desktop still accepts this CLI's pairing for, confirmed by `describe` on a same-user connection. Stored pairings alone never count. With zero or several such relays, the default stays `http://localhost:3000`. A desktop's refusal fails the command with its message. When another paired desktop's check fails (its same-user check times out or can't confirm the owner, or it stays rate limited), the command fails rather than pick a relay, even if one other relay was accepted, since that desktop may accept a relay of its own; a timeout or exhausted throttle fails with a retryable error. `auth desktop unpair` never runs discovery, and `auth desktop pair` never asks other desktops for their pairings, so one desktop's refusal can't block pairing another. Help text updated. - **`buzz auth desktop list`.** Lists each pairing's instance, relays and pubkey, with `accepted`, `not accepted` (revoked or no longer bound), `not running`, `unconfirmed`, `rate limited` or `refused` (the last three with an `error`), using the same check as the default relay. Desktops are asked at once, without waiting out throttling. It never prints tokens, needs no relay, prints `[]` with no pairings, and `--format compact` drops the pubkey. - **Retryable same-user timeout.** When a paired desktop's same-user check times out and nothing else serves, the command now fails with a retryable `unavailable` error (exit 2) instead of "no credentials" (exit 3). - **Regression tests** for the NIP-42 AUTH event naming the relay with its trailing slash, for skipping a paired desktop whose user can't be confirmed, without sending it anything, and for relay selection never asking desktops for pairings with an explicit key, `auth desktop pair` or `auth desktop unpair`, or over an inherited agent relay. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
744f3946f6 |
perf(relay): serve restriction rows for 10 s with cross-pod invalidation (#9199)
## Summary
The restriction cache (`AppState::restriction_state_cached`) is the
relay's hottest auth read. With a 1 s max age, most per-request
ban/timeout checks miss and hit Postgres. This change serves cached rows
for **10 s** and makes every restriction write invalidate them on every
pod, so the longer age only bounds a pod that misses the notification.
That's the same bound the membership cache already uses.
### What changes
- **`RESTRICTION_CACHE_MAX_AGE` goes from 1 s to 10 s.**
- **Cross-pod invalidation for every restriction writer.**
`AppState::invalidate_restrictions(tenant)` drops the community's rows
locally and publishes a new `CacheInvalidation::Restrictions` over the
existing Redis cache-invalidation channel. The writers that call it:
- kind 9040 ban, 9041 unban, 9042 timeout, 9043 untimeout
(`moderation_commands.rs`)
- admin HTTP `DELETE /members/{pubkey}/ban` and `/timeout`, which
invalidated nothing before
- report-resolution and direct ban/timeout, at the enforcement
convergence point so crash recovery repeats it. Timeout invalidated
nothing before.
- recording an agent's owner (HTTP token/NIP-OA owner link and both
invite claims, through `disconnect_unowned_agent_clusterwide`). The
agent's cached row predates the link and lacks the owner's restriction;
the old disconnect-path drop used to cover this.
Before this change, unban, timeout and untimeout cleared only the
handling pod, and bans cleared other pods only through the disconnect
path.
- **Community-wide drop.** An agent's row also carries its owner's
restriction (`restriction_state` reads `users.agent_owner_pubkey`), and
the cache can't tell which agents a target owns. Dropping the
community's rows covers owner→agent without a lookup. Restriction writes
are rare next to the reads the cache absorbs.
- **Stale-refill fence.** Before, a read in flight when a ban landed
could refill the old "allowed" row. The 1 s age bounded that; at 10 s it
needs a fence. The restriction cache now uses the epoch-fenced fill the
community-active cache already had: a fill is published only if no
invalidation happened since its read began.
### What got simpler
- That shared-read, epoch-fence and max-age logic is now one generic
`FencedCache<K, V>` (`fenced_cache.rs`). `CommunityActiveCache` becomes
a type alias over it.
- The restriction cache drops its hand-rolled fill in `state.rs`, plus
`CachedRestriction` and the separate `restriction_reads` gate.
- `disconnect_pubkey_local` no longer touches the restriction cache, so
roster removal and self-leave stop flushing it.
Net +146 lines, tests included (new writer assertions plus the generic
cache tests, moved from `community_active.rs`).
### Accepted bounds
- A pod that misses the Redis notification (subscriber reconnect window,
or an older pod mid-rollout) serves a stale row for at most 10 s. Older
pods log and skip the unknown `op`. Bans still close sockets clusterwide
through conn-control, and socket admission and moderation-command actors
still read fresh.
- `banned` is computed at read time, so an expiring ban can outlast its
expiry by up to 10 s. That fails closed. Timeouts still lift on time,
because the cached row carries `muted_until`.
- An owner link drops the whole community's rows, not just the agent's,
because the cross-pod message is community-scoped. A link happens once
per agent, so the extra refills are negligible.
### Identified debt (not in this PR)
- `open_channels_cache` and `HostCache` hand-roll similar read_at/epoch
logic. `open_channels_cache` could move onto `FencedCache`.
- Concurrent waiters whose shared read *fails* each re-read on their own
(`SharedReads::turn`). Sharing the failure is a separate change.
## Test plan
- `fenced_cache` unit tests cover:
- concurrent misses sharing one read
- entries served inside the max age and expired past it
- invalidation fencing an in-flight fill and its waiters
- scoped invalidation dropping only matching keys while also fencing
fills
- errors not being cached
- `state::tests::restriction_invalidation_drops_the_community_rows`
checks that local and received drops remove the target's and an owned
agent's rows, and that another community's rows stay.
- `owner_link_drops_the_agents_cached_restriction_row`: a timed-out
owner's fresh agent, admitted over HTTP with that owner, is refused its
first write. Removing the owner-link invalidation fails it.
- Postgres tests now assert that the cached row is dropped by:
- kinds 9040–9043
(`restriction_commands_drop_the_cached_restriction_row`)
- admin HTTP unban and untimeout, with other-community isolation
- direct ban and timeout through the enforcement convergence point
- Mutants (epoch check, scoped epoch bump, `Restrictions` arm, admin
unban invalidate, convergence invalidate, 9040 invalidate) each fail
their test.
- `stored_writes_honor_cached_ban_until_it_ages_out` pins the 10 s bound
with literal ages: a 9 s old cached ban still refuses a write, and a 10
s old one is reread. Raising `RESTRICTION_CACHE_MAX_AGE` to 11 s fails
it.
- On a cloud workstation: fmt, file-size, clippy (`buzz-pubsub`,
`buzz-relay`), the nextest unit suites, and the full `buzz-relay`
Postgres lane. In the full lane, 622/626 passed. The other 4 are tests
this change doesn't touch, and each passes in isolation at both the base
commit and this head.
- Two relays sharing one Postgres and Redis: a timeout, untimeout and
admin-HTTP lift on one relay take effect on the other's next write, a
few ms after the first relay acknowledges.
🤖 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>
|
||
|
|
abb2586f8c |
Pin sqlx to the upstream fix for a cancelled BEGIN (#9192)
## Problem sqlx 0.9.0 counts a transaction only after Postgres acknowledges its `BEGIN`. If the future is dropped while `BEGIN` is still in flight (a cancelled REQ history read, a client that disconnects, a timeout), sqlx doesn't queue a `ROLLBACK`. The pool's release ping then reads the late `BEGIN` reply, and the writer pool hands the connection to the next borrower while it is still inside that transaction. The reader pool already closes such connections in its release check (`Db::read_pool_options`). The writer pool has no release check. Load tests showed what happens next: - `there is already a transaction in progress` (25001) on the next event read's `BEGIN`. - Sometimes a burst of `current transaction is aborted` (25P02). The leftover from the event read is `READ ONLY`, so the next write fails and aborts it, and every later statement on that connection fails until the connection is closed. During a burst, ban checks failed closed with 503 and window reads returned 500. - A read-write leftover would also leave autocommit writes uncommitted, and the next read's `ROLLBACK` would discard them. ## Change - Pin every sqlx crate to transact-rs/sqlx#4394 (merged, not yet released). That commit takes the transaction count before the `BEGIN` round trip, so a cancelled begin queues its `ROLLBACK`. The crates share types, so they have to move together. The pin also picks up the other 23 commits between v0.9.0 and that fix, including the libsqlite3-sys fix `sqlx-sqlite` was already pinned for. - Add a regression test, `writer_pool_postgres_tests::begin_cancelled_in_flight_leaves_no_writer_transaction_open`. It holds `BEGIN` in flight behind a paused proxy and times it out, for both the event read's `BEGIN ... READ ONLY` and a plain `begin()`. It then asserts that the next borrower's autocommit write commits and that its backend is `idle`, not inside a transaction. Other upstream changes in the pin that reach the Postgres path: `TCP_NODELAY` on sockets (transact-rs/sqlx#4336), reuse of the TLS connector across connection attempts (transact-rs/sqlx#4242), a fix for a memory leak when the statement cache is disabled (transact-rs/sqlx#4337), a describe-time generic-plan change used only by the macros (transact-rs/sqlx#4286), and the `whoami` default-user fix (transact-rs/sqlx#4303). The rest are SQLite, MySQL, CLI, or docs changes. Remove the pin once sqlx 0.9.1 ships. ## What got simpler The lockfile loses its second `sqlx-core` copy, which the sqlx-sqlite-only pin used to pull in. No runtime code changes. A pool-level release check would have fixed the same bug, but it costs a round trip on every writer release. Comments that described the 0.9.0 behavior as current are refreshed: the reader pool's release check (`Db::read_pool_options`) and the lease heartbeat (`EntityDeletionStore::heartbeat`). The reader test `proof_timeout_mid_begin_leaves_no_reader_transaction_open` asserted that the release check closed the connection left mid-`BEGIN`. With the pin, sqlx rolls that transaction back itself, so the test now asserts the check finds nothing to close. That also catches a sqlx regression the check would otherwise hide. Identified debt (not in this PR): the release check's open-transaction arm, and the reader-session `default_transaction_isolation` pin it depends on, are now only a backstop against a sqlx regression. They could be removed in a follow-up. ## Testing - Against sqlx 0.9.0 (main's `Cargo.toml`/`Cargo.lock` at |
||
|
|
e43d57c6ba |
feat(cli): manage sidebar sections with buzz sidebar sections (#9191)
Replaces #8759, which had its head on a fork. This branch also carries
the stars/mutes (#8781) and sort mode (#8784) work that merged into it.
## Summary
Sidebar sections were desktop-only. They live in the encrypted
kind:30078 `channel-sections` preference, and the CLI had no command for
it. An agent that mirrors a team's Slack channels into Buzz could create
the channels but not group them into sections.
This adds `buzz sidebar sections`:
| Command | What it does |
|---|---|
| `list` | `{sections: [{id, name, icon?, order}], assignments:
{channel_id: section_id}}` |
| `create --name <n> [--icon <emoji>]` | Adds a section at the end;
returns `section_id` |
| `update --section <s> [--name] [--icon \| --clear-icon]` | Renames a
section or changes its icon |
| `delete --section <s>` | Deletes a section; its channels go back to
the default list |
| `reorder --section <s>...` | Sets the section order; unlisted sections
keep their order after the listed ones |
| `move --channel <uuid>... --section <s>` | Moves one or more channels
into a section |
| `unassign --channel <uuid>...` | Returns channels to the default list
|
`--section` takes a section id, or a section name when the name is
unique. One `create` and one `move` per section set up a whole sidebar:
```bash
buzz sidebar sections create --name "Engineering" --icon "🛠"
buzz sidebar sections move --section "Engineering" --channel <uuid> --channel <uuid>
```
## Design
- **Wire compatibility.** `sidebar_doc.rs` ports desktop's per-field LWW
register document (`channelSectionsSync.ts`, `sidebarLwwMap.ts`,
`sidebarLaneStore.ts`): decode, shape validation, projection, encode,
and `setRegs` stamping (`v = max(now, newest + 1)`, one shared stamp per
edit, unchanged leaves not rewritten). CLI writes merge with desktop
edits field by field. The CLI also imports the flat (meta-less) format
that mobile writes.
- **One user action is one publish.** Each command reads the head once
and publishes once. A multi-channel `move` therefore lands as one event.
A command that changes nothing publishes nothing and prints `"message":
"unchanged"`.
- **Never overwrite what it cannot read.** If the head fails decryption,
signature verification, or decoding (for example, an unknown `meta.v`),
`list` and every write fail. They do not treat the head as empty. A
write that the relay reports as dominated exits 5 (`Conflict`).
- **Same limits as desktop.** 100 sections, 1,000 assignments, and the
65,535-byte ciphertext cap. Channel IDs are stored in lowercase
hyphenated form, which is the form desktop matches.
- **No relay change.** The relay already stores `channel-sections` as a
generic replaceable kind:30078 event.
## Scope notes
- The commands always edit the **signing identity's own** sidebar,
because the document is NIP-44 encrypted to self. A local agent that
runs with the user's key (for example, `BUZZ_PRIVATE_KEY` set to the
user's nsec) sets up that user's sidebar. A managed agent with its own
key edits only its own sidebar.
- The request also mentioned "an equivalent agent/MCP tool". Agents use
`buzz-cli` for agent-facing operations, so the CLI help and
`crates/buzz-cli/README.md` document the `list` output contract. This PR
adds no separate MCP tool. This PR does not change `desktop/`
(deprecated; see #8840).
- `create` does not dedupe by name, which matches desktop. If a setup
script runs twice, it creates duplicate sections, and later `--section
<name>` lookups then fail as ambiguous. They do not pick one silently.
## Testing
- `cargo test -p buzz-cli`: 513 pass. The sidebar tests are:
- A journey through the production command functions against a fake
relay. The fake relay checks the head filter (`kinds`, `authors`, `#d`,
`limit`). The journey covers list, create, a multi-channel move as one
publish, a no-op repeat, invalid input with no publish, reorder, update,
unassign, delete, and the decrypted event's kind, tags, and document
shape.
- Rows for head selection (newest `created_at`, lower id on a tie,
another author's event ignored), `created_at = head + 1` after a
future-dated head, a mobile flat head, an unreadable head, a tampered
head, the count limit, the ciphertext cap, the NIP-44 plaintext cap, an
empty `--icon`, and a dominated write (exit 5).
- Each guard was mutation-checked: removing it makes at least one test
fail.
- **Desktop interop:** a CLI-written document was fed to desktop's own
`decodeDoc` and `projectSections` (from
`desktop/src/features/sidebar/lib`, run with node). Desktop decoded it
as a meta document, its projection matched the CLI's exactly, and
`canonical(json) === canonical(encodeDoc(tree))`. So desktop treats the
head as settled and does not republish it.
- **Live read:** `buzz sidebar sections list` against a real relay
correctly decrypted and projected a desktop-written sidebar (2 sections,
2 assignments). I ran no live writes.
- `cargo clippy -p buzz-cli --all-targets -D warnings` and `cargo fmt
--check` are clean.
- An agent review ran. It reported no blockers, and its optional
findings were fixed: tests now bind the relay filter and head selection,
a silently dropped head is an error, a no-op `reorder` writes nothing,
the `safe_int` overflow is fixed, channel IDs are canonical, and an
empty `--icon` is rejected.
### Manual test steps
1. `cargo build -p buzz-cli`, then set `BUZZ_PRIVATE_KEY` (your nsec)
and `BUZZ_RELAY_URL`.
2. `buzz sidebar sections list`: you see your current desktop sections.
3. `buzz sidebar sections create --name "CLI test" --icon "🧪"`: within a
few seconds, a "CLI test" section appears at the bottom of the desktop
sidebar.
4. `buzz sidebar sections move --section "CLI test" --channel <uuid>
--channel <uuid>`: both channels move under "CLI test" in desktop.
5. In desktop, rename the section. Then run `buzz sidebar sections
list`, which shows the new name.
6. `buzz sidebar sections delete --section <id>`: the section
disappears, and its channels return to the Channels list.
The author ran these steps and confirmed the result on an earlier head.
**Live re-test on this head** (rebased on `main` at
|
||
|
|
0a4d209046 |
Show mobile floating dates while scrolling (#9185)
Floating dates in conversations and threads now appear during reader scrolling, then fade to zero over 250 ms after one second idle. Automatic positioning stays quiet, and renewed scrolling reveals the date immediately. Reduced-motion behavior is preserved. Validation: mobile analysis, scroll/timing widget tests, and iPhone Air preview. Full mobile tests run through the push gate. Home and Inbox styling has moved to [#9187](https://github.com/block/buzz/pull/9187); this diff only contains scrolling-date behavior and its supporting tests/helper extraction. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
1bb2ad443d |
fix(relay): regenerate missing image thumbnails on request (#9174)
## Summary Clients ask for an image's small `.thumb.webp` thumbnail first and fall back to `.thumb.jpg` on a 404. Images uploaded before [#9003](https://github.com/block/buzz/pull/9003) only have the JPEG, so every client pays an extra 404 for them and transparent avatars keep showing on a black background. The same thing happens when a thumbnail write failed at upload time. When a thumbnail variant is missing but its image original exists, the relay now regenerates the thumbnails on that request and serves them with 200. It uses the same generator and storage writes as upload, which are now one shared `store_image_thumbnails`. Later requests are ordinary hits. - GET (full and range) and HEAD both regenerate, so a HEAD-then-GET client sees the same result. - Regeneration runs only after the existing tenant, auth and sidecar checks for that thumbnail. - It is bounded. At most two regenerations run per relay process (`try_acquire`, no waiting), and originals larger than `max_image_bytes` are skipped. In both cases, and for non-images, missing originals or any decode or storage error, the response is the 404 it is today. Errors are logged at warn. ### Related issue Follow-up to [#9003](https://github.com/block/buzz/pull/9003) and [#9105](https://github.com/block/buzz/pull/9105), which made relays write WebP thumbnails and clients prefer them. ### Testing Two router-level tests against Postgres and MinIO upload a PNG through the real route, then delete its thumbnails: - `missing_thumbnail_is_regenerated_from_image_original`: GET and HEAD of `.thumb.webp` and `.thumb.jpg` return 200 with the right content type, and both objects are stored afterward. With regeneration disabled it fails with `GET webp: 404, expected 200`. - `missing_thumbnail_is_not_regenerated_when_unsafe`: a non-image blob, a missing original, an oversize original and a busy relay (no free permit) each return 404 and store nothing. Once the permits are free, the same request returns 200. Removing the size cap or the permit guard fails it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> |
||
|
|
3fdd8c6ff1 |
feat(loadgen): model agent turns and sustained holds (#9182)
## Why The loadgen agent polled HTTP `/query` every few seconds. A real agent harness (`buzz-acp`) never polls when idle. It works in bursts around each prompt, and those bursts are the load a fleet of active agents puts on the relay. To answer "how well does the relay handle N active users plus their agents over a sustained period", the loadgen needs to model those bursts and hold them for an hour or more. ## What - **`[agent.turn]`**: an idle agent starts a turn on a Poisson schedule and runs one turn at a time. A turn lasts 0.5–1.5 × `mean_secs`. The steps follow `buzz-acp`: - **Start**: a `limit:1` `/query` finds the message to answer (`http_query`), which stands in for the harness's subscription. Then the harness's own reads (`turn_context` op): for a threaded message, its thread context in one `/query` (root by id, newest 13 replies, the agent's own newest reply) plus a `/count` when the window is full. Then kind:39000 metadata, project home (kind:30621, kind:30617) and the author's profile. Then two kind:7 reactions on the message. - **Work**: typing every `typing_secs` and observer frames of `frame_bytes` every `frame_secs` over the socket, plus `reads` tool reads (`/query`, limit 50). - **End**: a threaded reply, under the root that NIP-10 `resolve()` gives. Then reaction removal: query own reactions, then a kind:5 deletion for each. - One HTTP action at a time runs next to the socket. Socket publishes (presence, typing, frames) don't wait for their OK: each OK is matched as it arrives, one missing after 30 s is an error, and at most 128 wait at once. So the socket and HTTP sides stay on schedule while either is slow, as in the harness. A 2xx response that doesn't decode counts as an error. No agent request starts after the stop. - The request mix approximates sustained turns: every turn reads like a first prompt. A harness already holding a thread reads 25 replies (not 13), batches profile lookups and caches project-home lookups for 30 s, so this reads profiles and project homes more often than a busy harness. - `agent.channel_subscriptions`: one live mentions REQ per channel, like the harness. `agent.presence_interval_secs`: presence heartbeats. - `agent.query_interval_secs = 0` turns polling off. - **Sustained holds**: ramp steps may now repeat a level. A 60-minute hold can be six 600 s steps, each one reported and judged, so drift shows up as a trend. - New op `turn_context`. New scenario `perf/scenarios/local-agent-turns.toml`. `perf/LOADGEN.md` documents the model. All new fields are off by default (`turn.per_hour = 0`, presence 0, no channel subscriptions), so existing scenarios run unchanged. ## What got simpler - The agent loop's per-action branches now go through one `sent` result. The observer frame build, the HTTP submit and the `/query` body read are shared helpers. The old inline frame code is gone. - "Hold for an hour, judge every 10 minutes" is now a ramp of repeated levels. No new abort-window concept was needed. - Identified debt, not touched here: abort is still judged only at step end, so a collapsed relay is not detected until the step closes. Repeated short steps reduce that gap. ## Testing - On a Blox workstation at |
||
|
|
b329e05c5b |
fix(cli): list DMs, attach any file, validate kinds, default the relay (#9168)
Fixes five defects in the `buzz` CLI. - **`dms list` returned `[]`.** It queried kind 41001, which the relay no longer publishes. It now reads the viewer's DM group metadata (kind 39000, `t=dm`, `p=viewer`) and drops every DM listed in the viewer's newest relay-signed visibility snapshot (kind 30622), the same hidden set the desktop uses. It keeps paging until it has the requested number of visible DMs or the history runs out, so hidden DMs never crowd out visible ones. Hidden DMs stay hidden; reopening one brings it back. - **Uploads accepted only images and MP4.** The CLI now leaves type acceptance to the relay, as the desktop does: unsniffable files go up as `application/octet-stream`, and the relay's own allow and deny rules apply. Local size caps match the relay's defaults (50 MB images, 500 MB MP4, 100 MB other files). `messages send --file` adds an imeta `filename` (the basename, with control characters and both path separators replaced by spaces) and writes non-media files as a plain link (`[name](<url>)`) instead of ``; inline media still embeds. - **`messages get --kinds` silently ignored bad values.** Any non-numeric or out-of-range (above 65535) kind now fails with a bad-input error, exit 1. - **Upload errors lost their category.** A missing or unreadable file is bad input (exit 1), and `messages send --file` returns the upload's own error instead of wrapping every failure as `Other` (exit 4). The "auth tag may be stale" hint on every 403 is gone: the relay answers 403 for ordinary permission denials, such as a non-moderator reading moderation data, so the hint misled. - **Agents had to pass the relay by hand.** On macOS and Linux, with no `--relay` and no `BUZZ_RELAY_URL`, the CLI uses the relay an agent's inherited `BUZZ_CREDENTIAL_PROVIDER` token acts on, from its `describe` answer. If that lists several relays, it fails with exit 1 and asks for `--relay`. An explicit flag or env var still wins and is still checked against the credential. Paired tokens never choose a relay, `auth desktop unpair` never asks the desktop, and otherwise the default stays `http://localhost:3000`. Help text says so. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: wpfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
962b3fdd88 |
Add avatar color backdrop to mobile agent creation (#9184)
Adds a blurred, saturated backdrop to mobile’s **Choose avatar** screen that crossfades with the selected avatar or emoji color. Enlarged artwork keeps the effect abstract; the upper fade leaves the bottom controls clear. Supports reduced motion and high contrast. Validated with analysis, the full mobile suite (4,518 passed), light/dark widget previews, and an iPhone Air install with the existing account preserved. ### Snapshots Flutter widget captures: | | Before | After | |---|---|---| | Light |  |  | | Dark |  |  | buzz-review-completed Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
32997e2e1b |
Cache channel memberships once per user (#9060)
## Summary
A cold client in 280 channels can issue 280 separate membership reads,
each checking out a database connection. Cache the user's complete
active-membership set per community so those channel checks share one
read.
Keep the existing membership rules and 10-second freshness bound,
measured from connection checkout. Moka now accounts for snapshot size
with a 64 MiB weighted budget per relay. Each snapshot holds at most
16,384 UUIDs. The database pool controls query concurrency; there is no
separate 16-load limiter. Channel discovery keeps its separate filtering
rules. No schema change or migration.
### Request flow
1. Look up the community/user entry in Moka. A fresh complete snapshot
answers channel checks by binary search.
2. A fresh `PointChecks` marker sends each check to the database; it
stores no permission decision.
3. On a miss, create a lightweight, weighted loading entry. Requests for
that user share its lock.
4. Recheck after acquiring the lock. If waiting for that user’s lock
times out, use a point check.
5. Fetch at most 16,385 membership IDs. Above 16,384, retain a
`PointChecks` marker instead of a partial list.
6. Otherwise, sort the complete list into a compact immutable UUID
array.
7. Replace the loading entry through Moka. Its weigher now includes the
completed payload plus estimated bookkeeping.
8. Let Moka manage admission, eviction, and 10-second expiry. Membership
changes remove the relevant user or community entries.
Publication and invalidation share a short synchronous lock. A loader
publishes only while its original loading lock still identifies the
current entry. An old load can finish its original request, but cannot
repopulate an invalidated entry. Database failures propagate;
cancellation releases the user lock. Both database-query futures
allocate only when used, which keeps nested handler futures small. Cache
hits allocate neither query.
The budget covers estimated retained cache weight. Moka enforces it on a
best-effort basis; query buffers and snapshots held by active requests
can add memory. This is not a strict process-memory ceiling. Empty
entries and fallback markers have nonzero weight. There is no separate
memory-reservation or eviction implementation.
### Operational notes
- A newly granted membership can be absent from another relay's cached
snapshot until the Redis invalidation arrives, or until the 10-second
freshness bound expires if it is lost. Cached write checks can briefly
deny access; explicit channel subscriptions already confirm missing
membership against the database.
- `buzz_membership_cache_hits_total` counts channel checks served from a
fresh snapshot. `buzz_membership_cache_misses_total` counts bounded
full-set load attempts. Timeouts now use point checks, recorded by
`buzz_membership_cache_fallbacks_total{reason="wait_timeout"}`. The
other fallback reason is `oversize`; the `load_limit` path has been
removed. Before/after hit ratios are not directly comparable.
- Keep `channel_id` in membership invalidation messages for
compatibility with older relay instances during rolling deployments,
even though the new local cache invalidates by user.
### Related issue
Follow-up to #9012.
### Testing
**Current head: `a79a40cc5a6e4c7c611ed1b6bcbdd8b8c0490ef2`, including
main `f82944e3180e28502f5ea599ac9973aa6398ddb3`.** Commit `393b32583`
fixes the large-membership test fixture by refreshing planner statistics
after bulk seeding. Before that fix, the boundary test failed locally
and was the sole PostgreSQL test failure in GitHub CI (1,409/1,410
passed); the repaired test passes in 3.54 seconds including setup and
all checks. An isolated copy of the production schema/query at 16,384
memberships selected a quadratic join without statistics and a hash join
after `ANALYZE`; the latter executed in 10.86 ms. The cap and freshness
rules are unchanged.
At this exact head, the full regular buzz-db/buzz-relay suite passed
1,963/1,963 and the full PostgreSQL suite passed 1,310/1,310. This
includes both previously failing Git-auth cases and the repaired
membership boundary test (2.302 seconds including setup and checks).
Rebuilt signed live checks also passed. Full local `just ci` passed
(exit 0). GitHub CI is green: 79 successful checks and 31 skipped, with
none pending or failed at verification. The [verified matched A/B at
this
head](https://github.com/block/buzz/pull/9060#issuecomment-6673813172)
against main `f82944e31` is complete: four 6,600-client arms in both
orders show 90.98–91.24% fewer membership-cache queries and 73.76–74.23%
fewer authorization checkouts. Auth error rates improved in both orders;
auth p99 and overall operation errors were mixed. Average relay RSS
increased 1.6–4.3%, with flat to slightly lower sampled peaks;
PostgreSQL average PSS decreased 1.7–5.2%, with mixed peaks. The
16,384-row production query executed in 24.953 ms after fixture ANALYZE.
Signed pool-4 and 4,097/16,384/16,385-membership probes passed. See the
report for raw counts, memory, provenance and limitations. The
[completed A/B at the earlier `6f0358875`
head](https://github.com/block/buzz/pull/9060#issuecomment-6672419096)
is reported separately; it does not establish current-head performance.
Users above 16,384 memberships still use uncached authoritative point
checks. The higher cap reduces how many users hit that fallback; it does
not eliminate the cost. Each maximum-size list contains 256 KiB of UUIDs
plus overhead within the unchanged 64 MiB estimated cache budget.
Temporary allocations and retained readers can add memory outside that
budget.
Agent-run signed WebSocket test at
`a79a40cc5a6e4c7c611ed1b6bcbdd8b8c0490ef2`, using the rebuilt relay and
an isolated database: non-members cannot post; grants allow both private
channels; removal blocks writes and delivery from A while B continues;
re-add and resubscription restore A. Delivery checks completed in 1.776
s after removal and 0.466 s after re-add. A user grown above 5,000
memberships used snapshots with no oversize fallback. After growing
beyond 16,384, authoritative point checks still enforced removal and
re-add; four oversize fallbacks were recorded. The relay stopped and all
three test ports closed. Binary SHA-256:
`5b69a5aed07e9beef6bd6d2c5661c07fbdf1901dedb1729d27ab5e86a6b8d6a3`.
Build this head with `cargo build -p buzz-relay --bin buzz-relay` and
start an isolated local relay as described in
[TESTING.md](
|
||
|
|
9a6cb3ffdf |
mobile: make the first-agent card stand out in dark mode (#9171)
Follow-up to #9036. In dark mode the "Create your first agent" card used the raised dark surface (#1A1A1A), which barely separates from the black Home page, and dark mode shows no shadow — feedback was that it's hard to see. The card now uses the inverse surface in both themes (light: dark card on white; dark: light gray card with black text), so it always contrasts the page. No new tokens. Tests: `flutter analyze` on the widget; `flutter test test/shared/widgets/buzz_callout_test.dart test/features/channels/` all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) | | Before | After | |---|---|---| | Dark |  |  | | Light (unchanged) |  |  | _Rendered from the widget at phone width with the real fonts and artwork._ Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Morgarita <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4e28176f13 |
fix(relay): close legacy sockets after community FI upgrades (#8991)
## Summary
When a community starts requiring NIP-FI, the periodic community
revalidator now closes its existing legacy root and audio WebSockets
with Close 1008. It reads current policy from the writer by immutable
community ID, with a 500 ms budget per read. Failed reads retry on later
sweeps; late registrations are caught by the next pass.
Enforced FI admissions survive. Optional-shadow admissions remain
legacy. Off and repair mode retain their existing behavior. No client
request or Redis notification is needed.
The default sweep interval is 30 seconds, not a deadline during database
failure, overload or paused processes. Already-running requests and
descendant work may finish. Receipts prove policy acceptance and remain
`pending`. The `pending_reasons` value changes from
`registry_baseline_unverified` to `connection_closure_unverified`;
clients must tolerate unknown reasons. This change does not certify
fleet-wide completion.
### Related issue
[BUZZ-512](https://linear.app/squareup/issue/BUZZ-512), under the
revised eventual-closure scope. Builds on merged #8985 and #8988. The
replacement starts from main and adds no activation schema or migration.
### Rollout and limits
There is no new flag. Count already-required communities before
deployment: each relay closes their legacy sockets on its first
successful sweep. Closures within a community can cause simultaneous
reconnects. Keep new upgrades disabled until every serving relay runs
this version.
Observe `buzz_community_auth_policy_checks_total{outcome}` and
`buzz_community_auth_policy_disconnects_total` per relay, and verify
real idle root/audio Close 1008 probes plus FI reconnects. Counters
record checks and cancellation requests, not fleet completion. A binary
rollback requires no migration and restores the old pending reason, but
loses eventual closure; it does not undo durable policy. See the
[operator
contract](
|
||
|
|
b894fb02ff |
fix(mobile): show transparent avatars without a black background (#9105)
## Summary Transparent PNG and GIF avatars render on a black background in mobile avatars and iOS notification avatars. Both load the relay's small thumbnail, which was always a JPEG with no alpha channel. [block/buzz#9003](https://github.com/block/buzz/pull/9003) makes relays write a `{sha}.thumb.webp` thumbnail that keeps transparency, alongside the `{sha}.thumb.jpg` that every relay writes. This PR loads the WebP first. - `MediaImage` with `preferThumbnail` tries `.thumb.webp`, then `.thumb.jpg`, then the original. Only a 404 moves on to the next source; any other failure still shows the error widget. Missing thumbnails are remembered as before, so later builds skip straight to the source that exists. - `BuzzNotificationAvatarLoader` follows the same order for notification avatars. Relays without 9003, and images uploaded before it, keep rendering from the JPEG as they do today. ### Screenshots A transparent PNG avatar rendered by `MediaImage` on the same relay, before and after (widget harness on an iOS simulator). | Before | After | | --- | --- | |  |  | ### Related issue AIDA-2308 (internal). The desktop counterpart is [block/buzz-app#2477](https://github.com/block/buzz-app/pull/2477). ### Testing - `missing thumbnails fall back to the JPEG, then the original` (`avatar_image_test.dart`): when the relay 404s every thumbnail, the avatar requests the WebP, then the JPEG, then the original. When the WebP fails with a 500, the avatar shows its fallback and requests neither the JPEG nor the original. - The relay thumbnail journey tests run against a relay that 404s `.thumb.webp`, so they cover the older-relay path end to end. - `BuzzNotificationAvatarLoaderTests` checks the WebP-then-JPEG thumbnail order for relay media, and that non-relay media gets no thumbnails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a1efbf63bd |
fix(mobile): delete composer chips as one unit (#9133)
In the mobile composer, Buzz links (channel, message, repo, PR, issue) and selected agent `@mentions` render as chips, with the source text kept as invisible characters behind the chip. Backspace right after a chip removed one of those hidden characters, so the chip turned into a broken raw URL or partial name with no visible change for most of the presses. This PR gives chips one consistent editing model: - **A link is a chip only once something follows it.** Its pattern runs to whitespace or the end of the text, so in practice that is any time the caret isn't at its end. Pasting exactly one link adds a space after it (unless whitespace already follows), so it becomes a chip straight away. - **A link shows its raw URL while the caret is at its end.** Backspacing the space after a chip, or moving the caret back past it, reveals the URL for editing, one character at a time. Moving the caret away turns it back into a chip. A caret at the link's start leaves it a chip. - **Agent `@mention` chips stay atomic.** Backspace right after one removes the whole mention. - **Chips are atomic for selection and forward delete.** A caret placed inside a chip moves to its nearer edge, arrow keys step over it, and a selection that covers part of a chip grows to cover all of it. Forward delete just before a chip removes the whole chip. Typing, pasting, deleting or cutting over a selection replaces exactly the (snapped) selection. The controller records each chip's source range while it builds the spans, so only what is actually drawn as a chip behaves as a unit. Text in code spans and IME compositions are never rewritten. Undo restores the previous text: edits at the end of a revealed link are plain text edits, so undo no longer looks like a backspace into a chip. Human `@mentions` aren't chips today and are unchanged. After this change, in the simulator: a link becomes a chip once a space follows it, backspace reveals the raw URL, the next backspace removes one character, and the arrow keys reveal the link and turn it back into a chip. https://github.com/user-attachments/assets/1d051dd7-92fa-47c9-b569-a7ef5e00ca6f <img src="https://github.com/user-attachments/assets/9980aa01-bcf2-4a1b-9c25-4349ce999e26" width="300" alt="Composer with a channel link chip after a space"> <img src="https://github.com/user-attachments/assets/5dfbaa44-a0f4-4917-b7ca-eeeb63e47dae" width="300" alt="After one backspace the raw URL shows"> <img src="https://github.com/user-attachments/assets/1a675d56-7310-4b13-9dd9-caa26ff99283" width="300" alt="The next backspace removes one character"> ### Validation - Added tests: the composer permalink-chip test now covers the boundary space and reveal (soft and hardware backspace, arrow keys), caret and selection snapping, forward delete, paste adding a space, IME composition, and undo after typing at the end of a link or after completing a link. The agent-chip test covers whole-mention backspace, selection snapping over a mention and a mention in a code span. Breaking each rule (reveal, snapping, paste space, whole-chip delete, widening only inside one chip) fails a test. - Focused existing validation: the full mobile suite (`just mobile-check mobile-test`) passes. - Automated app run: iPhone 17 Pro simulator, iOS 27, debug build of this branch, in a private test channel. Typed a Buzz channel link and a space (chip), backspaced twice (raw URL, then one character removed), retyped the character and space (chip again), then moved the caret left past the space (raw URL) and right again (chip). Nothing was sent. The run covered the composer only; the change touches nothing outside it. 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: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fbfbf3d64f |
test(mobile): drop the video preview step removed with native frame loading (#9164)
Fixes red main. https://github.com/block/buzz/pull/9144 added a step to the expired-assertion journey in `native_media_headers_test` that loads a posterless video preview through `videoPreviewFrameLoaderProvider`. https://github.com/block/buzz/pull/9138 merged around the same time and replaced native first-frame loading with a static placeholder, removing that provider, so `flutter analyze` now fails on main with `Undefined name 'videoPreviewFrameLoaderProvider'`. Posterless previews no longer fetch anything natively, so the step has nothing left to guard. This deletes it along with its fake video player, restoring the test file to its state before #9144. ### Validation - `just mobile-check` and `just mobile-test` pass. 🤖 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> |
||
|
|
1bb44ae9ef | fix(relay): close a WS search whose hit hydration fails instead of sending EOSE (#9148) | ||
|
|
8ae35c5c92 | perf(db): stop the events recent pass at a ceiling for regular-kind reads (#9157) | ||
|
|
bb3c5f7175 |
feat(relay): project compact viewer memberships (BUZZ-615) (#9118)
## Summary Cold login downloads other people's full rosters just to find the viewer's channels. This adds a compact projection from canonical memberships through the existing Nostr `/query` bridge. One primary SQL statement reads the viewer's rows and channel state, then the relay signs that exact result. Merged spec PR #9117 owns both relay-only kind registrations. Self mode (`membership_snapshot`, kind 39008) requires the viewer's own NIP-98 key. Public mode (`membership_public`, kind 39009) lets an admitted requester look up any subject's listed, open forum/stream/workflow memberships. It excludes private/unlisted channels, DMs, system channels and deleted or removed memberships, including shared private channels. Both modes require payload-bound NIP-98 and return signed request-bound envelopes; overflow or backend errors fail the whole request. Legacy kind-39002 reads and signatures stay intact. No schema migration or persisted per-user list is added. ### Related issue [BUZZ-615](https://linear.app/squareup/issue/BUZZ-615). Implements the compact discovery contract merged in #9117. Related: [#8898](https://github.com/block/buzz/pull/8898), [#9060](https://github.com/block/buzz/pull/9060). Client lazy-loading follows separately; this does not include [buzz-app#2627](https://github.com/block/buzz-app/pull/2627). ### Testing Current head `5c0c35400a` rebases the five implementation patches unchanged onto main `c39ee507`, then repairs the removed Redis test API. McKeon independently verified the rebase and reviewed that repair. Full core/database/relay package tests, PostgreSQL membership tests (9/9), Clippy and every mandatory publication hook pass at this head. Old-head CI run 42132655155 failed six jobs on that same removed API; four summary checks propagated those failures. New-head hosted CI is pending. Main advanced to `e3a0f3d59d` during publication; a local merge-tree check found no conflicts. Historical runtime evidence below remains attributed to its tested heads. Prior self-mode validation at `6d2ffceeee` (historical evidence, not validation of the public-mode change): - `just ci`, pre-push hooks and all 2,221 core/DB/relay package tests passed. - All six new Postgres protocol tests passed. All five guard mutations failed assertions: mandatory auth, flag gate, single-statement cut, overflow probe and tenant predicate. A/B are older runs against production files unchanged from `9784df51`; C/D/E were rerun on final SQL/fixtures. The earlier surviving tenant mutation is superseded by the final duplicate-ID witness. - The full isolated Postgres run finished 1,288/1,294 green; six existing push/media/FI/startup cases failed. All six passed when rerun serially after the concurrent local gates stopped. The failed full run is retained and is not represented as a clean full-suite pass. The preceding correction `1c47dfc9` had passed all 1,294 tests. - `just test` passed its unit steps but integration setup was blocked by Docker Desktop organization sign-in enforcement. The waiting wrapper was stopped with SIGTERM; this is not a complete pass. - A real local relay rebuilt at this head returned 67,896 bytes for 468 memberships in 33 ms unthrottled. Signatures, request/viewer/tenant binding, content identity, wrong-viewer refusal, removal and role changes verified. The scale fixture adds five large rosters and 20,000 unrelated channels. Compact bytes stay unchanged as other members grow; EXPLAIN checks 468 indexed viewer rows and bounded channel-ID lookups. A same-UUID cross-tenant fixture verifies fields and duplicate rejection independently of row order. Durable local evidence: `WORK_LOGS/BUZZ_615_20261105/MANIFEST.md` and `MANIFEST.json` map commands/results to commits or verified mutant-file hashes and index the exported logs. Concrete human-test steps and the signed-request helper are included. Human testing remains pending; ready state follows the author’s explicit request. Native/browser 140/205 KB/s acceptance belongs to the client PRs. Public-mode validation at `c6e269170e`: McKeon independently ran all nine isolated PostgreSQL membership tests and five mutations. Every mutation failed its expected assertion: public selection predicate, subject tag, public routing, identity domain and self serialization order. The full core/DB/relay package run, all nine isolated PostgreSQL membership tests and the relay build passed at c6. A real signed HTTP workflow verified signatures, bindings, identity hashes, all visibility exclusions, archived guest roles, requester-independent content identity and malformed-request refusals. After a channel became private, the public list shrank from three entries to two; a role change appeared as admin. The local test relay was stopped afterward. The restacked child `8c56f97d6409` has the same code, tests and configuration as c6; only the spec differs. A new full core/DB/relay package run passed at that exact child head. The parent `17a481a89716` separately passed the full core package check. The first child attempt used the wrong local database and failed five existing tests on a missing table; its log is retained separately from the successful configured run. These checks do not attest human testing. ### Rollout and rollback This PR does not deploy or enable the feature. Both `BUZZ_MEMBERSHIP_SNAPSHOT_ENABLED` and `BUZZ_MEMBERSHIP_PUBLIC_ENABLED` default to false and independently gate advertisement and execution. An authorized operator sets them in the relay Helm environment managed by `squareup/block-coder-tf-stacks`. First deploy the image to every staging pod with both flags off; then enable each capability separately for staging validation. Enabling either mode does not require or enable the other. Repeat that dark-then-enable order for bb-block after staging passes. Opt-in client rollout follows the relay contract and the throttled desktop/browser workflow. Watch `buzz_membership_snapshot_query_seconds`, `buzz_membership_snapshot_encode_sign_seconds`, `buzz_membership_snapshot_response_bytes`, split by `mode=membership_snapshot` or `mode=membership_public`, plus `/query` 413/503 responses and primary-pool wait time for the Authorization operation. Confirm zero unexpected full-roster discovery downloads on supported clients and correct roles/membership against the canonical cut. Abort enablement for any binding/signature mismatch, access leakage, incorrect complete result, stale-response regrant, unexpected overflow for the 468-channel account, sustained 503s or a material regression in primary-pool wait/foreground reads. Turn the affected flag off to roll back. Self-mode clients recheck support and start a new legacy authority generation; public-mode clients recheck support and back off without treating public absence as membership loss. Keep cached display/user data. The first named usable channel target of five seconds at 140 KB/s remains a client acceptance target, not a result established by this relay PR. The 119-event approximately 5 MB read-state contention and native/browser throttled tests are required before broad client rollout. Generated with Codex --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> |
||
|
|
fd0ec10505 |
perf(mobile): rank search channels once per query (#9136)
Search re-ranked every channel on each live channel event, even while it
sat covered in the back stack, and could move a result while you were
reaching for it. On an iPhone 12 mini in a large community, each re-rank
cost 4 to 20 ms at about 2.4 channel events per second: 454 ms of 2,575
ms UI CPU in a 25 s scroll of a big channel with search behind it, and
343 ms in the 15 s after a resume.
Channel results are now ranked when the query runs and when the set of
channels changes: the first load and a directory install (including one
another caller started). Live channel events no longer reorder shown
results; they only update the shown rows' content (membership badge,
member count, name) in place. The next query ranks the current list.
### Accepted gap
A channel joined, left or renamed while results are shown updates its
row but keeps its position until the next query.
### Validation
- Added test: `live channel events never reorder shown results` adds a
channel and checks it is ranked in, moves it to the top of the list
through activity and checks the shown rows don't move, lets a live event
land between another caller's directory status turning loaded and its
install and checks the open channel shows, then joins that open channel
and checks its Open badge goes without the row moving. It fails when
activity re-ranks again, when a change to the channel set is not ranked,
and when shown rows keep stale content.
- Search suite and full mobile suite pass except the known
notification/push failures that also fail on main. Analyze clean.
- Automated simulator run of this branch (debug build, Builderlab
community): searched "studio", channel and people results listed, opened
a channel result, went back with the results still shown.
- Device numbers are from a wired Profile build of main
|
||
|
|
1a30e51689 |
mobile: start new agents on the server-recommended model (#9151)
## Summary Builderlab's model catalog can now recommend a model and thinking effort for new agents (`recommended_selection` on `/v3/beekeeper/get-model-catalog`). The mobile create-agent screen now starts on that recommendation, so the default can change on the server without an app release. New agents start on the first of these that applies: 1. The model an agent link suggests, when the catalog offers it (unchanged). 2. The server's recommended model and effort. 3. The first Sonnet model at medium effort, as added in #9034. Used while a deployment sends no recommendation. 4. Blank. A setup resumed after a failure still keeps its saved choice. A recommendation that isn't in the returned catalog, or that is malformed, is ignored. Existing agents' saved models are never changed. The server only sends a recommendation when the deployment's catalog config sets `recommended_selection`. Until then, the app keeps falling back to Sonnet. ## Testing - Unit tests cover the recommendation beating the Sonnet fallback, a draft model beating the recommendation, and recommendations that should be ignored (unsupported effort, unknown model, missing effort, malformed, absent). - A new widget test checks that the form opens on the recommended model and effort instead of Sonnet. - `just mobile-check` and the full mobile suite (`flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`) pass locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5d094cd769 |
release(mobile): push candidate tags as the operator, not the App (#9084)
Mobile release candidates (`mobile-vX.Y.Z-rc.N`) are currently created
by the `mobile-release-candidate.yml` workflow, using the
`buzz-release-bot` App key that also serves the desktop, relay and chart
lanes. This PR takes mobile off that key. After it lands, the only way
to create a mobile candidate tag is for a person on the mobile release
team to push it.
### What changes
- `scripts/mobile-release.sh candidate` keeps all its existing checks:
version, source branch, clean tree, canonical origin, ancestry and next
rc number. It no longer dispatches a workflow. It now:
- verifies the mobile tag ruleset 26588677 (active, the expected rules,
scoped to `refs/tags/mobile-*` or `refs/tags/mobile-v*`, and bypassable
by the current user), and that the App-only general tag ruleset 14378754
excludes `refs/tags/mobile-*`;
- requires origin's effective push URLs to be exactly its fetch URL, and
fetches a pinned `--commit` missing from a shallow clone;
- creates the annotated tag locally and pushes exactly that ref
(`--no-follow-tags`, explicit refspec);
- confirms through the GitHub API that `block/buzz` holds that exact tag
object, then verifies the remote tag as before.
A rejected push removes the local tag only when origin lacks it, so a
retry starts clean. When the push reports failure but origin has the
tag, the script keeps the local tag and still runs the canonical GitHub
check, reporting success only if it passes. When GitHub can't confirm
the tag, the local tag is kept and the message says how to reconcile it.
- `scripts/release-rulesets.sh`: `require_mobile_release_tag_ruleset`
replaces the App-oriented check. `MOBILE_RELEASE_TAG_RULESET_ID` is
`26588677`.
- Removed `.github/workflows/mobile-release-candidate.yml`,
`scripts/publish-mobile-release-candidate.sh` and its test, and their CI
references. Updated `RELEASING.md` and the auto-tag workflow header
comment.
- The desktop, relay and chart auto-tagging is unchanged.
### Rollout
Tag ruleset 26588677 ("Restrict Mobile Tags") is active with the two
mobile release managers as bypass actors. The App (Integration 4349119)
is temporarily a bypass actor of 26588677 and 19321162 so the current
`main` workflow keeps working; it is removed from both right before this
merges. General tag ruleset 14378754 now excludes `refs/tags/mobile-*`;
without that, rulesets stack and it would reject release managers'
mobile tag pushes. The branch ruleset 19321162 now has the same bypass
actors, so `require_mobile_release_branch_ruleset` no longer requires
"no bypass"; it still checks the review and status check policy. Release
managers must be bypass actors of both rulesets.
### Before merge
- [x] Remove Integration 4349119 (`buzz-release-bot`) from the bypass
actors of rulesets 26588677 and 19321162.
### Validation
- `shellcheck -x scripts/release-rulesets.sh scripts/mobile-release.sh
scripts/mobile-release-source.sh
scripts/test-mobile-release-contract.sh`: clean.
- `scripts/test-mobile-release-contract.sh`: passes. It runs against a
local bare remote with a stubbed `gh` and covers:
- a successful annotated push at the pinned SHA;
- ruleset failures (not bypassable, inactive, wrong rules, wrong scope,
excludes, non-numeric ID), with `refs/tags/mobile-v*` also accepted as
the include, each failing before any tag is created;
- a push rejected by a pre-receive hook, which leaves no local tag and
allows a clean retry;
- `push.followTags=true` with a stray local annotated mobile tag
(exactly one ref published), a push that lands but exits non-zero (tag
kept), and a GitHub API outage after the push (tag kept, rerun publishes
the next rc);
- origin push URLs pointing at a fork (pushurl, a second pushurl,
pushInsteadOf, insteadOf), a shallow clone pinning a historical commit,
and 14378754 lacking the mobile exclusion;
- rc numbering, source branch rules and `--commit` pinning.
- `scripts/test-release-ref-contract.sh` and
`scripts/test-desktop-release-candidate.sh`: pass.
- Live read-only check against real GitHub as a bypass actor: sourced
`scripts/release-rulesets.sh` and `scripts/mobile-release-source.sh`,
then ran `require_mobile_release_tag_ruleset` (including the 14378754
exclusion check) and `require_mobile_release_branch_ruleset
mobile-v0.27`. Both returned success. No tag was created.
- No mobile app test: this is a script and workflow change only.
🤖 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@localhost>
mobile-v0.28.0-rc.4
|
||
|
|
fc85b0d154 |
test(mobile): check posterless video previews renew the relay assertion (#9144)
Follow-up to https://github.com/block/buzz/pull/9134, which made posterless video previews and voice notes renew an expired relay assertion before handing headers to the native player. The voice-note path got a test there, but nothing failed if the video preview went back to the cached-only headers. This extends the expired-assertion journey in `native_media_headers_test` with one step: the real `videoPreviewFrameLoaderProvider` loads a relay video through a fake native video player, which records the headers it is created with. The player must receive both the media `Authorization` proof and the renewed `Nostr-Federated-Identity`. ### Validation - Added tests: the video-preview step above. Switching the loader back to the synchronous cached headers fails it. - `flutter analyze` is clean and the full mobile suite passes (4508 tests). - Test-only 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> |
||
|
|
fba8a7a94f |
feat(relay): log WebSocket EVENT writes on the access target (#9154)
Logs every WebSocket `EVENT` on the access target (`buzz_relay::access`). Before this, accepted writes logged only `Event ingested` on the handler target, and refused or ephemeral writes logged nothing. ### What's logged Each `EVENT` frame writes one `event=write` line when its handler finishes. The line has `conn_id`, `community`, `pubkey` (the authenticated login, if any), `kind`, `event_id`, and `outcome`/`reason`, taken from the OK the relay sent: - `accepted` - `rejected` (INFO), with `reason` set to the OK's NIP-01 prefix (`invalid`, `restricted`, `auth-required`, `duplicate`, `rate-limited`, …). Any other message is logged as `other`. - `error` (WARN), when the OK starts with `error` - `aborted`, when the handler ended before deciding an OK `outcome` is always the relay's decision. If the OK couldn't be queued because the outbound channel was full or closed, the line adds `ok_queued=false`; the field is left off otherwise. `EVENT`s refused before the handler runs are logged too, where their OK is sent: admission rate limits, with admission's reason, and a full handler pool, as `rate-limited`. The OK text after the prefix, the event's content and its tag values are never logged. Lines join to `auth`/`close` lines (and so to `peer_addr`) through `conn_id`, the same way `req` lines do. Rejected writes are capped per connection by the same refusal-budget mechanism as refused reads, on their own counter: the first 16 rejections are logged and the rest are only counted, in a new `writes_suppressed` field on the `close` line. Accepted writes are never capped. ### How `access::WriteLog` sends the OK and records it in one call (`write.ok(...)`). That call replaces every `conn.send(RelayMessage::ok(...))` in `handle_event` and the observer-frame handler, so the line always shows what the client was told. ### Tests - `access::tests`: the reason comes from the OK prefix only; each outcome logs at the right level and without content; rejected lines are capped by the budget and accepted ones are not (draining the channel so every OK is queued); an OK refused by a full or a closed outbound channel keeps its outcome and adds `ok_queued=false`. - `handlers::event::tests::fanout_access`: an unauthenticated `EVENT` going through `handle_event` writes one `rejected`/`auth-required` line, with no content or tag values. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |