Commit Graph
124 Commits
Author SHA1 Message Date
Eli FosterandClaude Opus 5 057c716700 fix(relay): disable admin dashboard on bad config instead of failing startup
BUZZ_ADMIN_HOST with a missing or invalid password aborted relay startup.
bb-public sets BUZZ_ADMIN_HOST=admin.buzz.xyz in production with no
password configured, so shipping this would have crashlooped the whole
relay fleet — websocket, media, git, everything — over an optional
dashboard.

Log at error! and leave the admin surface off instead. `admin = None`
means is_admin_host() is false, the admin router is never mounted, and
authorize() returns not_found, so nothing admin-scoped is reachable. The
security posture is unchanged; only the blast radius of a config gap is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
2026-08-03 16:50:27 -07:00
Eli FosterandClaude Opus 5 a9a0a088c9 test(relay): stop admin config test from poisoning process env
`admin_host_requires_a_strong_credential` set BUZZ_ADMIN_HOST with no
password to assert the rejection. Env vars are process-global, and the
~15 other tests that call `Config::from_env()` do not hold ENV_MUTEX, so
they observed the half-set state and panicked on `expect("default config
loads")`.

Extract the validation into `AdminConfig::from_values` and test the
rejection cases against it directly, with no env mutation. A separate
test still covers the env wiring; it sets the password before the host so
a concurrent `from_env()` never sees a host without one.

Repro before this change:
  cargo test -p buzz-relay --lib -- \
    api::admin::tests::report_detail_requires_admin_host_before_database_access \
    config::tests::admin_host_requires_a_strong_credential
  => 1 passed; 1 failed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
2026-08-03 16:05:37 -07:00
Eli FosterandClaude Opus 5 0808a23da4 fix(relay): drop now-unused IntoResponse import
Follow-up to dc2df1667: moving the response conversion out to the call
sites left `IntoResponse` unused in the admin module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
2026-08-03 15:53:17 -07:00
Eli FosterandClaude Opus 5 dc2df16677 fix(relay): satisfy clippy on admin auth changes
`authorize_request` returned `Result<(), Response>`, tripping
`clippy::result_large_err` (the axum `Response` Err variant is >=128
bytes). Return `ApiError` instead and convert at the two call sites.

