mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
b8b425e8b1bf3d6895afeba702929d7dc0bc64d5
3214 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b8b425e8b1 |
fix(db): drop a superseded head's mention rows with it (#8948)
## Summary
Replacing a replaceable or addressable event soft-deletes the previous
head, but its `event_mentions` rows stayed behind. Only the hard-delete
kinds (read-state and mesh status) removed theirs, in a separate
statement. No mention-index read (`#p` filters and feeds) returns a
soft-deleted event, so those rows are pure cost: every `#p` read walks
them and throws them away.
A kind:39002 roster republish leaves one dead row per channel member, so
a large channel piles them up with every join. Relay-emitted replaceable
lists with p-tags do the same.
The statement that retires the old head now deletes that head's mention
rows as well, through a data-modifying CTE over `RETURNING id`, on all
three supersede paths:
- the relay's kind:39002 roster writer
(`LockedMemberSnapshot::replace_member_event`)
- `replace_addressable_event` (kind 0/3/10000–19999, including the
relay-signed kind:13535 list)
- the NIP-33 parameterized path
Because the CTE covers every row the retire statement touches, a
coordinate that somehow had more than one live row also gets cleaned up.
The shared `superseded_with_mentions_sql!` macro sits at the crate root
so all three mention-indexing replacement paths use the same statement.
The kind:13534 membership-list and push-lease writers also retire heads,
with a bare `UPDATE`, but neither indexes p-tags, so they leave no
mention rows.
## What got simpler
The hard-delete special case (`if hard_delete_superseded { DELETE FROM
event_mentions … }`) is gone. Both retire statements go through one
macro, so a head and its mention rows leave together on all three paths.
## Not in this PR
- Mention rows already left behind by earlier supersedes.
`docs/database.md` rules out data backfills in migrations, so these get
cleaned up out of band, in batches.
- Other soft-delete paths (NIP-09 deletion, moderation, channel deletion
retiring its 39000–39002 events) also keep mention rows. They are
low-volume, and are noted here as identified debt.
## Tests
`superseded_heads_drop_their_mention_rows` (pg) has one table row per
supersede path: the member-snapshot roster writer (lock, replace,
release), the addressable path, the NIP-33 path, and the two NIP-33
kinds that are hard-deleted (NIP-RS read state and mesh status). In each
case the old head keeps zero mention rows and the new head is indexed.
Soft-delete rows check that the old head stays soft-deleted, and
hard-delete rows check that it is gone. Reverting the macro, any single
call site, or the hard-delete statement alone fails this test.
🤖 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>
|
||
|
|
298a555e34 |
perf(db): bind the channel scope as one array (#8946)
## Summary Event reads scoped to a set of channels pushed one bind parameter per channel: `channel_id IN ($5, $6, …)`. This applies to a viewer's access scope, which the relay adds to every REQ and `/query` without an `#h`, and to explicit multi-value `#h` filters. As a result, the SQL text depended on the viewer's channel count, and every count was a separate prepared statement. That caused two problems in production: - **Re-planning on every call.** A connection rarely reused a cached plan for these reads, so Postgres planned each one again. With no `since`, the plan covers every `events` partition. For `#p` mention reads (the `event_mentions` join), planning took far longer than executing the query. - **Hidden cost.** Monitoring that groups by statement split one query into one row per channel count, so its total cost never showed up in a single place. This PR binds the set as `channel_id = ANY($n)`, so one statement serves every channel count. On a production replica, the same query prepared with `ANY($5)` moved to Postgres's cached generic plan by its sixth run. Planning time dropped to near zero, and that plan was no slower than the custom one. ## What changed - `push_channel_scope` in `crates/buzz-db/src/store/event.rs` builds the channel-scope predicate as one array bind. - `build_query_events_sql` and `count_events_sql` each had their own copy of this block. Both now call the helper. - Semantics are unchanged: - Access scope still keeps channel-less global events. - Explicit `#h` still matches only the requested channels. - An empty set still means "no channels". ## What got simpler Two duplicated 20-line blocks became one helper. The statement count for channel-scoped reads no longer grows with channel count. ## Generic-plan exposure These statements were already persistent prepared statements, so a cached generic plan was always possible whenever the connection's statement cache held one. This change makes that reuse consistent; it doesn't introduce generic plans. Tag-value reads (`has_tag_values`) still run unnamed, as before. Plain (non-`#p`) access-scoped reads stay on custom plans. A production replica, with a viewer in hundreds of channels, ran the plain page read repeatedly under the default `plan_cache_mode`, and every run used the custom ordered index scan. Postgres estimated the generic plan as far more expensive, and when forced, it was orders of magnitude slower. Postgres switches only when the generic plan is no costlier than the average custom plan, so it never chose this one. The generic plan's cost comes from the `LIMIT` and `kind` values it can't see, not from the array length, and the per-arity `IN (…)` statements had the same blind spot. ## Tests - `channel_scope_sql_does_not_depend_on_channel_count` (unit): for the `#p` join and plain reads, for access scope and explicit `#h`, in both the page SELECT and the COUNT, the SQL for 1 channel equals the SQL for 40 channels. It also checks that the SQL uses `ANY(` and that global rows are retained only for access scope. - `channel_scoped_mention_reads_share_one_prepared_statement` (Postgres): on one connection, access-scoped `#p` reads with 1, 2 and 3 channels leave exactly one prepared `event_mentions` statement. With one mention per channel, one global mention, and one in an unscoped channel, every scope of 0–3 channels, with and without globals, returns and counts exactly its own rows. - The existing scope semantics tests still pass: - `access_scope_is_applied_before_historical_page_limit` - the explicit multi-channel `#h` page and count test - Mutation: reverting the helper to one bind per channel fails both new tests. ## Identified debt (not in this PR) - `get_last_message_at_bulk` still builds an `IN (…)` list per channel. It's a separate, lower-volume statement. - Superseded versions of replaceable roster events (kind 39002) keep their `event_mentions` rows. On large channels, a viewer's roster mention rows grow with every membership change. That's a follow-up. 🤖 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> |
||
|
|
f6198f6997 |
feat(relay): record NIP-FI enforce denials at parity with shadow (#8974)
## What this does
In enforce mode, NIP-FI denials used to be returned without a reason
counter or a log line. This PR records enforce denials with the same
`WouldDeny` reasons, label sets and log fields as shadow. **Admission
behavior does not change**: statuses, close codes and frame order are
all the same.
- **`buzz_nip_fi_enforce_total{route,stage,outcome,community}`**: the
same shape as `buzz_nip_fi_shadow_total`, including an `admit`/`admit`
stage, so `1 - admit/total` is the enforce denial rate. Each socket or
HTTP request gets exactly one verdict.
-
**`buzz_nip_fi_enforce_session_end_total{route,reason=expired|revoked,community}`**:
once per admitted session.
- **`buzz_nip_fi_enforce_strict_proof_total{route,outcome,community}`**:
the outcome of the proof check that actually decided the request.
Nothing is verified twice.
- **Log lines** `nip-fi enforce deny` and `nip-fi enforce session end`
(info, `mode="enforce"`, from `buzz_relay::nip_fi_shadow`). Shadow's
`nip-fi shadow would-deny` gains `mode="shadow"`. Neither mode logs
admits.
### How each verdict is recorded once
- **Sockets (root, audio).** `EnforceSession` moves Pending →
Admitted(pubkey) → Ended under one std mutex, and records inside the
lock with no awaits.
- Root admits at its AUTH commit; audio admits at its join commit.
- A refusal for a non-NIP-FI reason retires the session without a
record. Audio retires it before rollback.
- A deadline the effect gate sees before the expiry worker is claimed at
that seam.
- **Ends.** Revocation and expiry are recorded only by the terminal
transition that wins (`publish_authorization_denied`), and only while
the socket isn't already cancelled. A community close or cancellation
that wins first records nothing.
- **HTTP.** The router guard scopes each enforced request with one
verdict. An agent token's verdict is deferred to the end of the request:
- the first owner-decided refusal is the verdict, otherwise the request
was admitted;
- membership rechecks, batches, Git success and early returns each count
once.
- **Owner provenance** comes from the decision itself
(`BanVerdict{outcome,source}` from one `restriction_state` read), with
no rereads.
- Reasons: `owner_banned`, `owner_ban_unavailable`, `not_stored_owner`,
`owner_link_unavailable`.
- When the agent's own ban read fails and there's a token owner, the
result is `owner/unavailable`, as shadow records it.
- A ban or failed restriction read that ingest catches on `POST /events`
after membership passed replaces the request's admit with `owner/denied`
when the owner's row decided the ban, or `owner/unavailable` on a failed
read. An agent's own ban is not recorded as its owner's.
## Shadow-to-enforce map
| Shadow signal | Enforce counterpart |
|---|---|
| `buzz_nip_fi_shadow_total` denials: upgrade, guard, HTTP admission,
AUTH pairing, deny set, stale binding | `buzz_nip_fi_enforce_total` with
the same stage/outcome, plus a `nip-fi enforce deny` line |
| `buzz_nip_fi_shadow_total` owner stage: ws, audio, http | the same,
from the original decision: root/audio ban seam and owner link; HTTP
`enforce_membership`, smart Git, Git settings, buzz/v1 ban reread |
| `buzz_nip_fi_shadow_total` `deadline/session_deadline` (pending) |
`EnforceSession::expire`, from the worker's winning terminal or the
effect gate that saw it first |
| `buzz_nip_fi_shadow_total` admit |
`buzz_nip_fi_enforce_total{stage="admit",outcome="admit"}`, one per
socket or request |
| `buzz_nip_fi_shadow_session_end_total{reason=expired\|revoked}` |
`buzz_nip_fi_enforce_session_end_total`, same labels |
| `buzz_nip_fi_shadow_strict_proof_total` |
`buzz_nip_fi_enforce_strict_proof_total`, from the deciding check (a
rejection is the route's 403, or 401 when the proof is missing) |
| `buzz_nip_fi_shadow_disconnect_total{admin,accepted}` |
`buzz_nip_fi_disconnect_total` (`api/nip_fi.rs:160`) |
| `buzz_nip_fi_shadow_disconnect_total{admin,propagation_failure}` |
`buzz_nip_fi_disconnect_propagation_failures_total`
(`api/nip_fi.rs:180–185`) |
| `buzz_nip_fi_shadow_disconnect_total{admin,capacity}` |
`buzz_nip_fi_disconnect_capacity_rejections_total`
(`api/nip_fi.rs:196–201`) |
| `buzz_nip_fi_shadow_disconnect_total{cross_pod,capacity}` |
`buzz_nip_fi_cross_pod_capacity_exceeded_total`
(`api/nip_fi.rs:433–434`) |
| `buzz_nip_fi_shadow_disconnect_total{cross_pod,poison}` |
`buzz_nip_fi_cross_pod_shard_poison_total` (`api/nip_fi.rs:442–443`) |
| `buzz_nip_fi_shadow_disconnect_total{cross_pod,lag}` |
`buzz_nip_fi_disconnect_lag_total` (`main.rs:1596–1602`) |
The enforce series was renamed from `buzz_nip_fi_enforce_denied_total`
to `buzz_nip_fi_enforce_total` when it gained the `admit` stage.
## Tests
Infra-free, in the `test-unit` lane:
- `state::enforce_end_is_recorded_only_by_the_winning_terminal`: root
scan, audio scan and deadline, each against a community close or
cancellation first and FI first.
- `nip_fi_http::enforce_strict_proof_records_the_deciding_check_once`:
pass and reject, and no record when cardinality refuses.
-
`nip_fi_shadow_session::audio_owner_link_refusal_shares_roots_seam_and_reasons`:
calls the shared owner-link helper with route `audio`, not a huddle
socket.
- The existing shadow, upgrade, router and HTTP enforce tests.
Postgres lane:
-
`handlers::auth::enforce_root_auth_records_owner_refusals_from_one_decision`:
clean admit then `expired` through the real expiry worker; banned token
owner, banned stored owner, other stored owner and a failed ban read;
authorization reads counted from the decision only.
-
`handlers::auth::enforce_root_deadline_seen_by_the_gate_first_is_recorded_once`:
both orderings.
- `audio::handler::enforce_join_commit_is_the_audio_admission`: drives
the join-commit seam directly, not a huddle socket or the expiry worker:
committed then expired; archived with an expiry held inside the
rollback; gate-first deadline.
-
`api::bridge::token_owner_reaches_membership_on_every_nip_fi_http_route`:
one verdict per request on every NIP-FI HTTP route, including sidebar,
read-state, Blossom and Git, for an owner ban, a stored-owner conflict
and a clear owner.
-
`api::bridge::token_owner_ban_reread_is_the_fixed_nip_fi_denial_on_http`:
own ban, owner ban and unavailable at the reread, on sidebar and Git
settings.
-
`api::bridge::token_owner_publish_ban_is_the_fixed_nip_fi_denial_on_http`:
a late ban or failed read on `POST /events` at the write, typing,
moderation and relay-admin checks records exactly one owner denial and
no admit when only the owner is banned, and no owner denial when the
agent alone or both are banned.
-
`audio::handler::tests::already_expired_audio_bootstrap_records_the_deadline`:
an already-expired bootstrap records `deadline/session_deadline`, not a
deny-set denial.
Each new test names the mutation that turns it red. These were run red:
- deleting root's `session.admit(pubkey)`
- deleting audio's commit `session.admit(pubkey)`
- deleting `effect_refused`
- deleting enforce from `retire_shadow`
- deleting the commit seam's `expire`
- deleting the cancellation guard
- deleting the own-read-failure provenance
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
mobile-v0.27.0-rc.19
|
||
|
|
bf13f7c83b |
fix(ios): mint NIP-FI assertions in the notification extension (#8982)
Organization relays that enforce NIP-FI (federated identity) require a `Nostr-Federated-Identity` assertion on `/query`. The iOS notification service extension signed its `/query` with NIP-98 only, so the relay answered `401` and every push from an enforcing organization relay showed the generic "You have a new message." body. The app already mints these assertions in Dart from its organization account session, but that session lives in the app's own keychain, and assertions last minutes, so a cached one is useless by the time a push arrives. This change lets the extension mint its own: - **App:** whenever the organization `BuilderlabAccountClient` adopts or clears its session, the app writes or deletes a device-only keychain item in the group it already shares with the extension. The item holds the organization origin, the session credential and the organization's exact relay hosts (from `BUZZ_ORGANIZATION_RELAYS`). Builds without an organization never write it. - **Extension:** for a community whose relay host is one of those exact hosts, `BuzzRelayAssertionIssuer` requests an assertion from the organization issuer (`/api/goose/v1/buzz/identity/assertions`, session credential plus NIP-98 with the community's account key, the same request the app makes), checks it was issued for that key, and caches it in-process until 30 seconds before expiry. Both `/query` paths (message lookup and sender/channel enrichment) send it. Other relays get no header and never see the session. - Signing out awaits removal of the shared copy and fails if removal fails; each restore that finds no session retries the removal. - If issuance fails or the user is signed out, the extension sends the query without the header and the relay refuses it, which keeps today's generic fallback. Not covered here: relay-hosted avatars on enforcing relays still load without the assertion (the notification text and title are unaffected), hosted Builderlab communities, and Android. ### Validation Added tests: - `testOrganizationRelayQueriesCarryMintedAssertion`: against a stub relay that answers `401` without the assertion, two organization pushes resolve with message body and sender name from a single issuance, a non-organization relay gets neither the header nor the session, and after sign-out the notification falls back. Removing the header from the query path makes this test fail. - `testOrganizationAssertionIssuanceFailuresSendNoHeader`: refused, wrong-key, expired and header-injection responses produce no header. Subdomains and other ports of the organization relay host are never sent the session. - `builderlab_account_test.dart`: the shared copy follows exchange, restore and clear; a failed removal fails sign-out, and the next restore retries it. Focused existing validation: the full BuzzPushKit suite, the hosted Dart tests, `flutter analyze`, and an iOS device build of the app and extension. Not yet validated: a live push from the Block relay on a device. ## Before merge Live device push test: waived by Tom (2026-11-03). 🤖 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> |
||
|
|
a9aa1f73b0 |
Enable incremental artifact reads on every relay (#8975)
## Why Compatible clients need incremental artifact reads for local collections on every Buzz relay. ## What Expose incremental artifact reads and capability discovery on every Buzz relay. ## How Serve incremental queries through the existing authentication and authorization checks. Keep the existing work budgets and the separate WebSocket sync capability. The HTTP test verifies capability discovery and a successful incremental query. ## Risk The relay starts no additional background work. Clients that use incremental reads consume database capacity. The net database load is not yet measured. Serving writers must maintain the modification timestamps. ## Testing Read-only SQL checks on November 3, 2026 verify the prerequisites below. Each database records successful migrations 76–78. All five required columns have the expected types, defaults, and nullability. All five indexes have the expected definitions and are valid, ready, and live. | Deployment | Schema and indexes | Running timestamp writers | | --- | --- | --- | | Production `bb-block` | Pass | `f6f6d9a` includes PR 8523 | | Production `bb-public` | Pass | `f3a7bab` includes PR 8523 | | Staging `bb-block` | Pass | `b4509de` includes PR 8523 | The checked columns are `artifact_heads.modified_at`, `artifact_revisions.modified_at`, `artifact_revisions.source_channel_id`, `channel_members.sync_modified_at`, and `communities.artifact_policy_modified_at`. The checked indexes are `idx_artifact_revisions_artifact`, `artifact_heads_modified`, `artifact_heads_channel`, `channel_members_sync`, and `artifact_revisions_moves`. At commit `62b26ad`, all 21 NIP-11 tests and the HTTP capability test pass on Linux with PostgreSQL 17 and Redis. The HTTP test checks capability discovery and a successful incremental query. All required push hooks also pass. No manual testing. Human confirmation remains pending. ## Bigger picture [Client PR 1507](https://github.com/block/buzz-app/pull/1507) uses the advertised capability for local artifact sync. Existing deployments receive this behavior when they deploy the relay version that includes this PR. Generated with Codex --------- Signed-off-by: Daniel Rouleau <drouleau@squareup.com> |
||
|
|
ce47aefdc3 |
fix(relay): send membership notices on channel delete, moderation kick and moderation DM (#8977)
Related: [buzz-app#2215](https://github.com/block/buzz-app/pull/2215) ## Problem A connected client learns about membership changes from kind:44100/44101 notices and then re-reads its channel list. Three relay paths changed a member's channel list without sending a notice, so clients only caught up on their periodic full read: - **Channel delete (9008):** no member was told. - **Moderation kick:** the kicked member wasn't told, and the channel's 39000/39002 discovery wasn't refreshed, so it still listed them. - **Moderation DM:** a new, existing or hidden moderation DM didn't surface live. ## Change - **Channel delete:** `soft_delete_channel_capturing_members` (buzz-db) reads the channel type and active members under deletion's existing membership lock, in the same transaction. A join either commits first and is included, or waits and is then refused. After the commit, discovery cleanup and cache invalidation, the relay sends a type-tagged 44101 to each captured member in a spawned task, eight at a time, outside any lock. The 9008 OK doesn't wait for them, and the task is started before the `channel_deleted` system message is emitted, so a system-message failure can't drop them. An already-deleted channel sends nothing. `soft_delete_channel` keeps its signature and delegates. - **Moderation kick:** `apply_kick_live_side_effects` refreshes 39000/39002 after the removal fence commits, then sends 44101 to the kicked member. If the refresh fails, it returns the error before the notice, so the moderation driver's existing mutation-marker recovery retries. If the channel was deleted since the kick, there's no roster to refresh: it removes the channel's discovery events and treats that as converged, so the action still finalizes. The removal/re-add fence and the re-added early return are unchanged. - **Moderation DM:** `send_moderation_notice` drops both participants' cached channel sets on every pod (`invalidate_membership`), as opening a DM does in `command_executor::handle_dm_open`, so the read the notice prompts includes the DM. After clearing `hidden_at`, it republishes the recipient's NIP-DV kind:30622 visibility snapshot (clients filter the sidebar on it), then emits discovery, then sends 44100. It sends the notice on every delivery, tagged `moderation_source` with the delivery's source id, so two deliveries in the same second each get their own 44100. The content JSON is unchanged. A failed snapshot or discovery returns an error, so the outbox retries. - **Ordering guarantee:** each notice is sent after its change is committed and readable on the relay instance that sends it. Ordering across relay instances, and other instances' cached channel sets, are best-effort, the same as for existing membership notices. - A failed notice delivery is logged and never undoes the change, like the existing 9001 path. There's no new retry system. ## Tests - `membership_notice_postgres_tests.rs`: one journey per path. A `#[cfg(test)]` barrier at the point a notice is signed parks the producer, keyed by recipient. While it's parked, the test reads the channel list (and, for the DM, the 30622 snapshot) as a client would on receipt. The barrier can also pin a notice's `created_at`. - The DM journey pins both deliveries to the same second, hides the DM through the real kind:41012 path between them, and checks what a client sees: a live 44100 for each delivery, with distinct ids, then a channel-list read that lists the DM and a visibility read that no longer hides it. - `moderation_dm_notice_follows_a_cached_channel_list` warms the recipient's channel set with a read before the DM exists, then checks the read on receipt of the notice lists the DM. - `soft_delete_captures_members_at_its_serialization_point` (buzz-db): a join and then a delete queue behind a held membership lock. The delete must return the joiner, and a later join is refused. - `crash_recovery_finalizes_kick_after_channel_deleted` (admin): commits a kick, deletes the channel, then runs the recovery worker. The action finalizes, and the member's `removed_at` is unchanged, so the removal doesn't run again. Each ordering test fails when its prerequisite is moved or removed: the kick notice ahead of the roster refresh, the delete notices ahead of discovery cleanup, the DM without the snapshot republish or the cache drop, the DM notice without its source tag (the second notice coalesces), and the member capture outside the lock. The recovery test fails if a deleted channel's `ChannelNotFound` propagates. No spec files change. This reads a channel delete as removing every member under the existing `NOSTR.md` notice promise. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
7d40a66126 |
fix(ios): stop repeating an old message in push notifications (#8973)
## Context Pushes from the gateway carry no event reference (`crates/buzz-push-gateway/src/model.rs`), so the iOS Notification Service Extension resolves each push by querying the user's NIP-PL lease filters and showing the newest matching message. Any push whose trigger wasn't the newest message (a reaction, an edit or deletion, a community query that failed, or several pushes arriving for one message) showed that same newest message again. Tom saw one message notify four times over three hours. ## Behavior - The extension now remembers which messages it has shown, per community and account, in its existing notification cache (`BuzzNotificationCache`). - Each push shows the newest message not yet shown, across all communities. If every candidate was already shown, it shows the generic "You have a new message." text. - A message more than 15 minutes older than the newest one already shown, in any configured community (including one whose query failed), is treated as already covered. So a stale backlog item can't surface hours later, even from a community where nothing has been shown yet. 15 minutes matches the relay's `created_at` drift bound (`MAX_TIMESTAMP_DRIFT_SECS`), so a sender with a slow clock still gets through. A future-dated `created_at` is capped at the time it was shown, so it can't hold back later messages. - Selection and claim happen in one locked cache call. It reads each community's store once and writes only when it records a claim, so concurrent pushes can't claim against a stale view, and a rejected candidate never rewrites the store. - Retention drops only entries the age check would reject anyway. The rest are capped at the union of the newest 256 by capped time and the newest 256 by `created_at`. So neither a burst of messages nor future-dated entries can evict a message the relay still returns. The field is optional, so existing caches still decode with no version bump. - Only the newest 10 matches per community are fetched, as before. If more than 10 messages arrive before their pushes resolve, the extra pushes show the generic text. - The claim happens before presentation. If presentation then fails, that push shows the generic text and the message isn't offered again, which is better than repeating. - If the cache can't be read, dedupe is skipped (fail open), so new messages are never hidden. If a claim can't be written, the new message is still shown. An already-shown message is still rejected, since rejecting needs no write. A revoked scope is skipped. - Dedupe state lives in the extension's `Caches` directory, which iOS can purge. After a purge, the next push can show the newest message again, once. This is the NSE-only fix. It doesn't change the gateway payload or NIP-PL. ## Validation - New test `testEachPushShowsANewMessageAndNeverRepeatsOne` uses a fresh cache instance per push, to model separate extension processes. It covers: a repeat push giving the generic text; out-of-order resolution (late, then early, then nothing); a stale message outside the window; and the future-date clamp. I checked by mutation that removing any one of the three guards (id check, window, clamp) fails it. - New test `testShownMessagesStayShownAcrossCommunitiesFailuresAndBursts` covers the review findings. It checks that: - an old message isn't shown from a community with nothing shown yet, including when the other community's query fails; - a burst of 100 newer messages doesn't evict a shown mention the relay still returns; - a read-only store neither repeats a shown message nor hides a new one; - a future-dated message isn't shown again after later messages. - New test `testFutureDatedEntriesDoNotEvictAClaimedMessage`: 257 future-dated claims don't evict a just-claimed message. - Mutation checks all fail these tests: a candidate-only watermark, retention by `created_at` only, a 64-entry cap, and pruning by capped time. - Full BuzzPushKit suite on `15913d0`: 106 XCTest and 65 Swift Testing tests passed. - Live relay run (on `baac384`): a scratch test drove the real resolver and cache against a local relay. One key posted messages and another resolved five pushes (not committed): | Push | `main` | This branch | | --- | --- | --- | | 1 (after "Hello 1") | Hello 1 | Hello 1 | | 2 (no new message) | Hello 1 | You have a new message. | | 3 (after "Hello 2", "Hello 3") | Hello 3 | Hello 3 | | 4 (no new message) | Hello 3 | Hello 2 | | 5 (no new message) | Hello 3 | You have a new message. | - Automated app check: built and launched the branch on an iOS 27 simulator. The app started and showed the signed-in home screen. Only the extension uses the changed types. - An independent fresh-context review found no blockers. Its comment suggestions were applied. ### Limitations I couldn't run a real APNs push end to end. Dev-signed builds get no gateway pushes, and `simctl push` on iOS 27 doesn't invoke the Notification Service Extension. The live relay run covers the resolver and cache logic the extension runs. 🤖 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> |
||
|
|
40fe4b5d9a |
perf(mobile): evict verified-profile interest in one pass (#9007)
## Summary Follow-up to [#8931](https://github.com/block/buzz/pull/8931), from Wes's [review note](https://github.com/block/buzz/pull/8931#discussion_r4456891410). [#8931](https://github.com/block/buzz/pull/8931) replaced the per-eviction scan in `VerifiedProfilesNotifier.track` with `keys.first`. That was much cheaper than main, but not constant time: removing from a Dart map leaves deleted slots, and each new `keys` iterator walks past them. One `track` call that overflows the 1,024-subject cap by E subjects still costs about E²/2 slot checks. Wes measured ~3 ms at 4,300 subjects, 62 ms at 8,600 and 205 ms at 17,200. Eviction now takes the overflow from a single iteration (`keys.take(overflow).toList()`), so it's linear. Which subjects are kept doesn't change: still the 1,024 most recently inserted. The [#8931](https://github.com/block/buzz/pull/8931) eviction test counted keys yielded by the map, which `keys.first` keeps at one per eviction, so it couldn't fail on the quadratic path. It now counts iterations started over the interest map and expects exactly one per `track` call. Main's scan and the merged `keys.first` loop both start one per eviction. ### Testing - Falsification: with the merged `keys.first` loop restored, the new test fails (`Expected: <1>, Actual: <3276>`). With this change it passes. - `flutter test test/shared/verified_profiles/`: 31 passed. The existing "keeps the 1,024 most recently tracked subjects" journey still passes, so retention is unchanged. - `just mobile-check`: clean. - `just mobile-test`: 4,227 Flutter tests passed, 40 skipped, plus 4 push and 27 crypto tests. - Automated app run: installed this build on an iOS 27 simulator (iPhone 17 Pro) signed in to a Builderlab community, opened the inbox, a channel timeline with images and the image viewer, and the channel details member list, which all track verified-profile subjects. No errors. That community is small (30 members), so the overflow path itself is covered by the unit tests, not the app run. - Agent review: an independent agent review found no blockers. - Human test: waived under the mobile testing exception; the automated app run above replaces it. Not covered: Android, and no new device profile (Wes's AOT numbers above are the cost model; this removes the E² term). 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> |
||
|
|
33eaa73c83 |
fix(mobile): let users sign out after leaving all communities (#8993)
## Summary Fixes a dead-end in signed-in mobile onboarding: after leaving every community, Back kept returning to the same Add a community screen without a way to end the account session. Show “Signed in as [email]” on that screen. Back asks “Sign out of BuilderLab?” with the email; Cancel keeps the session, and Sign out returns to the welcome screen while retaining saved Buzz identities. Nested Back and Add community from an existing session keep their navigation behavior. ### Related issue None found in the existing issue and PR search for onboarding sign-out. ### Testing The new widget journey failed before the change and passes through the real onboarding widgets, account client, storage and flow with external HTTP/browser boundaries mocked. It covers nested Back, Cancel, system Back, durable session removal, retained identity/backup, and starting email sign-in again. The existing hosted onboarding journey also covers accounts without an email. On iPhone 18 Pro simulator (iOS 27), rendered the production onboarding screen and native confirmation using a disposable account fixture. Live browser account selection has not been verified; this clears the app session, not website cookies. To reproduce on an auth-enabled mobile build: sign in, leave all communities, check the displayed email, tap Back, and choose Cancel. Tap Back again and choose Sign out. The yellow welcome screen should appear, with email sign-in available. ## Screenshots Before, from the original reported phone screen: <img src="https://raw.githubusercontent.com/block/buzz/07440b3b10c3f48a6fd19c923f2ee5ba66d574f0/pr-8993--before.png" alt="Before: account state not shown" width="300"> After: Signed-in landing and native sign-out confirmation, using a disposable account: <img src="https://raw.githubusercontent.com/block/buzz/a4e18c85bc3a19d971b08ff595ba6b081c67e20f/pr-8993--landing.png" alt="Signed-in landing" width="300"> <img src="https://raw.githubusercontent.com/block/buzz/a4e18c85bc3a19d971b08ff595ba6b081c67e20f/pr-8993--confirmation.png" alt="Sign-out confirmation" width="300"> --------- Signed-off-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz> Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz> |
||
|
|
d3a4909cca |
perf(search): rank only the newest prefix matches and plan with the real tsquery (#8976)
## Summary Message search is one of the slowest relay queries. Short typeahead prefixes make it worst: `a:*` matches most of a community. **Why it was slow:** the tsquery was built inside a `CROSS JOIN LATERAL` subquery, so the planner saw an InitPlan and couldn't read the `search_tsv` statistics. It fell back to the default 0.5% selectivity for `@@`: for `a:*` on 1M rows it estimated 4,497 rows when 517,065 matched. Every search therefore took every GIN match, ran `ts_rank_cd` on each one, and top-N sorted them before `LIMIT 20`. This PR: 1. **Binds the real tsquery.** `push_tsquery` is unchanged. A separate `SELECT (…)::text` round trip resolves the tsquery, and the search binds it as `$q::tsquery`. An empty tsquery returns no hits without running the search. 2. **Plans each search for its own tsquery.** The search runs with `.persistent(false)`. A cached prepared statement can switch to a generic plan after five runs, and a generic plan can't see the tsquery. Forcing a generic plan lost the gain in testing. 3. **Ranks only the newest 1,000 prefix matches.** Prefix-mode relevance search ranks the newest `PREFIX_RANK_WINDOW` (1,000) matches. The inner `ORDER BY created_at DESC, id DESC LIMIT 1000` matches each partition's primary key read backwards, so for common prefixes Postgres streams the newest rows and stops at 1,000. For rarer terms it keeps the GIN bitmap plan, now with an accurate estimate. Full-text search and profile (kind:0) typeahead still rank every match. ### Behavior change: every human message search (product decision needed) Prefix mode isn't limited to keystroke typeahead. buzz-app (`useSearchMessages.ts`) and legacy desktop (`desktop/src-tauri/src/commands/messages.rs:184`) send `search_mode: "prefix"` for **all** message search. Full-text search runs only on the WS REQ path, which agents and the CLI use. So for people: - Results are the best-ranked of the newest 1,000 matches in the search's scope, not of all matches. If more than 1,000 newer messages also match, an older, more relevant message drops out of ⌘K results. The scope includes `#channel`, `from:` and date bounds, so a narrower search has the same limit within it. People have no full-text fallback. - The bridge honors `page`, and buzz-app loads later pages at 20 per page. Every page ranks the same window, so paging stays consistent, but results end after 1,000 matches (page 50). Full-text search (agents/CLI) and profile (kind:0) typeahead still rank every match. ### Perf risk: matches the backward scan has to walk far to find The planner picks the backward scan when it expects 1,000 matches near the top of the community, and it estimates that by treating each filter as independent. Two known shapes break that assumption. **Terms common in old messages but absent from recent ones.** The planner assumes matches are spread evenly over time. For a term that was common long ago but doesn't appear recently (an old project name, a former teammate), it can pick the backward primary-key scan and walk every newer row before reaching the first match. Reviewers measured this on 1.2M synthetic rows: about 2.05–2.1 s, against 0.7–1.8 s for the old query shape and 1.2 s with the GIN plan forced. The 20 s statement timeout and the #8954 cancel still bound it. The worst case is close to the old `a:*` worst case, but it now scales with community size, not match count. The planned EXPLAIN on production-shaped data will include such a term. **Terms common in channels the searcher can't read.** Human search always adds `channel_id = ANY(<readable channels>) OR channel_id IS NULL` (`build_search_channel_scope_filter`). A term that is common in private channels or other people's DMs can get a high match estimate, take the backward scan, and walk the community's rows to find a few readable matches, where the old shape used the GIN bitmap. This hasn't been reproduced yet; the production-shaped EXPLAIN will include such a term next to the old-only one. The same timeout and cancel bound it. ### Cost - One extra round trip per search to resolve the tsquery (about 0.03 ms of execution). It runs on the same pool checkout as the search (`cancel::fetch_all` takes the caller's connection), so a busy pool queues each search once. An IMMUTABLE SQL function could remove the round trip, but that needs a migration, so it's not done here. ### Related issue - Follows #8954 (cancel abandoned searches, bound the search pool) and #8964 (closed in favor of #8954). - Builds on #8947 (3-character server-side prefix minimum), merged from main. The resolve step passes its minimum. When the trailing prefix is too short, the resolved tsquery is NULL, and the search returns no hits without running the second query. - Client-side pairing: block/buzz-app#2210 (3-character last-word minimum for ⌘K message search). ## Testing **EXPLAIN ANALYZE on a synthetic 1M-row `events` table** (monthly partitions, GIN on `search_tsv`, kinds 9/40002/40008, custom plans, warm cache): | Prefix | Matches | Before | After | Plan after | |---|---|---|---|---| | `a` | 517k | 322 ms | **0.7 ms** | backward primary-key scan per partition, stops at 1,000 | | `th` | 137k | 127 ms | **1.2 ms** | backward primary-key scan, stops at 1,000 | | `a` (community with 10% of rows) | — | ~100 ms | **1–2 ms** | backward primary-key scan | | `deploy` | 8.2k | 15 ms | 5–10 ms | GIN bitmap, accurate estimate | | `d278` | 500 | 1.1 ms | 0.5 ms | GIN bitmap | | rare word | 0 | 0.07 ms | 0.03 ms | GIN bitmap | **Postgres integration tests:** - New test `prefix_relevance_ranks_only_the_newest_matches`. An old, highly relevant message drops out of prefix results behind 1,000 newer matches but still ranks first in full-text search. Prefix pages end at page 50. An old, best-ranked profile still tops profile typeahead behind 1,000 newer profile matches. Removing the window fails the test, and so does applying it to profiles. - `abandoned_search_cancels_its_statement`: the `pg_stat_activity` matcher now looks for `ts_rank_cd(`, because the old `search_query.query` text no longer exists. - Locally, each test ran against a fresh database because `cargo-nextest` isn't installed, so the hosted lane hasn't run yet. 26 of 27 pass. `artifact_search_uses_only_live_current_head` also fails on unmodified main in that local setup, so the failure isn't from this change. **Checks:** `cargo fmt --check` and `cargo clippy -p buzz-search --all-targets -D warnings` are clean. **Not yet done:** - hosted CI; - a manual check against a local relay with ⌘K typeahead; - EXPLAIN against production-shaped data, including a term common only in old messages and a term common only in channels the searcher can't read. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Salman Mohammed <smohammed@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3f03aa0450 |
fix(mobile): restore ordinary DM behavior for Bestie (#8987)
Mobile Bestie used a special flat timeline that hid reply threads. It now opens the normal DM screen, matching desktop’s pinned Bestie (block/buzz-app#1935), with existing threads, reply actions, and top-level sending in the same conversation. Thread-scoped agent typing in DMs stays beneath its originating message. Working avatars and mobile’s bouncing dots share a compact neutral container, matching desktop’s subtle activity control. The elbow is visible from the start, with the working container already at the final thread-footer position. For a single-agent reply, the same avatar stays mounted while the container and dots fade out over 200 ms, the avatar slides into its summary position, and the reply count fades/scales in from 95%. The elbow stays fixed. Reduced motion switches immediately. The avatar-to-dots gap is 4 points. Reply text uses its natural line height so its glyphs center on the avatar and elbow rather than drawing at the top of the footer. Multi-participant summaries retain their grouped layout. The accessible View thread action, reaction placement, and final reply-footer position are preserved. Validation: - The final commit (`c6fef5d80`) passed full analysis, 4,220 Flutter tests (40 skipped), 4 push-configuration tests, and 27 crypto tests. - Container alignment checks pass in both directions, including avatar continuity, intermediate fade and final geometry, reduced motion, and thread actions. Glyph-box alignment is checked against the avatar center. An additional regression covers a reply arriving before typing stops and work restarting mid-transition. - Updated DM journeys cover opening Bestie, existing threads, threaded and top-level sending, swipe-to-reply, and absence of duplicate main-row thread typing. - Installed the final product source (`c6fef5d80`) in place on Pixel 10 and iPhone Air previews. The iPhone uses a release-mode preview so it launches from the home screen; saved Kenny Lopez / Starry Night conversation verified after install. Pixel retains Testing Agent Sessions. The pending/midpoint/completed handoff was rendered in widget checks, and the corrected reply-label alignment was verified on the Pixel against the same messages before and after; human acceptance of this final animation remains pending. - Mandatory push hooks passed, including file-size and branch checks. A rendered widget preview verifies the container and elbow; live acceptance remains pending. Self-review found no blockers in the changed paths. Review status is preserved. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
a66cb5f7ab |
Modernize mobile channel and DM creation sheets (#8942)
Modernizes New Channel and New DM with full-height forms and X/Create or X/checkmark actions. iOS uses UIKit sheets throughout; Android uses the Flutter fallback. Channel menus default to Ongoing/Public, and native menu previews lift only the trailing value while labels stay fixed. Channel names disable automatic capitalization, autocorrect, and spellcheck; both platforms use descriptive placeholders. Preserves recipient search and limits, creation error retries, and navigation after native dismissal. Validation: 4,154 mobile tests passed (38 skipped), auxiliary suites and static checks passed; iOS device/simulator and Android builds passed. Exercised menus, dismissal, DM search and selection in the simulator and Android form behavior on Pixel; iPhone UI tested and approved by the author. | iOS native form (Simulator) | Android Flutter form (Pixel) | | --- | --- | |  |  | Native menu with the left label fixed:  --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
344f007f36 |
Add mobile Bestie onboarding and fix remote agent presence (#8957)
An empty Bestie slot cycles still posters from Fuzzies, Gloopies and Figgies every five seconds, without creating video players. The next poster is precached; reduced motion and hidden-screen pauses are preserved. The launcher uses the neutral “Bestie” label until a selection is available, then “Chat with …”. For supported communities with no owned agents, tapping opens Create agent directly. A successful first-agent setup saves that agent as Bestie without replacing an existing preference; existing-agent and unsupported-community paths keep the Agents list. Verified Builderlab registrations can show sleeping remote agents as Online. Owner-scoped saved runtime availability now prevents a known absent or deleted runtime from returning Online after Builderlab sign-out or a fresh load, even when another community retains its registration. Validation: mobile static checks; 4,274 Flutter tests passed with hosted flows enabled; launcher → cancel → create → Home “Chat with Scout”, including encrypted persisted selection; sign-out, fresh-load, owner isolation and deletion regressions. Restoring the old carousel produced three video-player requests; removing the saved-availability guard reproduced incorrect Online after sign-out. The earlier iPhone recording below shows the original carousel and entry flow and stops before creation. The new poster-only behavior and completed creation are covered by widget tests, not a new device recording. https://github.com/user-attachments/assets/94fcff72-967a-44c2-99d5-27c715e196a2 --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
0e5a69962e |
perf(mobile): evict verified-profile interest in constant time (#8931)
## Summary `VerifiedProfilesNotifier.track` trims its interest map to 1,024 subjects, but it finds the oldest subject by scanning every entry, once per eviction. Opening #announcements (4,302 members) evicts about 3,300 subjects, which is roughly 3.4 million comparisons. On a 12 mini Profile build that was 316 ms of main thread, inside the channel's 134 to 167 ms opening frames. It runs whether or not the relay serves verified profiles, so it also affects the prod Block community. A subject's interest is set once, on insertion (`??=`), so the map's insertion order is interest order. The oldest subject is therefore `keys.first`, and eviction is constant time. Which subjects are kept does not change. ### Related issue None found. ### Testing - New `verified_profile_directory_test.dart` case: tracking 4,300 subjects keeps exactly the 1,024 most recently tracked ones, and tracking a subject again does not refresh its age. This test checks that behavior is preserved, and it passes on main too. The speedup comes from removing the per-eviction scan, which the device CPU profile attributed. - `just mobile-check` passes. `flutter test test/shared/verified_profiles/` passes (30 tests). The pre-push hook ran the mobile unit tests. - Device (12 mini, Profile build, opening #announcements, first 5 s; runs not matched): `VerifiedProfilesNotifier.track` inclusive CPU 316 ms before, 14 ms after. Opening frames of 134 to 141 ms remain, mostly the channel name scope (follow-up). Not covered: a human test (waived under the mobile testing exception), Android. Separately, the 1,024 cap still churns subscriptions for rosters larger than that. This PR doesn't change that design. 🤖 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> |
||
|
|
d44031f418 |
perf(relay): cut writer-pool round trips past the 48k relay ceiling (#8846)
## Summary Five changes that take load off the writer pool and the relay's connection bookkeeping. On a single-relay load rig, the clean ceiling moves from 40k to about 48k WebSocket clients. In the A/B, connect p99 at 48k fell from 4.1 s (abort) to 1.65 s. 1. **Ban/mute restriction cache with a 1 s max age.** HTTP API requests, publishes and ephemeral writes read `(community, pubkey)` restriction state through one cache. An entry is served for 1 s from when its read began, matching the open-channel cache's bound. Socket admission, moderation-command actor checks and the `/buzz/v1` ban recheck still read fresh. An admitted socket outlives the 1 s window, so a banned client that reconnects inside it would otherwise keep a socket open indefinitely. A ban drops the target's entry, and each owned agent's entry, on every pod as part of the live-disconnect fan-out, best-effort. Timeouts, untimeouts and unbans drop it on the pod that handles them. 2. **Fewer round trips per event read.** Replica reads skip the per-read `statement_timeout` tighten, because the reader pool installs the deadline at connect. They keep the savepoint, so a failed or cancelled read leaves the session usable and releases its locks. That takes them from 4 round trips to 3. Writer reads send `BEGIN` and the tighten as one message, which also takes them from 4 to 3. 3. **Concurrent open-channel cache misses share one read.** The first miss in a community takes a per-community gate and reads; the others wait for it and use its result. A waiter whose leader failed reads once on its own and does not queue again. A waiter also stops waiting after the cache's 1 s max age and reads on its own, because the leader's set would be stale by then anyway. So a leader whose read hangs stalls only its own request. 4. **`has_admitted_connection` is one map lookup.** A `(community, pubkey) → admitted count` index is maintained under each socket's pubkey lock in `mark_admitted`, `set_authenticated_identity` and `deregister`. It replaces a scan of every connection on each disconnect. 5. **Host → community cache with a 1 s max age.** Only successful lookups are cached; unmapped hosts and errors go to the database every time. ## What got simpler - The ban handler's separate pod-local cache drop is gone; the disconnect every ban already runs covers it on every pod. - One write-restriction gate instead of two. `enforce_cached_write_restriction`, `enforce_relay_membership_ephemeral` and the `cached_ban` parameter chain through `enforce_membership` / `admit_member` / `deny_banned` are deleted. - The ephemeral path's separate 30 s ban cache is gone, so the relay has one staleness rule for restriction state instead of two. ## Notes - Bans reach the restriction cache on every pod. Every ban path (signed command, report action, relay admin) closes the target's and its agents' sockets clusterwide, and each pod drops those entries in the same step. A dropped Redis publish, or a restriction read already in flight when the drop lands (it can refill the old row), falls back to the 1 s bound. Timeouts on other pods also rely on the 1 s bound, and so do unbans and untimeouts, which fail closed. - On the load rig the restriction-cache hit rate was 28%, because HTTP agents poll every 5 s. A longer TTL with Redis invalidation would raise it, but that is a separate change. ## Load test Single relay pinned to 8 cores, native Postgres 16, a seeded fixture with 2,000 open channels, and a 6-shard generator. Steady ramp with 120 s holds; a step aborts at p99 > 3 s. Each step adds 10% HTTP agents. | clients | base connect p99 | this PR connect p99 | base relay cores | this PR relay cores | |---|---|---|---|---| | 32k | 34 ms | 31 ms | 1.71 | 1.66 | | 40k | 45 ms | 26 ms | 2.33 | 2.15 | | 48k | 4,107 ms (abort) | 1,652 ms | 4.7 | 3.0 | | 56k | n/a | 4,535 ms (abort) | n/a | 4.0 | Open-channel re-reads over the run fell from 72k to 1.6k. The tenant cache hit rate was 94%. 48k is this branch's knee. A later run of the same build aborted at 48k: connect p99 2.2 s, but 29% subscribe errors from the concurrent-handler cap. With a 10 s restriction max age, it passed 48k, at connect p99 1.56 s, 60% hit rate and 10% fewer relay cores, and both arms stopped at 56k. ## Testing - `cargo fmt --check`, `cargo clippy -p buzz-relay -p buzz-db --all-targets -D warnings` - `cargo test -p buzz-db --lib` and `-p buzz-relay --lib`, run in parallel. The only failures were 8 DB-seeding tests that need a migrated default database; the Postgres lane covers them. Commits after that run changed the replica savepoint, the `/buzz/v1` recheck revert and some test edits. - Postgres lane at head: `scripts/postgres-test-run.sh -p buzz-relay -p buzz-db --lib --bins --tests`. All 1,183 tests passed. - Mutants. Each one must turn its new test red: - waiters skipping the gate - a no-op index removal - the tenant cache ignoring age - the restriction cache ignoring age - a waiter waiting on the gate with no limit - a disconnect that keeps the cached restriction row - replica reads dropping the savepoint buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d005c7bb9c |
fix(search): skip message prefix search below three characters (#8947)
## Summary
Typeahead surfaces (the buzz-app and desktop topbars) send a NIP-50
search with `search_mode: "prefix"` whenever typing pauses. A one- or
two-character trailing `:*` prefix matches a large share of every
indexed message. The GIN index on `search_tsv` can't stop early or
return rows in rank order, so Postgres fetches, rechecks and ranks every
match before it applies `LIMIT`. In a large community, each of these
queries costs seconds of database CPU.
With this change, prefix-mode `search()` returns no hits when the
trailing whitespace token yields a lexeme shorter than three characters
(`PREFIX_MIN_CHARS`). The check runs inside the prefix tsquery SQL, on
the output of the same `to_tsvector('simple', …)` call that decides
which lexemes get `:*`. Punctuation therefore can't pad the count:
`pr.`, `@pr`, `#pr`, `c++`, `don't` and `pre-p` are all held back, and
`project ?` still runs, because `?` yields no `:*` lexeme. When the rule
applies, the tsquery is NULL, so it matches nothing and the GIN index
lookup returns no rows. Completed words in front of the trailing token
don't matter: `"the p"` runs the same broad `p:*` lookup as `"p"`.
Kind:0-only queries (profile typeahead) are exempt because there are few
profiles, and the short-name exact-lexeme ordering relies on one- and
two-character queries. `search_profile_names` is unchanged. Full-text
mode is unchanged.
Only the HTTP bridge (`POST /query` with `search_mode: "prefix"`)
reaches prefix mode. WebSocket REQ search always uses full-text mode, so
this change doesn't affect it.
## What got simpler
The kind:0-only predicate is now computed once (`profile_only`) and
shared by the new minimum and the existing exact-lexeme ordering. The
minimum lives in the one SQL fragment that already tokenizes the query,
so there is no second tokenizer in Rust to keep in sync with Postgres.
No new concepts beyond one constant.
## Known limits
- This bounds the worst case only. A common three-character prefix can
still match enough rows to be expensive. Bounding that needs a product
decision, such as a recency window for typeahead or a different index.
- Two-character CJK words (for example `東京`) get no message typeahead
until a third character. A trailing contraction or hyphenated word with
a short part (`don't`, `e-m`) gets none until the next word is started.
Full-text mode is unaffected.
- Clients should not show "no results" for one- and two-character input.
A client-side minimum is a companion change. That includes the legacy
desktop topbar in this repo (`desktop/src/features/search/hooks.ts`,
which searches at 2 characters globally and 1 in a channel); it is left
out here to keep this PR relay-only.
## Testing
- Extended
`prefix_mode_matches_final_token_prefix_without_changing_full_text` with
a table of prefix queries and the exact hit sets they must return. It
pins no hits for a trailing token that yields a lexeme below three
characters: alone, after a completed word (`project pr`), padded with
punctuation (`pr.`, `@pr`, `#pr`, `...p`), as a contraction (`don't`),
and hyphenated (`pre-p`). It covers `kinds = [9]`, no kinds, mixed `[0,
9]`, and `Newest` sort. Each of those rows has data it would match
without the minimum. The same table has controls that must still match:
`pro.`, `project ?` (punctuation-only trailing token), `pre-pro`, `t
pan` (a short completed word stays exact and isn't limited), and a
one-character kind:0 `p`. A full-text `don't` control shows that
full-text mode is unaffected.
`short_kind0_prefix_prioritizes_exact_lexeme_on_a_noisy_page` pins the
kind:0 exemption.
- Mutation-checked on Blox. Each of these fails a test: removing the
minimum, removing the kind:0 exemption, and applying the minimum to
every lexeme instead of only the trailing ones.
- Synthetic plan check on Blox Postgres (300k rows, GIN on a generated
`simple` tsvector, same `CROSS JOIN LATERAL` query shape). `'pr':*` took
3.6 s and read 10,411 buffers. The NULL tsquery took 0.01 ms: the bitmap
scan returned 0 rows and read no buffers.
- Two existing prefix tests used a two-character trailing token
(`project pl`, `alpha be`). They now use three or more characters
(`project plan`, `alpha bet`) and assert the same behavior.
- Blox: `cargo fmt --check`, `cargo clippy -p buzz-search --all-targets
-D warnings`, `buzz-search` unit tests, and the `buzz-search` Postgres
lane (`scripts/postgres-test-run.sh`).
🤖 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>
|
||
|
|
58eb36dd57 |
perf(mobile): cheaper identity-name rebuilds for large rosters (#8936)
## Summary Every identity-name source change (a profile batch, a verified name) rebuilds a channel's naming scope over its whole member list. In a 4,000-member channel, each rebuild derived every key's naming fact more than once: owner loading and resolution each derived it, and so did each label for a key outside the scope. It also checked every key against a regular expression several times, and iOS AOT builds run regular expressions in an interpreter. - `IdentityNames` derives each key's naming fact once per scope. - `isHexPubkey`: a character-loop hex check, used by the identity-name code instead of the regular expressions. Labels are unchanged. ## Measurements These were taken with an earlier version of this PR that also cached npub encoding. That cache was removed to keep the change small, so the remaining gain is smaller than shown. I haven't re-measured it. - **Synthetic** (Mac, `flutter test`, 4,391 members, 60% with profiles; scope, `missingOwnerProfiles` and every label for each new sources object): a warm rebuild took 63 ms before and 12 ms with the cache. The first rebuild took 120 ms before and 91 ms with the cache. - **Device** (iPhone 12 mini, Profile build, first 5 s after opening #announcements with 4,391 members; runs not matched): `channelIdentityNamesProvider` plus `watchChannelIdentityLabel` inclusive CPU was 148 ms before and 81 ms with the cache. Of that, `fullNpub` (npub encoding, the cached part) went from 62 ms to 22 ms. The identity path no longer calls `RegExp.hasMatch`. ## Testing - A new test counts profile lookups. Owner loading, every label and an outside key read each member's profile once. Without the memo, each is read 3 times. - New `isHexPubkey` tests cover both cases, wrong lengths, characters just outside each range, and a bad character at every position. - `just mobile-check mobile-test` passes. 🤖 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> |
||
|
|
369e1ef322 |
perf(mobile): stop re-parsing message markdown on every rebuild (#8928)
## Context Each message in a channel shows its text as Markdown. The `GptMarkdown` widget parses that text. Parsing is slow for long messages. `GptMarkdownTheme` gives Markdown colours and styles to the widgets below it. When it gets a new theme data object, it tells all `GptMarkdown` widgets below it to parse their text again. It compares the old and new objects by identity, because `GptMarkdownThemeData` has no value equality. Thus a new object with the same values also causes a parse. ## Problem Two widgets made a new theme data object on each build: - `MessageContent` made one for each message bubble. - `AppMarkdownTheme` made one for the full app. Thus each rebuild parsed all messages on the screen again. Many events cause a rebuild: a batch of profiles, a read marker, a keyboard frame. In a large channel such as #4things (1,235 members), these events occur almost all the time. ## Change - `MessageContent` keeps its bubble theme until one of its inputs changes. The inputs are: the app Markdown theme, the link colour, own or other message, the ink colour and `onSurface`. - `AppMarkdownTheme` makes new theme data only when `ThemeData` changes or when an outer Markdown theme changes. A change of colour, of brightness, or of own or other message still makes new theme data. Then the Markdown parses again with the new colours. This is correct. ### Related issue None found. ## Testing - New regression tests fail on main. They pass on this branch. - `message_content_stability_test.dart`: When a bubble rebuilds with the same inputs, it keeps the same theme object and the same parsed `TextSpan`. The test does this for own messages and for other messages. When own or other changes, the bubble gets a new theme. - `app_markdown_theme_test.dart`: The app keeps one theme object when it rebuilds. It makes a new one when light or dark mode changes. - `just mobile-install mobile-check mobile-test` passes. - Device test: iPhone 12 mini, Profile build, Tom's Block community, open #4things. The two runs had different live traffic and different cached content. Thus they show the cause and the fix. They do not give a controlled speed measurement. - Before: In the first 2.4 s, parses from rebuilds (`performRebuild`) used approximately 90 ms. One frame used 116 ms. Approximately 48 ms of that frame parsed visible messages again. - After: In 30 s, rebuilds caused no parses. Only the first build of each message (`_firstBuild`) parsed. - An independent agent review found no defects. It found one small item: `ChatColors` has no value equality. Thus a rebuild of `MaterialApp` still makes new app theme data one time. This costs only a little performance. We did not change it. Not tested: A human test did not occur. The mobile testing exception waives it, and an agent did the device test. No test occurred on an Android device. 🤖 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> |
||
|
|
eee75a1c7b |
perf(mobile): verify owned-agent registrations natively (#8938)
## Summary `loadOwnedAgentDirectory` checks the signature of each kind:30177 agent registration before it trusts the registration's `model`. It did this with `nostr.Event.fromJson`, which runs the `nostr` package's pure-Dart Schnorr code on the UI isolate. This change uses `verifiedNostrEventFromJson`, which the app's other verification sites already use. It makes the same timestamp, event id and BIP-340 signature checks with the bundled libsecp256k1. It also requires lowercase hex for the pubkey and signature, which relay events already use. A registration that fails verification still lists its agent without a model, as before. ## Measurements - **Synthetic** (Mac, `flutter test`, 20 signed registrations, warm): pure Dart 163 ms, native 2.6 ms. - **Device** (iPhone 12 mini, Profile build, first channel open after launch, first 5 s; runs not matched): `loadOwnedAgentDirectory` inclusive CPU was 71 ms before (69 ms in `Event._assertValid`) and 2 ms after. ## Testing - New test: a registration with a wrong signature still lists the agent, with no model. The correctly signed registration keeps its model. - `just mobile-check mobile-test` passes (pre-push hook). 🤖 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> |
||
|
|
cad7f1d53d |
perf(mobile): build composer channel names once per channel list (#8943)
## Summary `_useComposerChannelNames` ran on every `ComposeBar` build. Each time it built a lowercase-name to id map over every channel and joined every entry into a string key, only so a `useEffect` could skip calling `setChannelNames`. The composer rebuilds on every keystroke and keyboard animation frame. This memoizes the map and key on the channel list instance. The effect is still gated on the content key, so an equal new list still doesn't call `setChannelNames` again. ## Measurements iPhone 12 mini, Profile build, Builderlab, raising the composer keyboard in #4things: `_useComposerChannelNames` was 26 of 31 `ComposeBar.build` CPU samples (about 26 ms of UI work across the 600 ms raise). Limitation: I haven't re-measured on the device after the change. The removal is shown by the test below. ## Testing - New test: rebuilding the composer 5 times (keyboard insets and typing) with the same channel list doesn't iterate the list again. It fails with the change reverted (8 iterations instead of 3). - New test: changing the channel list still updates the composer's channel chips. - `just mobile-check mobile-test` passes (pre-push hook). 🤖 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> |
||
|
|
fc9e652c0e |
perf(mobile): format a channel timeline only when its events change (#8929)
## Summary The channel page ran `formatTimeline` on every build. The page also rebuilds for changes unrelated to messages: read state, presence, the roster, keyboard and layout. `formatTimeline` walks every loaded event and JSON-decodes each system row. In #4things (1,235 members, mostly join rows), on a 12 mini Profile build: - 58 ms of main thread over 21 idle seconds. - About 11 ms per frame while the keyboard animated. The messages notifiers publish a new list for every event change, so this memoizes the result on that list, the current pubkey, and the forum flag. `buildMainTimelineEntries` and the relay thread summaries it reads stay outside the memo. A summary-only update that republishes the same list still shows up. ### Related issue None found. ### Testing - Updated `loading_review_tests.dart`: unrelated page rebuilds now format the timeline 0 times, where main formats once per rebuild (the test fails on main). A new live event formats exactly once, and its message appears. - `just mobile-install mobile-check mobile-test` passes. - Device (12 mini, Profile build with this change, #4things idle). Runs are not matched, so this shows the mechanism, not a controlled speedup. - Before: 58 ms over 21 s. - After: 7 ms over 18 s, all from real event changes. - An independent agent review found no defects. It checked that `formatTimeline` reads only its arguments, that every publish path creates a new list or republishes unchanged events, and that nothing downstream mutates the shared result. Not covered: a human test (waived under the mobile testing exception; this was an agent-driven device run), and Android. 🤖 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> |
||
|
|
478cf0a97e |
docs(mobile): recommend Xcode 27.1 (#8949)
Updates the recommended Xcode from 27.0 to 27.1 in `mobile/README.md` (iOS toolchain section and the `DEVELOPER_DIR` example) and the `CONTRIBUTING.md` prerequisites table. Release pipelines move to Xcode 27.1.0 in [squareup/buzz-releases#118](https://github.com/squareup/buzz-releases/pull/118), because mobuild CI images no longer install 27.0. This repo's GitHub CI uses `macos-latest` with no Xcode pin, so no workflow changes are needed. ## Testing - With Xcode 27.1 (27A9275) selected, a clean `flutter build ipa --release --no-codesign` of `mobile/` on main succeeded (`Buzz.xcarchive`, 95s). That is the release pipeline's IPA build without signing. - Docs-only change, so no app test run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
76bc66acfc |
mobile: hold a queued list read for the 10s gap (#8958)
## Summary Follow-up to [#8910](https://github.com/block/buzz/pull/8910) for the two optional notes from its review: - **A queued list read now waits out the 10s gap** ([note](https://github.com/block/buzz/pull/8910#discussion_r4447689472)). If a change comes due while a slow read is still running, the read is queued. When the slow read finished, the queued one used to start right away, even if the gap after the previous read hadn't passed. That broke the "reads start at least 10s apart" rule: a read could start 9s after the one before. The queue now drains through `_readListIfChanged`, which holds the read until the gap ends. If the finished read already covered the change, nothing is read. - **A test for a partly failed unread catch-up** ([note](https://github.com/block/buzz/pull/8910#discussion_r4447689485)). Previously, deleting the line that retries a catch-up when only some channels' fallback reads failed left the suite passing. ### Related issue N/A. Follow-up to [#8910](https://github.com/block/buzz/pull/8910). ### Testing - New step in the open-list journey: the relay answers each of a read's two queries in 7s. A change that comes due while that read runs is read once, after the gap, not as soon as the slow read ends. Restoring the old drain fails the step. - New step in the resume journey: after a reconnect, the batched catch-up fails over to per-channel reads, and one channel's read is refused. That channel turns unread on the retry, and nothing more is read over the next 10 minutes. Removing the partial-failure retry fails the step. - `FakeRelay.holdQuery()` holds HTTP queries, so a test can make a read slow. - The full mobile suite passes, and `dart format` and `flutter analyze` are clean. - This is a timing-only change to background list reads, with no UI change. The journeys above drive the real notifiers against a fake relay. I did no device run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ff6ab96e42 |
fix(mobile): drop the # from Solo chat names in push notifications (#8955)
## Summary
Push notifications for Solo chats were titled like channels (`#Trip
planning`). The iOS notification extension added `#` to every stream and
forum conversation name, but the app presents Solo chats as personal
chats, not channels.
- `BuzzPushCachedChannel` gains optional `isSolo` and `creatorPubkey`,
derived from relay kind 39000 metadata. `isSolo` uses the same rule as
the app's `Channel.isSolo`: a private stream whose NIP-CL labels (every
`t` after the leading type) include `buzz.solo`. `creatorPubkey` is the
relay-authored `P` tag.
- The resolver uses the bare channel name only for the viewer's own Solo
chats (matching `Channel.isPersonalChat`, which requires `createdBy ==
viewer`). Someone else's Solo chat, and every other channel, keeps
`#name`.
- `isSolo` is optional, so existing App Group caches still decode.
Entries cached before this change have no flag and keep the `#` until
their metadata is refreshed.
## Validation
- `swift test --package-path mobile/ios/BuzzPushKit`: 104 XCTest and 66
Swift Testing tests pass, including two new ones:
- `testOnlyOwnSoloChatConversationNameDropsChannelHash`: a fresh Solo
metadata fetch yields `Trip planning` when the viewer created the chat
and `#Trip planning` when someone else did. Removing the creator check
makes it fail.
- `soloClassification`: only private `stream` channels labelled
`buzz.solo` are marked Solo (open, forum, label-only and unlabelled
private streams are not), and the `P` creator is cached lowercased.
- Live relay check: a local relay with synthetic identities, a Solo chat
("Weekend plans") and a private channel ("design-review"), each with a
second member who posts. The production `BuzzPushNotificationResolver`
(the code the notification extension calls), run against relay-built
kind 39000 metadata, gives:
| | Solo chat | Channel |
|---|---|---|
| main | `#Weekend plans` | `#design-review` |
| this PR | `Weekend plans` | `#design-review` |
- Not covered: a banner rendered by the notification extension. `simctl
push` delivers to the simulator as a local notification and never
launches the extension, and a real APNs push needs credentials this
machine doesn't have.
## Follow-up after merge
Merging on the live relay check above. Still to do once a dogfood build
includes this change:
- [ ] On-device push check on a BLIMP: a Solo chat push shows the bare
name and a channel push keeps `#name`.
- [ ] Before/after notification 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: Claude Opus 5.5 <noreply@anthropic.com>
mobile-v0.27.0-rc.18
|
||
|
|
972e89ac89 |
fix(mobile): allow community removal without a cached push grant (#8978)
## Summary Fix community removal failing with “Expected exactly one endpoint grant for push lease revocation” when saved push state exists but the current build has no cached grant, including builds without a push gateway. When the community has a saved installation ID, journal its targeted revocation without requiring the grant. An unknown grant expiry stays unknown: retain cleanup credentials until the relay accepts the revocation. Existing known-expiry cleanup and legacy grant-based addressing remain unchanged. ### Related issue No matching open issue found. Follows [the durable revocation outbox](https://github.com/block/buzz/pull/8706). ### Testing The new regression fails with the reported error on the preceding implementation. It now verifies missing-grant enqueue, offline retry, persistence across restart, the exact lease address and eventual cleanup after acceptance. On a physical iPhone with the fix applied to the onboarding test build, the previously failing community removal succeeded. Unknown-expiry records retain their signing key in secure storage until successful revocation. Downgrading to a client that requires an integer expiry will reject those pending records. Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz> |
||
|
|
09b21cbe63 |
fix(mobile): label community switching consistently (#8979)
## Summary Rename the mobile community menu action from **Switch workspace** to **Switch community**, matching the Switch Community page it opens and avoiding confusion with billing workspaces. ### Related issue N/A. No duplicate found. ### Testing The existing home-page widget journey opens the community menu and verifies the new label. Before/after captures show the production Flutter menu with disposable fixture data. ## Screenshots ### Before  ### After  Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz> |
||
|
|
6684f7e2f0 |
fix(mobile): make the Join button readable in dark mode (#8969)
## Summary After scanning or pasting a community invite in dark mode, the native Join button can display white text on a white background. This fix passes the theme’s foreground color to native confirmation and applies it alongside the button’s background color. ### Related issue Related invite handoff: https://github.com/block/buzz/pull/8601. ### Testing A widget regression checks the foreground and background colors sent to native confirmation. The isolated PR build shows readable black Join text on the white native button in dark mode. ### Screenshots Before (consent-only build `3d566de`):  After (this PR):  Signed-off-by: John Tennant <jtennant@block.xyz> Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz> |
||
|
|
83bd8b1cae | fix(loadgen): sign public URLs when dialling connect_addr in plaintext (#8926) | ||
|
|
1c143a40b7 |
fix(mobile): show the Join prompt when an invite launches Buzz (#8970)
## Summary Opening an invite while Buzz is closed can leave the Join prompt invisible and block later attempts. Native confirmation can attach to a temporary system dialog that then closes, leaving the join result pending. This fix uses Flutter confirmation when the native presenter is occupied. If UIKit rejects presentation, it clears the pending session so the user can try again. ### Related issue Related invite handoff: https://github.com/block/buzz/pull/8601. ### Testing The native regression fails before the fix and passes afterward. It covers rejected presentation, a second attempt, and an existing temporary dialog. The isolated PR build passed a fresh-user journey from Safari consent through launching Buzz, visible confirmation, Join, starter channels and seeded history. ### Screenshots Visible Join prompt after Safari opens Buzz, from this PR build:  ### Remaining work Finish Dart validation and review before readiness. Signed-off-by: John Tennant <jtennant@block.xyz> Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> |
||
|
|
205ff47744 |
fix(mobile): retry failed channel searches after reconnecting (#8968)
## Summary Searching while disconnected leaves “Open channel search couldn't finish” visible after the connection returns. Users must change the query or tap Retry channels to recover. This fix automatically retries the open-channel directory search when the relay reconnects, using the current query. It does not rerun message search or explain the separate onboarding setup failure. ### Related issue None found. ### Testing The widget regression searches while disconnected, reconnects without typing, and checks that the error clears and an unjoined open channel appears. It fails on main and passes with this fix. On the iOS simulator at this PR head, restarting the local relay cleared the error within about five seconds without changing the query; the unjoined channel remained listed. ### Screenshots Relay down: the error shows under the cached channel.  Relay back, same query, no typing: the error is gone and the unjoined channel stays listed.  --------- Signed-off-by: John Matthew Tennant <jtennant@squareup.com> Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cdd2af1a35 |
fix(mobile): let starter setup retry after a connection failure (#8967)
## Summary
After joining a community, “Retry setup” can keep failing until Buzz
restarts because it reuses an earlier connection error. A previously
successful wait can also let a retry proceed while disconnected.
Each setup attempt now waits for the current relay connection, retaining
the community check and 15-second timeout. This fixes retry recovery;
the original reported setup failure remains unexplained.
### Related issue
None found.
### Testing
Two provider regressions fail on main and pass with this fix: retry
after an interrupted wait, and retry after a previously connected
session disconnects.
In an iOS simulator comparison with an injected first-wait error, the
old build failed every retry. The fixed build completed setup and
displayed both starter channels without restarting. This proves recovery
from the injected error, not the original failure. Reconnect timing
after a previously successful wait is covered by the provider regression
only.
## Screenshots
iOS simulator against a local relay, running head
|
||
|
|
0ec74838a5 |
fix(mobile): open consent for scanned and pasted invites (#8952)
## Summary Scanning or pasting a valid community invite can show “approval has expired” before the user has reviewed the community’s requirements. This fix opens browser consent, preserves the joining identity, and resumes joining when approval returns. If the browser cannot open, Review requirements remains available to retry. ### Related issue Related invite handoff: https://github.com/block/buzz/pull/8601. ### Testing Provider and widget regressions cover missing or expired approval, browser-launch failure and retry, identity preservation, and native dismissal without reporting a successful join. The isolated PR build passed actual clipboard entry and Allow Paste, browser consent, return to Join, membership, starter channels and seeded history. Camera scanning remains untested. ### Screenshots Browser requirements opened from the native Join prompt:  Joining resumes after approval:  Joined with starter-channel history:  Signed-off-by: John Tennant <jtennant@block.xyz> Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> |
||
|
|
46da35810d | db: recycle pool connections after 5 minutes instead of 30 (#8951) | ||
|
|
02925ceb6e |
fix(relay): cap bridge searches in flight per user (#8956)
## Summary
Typeahead starts a bridge `/query` search on almost every keystroke. The
client aborts the previous request. But if the abort never reaches the
relay, for example because an intermediary keeps the upstream request
open, every one of those searches keeps running.
Each `(community, pubkey)` may now have **4** bridge searches in flight.
The next search supersedes the oldest one. The superseded request
returns **409** `{"error":"search superseded by a newer search"}`. It is
deliberately not a 429: desktop arms its relay-wide admission gate on
any 429, which would hold every relay request for 10 s, and the CLI
retries a `rate-limited:` 429. Its future is dropped, so #8954's
cancel-on-drop cancels its statement on the server.
- **Newest wins.** The newcomer is never rejected, because the newest
keystroke is the one the user is waiting for.
- **The cap is above 1.** Sessions that share a pubkey keep their
parallel searches: several agent sessions, or people search and message
search running side by side.
- **Bookkeeping lives in process.** `SearchSlots` holds a
`Mutex<HashMap<key, VecDeque<(id, CancellationToken)>>>`. A slot removes
itself by id when its search finishes, fails, is dropped or is
superseded. So a superseded search can never remove the newer search
that replaced it, and a user's entry is removed when their last search
leaves.
- **A search that finishes as it is superseded still returns its
result.** The `select!` is biased toward the search.
The limit is per relay process, not global. That is enough to bound how
much one user can pile onto one process's search pool.
## What got simpler
Nothing was removed. The change adds one small, self-contained module
and one call site. It also wires the new tests into the unit lane
(`Justfile test-unit` and `scripts/run-tests.sh`). The relay's unit lane
is an allowlist, so without that wiring the tests would run nowhere in
CI.
## Tests
`api::bridge::search_slots::tests`:
- `the_search_past_the_cap_supersedes_the_oldest`
- `a_finished_search_frees_its_slot`
- `a_superseded_search_leaves_its_successor_in_place`
- `no_entry_outlives_its_searches`
- `a_superseded_search_is_a_conflict_not_back_pressure`
Each of these mutants made at least one test fail:
- Removing the cap.
- Replacing the by-id removal with `pop_front`.
- Leaving empty user entries in the map.
- Answering supersession with 429.
🤖 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>
|
||
|
|
949f61dfab | fix(search): cancel abandoned searches and bound the search pool (#8954) | ||
|
|
009dc103f6 |
perf(mobile): read the channel list after live changes, not every minute (#8910)
## Summary While the app is open, a 60s timer reads the whole channel list again: every membership roster, the channel metadata, and the unread catch-up. On the test account that is **about 4MB of JSON every minute** on the 12 mini, about 240MB an hour, whether or not anything changed. The live subscriptions already deliver what the list is built from, so this PR removes the timer. The list is read again when a live event says it may have changed: - a relay notice that tags this reader: added to a channel or a new DM (kind 44100), removed from one (44101), or a DM hidden or shown on another device (30622) - a metadata edit on a listed channel (kind 9002, now part of the live filter) - a system notice on a listed channel that changes the channel itself: archived (including the relay auto-archiving an idle ephemeral channel), unarchived, deleted, visibility, topic, purpose, TTL and similar. Member joins and leaves don't count. The read starts 2s after the last such event, and no later than 10s after the first, so a burst costs one read. Reads start at least 10s apart however busy the relay is. A read that fails is retried with backoff (5s, doubling up to 5 minutes), including the read after a resume or reconnect. Nothing is read in the background: launch, resume, reconnect and pull to refresh read the list as before. Live subscriptions ask for no stored events (`limit: 0`), so nothing sent while one is down is replayed. So: - Each list read opens the notice subscription before it reads, and the read covers anything sent before. - If the relay closes the notice subscription or a channel chunk for a reason that may clear (busy, rate-limited, timed out), the relay session reopens it itself, as before. The channel list now hears about that through `subscribeWithStatus`. - If the relay refuses one as over its subscription limit, only that REQ is retried, after a jittered backoff (5s, doubling up to 5 minutes). The backoff starts over once a reopened subscription has stayed up for a minute. Any other refusal (`restricted:`, `invalid:` and so on) can't succeed unchanged, so it is left alone until the next connection, as on main. - A reopened subscription that is still up when its read comes due counts as a change, so the list is read once to catch up on what it missed. If the relay admits a REQ and then closes it again, it triggers no reads. Known gaps, accepted: deleting a channel or kicking a member sends this reader no notice, and archived channels have no live coverage, so an unarchive done on another device isn't seen. An edit to a newly added channel that lands in the round trip between reading its metadata and its live subscription starting is also missed. The list catches up on the next launch, resume, reconnect or pull to refresh. Archiving or unarchiving on this device re-reads the list at once, as before. ## Measurements iPhone 12 mini on Wi-Fi against the internal relay, app open and idle, instrumented profile build (HTTP response bodies after decompression, websocket text in). Measured on an earlier revision of this PR, which read on a 60s tick only after a change. Idle behavior is the same now (no change, no read). | 10 idle minutes | main | this PR | | --- | --- | --- | | Channel list reads | 10 | 1 | | HTTP queries | 40 | 5 | | HTTP response bytes | 39.9MB | 4.0MB | | Websocket bytes in | 4.4MB | 1.1MB | On main, every tick read memberships (348KB), another 310KB query and the unread catch-up (3.3MB). With this PR, the only read in the window came right after another device hid or showed a DM (kind 30622), which is exactly the kind of change it should pick up. Sizes are JSON after decompression; the relay gzips responses, so wire bytes are smaller (not measured). [#8901](https://github.com/block/buzz/pull/8901) separately shrinks each read's unread catch-up; the two are independent. ## Testing - New journey test: with the app open and nothing changing, nothing is read. Adding the reader to a channel, renaming a listed channel, archiving or auto-archiving one and removing the reader each cause one read about 2s later, and a member joining causes none. A change right after a read waits out the 10s gap. A steady stream of changes is read after 10s and then no more than every 10s. A burst costs one read. A failed read is retried with backoff and then stops. - New journey test, for the notice subscription and for a channel chunk: the relay refuses it twice as over its limit while the list changes. It is retried with backoff, and once it has stayed up the list is read once to catch up. Later the relay drops it, and having stayed up it is reopened within about 5s and catches up again. Then it delivers live. The relay then closes it as busy while the list changes, and the list catches up once it is back; closed again right after reopening, it triggers no read until it stays up. Refused as `restricted:`, it is not retried. - The resume journey: a reconnect read and a resume read each fail once while a channel is added, and each is retried and shows the channel. - Each guard was removed once to confirm a test fails: the debounce, the max wait, the minimum gap, the read retry (including after reconnect and resume), the catch-up reads after a retry or a relay-session reopen, the still-up checks, the backoff restart, the background check, retrying only capacity refusals, and clearing refusals on a new connection. - The terminal-rejection tests expect a chunk refused as `restricted:` never to be retried on that connection or to cause a list or history read, and a new connection to retry it at once. - `just mobile-check` and `just mobile-test` pass. - Device: on the 12 mini, 10 idle minutes with the app open, before and after (table above). 🤖 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> |
||
|
|
fa395e9e80 |
feat(mobile): open a prefilled Create agent form from Builderlab agent cards (#8912)
Builderlab agent cards (printed for the event, and generated at the kiosk) carry a QR to a share page, `app.builderlab.xyz/a/<id>`. Its **Add to Buzz** button opens `buzz://agent/new?card=<id>`, which no Buzz client handled until now. This PR makes mobile handle it. ### Behavior - Opening the link shows the Agents page, then a Create agent form prefilled from the card. Everything stays editable, and nothing is created until the user taps Create. - The app fetches the card's `agent.json` from the avatars CDN for this build's Builderlab environment (production or staging). The link carries only the card id, never a host, so the QR stays small no matter how long a card's instructions get. - `agent.json` is validated against the card schema. `id` (matching the link), `name` and `avatarUrl` (an https URL) are required. `instructions`, `model` and `reasoningEffort` are optional (published cards don't have them yet) and left for the user when absent. A missing required field or a wrongly typed known field rejects the card; any other key is ignored whatever its type, including `v` and `description` (Buzz agents have no description to prefill), so Builderlab can add fields without breaking released apps. The model and effort are preselected only if the live catalog offers them. The card id is opaque: any non-empty id is accepted and percent-encoded into the URL. - The fetch uses the same limits as image downloads, now shared in `bounded_fetch.dart`: aborted after 20 s without a byte or 5 min overall, and rejected over 2 MB. `MediaImageProvider` now calls the same helper, with no behavior change. - The link waits for sign-in, then reloads the registry for the signed-in account before anything else. It's dropped with a message if the card is unavailable (403/404), invalid, can't be loaded (any other response), or an agent setup is unfinished, including a setup saved before a sign-in the link triggered. - A link that arrives while Agents shows only its list replaces that page instead of stacking a second one. If a form or sheet is open there, the link is dropped with "Finish or close what’s open in Agents, then reopen the link." and the open work is kept. ### Demo Simulator (iPhone 17 Pro, debug build of this branch plus the sign-in fix from [#8914](https://github.com/block/buzz/pull/8914) and the hidden debug banner from [#8915](https://github.com/block/buzz/pull/8915), signed in to Builderlab, remote-agent-pilot), recorded in one take with idle time trimmed: the card's share page in Safari, Add to Buzz, the prefilled Create agent form, a model, thinking level and instructions, Create, then a message and the agent's reply. https://github.com/user-attachments/assets/e66aae17-884a-4e68-a1b8-ecb40562be1d ### Validation Added tests: - Table tests for the link shape (only `?card=<id>` with an opaque id; the inline `#` form, extra parameters and dot segments are rejected) and for reading `agent.json` (a published card, a card with instructions and model defaults, unknown keys of any type ignored, including `v`, `description`, arrays, objects and null, and invalid cards: missing or mismatched required fields, wrong types, and non-https or relative avatars). - Table tests for fetching: the request goes to this build's CDN without following redirects, with the id percent-encoded as one path segment; 403/404, a redirect, a 500, a network error, declared and streamed oversize bodies, non-JSON and a JSON array each give a specific message. The image loader's existing stall, deadline, cap and cancel tests now cover the shared helper. - A journey test from link to Create against a fake CDN: prefill and edit, then submit; an unavailable card; a failed model catalog whose Retry then preselects the card's model; a Builderlab account change while the card loads, which drops the draft; a v1 card with no instructions and an unknown model; an unfinished setup; a link arriving while a Create form with edits is open, which keeps the form and its edits; and a link arriving while Agents is open. Disabling the https or id check makes the table test fail, and removing either the account recheck or the loaded-catalog check fails the journey. The journey runs through the production root scope (removing its link handler fails it) and runs in CI's configured-authentication-domains step. Full mobile check and test suite pass locally. Live data: a real staging card's `agent.json` (`av_MioTAG6dm7q3Zcm0UV2j8A`) has the v1 shape the table test uses, and an unknown id returns 403 from the CDN. Live run on an iPhone SE (build of 63ed54c749, Builderlab sign-in enabled, remote-agent-pilot, signed in as builderlab@tombrow.com) with a real production card, [ToastyMacbeth](https://app.builderlab.xyz/a/av_lrmRlJR6ylrQKvJkoLWCOQ) (a v1 card with no instructions and an extra `description` key): - Opening `buzz://agent/new?card=av_lrmRlJR6ylrQKvJkoLWCOQ` from a cold start opens Create agent prefilled with the card's name and avatar. - After choosing a model and adding instructions, Create adds the agent with the card avatar, and it replies in a DM. - An unknown card id shows "This agent card isn’t available." | Prefilled | Created | Reply | Unknown card | | --- | --- | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--01-prefilled.png" width="200"> | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--02-created.png" width="200"> | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--03-reply.png" width="200"> | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--04-unavailable.png" width="200"> | Before this PR, the app had no handler for `buzz://agent/new`, so the link did nothing. A separate widget test covers a link that needs Builderlab sign-in first: once sign-in lands, a setup saved for that account blocks the card, which is never fetched. Removing the registry reload fails it, as does removing the busy-Agents guard for the journey step above. ### Before merge - [x] Human live check waived under the repository owner's standing testing exception. In its place, agent-driven full-app runs exercised the flow end to end: the iPhone SE device run and the simulator demo above (a real card's share page, **Add to Buzz**, the prefilled form, Create and a reply from the agent). Limitation: those runs predate [ |
||
|
|
61b8455a3c |
fix(mobile): open unjoined channels from links and search results (#8923)
Tapping a `#channel` link to an open channel you haven't joined showed
"Channel could not be opened", because a tap could only open a channel
already in the local list. A search hit had the same gap: it did nothing
when its channel wasn't in the loaded list, for example when the
open-channel directory failed to load or hadn't loaded yet.
Both taps now go through one resolver. It looks in the loaded list
first, then reads the channel's own metadata, as `buzz://` deep links
already do.
- An open channel you haven't joined opens in preview ("Join this
channel from Manage to participate").
- A private or withheld channel stays closed and shows "Channel not
found here".
- A failed lookup shows "Could not open the channel". So does a missing
channel when the channel list failed to load, since it may be one you
belong to.
The resolver also guards two cases:
- A second tap during a slow lookup doesn't open the channel twice.
- A lookup that finishes after you switch community or identity, or
after you moved to another page, opens nothing and doesn't block a new
tap.
Fixes AIDA-2324 and AIDA-2286.
[#8764](https://github.com/block/buzz/pull/8764) already made search
load the open directory, which fixed the common search case, so the
search part here covers the remaining fallback.
### Before: a link to an unjoined open channel fails
https://github.com/user-attachments/assets/c39cf0db-8098-4010-993e-cf664230c809
### After: the same link opens the channel in preview
https://github.com/user-attachments/assets/1422e70f-39c5-4598-8232-efad190b3413
### Validation
- Added tests:
- A table-driven channel-link journey with four rows: an unjoined open
channel opens, a withheld channel and a private channel show "Channel
not found here", and a failed lookup shows "Could not open the channel".
Each row double-taps during a gated lookup and asserts a single lookup
and a single push. Failure rows then tap again to show the lock is
released.
- A lookup that settles after the user opened another page pushes
nothing.
- Search journeys: a message hit opens its unlisted channel (including
after the channel list failed to load), or explains why it can't ("Could
not open the channel" when the list failed).
- Taps whose lookup will be discarded don't block the next tap: after a
community switch away and back, or from another page after the first
page closed. Each row fails if its part of the tap lock or the
connection fence is removed.
- I reverted the production files to `main` once and confirmed every new
test fails there.
- The full mobile suite and `mobile-check` pass.
- I ran it on an iPhone 18 Pro simulator (iOS 27), signed in to
Builderlab with an account that isn't a member of the open channel
`#speed-tests`.
- A link to that channel shows the error toast on `main` and opens a
preview with this change (videos above).
- A search hit in that channel opens it with this change. On `main` it
also opens, through [#8764](https://github.com/block/buzz/pull/8764)'s
directory load.
- Not covered by tests: waiting for a channel-list load still in flight,
a short guard I checked by reading the code.
- The human app-test step doesn't apply (Tom's standing exception for
mobile client changes).
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>
|
||
|
|
97ff4e19d1 |
docs(mobile): describe the role split in the media image keys (#8932)
Follow-up to [#8913](https://github.com/block/buzz/pull/8913). Its review [noted](https://github.com/block/buzz/pull/8913#discussion_r4445612714) that three doc comments in `media_image.dart` still describe the old keys after that PR split direct and thumbnail-fallback loads: - The class doc now says the provider keys on `isThumbnailFallback` as well as url, scale and auth scope. - The `_cooldownUntil` doc says it is keyed by (url, isThumbnailFallback), like `_inFlight` but without auth. - The `_SharedFetch` doc says a download is shared per URL, auth scope and role. Comments only, no behavior change. `flutter analyze` is clean and the full mobile suite passes. No app run, since nothing executes differently. 🤖 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> |
||
|
|
8f83c63209 |
fix(db): preserve partition reader grants (#8485)
## Summary PostgreSQL does not copy parent table grants to new partitions. A reader can query the `events` parent but fail direct partition reads after the relay creates another month or replaces its catch-all. Copy each managed parent's explicit table-level `SELECT` grants to its new children inside the existing maintenance transaction. The parent ACL supplies the policy; there is no new setting or deployment-specific role name. Writes, column-only grants, owner entries, and grant options are excluded. A grant failure rolls back the table's partition changes. Existing children and manually created or attached partitions still need grants from database provisioning. Apply the [companion initializer fix](https://github.com/squareup/bb-utility/pull/2253) for existing partitions before relying on automatic grants for future ones. ### Related issue No duplicate found. The closest related PR is #8464, which enables catch-all advancement by default; this change independently preserves reader access during that maintenance. ### Testing No manual testing. Production rollout and the Fivetran setup check remain pending. Generated with Codex Signed-off-by: coder2 <4acc49cb93576448ba82e631688c51a40bf03d6a889d78fddf57917d198de10f@buzz.block.builderlab.xyz> Co-authored-by: coder2 <4acc49cb93576448ba82e631688c51a40bf03d6a889d78fddf57917d198de10f@buzz.block.builderlab.xyz> |
||
|
|
35df866b33 |
fix(mobile): split escaped voice-note links into their own segment (#8940)
Follow-up to [#8925](https://github.com/block/buzz/pull/8925), from a [Codex finding](https://github.com/block/buzz/pull/8925#discussion_r4446048706) there. After #8925, message Markdown arrives with backslash escapes shielded, but imeta is keyed by plain URLs. Two lookups used the raw, still-escaped destination: - **Bubble segmentation** (`bubble_segments.dart`): an attachment link such as `[voice.mp4](https://…/voice\_note.mp4)` missed its imeta entry and stayed inside the text bubble, even though the link builder rendered it as an attachment. The fix unshields the URL before the lookup. - **Trailing image gallery** (`extractTrailingImageGallery` in `message_gallery.dart`): with two or more trailing images, an escaped destination kept its backslash, so the carousel loaded the wrong URL and lost the imeta alt text. The fix unescapes the destination the same way as the label beside it. Its callers in `message_media_avatar.dart` and `message_skeleton_body.dart` get the fix too. This gap was already on main. ### Validation - The existing `attachments stay outside bubbles and text order is preserved` journey now uses an escaped destination with a plain imeta URL. The existing carousel journey now has one escaped image destination and taps it by its plain URL. Each fails with its fix reverted. - `flutter analyze` is clean and the full mobile suite passes (4151 tests). - Limitation: no simulator run. Both changes are key lookups, and the widget journeys exercise the rendered layout. The full-app run for the surrounding escape handling is recorded in #8925. 🤖 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> |
||
|
|
b5b69d3de8 |
fix(mobile): let the issuer choose relay assertion lifetime (#8935)
## Why
Mobile rejects any NIP-FI relay assertion whose `expires_at` is more
than 300 seconds away (`hosted_relay_auth.dart`). Once an issuer returns
longer assertions, every fetch fails with `invalidResponse` and
organization sign-in can't connect to the relay. Builds with org sign-in
configured hit this on every attempt.
## What
Keep rejecting assertions that have already expired, and drop the
300-second cap so the issuer chooses the lifetime. Desktop made the same
change in block/buzz-app#2171. The relay still enforces its own
`maximum_assertion_age`.
The token cache needs no change. It already refreshes 30s before
`expires_at`.
## Testing
- Added a table case to `organization_relay_auth_test.dart`: 300s and
24h are accepted; 0 and -60 are rejected with `invalidResponse`. It
fails against the old cap and passes with the fix.
- `just mobile-install mobile-check mobile-test` passed locally at
mobile-v0.27.0-rc.17
|
||
|
|
2fd5c122e8 |
fix(mobile): load earlier edits to thread replies when a thread opens (#8870)
## Summary In the mobile thread view, if a reply was edited before you opened the thread, the reply showed its original text and no "(edited)" marker. An edit that arrived while the thread was open worked. The thread `/query` asked only for message kinds. The channel window carries edits, reactions and deletions (aux events) only for top-level messages, so an earlier edit to a reply was in neither result. The thread query now sends `include_aux: true`, as desktop does (`buzz-app` `src/features/relay/threads.ts`). The relay already supports this on the `depth_limit` thread route: it returns the aux events for the root and every reply, plus deletions of those (`crates/buzz-relay/src/api/bridge.rs`, `bridge_thread_aux`). A relay that ignores the flag gives the same result as before. The relay sends a page's rows (at most 200 reply-marked events, of any kind) before that page's aux events. It also repeats the root's aux on every page. So `fetchCompleteThread` treats the leading run of events reply-marked into this thread, other than reactions and NIP-09 deletions, as the page's rows. Only rows count toward a page, move the cursor and feed the reply cache. A reply-marked huddle event still fills a page, and a reply-marked reaction never becomes a reply. A reply-marked edit of the root in the repeated aux can follow a short final page's rows and look like a full page, so the scan also stops when the next cursor does not advance. The thread-summary scan (`fetchCompleteThreadReplies`) still asks for replies only and pages exactly as on `main`. ### Related issue Found during the emulator testing of https://github.com/block/buzz/pull/8863. ### Testing - `just mobile-check`, `just file-size-check` and `just mobile-test` pass. - `thread_replies_provider_test.dart` adds two tests. The first checks that the query sends `include_aux` and that an edit made before the thread opens folds into its reply. The second covers pagination: a reply-marked huddle row filling a page, a reply-marked edit that is a row on one page and aux on the others, a reply-marked reaction that stays aux, 200 reply-marked reactions after 199 replies (one query), 200 reply-marked root edits after a short final page (the progress guard ends the scan), an edit reply-marked into another thread that stays aux, and that the summary scan doesn't send `include_aux`. It fails if any of these is removed: the reaction/deletion exclusion, the root check, the 200 cap, the progress guard, or the separate row and aux id sets. - `channel_detail_page_test.dart` adds a journey: it opens `ThreadDetailPage` with the real thread query and a fake relay session that sends the reply's earlier edit only when `include_aux` is set. It checks the edited text, that the original text is gone, and the marker. It fails when the flag is removed or when the provider drops aux. - Android emulator (AVD, debug build) against a local relay. Morty posts "Who can review the release notes?", Demo User replies "I can, after lunhc" and then edits the reply to "I can, after lunch", all before the thread is opened. These shots come from the first revision of this branch. The pagination changes after it give the same result for a one-page thread like this one. | Before (unfixed: the #8863 build) | After (this branch) | Live edit while the thread is open | | --- | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-before.png" width="300" alt="Before: original reply text, no marker"> | <img src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-after.png" width="300" alt="After: edited reply text with (edited)"> | <img src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-live.png" width="300" alt="Live edit while the thread is open"> | Before, the thread shows the original reply text with no marker. After, it shows the edited text and "(edited)". A live edit still lands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
54ca2a3cc6 |
fix(mobile): render Markdown backslash escapes (#8925)
Mobile showed Markdown backslash escapes literally. A message linking a channel as `[\#buzz-mobile](buzz://channel/…)` rendered as `\#buzz-mobile`, and `\[text\]` rendered as LaTeX math. gpt_markdown has no backslash escapes. Outside code, each escaped ASCII punctuation character is now held as a stand-in (an invisible word joiner plus the character's fullwidth form) before parsing, so no other syntax (links, emphasis, mentions, channel tokens, math) matches it. A new inline component and the link and image builders restore the original character. The inbox/search preview builder already handled escapes this way; its helpers moved to a shared `markdown_escapes.dart`. Two edge cases found in review are covered: shielding runs after trailing-gallery extraction so carousel URLs and labels stay as authored, and a bare link no longer takes a trailing backslash into its destination. Codex review added: escaped backticks stay literal instead of opening code, authored word joiners are not mistaken for stand-ins, Buzz URL punctuation peeling keeps a trailing backslash, gallery semantic labels drop escapes, the trailing-backslash peel is linear and reruns after delimiter stripping, an escaped `\<url>` is not treated as an autolink, `~~~` fences are skipped like backtick fences, an autolink containing `\` keeps it in both label and destination, and the stand-ins use fullwidth characters so the one gpt_markdown fallback that shows raw text (multi-line strikethrough) still reads right. Later review: fence detection looks back only over blanks and `>` markers, so it stays linear on long lines (security review) and treats an unclosed fence inside a quote or list as code, like the renderer (Carl). Behavior change: `\(x\)` and `\[x\]` now show as `(x)` and `[x]` instead of math, per CommonMark. Desktop (buzz-app) doesn't render math either. `$…$` math is unchanged. ### Validation - Added tests: a `MessageContent` table covering escapes in prose, bold, link labels (HTTP and `buzz://channel`), fenced and inline code (left literal), a bare link ending in `\`, and escaped backticks, authored word joiners and fullwidth characters, a `~~~` fence, a multi-line strikethrough, `<https://example.com/a\>`, a Buzz URL ending in `.\`, an emphasized bare URL ending in `\*`, an escaped `\<url>`, and a trailing image gallery with escaped labels (including its semantics label), in plain and bubble layouts. Each row also asserts no stand-in reaches visible text or semantics. Reverting any one of these fixes fails it. - `flutter analyze` is clean and the full mobile suite passes, except for 11 `notification_toggle_test.dart` tests. Those fail locally on the Studio at the CI-green |
||
|
|
5f8d0aab98 |
fix(relay): refuse a second listed open channel with the same name (#8782)
## Summary
Two open channels in one community could have the same name, so the
channel list showed two `#square-ios` rows and people could not tell
them apart. Now the relay refuses to create, rename or open up a channel
when that would give it the same name as another channel in the channel
list, ignoring case. The client gets `invalid: an open channel named "…"
already exists`.
The rule follows Slack: an archived channel keeps its name until it is
deleted. Private channels, DMs and unlisted channels may still repeat
names. So a refusal never reveals that a private channel exists.
The PR also adds a script that renames the duplicates that exist today.
It sends each rename through the relay, so relays keep serving while it
runs. The stacked PR https://github.com/block/buzz/pull/8787 then adds a
database index that enforces the rule. This PR is the release that must
be fully deployed before that index.
## Details
**Which channels the rule covers.** A *listed* channel: open, not
unlisted, a stream or forum, not deleted. Archived channels are
included. The check runs on create, rename, private → open and unlisted
→ listed.
**Relay check** (`crates/buzz-db/src/store/channel.rs`,
`crates/buzz-relay/src/handlers/ingest.rs`):
- The check runs after the write, in the same transaction, under a
per-name lock. So two concurrent writers cannot both commit the same
name.
- A refused create or edit (kind:9007 or kind:9002) gets `OK false
"invalid: an open channel named … already exists"`. Nothing from it is
stored.
- The relay gives the same answer when the database index from
https://github.com/block/buzz/pull/8787 refuses the write. That covers
the rollout, and a rollback from that release.
- A channel create with no `h` tag (the new channel's id) is now refused
before it is stored. Before, such a create ran after the event was
stored, so a refusal (for example, a taken name) still answered
"accepted". All first-party clients send `h`.
**Operator rename** (`crates/buzz-relay/src/handlers/side_effects.rs`).
The rename script needs to rename channels it does not own, archived
ones included. So a relay operator may now send a channel edit that
changes only the name of a listed channel. Everything else still needs
the channel's owner or admin.
- "Operator" means `RELAY_OPERATOR_PUBKEYS`, the owner fallback, or an
`operator` row in `relay_operators`. A moderator is not an operator.
- The rename also applies to an archived listed channel, whatever the
operator's role in that channel, and the channel stays archived. Other
edits of an archived channel are still refused.
- Private channels, unlisted channels and DMs still need the channel's
owner or admin. The relay decides "listed" under the channel row lock,
so a channel made private during the rename is not renamed.
- The rename takes the normal edit path: the name check, the stored
event, the channel metadata event and fan-out.
**Rename script**
(`scripts/maintenance/rename_duplicate_listed_channel_names.sh`, with
the read-only plan `plan_duplicate_listed_channel_renames.sql`):
- In each group of listed channels that share a name, the oldest
unarchived channel keeps the name. Each other channel becomes
`<name>-<first 8 chars of id>`, or `<name>-<id>` when that is also
taken. When all the channels in a group are archived, the oldest keeps
the name.
- **Starter channels.** Released desktop and mobile apps create
`#general` and `#welcome-everyone` when a community has no open,
unarchived stream with that name. Under the new rule, the relay refuses
that create while an archived channel has the name, and the apps' setup
fails. So the script also renames `general` and `welcome-everyone` when
every listed channel with that name is archived. That frees the name for
the apps.
- The apps create each starter at a fixed id, derived from the relay's
address. If a channel already has that id, the relay refuses the create
there whatever its name, so a rename cannot help. For that name the
script renames nothing, and an archived starter at its id keeps the
name. The script computes the ids from `BUZZ_RELAY_URL` (or
`BUZZ_STARTER_ORIGIN` when the apps use a different address) and prints
them, so the operator can check them against a client. A relay-side fix
for those clients is planned separately.
- The operator key goes in `BUZZ_PRIVATE_KEY`. It must be a relay
operator and a member of the community. The script stops at the first
auth refusal and says so.
- The script stops before the plan for an unknown host, an archived
community and a community being deleted, and says what to do.
- A failed rename (the new name is now taken, or the relay can't be
reached) is reported, and the script goes on. At the end it reads the
plan again and exits 1 if any rename remains. `--dry-run` prints the
plan.
- The script replaces the earlier direct-SQL repair and `buzz-admin
reconcile-channels --metadata`, which needed every relay pod stopped.
**Duplicates that exist today** (read on 2026-10-30):
`ml-platform-public` (`d41a31f8…`, `7be55f5a…`) and `square-ios`
(`79f8e9d4…` archived, `fe246d3e…` live).
### Rollout
The full procedure, with exact SQL and recovery steps, is in
`docs/channel-name-uniqueness-deployment.md` in
https://github.com/block/buzz/pull/8787.
1. **Deploy this PR.** Wait until no older relay pod serves; check the
image of every pod. An older relay can still create a duplicate, and it
refuses the operator rename.
2. **Rename the existing duplicates in each community**, with relays
serving. The key must be an `operator`, not a moderator, and a member of
the community (`RELAY_URL=wss://<host> buzz-admin add-member --pubkey
<npub>`).
```sh
DATABASE_URL=postgres://... BUZZ_PRIVATE_KEY=<operator key> \
scripts/maintenance/rename_duplicate_listed_channel_names.sh --dry-run
<community host>
# review the plan, then run it again without --dry-run
```
For the duplicates known today, the newer `ml-platform-public` and the
archived `square-ios` (`79f8e9d4…`) get the `-<id8>` suffix.
3. **Confirm that no duplicate remains** in any community (query in the
doc).
4. **Build the index, then deploy
https://github.com/block/buzz/pull/8787.**
Rollback from https://github.com/block/buzz/pull/8787 goes to this
release only, keeping the index, with auto-migration off.
### Related issue
None found.
### Testing
- **Live local relay.** The script ran against a local relay built at
mobile-v0.27.0-rc.16
|
||
|
|
f1ce1c5d47 |
docs(nip-fa): make assertion lifetime issuer operator policy (#8934)
NIP-FA fixed the assertion lifetime at 300 seconds. That limit belongs to the adapter operator: the relay already enforces each issuer's configured `maximum_assertion_age`. This changes the `expires_at` paragraph to: - make the lifetime the adapter operator's policy; - keep the rule that `exp` cannot be later than the adapter session's expiry; - require the lifetime to stay within the `maximum_assertion_age` the relay accepts for the issuer; - replace "Clients MAY refuse … more than 300 seconds" with a rule that clients must not refuse an assertion for its lifetime alone; - state that the lifetime bounds how long a revoked key can keep reconnecting after a relay restart. NIP-FI and the other NIPs have no fixed 5-minute assertion rule, so they are unchanged. Signed-off-by: Will Pfleger <wpfleger@block.xyz> |
||
|
|
da93ce3f62 |
fix(mobile): open the public Builderlab login the iOS bridge allows (#8914)
On main, "or sign in with email" on iOS fails immediately with "We could not finish connecting. You can try again." No Builderlab page opens. [#8700](https://github.com/block/buzz/pull/8700) changed the iOS browser bridge so the public account hosts (`app.builderlab.xyz`, `app.builderlabstaging.xyz`) may only open `/auth/native`. The Dart side still opens `/api/goose/v1/auth/login` for them, so the bridge rejects every public sign-in before presenting a browser. Every iOS build since #8700 is affected; organization sign-in on tenant hosts is not. The matching Dart change was Wes's [#8712](https://github.com/block/buzz/pull/8712). It was merged into the #8700 branch, but main's squash of #8700 kept only the bridge half. This PR restores #8712 on top of main: - The public account opens `/auth/native` (state, S256 challenge, callback and retained invite unchanged, without the CLI-only `type`/`product` parameters). Organization tenants keep `/api/goose/v1/auth/login`. - A saved session whose account still needs web setup goes through the same browser handoff, keeping its subject and destination. Android has no path allowlist in its browser plugin, so it needs no change. ### Validation - Reproduced on an iPhone 17 Pro (iOS 27) simulator with a main-based debug build: "or sign in with email" fails instantly. With this change applied, the same build shows the system "Buzz wants to use app.builderlab.xyz to sign in" prompt, then the login.builderlab.xyz "Welcome back to Buzz" page, which confirms production serves `/auth/native`. - Restored #8712's tests on top of main's current tests: the browser test checks the public login URL and its exact parameters, and the hosted invite journey runs for both a fresh account and a saved account that still needs setup, asserting `/auth/native` and that the invite is retained. Without the Dart fix, these fail. - Mobile format and analyze pass; the hosted onboarding journeys and `shared/hosted` tests pass with the CI auth-domain configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> |
||
|
|
f99807a79a |
fix(desktop): skip a starter channel the relay will never create (#8911)
## Problem On first run, the desktop app creates the `general` and `welcome-everyone` starter channels at fixed ids when it can't find a live one. Some refusals of that create are permanent. Any refusal aborted setup **before it joined the new member to the starters that do exist**: | Starter state in the community | Relay reply to the create | Before | |---|---|---| | Another listed channel holds the name, archived ones included (with #8782) | 400 `invalid: an open channel named "…" already exists` | setup fails, not joined to `general` | | An archived channel holds the fixed id | 400 `invalid: channel is archived` (the relay checks this before duplicates) | setup fails | | The channel at the fixed id was renamed or retyped | `duplicate` | waits, then `starter channels created but metadata not yet available` | | The channel at the fixed id was deleted | `duplicate`, and no metadata is ever served | same | Onboarding itself continues, because public starter failures are already caught in `initializeStarterChannels`. The new member just never gets joined to the other starter. With #8782 this happens to every new desktop user in a community whose `welcome-everyone` or `general` is archived. ## Fix `ensure_starter_channels_inner` now skips a starter it can never create or use, clears its pending-owner mark, and still joins the rest. Before skipping after a permanent refusal, it reads the open directory again, from the writer rather than a replica, once for all refused starters: a live starter created after the first read is joined instead of skipped. Any other refusal still fails setup. On the TS side, `ensureStarterChannels` reports a skipped starter as `null` instead of throwing. No caller reads those fields. ## Testing - New tests run `ensure_starter_channels_inner` against a stub relay that sends the relay's real reply shapes, one per row above, plus one checking that other refusals still fail, and one where a live `welcome-everyone` is listed only by writer-pinned reads (a lagging replica) and gets joined. Each test fails when its guard is removed. - Ran the real starter-setup code as a new user against a local relay built from #8782, one community per row: - On `main`, rows 1, 2 and 4 fail. - On this branch, all four join `general`, and each community still has exactly one `welcome-everyone`. - A rerun is a no-op. - `cargo test` (desktop Tauri, full), clippy, fmt, desktop `pnpm test`, `tsc`, and biome all pass. Related: #8782 (relay refuses taken names), #8807 (the same fix for mobile). The archived-id case returns `channel is archived`, not `duplicate`, so mobile needs to handle that reply too. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Salman Mohammed <smohammed@squareup.com> Co-authored-by: opus <f038cc09b32a776e7d95b65c267e9d851777ee2b6cdbd46a48edba01b1d10ebd@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3ae2b6a377 |
fix(mobile): hide the Flutter debug banner (#8915)
Debug and simulator builds of the mobile app draw Flutter's red DEBUG ribbon over the top-right corner of every screen. It covers app UI in demo videos and screenshots, and nobody relies on it. This turns it off on the app's `MaterialApp`, matching the profile avatar sheet app, which already does. Release builds never showed it, so they're unchanged. ### Validation - `flutter analyze` and `dart format` pass. - Recorded the agent card flow demo on an iPhone 17 Pro simulator (iOS 27) from a debug build with this flag set: no DEBUG ribbon on any screen. 🤖 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> |
||
|
|
9f8913dd1b |
mobile: keep a thumbnail fallback apart from a direct load of the same URL (#8913)
## Summary Follow-up to [#8896](https://github.com/block/buzz/pull/8896), from its review threads. **A fallback and a direct load of the same thumbnail no longer share a download.** `MediaImageProvider.isThumbnailFallback` decides what a 404 means: for a thumbnail `MediaImage` picked in place of an original, it records a missing thumbnail so the next build loads the original; for any other URL it starts the 30s failure cooldown. It was left out of equality and the in-flight download key, so when a profile URL that is itself a thumbnail and a fallback to that same thumbnail were on screen together, both joined one download and only one of them ran its own 404 handling. Now each keeps its own provider and download, and each gets its own handling. The failure cooldown is keyed the same way. Before, a direct 404 of a thumbnail put its URL on cooldown (30s, up to 5 min with Retry-After), and an avatar that fell back to that thumbnail during the cooldown hit the cooldown error instead of a 404, so it showed initials instead of loading the original. Also: - Reworded the `preferThumbnail` doc so it no longer names community icons, which never set it. - Removed the community-icon avatar test, which fed a URL production never produces. ## Testing - New test: a direct thumbnail URL and a fallback to it, shown together, make two requests; after a 404 the direct URL is in cooldown and the fallback's original loads (not the initials). It fails with the fetch key change reverted. - New test: after a direct thumbnail 404, an avatar that falls back to the same thumbnail still loads the original. It fails with the cooldown key change reverted. - `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> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |