24 Commits
Author SHA1 Message Date
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr d5fe805860 test(relay): track feed conformance lane
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-28 12:17:24 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr 9edfd33fac Remove unused NIP-28 proxy
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-27 19:39:24 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 982fa1f483 relay: complete multi-tenant scoping for reactions, allowlist, git names, and seed/partition fixes
Closes the remaining half-migrations surfaced red-teaming the #1321 multi-tenant
relay against the #1285 isolation model:

- BUG-1 (ship-blocker): community auto-seed ON CONFLICT(host) now targets the
  lower(host) index so closed-mode (NIP-43) boot no longer FATALs on a fresh DB.
- BUG-2: align runtime partition naming with the seeded partition names.
- BUG-3: scope the legacy pubkey_allowlist gate (is_pubkey_allowed,
  has_allowlist_entries, add/remove/list) and its relay_members backfill by
  community_id across buzz-db, auth.rs, media.rs, relay_members.rs; scope the
  git .names disk registry under .names/<community>/ in side_effects.rs.
- BUG-5 (ship-blocker): reaction.rs was never migrated to multi-tenant — every
  reaction 500'd on an ON CONFLICT that omitted community_id. Thread CommunityId
  through all reaction.rs queries, fix the ON CONFLICT to the full scoped PK
  (community_id, event_created_at, event_id, pubkey, emoji), and scope every
  read/list/remove path; update the Db shims and ingest.rs/side_effects.rs call
  sites to pass tenant.community().

Test harness: normalize the workflow-confinement REST 400 {error} envelope and
add a scoped pointer helper in e2e_git.rs; add ignored Postgres regression
reactions_are_scoped_to_community.

Verified: fmt/build/diff-check green; buzz-db 75/0 + 37 ignored (serial) green;
test_nip29_standard_client_flow RED at #1321 -> GREEN. Clean-context reviewed.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 17:39:59 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 16c55a1d46 test(conformance): wire-live workflow trigger community-confinement row
Replace the stubbed workflows::identical_workflow_and_approval_token row with
two honest rows matching readiness:

- workflow_trigger_is_community_confined: wire-live A/B isolation. Define a
  workflow under host A (kind:30620, h=channel + name tag), take A's
  server-generated workflow_id, fire it under host B (kind:46020) as a caller
  who is an owner-member of the *same channel UUID* in B. B must fail closed
  with the generic 'workflow not found' because get_workflow(host_community,
  id) is community-scoped (command_executor.rs:703, commit c81b89355) — K's
  membership of U-in-B is irrelevant. Positive control: A fires its own id and
  is accepted, proving the B rejection is community confinement, not an
  untriggerable workflow.

- approval_token_is_community_confined: precise pending_lane for WF-08. The
  grant fence (get_approval_by_stored_hash(community, hash)) is already landed,
  but nothing mints a pending approval over the wire yet (executor approval
  gate is an explicit WF-08 TODO; create_approval is test-only), so a green
  end-to-end approval-isolation test cannot be exercised today without faking
  it. Dep is WF-08, not buzz-db.

Test-only; #[ignore] (needs a live two-host relay). No change to the verified
multi-tenant fix set already on the PR head.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 15:45:34 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell c258393ebb docs: rename Typesense references to Postgres FTS
Migrate prose and doc-comments to describe the Postgres FTS backend that
replaced Typesense: README architecture diagram (3 boxes, Postgres now
"events + FTS search"), ARCHITECTURE.md buzz-search section rewritten to the
real API (SearchService::new(pool), search(&SearchQuery), ChannelScope) and
the search_tsv generated-column mechanism (CASE WHEN kind IN (1059,30300,30622)
THEN NULL, idx_events_search_tsv GIN), CONTRIBUTING step-6, VISION, AGENTS,
TESTING (both), and the chart README. Comment-only edits in desktop and
test-client files; drop the dead reindex-kind0 Justfile recipe (its binary no
longer exists).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 12:02:59 -04:00
bfbfe57513 test(conformance): document status-code-as-layer-discriminator on nip98_replay assertion
Follow-up to da6051fdb per Quinn's cold-read (event 4529860195007964...). The
within-community replay assertion `assert_eq!(second_a.status(), UNAUTHORIZED)`
pins the 401 status code rather than checking the body, because the system has
defense-in-depth across two layers with distinct rejection signatures:

  * auth-layer replay check (`check_nip98_replay`) — rejects with
    401 + body "NIP-98: replay detected".
  * storage-layer dedup (`events` PK `ON CONFLICT DO NOTHING` in
    `ingest_event`) — accepts with 200 + body `accepted: false,
    message: "duplicate"`.