Also drops a redundant closure in the config test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
2026-08-03 15:25:59 -07:00
Jordan Mecom b747feddb3 Require credentials for deployment admin access
Co-authored-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Jordan Mecom <jm@squareup.com>
2026-08-03 13:44:05 -07:00
Tal WeissandGitHub fc598f5f8d fix(git): allow deleting the default branch (#4297)
Tal here, human. Trying to help. This bug bugged me...

## Summary

A repository's first branch becomes its symbolic `HEAD`, and Git's
bare-repository default rejects deleting that branch even when another
branch survives.

This change:

- sets `receive.denyDeleteCurrent=ignore` only for the ephemeral `git
receive-pack` process
- preserves the existing server-side `core.hooksPath` override and
authorization hook
- lets the existing CAS publication logic select a surviving branch as
the next manifest `HEAD`
- adds regression coverage using a real stateless `git receive-pack`
request and a manifest HEAD-selection test

This lets users replace an accidental default branch without deleting
the object-storage manifest pointer.

### Related issue

Fixes #3572

### Testing

- `cargo test -p buzz-relay api::git::` (128 passed, 5 ignored)
- `just ci`
- live E2E roundtrip against a release relay with PostgreSQL, Redis, and
MinIO:
  - created a repository through signed Nostr events
  - verified authorized pushes and rejected unauthorized clone/push
  - pushed a surviving `master` branch
  - deleted the active `main` branch over authenticated Smart HTTP
- freshly cloned the repository and verified `master` became HEAD,
`origin/main` was absent, and repository content remained intact

Signed-off-by: Tal Weiss <major.tal@gmail.com>
2026-08-02 13:29:41 -04:00
5765fc74b7 fix(relay): allow open relays to set their NIP-11 workspace icon (kind:9033) (#3998)
## Problem

The desktop deliberately shows the workspace icon editor on open relays
(#2640, gate: `canEditIcon` in
`desktop/src/features/communities/ui/EditCommunityDialog.tsx`) and
defers to the relay-side kind:9033 check — which required an admin/owner
row in `relay_members`. For a community with **no admin/owner row at
all** (the `ensure_configured_community` path, which never writes an
owner), every 9033 was refused and the icon was permanently unsettable.

**Correction from review (thanks @Dawn):** the original version of this
PR claimed nobody holds a role on an open relay. That's false —
`main.rs` bootstraps `RELAY_OWNER_PUBKEY` as owner regardless of
`BUZZ_REQUIRE_RELAY_MEMBERSHIP`, so a production open relay like
bb-block *does* have an owner row, and the old gate was refusing
everyone except that owner. The first revision of this diff would have
silently widened that owner-only control to any NIP-42-authenticated
sender.

## Fix — steward-wins

`may_set_workspace_profile(sender_role, membership_enforced,
community_has_steward)`:

| Relay mode | Community has admin/owner row? | Who may set the icon |
|---|---|---|
| Closed (`require_relay_membership=true`) | any | admin or owner
(unchanged) |
| Open | yes (e.g. bb-block) | admin or owner (unchanged posture) |
| Open | no (genuinely rosterless) | any NIP-42-authenticated sender |

- New DB helper `has_admin_or_owner(community)`
(`crates/buzz-db/src/relay_members.rs`); the call site only queries it
on open relays.
- The rosterless admit logs a `warn!` with the sender pubkey — 9033
writes no audit row and publishes no announcement event (unlike
9030/9031), so this is the only durable attribution.
- Kinds 9030–9032, NIP-42 auth, `AdminUsers` scope, ban gate, and icon
validation are all untouched.
- Doc comment fixed: cited nonexistent `canEditCommunityProfile`; real
symbol is `canEditIcon`.

## Test coverage — closing the mutation gap

Dawn's mutation testing showed the original unit tests pinned only the
helper's truth table: inverting the flag at the call site or deleting
the gate entirely survived the full suite.

- Unit tests now cover the 3-arg truth table (closed
steward-independent, open-with-steward stays steward-only,
rosterless-open admits).
- Two `#[ignore]`d Postgres integration tests drive
`handle_relay_admin_event` with a real `AppState` (open rosterless admit
→ steward appears → roleless refused again; closed relay member
refused). Wired into the Backend Integration CI job as a dedicated
nextest step.
- **Both of Dawn's mutants verified killed** at this head: flag
inversion fails 1 unit test; gate deletion fails both integration tests
(`Ok(())` where `Rejected` expected).

## CI wrinkle found and fixed: pre-existing schema drift

The first Backend Integration run of the new 9033 tests failed with
`column "icon" of relation "communities" does not exist` — migration
`0003_community_icon.sql` added the column, but `schema/schema.sql` (the
desired-state file that CI job applies via pgschema) was never updated.
Pre-existing drift, invisible until a test in that job actually wrote
the column. Fixed in `297148f62` (3-line addition to
`schema/schema.sql`).

## Receipts (at `1b4b52db8` code / `297148f62` head)

- `cargo test -p buzz-relay`: 835 pass, 1 fail —
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo`,
pre-existing (fails identically at the old base and on clean main);
`telemetry::trace_context_lookup_does_not_enable_callsites` is a known
order-dependent flake, passes in isolation.
- `cargo test -p buzz-db`: 94 pass.
- Both ignored integration tests pass live against local Postgres.
- `cargo fmt --all -- --check`: clean.
- Live-local pass per TESTING.md at this head (release build, relay on
:3199, real WS + NIP-42 via nak):
- open rosterless: roleless key sets icon → NIP-11 serves it; `warn!`
with sender pubkey in the relay log
- open + owner row inserted: fresh roleless key refused ("must be admin
or owner"); owner sets icon
- closed relay (owner bootstrapped, `BUZZ_RELAY_PRIVATE_KEY` set): plain
member refused, owner sets icon, `javascript:` URL rejected, empty icon
clears (NIP-11 → null)

---------

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-08-01 12:03:31 -04:00
cb9701cd30 feat(relay): accept kind:30621 multi-repo projects at ingest (#3171)
Buzz renders one card per `kind:30617`, so a project spanning several
repositories has no representation.
[NIP-MP](https://github.com/block/buzz/pull/3163) defines `kind:30621`
as an addressable container holding a group's name, description, channel
binding, and member coordinates. This adds the kind to `buzz-core` and
its structural validation to the relay ingest path.

## Event shape

```json
{
  "kind": 30621,
  "tags": [
    ["d", "platform"],
    ["name", "Platform"],
    ["description", "Relay, desktop, and mobile."],
    ["a", "30617:<owner-a-hex>:buzz"],
    ["a", "30617:<owner-b-hex>:buzz-infra"],
    ["buzz-channel", "<channel-uuid>"],
    ["buzz-visibility", "listed"]
  ]
}
```

## Validation at ingest

| Rule | Behavior |
|------|----------|
| `d` tag | exactly one, non-empty (length already bounded by the
generic `D_TAG_MAX_LEN` check) |
| member `a` tag arity | exactly 2 or 3 elements per NIP-01's `a` tag
grammar; a 4th element has no defined meaning and is rejected |
| member `a` tag coordinate | must parse as
`30617:<lowercase-64-hex-owner>:<non-empty-d>` |
| duplicate members | rejected on exact string match of the canonical
coordinate |
| member cap | 64, counted over raw `a` tags |
| metadata cardinality | at most one each of `name`, `description`,
`buzz-channel`, `buzz-visibility` |
| metadata length | `name` ≤ 256 bytes, `description` ≤ 2048 bytes,
`buzz-channel` ≤ 256 bytes, `buzz-visibility` ≤ 256 bytes |
| zero members | valid |
| unknown tags | ignored |

Rejection order is normative so a client can predict which rule fires:
`d`-cardinality → `d`-empty → member-cap → member-arity → coordinate
parse → member-duplicate → metadata cardinality → metadata length.

## Design notes

**No membership authorization.** Members are `a` tags, so one project
may name repositories owned by different pubkeys — the entire point of
the kind. That is safe because membership grants nothing: push policy
reads a repository's own `kind:30617` (`api/git/policy.rs`) and never a
project. `buzz-channel` is a metadata reference, not a routing
directive, so projects are classified global-only.

**Owner-only editing is free.** NIP-33 addressing keys replacement on
`(pubkey, kind, d)`, so one signer can never overwrite another's
project. No relay-side permission check exists or is needed, and
`test_project_same_d_under_two_authors_are_independent` pins it.

**Duplicates are rejected, not deduped.** A relay cannot rewrite tags
inside a signed event without invalidating its id and signature, so the
alternative to rejection is a stored duplicate-member head that every
consumer must apply a first-wins rule to.

**The cap is checked before the duplicate set is built.** Counting raw
`a` tags rather than distinct coordinates means an event naming one
coordinate thousands of times is refused on count, instead of being
bounded only by the relay frame limit.

**No side-effect handler.** Generic NIP-33 replacement and generic
NIP-09 coordinate soft-delete already cover replacement and deletion;
`kind:30621` needs no entry in `is_side_effect_kind`.

## Generic NIP-09 fix carried along

`soft_delete_by_coordinate` (`crates/buzz-db/src/event.rs`) previously
deleted the live coordinate head regardless of the tombstone's own
`created_at`, so a delayed or replayed `a`-tag deletion signed between
two versions destroyed the newer replacement. NIP-09 scopes an `a`-tag
deletion to versions at or before the deletion request, so the `UPDATE`
now carries `created_at <= $5` and `handle_a_tag_deletion` threads the
deletion event's `created_at` through.

The bug predates `kind:30621` and affected every
parameterized-replaceable kind on the generic path — `kind:30617`
repository announcements included — so the fix lands there rather than
as a project special case. `events.created_at` is immutable per row, so
the predicate guarantees a tombstone can never erase a version newer
than itself; the UPDATE re-evaluates its WHERE clause after any lock
wait. Under READ COMMITTED, a same-coordinate replacement racing the
deletion may cause the deletion to evaluate before the new head lands,
returning `Ok(false)` — but that outcome is state-identical to the
deletion having arrived first, a valid Nostr ordering Nostr never fixes.
The return value feeds only a debug log. No coordinate-level lock is
needed.

## Coverage

32 unit tests in `crates/buzz-relay/src/handlers/ingest.rs` pin the
envelope contract (accept: minimal, cross-owner, zero-member, same repo
`d` under two owners, colon-bearing repo `d`, cap boundary, unknown
tags, relay hint on member `a` tag, max-length metadata, stranger-owned
member, uninterpreted metadata values, non-empty content; reject: every
rule above plus valueless `d`/`a` tags). A fixture-driven test
(`project_envelope_validates_all_shared_fixtures`) runs every case in
the shared `NIP-MP.fixtures.json` oracle (11 accept + 20 reject) against
`validate_project_envelope`, so any future change that breaks a case
turns the test suite red.

6 `#[ignore]`d e2e tests in
`crates/buzz-test-client/tests/e2e_project.rs` cover behavior that only
exists past storage — coordinate round-trip, newer-wins replacement, two
authors sharing a `d`, an `a`-tag tombstone that removes the project
while leaving referenced `kind:30617`s intact, and a tombstone
timestamped between V1 and V2 that must leave V2 live. The negative e2e
case asserts on the rejection message so a refusal for an unrelated
reason cannot satisfy it; that is what proves the validator is reachable
from the live write path rather than merely correct in isolation. The
new e2e binary is wired into the Relay E2E job.

The timestamp predicate is additionally pinned at the storage layer by
`coordinate_delete_spares_head_newer_than_the_deletion` in
`crates/buzz-db/src/lib.rs`, which asserts both directions: a stale
tombstone deletes nothing and leaves the newer head readable, and a
tombstone at the head's own timestamp still deletes it. This test is
wired into the Backend Integration job.

Related: #3163 (the NIP-MP spec and shared conformance fixtures).
Independent — either can merge first.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-07-31 16:22:57 -04:00
23f0c26b1c fix(relay): align NIP-11 max_limit with REQ ceiling (#3635)
Buzz's NIP-11 document advertised `limitation.max_limit: 10_000`, but
the effective websocket REQ page ceiling was `1_000` — a 10x lie.

The websocket REQ path never sets `EventQuery::max_limit`, so
`query_events` applied its own `unwrap_or(1000)` clamp to every
historical query. Only the COUNT fallback (`apply_count_fallback_limit`)
ever raises that clamp. A client that trusts the advertised value asks
for 10,000 events, silently receives 1,000, and — with no error and no
continuation signal — reads that short page as exhaustion. Up to 9,000
events are dropped without anyone noticing.

`MAX_HISTORICAL_LIMIT = 2_000` in `handlers/req.rs` was dead weight for
the same reason: nothing clamped to 2,000 could survive the DB's 1,000
clamp one layer down.

## Change

`buzz_db::DEFAULT_MAX_PAGE_LIMIT` (`1_000`) is now the single source of
truth. It is the `query_events` clamp default, the value both REQ clamp
sites use, and the value advertised as NIP-11 `max_limit`.
`MAX_HISTORICAL_LIMIT` is removed rather than re-pointed — an alias for
a constant used four lines away adds a name without adding meaning.

The NIP-50 search path carries a second, independent bound. It clamps
its emission target to the shared ceiling like any other REQ, but how
many FTS candidates it will scan was bounded separately, by a bare
10-page loop over 100-hit pages. That product only coincidentally
equalled the ceiling, so raising the ceiling — or shrinking a page —
would shrink the scan relative to what clients may now request,
degrading search quality while nothing in the code registered the
change. The page count is now ceiling-divided from
`DEFAULT_MAX_PAGE_LIMIT` over a named `SEARCH_PAGE_SIZE`, so the scan
budget tracks the advertised ceiling by construction.

That budget is a resource policy, not a delivery promise. It bounds
candidates *scanned*, not events *emitted*: post-filtering (NIP-01
match, channel access, reader visibility, dedup) discards an
unpredictable share of every page, so a search result smaller than the
requested limit remains possible. This is not a NIP-11 violation —
`max_limit` is defined as a clamp the relay applies to a requested
`limit`, not a guaranteed count in the response.

Two guards hold the pair together:

- `req_filter_limit_clamps_to_advertised_nip11_max_limit` reads
`max_limit` back out of a built `RelayInfo` and asserts the REQ path
clamps to exactly that number.
- `search_scan_capacity_covers_advertised_nip11_max_limit` asserts the
scan budget covers exactly one advertised ceiling's worth of candidates
— no less, and with no spare page of slack, so the derivation can't be
quietly replaced by a hand-tuned constant that happens to pass today.

## Behavior

Websocket behavior is unchanged: 1,000 was already the real ceiling on
every path, including NIP-50. The advertisement now tells the truth
about it. Raising the effective limit is a capacity decision and is
deliberately not made here.

The generic HTTP bridge's page-2+ offsets do change, as a consequence of
the corrected clamp. `extract_page_offset` sizes a page from
`query.limit` *before* the DB clamp applies, so an absent limit
previously produced an offset of 2,000 and a requested 1,500 produced
1,500 — while the page actually returned held at most 1,000 rows. Both
now produce 1,000. This corrects paging that had been skipping rows the
previous page never returned;
`extract_page_offset_sizes_pages_from_clamped_limit` locks it down.

## Scope note

The bridge's per-endpoint ceilings — `BRIDGE_WINDOW_MAX_LIMIT` (200) for
channel windows and `BRIDGE_THREAD_MAX_LIMIT` (500) for thread reads —
are endpoint contracts on a non-NIP-01 transport, not values NIP-11
speaks for, and are unchanged.


Fixes #3757

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-07-30 18:42:18 -04:00
Will PflegerandGitHub 114d40d9d3 feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358)
Team catalog projections (`kind:30178`) embed every member's system
prompt, so they need the same read gate personas already have: only the
author sees an unshared event. The gate was hardcoded to `kind:30175` at
six read surfaces plus the SQL pushdown, so rather than adding a second
special case it becomes kind-generic over `SHARED_GATED_KINDS = {30175,
30178}`.

## Kind 30178

New parameterized-replaceable kind, addressed by `(pubkey_o, 30178,
team_id)`. It embeds sanitized member projections instead of referencing
`kind:30175` heads — a foreign reader of a shared team could not
otherwise hydrate members whose own persona events are unshared or, for
built-ins, absent entirely. `kind:30176`'s wire body is untouched, so
device sync keeps its contract.

## Kind-generic shared gate

`buzz_core::kind` replaces `is_persona_shared_kind` /
`is_unshared_persona_event` / `persona_event_is_shared` with
`SHARED_GATED_KINDS` and the kind-agnostic `is_shared_gated_kind` /
`is_unshared_gated_event` / `event_is_shared`. Every read surface
consults the set:

| Surface | File |
|---|---|
| REQ historical delivery + `ids` lookup |
`crates/buzz-relay/src/handlers/req.rs` |
| Live fan-out | `crates/buzz-relay/src/handlers/event.rs` |
| COUNT fallback | `crates/buzz-relay/src/handlers/count.rs` |
| NIP-98 HTTP `/query`, `/count`, `/search` |
`crates/buzz-relay/src/api/bridge.rs` |
| Pre-`LIMIT` SQL pushdown | `crates/buzz-db/src/event.rs` |

The SQL clause generalizes from `kind != 30175` to `kind NOT IN (...)`
bound from `SHARED_GATED_KINDS`, still applied before `ORDER BY … LIMIT`
so a page of newer private events cannot starve an older shared one off
the candidate set. `EventQuery::persona_reader` is renamed
`shared_gated_reader` and `needs_persona_filtering` to
`needs_shared_gate_filtering` to match.

Because the `buzz-core` rename has consumers outside the relay, the four
desktop call sites of `persona_event_is_shared` travel with it:
`desktop/src-tauri/src/commands/personas/pending.rs`,
`desktop/src-tauri/src/event_sync.rs`, and two in
`desktop/src-tauri/src/managed_agents/persona_events.rs`. Each call is
unchanged apart from the name — the persona `shared` projection behaves
exactly as before.

## Ingest validation

`validate_persona_envelope` splits into two reusable pieces —
`validate_shared_tag` (exactly-two-element `["shared","true"]`, at most
one occurrence) and `single_bounded_d_tag` (exactly one `d` tag,
non-empty, `<=64` chars, no ASCII control characters or whitespace).
`validate_team_catalog_envelope` composes both; personas additionally
keep the slug grammar `^[a-z0-9][a-z0-9_-]{0,63}$`.

`kind:30178` deliberately does **not** get the slug grammar. Team ids
are UUIDs or built-in identifiers such as `builtin-team:welcome`, and
the colon is not slug-legal; rewriting ids to fit would break NIP-33
addressing against the team's own `kind:30176` head. The non-empty and
exactly-one checks are load-bearing regardless — without them generic
NIP-33 storage maps a missing `d` onto `(pubkey_o, 30178, "")` and every
team overwrites its predecessor.

The exact two-element `shared` shape is enforced because the SQL
visibility clause is JSONB containment (`tags @>
'[["shared","true"]]'`), which would match a three-element superset such
as `["shared","true","extra"]`.

`kind:30178` is also added to the `Scope::UsersWrite` allowlist and to
`is_global_only_kind`, so a stray `h` tag cannot channel-scope an
owner-authored definition.

## Deferred

`kind:30176` is deliberately not a gate member. Its writers never emit
`shared`, so catalog opt-in semantics do not describe it — it needs
owner-private reads driven by an authenticated principal set, tracked as
a separate follow-up.

## Tests

- 19 new `ingest.rs` unit tests covering the 30178 envelope (UUID and
colon `d` tags, 64-char boundary, non-ASCII bound,
empty/valueless/duplicate/missing `d`, embedded newline, `shared`
false/three-element/duplicate, scope and global-only membership).
- Persona regressions for the valueless `["d"]` shapes, since the
`d`-tag helper is shared by both validators.
- Existing `kind.rs` gate tests generalized and extended to assert the
gate applies to 30178 as it does to 30175.
- New `crates/buzz-test-client/tests/e2e_team_catalog.rs`: 9 WS-level
tests over a live relay covering author reads of unshared heads, foreign
omission from REQ, `ids`-lookup denial, COUNT existence-leak, share and
unshare transitions, and the mixed-kind filter case.
- `.github/workflows/ci.yml` adds `--test e2e_team_catalog` to the Relay
E2E job so the new suite runs.

## Docs

`docs/nips/NIP-AP.md` gains a "Team catalog projection: kind:30178"
section and an "Ingest validation: kind:30178" subsection, records the
gate as kind-generic, documents 30178 deletion vs. unshare semantics,
and adds a security note that sharing a team exposes every member's
instructions even when that member's own `kind:30175` head is unshared.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-07-30 17:53:30 -04:00
4933672eb4 feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) (#3741)
## Summary

This is **part 1 of 2** split out from #3467 (per Tyler's request),
carrying only the mesh-scoped changes. The agent/ACP response-behavior
changes and the new `send_message` tool stay in #3467 as part 2. All
commits are @michaelneale's work, cherry-picked with authorship
preserved.

- Upgrade embedded Mesh to v0.74.0 (tag-pinned instead of commit rev)
and use canonical Gemma model IDs.
- Keep shared compute serving through member joins, roster changes, app
recovery, and community switching.
- Wait for actual model readiness and avoid resuming incomplete
downloads after quit.
- Leave `BUZZ_AGENT_THINKING_EFFORT` unset by default so each model's
chat template picks its own thinking default (`none` suppressed Gemma
tool-calling entirely; pinning `low` made Qwen3 burn ~4x output budget).
Explicit agent/persona/global values still win.

## Relationship to #3467

Contains the mesh commits from #3467 (`2cd640b23`, `0ad81c341`,
`ad13ed841`) rebased onto current main, with one deliberate exclusion:
the `crates/buzz-agent/src/llm.rs` reasoning→text parser change from
`2cd640b23` is **not** here. That change unconditionally affects every
OpenAI-compat/Responses provider, so it belongs with the reply-behavior
work in part 2, where it can be reviewed as what it is.

Not included (remaining in #3467 / part 2):
- typed `send_message` tool in dev-mcp + `BUZZ_ACP_SEND_MESSAGE_TOOL`
gating
- plain-reply delivery fallback in buzz-acp
(`BUZZ_ACP_DELIVER_PLAIN_REPLIES`)
- the mesh_agent_e2e P5/P6 rewrite (exists to prove the reply path)
- the two `env.insert` preset opt-ins in `relay_mesh.rs` for the flags
above
- the llm.rs parser change

This PR is independently mergeable; part 2's flags are all off by
default so it can land before or after.

## Testing

- `cargo test -p buzz-relay --locked` — 780 passed (one telemetry test
is order-sensitive under parallel default settings; passes in the
pre-push suite and standalone, unrelated to this diff — files untouched
here).
- `just desktop-tauri-test` (default features) — 1877 passed.
- `cargo test --locked --features mesh-llm` in `desktop/src-tauri` —
1961 passed, including the new relay-mesh preset and
coordinator/recovery tests.
- Both `Cargo.lock`s resolve with `--locked` against the v0.74.0 tag.
- Full pre-push hook suite green (rust-tests, desktop-check/test, tauri
checks).

Live validation of the mesh v0.74 upgrade itself is documented on #3467
(two-Mac cross-version test).

---------

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-30 11:37:27 -04:00
63496cc1d4 feat(replica): portable heartbeat-token fence with snapshot-local reader routing (#3268)
## Summary

Expands read-replica usage on the relay per the Rev 2 design (thread
39d1e174 in #buzz-read-only-replica-usage): replaces the
Aurora-incompatible WAL-LSN read-side fence observation with a portable
**heartbeat token**, makes the freshness proof **snapshot-local to the
serving reader session**, and adds a default-off bounded-staleness gate
for head fetches.

### Probe (writer side, unchanged ordering)
The ordered writer scan is kept verbatim — `S = clock_timestamp()` →
masked-visibility `pg_stat_activity` oldest-xact scan — and now ends by
committing a heartbeat token **last** on the same pinned connection
(single-row `UPDATE replica_heartbeat ... RETURNING token, epoch`,
migration `0026`). The single-row update serializes all pods' probes, so
tokens are globally commit-ordered; the three-bucket completeness
argument carries over unchanged. Ring retains `(token, committed_at,
fence_wall)`; epoch change resets the ring; a same-epoch token
regression (restore adversary) clears the ring **and rotates the epoch
on the writer** so pre-rewind readers fail the epoch check. Cadence 1s.

### Routing (snapshot-local proof)
Every routed read opens `BEGIN ISOLATION LEVEL REPEATABLE READ, READ
ONLY` on a reader session and observes the heartbeat as the
transaction's **first statement** — the proof's snapshot is exactly the
snapshot the page, participants batch, and bridge aux closure read from
(`ReadSession` carries the open transaction; drop = rollback).
Fail-closed everywhere: begin failure, missing heartbeat row, epoch
mismatch, token below the ring, over-budget entry → writer.

- **Predicate B (cursor pages, default-on):** same completeness math as
the existing fence — cursor timestamp must be ≤ the proved wall; thread
candidate-terminal and above-wall pages re-run on the writer.
- **Predicate A (head fetches, default-OFF):** gated by
`BUZZ_REPLICA_HEAD_MAX_AGE_SECS` (0 = off, clamped to fence staleness).
Bounded-stale head semantics are an explicit product decision — **do not
enable anywhere without Tyler's backdated-event-semantics acceptance.**

### Observability
`buzz_db_route_decision{path, decision, reason}` across all five paths,
`buzz_db_replica_heartbeat_age_seconds` gauge, and per-decision debug
logs carrying the proved token plus backend identity: `addr:port pid=N`,
prefixed with `aurora_db_instance_identifier()` when the endpoint
supports it (probed once per process on an autocommit checkout; SQLSTATE
42883 caches a definitive false; identity is evidence, never a routing
gate).

## Review & verification

- **Wren:** full review 9/9/9 at `5f81b10e5`; identity delta re-review
approved at exact head `fedb46368` (90/90 unit, clippy `-D warnings`,
fmt independently reproduced).
- **Max:** local E2E **PASS** at `5f81b10e5` — isolated PG17 writer +
two streaming standbys behind HAProxy; paused-reader legs split exactly
as designed (6 replica/fresh + 6 writer/stale), recovery clean, RR
snapshot hardening observed in runtime logs. Evidence:
`WORK_LOGS/2026-07-28_REPLICA_HEARTBEAT_REPLACEMENT_SHA_E2E.md`.
- **My gates at head (same shell):** buzz-db 90 unit + 143
Postgres-gated green (scratch DBs); buzz-relay 767/768 — lone red is the
pre-existing `mesh_demo` flake, red on base; clippy `-D warnings` + fmt
clean.
- Key regression tests:
`routed_request_holds_one_snapshot_across_page_and_aux`
(mutation-verified both directions), same-epoch rotation,
capability-probe negative, head-gate truth table, divergent-fixture
routing suite.

## Post-merge plan

Merge publishes the immutable `sha-*` main image → bb-public PR pins it
→ ArgoCD sync → Max runs the production cursor-routing canary on Aurora
(positive identity branch proven live). Head gate stays off. Client
`since`-widening ships separately.

---

## Update — full read routing (Rev 6, commit `9fa3c9c0b`)

Extends routing to the remaining read seams per
`PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md` Rev 6 (thread cf5deba4 in
#buzz-read-only-replica-usage).

### New routed seams — ALL deploy-default dark
The new seams (`query_events_routed`, `query_events_routed_bounded`,
`count_events_routed`, `get_events_by_ids_routed`,
`query_feed_{mentions,needs_action,activity}_routed`) are
**Bounded-only** and gated on `BUZZ_REPLICA_READ_MAX_AGE_MS`: unset ⇒
every new seam records `writer/disabled` and merging is a no-op. COUNT
and feed/by-ids never take the covered arm (deletion visibility: covered
bounds insert-completeness only). **Note:** the pre-existing cursor
paths (channel windows, thread pages) are *not* gated by this env var —
they route at B=0 today and that status quo is unchanged.

### Reader pool (D4/D5)
- Lazy pool (`connect_lazy`, `min_connections(0)`): reader-down at boot
can't crash the relay; a warn-only boot ping is the only boot-time
visibility, and it primes the Aurora identity capability cache. Priming
is an optimization only — the routed path itself spends a single acquire
budget regardless (see the single-checkout fix below), because a boot
ping that *fails* is correlated with exactly the reader-unavailable case
the budget bound exists for.
- `READER_ACQUIRE_TIMEOUT` = 150ms; a miss fails closed to the writer
with reason **`reader_acquire_timeout`** — named for the mechanism, not
a diagnosis. The budget includes cold connects and sqlx's `size` counts
in-flight dials, so this metric alone does not distinguish contention
from slow connection establishment (see `proved_reader` doc-comment for
the runbook guidance; the pool gauges are 10s samples and are for
capacity planning).
- `BUZZ_DB_READ_POOL_SIZE` sizes the reader independently (invalid/0
inherits writer sizing); `read_pool_stats().max` reports the reader's
own ceiling.

### Community isolation (formal-model question)
Isolation is structural — an explicit `community_id = $n` predicate
compiled into every query builder; no RLS, no session-GUC tenant state
(zero `CREATE POLICY` across migrations). The `_on` variants reuse the
exact same builders with only the executor swapped. Proven by a
seven-seam two-community divergent-fixture test
(`routed_reads_are_confined_to_the_requested_community`),
mutation-tested by Dawn: all four single-predicate stubs killed; the
mention-join feeds are defended in depth (three independent predicates)
so only complete removal leaks there.

### Accepted limitation
D6: client-side staleness on bounded reads (up to `_MS`) is accepted
product behavior when the gate is enabled; gate stays off at merge.

## Update — single acquire budget per routed read (commit `dd26caa9f`)

Max found (and Dawn independently reproduced, 302–330ms measured) that
the boot-unavailable cold path spent **two** stacked
`READER_ACQUIRE_TIMEOUT` budgets: the Aurora capability probe did its
own `pool.acquire()` before `begin_with` acquired again. Fixed by
acquiring **once** per routed read — the capability probe runs on the
held connection (`reader_aurora_capability_on`) and the read-only
`REPEATABLE READ` transaction begins on that same connection
(`Transaction::begin` accepts a `PoolConnection` at `'static` via sqlx's
`MaybePoolConnection`). Reason codes unchanged; capability still never
negatively cached. Measured routed fallback: ~150ms (one budget).

Ships with a PG-gated regression fixture
(`routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold`,
authored by Dawn): size-1 reader saturated, capability cache asserted
cold, routes through `count_events_routed`, asserts writer answer +
one-budget elapsed + `writer/reader_acquire_timeout` label
(mutation-tested: `pool_busy` and `reader_validation_error` mutants both
killed). It fails at 330ms on the previous commit and passes on this
one.

### Verification at `dd26caa9f` (pinned toolchain rustc 1.95.0 via repo
`bin/`; ambient rustc 1.89 fails sqlx resolution — always prepend
`bin/`)
- buzz-db: 94 unit + **150/151** PG-gated serial (`--test-threads=1`;
suite is not parallel-safe against one Postgres). The 1 red is
`test_usage_metrics_lock_has_single_owner_and_releases_on_drop` — a
**pre-existing** same-key-same-database advisory-lock collision with any
live relay pointed at the shared `buzz` DB (the relay re-arms
`USAGE_METRICS_LOCK_KEY` on a 300s tick). It passes on a private scratch
DB with the relay still running — verified this run. Remedy is a scratch
`TEST_DATABASE_URL`, **not** terminating lock holders: inspect
`pg_locks`/`pg_stat_activity` and resolve the owning process first; an
idle advisory holder may be a live dev relay. Pre-existing, not a #3268
blocker — filed as #3619.
- buzz-relay: 773/773; clippy `-D warnings` + fmt clean. (`mesh_demo`
echo test is a known pre-existing Redis-dependent flake — Dawn confirmed
it fails identically on the base commit, untouched by this PR.)
- Dawn: independent re-verification at `dd26caa9f` — byte-identical diff
application confirmed, suite reproduced, and fixture proven to still
discriminate (fails at 320ms with only the production fix reverted).
Gate clear. Max: independent rig pass at `dd26caa9f` — 94/94 unit,
**15/15 routing/fallback matrix** (cursor behavior, default-dark seams,
seven-seam confinement, one-snapshot, dead-reader/no-URL fallback,
hard-delete fail-closed, reader max), 773/773 relay, private-DB lock
control. Gate clear; recommends merge + staged bb-block rollout
(no-reader-URL no-op → cursor-only → observe `buzz_db_route_decision` →
conservative nonzero `BUZZ_REPLICA_READ_MAX_AGE_MS`; rollback is
config-only).

## Known gap — CI does not run the PG-backed fixtures (#3622)

Dawn found post-sign-off (confirmed by Max and Eva at `dd26caa9f`):
**every Postgres-backed fixture in this PR is `#[ignore]`d and no CI job
selects it.** `Unit Tests` runs `cargo nextest run -p buzz-db --lib`
(`Justfile:279-285`, ignored tests excluded); the only `--run-ignored
ignored-only` invocations (`ci.yml:690`, `:702`) filter to
`relay_invite::tests`. So the one-budget regression, the seven-seam
isolation fixture, and the fence/floor-guard/fallback tests have zero
automated execution — the verification record above is exhaustive but
manual, at this exact SHA.

Not introduced by this PR (the `#[ignore]` + narrow-filter pattern
predates it; 34 ignored tests total) and not a merge blocker: all new
seams are dark until `BUZZ_REPLICA_READ_MAX_AGE_MS` is set. But it
changes the rollout gate — **do not set a nonzero
`BUZZ_REPLICA_READ_MAX_AGE_MS` in bb-block until CI enforces these
fixtures.** Filed as #3622 (explicit CI selection against the existing
backend-integration Postgres archive; ordering note: #3619 must land
first or be excluded, since widening the filter would select the
colliding usage-metrics lock test).
## Live redteam results (Max, real Helm + PG17 streaming replication at
`dd26caa9f`) — #3643, #3644

Max deployed this exact SHA via the repo chart against a physical
writer/standby pair and attacked it. **The PR's fenced routing passed
every fault**: healthy baseline routes proven on the real standby;
paused WAL replay moved head/cursor reads to `writer/stale`; B=0 moved
routed reads to `writer/disabled`; reader-URL removal rolled back
cleanly.

The redteam also found a **pre-existing** production risk this PR does
not create and does not fix: NIP-50 search builds its own eager,
unfenced pool straight from `READ_DATABASE_URL` (`main.rs:389-402`,
introduced by #2084, present in the deployed bb-block `sha-dd222a5` and
bb-public `sha-22be8bb` images, and both production ESOs already
template `READ_DATABASE_URL`). Measured: ~30s search stall on reader
loss, acknowledged-but-unsearchable writes under replica lag (unaffected
by `BUZZ_REPLICA_READ_MAX_AGE_MS`), fatal eager connect blocking pod
startup when the reader is down, and `replica=true` FTS even at B=0.
Filed as **#3643** (fix: pin FTS to the writer, or fence it like the
routed seams) with **#3644** for the writer-pointing-reader-URL
telemetry gap. Aurora's cluster-ro writer-fallback DNS softens the
outage modes in prod but not the lag mode.

**Revised ladder consequence:** step 2 ("add `READ_DATABASE_URL`, budget
unset") is NOT a no-op with today's binary — it hands FTS an unfenced
replica pool. Rollout order is now: merge (still a true no-op —
bb-block/bb-public already run the unfenced-FTS code and this PR only
adds dark seams) → fix #3643 → CI enforcement #3622 → then reader URL +
budget per the original ladder, re-running Max's three deployment tests
(reader-down latency, reader-down cold boot, paused-replay
read-your-own-write search).
## Activation gate (consolidated) — merge is clear; ALL of the below
precede any nonzero `BUZZ_REPLICA_READ_MAX_AGE_MS`

Six independent verification passes at `dd26caa9f` (Dawn ×2 incl.
byte-level + defang check, Max matrix + live Helm redteam, Wren full
CRUD regression + greenfield 0026 + 151/151 PG, Eva). Every induced
fault was either handled correctly by this PR's machinery or traced to
pre-existing main code. Remaining work gates **activation, not merge**:

1. **#3643** — unfenced NIP-50 FTS pool (pre-existing #2084; live in
prod today; also explains the blackhole search hang Wren observed — the
routed path was measured bounded at 151-152ms against a silent endpoint,
sqlx `inner.rs:252-255`).
2. **#3651** — reader `statement_timeout`: statements after acquire are
unbounded; mid-transaction blackhole reproduced hanging ≥15s (Dawn).
Small fix via `.after_connect` on the reader pool.
3. **#3622** — CI enforcement of the PG fixtures (with #3619 ordering).
4. **Recovery-conflict cancellation live test** — standby cancels a
routed read mid-snapshot → complete writer page, never partial (#3651's
fix also bounds the cancellation-never-arrives shape).
5. **DDL replication lag test** — migration on writer + paused replay +
budget on → fallback, not client-visible SQL errors.
6. **Reader-tx soak** — 30-60min mixed load; no idle-in-tx accumulation
on the standby.

Then Max's three deployment tests (reader-down latency, reader-down cold
boot, paused-replay read-your-own-write search) re-run on the fixed
binary before the first nonzero budget on bb-block.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-29 20:35:15 -04:00
788b3c002b fix(git): channel binding tooling + author remediation for unbound repos (#3626)
Closes #3527.

Repos announced via vanilla NIP-34 (kind:30617 without a `buzz-channel`
tag) 404 forever: the SEC-005 read gate requires a channel-membership
ACL, and nothing tells the author why or how to fix it. Per the ruling
in the originating thread, this ships **bind/rebind tooling plus a
narrow author-only remediation carve-out** — the shelved owner-circle
approach is intentionally absent.

## Relay
- **`api/git/binding.rs` (new):** shared tri-state binding resolver —
`Bound(uuid)` / `NotBound` / `Broken`. First-tag, fail-closed: a
malformed `buzz-channel` tag is `Broken`, never conflated with "no tag".
Both gates use it.
- **Read gate (`transport.rs`):** a **never-bound** repo read by **its
own announcement author** still returns 404 (status byte-identical to
the generic denial) but the body carries remediation: `run: buzz repos
bind --id <repo> --channel <channel-uuid> — …`. This leaks nothing — the
author announced the repo, and only the author can rebind (30617 is
keyed by `(author, d)`). `Broken` bindings stay generic-denial for
everyone, including the author (revocation shape).
Bound-to-nonexistent-channel stays generic (phase 1; ingest validation
is phase 2).
- **Push gate (`policy.rs`):** unbound denial now returns
`GIT_NO_CHANNEL_BINDING_BODY`. A deploy-skew test pins that the body
carries both the new token (`no_channel_binding`) and the legacy phrase
(`"no channel binding"`) so already-shipped desktops keep matching.
**(Review r1, blocker 2)** `Broken` no longer collapses into "unbound":
it denies 403 `invalid channel binding` for *everyone — including the
announcement owner —* **before** the owner short-circuit, matching the
read gate's fail-closed posture. The remediation token stays
NotBound-only.
- **`ingest.rs`:** side-effect failure `warn!` → `error!` — prod runs
`RUST_LOG=error`, so these failures were invisible during triage.

## Contract
- **`buzz-core/git_perms.rs`:** `GIT_NO_CHANNEL_BINDING_TOKEN` /
`GIT_NO_CHANNEL_BINDING_BODY` consts as the declared cross-component
contract; relay tests and desktop matcher both build on them.

## CLI
- **`buzz repos bind --id <repo> --channel <uuid>`** — rebinds an
existing announcement, preserving other tags.
- **(Review r1, blocker 1)** **`--channel` on `buzz repos create`** —
optional; injects exactly one shape-validated `buzz-channel` tag at
creation via a pure `build_create_announcement` builder, so the primary
create command stops producing repos the relay 404s. UUID
existence/membership stays the relay's authority at git-access time
(same TOCTOU posture as `repos bind`). Overlaps with #3594 (open, head
6bbe38459) — happy to reconcile whichever lands first; this branch also
carries the bind path and tag preservation.

