Commit Graph

41 Commits

Author SHA1 Message Date
tlongwell-block
1d7db983ab test(conformance): audit_log isolation — doc-only row + integrated relay test + error sanitization
The audit log has no client-reachable wire surface: there is no /audit
route in the relay, and AuditError is never relayed to a client. A pure
black-box A≡B conformance row (the shape every other row in
conformance_multitenant.rs uses) is therefore impossible, and reaching
behind the wire into Postgres from that file would break its black-box
contract. So the obligation is proven across three honest homes:

1. Doc-only conformance row (conformance_multitenant.rs): cites the
   no-wire-surface fact — a strictly stronger isolation claim than "the
   oracle is denied" — plus the per-community-chain substrate and the two
   executable proofs below.

2. Integrated relay test (buzz-relay handlers::event): drives
   dispatch_persistent_event under two tenants against a shared Postgres
   and asserts each community's audit chain contains only its own
   object_id and verifies independently. Proves the
   host→TenantContext→chain wiring keeps tenants isolated end-to-end.
   No WS-AUTH in the loop, so it is not blocked on NIP-42.

3. Error-sanitization unit test (buzz-audit error): asserts no AuditError
   variant's rendered text embeds a community_id, constraint name, or
   cross-community object id, with a non-vacuous check that per-community
   seq still appears.

Both runnable pieces mutate-bitten: a stale-tenant scoping bug reds the
isolation assertion ("B's event id appeared in A's chain"); leaking a
constraint name into an #[error] string reds the sanitization assertion.

Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-27 08:32:59 -04:00
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
bf8a1a4fa7 buzz-relay: bind NIP-42 AUTH relay tag to per-tenant host (row 44 sibling)
NIP-42 sibling of the NIP-98 host-binding fix in be9d26e55. `handle_auth` was verifying the AUTH event's `relay` tag against `state.config.relay_url` (one static string per deployment), so under multi-tenant:

  (a) An AUTH event signed against community A's host could be accepted on a connection whose tenant resolved to community B (cross-host token reuse — the same hole `nip98_expected_url` closed on the HTTP side).

  (b) Every legitimate connection whose tenant host wasn't the single configured one would be rejected (the wall Quinn hit bringing up `search_fts`'s two-host harness).

Add `nip42_expected_relay_url(config_relay_url, &tenant)` next to `nip98_expected_url` in `bridge.rs` — scheme from config (preserves `ws://`/`wss://` TLS posture), host from `tenant.host()` (request-resolved, never client-supplied). Thread it at `handlers/auth.rs:73` so `verify_auth_event` receives the per-tenant URL.