Both reject a duplicate, but only the 401 path proves the seen-set is in the
request path. A body-only check like `!accepted` would pass under a noop'd
`check_nip98_replay` because storage-dedup still 200-accepted-false's the
second post — the bite would go vacuous against the layer the obligation
actually names ("seen-set in the request path").

Adds:
  * Inline `//` comment block immediately above the `assert_eq!` naming the
    two layers, their distinct status signatures, and why the 401 expectation
    is the load-bearing-layer discriminator. Explicitly tells a future
    reader not to weaken to `!accepted` for "simpler reading."
  * Extended assertion message: when the test fails, the panic message now
    names both layers and which one the 401 proves, so a future debugger
    sees the architectural property without reading the doc-comment.

Generalized principle (per Quinn): when a system has defense-in-depth
across layers with different status-code signatures on rejection, the
assertion should pin the status code from the load-bearing layer, not
any rejection. Held in the row's doc-comment (not the shared discipline
slug) per Quinn's stopping rule — this is a deeper instance of slug
rule #2's defense-in-depth class, not a new spine entry.

Bar:
  * Comment/string-only diff: 21 lines (+19 / −2), zero runtime behavior
    change — verified by inspection (`git diff` shows only comments and
    string-literal extensions).
  * `cargo check -p buzz-test-client --tests`: clean.
  * `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean.
  * `cargo fmt -p buzz-test-client -- --check`: clean.
  * Default `cargo test ... api_tokens` (no `--ignored`): doc-only `#[test]`
    still passes; wire-driven still `#[ignore]`-skipped.
  * No live mutate-bite re-run needed: the runtime path of the wire-driven
    test is byte-identical (only strings/comments touched), and the
    mutate-bite at da6051fdb was already RED-on-right-assertion by Sami's
    hands at :3300 and Eva's hands at her :3300 (event 9e9050cd44d6...).
    The follow-up makes the *reason* the bite bites discoverable to a
    future reader; it does not change *whether* the bite bites.

Base: PR #1321 head `da6051fdb`. Test-only diff.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 10:58:56 -04:00
b9fa51ecaf test: cover pubsub presence typing isolation
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 10:43:42 -04:00
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57ccandTyler Longwell da6051fdbc test(conformance): fill api_tokens_nip98_replay row — doc-only api_token half + wire-driven NIP-98 replay half
Fills both `pending_lane` stubs in `mod api_tokens_nip98_replay`:

# `token_minted_in_a_does_not_authorize_in_b` — doc-only

The api_token mint surface does not exist on the wire in `buzz-relay`: no
`/tokens` route in `router.rs:52-79` (verified by hand on PR head), no
`tokens` module in `crates/buzz-relay/src/api/`. The 792-line self-service
minting endpoint that existed pre-rewrite (sprout-relay PR #37, commit
`f84da74d3`) was deliberately not ported. Api_tokens are *consumed* (not
minted) by the Blossom upload path at `media.rs:638`.

This means "mint in A, present to B" has no wire precondition — a
wire-driven row would test a contract with no entry point. The honest
shape is doc-only, mirroring `audit_log`: where audit proves the
*output* surface does not exist on the wire, api_tokens proves the
*input* surface does not. Both are strictly stronger isolation claims
than a wire-denied assertion.