## Desktop
- **Rust:** new `commands/project_git_merge_error.rs` (extracted from
`project_git_workflow.rs` to respect the 1000-line ratchet); maps the
token to a structured `no_channel_binding` error carrying the bind
command.
- **TS:** new `features/projects/lib/projectBranchErrors.ts` + tests —
dual matcher (new token AND legacy spaced phrase);
`ProjectBranchDialogs.tsx` uses it.

## Tests / verification (at head f914c7066, base 581baa625)
- Workspace `cargo test` green; `clippy -D warnings` clean; desktop Rust
1859 pass; TS 3780 pass; tsc/biome/file-size checks pass. Pre-push hooks
re-ran all suites at the pushed head.
- Postgres-gated `sec005_read_gate_tests`: all 6 pass, including
`read_gate_gives_author_of_unbound_repo_remediation_body` — asserts 404
status, `text/plain` content-type, and exact body bytes, distinguishing
remediation from generic denial (a blind `is_err()` can't).
- **New (review r1):** `buzz-cli` emitted-event tests —
`create_with_channel_emits_exactly_one_binding_tag`,
`create_without_channel_emits_no_binding_tag`,
`create_rejects_malformed_channel_uuid` (266/266 pass). Postgres-gated
`push_gate_denies_owner_through_broken_binding` — owner +
malformed-first/valid-second binding → 403 generic body without the
remediation token; never-bound control stays 200, pinning the denial to
`Broken` specifically.
- e2e git tests now bind announcements to a real channel via a
`create_test_channel` helper.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-29 20:07:55 -04:00
7012d86d52 feat: configure S3 URL addressing style (#3400)
## Summary

- add one strict `BUZZ_S3_ADDRESSING_STYLE=path|virtual` setting shared
by media and Git/CAS storage
- preserve path-style defaults for bundled Compose/Helm MinIO while
supporting Railway's virtual-hosted bucket contract
- fail startup on invalid or non-Unicode values before dependency
connection, and validate the Helm value with the same two choices
- document operator mappings and why endpoint and bucket remain separate
for routing and SigV4 signing

## Best-practice rationale

AWS documents both URL forms and favors virtual-hosted addressing for
S3, while compatibility endpoints such as the bundled MinIO deployment
can require path style. `rust-s3` defaults to virtual/subdomain
addressing and provides `with_path_style()` for the explicit
compatibility case.

Some providers buckets only support as virtual-hosted bucket styles.
This PR therefore uses one explicit, provider-neutral switch rather than
endpoint heuristics or fallback behavior, while retaining `path` as
Buzz's backward-compatible default.

Sources:
-
https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html
- https://docs.rs/rust-s3/0.37.0/s3/bucket/struct.Bucket.html
- https://docs.railway.com/storage-buckets#url-style
-
https://github.com/minio/minio/blob/master/docs/config/README.md#domain

## Validation

- `cargo fmt --all`
- `cargo check --workspace --all-targets`
- targeted `buzz-media` and `buzz-relay` parsing/client-construction
tests for defaults, strict errors, and both URL styles
- Helm unittest: 45/45 passed
- Compose config/render validation passed
- local MinIO path-mode relay startup passed the Git A3 conformance
probe and became ready
- unreachable object storage failed startup and readiness never opened
- push hooks completed the broader Rust and desktop suites successfully

---------

Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
2026-07-29 17:00:41 -07:00
005b5b819a feat(tracing): correlate trace IDs in relay logs (#3608)
## Summary
Correlates trace + span IDs with logs, allowing traces and logs to be
bridged seamlessly

### Related issue
none found

### Testing
Unit tests

Signed-off-by: David Grochowski <dgrochowski@squareup.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-07-29 17:01:26 -04:00
22be8bb351 fix(relay): avoid subscription lock inversion (#3413)
## Summary
- drop the `subs` DashMap guard before mutating subscription indexes
- snapshot fan-out candidate vectors so index guards are dropped before
looking up `subs`
- add concurrent fan-out/replacement regression coverage

## Why
`fan_out_scoped` previously held an index guard while `push_match`
acquired `subs`, while CLOSE and same-ID replacement held `subs` while
removing from an index. The reverse ordering made an AB/BA deadlock
reachable and could synchronously park all Tokio workers.

## Validation
- `rustup run 1.95.0 cargo test -p buzz-relay` — 769 library tests
passed, 33 ignored; 11 binary tests passed; doc tests passed
- push hooks with pinned Rust 1.95 — branch-skew, repository Rust
suites, and desktop Tauri suite passed
- `git diff --check`

## Residual risk
Fan-out now clones bounded candidate vectors before matching. This adds
allocation/copy cost proportional to the indexed candidate set, in
exchange for eliminating nested DashMap guards. This fixes the concrete
lock cycle but does not prove every observed production wedge had this
cause.

---------

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-07-28 21:13:28 -04:00
2ce2d71cc3 feat(relay): make Postgres pool size configurable, default 50 (#3191)
## Summary

- Raise the relay's Postgres pool cap from the `buzz-db` default of 20
to 50 per pool, and expose `BUZZ_DB_POOL_SIZE` for per-deploy tuning
- Applies to the writer pool and, when `READ_DATABASE_URL` is set, the
reader pool; zero/unparsable values fall back to the default
- The `buzz-db` library default is unchanged — only the relay opts into
the larger cap

## Why

During the 2026-07-27 18:40–19:05Z traffic burst on bb-public, per-pod
PG pools pinned at 20 fleet-wide and ~380 requests failed on the 3s
acquire timeout — membership checks, channel access lookups, and
historical queries returning errors to users. The database was nowhere
near a limit: Aurora (db.r8g.8xlarge, ~5,000 max connections) sat at 19%
CPU, 201 connections (~4% of capacity), commit latency flat at 0.01ms.

The 20-connection default was sized for "four relay pods against PG
max_connections=100" (the comment in `buzz-db` says exactly that).
Production now runs 12–15 pods against Aurora — the per-pod cap is the
binding constraint, not the DB.

Budget at the new default: 15 pods × (50 writer + 50 reader + 5 audit) ≈
1,575 potential connections, ~30% of Aurora's ceiling — and actual usage
stays demand-driven (`min_connections` stays 2, connections only open
under load).

Same shape as #2521 (`BUZZ_REDIS_POOL_SIZE`), which fixed the identical
class of ceiling on the Redis side.

## Testing

- `cargo test -p buzz-relay`: 762 passed, 1 failed — the lone red is
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo`, the
known pre-existing flake; it fails identically on clean `main` at the
same SHA (verified via `git stash` / rerun)
- New test `db_pool_size_env_override_and_invalid_fallback` covers
override, zero, and unparsable fallback
- `defaults_are_valid` extended to pin the new default
- `cargo clippy -p buzz-relay --all-targets -- -D warnings` and `cargo
fmt --check` clean

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-28 09:17:43 -04:00
e94b9aeda0 feat(tracing): add datastore tracing plumbing (#2760)
Configure a dedicated datastore tracing target on the OTLP layer while
preserving explicit logging filters and avoiding span overhead when OTLP
is disabled.

This is in preparation for adding trace spans for datastores used in
Buzz

## Update — 2026-07-27

- Export HTTP requests as `INFO` server spans under `buzz_relay`,
preserving request parentage for datastore spans.
- Configure OTEL span filtering independently with `BUZZ_OTEL_FILTER`,
so `RUST_LOG` changes cannot break trace topology.
- Verify exported HTTP and datastore spans share a trace ID and have the
expected parent/child relationship.

Co-authored-by: Amp <amp@ampcode.com>
2026-07-27 23:32:42 -04:00
d500c2d5cf feat(invites): add use-limited invite links (#3141)
## Summary

- add database-backed v2 invite links with optional maximum-use limits
and atomic final-slot redemption
- preserve v1 invite compatibility while adding
exhausted/expired/invalid client handling across desktop, web, and
mobile
- emit structured claim-outcome logs with community, invite ID, outcome,
maximum uses, and post-claim count

## Verification

- `cargo fmt --all -- --check`
- `cargo test -p buzz-db` (85 passed, 134 Postgres-dependent ignored)
- `cargo clippy -p buzz-db --all-targets -- -D warnings`
- desktop `npm run typecheck`
- push hook: desktop checks/tests, desktop Tauri tests, Rust tests, and
branch-skew passed
- Postgres integration tests were previously reviewed green at the
pre-rebase tree; local rerun on this session was unavailable because
Postgres/Docker were not running
- mobile push-hook check could not start because Flutter is unavailable
locally

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c <c57bf9b4275088b2b33db7f746975407210f159fbd8bd733c0e532375f69aa80@buzz.block.builderlab.xyz>
Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-07-27 15:19:39 -07:00
f069a85503 feat(admin): show reported message content in report detail (#3149)
## Summary

- include the reported event's complete stored content, author, creation
time, and deletion state in the admin report detail response
- resolve the event through a community-scoped join so an event ID
collision cannot cross tenant boundaries
- render the message only on report detail, with an explicit unavailable
state when retention has removed it
- preserve the existing report list contract so message bodies are not
returned during queue browsing

## Security

- the existing admin host/origin authorization runs before the detail
database read; a route test pins that ordering
- the target event is selected using both `events.community_id =
moderation_reports.community_id` and `events.id =
moderation_reports.target_event_id`
- the client supplies only the report UUID; it cannot choose a community
or arbitrary event ID
- soft-deleted content is visible only through this restricted admin
detail route and is labeled deleted
- responses retain the admin API's `no-store`, CSP, `nosniff`, frame
denial, and referrer policy middleware

## Testing

- `pnpm -C admin-web check`
- `pnpm -C admin-web test:e2e` (10 passed)
- `cargo test -p buzz-db` (84 passed, 130 ignored)
- `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings`
- focused admin authorization tests
- pre-push Rust and desktop/Tauri suites passed

The new Postgres integration test is ignored under the repository
convention and will run when explicitly enabled against migrated
Postgres. Local Postgres and Redis were unavailable, so the full
`buzz-relay --lib` run had 8 existing infrastructure-dependent failures
after 749 tests passed.

---------

Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
2026-07-27 12:49:21 -07:00
9b0f744804 resolve findings (#3150)
Fixes all six HIGH findings from the buzz security report, one commit
per finding. Independently reviewed to approval by Max at `0158ae542`,
plus a deep isolated live pass (clean-room compose stack, weird ports,
full product matrix) at the same head — see the buzz-security thread for
evidence. `fe65c07c3` merges current `origin/main` on top (new commit,
no rebase), inheriting the nostr 0.44.6 bump (#3135) and relay-admin ban
gate (#3128).

## Findings and fixes

| Finding | Commit | Fix |
|---|---|---|
| 003 — quinn-proto RUSTSEC-2026-0185 | `e5dcdec72` | Bump quinn-proto
0.11.14 → 0.11.16 (lockfile-only) |
| 002/004 — linkify-it quadratic-parse DoS (GHSA-22p9-wv53-3rq4,
GHSA-v245-v573-v5vm) | `923b3c20f` | pnpm override `linkify-it: ^5.0.2`;
`pnpm why` confirms a single 5.0.2 copy |
| 001 — media reads served unauthenticated by default | `0f277e3e2` |
Helm `requireMediaGetAuth` defaults to `true` + rendered-chart test
pinning the default |
| 006 — removed workflow owners retain webhook-exfiltration authority |
`4749bd56c` | Fail-closed per-fire authority gate (current owner/admin
membership) on **all four** trigger doors (on_event, scheduler
pre-claim, manual trigger, webhook — masked as generic 404), save-time
gate for `call_webhook` defs, durable disable-on-removal wired to kinds
9001 + 9022 |
| 005 — git Smart-HTTP reads ignore channel membership | `e648f2dba` +
`0158ae542` | `authorize_git_read`: caller's **current active
membership** in the repo's bound channel, checked before any
hydration/subprocess on all three read doors (`info_refs` for both
services + `upload_pack` POST). Uniform generic 404 denials (no
membership probing), no repo-owner bypass, first-`buzz-channel`-tag
binding semantics fail closed on ambiguous duplicates (mutation-verified
test). Resolution follows the live kind:30617 announcement, so
deleted/replaced announcements deny immediately. The committed
`e2e-git-perms.sh` guest scenario previously asserted the vulnerability
— now asserts denial. |

## Behavior changes to be aware of

1. **Unbound repos fail closed for git reads.** `buzz repos create`
emits no `buzz-channel` tag, so CLI-created repos without a binding are
unreadable via git HTTP. Correct per finding 005's fail-closed posture;
a follow-up could bind CLI-created repos at creation time.
2. **006 is conservative:** a workflow disabled on owner removal does
not auto-re-enable if the owner is re-added — explicit re-enable
required.
3. Merge conflict resolution in `fe65c07c3`: kept main's
`@radix-ui/react-dismissable-layer` 1.1.19 bump alongside the linkify-it
security override (`pnpm-workspace.yaml` + lockfile).

## Verification at the merge head `fe65c07c3` (same shell)

- buzz-relay `--lib`: 761 passed / 1 failed — the lone red is the known
pre-existing `mesh_demo::demo_join_forwarded_arm_round_trips_echo` 504
flake, present on main
- SEC-005 module incl. PG behavioral matrix: 8/8 (removed-member,
never-member, owner-no-bypass, deleted-30617, malformed/ambiguous
binding, owner-mismatch all denied)
- buzz-workflow 153/0, buzz-db 84/0; `clippy --all-targets -D warnings`
+ `fmt --check` clean
- Desktop JS 3637/3637, tsc clean, biome clean,
file-size/px-text/pubkey-truncation gates clean
- `helm lint` + `helm unittest` (40/40) on `deploy/charts/buzz`
- All five pre-push hooks green (desktop-check, desktop-test,
rust-tests, desktop-tauri-test, branch-skew)

Prior review evidence at `0158ae542` (pre-merge): Max's independent
exact-head approval + clean-room live regression pass
(`WORK_LOGS/2026-07-27_SECURITY_HIGH_LIVE_TEST.md` in his workspace).
Max will re-run the deep local pass at this post-merge head before
merge.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-27 14:18:24 -04:00
Will PflegerandGitHub a041e2d21e Revert "fix(cli,relay): resolve agents by verified owner" (#3168)
Reverts block/buzz#2615
2026-07-27 14:10:16 -04:00
c3084b36d9 fix(cli,relay): resolve agents by verified owner (#2615)
## Context

`buzz users get --name Honey` searches relay-wide profiles and returns
up to 100 identically named results without verified ownership metadata.
An agent resolving “my Honey” cannot distinguish the requesting human's
agent from another owner's agent, and the owned match can be excluded by
the result limit. This caused the wrong Honey and Bumble pubkeys to be
added to a channel.

## Summary

This bug fix makes personal-agent resolution owner-aware. Callers can
filter profiles by a verified owner identity before result limits are
applied, and all profile results expose enough ownership context to
diagnose duplicate names.

## Changes

- Adds `buzz users get --owner me|<hex>|<npub>` for name and pubkey
lookups.
- Resolves `me` to the NIP-OA owner identity when the CLI runs as an
agent.
- Filters profiles by the relay's verified `agent_owner_pubkey`
relationship before applying the result limit.
- Returns `owner_pubkey`, `owner_display_name`, and client-relative
`owned_by_me` in compact and JSON output.
- Returns an empty result when no owned profile matches instead of
removing the ownership constraint.
- Rejects malformed owner values instead of silently running an unscoped
query; explicit `null` remains equivalent to no owner filter for
ordinary CLI lookups.
- Rejects owner constraints on specialized channel-window, feed, and
thread filters that cannot enforce author filtering.
- Scopes owner filtering and enrichment to the active community.
- Adds a partial `(community_id, agent_owner_pubkey)` index for owner
lookups.
- Documents the safe `users get --name Honey --owner me` lookup.

## Reviewer-reproducible examples

The relay-backed test creates two same-name agents with different
verified owners, queries through the HTTP `/query` route, verifies only
the selected owner's agent is returned with verified owner metadata, and
verifies a missing owner returns `[]`.

```bash
cargo test -p buzz-relay query_agent_owner_returns_only_verified_owner_matches --lib -- --ignored
```

The owner/author intersection and unsupported-specialized-filter
contracts also have infrastructure-free relay tests:

```bash
cargo test -p buzz-relay agent_owner --lib
```

The CLI surface is visible in command help:

```bash
cargo run -q -p buzz-cli -- users get --help | grep -- --owner
```

```text
      --owner <OWNER>     Filter agents by verified owner (`me`, 64-char hex, or npub)
```

## Validation

- `cargo test -p buzz-cli` (252 passed)
- `cargo test -p buzz-db` (84 passed, 122 infrastructure tests ignored)
- `cargo test -p buzz-relay --lib` (owner-filter tests pass; the full
local suite is blocked by unrelated Postgres pool timeouts in
media/admin tests)
- `cargo test -p buzz-relay
query_agent_owner_returns_only_verified_owner_matches --lib --
--ignored` (passed)
- `cargo check --workspace --all-targets`
- `cargo fmt --all -- --check`
- Pre-push Rust, Desktop, and Desktop Tauri suites passed
- Pre-push mobile suite could not start because `flutter` is not
installed
- `pnpm check:file-sizes` (passed after rebasing onto current `main`)

---------

Signed-off-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz>
Co-authored-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz>
2026-07-27 13:10:42 -04:00
e2e0079101 fix(security): enforce durable community ban on NIP-43 relay-admin kinds 9030-9033 (#3128)
## Summary

`ingest_event`'s durable write-path restriction gate exempts NIP-43
relay-admin kinds **9030–9033**, so that a *timed-out* admin keeps
administrative capability. That exemption was ban-blind, and
`handle_relay_admin_event` performed no restriction check of its own. A
**banned** admin or owner could still add members, remove members,
change member roles, and set the workspace icon by posting a signed
NIP-98 request to `POST /events`. No open WebSocket required.

Reported externally by **Bilal Syed** (also filed publicly as #3020
before he read `SECURITY.md`). Verified true, reproduced live, and found
slightly worse than reported.

Same class as BUZZ-SEC-007, which PR #1915 closed for moderation command
kinds 9040–9044. That fix was never extended to the 9030 range.

## Why it worked

- `handlers/ingest.rs:1639` skipped the restriction check when
`is_relay_admin_kind(kind)` was true.
- `handlers/relay_admin.rs` did a freshness check and a role lookup only
— zero restriction reads in the file.
- A ban does not remove the role: `ban_member`
(`buzz-db/src/moderation.rs:314`) writes only `community_bans`, so the
`relay_members` admin row survives.
- The HTTP path never consulted ban state — `enforce_relay_membership`
is a bare `SELECT 1 FROM relay_members`.
- The ban was enforced only at the NIP-42 auth seam, which an HTTP
request never crosses.

**Worse than reported:** the report covered remove (9031) and icon
(9033). Add (**9030**) works too, so a banned admin can *plant* new
members. That matters because `moderation_authz.rs:163-170` derives "an
admin cannot ban an owner or fellow admin" from `relay_members` — the
very table 9030/9031 mutate. A banned admin could seed accomplices into
the roster the ban was meant to stop them touching.

Also of note: `moderation_authz.rs:158-165` already asserts in a comment
that *"The command handler separately rejects a banned actor on every
transport."* `relay_admin.rs` was the one command handler not holding
that invariant.

## The fix

Enforce the durable ban **inside `handle_relay_admin_event`** — the
reporter's own suggested shape, and the `moderation_commands.rs:99-108`
precedent.

Deliberately **not** the one-token alternative of dropping `&&
!is_relay_admin_kind(kind_u32)` at `ingest.rs:1639`: that would also
start blocking *timed-out* admins, silently changing policy. Bans are
refused; timeouts still administer, which is the entire reason the
exemption exists.

`handle_relay_admin_event` becomes a thin admission wrapper around an
unchanged `execute_relay_admin_command` body, so no future early return
inside that body can precede the check. The check therefore also
necessarily precedes the freshness check.

**The refusal category is part of the security contract**, so this
returns a typed `RelayAdminError` rather than a string. A `blocked:`
string would have kept the right wire text but returned **400** instead
of **403** (`api/bridge.rs:845` vs `:858`), and would have reported a
restriction-DB outage as a client error:

| Variant | Ingest | Wire | HTTP |
|---|---|---|---|
| `Banned` | `AuthFailed` | `blocked: you are banned from this
community` | **403** |
| `Rejected(..)` | `Rejected` | `invalid: …` | 400 (unchanged) |
| `Internal(..)` | `Internal` | `error: …` (sanitized) | **500** |

## Verification

Live over real HTTP against an isolated relay, all four exempt kinds
refused, DB checked after each for non-mutation:

```
[banned] 9031 remove      -> 403 blocked: you are banned from this community
[banned] 9030 add         -> 403 blocked: you are banned from this community
[banned] 9032 change role -> 403 blocked: you are banned from this community
[banned] 9033 set icon    -> 403 blocked: you are banned from this community
```

Victim still `member`, planted key absent, role target unchanged, icon
still NULL. 9032 required a banned **owner** to be a real test, since it
is owner-only.

- **Mutation-tested.** The admission decision is the pure
`admits_relay_admin_command(&RestrictionState)`, covered by the
*default* suite. Neutering it fails
`banned_actor_is_not_admitted_to_a_relay_admin_command`. The first
version of this patch would have stayed green if someone deleted the
check — that gap is closed. The unit test does not prove handler
*wiring*; the `#[ignore]`d live E2E is what checks linkage.
- **Fail-closed proven empirically**, by manual fault injection rather
than assertion: renaming `community_bans.banned` out from under the
running relay yields 500, no mutation, and no schema detail leaked to
the client.
- Negative/positive controls: timed-out admin still administers *and* is
still content-write-blocked; clean admin unaffected with mutation
confirmed; non-admin still gets `invalid:`/400.
- Reviewed iteratively by **@Mari** over three rounds; final approval at
9/10+ on minimalness, elegance, and correctness. She also ran an
independent deep regression pass on an isolated stack (odd port 44391)
covering channel lifecycle, membership,
messages/replies/search/edit/delete, reactions, canvas, DMs, and
moderation transitions — no regressions.
- `cargo fmt --all --check`, `cargo clippy -p buzz-relay --all-targets
-D warnings`, `buzz-core` 229/229, `buzz-cli` 250/250, `run-tests.sh
unit` all five packages green.
- `buzz-relay --lib`: **756 passed / 1 failed**. The sole failure
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` (504
vs 200) is **pre-existing** — reproduced identically in a detached
worktree at merge base `00ecf2c`.

## Notes for the reviewer

- Merged `origin/main` in as a merge commit rather than rebasing, per
instruction. No conflicts; the eight incoming commits touch none of the
three files here. Closest neighbour is `00ecf2c` (kind:9000 NIP-29
*channel* role authz) — disjoint from this NIP-43 *relay-admin* fix.
- **This does not close the class.** Two separate items remain open,
deliberately excluded to keep an externally-known security fix
reviewable:
1. **Command kinds dispatch before the gate.** `is_command_kind` fires
at `ingest.rs:1561`, ~80 lines *before* the restriction gate, and
`command_executor.rs` has no restriction read. Measured live: a banned
member can still open a DM (41010 → 200). 41011/41012/30620/46030/46031
unprobed. Needs per-kind semantics enumerated first (reports allowed
while banned; moderation commands allow timeouts but reject bans;
ordinary writes reject both).
2. **`moderation_commands.rs` maps its own restriction-DB failure to
400, not 500**, and leaks the raw Postgres message to the client.
- One correction for the public issue: its repro step 1 says
`kind:9041`, which is **unban**. The ban is **9040**
(`KIND_MODERATION_BAN`, `buzz-core/src/kind.rs:298`). Following the
steps verbatim yields a false negative.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-27 12:17:14 -04:00
00ecf2cac7 fix(security): authorize kind:9000 role changes in both directions (#3017)
## Summary

NIP-29 `kind:9000` (PUT_USER) role changes were only authorized when the
**new** role was elevated. Demotions were unauthorized, so any
authenticated user could strip a channel owner to `member` with a single
event — and the demotion was unrecoverable, since the ex-owner then
lacked the privilege to restore themselves.

Reported by @Tyler in `#buzz-security`. Verified true, plus two adjacent
defects the report flagged and one it did not.

## The defects

1. **Demotion unauthorized.** The actor check only fired when the
*requested* role was elevated. Lowering someone's role skipped it
entirely.
2. **Open channels skipped the actor check.** It was nested under
`visibility == "private"`.
3. **`add_member` had no last-owner guard** while `remove_member` did —
so a channel could be left with zero owners.
4. **(Not in the report.)** An absent `role` tag defaulted to `Member`,
so a bare self-targeted PUT_USER silently demoted the sender. No
attacker required.

## The fix

**`crates/buzz-db/src/channel.rs`** — the authority, because it also
covers the desktop/admin callers that bypass the relay validator:

- Changing an **active** member's role requires an elevated actor **in
both directions**. Re-adding at the same role stays unguarded and
idempotent (the huddle bot-add and `kind:9021` join paths depend on
this).
- Last-owner guard in `add_member`, mirroring `remove_member`.
- Keyed on the **active** role (`removed_at IS NULL`). A soft-removed
row's role is history, not live authority — otherwise soft-deleted
ownership becomes a resurrection token: a kicked owner self-rejoins via
`9021` and silently regains ownership.
- New `pg_advisory_xact_lock` on a channel-membership namespace, taken
as the first statement in both `add_member` and `remove_member`. Both
read an owner `COUNT` and then write a *different* row, so READ
COMMITTED alone lets two concurrent demotions each observe 2 owners and
together leave 0.
- `remove_member`'s `is_agent_owner` lookup moved before the transaction
opens — it borrows a second pool connection, and issuing it while
holding the lock could self-deadlock on a small pool. Safe because
`agent_owner_pubkey` is immutable (first-mint-wins).

**`crates/buzz-relay/src/handlers/side_effects.rs`**:

- Role tag is now `Option` — absent means "no role change requested"
rather than defaulting to `Member`.
- Actor-role lookup hoisted out of the `visibility == "private"` block,
so open channels are covered.
- Role-change and last-owner guards on every visibility. Rejecting here
*as well as* in the DB means clients get a real error instead of an `OK`
whose side effect then fails silently.

## Verification

**Mutation tested — every guard stubbed individually to confirm a test
actually dies.** Three of eight guards were originally uncovered and
survived being disabled with the suite fully green:

| Guard | Dying test |
|---|---|
| DB actor-auth | *survived* → **new**
`unprivileged_member_cannot_demote_a_co_owner` |
| DB last-owner | `owner_can_still_manage_roles_after_demotion_guard` |
| DB active-role (soft-remove) |
`kicked_owner_rejoins_as_member_not_owner` + 3 |
| `add_member` advisory lock |
`membership_writes_serialize_on_the_shared_channel_lock` |
| `remove_member` advisory lock | +
`remove_member_rejects_an_actor_demoted_while_it_waited` |
| relay no-role-tag preservation |
`test_nip29_put_user_without_role_tag_preserves_role` |
| relay actor-auth | *survived* → **new**
`test_nip29_relay_rejects_role_change_by_unprivileged_actor` |
| relay last-owner | *survived* → **new**
`test_nip29_relay_rejects_last_owner_self_demotion` |

The three gaps shared one cause: every existing test asserts resulting
**state** ("the role did not change"), and the DB guards enforce that
state, masking every layer above them. With a relay guard stubbed the
relay answers `accepted:true` and logs `Side effect failed: access
denied: ...` while the state assertion still passes — the entire relay
validator could be deleted unnoticed. The new relay tests assert
`accepted == false` instead, the one observable only the validator
controls. Each new test is verified in both directions: green against
the real fix, failing with its intended message when its guard alone is
stubbed.

**Test runs** (at `9461eedb`):

- `buzz-db`, serial: **210 passed / 3 failed** — the same 3 failures as
clean `main` (202/3), which are pre-existing and unrelated
(`concurrent_same_owner_create…`,
`create_community_with_owner_is_atomic…`,
`test_usage_metrics_lock_has_single_owner…`). +8 = the new tests.
- `e2e_relay --ignored`: **40 passed / 3 failed**. Clean `main` on the
same relay is 35/6 — the same 3 infra failures
(`test_invite_mint_and_claim…`, `test_subscription_limit_enforced`,
`test_unarchive_emits_member_added_notification`) plus the 3 security
tests that fail unpatched and pass here.
- `cargo fmt`, `clippy`, `git diff --check` all clean.

**Live manual drive** against a locally running relay, using raw
`nak`-signed events (the `buzz` CLI refuses malformed `kind:9000`, so
the guards have to be exercised directly):

- *Rejected:* member demotes owner; member demotes admin; self-promote
to owner; self-promote to admin; admin demotes the last owner; sole
owner self-demote; demoted ex-owner demotes last owner; private-channel
member demotes owner; non-member demotes owner in private.
- *Allowed:* bare PUT_USER with no role tag (owner keeps role);
idempotent re-add at same role; owner promotes admin→owner, then owner2
legitimately demotes owner1.
- *Resurrection defeated:* owner promotes attacker to admin → kicks them
(`9001`) → attacker self-rejoins (`9021`) → returns as **member**, not
admin, and cannot demote the owner.
- Normal ops unaffected throughout: channel creation, messaging, member
listing, and legitimate governance all work.

## Behavior change to be aware of

Huddle bot-add sends `role="bot"`. If the target is **already an active
member at a different role**, that is now a role change and requires an
elevated actor. Previously it silently re-roled them — the same privesc
primitive through a different door, so narrowing it is intended.

This does not break the huddle flow in the path that matters: the
ephemeral channel add (the one that fails hard) is performed by the
host, who *created* that channel and is therefore its owner — verified
live. The parent-channel add is already explicitly best-effort,
capturing the error into `parent_error` with a comment anticipating "may
already be member"; adding a non-member agent there still works.
Flagging it rather than burying it.

## Notes

- Commit is **signoff-only, not cryptographically signed** — `-S` fails
in this environment (git tries to load the agent npub as an SSH key
file). DCO trailers are present and correct.
- Branch was merged with `origin/main` via `--no-ff` (not rebased).
Upstream had 17 commits, none touching these files, no migration
changes.

---------

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-07-27 10:24:19 -04:00
ab3af82871 feat(relay): add author-only-unless-shared read gate for kind 30175 (#2768)
Kind 30175 persona sync events carry plaintext `system_prompt` and
`respond_to_allowlist`. This PR adds **author-only-unless-shared read
semantics**: events without `["shared","true"]` are visible only to the
author; events with that tag are community-readable.

## What changed

### New read class (kind 30175)
Kind 30175 gets per-event gating at every relay read surface. The
`shared` marker is a **tag**, not a content field, so content bytes
(which double as the `source_version` drift basis) are not affected when
toggling share state.

### `event_visible_to_reader` helper (`handlers/req.rs`)
Centralizes the three per-event access predicates —
`is_author_only_event`, `is_unshared_persona_event`,
`reader_authorized_for_event` — into one `pub(crate)` fn callable from
both WS and HTTP adapters. All result-visibility sites now call this
single helper.

### NIP-98 HTTP bridge (`api/bridge.rs`)
- `POST /query` catchall: replaced the two-step author-only +
result-gated checks with `event_visible_to_reader` (now also covers the
persona shared-gate).
- `POST /count`: added `needs_persona_filtering` to the fast-path guard
(forces per-event fallback when filter can match `kind:30175`) and
replaced both fallback loops' individual checks with
`event_visible_to_reader`.
- FTS `/search` bridge helper: replaced `is_author_only_event` with
`event_visible_to_reader` as defense-in-depth (30175 is not in the FTS
allowlist today; comment at site explains the future-proofing intent).

### Ingest validation (`handlers/ingest.rs`)
`validate_persona_envelope` rejects malformed `shared` tags: wrong
value, missing value, duplicates. Accepts exactly `["shared","true"]`
and tag-absent.

### Kind helpers (`buzz-core/src/kind.rs`)
`is_persona_shared_kind`, `is_unshared_persona_event`,
`filter_can_match_persona_shared_kinds`.

### Tests (`e2e_persona.rs`)
8 unit tests in `kind.rs`, 6 in `ingest.rs`, 8 e2e tests total:
- AC-1–6 covering the gated surfaces
- `test_persona_live_fanout_shared_gate`: reworked with explicit
monotonic `created_at` timestamps (t0 < t1 < t2) and per-step head
assertions, eliminating the NIP-33 event-id tie-break race. Also asserts
foreign live subscription receives nothing on shared→unshared
transition.
- `test_persona_ingest_shared_tag_validation`: added `shared=x` and
missing-value wire-level rejection cases.
- `test_persona_mixed_kind_filter_does_not_leak`: publishes a kind-9
event and asserts it IS returned; absence-only assertion no longer
sufficient.
- `test_persona_http_query_cross_author_gate`: NIP-98 `/query`
cross-author gate (authors filter, kindless `ids` — both blocked; shared
`ids` — passes).
- `test_persona_http_count_cross_author_gate`: NIP-98 `/count`
cross-author gate (foreign sees 1/shared, author sees all, wildcard
checked).

### NIP-AP.md
Replaced aspirational "every relay read chokepoint" wording with an
enumerated list of gated surfaces including NIP-98 `/query`, `/count`,
and FTS/search with their enforcement mechanism named. Added
**Non-goal** note for side-band existence oracles
(reaction/report/deletion target resolution).

## Existing tests
All pre-existing `e2e_persona` tests use `{ids:[event_id]}` or
`{authors:[self]}` filters — author self-reads bypass the gate and are
unaffected.

## Gates
`just check` ✅ | `just test-unit` ✅ | `cargo test -p buzz-relay` ✅ (749
passed, 1 pre-existing failure in
`demo_join_forwarded_arm_round_trips_echo` — flaky on `main`, unrelated
to this PR, verified red at `origin/main` before this branch)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-07-24 23:17:30 -04:00
5ca36e7b91 fix(relay): decompress gzip-encoded git smart-HTTP request bodies (#2670)
Signed-off-by: Kaal <kaal@shib.io>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-23 22:37:55 -07:00
1911c69aa2 fix(relay): send 1012 restart close to all clients on graceful drain (#2575)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-23 13:16:12 -07:00
d0ab3fdb05 fix(channels): strip leading hash prefixes from names (#2250)
Signed-off-by: Logan Johnson <loganj@squareup.com>
Signed-off-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Co-authored-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
2026-07-23 12:56:10 -04:00
bcc3e13069 feat(relay): make Redis pool size configurable, default 16 (#2521)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-23 07:55:44 -07:00
bd37a4d584 feat(media): add S3-truth per-community storage sweep (#2044)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-07-22 12:32:52 -04:00
7e34bee62c feat(relay): log NIP-98 pubkey attribution on HTTP bridge requests (#2206)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-07-21 23:28:16 +00:00
TylerandGitHub 0fb820f9bf Revert "feat(relay): inventory unreachable Git objects" (#2275) 2026-07-21 13:09:07 -07:00
thomaspblockandGitHub 3afc9dae15 feat(relay): inventory unreachable Git objects (#2264) 2026-07-21 22:03:24 +02:00
b9f54c43fe relay: add author_type label to buzz_events_stored_total (#2243)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
2026-07-21 11:50:42 -07:00
thomaspblockandGitHub 166f27be4b fix(git): make project branch workflows reliable (#2213) 2026-07-21 12:58:39 +02:00
03fe19d603 fix(cli): paginate channel directory queries (#2181)
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
2026-07-20 19:22:11 -04:00
thomaspblockandGitHub 80e0ab16b0 perf(relay): compact Git packs before manifest limits (#2172) 2026-07-20 19:36:28 +02:00
thomaspblockandGitHub a4d82ec722 perf(relay): cache Git pack hydration (#2169) 2026-07-20 16:50:11 +02:00
thomaspblockandGitHub 5f7c93d9c1 fix(relay): bound and observe Git read operations (#2167) 2026-07-20 15:02:49 +02:00
e43b2d5aac relay: gate push enqueue on live leases; batch matcher pipeline (T1b/T1a-repair/T2b) (#2145)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-19 21:42:58 -04:00
bf5acabdde relay: add audit logging disable switch (#2134)
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
2026-07-19 15:09:42 -04:00
2e936d439c relay: skip TTL deadline bump for known-permanent channels (T1a write-amp) (#2125)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
2026-07-19 14:55:51 -04:00
c12257d57a fix(git): carry NIP-OA delegation in auth event (#2120)
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-07-19 11:29:26 -04:00
29c48883d3 Route lag-tolerant reads to an optional Postgres read replica (#2084)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-18 22:45:33 -04:00
ca384d082d fix: recover community access visibility (#2074)
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
2026-07-18 19:01:03 +00:00
d7f918e3cb feat: proxy feedback-scoped admin attachments (#2059)
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
2026-07-18 15:15:57 +00:00
68e670e001 feat: add read-only deployment moderation dashboard (#1999)
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
2026-07-17 12:50:06 -07:00
64b8fea6dc Bug-bash round 2: table scroll, Goose instructions, workflow mention wake (#2034)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
2026-07-17 14:49:54 -04:00
5cfd69cb0c Strip media metadata on clients and reject it at the relay (#2006)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Atish Patel <atish@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
2026-07-17 13:39:10 -04:00