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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
`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>
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>
`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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>