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>
First real (non-pending_lane) conformance row in conformance_multitenant.rs;
the reference pattern remaining rows copy.
Asserts the wire-observable complement to the compile-time static-input fence
(_RELAY_INFO_BUILD_STATIC_INPUT_FENCE): the NIP-11 relay-info document served
for host A, host B, and an *unmapped* host are all byte-identical. Identical
docs are the proof that the unauthenticated relay-info endpoint carries no
host-derived field and therefore cannot be used to probe which communities are
configured on a deployment.
Corrects an initial design error caught by running the row against a live
two-host relay: the unmapped-host case must return 200 with the same static
doc, NOT 404 — a 200-vs-404 status difference between mapped and unmapped hosts
would itself be the enumeration oracle. Fail-closed host binding lives on the
WS-upgrade / non-nostr+json path (router.rs::nip11_or_ws_handler) and is the
obligation of row_zero_host_binding, not this row.
Adds url_unknown() helper (RELAY_URL_UNKNOWN) alongside url_a/url_b.
Verified by hand against a live two-host relay (a/b.localhost:3100, shared
PG/Redis): green -> mutate (leak request Host into the served description) ->
red on the A==B assertion with a real wire diff, not a compile break ->
restore -> green.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Conformance row 44 obligates that API token lookups key on
(community_id, token_hash), not on token_hash alone. The storage UNIQUE
index `idx_api_tokens_hash` already enforces this as a *storage*
guarantee — but the query side was filtering on `token_hash = $1` only,
relying on uniqueness as load-bearing for tenancy. That's a structural
gap: any future relaxation of the index (or an adversarial mint that
landed two rows via a tx race) would let a token minted in community A
authorize against a request bound to community B.
This change closes the query-side gap. All eight Db API surface methods
now take a `CommunityId` first parameter, and the underlying SQL adds
`AND community_id = $N` (or includes the column on INSERT). The
`create_api_token*` family additionally INSERTs into the `community_id`
column, which it previously omitted — schema declares it NOT NULL, so
those functions would have failed at runtime if invoked. They have no
callers today (token mint is staged but not wired), but fixing them in
the same diff un-rots the public API and prevents the next caller from
hitting a runtime FK error.
The only live caller is `crates/buzz-relay/src/api/media.rs::resolve_
upload_scopes`, called from the `AuthenticatedUpload` extractor. The
extractor previously resolved scopes BEFORE binding the request's
tenant via `bind_community`, so threading the community through would
have been impossible — the tenant didn't yet exist. Reordered: row-zero
tenant bind moves to step 4 (immediately after header validation),
scope resolution to step 5 with `&TenantContext` in hand. The lookup
in `resolve_upload_scopes` now calls `get_api_token_by_hash_including_
revoked(tenant.community(), &hash)`.
Sharp regression test added at `api_token::tests::lookup_by_hash_is_
scoped_to_community` (#[ignore = "requires Postgres"]): inserts two
same-hash tokens in two communities (legal under UNIQUE(community_id,
token_hash)) and asserts each lookup returns only its own community's
row, and that a third unrelated community returns None. Mirror test
`active_lookup_by_hash_is_scoped_to_community` covers the
`revoked_at IS NULL` variant on `Db::get_api_token_by_hash`.
Mutate-bite proof (verified manually before commit): stripping
`AND community_id = $1` from the WHERE clause fails the test with
`community-B lookup must return B's row` — Postgres returns the
first-inserted (A's) row when filtering on hash alone, as expected.
Restored the clause and re-ran clean.
Verification:
- cargo build --workspace --tests: clean (1.95.0)
- cargo test -p buzz-db -- --include-ignored --test-threads=1: 101/101
- cargo test -p buzz-relay -- --test-threads=1: 399 + 1/1
- cargo clippy --workspace --tests -- -D warnings: clean
- Line-read final diff for tenant provenance: every binding correctly
threads the request-resolved CommunityId; no client-supplied path.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Row 44 obligation closed: NIP-98 u-tag URL host must match req.community.
Previously expected_url was built from state.config.relay_url (one static
string per deployment), which under multi-tenant both (a) admitted events
signed for community A's host at community B's connection, and (b) rejected
every legitimate request whose community host wasn't the single configured
one.
Adds nip98_expected_url(config_relay_url, tenant, path): scheme from config
(preserves ws/wss dev-vs-prod), host from tenant.host() (the same host
row-zero bound from the request Host header). Swaps the three bridge call
sites (submit_event, query_events, count_events). Removes the orphaned
canonical_url helper.
Tests: 4 new in api::bridge::tests covering helper unit (both directions
of host substitution + scheme mapping) and verify_bridge_auth integration
(cross-host rejection + matching-host acceptance). Mutate-bite verified:
reverting the helper internals to config-global behavior turns all 4 new
tests RED with the exact diagnostics they were designed to surface.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The Typesense→Postgres FTS rewrite replaced out-of-band indexing with
`search_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', content))
STORED` over every row. The old relay (handlers/event.rs:287 on main)
deliberately skipped search-indexing for three kind classes, and the new
search query layer has no kind exclusion — so gift wraps, DM-visibility
snapshots, and event reminders were all in the FTS index.
Fix at the storage layer (option A — single source of truth, zero
app-layer drift across multiple search call sites): make the generated
column yield `NULL::tsvector` for excluded kinds via a CASE expression.
A NULL tsvector never matches `@@`, so excluded rows are structurally
unsearchable.
Excluded set, parity with main's `handlers/event.rs:287-290`:
- 1059 KIND_GIFT_WRAP (NIP-17 ciphertext)
- 30300 KIND_EVENT_REMINDER (AUTHOR_ONLY_KINDS — defense in depth)
- 30622 KIND_DM_VISIBILITY (per-viewer private hide state)
Constants are inlined in the migration with a comment naming the
`buzz_core::kind` names: sqlx migrations are frozen SQL and can't
`use buzz_core::kind`; importing core into a migration would be worse
drift than the inline-with-comment shape.
Three coupled layers:
1. Schema CASE in migrations/0001_initial_schema.sql. The 0001 schema
was consolidated by Max in 4b7654a1c (Lane-0 contract) and is
pre-deploy; editing in place rather than adding a new migration
matches the agreed shape and is covered by
`run_migrations_applies_consolidated_initial_schema_on_fresh_database`.
2. buzz-search/tests/fts_integration.rs — new test
`excluded_kinds_are_storage_level_unsearchable` inserts kind:1059 +
kind:30300 + kind:30622 + kind:9 with the same unique token; asserts
only the kind:9 control surfaces. Each excluded kind has its own
load-bearing negative assertion with a diagnostic message naming the
regression, plus a tight exactly-one-hit bound.
Mutate-bite verified: dropping the CASE's NULL branch (revert to
`to_tsvector('simple', content)`) makes excluded kinds searchable
and the test fails RED with the designed message
"kind:1059 MUST NOT be searchable — privacy regression in search_tsv
generated column". Restored → 10/10 green.
3. crates/buzz-test-client/tests/e2e_nostr_interop.rs — rewrote
`test_nip17_gift_wrap_not_searchable` to use the relay's actual
NIP-50 search seam (`Filter::new().search(token)`,
`collect_until_eose`) instead of the now-removed Typesense
`/multi_search`. Pattern cribbed from
`test_nip50_search_returns_results_and_eose`. Pointer comment to
the underlying mutate-bite in fts_integration.rs.
Verification on `quinn/search-kind-exclusions` off `34ffb8ab3`:
- `cargo test -p buzz-search --tests -- --include-ignored`: 10/10
- `cargo test -p buzz-db -- --include-ignored`: 99/99 (incl. migration
lints + consolidated-schema fresh-DB)
- `cargo check -p buzz-test-client --tests`: clean
- `cargo clippy -p buzz-search -p buzz-db --all-targets -- -D warnings`:
clean
- `cargo fmt --all -- --check`: clean
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Adds two regression tests against Max's NIP-98 replay-guard wiring
(b30869d44), proving distinct properties beyond his existing
cross-pod test:
1. nip98_replay_guard_rejects_same_pod_same_community_replay
(#[ignore], requires Redis) — single guard instance, A1 then A2
with the same TenantContext rejects the second call. Guards
against a fix that accidentally weakens same-pod replay
detection when moka is replaced with the shared Redis seen-set.
2. nip98_replay_check_fails_closed_when_guard_errors (CI-runnable,
no Redis) — injects a stub Nip98ReplayGuard that always returns
Err(AuthError::Internal(_)); asserts check_nip98_replay_with_guard
maps it to (401, error="NIP-98: replay check unavailable").
Exercises the Err => arm of the match (bridge.rs:107-117) which
is otherwise untested. This is the load-bearing fail-closed
property: a stateless worker that loses Redis MUST reject rather
than admit (Nip98ReplayGuard trait contract,
buzz-auth/src/nip98_replay.rs:70-73).
Mutate→red→restore (two orthogonal bites, both verified):
- Mutate Ok(false) => Err(...) → Ok(()) at bridge.rs:103. Reds
Max's cross_pod test AND same_pod_replay; fail_closed stays
green (doesn't exercise this arm). Restored.
- Mutate Err(e) => Err(api_error(...)) → Ok(()) at bridge.rs:113.
Reds ONLY fail_closed; cross_pod and same_pod stay green
(don't trigger Err). Restored.
Distinct mutations bite distinct tests — proves the new properties
are load-bearing, not vacuous, and independent of Max's coverage.
Verification on this commit:
- cargo fmt --all -- --check ✅
- cargo clippy -p buzz-relay --all-targets -- -D warnings ✅
- REDIS_URL=… cargo test -p buzz-relay --lib \
api::bridge::tests::nip98_replay -- --include-ignored \
--test-threads=1 → 3/0 ✅ (Max's + 2 new)
- cargo test -p buzz-relay -- --test-threads=1 → 390/0 + 2 ignored ✅
(baseline 389/1 ignored; +1 passing fail-closed, +1 ignored same-pod)
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
(cherry picked from commit 64a94bec901733ee87a84cbb4938e1180a5ca289)
The relay-side read-row emitter relies on a load-bearing contract from
`Db::communities_of_channels`: a channel id with no row in the DB MUST
be absent from the returned map, never mapped to a default. The relay's
`MissingLookup → ImplBug{row_community_lookup_missing} → CoverageBreach`
fail-closed guard-rail goes blind if this helper ever started returning
a default/zero entry for unknown channels — and the relay-side
mutate-bite for that guard-rail wouldn't catch it (different layer).
This adds a PG-ignored test that pins both directions:
- (1) Existing channel → present with its true community.
- (2) Missing channel → ABSENT from the result map (load-bearing).
- (3) Map size equals the number of existing channels.
Mutate → red → restore verified against live Postgres:
Mutant: post-loop `for ch in channel_ids { entry().or_insert(nil) }`
Result: assertion (2) bites with explicit message
"missing channel must be absent from the result map,
got Some(CommunityId(00000000-…))"
Restored: green.
Closes the read-seam fail-closed chain end-to-end (DB layer through
checker), making it non-vacuous across both layers.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Land the read-seam emitter for the runtime conformance gate. Two
emit sites, one buzz-db helper, one negative fixture.
## buzz-db: `communities_of_channels` helper
`Buzz::communities_of_channels(&[Uuid]) -> HashMap<Uuid, CommunityId>`
— batched per-channel community lookup. Used by the relay emitters to
project each row's true community label independently of the fetch
query's WHERE clause. That independence is what makes the
`Inv_NonInterference` / `Inv_ReadConfinement` bite non-vacuous: a
mutation dropping `community_id = $X` from `query_events` would
still let this helper return the row's true label and the checker
would catch the mismatch.
Channels missing from the result map are intentionally NOT mapped to
a default — callers MUST treat "channel-id not in map" as a coverage
breach, never as "use the resolved community."
## buzz-relay: projection + record helpers
`crate::conformance` gains four new items:
- `project_row_community` — single-row helper encoding the (B)
strategy: channel-less → resolved (honest, not tautological);
channel-scoped → lookup or `None` (caller fails closed).
- `RowCommunityProjection` enum — Ok(Vec<CommunityLabel>) OR
MissingLookup discriminated outcome.
- `record_read_message_rows` — non-search lane: emits
`ReadMessageRows` on Ok projection, `ImplBug { kind:
"row_community_lookup_missing" }` on MissingLookup.
- `record_read_by_id_rows` — search lane companion, same shape but
emits `ReadByIdRows`. `filter_channel` is `None` for the search
lane (search at the abstract level isn't bound to a single channel;
per-row `channel_id` carries channel identity honestly).
## req.rs wire-up: two emit sites
- Site 1 (`req.rs` non-search loop, after `query_events`): collect
distinct channel ids from the result set → `communities_of_channels`
→ `record_read_message_rows`. Production cost: one extra DB query
per request (NoopTracer short-circuits in non-conformance builds).
- Site 2 (`req.rs` search loop, after `get_events_by_ids`): same
pattern → `record_read_by_id_rows`. `handle_search_req` gains a
threaded-through `trace_state: Option<&AbstractState>` parameter.
DB-helper errors on either site fall back to an empty lookup map.
This intentionally triggers `MissingLookup` → `ImplBug` for any
channel-scoped row in the result set, surfacing the helper failure
as a coverage breach (fail-closed) rather than a silent resolved-
label substitution.
## Negative fixture: foreign-row leak
New `bad_foreign_row_leak.jsonl` + matching test. The fixture is a
`ReadMessageRows` whose row_communities contains community B while
the state is bound to community A. This is the proof artifact Eva
requested for the (B)-projection guard-rail: if the row had been
mis-projected as channel-less (defaulting to resolved A), the subset
check would have passed vacuously. By recording the row's TRUE
community independently, `Inv_NonInterference` surfaces it
immediately as `NonInterference`.
## Unit tests (conformance::tests)
Five new tests pinning every behavior:
- `project_row_communities_channelless_uses_resolved` (positive)
- `project_row_communities_channel_scoped_uses_lookup_label` (the
non-tautological correctness — lookup label, NOT resolved)
- `project_row_communities_channel_scoped_missing_is_breach` (the
guard-rail bite)
- `record_read_message_rows_missing_lookup_emits_impl_bug`
- `record_read_by_id_rows_ok_emits_read_by_id_rows`
## Mutate → red → restore (three independent bites)
1. Make `project_row_community` fall back to resolved on missing-
lookup (the tempting wrong-fix): `project_row_communities_channel_
scoped_missing_is_breach` + `record_read_message_rows_missing_
lookup_emits_impl_bug` go red with explicit messages
("missing lookup must be a breach, got Ok([...])", "expected
ImplBug coverage breach, got ReadMessageRows {...}"). Restored.
2. Make every channel-scoped row project to resolved (the
tautological projection): 4 of 9 unit tests go red — the lookup-
label, missing-breach, and record-helper tests all bite. Restored.
3. Edit the negative fixture to use community_a instead of
community_b: `foreign_row_leak_is_non_interference` reds ("foreign
row community label must be rejected by Inv_NonInterference"). The
fixture is load-bearing, not decorative. Restored.
## Test surfaces
- `cargo test -p buzz-relay --lib` → **394/0** (was 387 baseline).
- `cargo test -p buzz-conformance --lib` → 9/0.
- `cargo test -p buzz-conformance --test replay_fixtures` → **6/0**
(was 5; +foreign_row_leak).
- `cargo test -p buzz-db --lib` → 75/0 (helper compiles; DB-driven
integration coverage lives in `--include-ignored` lane on PG).
- `cargo clippy -p buzz-relay -p buzz-db -p buzz-conformance
--all-targets -- -D warnings` clean.
- `cargo fmt --all -- --check` clean.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Wire the conformance read seam at `req.rs` channel-membership
confirmation. When the relay falls through to the DB-uncached
membership check, record one `AuthCheck` step on the tracer
mapping `is_member` → `Allow`/`Deny`.
Design notes:
- `trace_state` is built once at request entry, after `pubkey_bytes`
is available. Reused by every downstream emit (matches ingest's
`state_for_request` discipline). The `Option` only goes `None`
on malformed pubkey bytes — a separate failure path.
- `claimed_community: None` is the load-bearing choice on the read
path: the REQ wire has NO client-asserted community (the `h` filter
is a channel-id, not a community-id). Encoding `None` here rather
than copying the resolved community means a future regression that
ever starts reading a wire-community on REQ would need to put a
real value in the field — that surfaces at code-review time
instead of silently projecting away the M2 (claim ≠ resolved) bite.
- No `EmitGuard` at REQ entry: read paths legitimately skip the DB
on cache hit (no `is_member` call), so a coverage-breach guard
would false-positive on the common case. Coverage for the read
seam comes from the upcoming row-emit fixtures, not from a guard
at the entry point.
Implementation:
- New `crate::conformance::record_req_authcheck` helper centralises
the emit so the call site stays one line and the helper carries
the design rationale in its doc comment.
- Two unit tests pin the verdict mapping table:
`record_req_authcheck_emits_allow_with_none_claim_when_member` and
`record_req_authcheck_emits_deny_when_not_member`. Mutate→red→
restore verified: inverting the `if member` branches reds both
tests with explicit panic messages ("member=true must map to
Allow", "member=false must map to Deny"); restored both green.
Test surfaces:
- `cargo test -p buzz-relay --lib` → 389/0 (was 387 baseline).
- `cargo test -p buzz-conformance` → 14/0 unchanged.
- `cargo clippy -p buzz-relay --all-targets -- -D warnings` clean.
- `cargo fmt --all -- --check` clean.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Two unit tests in `crates/buzz-relay/src/conformance/mod.rs` that prove
the structural fail-closed property of the [`EmitGuard`]:
- `emit_guard_drop_records_exactly_one_impl_bug_when_no_emit` — drop
the guard with zero recorded steps on the returned counting tracer →
exactly one `ImplBug` step lands on the inner tracer, carrying the
seam-name string passed to `EmitGuard::arm`.
- `emit_guard_drop_is_silent_when_an_emit_reached_the_tracer` — record
at least one step through the counting wrapper → Drop emits no
`ImplBug`, only the original step.
These pin the counting-tracer design against a future refactor to a
"disarm" flag, which would only fail-close by author discipline. The
counting wrapper makes coverage breach fire *structurally* regardless
of what the request path did or didn't do — that's the whole point of
the coverage-breach mode.
Verify: `cargo test -p buzz-relay --lib conformance::` → 2/2.
Full `cargo test -p buzz-relay --lib -- --test-threads=1` → 387/0.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Add `crates/buzz-relay/src/conformance/` module:
- `Tracer` re-export, `NoopTracer` (production), `JsonlTracer` (test/CI).
- `EmitGuard::arm(tracer, state, kind) → (guard, counting_tracer)`:
RAII coverage breach. The guard wraps the original tracer in a
counting layer; production callers transparently use that wrapper.
If no emit reaches the wrapper before the guard drops, the guard
emits a synthetic `ImplBug` step on the underlying tracer — the
checker treats that as CoverageBreach. The wrapper design means
production paths never need to "disarm" or pass anything around;
a future new exit that forgets to emit will be caught
automatically.
- `state_for_request(tenant, actor)`: builds AbstractState. Pulls
community + host directly from server-resolved TenantContext.
- `claimed_community_from_event`: reads the event's h tag for the
trace's `claimed_community` field — recorded SEPARATELY from
`resolved_community` so M2 (claim≠resolved) and M8 (host/channel
disagreement) bite at the checker.
- `sanitized_reason_for(&IngestError) → SanitizedReason`: 1:1 map
of IngestError variants (Rejected/AuthFailed/Internal) onto the
closed SanitizedReason alphabet (Invalid/Restricted/ServerError).
Adding a fourth IngestError variant breaks this match — CI catches
it before it ships.
Emitter wiring in `crates/buzz-relay/src/handlers/ingest.rs`:
- `ingest_event` is now a thin wrapper: arms EmitGuard, calls
`ingest_event_inner`, and on Err maps to SanitizedError. All 36
early-Err returns and 6 Ok returns in the inner fn are covered
by this single outer mapping.
- At `check_channel_membership` call site (line 1401): emits
`AuthCheck { channel, claimed_community, verdict }` with
Allow on Ok, Deny on Err. The verdict basis is `tenant.community()`
server-resolved — confirmed at ingest.rs:424's `is_member_cached`
call signature (not event-derived).
- At each `dispatch_persistent_event` call site (lines 1908, 2014):
emits `WriteInsert` (channel + was_inserted=true), `WriteDuplicate`
(channel + was_inserted=false), or `WriteInsertGlobal` (no channel).
This is the entire write side of the ingest seam.
`crates/buzz-relay/src/handlers/event.rs` `dispatch_persistent_event`:
- No emit added. Documented why inline: the spec has no separate
fan-out action — acceptance is recorded at ingest's WriteInsert;
fan-out surfaces as ReadMessageRows on the subscriber side (the
read seam in req.rs, lands in the held-back additive diff).
AppState carries `tracer: Arc<dyn buzz_conformance::Tracer>`,
defaulting to NoopTracer (zero cost). Test contexts overwrite this
field with a JsonlTracer after construction.
Verify:
- cargo check -p buzz-relay green
- cargo test -p buzz-relay --lib: 378/378 (no regressions)
- cargo test -p buzz-conformance: 9/9 (checker still bites all four
failure modes)
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Adds `crates/buzz-conformance/` — the substrate for runtime formal-spec
conformance. It is the **independent oracle** for the multi-tenant relay:
given a trace of seam events recorded by the relay at runtime, the
checker asserts they obey `docs/spec/MultiTenantRelay.tla`. Production
binaries pay zero cost (the relay defaults to `NoopTracer`); test/staging
runs against `JsonlTracer` and the checker re-runs every captured trace.
Crate contents:
- `src/lib.rs` — schema: `TraceStep`, `TraceAction` (8 spec actions +
`ImplBug` for coverage-breach), `AbstractState` (resolved_community,
bound_host, actor), the `Tracer` trait, `NoopTracer` for prod.
- `src/transitions.rs` — re-implementation of the spec's `Next` relation
in Rust, used by the checker. Owned by this crate, not pulled from the
relay — that's what makes the oracle independent.
- `src/checker.rs` — replay engine: `check_trace` returns
`Err(IllegalTransition | StateMismatch | NonInterference | CoverageBreach)`
on any departure from the spec. 9 unit tests covering each failure mode
plus the M2/M8 (`claimed != resolved`) and NI/ReadConfinement bites.
- `tests/replay_fixtures.rs` + `tests/fixtures/*.jsonl` — five tests that
reconstruct three on-disk JSONL fixtures from typed Rust, assert the
committed file matches byte-for-byte (any schema change requires
`BUZZ_CONFORMANCE_UPDATE=1` to refresh), then replay each through
`check_trace`:
- `good.jsonl` → `Ok(())`
- `bad_host_channel_mismatch.jsonl` → `IllegalTransition`
- `bad_coverage_breach.jsonl` → `CoverageBreach`
- `TRACE_SCHEMA.md` — grounds every action in its `MultiTenantRelay.tla`
line and calls out the three load-bearing projection rules.
- `LIMITS.md` — honestly describes what a green run does/doesn't prove,
and the CI command listing the test surfaces.
Production-fence discipline: deps are exactly `serde / serde_json /
thiserror / uuid`. Zero `buzz-*` production crates. `CommunityLabel(Uuid)`
is a newtype in this crate, NOT `buzz_core::CommunityId` — the checker
physically cannot inherit a production bug because it shares no code
with the relay.
Verify discipline:
- `cargo test -p buzz-conformance --lib` → 9/9
- `cargo test -p buzz-conformance --test replay_fixtures` → 5/5
- Mutate→red→restore proven three times (in earlier session): row-label
corruption → `NonInterference` fires; trace `claimed = resolved` →
`IllegalTransition` vanishes; counter threshold loosened →
`ImplBug` doesn't fire.
This commit lands the substrate only. The relay-side glue
(`crates/buzz-relay/src/conformance/{mod,tracers}.rs`, `AppState.tracer`,
`EmitGuard`) and the ingest-seam emitter follow on the next branch
(`quinn/conformance-relay-glue`). The req.rs read-seam emitters land
after.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The H1 fanout fix threaded a server-resolved CommunityId through the
connection registry and two membership caches, tipping three clippy lints:
register() to 8 args (too_many_arguments) and the moka cache keys to
type_complexity. Allow both locally with rationale, matching existing
repo convention (observer_owner_cache already carries the same allow in
this file; buzz-db/buzz-cli use too_many_arguments allows). Also relocate
topic_for_subscription above the req.rs test module to clear
items_after_test_module surfaced by --all-targets.
No behavior change; buzz-relay 385/0, clippy --all-targets -D warnings clean.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The host-binding seam (Inv_RowZero) must derive a community only from a
request's actual Host. An empty raw_host carries no community evidence,
yet bind_community passed it straight to the resolver. The schema does
not forbid an `host = ''` row in communities (0001_initial_schema.sql:
`host VARCHAR(255) NOT NULL`, unique index on `lower(host)`), so a
misconfigured/empty-host row plus a request with a missing, unreadable,
or whitespace-only Host header would silently bind to that community.
Guard before the resolver lookup: if normalize_host(raw_host) is empty,
return BindError::UnmappedHost. Reuses the existing variant so the
rejection is byte-identical to any other unmapped host — an
unauthenticated caller cannot probe whether an empty-host row exists.
Red-team finding (Attack 2, MEDIUM defense-in-depth) by Sami; her three
proof tests (empty, whitespace-only, plus a non-empty negative control)
are included un-ignored. Verified mutate->red->restore: removing the
guard reds both empty-host gates with the literal fence collapse
(TenantContext{community: X, host: ""}). Full cargo test -p buzz-relay
--lib: 381 passed, 0 failed.
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The desktop e2e integration relay-boot still used BUZZ_AUTO_MIGRATE with no
pre-boot community seed, so the channel reconciler bound the deployment
community ONCE at boot (outside its retry loop) before setup-desktop-test-data.sh
seeded it, hit UnmappedHost, and exited permanently. The reconciler's retry
loop only covers late-seeded channels, not a late-seeded community — so the
9 seeded channels were never reconciled and 'loads channels from the relay'
saw 0 channels (60s timeout). Both Desktop E2E Integration shards red.
Mirror the proven backend-integration ordering: apply schema + seed the
localhost:3000 community BEFORE the relay starts, and drop BUZZ_AUTO_MIGRATE
(schema is now applied pre-boot). setup-desktop-test-data.sh's own idempotent
community seed becomes a no-op; its channel INSERTs are then picked up by the
reconciler's retry loop.
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Two CI-honesty follow-ups after the first seed surfaced host/ordering
mismatches in the MT e2e path (no product behavior):
1. Desktop E2E 404: the seed-readiness helper queried 127.0.0.1:3000
while the relay reconciles + the community is seeded for localhost:3000.
normalize_host keeps the non-default port and 127.0.0.1 != localhost,
so the inbound host resolved to no community and every /query 404'd.
Default the helper to http://localhost:3000, matching the rest of the
desktop e2e suite (e2eBridge.ts / bridge.ts already use localhost) and
the relay's RELAY_URL.
2. Backend Integration UnmappedHost: the reminder scheduler binds the
deployment community once at boot and exits permanently on an unmapped
host (no retry, unlike the channel reconciler). The community was being
seeded after relay start, leaving the scheduler dead. Apply the schema
and seed the community BEFORE starting the relay (dropping
BUZZ_AUTO_MIGRATE since the schema is now applied up front), so the
scheduler binds on its single boot-time attempt.
Both are test/CI wiring. The Relay E2E suite stays red on a separate,
gated body-level bug (command_executor.rs inserts events without
community_id) tracked for the §4 scoping slice.
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
The relay resolves each connection's tenant from the durable communities
host map and fails closed on an unmapped host. Under the MT schema,
channels.community_id is NOT NULL with a FK to communities, so the
pre-MT e2e seed (unscoped channel/member INSERTs against an empty
communities table) fails, and every e2e client connection 404s at
host-binding. The relay never auto-seeds a community
(ensure_configured_community has no callers).
Seed the deployment community (host=localhost:3000, matching
RELAY_URL=ws://localhost:3000 after normalize_host keeps the non-default
port) and thread community_id through the channel/member INSERTs:
- setup-desktop-test-data.sh: insert the community row first, then scope
every channel/member INSERT (Desktop E2E Integration).
- start-relay-for-tests.sh: seed the community after schema apply
(Relay E2E); psql-or-docker fallback since psql is not on PATH in hermit.
- ci.yml backend-integration: seed after relay start (reconciler retries
for 2min), before the NIP-ER reminder suite.
ON CONFLICT targets lower(host) to match idx_communities_host, keeping
the seed idempotent.
Verified against live PG: schema apply clean (165 stmts), seed inserts
9 scoped channels + 19 scoped members with zero nulls, host resolves,
re-run is idempotent. Adversarial: an unscoped channel INSERT fails
not-null and a channel against a nonexistent community fails the FK,
proving the community row is load-bearing.
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Relay E2E builds the database from schema/schema.sql via pgschema apply, while the rewrite migration had moved the first-class community_id schema forward. The snapshot was still mostly pre-MT, so it produced unscoped tables such as channels(id) instead of channels(community_id, id).
Make the declarative snapshot match migrations/0001_initial_schema.sql exactly so the schema path and migration path create the same tenant-scoped shape.
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
CI Rust Lint + Windows Rust run `cargo clippy --workspace --all-targets -- -D warnings`; the community_id/tenant args pushed six fns to 8/7 and the new NIP-98 replay code tripped clamp/const-assert lints. Resolve at the bar, matching existing repo conventions:
- 6x #[allow(clippy::too_many_arguments)] on the fns that gained a tenant/community arg (same convention already used across buzz-db/relay).
- buzz-pubsub replay TTL: .max().min() -> .clamp() (floor 120 < ceiling 3600, cannot panic; behavior identical, incl. the u64::MAX clamp test).
- buzz-auth replay const-drift tripwires: scoped #[allow(clippy::assertions_on_constants)] — the assert-on-constant IS the design (fails if someone drifts the TTL constants).
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>