Tests (mirror `nip98_expected_url_*` shape, `bridge.rs:1303-1432`):

  * `verify_nip42_rejects_event_signed_for_wrong_communitys_host` — attacker on B-bound connection signs AUTH matching `config.relay_url` (=A's host); fix rejects with `RelayUrlMismatch`. Bites the exact "reverted to config host" regression.

  * `verify_nip42_accepts_event_signed_for_matching_host` — positive control: matching-host AUTH verifies.

  * `nip42_expected_relay_url_uses_tenant_host_not_config_host` — pins host-from-tenant in both directions.

  * `nip42_expected_relay_url_derives_scheme_from_config` — pins `ws://` ↔ `wss://` scheme passthrough.

Plus a live two-host integration test (`crates/buzz-test-client/tests/nip42_host_binding_live.rs`, `#[ignore]`): two seeded communities at `a.localhost:3100`/`b.localhost:3100`, raw WS AUTH with forged `relay` tag, four cases. Under the pre-fix mutation (`config.relay_url` verbatim) with `RELAY_URL=ws://a.localhost:3100`: `nip42_matching_host_accepted_b` failed (`auth-required: verification failed` — legit B traffic blocked) and `nip42_cross_host_rejected_a_relay_tag_on_b_connection` failed (relay accepted A-tag on B connection — the hole). Both green after restore.

Bar:

  * `cargo test -p buzz-relay -p buzz-auth -- --test-threads=1`: buzz-auth 45/45, buzz-relay 403/403 + 1 integration.

  * `cargo test -p buzz-test-client --test nip42_host_binding_live -- --ignored`: 4/4 against live two-host relay.

  * `cargo clippy -p buzz-relay -p buzz-auth -p buzz-test-client --tests -- -D warnings`: clean.

  * Mutate-bite (helper body → `config_relay_url.to_string()`): unit tests 3 RED, live tests 2 RED — exact pre-fix bug shape — both restored byte-identical to green.

Base: 4b6e1e43d (PR #1321 tip). Row-zero priority — gates every WS-authed conformance matrix row.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 08:22:49 -04:00
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
4b6e1e43d9 buzz-db: scope api_tokens lookups to community_id (Gap 2 / row 44)
Conformance row 44 obligates that API token lookups key on
(community_id, token_hash), not on token_hash alone. The storage UNIQUE
index `idx_api_tokens_hash` already enforces this as a *storage*
guarantee — but the query side was filtering on `token_hash = $1` only,
relying on uniqueness as load-bearing for tenancy. That's a structural
gap: any future relaxation of the index (or an adversarial mint that
landed two rows via a tx race) would let a token minted in community A
authorize against a request bound to community B.

This change closes the query-side gap. All eight Db API surface methods
now take a `CommunityId` first parameter, and the underlying SQL adds
`AND community_id = $N` (or includes the column on INSERT). The
`create_api_token*` family additionally INSERTs into the `community_id`
column, which it previously omitted — schema declares it NOT NULL, so
those functions would have failed at runtime if invoked. They have no
callers today (token mint is staged but not wired), but fixing them in
the same diff un-rots the public API and prevents the next caller from
hitting a runtime FK error.

The only live caller is `crates/buzz-relay/src/api/media.rs::resolve_
upload_scopes`, called from the `AuthenticatedUpload` extractor. The
extractor previously resolved scopes BEFORE binding the request's
tenant via `bind_community`, so threading the community through would
have been impossible — the tenant didn't yet exist. Reordered: row-zero
tenant bind moves to step 4 (immediately after header validation),
scope resolution to step 5 with `&TenantContext` in hand. The lookup
in `resolve_upload_scopes` now calls `get_api_token_by_hash_including_
revoked(tenant.community(), &hash)`.

Sharp regression test added at `api_token::tests::lookup_by_hash_is_
scoped_to_community` (#[ignore = "requires Postgres"]): inserts two
same-hash tokens in two communities (legal under UNIQUE(community_id,
token_hash)) and asserts each lookup returns only its own community's
row, and that a third unrelated community returns None. Mirror test
`active_lookup_by_hash_is_scoped_to_community` covers the
`revoked_at IS NULL` variant on `Db::get_api_token_by_hash`.

Mutate-bite proof (verified manually before commit): stripping
`AND community_id = $1` from the WHERE clause fails the test with
`community-B lookup must return B's row` — Postgres returns the
first-inserted (A's) row when filtering on hash alone, as expected.
Restored the clause and re-ran clean.

Verification:
- cargo build --workspace --tests: clean (1.95.0)
- cargo test -p buzz-db -- --include-ignored --test-threads=1: 101/101
- cargo test -p buzz-relay -- --test-threads=1: 399 + 1/1
- cargo clippy --workspace --tests -- -D warnings: clean
- Line-read final diff for tenant provenance: every binding correctly
  threads the request-resolved CommunityId; no client-supplied path.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 07:45:25 -04:00
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
be9d26e55a relay/auth: NIP-98 u-URL host is per-tenant, not config-global
Row 44 obligation closed: NIP-98 u-tag URL host must match req.community.
Previously expected_url was built from state.config.relay_url (one static
string per deployment), which under multi-tenant both (a) admitted events
signed for community A's host at community B's connection, and (b) rejected
every legitimate request whose community host wasn't the single configured
one.

Adds nip98_expected_url(config_relay_url, tenant, path): scheme from config
(preserves ws/wss dev-vs-prod), host from tenant.host() (the same host
row-zero bound from the request Host header). Swaps the three bridge call
sites (submit_event, query_events, count_events). Removes the orphaned
canonical_url helper.

Tests: 4 new in api::bridge::tests covering helper unit (both directions
of host substitution + scheme mapping) and verify_bridge_auth integration
(cross-host rejection + matching-host acceptance). Mutate-bite verified:
reverting the helper internals to config-global behavior turns all 4 new
tests RED with the exact diagnostics they were designed to surface.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 01:17:12 -04:00
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
34ffb8ab37 test(relay): red-team Attack 3 — same-pod regression + fail-closed proof
Adds two regression tests against Max's NIP-98 replay-guard wiring
(b30869d44), proving distinct properties beyond his existing
cross-pod test:

1. nip98_replay_guard_rejects_same_pod_same_community_replay
   (#[ignore], requires Redis) — single guard instance, A1 then A2
   with the same TenantContext rejects the second call. Guards
   against a fix that accidentally weakens same-pod replay
   detection when moka is replaced with the shared Redis seen-set.

2. nip98_replay_check_fails_closed_when_guard_errors (CI-runnable,
   no Redis) — injects a stub Nip98ReplayGuard that always returns
   Err(AuthError::Internal(_)); asserts check_nip98_replay_with_guard
   maps it to (401, error="NIP-98: replay check unavailable").
   Exercises the Err => arm of the match (bridge.rs:107-117) which
   is otherwise untested. This is the load-bearing fail-closed
   property: a stateless worker that loses Redis MUST reject rather
   than admit (Nip98ReplayGuard trait contract,
   buzz-auth/src/nip98_replay.rs:70-73).

Mutate→red→restore (two orthogonal bites, both verified):

- Mutate Ok(false) => Err(...) → Ok(()) at bridge.rs:103. Reds
  Max's cross_pod test AND same_pod_replay; fail_closed stays
  green (doesn't exercise this arm). Restored.
- Mutate Err(e) => Err(api_error(...)) → Ok(()) at bridge.rs:113.
  Reds ONLY fail_closed; cross_pod and same_pod stay green
  (don't trigger Err). Restored.

Distinct mutations bite distinct tests — proves the new properties
are load-bearing, not vacuous, and independent of Max's coverage.

Verification on this commit:
- cargo fmt --all -- --check ✅
- cargo clippy -p buzz-relay --all-targets -- -D warnings ✅
- REDIS_URL=… cargo test -p buzz-relay --lib \
    api::bridge::tests::nip98_replay -- --include-ignored \
    --test-threads=1 → 3/0 ✅ (Max's + 2 new)
- cargo test -p buzz-relay -- --test-threads=1 → 390/0 + 2 ignored ✅
  (baseline 389/1 ignored; +1 passing fail-closed, +1 ignored same-pod)

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
(cherry picked from commit 64a94bec901733ee87a84cbb4938e1180a5ca289)
2026-06-27 00:42:49 -04:00
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
726c83185a fix(relay): share NIP-98 replay guard via Redis
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 00:32:03 -04:00
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm
36485ade2b feat(relay): emit read-row trace steps with (B) projection
Land the read-seam emitter for the runtime conformance gate. Two
emit sites, one buzz-db helper, one negative fixture.

## buzz-db: `communities_of_channels` helper

`Buzz::communities_of_channels(&[Uuid]) -> HashMap<Uuid, CommunityId>`
— batched per-channel community lookup. Used by the relay emitters to
project each row's true community label independently of the fetch
query's WHERE clause. That independence is what makes the
`Inv_NonInterference` / `Inv_ReadConfinement` bite non-vacuous: a
mutation dropping `community_id = $X` from `query_events` would
still let this helper return the row's true label and the checker
would catch the mismatch.

Channels missing from the result map are intentionally NOT mapped to
a default — callers MUST treat "channel-id not in map" as a coverage
breach, never as "use the resolved community."

## buzz-relay: projection + record helpers

`crate::conformance` gains four new items:

- `project_row_community` — single-row helper encoding the (B)
  strategy: channel-less → resolved (honest, not tautological);
  channel-scoped → lookup or `None` (caller fails closed).
- `RowCommunityProjection` enum — Ok(Vec<CommunityLabel>) OR
  MissingLookup discriminated outcome.
- `record_read_message_rows` — non-search lane: emits
  `ReadMessageRows` on Ok projection, `ImplBug { kind:
  "row_community_lookup_missing" }` on MissingLookup.
- `record_read_by_id_rows` — search lane companion, same shape but
  emits `ReadByIdRows`. `filter_channel` is `None` for the search
  lane (search at the abstract level isn't bound to a single channel;
  per-row `channel_id` carries channel identity honestly).

## req.rs wire-up: two emit sites

- Site 1 (`req.rs` non-search loop, after `query_events`): collect
  distinct channel ids from the result set → `communities_of_channels`
  → `record_read_message_rows`. Production cost: one extra DB query
  per request (NoopTracer short-circuits in non-conformance builds).
- Site 2 (`req.rs` search loop, after `get_events_by_ids`): same
  pattern → `record_read_by_id_rows`. `handle_search_req` gains a
  threaded-through `trace_state: Option<&AbstractState>` parameter.

DB-helper errors on either site fall back to an empty lookup map.
This intentionally triggers `MissingLookup` → `ImplBug` for any
channel-scoped row in the result set, surfacing the helper failure
as a coverage breach (fail-closed) rather than a silent resolved-
label substitution.

## Negative fixture: foreign-row leak

New `bad_foreign_row_leak.jsonl` + matching test. The fixture is a
`ReadMessageRows` whose row_communities contains community B while
the state is bound to community A. This is the proof artifact Eva
requested for the (B)-projection guard-rail: if the row had been
mis-projected as channel-less (defaulting to resolved A), the subset
check would have passed vacuously. By recording the row's TRUE
community independently, `Inv_NonInterference` surfaces it
immediately as `NonInterference`.

## Unit tests (conformance::tests)

Five new tests pinning every behavior:
- `project_row_communities_channelless_uses_resolved` (positive)
- `project_row_communities_channel_scoped_uses_lookup_label` (the
  non-tautological correctness — lookup label, NOT resolved)
- `project_row_communities_channel_scoped_missing_is_breach` (the
  guard-rail bite)
- `record_read_message_rows_missing_lookup_emits_impl_bug`
- `record_read_by_id_rows_ok_emits_read_by_id_rows`

## Mutate → red → restore (three independent bites)

1. Make `project_row_community` fall back to resolved on missing-
   lookup (the tempting wrong-fix): `project_row_communities_channel_
   scoped_missing_is_breach` + `record_read_message_rows_missing_
   lookup_emits_impl_bug` go red with explicit messages
   ("missing lookup must be a breach, got Ok([...])", "expected
   ImplBug coverage breach, got ReadMessageRows {...}"). Restored.

2. Make every channel-scoped row project to resolved (the
   tautological projection): 4 of 9 unit tests go red — the lookup-
   label, missing-breach, and record-helper tests all bite. Restored.

3. Edit the negative fixture to use community_a instead of
   community_b: `foreign_row_leak_is_non_interference` reds ("foreign
   row community label must be rejected by Inv_NonInterference"). The
   fixture is load-bearing, not decorative. Restored.

## Test surfaces

- `cargo test -p buzz-relay --lib` → **394/0** (was 387 baseline).
- `cargo test -p buzz-conformance --lib` → 9/0.
- `cargo test -p buzz-conformance --test replay_fixtures` → **6/0**
  (was 5; +foreign_row_leak).
- `cargo test -p buzz-db --lib` → 75/0 (helper compiles; DB-driven
  integration coverage lives in `--include-ignored` lane on PG).
- `cargo clippy -p buzz-relay -p buzz-db -p buzz-conformance
  --all-targets -- -D warnings` clean.
- `cargo fmt --all -- --check` clean.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 00:15:09 -04:00
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm
1d3fb3c59a feat(relay): emit AuthCheck on REQ membership decision
Wire the conformance read seam at `req.rs` channel-membership
confirmation. When the relay falls through to the DB-uncached
membership check, record one `AuthCheck` step on the tracer
mapping `is_member` → `Allow`/`Deny`.

Design notes:
- `trace_state` is built once at request entry, after `pubkey_bytes`
  is available. Reused by every downstream emit (matches ingest's
  `state_for_request` discipline). The `Option` only goes `None`
  on malformed pubkey bytes — a separate failure path.
- `claimed_community: None` is the load-bearing choice on the read
  path: the REQ wire has NO client-asserted community (the `h` filter
  is a channel-id, not a community-id). Encoding `None` here rather
  than copying the resolved community means a future regression that
  ever starts reading a wire-community on REQ would need to put a
  real value in the field — that surfaces at code-review time
  instead of silently projecting away the M2 (claim ≠ resolved) bite.
- No `EmitGuard` at REQ entry: read paths legitimately skip the DB
  on cache hit (no `is_member` call), so a coverage-breach guard
  would false-positive on the common case. Coverage for the read
  seam comes from the upcoming row-emit fixtures, not from a guard
  at the entry point.

Implementation:
- New `crate::conformance::record_req_authcheck` helper centralises
  the emit so the call site stays one line and the helper carries
  the design rationale in its doc comment.
- Two unit tests pin the verdict mapping table:
  `record_req_authcheck_emits_allow_with_none_claim_when_member` and
  `record_req_authcheck_emits_deny_when_not_member`. Mutate→red→
  restore verified: inverting the `if member` branches reds both
  tests with explicit panic messages ("member=true must map to
  Allow", "member=false must map to Deny"); restored both green.

Test surfaces:
- `cargo test -p buzz-relay --lib` → 389/0 (was 387 baseline).
- `cargo test -p buzz-conformance` → 14/0 unchanged.
- `cargo clippy -p buzz-relay --all-targets -- -D warnings` clean.
- `cargo fmt --all -- --check` clean.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 00:04:40 -04:00
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm
e6ed8e9356 test(relay): EmitGuard coverage-breach self-test
Two unit tests in `crates/buzz-relay/src/conformance/mod.rs` that prove
the structural fail-closed property of the [`EmitGuard`]:

- `emit_guard_drop_records_exactly_one_impl_bug_when_no_emit` — drop
  the guard with zero recorded steps on the returned counting tracer →
  exactly one `ImplBug` step lands on the inner tracer, carrying the
  seam-name string passed to `EmitGuard::arm`.
- `emit_guard_drop_is_silent_when_an_emit_reached_the_tracer` — record
  at least one step through the counting wrapper → Drop emits no
  `ImplBug`, only the original step.

These pin the counting-tracer design against a future refactor to a
"disarm" flag, which would only fail-close by author discipline. The
counting wrapper makes coverage breach fire *structurally* regardless
of what the request path did or didn't do — that's the whole point of
the coverage-breach mode.

Verify: `cargo test -p buzz-relay --lib conformance::` → 2/2.
Full `cargo test -p buzz-relay --lib -- --test-threads=1` → 387/0.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-26 23:58:03 -04:00
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm
acb01e4da0 feat(relay): wire conformance emitter into ingest seam
Add `crates/buzz-relay/src/conformance/` module:
- `Tracer` re-export, `NoopTracer` (production), `JsonlTracer` (test/CI).
- `EmitGuard::arm(tracer, state, kind) → (guard, counting_tracer)`:
  RAII coverage breach. The guard wraps the original tracer in a
  counting layer; production callers transparently use that wrapper.
  If no emit reaches the wrapper before the guard drops, the guard
  emits a synthetic `ImplBug` step on the underlying tracer — the
  checker treats that as CoverageBreach. The wrapper design means
  production paths never need to "disarm" or pass anything around;
  a future new exit that forgets to emit will be caught
  automatically.
- `state_for_request(tenant, actor)`: builds AbstractState. Pulls
  community + host directly from server-resolved TenantContext.
- `claimed_community_from_event`: reads the event's h tag for the
  trace's `claimed_community` field — recorded SEPARATELY from
  `resolved_community` so M2 (claim≠resolved) and M8 (host/channel
  disagreement) bite at the checker.
- `sanitized_reason_for(&IngestError) → SanitizedReason`: 1:1 map
  of IngestError variants (Rejected/AuthFailed/Internal) onto the
  closed SanitizedReason alphabet (Invalid/Restricted/ServerError).
  Adding a fourth IngestError variant breaks this match — CI catches
  it before it ships.

Emitter wiring in `crates/buzz-relay/src/handlers/ingest.rs`:
- `ingest_event` is now a thin wrapper: arms EmitGuard, calls
  `ingest_event_inner`, and on Err maps to SanitizedError. All 36
  early-Err returns and 6 Ok returns in the inner fn are covered
  by this single outer mapping.
- At `check_channel_membership` call site (line 1401): emits
  `AuthCheck { channel, claimed_community, verdict }` with
  Allow on Ok, Deny on Err. The verdict basis is `tenant.community()`
  server-resolved — confirmed at ingest.rs:424's `is_member_cached`
  call signature (not event-derived).
- At each `dispatch_persistent_event` call site (lines 1908, 2014):
  emits `WriteInsert` (channel + was_inserted=true), `WriteDuplicate`
  (channel + was_inserted=false), or `WriteInsertGlobal` (no channel).
  This is the entire write side of the ingest seam.

`crates/buzz-relay/src/handlers/event.rs` `dispatch_persistent_event`:
- No emit added. Documented why inline: the spec has no separate
  fan-out action — acceptance is recorded at ingest's WriteInsert;
  fan-out surfaces as ReadMessageRows on the subscriber side (the
  read seam in req.rs, lands in the held-back additive diff).

AppState carries `tracer: Arc<dyn buzz_conformance::Tracer>`,
defaulting to NoopTracer (zero cost). Test contexts overwrite this
field with a JsonlTracer after construction.

Verify:
- cargo check -p buzz-relay green
- cargo test -p buzz-relay --lib: 378/378 (no regressions)
- cargo test -p buzz-conformance: 9/9 (checker still bites all four
  failure modes)

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-26 23:57:18 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
bc453f3d91 fix(relay): clear clippy on integrated multi-tenant stack
The H1 fanout fix threaded a server-resolved CommunityId through the
connection registry and two membership caches, tipping three clippy lints:
register() to 8 args (too_many_arguments) and the moka cache keys to
type_complexity. Allow both locally with rationale, matching existing
repo convention (observer_owner_cache already carries the same allow in
this file; buzz-db/buzz-cli use too_many_arguments allows). Also relocate
topic_for_subscription above the req.rs test module to clear
items_after_test_module surfaced by --all-targets.

No behavior change; buzz-relay 385/0, clippy --all-targets -D warnings clean.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-26 23:45:01 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
2738faf9b2 fix(relay): scope DM command writes by community
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
2026-06-26 23:33:23 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
989f87e0ab fix(relay): fail closed on empty/whitespace host in bind_community
The host-binding seam (Inv_RowZero) must derive a community only from a
request's actual Host. An empty raw_host carries no community evidence,
yet bind_community passed it straight to the resolver. The schema does
not forbid an `host = ''` row in communities (0001_initial_schema.sql:
`host VARCHAR(255) NOT NULL`, unique index on `lower(host)`), so a
misconfigured/empty-host row plus a request with a missing, unreadable,
or whitespace-only Host header would silently bind to that community.

Guard before the resolver lookup: if normalize_host(raw_host) is empty,
return BindError::UnmappedHost. Reuses the existing variant so the
rejection is byte-identical to any other unmapped host — an
unauthenticated caller cannot probe whether an empty-host row exists.

Red-team finding (Attack 2, MEDIUM defense-in-depth) by Sami; her three
proof tests (empty, whitespace-only, plus a non-empty negative control)
are included un-ignored. Verified mutate->red->restore: removing the
guard reds both empty-host gates with the literal fence collapse
(TenantContext{community: X, host: ""}). Full cargo test -p buzz-relay
--lib: 381 passed, 0 failed.

Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-26 23:30:51 -04:00
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
b182187a5e fix(relay): bind fanout to receiver community
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-26 23:27:16 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
fb0d6a4ea0 fix(relay): use deployment tenant binding for startup membership
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
2026-06-26 22:29:59 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
139d6dbda5 fix(relay): preserve relay URL port for deployment binding
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
2026-06-26 22:25:07 -04:00
tlongwell-block
3e57144b48 fix(relay-mt): clear clippy -D warnings introduced by tenant threading
CI Rust Lint + Windows Rust run `cargo clippy --workspace --all-targets -- -D warnings`; the community_id/tenant args pushed six fns to 8/7 and the new NIP-98 replay code tripped clamp/const-assert lints. Resolve at the bar, matching existing repo conventions:

- 6x #[allow(clippy::too_many_arguments)] on the fns that gained a tenant/community arg (same convention already used across buzz-db/relay).
- buzz-pubsub replay TTL: .max().min() -> .clamp() (floor 120 < ceiling 3600, cannot panic; behavior identical, incl. the u64::MAX clamp test).
- buzz-auth replay const-drift tripwires: scoped #[allow(clippy::assertions_on_constants)] — the assert-on-constant IS the design (fails if someone drifts the TTL constants).

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:48:11 -04:00
tlongwell-block
a8a9dd9e1a feat(relay): wire relay + admin call sites to community-scoped v3 API
Threads server-resolved `community_id`/`TenantContext` through the whole
relay call graph and the operator CLI against the v3 DB/pubsub API, so
every scoped row read and every Redis publish names a community the relay
derived from data, never from caller input.

Relay (`crates/buzz-relay`):
- Read-path caches take `CommunityId`; write/invalidate publishers take
  `&TenantContext` (the Redis topic key needs the host). The cross-node
  fan-out path only has the community, so caches stay constructible there.
- Doors fail closed: WS/bridge/media/NIP-05 bind community from the request
  host via `bind_community`, falling through to an empty/404 response on an
  unmapped host — no default tenant, no host echo.
- Background loops get tenant from the DB row they act on: the reaper builds
  `TenantContext::resolved(row.community_id, row.host)` per archived channel
  from the reaper RETURNING; the dev/CI reconciler and reminder scheduler
  resolve the one configured community from `relay_url`, fail-closed.
- Deployment-community cases with no connection tenant (git hook/finalize,
  workflow sink) resolve via the same host-resolution seam.
- Drop the Typesense-only `reindex_kind0` backfill binary, obsolete under
  the Postgres FTS migration and referenced nowhere.

Admin CLI (`crates/buzz-admin`):
- New `resolve_admin_tenant` reads `RELAY_URL` host (the CLI runs
  `compose exec relay buzz-admin`, sharing the relay's env) and resolves it
  via `lookup_community_by_host`, fail-closed on an unmapped host.
- Scope the NIP-43 membership-list publish (`EventTopic::Global`), channel
  reconcile, `get_members`, and the kind:39000 existence `EventQuery`
  (`..EventQuery::for_community`). Drop the now-dead `uuid` dep.

Workspace gate: `cargo check --workspace` green; buzz-db 97/97, buzz-audit
13/13, buzz-relay 375 + main 1 (`--include-ignored --test-threads=1`),
buzz-admin compiles, fmt + buzz-admin clippy clean.

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:37:00 -04:00
tlongwell-block
ec22cdb117 feat(relay): RelayInfo::build static-input fence (conformance NIP-11 row)
The conformance obligation for the NIP-11 surface: RelayInfo::build must
not grow unscoped DB/search/audit inputs, so an unauthenticated NIP-11
read can never become a cross-community enumeration oracle.

Binds RelayInfo::build to its exact allowed signature via a const fn
pointer. Adding a &Db / &AppState / search / audit input makes the
function-pointer type stop matching and breaks the build at the fence —
a silent cross-tenant leak becomes a hard compile error, deny-lint style.

Adversarially proven: injecting a &AppState param into build() produces
error[E0308] mismatched types at the fence const (plus E0061 at the call
sites); reverted to confirm the fence, not the call sites alone, is the
guard. buzz-relay package 374 green at --test-threads=1.

(cherry picked from commit 76a4044c7cfb1c96a6817be1e81c7ae42d1ea3da)

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:59 -04:00
tlongwell-block
be56652d63 feat(relay): huddle-audio-unavailable guardrail under horizontal scaling (§5b)
Plan §5b, decided by Tyler: rather than sticky-route huddles or ship a
silent split-room, a horizontally-scaled deployment surfaces a clear,
client-handleable unavailable signal on huddle join.

- config: huddle_audio_available bool, env BUZZ_HUDDLE_AUDIO_AVAILABLE.
  Defaults true so single-pod (N=1) deployments keep today's huddle
  behavior unchanged. Operators running multiple relay pods set it false.
- audio handler: after auth + membership pass and BEFORE get_or_create joins
  a room, if huddle_audio_available is false we send
  {type:error, code:huddle_audio_unavailable, message:...} and return — no
  silent room join whose frames never cross pods.

Why a config flag and not pod-count self-detection: the relay can't reliably
count its own pods; an explicit operator flag is the honest model and keeps
the §4 fork-B (any-pod-any-connection) generic routing free of huddle
stickiness. The real fix is the out-of-relay media/SFU service (Tyler's
long-term target), out of scope for this rewrite.

Tests: default-true (N=1 compat) and env-false-disables, both green. Full
buzz-relay --lib green at --test-threads=1 (374). Note for this lane: there
is a pre-existing parallel-run env-var race (global_presence_pubsub test
calls Config::from_env without the config tests' ENV_MUTEX guard) — not a
regression from this change; flagged to fix in the wiring lane.

(cherry picked from commit cc2bc29d4429da9e1a3e80217936340a4c1ca721)

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:59 -04:00
tlongwell-block
c6ec9a33fe feat(relay): row-zero host-binding seam (HostResolver + fail-closed bind)
Conformance row zero: req.community = resolve_host(connection.host), bound
before any handler observes tenant data. This lands the relay-side seam:

- HostResolver trait (native async fn, no async-trait dep) — buzz-db's
  Db::resolve_host satisfies it; the relay depends on the trait, not the
  query, so the binding is testable without a database. Callers are generic
  over R, no dyn dispatch (the relay holds a concrete Db).
- bind_community(): normalizes the host with the one shared rule, resolves
  it, and fails closed on BOTH unmapped host AND lookup error — there is no
  path that yields a default/fallback community. UnmappedHost is a distinct
  variant the call site turns into a GENERIC reject (no host echo, no
  unmapped-vs-error distinction) so an unauthenticated caller can't probe
  which hosts exist.
- TenantContext carries the normalized host, so downstream NIP-05/audit
  labelling and the NIP-98 u-host check all see the canonical form the
  community was resolved from.

Tests (4, green) cover known-host bind, variant normalization (case/dot/
default-port can't split a tenant), unmapped fail-closed, and lookup-error
fail-closed-not-default. Adversarially verified: mutating the None arm to
fall through to a nil default community turns unmapped_host_fails_closed RED.

Seam contract for the buzz-db lane (Mari): Db::resolve_host(&self,
normalized_host: &str) -> Result<Option<CommunityId>, DbError>, a SELECT id
FROM communities WHERE host = $1 on the normalized key. Router call site
(nip11_or_ws_handler) + threading TenantContext through handle_connection
land next in this lane.

(cherry picked from commit 0be8532e0e94e5ecd6529f2f3f52255dd36f6009)

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:59 -04:00
tlongwell-block
b7f249cd20 fix(db): scope relay rows by community
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:57 -04:00
Tyler
e1c51d71d4 Fix cross-pod membership notification fanout (#1291)
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
2026-06-25 16:09:15 -04:00
Tyler
73cc31cc52 chore: remove LLM-slop comments across the codebase (#1277)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-25 11:03:05 -04:00
Will Pfleger
68a0cc8506 chore(release): release Buzz Relay version 0.1.1 (#1269) 2026-06-24 23:49:09 -04:00
Tyler
6284454298 fix(relay): multi-pod subscription coherence (one access-gated fan-out path + cross-pod cache invalidation + REQ/COUNT DB guard) (#1261)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@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: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
2026-06-24 22:17:25 -04:00
Tyler
856815994c perf(relay/git): stream read-path, manifest info/refs fast path, idx sidecar (A/B/C) (#1240)
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>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
2026-06-24 19:44:24 -04:00
Will Pfleger
d256935c36 feat: publish personas, teams, and managed agents as relay events (#939)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
2026-06-24 22:28:25 +00:00
Will Pfleger
86d7748ebd fix(desktop): resolve repos_dir symlink at boot before agent restore (#1231)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-06-24 11:58:48 -04:00
Tyler
36d3d2ed1e Fix presence fan-out across relay pods (#1227)
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>
2026-06-23 22:32:46 -04:00
Will Pfleger
142a5c9095 fix(relay): raise grace limit, add replay backpressure, and NOTICE on oversized frames (#1226) 2026-06-23 20:26:54 -04:00
Wes
38a9533439 [codex] Make relay frame limit configurable (#1225) 2026-06-23 17:18:55 -07:00
Will Pfleger
549b7d2481 fix(release): publish versioned relay Docker tags via independent release lanes (#1173)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-06-23 18:35:30 -04:00
Will Pfleger
6780ea21e4 fix(relay): resubscribe agents when an archived channel is restored (#1187)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-06-22 22:16:16 -04:00
Tyler
959f25982b Avoid duplicate thread event fetches (#1129)
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
2026-06-19 12:46:08 -04:00
Will Pfleger
26563968cb feat(relay): add NIP-ER push scheduler with cross-pod delivery (#957)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-06-17 10:43:22 -04:00
Will Pfleger
79fcfd82bc feat(relay): implement NIP-ER event reminder support (kind:30300) (#934)
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-06-17 10:24:02 -04:00
Will Pfleger
fd2553726c fix(buzz): prevent reconnect storms from reaped ephemeral channels (#1071)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-06-16 13:13:23 -04:00
tlongwell-block
2300248d3b Add automatic database migrations (#988)
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-16 08:39:19 -04:00
tlongwell-block
5f8ab33b3b Fix relay NIP-11 software URL (#1030)
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
2026-06-13 13:45:57 -04:00
Will Pfleger
d99ad131f1 refactor: rename sprout backend to buzz (#958)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
2026-06-10 19:29:51 -04:00