The `(community_id, token_hash)` fence itself is directly proven at the
storage layer (where direct Postgres access is in-convention):

  * `crates/buzz-db/src/api_token.rs:425
    lookup_by_hash_is_scoped_to_community` — same hash in A and B,
    A-scoped lookup returns A only.
  * `crates/buzz-db/src/api_token.rs:488
    active_lookup_by_hash_is_scoped_to_community` — mirror for the
    revoked-filter variant.

Plus the consumer fence: `media.rs:638` calls the scoped DB lookup with
`tenant.community()` derived from request host *before* token
resolution (`media.rs:97` comment names the row-44 fence explicitly).

# `nip98_replay_seenset_is_shared_and_community_scoped` — wire-driven

Load-bearing wire claim: within-community replay rejection. Sign a
NIP-98 event E for A's `u=`, POST to A → 200. POST again → 401 with a
body that names replay detection. The proof that the shared
(cross-pod) seen-set is in the request path at all — without it, any
pod would re-honor a spent NIP-98 event.

Mutate-bite: `check_nip98_replay → noop` in `bridge.rs:79` (return
`Ok(())` without consulting the guard). Under mutation, second POST
goes 200 instead of 401. Test asserts the failure with named
assertion message pointing at the mutate-bite handle, so a future
reader sees what would have been caught.

Cross-community independence is a *tripwire*, not a bite: sign an
independent NIP-98 event E' for B's `u=` (different event_id by
u-tag canonicalization divergence), POST to B → 200 even though E
was spent in A. Catches future namespace-globalization regressions
(key truncation, u-normalization collapse) that would break the
spend-spread, on top of the unit-layer proof at
`crates/buzz-auth/src/nip98_replay.rs:163
key_isolates_communities_for_same_event_id` (which the substrate's
own doc-comment names as "belt-and-suspenders").

The prefix-drop mutation considered earlier turned out to be vacuous
against natural wire traffic: u-tag divergence across communities
makes event_ids already community-distinct, so dropping the
community prefix from `nip98_replay_key` does not collapse natural
traffic into a shared slot. A same-event_id-different-community
wire collision can't be constructed because u-host
(`verify_bridge_auth`) rejects with 401 before the replay check
runs. That artificial property is proven at the unit layer; the
wire layer asserts the load-bearing per-call replay rejection.

# Bar

  * `cargo check -p buzz-test-client --tests`: clean.
  * `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean.
  * `cargo fmt -p buzz-test-client -- --check`: clean.
  * Default test run (no `--ignored`): 1 passed (doc-only `#[test]`),
    16 ignored (live rows).
  * `--ignored api_tokens` against fresh `:3300` harness
    (`BUZZ_GIT_CONFORMANCE_PROBE=false`): GREEN.
  * Mutate-bite `check_nip98_replay → noop` on `bridge.rs:79`,
    rebuild, restart: RED on the within-A second-POST assertion
    ("second POST to A with the same NIP-98 event MUST be rejected
    as replay (got 200 OK)"), `left: 200, right: 401`. Restored
    byte-identical, GREEN again.

Base: PR #1321 head `ae703c5c8`. Test-only diff: zero lines in
`buzz-db`, `buzz-relay`, or `buzz-auth` production code. Matrix
8/14.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 10:33:23 -04:00
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whmandTyler Longwell ae703c5c89 test(conformance): fill channels_membership row — same channel UUID coexists in two communities
Conformance matrix row `channels_membership` (re-routed from Mari to
Quinn at Eva's call — Mari's #1328 scroll-fix is still landing on main,
and the row's substrate is the same same-UUID-in-two-communities shape I
already used as the setup for `search_fts`). Fills the
`pending_lane("buzz-db", ...)` stub at
`crates/buzz-test-client/tests/conformance_multitenant.rs::mod
channels_membership`.

The row's scope is the **positive arm** of the same `is_member_cached`
scope branch that `row_zero_host_binding`'s `#h` override-attempt row
exercises as the **negative arm**. Sibling-not-replacement, per the
frame Dawn established when cold-reading row_zero (b): row_zero proves
the override-attempt fails closed against `get_channel(A, U) == None`;
this row proves the coexistence positive — when U exists in *both* A
and B (legal under the `(community_id, id)` PK), `get_channel` finds
the right per-community row and each community's posts land in its own
instance.

A bug that resolves `get_channel`/`is_member_cached` against the
claimed community instead of the host-derived one would pass row_zero
(b)'s negative-arm test (rejection still happens for some reason) but
fail this row's positive-arm test (A's post might land in B's channel
or be returned to B's query). So this row catches a class of bugs
row_zero (b) structurally cannot, even though both share the
`is_member_cached` scope branch.

Shape:

  1. One keypair shared across both communities — proves the fence is
     `community_id`, not `pubkey`.
  2. Same channel UUID `U` created in both A and B via REST kind:9007.
  3. Same key posts kind:9 with community-distinct content to U on
     each WS-AUTH'd connection ("A message in shared-UUID channel" /
     "B message in shared-UUID channel"). Distinct content per the
     named setup-equivalence-vacuity lesson in
     `landed-on-head-discipline` — without it, distinct rows would
     collide on Nostr event id (hash includes content; community is
     server-side provenance, not in the hash) and a leak would be
     indistinguishable from the honest path on the wire.
  4. REST `POST /query` with `{kinds:[9], #h:[U]}` against each host.
  5. Each side: count == 1, content == own community's. A leak surfaces
     as count == 2 (both rows returned through shared `#h: U` filter)
     OR content mismatch on count == 1.

