1177 Commits
Author SHA1 Message Date
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc 1b53c2ffcb test(search): track p-gated FTS skip drift
Companion to author_only_kinds_are_storage_level_unsearchable (a3f407ce
extended the hardcoded skip-set; 46ba39e4 added the AUTHOR_ONLY drift
tripwire). Closes the parallel drift surface for P_GATED_KINDS: today the
schema NULL-tsvector CASE happens to cover every persistent p-gated kind,
but a new entry in P_GATED_KINDS without a matching schema migration would
silently reduce search privacy to L2 (the filter-level #p gate) alone.

Move P_GATED_KINDS from a private const in
crates/buzz-relay/src/handlers/req.rs to a pub const in
crates/buzz-core/src/kind.rs, mirroring AUTHOR_ONLY_KINDS's shape and
placement. The relay handler keeps its identical usage; buzz-search's
integration test now imports the canonical const and iterates it.

Why move rather than re-export: P_GATED_KINDS is a privacy-classification
constant about kinds, not a relay implementation detail. AUTHOR_ONLY_KINDS
already lives in buzz-core::kind for exactly this reason. Adding buzz-relay
as a dev-dependency of buzz-search would create a crate cycle (buzz-relay
depends on buzz-search).

The new tripwire skips ephemeral kinds via buzz_core::kind::is_ephemeral:
ephemeral events (20000-29999) are never stored, so the storage-layer
search defense does not apply to them by category.

Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
2026-06-28 12:56:52 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr 0bd34eac8f fix(search): use clamp for page bounds
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:54:43 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr a3f407ce96 fix(search): exclude p-gated membership notices from FTS
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:46:48 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr 46ba39e45a test(search): track author-only FTS skip drift
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:41:40 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr 4c51a8119f fix(search): harden FTS query inputs
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:37:22 -04:00
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 bdf48ac1d5 fix(relay): scope feed queries by community
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 11:47:24 -04:00
4720dc54c2 test(buzz-conformance): property/fuzz traces for the replay checker
Add proptest-generated action sequences exercising the conformance
checker beyond the hand-built fixtures, closing the skill's
"property/fuzz-generated action sequences where feasible" gap
(skill-runtime-formal-compliance). Test-only: no production or checker
behavior change.

The tests assert spec-derived invariants about check_trace's verdict —
NOT a parallel oracle re-deriving the verdict (which would just clone
check_step and test the code against itself). Six properties, each
honoring check_trace's fail-fast contract by constructing traces where
the targeted violation is the first/only one:

- non-interference soundness: any read (ReadMessageRows / ReadByIdRows /
  ReadHostFeedRows) carrying a foreign row label is rejected
- non-interference completeness: a fully clean trace is accepted
- AuthCheck Allow + foreign claim bites IllegalTransition; Deny is in-spec
- ImplBug bites CoverageBreach
- a mid-trace state flip bites StateMismatch
- check_trace is deterministic and never panics

proptest is added as a dev-dependency only; the property tests touch
only the crate's public check_trace API and depend on no production
crate, preserving the checker's independence rule.

128 cases, trace length 1..=12. The new tests run in the existing
just test-unit gate (now 22 buzz-conformance tests, was 15) at
negligible cost.

Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-28 09:28:40 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 1b2d8fa85e ci: run the multi-tenant conformance gate in the unit-test job
The buzz-conformance crate is the independent replay checker for the multi-tenant isolation contract (15 tests: 9 checker unit tests + 6 golden replay fixtures). It has no production buzz-crate deps and needs no infra — pure in-process trace replay — but `just test-unit` only ran buzz-core and buzz-auth, so this gate ran nowhere in CI.

Add `cargo nextest run -p buzz-conformance` to the nextest path of test-unit, and the equivalent `cargo test -p buzz-conformance` to the scripts/run-tests.sh fallback, so the conformance gate runs on every CI unit-test pass. Runs all targets (lib + tests/replay_fixtures.rs), ~0.01s.

Note: the live two-host A/B suite (buzz-test-client conformance_multitenant) still needs a running multi-tenant relay and has todo!()-stubbed rows, so it is not wired into CI here.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-28 08:33:51 -04:00
e23f39175d fix(buzz-db): index-match lookup_community_by_host on lower(host)
lookup_community_by_host runs on every relay handshake (WS/bridge/media/git/nip05). The query used `WHERE host = $1`, but the only host index is `idx_communities_host ON communities (lower(host))` — so Postgres seq-scanned communities on every connection: ~5.4ms scanning all rows at 100k tenants.

Match the indexed expression with `WHERE lower(host) = lower($1)` so the lookup uses idx_communities_host (~0.037ms index scan at 100k rows). Adds an ignored Postgres regression test asserting case-insensitive host lookup resolves against the lower(host) index.

Found by Max while load-testing #1321 at 100k communities.

Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-28 08:33:32 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr aaab6ac49c Trim desktop localhost resolver comment
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 22:08:42 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr 2cfb0192a5 Pin desktop localhost HTTP resolution
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 21:54:09 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr 1ac082d075 Preserve local relay host for desktop HTTP calls
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 21:39:57 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr 8356aa4d21 Fix virtualized bottom target after history prepend
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 20:53:35 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr 16f955760d Merge origin/main into relay rewrite branch
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 20:34:27 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr a0c9009a7f ci: retrigger checks after scroll-pin fix
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 20:28:28 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr 948306bfaf Fix desktop bottom scroll pin settling
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 20:21:50 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr ebd11f6608 Handle delivery log partition identities
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 20:04:35 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr 82a8b6a945 Repair CI schema partition setup
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:58:20 -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
744c77bc2d fix(buzz-agent): charge images a token-equivalent for the handoff gate (#1332)
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-06-27 16:16:28 -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 08a0399923 style(workflow): rustfmt the multi-tenant fix set
Apply cargo fmt to the 11 files touched by the 9 reviewed multi-tenant
fix commits. CI Rust Lint (just fmt-check) flagged formatting drift my local
clippy --lib --tests run did not exercise; base a7d3817481 was fmt-clean, so
this only normalizes lines my own commits introduced. No logic change.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 15:26:18 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell ea687ccb33 fix(workflow): seed interval cold-start anchor so new schedules fire
A brand-new interval workflow on a cold engine has no in-memory last_fired
entry and no prior durable claim, so the scheduler resolves last = None.
interval_should_fire then reads last = now and suppresses the tick (correct:
wait a full interval), but the in-memory anchor is only written AFTER a won
claim, and no claim is attempted until the prefilter passes. Every subsequent
tick repeats with last = None, so the workflow suppresses forever.

Extract interval_prefilter_should_fire (free fn over the last_fired map + a
thin &self wrapper): on the cold-start None suppress path it seeds the anchor
to now so the next tick counts from a real anchor and fires after one interval.
It seeds ONLY when last was None; an existing Some anchor is mid-interval and
must elapse on its own, so it is never advanced. A due/firing tick passes
through without seeding (the post-claim path owns that write).

Unit tests (no Db/Postgres; pure in-memory anchor state):
- cold start seeds then fires after one interval
- mid-interval suppress does not advance an existing anchor
- a due fire passes through without seeding

Caught by Max in cold review of the scheduled-workflow lane; predates this
branch's claim work but lives on the exact lane being cleared.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:43:51 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 9e0534eecc fix(workflow): add scheduled_workflow_fires.workflow_run_id for the attach audit link
The scheduler's post-run `attach_scheduled_workflow_run` does `SET workflow_run_id`,
but neither schema/schema.sql nor the initial migration declared that column, so
every won scheduled claim would create+run, then the best-effort attach would hit
`column "workflow_run_id" does not exist` (PG 42703) and warn forever — the audit
link could never populate. Found by Max in cold review of 778a5d28c.

- Add nullable `workflow_run_id UUID` to scheduled_workflow_fires (schema + migration)
  with a composite FK `(community_id, workflow_run_id) REFERENCES workflow_runs
  (community_id, id)`. The FK uses ON DELETE NO ACTION, not SET NULL: community_id is
  shared with the claim PK and is NOT NULL, so SET NULL is unimplementable (verified
  against live PG: it raises a NOT NULL violation mid-cascade). NO ACTION blocks a
  delete of a still-linked run cleanly; workflow_runs are not pruned today regardless.
- Rewrite the stale scheduled-fires schema comment that still claimed community is
  'resolved server-side from workflow_id, never a caller-supplied claim parameter' —
  contradicted by the S1 reconciliation: community is server provenance from
  list_all_enabled_workflows(), passed explicitly, since id is not globally unique.
- Surface the interval-anchor read failure with a warn! instead of unwrap_or(None)
  swallowing it (still fail-closed: a missing anchor suppresses the tick and retries).
- Add attach_links_run_to_claim_and_is_idempotent: proves the column populates on
  attach and the IS NULL guard makes a second attach a no-op. Proven RED against the
  pre-migration schema (the exact 42703 error), GREEN after — the regression that
  would have caught this gap.

Verified vs live Postgres, serial: buzz-db 110 / buzz-workflow 145 / buzz-relay 414,
0 failed; clippy -D warnings clean on the trio.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:43:51 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell f64974705c fix(workflow): wire community-scoped durable claim into scheduler
The scheduled-workflow fire path never called the durable claim
primitives (`claim_scheduled_workflow_fire` had zero callers outside the
DB wrappers and tests), so with N relay pods every pod that saw a due
cron/interval row created a run and executed the side effect — a
multi-pod duplicate-run bug. And the claim primitive itself was still
keyed by bare `workflow_id` (`WHERE w.id = $1`) despite the schema's
`(community_id, id)` workflow identity: with duplicate workflow UUIDs
across communities (which the schema explicitly allows and the Issue-4
confinement tests pin), a single `INSERT ... SELECT` matched every
community's row and fanned one claim across all of them.

Scope the claim to its community and wire the claim->run boundary into
the scheduler:

- `claim_scheduled_workflow_fire` takes `CommunityId` and binds
  `WHERE w.community_id = $1 AND w.id = $2`. The community is server
  provenance — the `workflow.community_id` from the global scan, never
  client input. This reverses the earlier S1 "resolve-from-id-alone"
  lock, which was written against a globally-unique-`workflow_id`
  assumption the final `(community_id, id)` schema does not hold (and
  which is unimplementable on that key). The surviving invariant is
  "the claim community is server provenance, not client-controlled."

- `WorkflowEngine::run` now claims before `create_workflow_run`; the
  loser skips before any run creation or side effect. The claim anchor
  `scheduled_for` is deterministic across pods: the cron's own scheduled
  instant (`cron_fire_instant`, not `now`) or the interval bucket
  boundary (`interval_fire_instant`, floor to the interval). The
  interval anchor is seeded from `latest_scheduled_workflow_fire` on the
  first tick after restart so a process bounce can't double-fire within
  an interval. `attach_scheduled_workflow_run` links the won claim to
  its run for ops/audit forensics.

Tests:
- Rewrite the stale S1 comment block and `claim_for_workflow_in_other_
  community_no_ops` (which encoded the now-false globally-unique
  assumption) into `claim_confined_to_its_community`: a dup workflow
  UUID in A and B claims independently (claiming A/id leaves B/id
  claimable). Proven RED on the bare-`id` regression.
- `concurrent_same_window_claims_exactly_one_wins` and the prune-anchor
  test updated to the scoped signature.
- New `cron_fire_instant` / `interval_fire_instant` unit tests pin the
  deterministic, drift-stable claim anchors.

Full `cargo test -p buzz-db -p buzz-workflow -p buzz-relay` green vs
live Postgres (serial); clippy clean on the trio.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:43:51 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell c81b893558 fix(workflow): scope workflow execution and approvals to their community
`workflows`, `workflow_runs`, and `workflow_approvals` are all keyed
`(community_id, id|token)`, so the same UUID/token is structurally allowed
in two communities — exactly like channels and events. But the execution
and approval spine still fetched, listed, mutated, and posted by bare id,
so a webhook/manual trigger or NIP-09 deletion in community B could load,
drive, or erase community A's colliding workflow, and workflow side effects
were published under the deployment/default tenant instead of the run's own
community. This threads the owning community through every request-scoped
and run-scoped path so each lookup, write, and side effect is confined to
its tenant.

- `ActionSink::send_message` now takes the run's `community_id` as its first
  parameter. `RelayActionSink` drops `bind_deployment_community(relay_url)` —
  the Issue-4 root cause — and instead resolves the run community's host via
  `lookup_community_host` to form a complete `TenantContext::resolved`, fail
  closed if the community is unmapped. A workflow in B now posts into B.

- Executor (`dispatch_action`, `execute_run`, `execute_from_step`,
  `execute_steps`) and engine (`finalize_run`, `on_event`) carry the run's
  community; every `get_workflow_run` / `get_workflow` / `send_message` and
  the post-store `on_event` call (from `dispatch_persistent_event`, which has
  the bound `tenant`) are scoped. The interval `last_fired` DashMap is keyed
  `(CommunityId, Uuid)` so duplicate workflow UUIDs across communities cannot
  cross-suppress in memory.

- Webhook `/hooks/{id}` now binds its community from the request Host before
  any lookup (`bind_community`), then `get_workflow(community, id)`. The host
  — not the workflow row — determines the tenant, so a request to A's host
  can only reach A's workflows; unmapped host and not-found both fail closed
  with the same generic 404.

- WS manual trigger and `create_workflow` use `tenant.community()` as the
  authoritative owner. `create_workflow` no longer resolves the community via
  the ambiguous `community_of_channel(channel_id)`; it verifies the channel
  exists *inside* the bound community via scoped `get_channel` (the same
  guarantee the composite FK enforces, surfaced as a clean rejection).

- Approval grant/deny/resume handlers and the `buzz-db` approval methods
  (`get_approval`, `get_approval_by_stored_hash`, `get_run_approvals`,
  `update_approval`, `update_approval_by_stored_hash`, `create_approval`)
  are scoped by community; `create_approval`'s INSERT now includes the
  `community_id` NOT-NULL column it previously omitted. NIP-09 a-tag workflow
  deletion (`delete_workflow`, `find_workflow_by_owner_and_name`) is scoped
  to the request tenant.

Adds three `#[ignore]` Postgres regressions in `buzz-db::workflow`, each
verified green against live PG and red when the `community_id` predicate is
dropped: `workflow_lookup_is_confined_to_its_community` (dup workflow+channel
UUID in A/B; scoped get/list resolve only the bound community's row, cross
lookup is NotFound), `workflow_delete_is_confined_to_its_community` (deleting
A/id leaves B/id intact), and `approval_is_confined_to_its_community` (same
token in A/B; granting A leaves B pending). Full `cargo test -p buzz-db
-p buzz-workflow -p buzz-relay` green, clippy clean on the trio.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:43:51 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell ce747ec759 fix(admin): derive admin tenant host via shared relay_url_authority
Issue 3 reopen (Max): buzz-admin's resolve_admin_tenant derived its
lookup host with Url::host_str(), which drops an explicit non-default
port and IPv6 brackets. For the default RELAY_URL ws://localhost:3000 the
admin would look up community host `localhost` while startup seeding (and
live request resolution) bind `localhost:3000` — so the admin CLI's
membership writes would miss, or hit, the wrong deployment community.

Lift relay_url_authority into buzz-core::tenant as the single canonical
helper so the relay's host-resolution seam (startup seeding,
bind_deployment_community) and the buzz-admin CLI derive a byte-identical
authority: host plus explicit non-default port, IPv6 brackets preserved,
default ports collapsed — exactly as normalize_host shapes an inbound
Host header. The relay tenant module now `pub use`-re-exports it (no
behavior change at the relay seam); buzz-admin calls it directly.

Adds 4 buzz-core unit tests pinning the authority shape:
non-default-port retention (localhost:3000, relay.example:8443),
default-port collapse (:443/:80), IPv6 brackets ([::1]:3000), and
unparseable/empty -> empty (callers fail closed on empty).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:43:51 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 2adae32bc7 fix(db): scope reminder claim/release to their community
`claim_due_reminder_with_stamp` and `release_due_reminder` predicated only
on `(created_at, id)`, but `events` is keyed `(community_id, created_at, id)`
and the same Nostr event id — hence the same `id`/`created_at` pair — is
allowed across communities. So a claim for reminder `A/X` would also mark
`B/X` delivered (suppressing B's reminder), and a matching-stamp release for
`A/X` would clear `B/X`. That is cross-community interference in exactly the
reminder lane the claim-before-publish gate fixes; the scheduler's
exactly-once-publish proof rests on this primitive being community-scoped.

Thread `CommunityId` through both `event::` fns, their `Db` wrappers, and the
bare `claim_due_reminder` convenience fn; every predicate is now
`WHERE community_id = $1 AND created_at = $2 AND id = $3 ...`. The reminder
scheduler already carries `reminder.community_id` on the `DueReminder` row
(joined from `communities`), so both call sites pass it with no new tenant
minting.

Adds `reminder_claim_and_release_are_confined_to_their_community`: inserts one
signed reminder event into communities A and B (identical id/created_at),
claims A/X and asserts B/X stays claimable, then releases A/X and asserts B/X
stays claimed while A/X becomes reclaimable. Green against live Postgres
alongside the existing claim-race and stamp-rollback tests.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:43:51 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 1151eca83d fix(relay): scope relay membership to its community
NIP-43 admission confinement (Max review #3 on PR #1321). The
`relay_members` table is keyed `(community_id, pubkey)`, but every DB
access keyed on `pubkey` alone. In closed mode a pubkey admitted to
community A was therefore admitted to community B — the exact M9
mutation #1285 targets.

Request-path scoping:
- thread `CommunityId` through all 9 `buzz-db::relay_members` functions
  and their `Db` wrappers; every query/insert/list/bootstrap now binds
  `community_id` (PK `ON CONFLICT (community_id, pubkey)`, WHERE clauses
  carry community).
- `check_/enforce_relay_membership` take the server-resolved community;
  pass `tenant.community()` at every entrypoint that already binds a
  tenant: bridge (events/query/count), media upload, git transport,
  audio handle, WS auth (`conn.tenant`), mesh `require_mesh_member`
  (connect + status), relay-admin events, leave-request ingest,
  identity-archive consent, NIP-43 list publish, and the buzz-admin CLI
  (via `resolve_admin_tenant`).

Startup seeding (the bootstrap half of the same fix): membership
backfill and owner bootstrap previously ran with no community. They now
run against the deployment's own community, seeded via
`ensure_configured_community` under the *same* normalized host that live
request resolution derives (`relay_url_authority` → `normalize_host`, now
`pub`), so the bootstrapped owner lands in exactly the community requests
for this host resolve to. An unparseable `relay_url` fails fast when
membership is enforced rather than seeding an unreachable empty-host
community.

Regression (Postgres-backed, `#[ignore]`):
- `membership_is_confined_to_its_community`: A admits a pubkey, B does
  not — `is_/get_/list_relay_members` confine it to A.
- `owner_bootstrap_is_confined_to_its_community`: owner bootstrapped in A
  is not a member of B.

cargo test -p buzz-db -p buzz-relay -p buzz-admin green; both new tests
pass against live Postgres; clippy clean on all three crates.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:43:51 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 6846a8f10c fix(relay): claim reminders before publishing
The NIP-ER reminder scheduler published the reminder event to Redis first
and only then claimed it (`claim_due_reminder`), treating a duplicate publish
as harmless because subscribers dedup by event id. That is the old unsafe
ordering: across N pods a due reminder could be published more than once,
violating the claim-before-side-effect rule for periodic producers (every
side effect must be claimed exactly once, not deduped after the fact).

Rewire to claim-before-publish using the stamp-guarded primitives that were
already built and wired into buzz-db but had zero callers:
`claim_due_reminder_with_stamp` (event.rs:1186) and `release_due_reminder`
(event.rs:1213). Each attempt mints a unique per-pod stamp; the scheduler
claims first, publishes only on a winning claim (`Ok(true)`) and `continue`s
on the loser (`Ok(false)`) so the loser never produces the side effect, and
releases its own claim on publish failure via compare-and-clear so the
reminder is redeliverable next tick. `events.delivered_at` is only ever read
as a NULL/non-NULL sentinel (due-reminder query guard + partial index), never
as a wall-clock value, so an opaque stamp is safe to store there.

The unused convenience wrapper `claim_due_reminder` (seconds stamp) is left in
place as public API; the scheduler no longer uses it.

Tests (buzz-db, Postgres-backed, verified locally against the dev DB):
- claim_due_reminder_is_won_by_exactly_one_of_two_racing_pods: two pods, two
  stamps, one reminder -> exactly one wins; the single winning claim is the
  proof of exactly one publish side effect.
- release_due_reminder_rolls_back_only_the_matching_stamp: a wrong-stamp
  release is a no-op (cannot clear another pod's claim); a matching-stamp
  release makes the reminder reclaimable for retry.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:43:51 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell c77f2c27d7 fix(relay): scope local-echo dedup by community
The local-echo dedup cache (`AppState.local_event_ids`) was keyed on the
bare Nostr event id. The same event id can legitimately exist in two
communities (channel-less events; same-channel-UUID/same-`h` events across
tenants), so a local publish of event X in community A would suppress
delivery of a *distinct* same-id event arriving via Redis for community B
for the 60s TTL — a cross-community non-interference violation.

Key the cache on `(CommunityId, [u8; 32])` instead. `mark_local_event` now
takes the community; all 11 callers pass the tenant already in scope
(`tenant.community()` / `conn.tenant.community()`), and the Redis-subscriber
skip/invalidate checks compare the pair. The community was already extracted
at the skip site (`handlers/event.rs`) and used by the scoped fan-out right
below it — it was simply absent from the dedup key.

Regression: `local_echo_suppression_is_scoped_to_its_community` marks A/X
locally, feeds B/X through `fan_out_pubsub_event`, and asserts the B-bound
subscriber still receives it. Fails on the bare-id key (delivery dropped),
passes once the key carries the community.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 14:43:51 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell a7d3817481 ci: drop Typesense from the integration-test workflow
The relay-e2e and desktop-e2e jobs started Typesense via `docker compose
up -d ... typesense ...`, waited on a `buzz-typesense` healthcheck, and passed
TYPESENSE_URL/TYPESENSE_API_KEY to the relay. The service was removed from
compose, so `docker compose up` failed with 'no such service: typesense' and
took all four E2E jobs down at setup time. Remove the service from both job
blocks, drop the healthcheck wait, and drop the two now-dead relay env vars
(the relay stopped reading them when the config fields were removed in
f1f6bbf3c).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 12:16:34 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 4e117adb06 docs: update multi-tenant spec search obligation to Postgres FTS
The #1285 conformance checklist and formal spec described the search
isolation obligation against the Typesense backend that no longer exists. Keep
the obligation intact (search query carries community_id; results never cross
tenants; refetch is (community_id, event_id)) and restate the mechanism in
terms of the Postgres FTS implementation #1321 ships: the events.search_tsv
generated tsvector column, GIN index, community_id filter BitmapAnd-ed with the
@@ probe, and ChannelScope::ChannelLessOnly in place of the __global__ sentinel.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 12:04:11 -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
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 9d9cbe1172 chore(compose,scripts): drop Typesense from local dev/test stacks
Search is Postgres FTS; the relay no longer reads TYPESENSE_URL/_API_KEY, so
the local stacks no longer provision or wait on a Typesense container:

- docker-compose.yml: removed the typesense service + typesense-data volume.
- deploy/compose/compose.yml: removed the typesense service, its volume, the
  relay's TYPESENSE_URL env, and the relay depends_on: typesense health gate;
  compose.dev.yml: removed the typesense port/CORS override.
- scripts/start-relay-for-tests.sh: stopped bringing up + health-waiting on
  the typesense container and stopped exporting TYPESENSE_* to the relay.
- scripts/{dev-setup,run-tests}.sh: removed TYPESENSE_* env export/printout.
- scripts/dev-reset.sh, e2e-*.sh, compose READMEs/run.sh: doc-string cleanup.

Verified: docker compose config renders cleanly for docker-compose.yml and for
deploy/compose (compose.yml + compose.dev.yml), zero typesense in the rendered
output; all touched shell scripts pass bash -n.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 11:57:17 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell 73aae6a010 chore(chart): remove the eval-only Typesense subsystem from the Helm chart
Typesense is no longer a relay dependency (search is Postgres FTS), so the
chart no longer provisions, wires, validates, or secrets a Typesense backend:

- Deleted templates/quickstart-typesense.yaml (in-cluster eval Deployment).
- deployment.yaml: dropped TYPESENSE_URL / TYPESENSE_API_KEY env from the relay.
- secret-chart.yaml: dropped the Typesense URL/key composition block.
- _helpers.tpl: removed buzz.typesenseFullname / buzz.typesenseUrl defines.
- _validate.tpl: removed the 'Typesense source must exist' fail-guard.
- values.yaml / values.schema.json: removed the typesense.* value block and
  schema, and the TYPESENSE_* entries from the existingSecret key docs.
- NOTES.txt: dropped Typesense from the quickstart/production profile output.
- ci/, examples/, Chart.yaml: dropped Typesense from the quickstart render
  scenario, the GitOps samples, and the chart description.
- tests/: removed the three Typesense-specific helm-unittest cases and the
  now-meaningless typesense.* set/fixture boilerplate.

Verified: helm-unittest 25/25 green (was 28; -3 Typesense cases); helm lint
clean; ci/quickstart + ha + production-existing-secret render matrices all
template cleanly after helm dependency build.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 11:55:12 -04:00
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dandTyler Longwell f1f6bbf3c2 refactor(relay): drop dead Typesense config + correct stale search comments
The Typesense search backend was replaced by Postgres FTS in this rewrite,
but two vestiges remained in live code:

- `Config.typesense_url` / `Config.typesense_key` were still parsed from
  `TYPESENSE_URL` / `TYPESENSE_API_KEY` and stored on the struct, yet read
  nowhere outside config.rs. Removed the fields, env parsing, and struct init.
- Several doc/inline comments still described the search path as hitting
  Typesense (req.rs NIP-50 handler, bridge.rs post-filter rationale). The
  behavior is unchanged but the engine is Postgres FTS; corrected the naming
  so the comments match the code.

Kept genuinely historical references intact (event.rs note that the old
index_event worker is gone; query.rs/schema provenance of the legacy
__global__ sentinel and the FTS migration).

cargo check -p buzz-relay green.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 11:48:44 -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
d7600deb18 fix(relay): scope media and git substrate by tenant
Community-scope media metadata sidecars so shared CAS bytes are only readable when the request tenant owns the sidecar.

Community-scope git repo pointer cells while keeping immutable pack and manifest CAS shared, and bind git NIP-98 URL verification to the server-resolved request host.

Also isolate git repo path config validation so concurrent tests do not leak BUZZ_GIT_REPO_PATH mutations into unrelated Config::from_env() callers.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-27 10:46:38 -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
klopez4212andGitHub aca40b6289 Polish thread reply hover states (#1329) 2026-06-27 06:27:51 -07: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