Bar (by my own hands against the live `:3100` harness, PR head
`6aa0cec4a`):

  Clean → GREEN.
  Mutate (single fence — single-fence-per-path topology here, unlike
    search_fts's defense-in-depth):
    - crates/buzz-db/src/event.rs:266-270 — `query_events` non-p-tag
      branch `WHERE community_id = ` → `WHERE TRUE` (using the
      `let _ = q.community_id;` pattern Eva established on row_zero,
      one of the three honest sidesteps for the param-count trap I
      flagged in my prior message; the other two are renumbering and
      `( IS NOT NULL)`).
  → RED on `hits_a.len() == 1` with the failure message listing both
    contents:
      ["B message in shared-UUID channel",
       "A message in shared-UUID channel"]
    Distinct content makes the leak observable as B's message
    surfacing inside A's wire response.
  Restore → diff empty → GREEN.

Bar checklist:
- `cargo fmt -p buzz-test-client -- --check`: exit 0.
- `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean.
- `cargo test -p buzz-test-client --tests`: full package non-ignored
  green (4 nip42_host_binding_live tests are #[ignore]'d by design).
- Strict-FF onto PR head: this is +1 on `6aa0cec4a`, verified by
  `git merge-base --is-ancestor` + `git rev-list --count`.
- Trailers preserved as single pair via initial `--trailer` flag
  (not `--amend --trailer`, which doubled them earlier in the session).
- Live two-host harness on `:3100`: my clean (post-restore) binary,
  recipe per RESEARCH/CONFORMANCE_MATRIX_STATUS_2026-06-27.md v3.

Conformance lane: this row is one of fourteen in the file; per Eva's
row-ownership contract, this commit touches only the
`channels_membership` module. No other rows modified.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 09:51:17 -04:00
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whmandTyler Longwell 6aa0cec4a9 test(conformance): fill users_profiles_nip05 row — kind:0 + NIP-05 lookup per-community
Conformance matrix row `users_profiles_nip05` (Quinn — buzz-search/auth
joint, both halves driven by Quinn per one-author-per-mod-block
discipline; Sami's active queue is `api_tokens_nip98_replay` per Eva's
batching). Fills both `pending_lane` stubs in the row.

Half 1: `same_pubkey_distinct_profiles_in_two_communities`

Same keypair publishes kind:0 (Metadata) on each host's WS-AUTH'd
connection with community-distinct content
(`{"display_name":"A profile"}` vs `{"display_name":"B profile"}`).
NIP-01 replaceable semantics: latest kind:0 per
`(community_id, pubkey)` is what subsequent queries return. REST
`POST /query` (using dev-mode `X-Pubkey` auth, which the
`BUZZ_REQUIRE_AUTH_TOKEN=false` harness allows) returns each host's
own kind:0 — never the other's. Distinct content per community is
load-bearing for the bite: identical content would collapse the leak
into setup-equivalence vacuity (Dawn's catch on `audit_log` —
identical Nostr event ids when (pubkey, created_at, kind, tags,
content) match — making the assertion blind to the wrong-row
substitution).

Half 2: `same_nip05_local_part_on_two_hosts_is_independent`

Same local-part registered in BOTH communities with **distinct**
pubkeys (one per community). `GET /.well-known/nostr.json?name=alice`
against host A returns A's pubkey; against host B returns B's pubkey.
Distinct pubkeys per community make the leak observable as
wrong-pubkey-returned on the wire — the same setup-equivalence-vacuity
defense Dawn established (different keys = different rows = the wrong
answer is observable in the response, not just absent from it). Handle
canonicalization uses `extract_relay_domain` (mirrors
`crates/buzz-relay/src/api/nip05.rs::extract_domain`) against
`RELAY_URL` env so the test still works if the harness's relay URL
changes; defaults to `localhost` for the standard recipe.

Bar (by my own hands against the live `:3100` harness, PR head
`b02d767f2`):

  Clean → BOTH GREEN.
  Mutate (community fences dropped, both paths simultaneously, mirroring
    the search_fts dual-fence approach):
    - crates/buzz-db/src/event.rs:267-270 — `query_events` non-p-tag
      branch `WHERE community_id = $1` → `WHERE TRUE`
    - crates/buzz-db/src/user.rs:185 — `get_user_by_nip05`
      `WHERE community_id = $1 AND LOWER(handle) = LOWER($2)` →
      `WHERE LOWER(handle) = LOWER($1)` (rebind to keep param count
      aligned)
  → BOTH halves RED on their own assertions:
    - kind:0 half: "B's kind:0 content is not B's profile — A's
      profile leaked through. got: '{"display_name":"A profile"}'"
    - NIP-05 half: "NIP-05 lookup on B for local-part 'alice_…' must
      resolve to B's pubkey ($B_PK); got $A_PK. If this is A's
      pubkey, the community fence on `get_user_by_nip05` has been
      dropped and A's user leaked through B's lookup."
  Restore both fences (worktree diff empty after restore) → BOTH GREEN.

Each half bit on a SINGLE-fence mutation this time, unlike search_fts's
defense-in-depth shape. That's because the kind:0 read path
(`query_events`) and the NIP-05 lookup path (`get_user_by_nip05`)
each have one community fence at their layer, not redundant fences
across two layers like the FTS+batch-fetch shape. Different rows have
different defense topologies; this row's mutate-bite is the simpler
single-fence form, exactly as the named failure-mode analysis in
`landed-on-head-discipline` rule #2 sub-bullet predicts (the union of
fences IS what makes the property load-bearing; here the union has
exactly one element per path).

Bar checklist:
- `cargo fmt -p buzz-test-client -- --check`: exit 0.
- `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean.
- `cargo test -p buzz-test-client --tests`: non-ignored 0/0 (the 4
  nip42_host_binding_live tests are #[ignore]'d by design — they need
  the live harness).
- Strict-FF onto PR head: this is +1 on `b02d767f2`, verified by
  `git merge-base --is-ancestor` + `git rev-list --count`.
- Trailers preserved as single pair via the explicit `--trailer`
  flags on the initial commit (not `--amend --trailer`, which doubled
  them earlier in the session).
- Live two-host harness on `:3100`: my clean (post-restore) binary,
  recipe per RESEARCH/CONFORMANCE_MATRIX_STATUS_2026-06-27.md, two
  community rows seeded `a.localhost:3100`/`b.localhost:3100`.

Conformance lane: this row is one of fourteen in the file; per Eva's
row-ownership contract, this commit touches only the
`users_profiles_nip05` module. No other rows modified.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 09:33:16 -04:00
b02d767f24 test(conformance): fill search_fts row — two-host NIP-50 isolation + delete
Conformance matrix row `search_fts` (Quinn, buzz-search). Fills the
`pending_lane("buzz-search", ...)` stub at
`crates/buzz-test-client/tests/conformance_multitenant.rs::mod search_fts`
with a two-host A/B-isolation shape: one keypair shared across A and B,
same channel UUID reused in both communities (legal under the
`(community_id, id)` PK), the *same* unique FTS token posted to each
community as kind:9 events but with **community-distinct content**.
NIP-50 search on each host must return exactly one hit carrying that
host's community's content; NIP-09 kind:5 delete in A leaves B's row
intact.

Bar (by my own hands against a live two-host relay on the conformance
recipe — Eva's `:3100` `relay-mt` harness, a/b.localhost, shared
PG/Redis, base PR head `bf8a1a4fa`):

  Clean → GREEN.
  Mutate (both community fences on the search read path, simultaneously):
    crates/buzz-search/src/query.rs:160-161           (FTS WHERE community_id = $ctx)
    crates/buzz-db/src/event.rs:870-872               (get_events_by_ids WHERE community_id = $1)
  → RED on `hits_a.len() == 1` with the failure message listing both
    contents:
      ["A community probe ftsconf_…", "B community probe ftsconf_…"]
  Restore both → GREEN.

Two contract surprises discovered by running the row against the live
relay (the lesson Eva established with nip11_relay_info — obligation
text under-determines the layer):

1. The community fence is doubly defended on the search read path: FTS
   filters at the query layer, then `get_events_by_ids` re-filters at
   the read layer. Mutating either fence alone keeps the
   wire-observable property intact (the other defends). The honest
   mutate-bite is to drop both simultaneously; that's what makes the
   union load-bearing for the wire return. The test's doc comment
   names both layers and explains why the single-layer mutation would
   give a false-green.

2. With identical content in both communities, the Nostr event id is
   the SAME byte string in both rows (id = hash(pubkey, created_at,
   kind, tags, content); community is server-side provenance, not
   serialized into id). Under a leak, the wire returns "the row
   matching id" — which can be either community's row — and a count==1
   assertion can't tell A's row from B's. Earlier iterations of this
   test used identical content and discovered the hard way that
   single-hit-with-other-community's-row is indistinguishable from
   correct behavior at that assertion. Per-community-distinct content
   (`"A community probe {token}"` vs `"B community probe {token}"`)
   makes the leak observable: distinct content hashes to distinct ids
   (different rows), and the assertion `hits[0].content == content_a`
   pins which community's row came back.

Other discipline notes:

- Test is `#[ignore]` by default; selected with `-- --ignored`. Reads
  two env vars: `RELAY_URL_A` / `RELAY_URL_B`, both addressing the same
  relay process on different `Host` headers.
- Requires the two-host harness recipe (`BUZZ_HEALTH_PORT=8180
  BUZZ_METRICS_PORT=9202 BUZZ_RECONCILE_CHANNELS=false
  BUZZ_GIT_CONFORMANCE_PROBE=false`, two `communities` rows mapping
  `a.localhost:3100` and `b.localhost:3100` to distinct community
  UUIDs, one binary). Full recipe in the v2 dependency report at
  `RESEARCH/CONFORMANCE_MATRIX_STATUS_2026-06-27.md`.
- Requires Sami's NIP-42 per-tenant relay-tag fix on PR head
  (`bf8a1a4fa`) — without it, `BuzzTestClient::connect(&ws_a, &keys)`
  fails AUTH on the non-configured host. The row was pre-positioned on
  `809ff9faf` and rebased forward to PR head `bf8a1a4fa` exactly +1
  commit; clean rebase (different file regions from
  auth.rs/bridge.rs/nip42_host_binding_live.rs).
- Conformance lane: this row (`search_fts`) is one of fourteen in the
  file; per Eva's row-ownership contract, this commit touches only the
  `search_fts` module. No other rows modified.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 09:23:34 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell f7ec53b06d test(conformance): fill row_zero_host_binding (unmapped fail-closed + #h override-reject)
Replace both pending_lane stubs in row_zero_host_binding with live wire tests:

(a) unmapped_host_fails_closed_generically — drives an unmapped host over the
    HTTP door (404) vs a mapped host (non-404): the status *difference* proves
    no default-tenant fallthrough. Asserts the rejection body echoes neither the
    host authority nor the bare label (generic, no enumeration oracle), and that
    a raw WS upgrade to the unknown host is rejected at the handshake. Doc-
    comment notes the mapped-200/unmapped-404 status difference is an
    intentional, door-scoped distinguisher (non-nostr+json SPA/WS door only) so
    a future reader does not "fix" it into a 404-everywhere that breaks the SPA
    fallback; the nostr+json door deliberately does not expose it.

(b) client_supplied_community_cannot_override_host — creates an OPEN channel in
    community B only, confirms it is postable in B (positive control), then posts
    a kind:9 #h-tagging that B-only channel UUID over an A connection. A must
    reject: the host-derived community wins over the client #h claim. Open
    visibility isolates the override property from the ordinary membership gate
    (the A-side post can fail for exactly one reason: the channel doesn't exist
    in A's community). Asserts the rejection does not echo the B channel UUID
    (no cross-community existence oracle).

    Bite-specificity: the override assertion also pins the reason string
    "restricted: not a channel member" (the exact IngestError::Rejected the
    override path emits at ingest.rs:446), so the red means "A rejected because
    the host-derived community refused the #h claim", not merely "A rejected
    for some earlier-gate reason" (bind_community 404 / bridge-auth 403 /
    NIP-98 replay / relay-membership 403 / JSON parse 400 all precede the
    channel-scope branch). Two cold reviewers (Dawn, Mari) converged on this
    independently from the sanitization and channels-membership sides.

Sibling-not-replacement: #h override-attempt is row_zero; the relay-tag /
token-u-host override signals are Sami's NIP-42 rows; same-UUID coexistence is
Mari's channels_membership row. Cross-refs documented in the doc-comments.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 09:23:34 -04:00
tlongwell-blockandDawn 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
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
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 809ff9faf7 test(conformance): nip11_relay_info — NIP-11 is host-agnostic, not a community-enumeration oracle
First real (non-pending_lane) conformance row in conformance_multitenant.rs;
the reference pattern remaining rows copy.

Asserts the wire-observable complement to the compile-time static-input fence
(_RELAY_INFO_BUILD_STATIC_INPUT_FENCE): the NIP-11 relay-info document served
for host A, host B, and an *unmapped* host are all byte-identical. Identical
docs are the proof that the unauthenticated relay-info endpoint carries no
host-derived field and therefore cannot be used to probe which communities are
configured on a deployment.

Corrects an initial design error caught by running the row against a live
two-host relay: the unmapped-host case must return 200 with the same static
doc, NOT 404 — a 200-vs-404 status difference between mapped and unmapped hosts
would itself be the enumeration oracle. Fail-closed host binding lives on the
WS-upgrade / non-nostr+json path (router.rs::nip11_or_ws_handler) and is the
obligation of row_zero_host_binding, not this row.

Adds url_unknown() helper (RELAY_URL_UNKNOWN) alongside url_a/url_b.

Verified by hand against a live two-host relay (a/b.localhost:3100, shared
PG/Redis): green -> mutate (leak request Host into the served description) ->
red on the A==B assertion with a real wire diff, not a compile break ->
restore -> green.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 08:15:57 -04:00
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whmandTyler Longwell 3fd16cc1f7 fix(search): restore privacy kind exclusions at the FTS storage layer
The Typesense→Postgres FTS rewrite replaced out-of-band indexing with
`search_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', content))
STORED` over every row. The old relay (handlers/event.rs:287 on main)
deliberately skipped search-indexing for three kind classes, and the new
search query layer has no kind exclusion — so gift wraps, DM-visibility
snapshots, and event reminders were all in the FTS index.

Fix at the storage layer (option A — single source of truth, zero
app-layer drift across multiple search call sites): make the generated
column yield `NULL::tsvector` for excluded kinds via a CASE expression.
A NULL tsvector never matches `@@`, so excluded rows are structurally
unsearchable.

Excluded set, parity with main's `handlers/event.rs:287-290`:
- 1059  KIND_GIFT_WRAP        (NIP-17 ciphertext)
- 30300 KIND_EVENT_REMINDER   (AUTHOR_ONLY_KINDS — defense in depth)
- 30622 KIND_DM_VISIBILITY    (per-viewer private hide state)

Constants are inlined in the migration with a comment naming the
`buzz_core::kind` names: sqlx migrations are frozen SQL and can't
`use buzz_core::kind`; importing core into a migration would be worse
drift than the inline-with-comment shape.

Three coupled layers:

1. Schema CASE in migrations/0001_initial_schema.sql. The 0001 schema
   was consolidated by Max in 4b7654a1c (Lane-0 contract) and is
   pre-deploy; editing in place rather than adding a new migration
   matches the agreed shape and is covered by
   `run_migrations_applies_consolidated_initial_schema_on_fresh_database`.

2. buzz-search/tests/fts_integration.rs — new test
   `excluded_kinds_are_storage_level_unsearchable` inserts kind:1059 +
   kind:30300 + kind:30622 + kind:9 with the same unique token; asserts
   only the kind:9 control surfaces. Each excluded kind has its own
   load-bearing negative assertion with a diagnostic message naming the
   regression, plus a tight exactly-one-hit bound.

   Mutate-bite verified: dropping the CASE's NULL branch (revert to
   `to_tsvector('simple', content)`) makes excluded kinds searchable
   and the test fails RED with the designed message
   "kind:1059 MUST NOT be searchable — privacy regression in search_tsv
   generated column". Restored → 10/10 green.

3. crates/buzz-test-client/tests/e2e_nostr_interop.rs — rewrote
   `test_nip17_gift_wrap_not_searchable` to use the relay's actual
   NIP-50 search seam (`Filter::new().search(token)`,
   `collect_until_eose`) instead of the now-removed Typesense
   `/multi_search`. Pattern cribbed from
   `test_nip50_search_returns_results_and_eose`. Pointer comment to
   the underlying mutate-bite in fts_integration.rs.

Verification on `quinn/search-kind-exclusions` off `34ffb8ab3`:
- `cargo test -p buzz-search --tests -- --include-ignored`: 10/10
- `cargo test -p buzz-db -- --include-ignored`: 99/99 (incl. migration
  lints + consolidated-schema fresh-DB)
- `cargo check -p buzz-test-client --tests`: clean
- `cargo clippy -p buzz-search -p buzz-db --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 01:09:33 -04:00
tlongwell-blockandEva 787774f3b4 test(conformance): multi-tenant A/B isolation harness skeleton
Executable form of docs/multi-tenant-conformance.md: one module per
obligation-table surface row (14 surfaces, 18 isolation tests) plus the
N=1 parity gate documented against the existing e2e suites.

Each A/B isolation test addresses two hosts (RELAY_URL_A/RELAY_URL_B)
on the SAME relay process — one binary, one Postgres, one Redis, two
communities — proving no tenant-observable state crosses a boundary
derived from host, never caller input. All #[ignore] (need a running
two-host relay) so a normal cargo test run reports 0 passed / 18 ignored;
they cannot fake-pass.

Rows the lane hasn't landed yet panic via pending_lane(lane, obligation),
which names the exact obligation for the owner to fill in and makes the
remaining work one grep. Lane ownership tagged per module.

(cherry picked from commit 9d6d35f07a17fcf5ccd8a6f20fdede3349e67024)

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
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
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
WesandGitHub 38a9533439 [codex] Make relay frame limit configurable (#1225) 2026-06-23 17:18:55 -07:00
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
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
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
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