mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
eva/integration
321
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ef9e174edf |
fix(workflow): seed interval cold-start anchor so new schedules fire
A brand-new interval workflow on a cold engine has no in-memory last_fired entry and no prior durable claim, so the scheduler resolves last = None. interval_should_fire then reads last = now and suppresses the tick (correct: wait a full interval), but the in-memory anchor is only written AFTER a won claim, and no claim is attempted until the prefilter passes. Every subsequent tick repeats with last = None, so the workflow suppresses forever. Extract interval_prefilter_should_fire (free fn over the last_fired map + a thin &self wrapper): on the cold-start None suppress path it seeds the anchor to now so the next tick counts from a real anchor and fires after one interval. It seeds ONLY when last was None; an existing Some anchor is mid-interval and must elapse on its own, so it is never advanced. A due/firing tick passes through without seeding (the post-claim path owns that write). Unit tests (no Db/Postgres; pure in-memory anchor state): - cold start seeds then fires after one interval - mid-interval suppress does not advance an existing anchor - a due fire passes through without seeding Caught by Max in cold review of the scheduled-workflow lane; predates this branch's claim work but lives on the exact lane being cleared. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
00f75bcfc9 |
fix(workflow): add scheduled_workflow_fires.workflow_run_id for the attach audit link
The scheduler's post-run `attach_scheduled_workflow_run` does `SET workflow_run_id`, but neither schema/schema.sql nor the initial migration declared that column, so every won scheduled claim would create+run, then the best-effort attach would hit `column "workflow_run_id" does not exist` (PG 42703) and warn forever — the audit link could never populate. Found by Max in cold review of 778a5d28c. - Add nullable `workflow_run_id UUID` to scheduled_workflow_fires (schema + migration) with a composite FK `(community_id, workflow_run_id) REFERENCES workflow_runs (community_id, id)`. The FK uses ON DELETE NO ACTION, not SET NULL: community_id is shared with the claim PK and is NOT NULL, so SET NULL is unimplementable (verified against live PG: it raises a NOT NULL violation mid-cascade). NO ACTION blocks a delete of a still-linked run cleanly; workflow_runs are not pruned today regardless. - Rewrite the stale scheduled-fires schema comment that still claimed community is 'resolved server-side from workflow_id, never a caller-supplied claim parameter' — contradicted by the S1 reconciliation: community is server provenance from list_all_enabled_workflows(), passed explicitly, since id is not globally unique. - Surface the interval-anchor read failure with a warn! instead of unwrap_or(None) swallowing it (still fail-closed: a missing anchor suppresses the tick and retries). - Add attach_links_run_to_claim_and_is_idempotent: proves the column populates on attach and the IS NULL guard makes a second attach a no-op. Proven RED against the pre-migration schema (the exact 42703 error), GREEN after — the regression that would have caught this gap. Verified vs live Postgres, serial: buzz-db 110 / buzz-workflow 145 / buzz-relay 414, 0 failed; clippy -D warnings clean on the trio. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
4dc65b6ee5 |
fix(workflow): wire community-scoped durable claim into scheduler
The scheduled-workflow fire path never called the durable claim primitives (`claim_scheduled_workflow_fire` had zero callers outside the DB wrappers and tests), so with N relay pods every pod that saw a due cron/interval row created a run and executed the side effect — a multi-pod duplicate-run bug. And the claim primitive itself was still keyed by bare `workflow_id` (`WHERE w.id = $1`) despite the schema's `(community_id, id)` workflow identity: with duplicate workflow UUIDs across communities (which the schema explicitly allows and the Issue-4 confinement tests pin), a single `INSERT ... SELECT` matched every community's row and fanned one claim across all of them. Scope the claim to its community and wire the claim->run boundary into the scheduler: - `claim_scheduled_workflow_fire` takes `CommunityId` and binds `WHERE w.community_id = $1 AND w.id = $2`. The community is server provenance — the `workflow.community_id` from the global scan, never client input. This reverses the earlier S1 "resolve-from-id-alone" lock, which was written against a globally-unique-`workflow_id` assumption the final `(community_id, id)` schema does not hold (and which is unimplementable on that key). The surviving invariant is "the claim community is server provenance, not client-controlled." - `WorkflowEngine::run` now claims before `create_workflow_run`; the loser skips before any run creation or side effect. The claim anchor `scheduled_for` is deterministic across pods: the cron's own scheduled instant (`cron_fire_instant`, not `now`) or the interval bucket boundary (`interval_fire_instant`, floor to the interval). The interval anchor is seeded from `latest_scheduled_workflow_fire` on the first tick after restart so a process bounce can't double-fire within an interval. `attach_scheduled_workflow_run` links the won claim to its run for ops/audit forensics. Tests: - Rewrite the stale S1 comment block and `claim_for_workflow_in_other_ community_no_ops` (which encoded the now-false globally-unique assumption) into `claim_confined_to_its_community`: a dup workflow UUID in A and B claims independently (claiming A/id leaves B/id claimable). Proven RED on the bare-`id` regression. - `concurrent_same_window_claims_exactly_one_wins` and the prune-anchor test updated to the scoped signature. - New `cron_fire_instant` / `interval_fire_instant` unit tests pin the deterministic, drift-stable claim anchors. Full `cargo test -p buzz-db -p buzz-workflow -p buzz-relay` green vs live Postgres (serial); clippy clean on the trio. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
5e62ba8cb8 |
fix(workflow): scope workflow execution and approvals to their community
`workflows`, `workflow_runs`, and `workflow_approvals` are all keyed
`(community_id, id|token)`, so the same UUID/token is structurally allowed
in two communities — exactly like channels and events. But the execution
and approval spine still fetched, listed, mutated, and posted by bare id,
so a webhook/manual trigger or NIP-09 deletion in community B could load,
drive, or erase community A's colliding workflow, and workflow side effects
were published under the deployment/default tenant instead of the run's own
community. This threads the owning community through every request-scoped
and run-scoped path so each lookup, write, and side effect is confined to
its tenant.
- `ActionSink::send_message` now takes the run's `community_id` as its first
parameter. `RelayActionSink` drops `bind_deployment_community(relay_url)` —
the Issue-4 root cause — and instead resolves the run community's host via
`lookup_community_host` to form a complete `TenantContext::resolved`, fail
closed if the community is unmapped. A workflow in B now posts into B.
- Executor (`dispatch_action`, `execute_run`, `execute_from_step`,
`execute_steps`) and engine (`finalize_run`, `on_event`) carry the run's
community; every `get_workflow_run` / `get_workflow` / `send_message` and
the post-store `on_event` call (from `dispatch_persistent_event`, which has
the bound `tenant`) are scoped. The interval `last_fired` DashMap is keyed
`(CommunityId, Uuid)` so duplicate workflow UUIDs across communities cannot
cross-suppress in memory.
- Webhook `/hooks/{id}` now binds its community from the request Host before
any lookup (`bind_community`), then `get_workflow(community, id)`. The host
— not the workflow row — determines the tenant, so a request to A's host
can only reach A's workflows; unmapped host and not-found both fail closed
with the same generic 404.
- WS manual trigger and `create_workflow` use `tenant.community()` as the
authoritative owner. `create_workflow` no longer resolves the community via
the ambiguous `community_of_channel(channel_id)`; it verifies the channel
exists *inside* the bound community via scoped `get_channel` (the same
guarantee the composite FK enforces, surfaced as a clean rejection).
- Approval grant/deny/resume handlers and the `buzz-db` approval methods
(`get_approval`, `get_approval_by_stored_hash`, `get_run_approvals`,
`update_approval`, `update_approval_by_stored_hash`, `create_approval`)
are scoped by community; `create_approval`'s INSERT now includes the
`community_id` NOT-NULL column it previously omitted. NIP-09 a-tag workflow
deletion (`delete_workflow`, `find_workflow_by_owner_and_name`) is scoped
to the request tenant.
Adds three `#[ignore]` Postgres regressions in `buzz-db::workflow`, each
verified green against live PG and red when the `community_id` predicate is
dropped: `workflow_lookup_is_confined_to_its_community` (dup workflow+channel
UUID in A/B; scoped get/list resolve only the bound community's row, cross
lookup is NotFound), `workflow_delete_is_confined_to_its_community` (deleting
A/id leaves B/id intact), and `approval_is_confined_to_its_community` (same
token in A/B; granting A leaves B pending). Full `cargo test -p buzz-db
-p buzz-workflow -p buzz-relay` green, clippy clean on the trio.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
|
||
|
|
66a0add86d |
fix(admin): derive admin tenant host via shared relay_url_authority
Issue 3 reopen (Max): buzz-admin's resolve_admin_tenant derived its lookup host with Url::host_str(), which drops an explicit non-default port and IPv6 brackets. For the default RELAY_URL ws://localhost:3000 the admin would look up community host `localhost` while startup seeding (and live request resolution) bind `localhost:3000` — so the admin CLI's membership writes would miss, or hit, the wrong deployment community. Lift relay_url_authority into buzz-core::tenant as the single canonical helper so the relay's host-resolution seam (startup seeding, bind_deployment_community) and the buzz-admin CLI derive a byte-identical authority: host plus explicit non-default port, IPv6 brackets preserved, default ports collapsed — exactly as normalize_host shapes an inbound Host header. The relay tenant module now `pub use`-re-exports it (no behavior change at the relay seam); buzz-admin calls it directly. Adds 4 buzz-core unit tests pinning the authority shape: non-default-port retention (localhost:3000, relay.example:8443), default-port collapse (:443/:80), IPv6 brackets ([::1]:3000), and unparseable/empty -> empty (callers fail closed on empty). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
ae7b2e9b30 |
fix(db): scope reminder claim/release to their community
`claim_due_reminder_with_stamp` and `release_due_reminder` predicated only on `(created_at, id)`, but `events` is keyed `(community_id, created_at, id)` and the same Nostr event id — hence the same `id`/`created_at` pair — is allowed across communities. So a claim for reminder `A/X` would also mark `B/X` delivered (suppressing B's reminder), and a matching-stamp release for `A/X` would clear `B/X`. That is cross-community interference in exactly the reminder lane the claim-before-publish gate fixes; the scheduler's exactly-once-publish proof rests on this primitive being community-scoped. Thread `CommunityId` through both `event::` fns, their `Db` wrappers, and the bare `claim_due_reminder` convenience fn; every predicate is now `WHERE community_id = $1 AND created_at = $2 AND id = $3 ...`. The reminder scheduler already carries `reminder.community_id` on the `DueReminder` row (joined from `communities`), so both call sites pass it with no new tenant minting. Adds `reminder_claim_and_release_are_confined_to_their_community`: inserts one signed reminder event into communities A and B (identical id/created_at), claims A/X and asserts B/X stays claimable, then releases A/X and asserts B/X stays claimed while A/X becomes reclaimable. Green against live Postgres alongside the existing claim-race and stamp-rollback tests. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
f801807653 |
fix(relay): scope relay membership to its community
NIP-43 admission confinement (Max review #3 on PR #1321). The `relay_members` table is keyed `(community_id, pubkey)`, but every DB access keyed on `pubkey` alone. In closed mode a pubkey admitted to community A was therefore admitted to community B — the exact M9 mutation #1285 targets. Request-path scoping: - thread `CommunityId` through all 9 `buzz-db::relay_members` functions and their `Db` wrappers; every query/insert/list/bootstrap now binds `community_id` (PK `ON CONFLICT (community_id, pubkey)`, WHERE clauses carry community). - `check_/enforce_relay_membership` take the server-resolved community; pass `tenant.community()` at every entrypoint that already binds a tenant: bridge (events/query/count), media upload, git transport, audio handle, WS auth (`conn.tenant`), mesh `require_mesh_member` (connect + status), relay-admin events, leave-request ingest, identity-archive consent, NIP-43 list publish, and the buzz-admin CLI (via `resolve_admin_tenant`). Startup seeding (the bootstrap half of the same fix): membership backfill and owner bootstrap previously ran with no community. They now run against the deployment's own community, seeded via `ensure_configured_community` under the *same* normalized host that live request resolution derives (`relay_url_authority` → `normalize_host`, now `pub`), so the bootstrapped owner lands in exactly the community requests for this host resolve to. An unparseable `relay_url` fails fast when membership is enforced rather than seeding an unreachable empty-host community. Regression (Postgres-backed, `#[ignore]`): - `membership_is_confined_to_its_community`: A admits a pubkey, B does not — `is_/get_/list_relay_members` confine it to A. - `owner_bootstrap_is_confined_to_its_community`: owner bootstrapped in A is not a member of B. cargo test -p buzz-db -p buzz-relay -p buzz-admin green; both new tests pass against live Postgres; clippy clean on all three crates. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
7efb4af021 |
fix(relay): claim reminders before publishing
The NIP-ER reminder scheduler published the reminder event to Redis first and only then claimed it (`claim_due_reminder`), treating a duplicate publish as harmless because subscribers dedup by event id. That is the old unsafe ordering: across N pods a due reminder could be published more than once, violating the claim-before-side-effect rule for periodic producers (every side effect must be claimed exactly once, not deduped after the fact). Rewire to claim-before-publish using the stamp-guarded primitives that were already built and wired into buzz-db but had zero callers: `claim_due_reminder_with_stamp` (event.rs:1186) and `release_due_reminder` (event.rs:1213). Each attempt mints a unique per-pod stamp; the scheduler claims first, publishes only on a winning claim (`Ok(true)`) and `continue`s on the loser (`Ok(false)`) so the loser never produces the side effect, and releases its own claim on publish failure via compare-and-clear so the reminder is redeliverable next tick. `events.delivered_at` is only ever read as a NULL/non-NULL sentinel (due-reminder query guard + partial index), never as a wall-clock value, so an opaque stamp is safe to store there. The unused convenience wrapper `claim_due_reminder` (seconds stamp) is left in place as public API; the scheduler no longer uses it. Tests (buzz-db, Postgres-backed, verified locally against the dev DB): - claim_due_reminder_is_won_by_exactly_one_of_two_racing_pods: two pods, two stamps, one reminder -> exactly one wins; the single winning claim is the proof of exactly one publish side effect. - release_due_reminder_rolls_back_only_the_matching_stamp: a wrong-stamp release is a no-op (cannot clear another pod's claim); a matching-stamp release makes the reminder reclaimable for retry. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
60519a6f33 |
fix(relay): scope local-echo dedup by community
The local-echo dedup cache (`AppState.local_event_ids`) was keyed on the bare Nostr event id. The same event id can legitimately exist in two communities (channel-less events; same-channel-UUID/same-`h` events across tenants), so a local publish of event X in community A would suppress delivery of a *distinct* same-id event arriving via Redis for community B for the 60s TTL — a cross-community non-interference violation. Key the cache on `(CommunityId, [u8; 32])` instead. `mark_local_event` now takes the community; all 11 callers pass the tenant already in scope (`tenant.community()` / `conn.tenant.community()`), and the Redis-subscriber skip/invalidate checks compare the pair. The community was already extracted at the skip site (`handlers/event.rs`) and used by the scoped fan-out right below it — it was simply absent from the dedup key. Regression: `local_echo_suppression_is_scoped_to_its_community` marks A/X locally, feeds B/X through `fan_out_pubsub_event`, and asserts the B-bound subscriber still receives it. Fails on the bare-id key (delivery dropped), passes once the key carries the community. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
2c6b1b925c |
docs: rename Typesense references to Postgres FTS
Migrate prose and doc-comments to describe the Postgres FTS backend that replaced Typesense: README architecture diagram (3 boxes, Postgres now "events + FTS search"), ARCHITECTURE.md buzz-search section rewritten to the real API (SearchService::new(pool), search(&SearchQuery), ChannelScope) and the search_tsv generated-column mechanism (CASE WHEN kind IN (1059,30300,30622) THEN NULL, idx_events_search_tsv GIN), CONTRIBUTING step-6, VISION, AGENTS, TESTING (both), and the chart README. Comment-only edits in desktop and test-client files; drop the dead reindex-kind0 Justfile recipe (its binary no longer exists). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
f1fda2e15e |
refactor(relay): drop dead Typesense config + correct stale search comments
The Typesense search backend was replaced by Postgres FTS in this rewrite, but two vestiges remained in live code: - `Config.typesense_url` / `Config.typesense_key` were still parsed from `TYPESENSE_URL` / `TYPESENSE_API_KEY` and stored on the struct, yet read nowhere outside config.rs. Removed the fields, env parsing, and struct init. - Several doc/inline comments still described the search path as hitting Typesense (req.rs NIP-50 handler, bridge.rs post-filter rationale). The behavior is unchanged but the engine is Postgres FTS; corrected the naming so the comments match the code. Kept genuinely historical references intact (event.rs note that the old index_event worker is gone; query.rs/schema provenance of the legacy __global__ sentinel and the FTS migration). cargo check -p buzz-relay green. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
a7a6dae2bf |
test(conformance): document status-code-as-layer-discriminator on nip98_replay assertion
Follow-up to |
||
|
|
f49a7dc18e |
fix(relay): scope media and git substrate by tenant
Community-scope media metadata sidecars so shared CAS bytes are only readable when the request tenant owns the sidecar. Community-scope git repo pointer cells while keeping immutable pack and manifest CAS shared, and bind git NIP-98 URL verification to the server-resolved request host. Also isolate git repo path config validation so concurrent tests do not leak BUZZ_GIT_REPO_PATH mutations into unrelated Config::from_env() callers. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
1f08892501 |
test: cover pubsub presence typing isolation
Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
354ad213c5 |
test(conformance): fill api_tokens_nip98_replay row — doc-only api_token half + wire-driven NIP-98 replay half
Fills both `pending_lane` stubs in `mod api_tokens_nip98_replay`: # `token_minted_in_a_does_not_authorize_in_b` — doc-only The api_token mint surface does not exist on the wire in `buzz-relay`: no `/tokens` route in `router.rs:52-79` (verified by hand on PR head), no `tokens` module in `crates/buzz-relay/src/api/`. The 792-line self-service minting endpoint that existed pre-rewrite (sprout-relay PR #37, commit `f84da74d3`) was deliberately not ported. Api_tokens are *consumed* (not minted) by the Blossom upload path at `media.rs:638`. This means "mint in A, present to B" has no wire precondition — a wire-driven row would test a contract with no entry point. The honest shape is doc-only, mirroring `audit_log`: where audit proves the *output* surface does not exist on the wire, api_tokens proves the *input* surface does not. Both are strictly stronger isolation claims than a wire-denied assertion. The `(community_id, token_hash)` fence itself is directly proven at the storage layer (where direct Postgres access is in-convention): * `crates/buzz-db/src/api_token.rs:425 lookup_by_hash_is_scoped_to_community` — same hash in A and B, A-scoped lookup returns A only. * `crates/buzz-db/src/api_token.rs:488 active_lookup_by_hash_is_scoped_to_community` — mirror for the revoked-filter variant. Plus the consumer fence: `media.rs:638` calls the scoped DB lookup with `tenant.community()` derived from request host *before* token resolution (`media.rs:97` comment names the row-44 fence explicitly). # `nip98_replay_seenset_is_shared_and_community_scoped` — wire-driven Load-bearing wire claim: within-community replay rejection. Sign a NIP-98 event E for A's `u=`, POST to A → 200. POST again → 401 with a body that names replay detection. The proof that the shared (cross-pod) seen-set is in the request path at all — without it, any pod would re-honor a spent NIP-98 event. Mutate-bite: `check_nip98_replay → noop` in `bridge.rs:79` (return `Ok(())` without consulting the guard). Under mutation, second POST goes 200 instead of 401. Test asserts the failure with named assertion message pointing at the mutate-bite handle, so a future reader sees what would have been caught. Cross-community independence is a *tripwire*, not a bite: sign an independent NIP-98 event E' for B's `u=` (different event_id by u-tag canonicalization divergence), POST to B → 200 even though E was spent in A. Catches future namespace-globalization regressions (key truncation, u-normalization collapse) that would break the spend-spread, on top of the unit-layer proof at `crates/buzz-auth/src/nip98_replay.rs:163 key_isolates_communities_for_same_event_id` (which the substrate's own doc-comment names as "belt-and-suspenders"). The prefix-drop mutation considered earlier turned out to be vacuous against natural wire traffic: u-tag divergence across communities makes event_ids already community-distinct, so dropping the community prefix from `nip98_replay_key` does not collapse natural traffic into a shared slot. A same-event_id-different-community wire collision can't be constructed because u-host (`verify_bridge_auth`) rejects with 401 before the replay check runs. That artificial property is proven at the unit layer; the wire layer asserts the load-bearing per-call replay rejection. # Bar * `cargo check -p buzz-test-client --tests`: clean. * `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean. * `cargo fmt -p buzz-test-client -- --check`: clean. * Default test run (no `--ignored`): 1 passed (doc-only `#[test]`), 16 ignored (live rows). * `--ignored api_tokens` against fresh `:3300` harness (`BUZZ_GIT_CONFORMANCE_PROBE=false`): GREEN. * Mutate-bite `check_nip98_replay → noop` on `bridge.rs:79`, rebuild, restart: RED on the within-A second-POST assertion ("second POST to A with the same NIP-98 event MUST be rejected as replay (got 200 OK)"), `left: 200, right: 401`. Restored byte-identical, GREEN again. Base: PR #1321 head `ae703c5c8`. Test-only diff: zero lines in `buzz-db`, `buzz-relay`, or `buzz-auth` production code. Matrix 8/14. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
2ff2af6e4b |
test(conformance): fill channels_membership row — same channel UUID coexists in two communities
Conformance matrix row `channels_membership` (re-routed from Mari to Quinn at Eva's call — Mari's #1328 scroll-fix is still landing on main, and the row's substrate is the same same-UUID-in-two-communities shape I already used as the setup for `search_fts`). Fills the `pending_lane("buzz-db", ...)` stub at `crates/buzz-test-client/tests/conformance_multitenant.rs::mod channels_membership`. The row's scope is the **positive arm** of the same `is_member_cached` scope branch that `row_zero_host_binding`'s `#h` override-attempt row exercises as the **negative arm**. Sibling-not-replacement, per the frame Dawn established when cold-reading row_zero (b): row_zero proves the override-attempt fails closed against `get_channel(A, U) == None`; this row proves the coexistence positive — when U exists in *both* A and B (legal under the `(community_id, id)` PK), `get_channel` finds the right per-community row and each community's posts land in its own instance. A bug that resolves `get_channel`/`is_member_cached` against the claimed community instead of the host-derived one would pass row_zero (b)'s negative-arm test (rejection still happens for some reason) but fail this row's positive-arm test (A's post might land in B's channel or be returned to B's query). So this row catches a class of bugs row_zero (b) structurally cannot, even though both share the `is_member_cached` scope branch. Shape: 1. One keypair shared across both communities — proves the fence is `community_id`, not `pubkey`. 2. Same channel UUID `U` created in both A and B via REST kind:9007. 3. Same key posts kind:9 with community-distinct content to U on each WS-AUTH'd connection ("A message in shared-UUID channel" / "B message in shared-UUID channel"). Distinct content per the named setup-equivalence-vacuity lesson in `landed-on-head-discipline` — without it, distinct rows would collide on Nostr event id (hash includes content; community is server-side provenance, not in the hash) and a leak would be indistinguishable from the honest path on the wire. 4. REST `POST /query` with `{kinds:[9], #h:[U]}` against each host. 5. Each side: count == 1, content == own community's. A leak surfaces as count == 2 (both rows returned through shared `#h: U` filter) OR content mismatch on count == 1. Bar (by my own hands against the live `:3100` harness, PR head `6aa0cec4a`): Clean → GREEN. Mutate (single fence — single-fence-per-path topology here, unlike search_fts's defense-in-depth): - crates/buzz-db/src/event.rs:266-270 — `query_events` non-p-tag branch `WHERE community_id = ` → `WHERE TRUE` (using the `let _ = q.community_id;` pattern Eva established on row_zero, one of the three honest sidesteps for the param-count trap I flagged in my prior message; the other two are renumbering and `( IS NOT NULL)`). → RED on `hits_a.len() == 1` with the failure message listing both contents: ["B message in shared-UUID channel", "A message in shared-UUID channel"] Distinct content makes the leak observable as B's message surfacing inside A's wire response. Restore → diff empty → GREEN. Bar checklist: - `cargo fmt -p buzz-test-client -- --check`: exit 0. - `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean. - `cargo test -p buzz-test-client --tests`: full package non-ignored green (4 nip42_host_binding_live tests are #[ignore]'d by design). - Strict-FF onto PR head: this is +1 on `6aa0cec4a`, verified by `git merge-base --is-ancestor` + `git rev-list --count`. - Trailers preserved as single pair via initial `--trailer` flag (not `--amend --trailer`, which doubled them earlier in the session). - Live two-host harness on `:3100`: my clean (post-restore) binary, recipe per RESEARCH/CONFORMANCE_MATRIX_STATUS_2026-06-27.md v3. Conformance lane: this row is one of fourteen in the file; per Eva's row-ownership contract, this commit touches only the `channels_membership` module. No other rows modified. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
e8f42796ce |
test(conformance): fill users_profiles_nip05 row — kind:0 + NIP-05 lookup per-community
Conformance matrix row `users_profiles_nip05` (Quinn — buzz-search/auth
joint, both halves driven by Quinn per one-author-per-mod-block
discipline; Sami's active queue is `api_tokens_nip98_replay` per Eva's
batching). Fills both `pending_lane` stubs in the row.
Half 1: `same_pubkey_distinct_profiles_in_two_communities`
Same keypair publishes kind:0 (Metadata) on each host's WS-AUTH'd
connection with community-distinct content
(`{"display_name":"A profile"}` vs `{"display_name":"B profile"}`).
NIP-01 replaceable semantics: latest kind:0 per
`(community_id, pubkey)` is what subsequent queries return. REST
`POST /query` (using dev-mode `X-Pubkey` auth, which the
`BUZZ_REQUIRE_AUTH_TOKEN=false` harness allows) returns each host's
own kind:0 — never the other's. Distinct content per community is
load-bearing for the bite: identical content would collapse the leak
into setup-equivalence vacuity (Dawn's catch on `audit_log` —
identical Nostr event ids when (pubkey, created_at, kind, tags,
content) match — making the assertion blind to the wrong-row
substitution).
Half 2: `same_nip05_local_part_on_two_hosts_is_independent`
Same local-part registered in BOTH communities with **distinct**
pubkeys (one per community). `GET /.well-known/nostr.json?name=alice`
against host A returns A's pubkey; against host B returns B's pubkey.
Distinct pubkeys per community make the leak observable as
wrong-pubkey-returned on the wire — the same setup-equivalence-vacuity
defense Dawn established (different keys = different rows = the wrong
answer is observable in the response, not just absent from it). Handle
canonicalization uses `extract_relay_domain` (mirrors
`crates/buzz-relay/src/api/nip05.rs::extract_domain`) against
`RELAY_URL` env so the test still works if the harness's relay URL
changes; defaults to `localhost` for the standard recipe.
Bar (by my own hands against the live `:3100` harness, PR head
`b02d767f2`):
Clean → BOTH GREEN.
Mutate (community fences dropped, both paths simultaneously, mirroring
the search_fts dual-fence approach):
- crates/buzz-db/src/event.rs:267-270 — `query_events` non-p-tag
branch `WHERE community_id = $1` → `WHERE TRUE`
- crates/buzz-db/src/user.rs:185 — `get_user_by_nip05`
`WHERE community_id = $1 AND LOWER(handle) = LOWER($2)` →
`WHERE LOWER(handle) = LOWER($1)` (rebind to keep param count
aligned)
→ BOTH halves RED on their own assertions:
- kind:0 half: "B's kind:0 content is not B's profile — A's
profile leaked through. got: '{"display_name":"A profile"}'"
- NIP-05 half: "NIP-05 lookup on B for local-part 'alice_…' must
resolve to B's pubkey ($B_PK); got $A_PK. If this is A's
pubkey, the community fence on `get_user_by_nip05` has been
dropped and A's user leaked through B's lookup."
Restore both fences (worktree diff empty after restore) → BOTH GREEN.
Each half bit on a SINGLE-fence mutation this time, unlike search_fts's
defense-in-depth shape. That's because the kind:0 read path
(`query_events`) and the NIP-05 lookup path (`get_user_by_nip05`)
each have one community fence at their layer, not redundant fences
across two layers like the FTS+batch-fetch shape. Different rows have
different defense topologies; this row's mutate-bite is the simpler
single-fence form, exactly as the named failure-mode analysis in
`landed-on-head-discipline` rule #2 sub-bullet predicts (the union of
fences IS what makes the property load-bearing; here the union has
exactly one element per path).
Bar checklist:
- `cargo fmt -p buzz-test-client -- --check`: exit 0.
- `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean.
- `cargo test -p buzz-test-client --tests`: non-ignored 0/0 (the 4
nip42_host_binding_live tests are #[ignore]'d by design — they need
the live harness).
- Strict-FF onto PR head: this is +1 on `b02d767f2`, verified by
`git merge-base --is-ancestor` + `git rev-list --count`.
- Trailers preserved as single pair via the explicit `--trailer`
flags on the initial commit (not `--amend --trailer`, which doubled
them earlier in the session).
- Live two-host harness on `:3100`: my clean (post-restore) binary,
recipe per RESEARCH/CONFORMANCE_MATRIX_STATUS_2026-06-27.md, two
community rows seeded `a.localhost:3100`/`b.localhost:3100`.
Conformance lane: this row is one of fourteen in the file; per Eva's
row-ownership contract, this commit touches only the
`users_profiles_nip05` module. No other rows modified.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
|
||
|
|
b5d637d23b |
test(conformance): fill search_fts row — two-host NIP-50 isolation + delete
Conformance matrix row `search_fts` (Quinn, buzz-search). Fills the
`pending_lane("buzz-search", ...)` stub at
`crates/buzz-test-client/tests/conformance_multitenant.rs::mod search_fts`
with a two-host A/B-isolation shape: one keypair shared across A and B,
same channel UUID reused in both communities (legal under the
`(community_id, id)` PK), the *same* unique FTS token posted to each
community as kind:9 events but with **community-distinct content**.
NIP-50 search on each host must return exactly one hit carrying that
host's community's content; NIP-09 kind:5 delete in A leaves B's row
intact.
Bar (by my own hands against a live two-host relay on the conformance
recipe — Eva's `:3100` `relay-mt` harness, a/b.localhost, shared
PG/Redis, base PR head `bf8a1a4fa`):
Clean → GREEN.
Mutate (both community fences on the search read path, simultaneously):
crates/buzz-search/src/query.rs:160-161 (FTS WHERE community_id = $ctx)
crates/buzz-db/src/event.rs:870-872 (get_events_by_ids WHERE community_id = $1)
→ RED on `hits_a.len() == 1` with the failure message listing both
contents:
["A community probe ftsconf_…", "B community probe ftsconf_…"]
Restore both → GREEN.
Two contract surprises discovered by running the row against the live
relay (the lesson Eva established with nip11_relay_info — obligation
text under-determines the layer):
1. The community fence is doubly defended on the search read path: FTS
filters at the query layer, then `get_events_by_ids` re-filters at
the read layer. Mutating either fence alone keeps the
wire-observable property intact (the other defends). The honest
mutate-bite is to drop both simultaneously; that's what makes the
union load-bearing for the wire return. The test's doc comment
names both layers and explains why the single-layer mutation would
give a false-green.
2. With identical content in both communities, the Nostr event id is
the SAME byte string in both rows (id = hash(pubkey, created_at,
kind, tags, content); community is server-side provenance, not
serialized into id). Under a leak, the wire returns "the row
matching id" — which can be either community's row — and a count==1
assertion can't tell A's row from B's. Earlier iterations of this
test used identical content and discovered the hard way that
single-hit-with-other-community's-row is indistinguishable from
correct behavior at that assertion. Per-community-distinct content
(`"A community probe {token}"` vs `"B community probe {token}"`)
makes the leak observable: distinct content hashes to distinct ids
(different rows), and the assertion `hits[0].content == content_a`
pins which community's row came back.
Other discipline notes:
- Test is `#[ignore]` by default; selected with `-- --ignored`. Reads
two env vars: `RELAY_URL_A` / `RELAY_URL_B`, both addressing the same
relay process on different `Host` headers.
- Requires the two-host harness recipe (`BUZZ_HEALTH_PORT=8180
BUZZ_METRICS_PORT=9202 BUZZ_RECONCILE_CHANNELS=false
BUZZ_GIT_CONFORMANCE_PROBE=false`, two `communities` rows mapping
`a.localhost:3100` and `b.localhost:3100` to distinct community
UUIDs, one binary). Full recipe in the v2 dependency report at
`RESEARCH/CONFORMANCE_MATRIX_STATUS_2026-06-27.md`.
- Requires Sami's NIP-42 per-tenant relay-tag fix on PR head
(`bf8a1a4fa`) — without it, `BuzzTestClient::connect(&ws_a, &keys)`
fails AUTH on the non-configured host. The row was pre-positioned on
`809ff9faf` and rebased forward to PR head `bf8a1a4fa` exactly +1
commit; clean rebase (different file regions from
auth.rs/bridge.rs/nip42_host_binding_live.rs).
- Conformance lane: this row (`search_fts`) is one of fourteen in the
file; per Eva's row-ownership contract, this commit touches only the
`search_fts` module. No other rows modified.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
|
||
|
|
0e9e8583aa |
test(conformance): fill row_zero_host_binding (unmapped fail-closed + #h override-reject)
Replace both pending_lane stubs in row_zero_host_binding with live wire tests:
(a) unmapped_host_fails_closed_generically — drives an unmapped host over the
HTTP door (404) vs a mapped host (non-404): the status *difference* proves
no default-tenant fallthrough. Asserts the rejection body echoes neither the
host authority nor the bare label (generic, no enumeration oracle), and that
a raw WS upgrade to the unknown host is rejected at the handshake. Doc-
comment notes the mapped-200/unmapped-404 status difference is an
intentional, door-scoped distinguisher (non-nostr+json SPA/WS door only) so
a future reader does not "fix" it into a 404-everywhere that breaks the SPA
fallback; the nostr+json door deliberately does not expose it.
(b) client_supplied_community_cannot_override_host — creates an OPEN channel in
community B only, confirms it is postable in B (positive control), then posts
a kind:9 #h-tagging that B-only channel UUID over an A connection. A must
reject: the host-derived community wins over the client #h claim. Open
visibility isolates the override property from the ordinary membership gate
(the A-side post can fail for exactly one reason: the channel doesn't exist
in A's community). Asserts the rejection does not echo the B channel UUID
(no cross-community existence oracle).
Bite-specificity: the override assertion also pins the reason string
"restricted: not a channel member" (the exact IngestError::Rejected the
override path emits at ingest.rs:446), so the red means "A rejected because
the host-derived community refused the #h claim", not merely "A rejected
for some earlier-gate reason" (bind_community 404 / bridge-auth 403 /
NIP-98 replay / relay-membership 403 / JSON parse 400 all precede the
channel-scope branch). Two cold reviewers (Dawn, Mari) converged on this
independently from the sanitization and channels-membership sides.
Sibling-not-replacement: #h override-attempt is row_zero; the relay-tag /
token-u-host override signals are Sami's NIP-42 rows; same-UUID coexistence is
Mari's channels_membership row. Cross-refs documented in the doc-comments.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
|
||
|
|
c726103f73 |
test(conformance): audit_log isolation — doc-only row + integrated relay test + error sanitization
The audit log has no client-reachable wire surface: there is no /audit
route in the relay, and AuditError is never relayed to a client. A pure
black-box A≡B conformance row (the shape every other row in
conformance_multitenant.rs uses) is therefore impossible, and reaching
behind the wire into Postgres from that file would break its black-box
contract. So the obligation is proven across three honest homes:
1. Doc-only conformance row (conformance_multitenant.rs): cites the
no-wire-surface fact — a strictly stronger isolation claim than "the
oracle is denied" — plus the per-community-chain substrate and the two
executable proofs below.
2. Integrated relay test (buzz-relay handlers::event): drives
dispatch_persistent_event under two tenants against a shared Postgres
and asserts each community's audit chain contains only its own
object_id and verifies independently. Proves the
host→TenantContext→chain wiring keeps tenants isolated end-to-end.
No WS-AUTH in the loop, so it is not blocked on NIP-42.
3. Error-sanitization unit test (buzz-audit error): asserts no AuditError
variant's rendered text embeds a community_id, constraint name, or
cross-community object id, with a non-vacuous check that per-community
seq still appears.
Both runnable pieces mutate-bitten: a stale-tenant scoping bug reds the
isolation assertion ("B's event id appeared in A's chain"); leaking a
constraint name into an #[error] string reds the sanitization assertion.
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
|
||
|
|
0eddf10345 |
buzz-relay: bind NIP-42 AUTH relay tag to per-tenant host (row 44 sibling)
NIP-42 sibling of the NIP-98 host-binding fix in |
||
|
|
7469194042 |
test(conformance): nip11_relay_info — NIP-11 is host-agnostic, not a community-enumeration oracle
First real (non-pending_lane) conformance row in conformance_multitenant.rs; the reference pattern remaining rows copy. Asserts the wire-observable complement to the compile-time static-input fence (_RELAY_INFO_BUILD_STATIC_INPUT_FENCE): the NIP-11 relay-info document served for host A, host B, and an *unmapped* host are all byte-identical. Identical docs are the proof that the unauthenticated relay-info endpoint carries no host-derived field and therefore cannot be used to probe which communities are configured on a deployment. Corrects an initial design error caught by running the row against a live two-host relay: the unmapped-host case must return 200 with the same static doc, NOT 404 — a 200-vs-404 status difference between mapped and unmapped hosts would itself be the enumeration oracle. Fail-closed host binding lives on the WS-upgrade / non-nostr+json path (router.rs::nip11_or_ws_handler) and is the obligation of row_zero_host_binding, not this row. Adds url_unknown() helper (RELAY_URL_UNKNOWN) alongside url_a/url_b. Verified by hand against a live two-host relay (a/b.localhost:3100, shared PG/Redis): green -> mutate (leak request Host into the served description) -> red on the A==B assertion with a real wire diff, not a compile break -> restore -> green. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
efaad7e662 |
buzz-db: scope api_tokens lookups to community_id (Gap 2 / row 44)
Conformance row 44 obligates that API token lookups key on (community_id, token_hash), not on token_hash alone. The storage UNIQUE index `idx_api_tokens_hash` already enforces this as a *storage* guarantee — but the query side was filtering on `token_hash = $1` only, relying on uniqueness as load-bearing for tenancy. That's a structural gap: any future relaxation of the index (or an adversarial mint that landed two rows via a tx race) would let a token minted in community A authorize against a request bound to community B. This change closes the query-side gap. All eight Db API surface methods now take a `CommunityId` first parameter, and the underlying SQL adds `AND community_id = $N` (or includes the column on INSERT). The `create_api_token*` family additionally INSERTs into the `community_id` column, which it previously omitted — schema declares it NOT NULL, so those functions would have failed at runtime if invoked. They have no callers today (token mint is staged but not wired), but fixing them in the same diff un-rots the public API and prevents the next caller from hitting a runtime FK error. The only live caller is `crates/buzz-relay/src/api/media.rs::resolve_ upload_scopes`, called from the `AuthenticatedUpload` extractor. The extractor previously resolved scopes BEFORE binding the request's tenant via `bind_community`, so threading the community through would have been impossible — the tenant didn't yet exist. Reordered: row-zero tenant bind moves to step 4 (immediately after header validation), scope resolution to step 5 with `&TenantContext` in hand. The lookup in `resolve_upload_scopes` now calls `get_api_token_by_hash_including_ revoked(tenant.community(), &hash)`. Sharp regression test added at `api_token::tests::lookup_by_hash_is_ scoped_to_community` (#[ignore = "requires Postgres"]): inserts two same-hash tokens in two communities (legal under UNIQUE(community_id, token_hash)) and asserts each lookup returns only its own community's row, and that a third unrelated community returns None. Mirror test `active_lookup_by_hash_is_scoped_to_community` covers the `revoked_at IS NULL` variant on `Db::get_api_token_by_hash`. Mutate-bite proof (verified manually before commit): stripping `AND community_id = $1` from the WHERE clause fails the test with `community-B lookup must return B's row` — Postgres returns the first-inserted (A's) row when filtering on hash alone, as expected. Restored the clause and re-ran clean. Verification: - cargo build --workspace --tests: clean (1.95.0) - cargo test -p buzz-db -- --include-ignored --test-threads=1: 101/101 - cargo test -p buzz-relay -- --test-threads=1: 399 + 1/1 - cargo clippy --workspace --tests -- -D warnings: clean - Line-read final diff for tenant provenance: every binding correctly threads the request-resolved CommunityId; no client-supplied path. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
a1cd66c812 |
relay/auth: NIP-98 u-URL host is per-tenant, not config-global
Row 44 obligation closed: NIP-98 u-tag URL host must match req.community. Previously expected_url was built from state.config.relay_url (one static string per deployment), which under multi-tenant both (a) admitted events signed for community A's host at community B's connection, and (b) rejected every legitimate request whose community host wasn't the single configured one. Adds nip98_expected_url(config_relay_url, tenant, path): scheme from config (preserves ws/wss dev-vs-prod), host from tenant.host() (the same host row-zero bound from the request Host header). Swaps the three bridge call sites (submit_event, query_events, count_events). Removes the orphaned canonical_url helper. Tests: 4 new in api::bridge::tests covering helper unit (both directions of host substitution + scheme mapping) and verify_bridge_auth integration (cross-host rejection + matching-host acceptance). Mutate-bite verified: reverting the helper internals to config-global behavior turns all 4 new tests RED with the exact diagnostics they were designed to surface. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
ce406c7160 |
fix(search): restore privacy kind exclusions at the FTS storage layer
The Typesense→Postgres FTS rewrite replaced out-of-band indexing with
`search_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', content))
STORED` over every row. The old relay (handlers/event.rs:287 on main)
deliberately skipped search-indexing for three kind classes, and the new
search query layer has no kind exclusion — so gift wraps, DM-visibility
snapshots, and event reminders were all in the FTS index.
Fix at the storage layer (option A — single source of truth, zero
app-layer drift across multiple search call sites): make the generated
column yield `NULL::tsvector` for excluded kinds via a CASE expression.
A NULL tsvector never matches `@@`, so excluded rows are structurally
unsearchable.
Excluded set, parity with main's `handlers/event.rs:287-290`:
- 1059 KIND_GIFT_WRAP (NIP-17 ciphertext)
- 30300 KIND_EVENT_REMINDER (AUTHOR_ONLY_KINDS — defense in depth)
- 30622 KIND_DM_VISIBILITY (per-viewer private hide state)
Constants are inlined in the migration with a comment naming the
`buzz_core::kind` names: sqlx migrations are frozen SQL and can't
`use buzz_core::kind`; importing core into a migration would be worse
drift than the inline-with-comment shape.
Three coupled layers:
1. Schema CASE in migrations/0001_initial_schema.sql. The 0001 schema
was consolidated by Max in
|
||
|
|
4aa69f1d1b |
test(relay): red-team Attack 3 — same-pod regression + fail-closed proof
Adds two regression tests against Max's NIP-98 replay-guard wiring (b30869d44), proving distinct properties beyond his existing cross-pod test: 1. nip98_replay_guard_rejects_same_pod_same_community_replay (#[ignore], requires Redis) — single guard instance, A1 then A2 with the same TenantContext rejects the second call. Guards against a fix that accidentally weakens same-pod replay detection when moka is replaced with the shared Redis seen-set. 2. nip98_replay_check_fails_closed_when_guard_errors (CI-runnable, no Redis) — injects a stub Nip98ReplayGuard that always returns Err(AuthError::Internal(_)); asserts check_nip98_replay_with_guard maps it to (401, error="NIP-98: replay check unavailable"). Exercises the Err => arm of the match (bridge.rs:107-117) which is otherwise untested. This is the load-bearing fail-closed property: a stateless worker that loses Redis MUST reject rather than admit (Nip98ReplayGuard trait contract, buzz-auth/src/nip98_replay.rs:70-73). Mutate→red→restore (two orthogonal bites, both verified): - Mutate Ok(false) => Err(...) → Ok(()) at bridge.rs:103. Reds Max's cross_pod test AND same_pod_replay; fail_closed stays green (doesn't exercise this arm). Restored. - Mutate Err(e) => Err(api_error(...)) → Ok(()) at bridge.rs:113. Reds ONLY fail_closed; cross_pod and same_pod stay green (don't trigger Err). Restored. Distinct mutations bite distinct tests — proves the new properties are load-bearing, not vacuous, and independent of Max's coverage. Verification on this commit: - cargo fmt --all -- --check ✅ - cargo clippy -p buzz-relay --all-targets -- -D warnings ✅ - REDIS_URL=… cargo test -p buzz-relay --lib \ api::bridge::tests::nip98_replay -- --include-ignored \ --test-threads=1 → 3/0 ✅ (Max's + 2 new) - cargo test -p buzz-relay -- --test-threads=1 → 390/0 + 2 ignored ✅ (baseline 389/1 ignored; +1 passing fail-closed, +1 ignored same-pod) Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> (cherry picked from commit 64a94bec901733ee87a84cbb4938e1180a5ca289) |
||
|
|
09b56818d7 |
test(buzz-db): pin communities_of_channels missing-channel-absent contract
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>
|
||
|
|
f190bd41b9 |
fix(relay): share NIP-98 replay guard via Redis
Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
215a82c7f8 |
feat(relay): emit read-row trace steps with (B) projection
Land the read-seam emitter for the runtime conformance gate. Two
emit sites, one buzz-db helper, one negative fixture.
## buzz-db: `communities_of_channels` helper
`Buzz::communities_of_channels(&[Uuid]) -> HashMap<Uuid, CommunityId>`
— batched per-channel community lookup. Used by the relay emitters to
project each row's true community label independently of the fetch
query's WHERE clause. That independence is what makes the
`Inv_NonInterference` / `Inv_ReadConfinement` bite non-vacuous: a
mutation dropping `community_id = $X` from `query_events` would
still let this helper return the row's true label and the checker
would catch the mismatch.
Channels missing from the result map are intentionally NOT mapped to
a default — callers MUST treat "channel-id not in map" as a coverage
breach, never as "use the resolved community."
## buzz-relay: projection + record helpers
`crate::conformance` gains four new items:
- `project_row_community` — single-row helper encoding the (B)
strategy: channel-less → resolved (honest, not tautological);
channel-scoped → lookup or `None` (caller fails closed).
- `RowCommunityProjection` enum — Ok(Vec<CommunityLabel>) OR
MissingLookup discriminated outcome.
- `record_read_message_rows` — non-search lane: emits
`ReadMessageRows` on Ok projection, `ImplBug { kind:
"row_community_lookup_missing" }` on MissingLookup.
- `record_read_by_id_rows` — search lane companion, same shape but
emits `ReadByIdRows`. `filter_channel` is `None` for the search
lane (search at the abstract level isn't bound to a single channel;
per-row `channel_id` carries channel identity honestly).
## req.rs wire-up: two emit sites
- Site 1 (`req.rs` non-search loop, after `query_events`): collect
distinct channel ids from the result set → `communities_of_channels`
→ `record_read_message_rows`. Production cost: one extra DB query
per request (NoopTracer short-circuits in non-conformance builds).
- Site 2 (`req.rs` search loop, after `get_events_by_ids`): same
pattern → `record_read_by_id_rows`. `handle_search_req` gains a
threaded-through `trace_state: Option<&AbstractState>` parameter.
DB-helper errors on either site fall back to an empty lookup map.
This intentionally triggers `MissingLookup` → `ImplBug` for any
channel-scoped row in the result set, surfacing the helper failure
as a coverage breach (fail-closed) rather than a silent resolved-
label substitution.
## Negative fixture: foreign-row leak
New `bad_foreign_row_leak.jsonl` + matching test. The fixture is a
`ReadMessageRows` whose row_communities contains community B while
the state is bound to community A. This is the proof artifact Eva
requested for the (B)-projection guard-rail: if the row had been
mis-projected as channel-less (defaulting to resolved A), the subset
check would have passed vacuously. By recording the row's TRUE
community independently, `Inv_NonInterference` surfaces it
immediately as `NonInterference`.
## Unit tests (conformance::tests)
Five new tests pinning every behavior:
- `project_row_communities_channelless_uses_resolved` (positive)
- `project_row_communities_channel_scoped_uses_lookup_label` (the
non-tautological correctness — lookup label, NOT resolved)
- `project_row_communities_channel_scoped_missing_is_breach` (the
guard-rail bite)
- `record_read_message_rows_missing_lookup_emits_impl_bug`
- `record_read_by_id_rows_ok_emits_read_by_id_rows`
## Mutate → red → restore (three independent bites)
1. Make `project_row_community` fall back to resolved on missing-
lookup (the tempting wrong-fix): `project_row_communities_channel_
scoped_missing_is_breach` + `record_read_message_rows_missing_
lookup_emits_impl_bug` go red with explicit messages
("missing lookup must be a breach, got Ok([...])", "expected
ImplBug coverage breach, got ReadMessageRows {...}"). Restored.
2. Make every channel-scoped row project to resolved (the
tautological projection): 4 of 9 unit tests go red — the lookup-
label, missing-breach, and record-helper tests all bite. Restored.
3. Edit the negative fixture to use community_a instead of
community_b: `foreign_row_leak_is_non_interference` reds ("foreign
row community label must be rejected by Inv_NonInterference"). The
fixture is load-bearing, not decorative. Restored.
## Test surfaces
- `cargo test -p buzz-relay --lib` → **394/0** (was 387 baseline).
- `cargo test -p buzz-conformance --lib` → 9/0.
- `cargo test -p buzz-conformance --test replay_fixtures` → **6/0**
(was 5; +foreign_row_leak).
- `cargo test -p buzz-db --lib` → 75/0 (helper compiles; DB-driven
integration coverage lives in `--include-ignored` lane on PG).
- `cargo clippy -p buzz-relay -p buzz-db -p buzz-conformance
--all-targets -- -D warnings` clean.
- `cargo fmt --all -- --check` clean.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
|
||
|
|
036e3eee30 |
feat(relay): emit AuthCheck on REQ membership decision
Wire the conformance read seam at `req.rs` channel-membership
confirmation. When the relay falls through to the DB-uncached
membership check, record one `AuthCheck` step on the tracer
mapping `is_member` → `Allow`/`Deny`.
Design notes:
- `trace_state` is built once at request entry, after `pubkey_bytes`
is available. Reused by every downstream emit (matches ingest's
`state_for_request` discipline). The `Option` only goes `None`
on malformed pubkey bytes — a separate failure path.
- `claimed_community: None` is the load-bearing choice on the read
path: the REQ wire has NO client-asserted community (the `h` filter
is a channel-id, not a community-id). Encoding `None` here rather
than copying the resolved community means a future regression that
ever starts reading a wire-community on REQ would need to put a
real value in the field — that surfaces at code-review time
instead of silently projecting away the M2 (claim ≠ resolved) bite.
- No `EmitGuard` at REQ entry: read paths legitimately skip the DB
on cache hit (no `is_member` call), so a coverage-breach guard
would false-positive on the common case. Coverage for the read
seam comes from the upcoming row-emit fixtures, not from a guard
at the entry point.
Implementation:
- New `crate::conformance::record_req_authcheck` helper centralises
the emit so the call site stays one line and the helper carries
the design rationale in its doc comment.
- Two unit tests pin the verdict mapping table:
`record_req_authcheck_emits_allow_with_none_claim_when_member` and
`record_req_authcheck_emits_deny_when_not_member`. Mutate→red→
restore verified: inverting the `if member` branches reds both
tests with explicit panic messages ("member=true must map to
Allow", "member=false must map to Deny"); restored both green.
Test surfaces:
- `cargo test -p buzz-relay --lib` → 389/0 (was 387 baseline).
- `cargo test -p buzz-conformance` → 14/0 unchanged.
- `cargo clippy -p buzz-relay --all-targets -- -D warnings` clean.
- `cargo fmt --all -- --check` clean.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
|
||
|
|
473075561e |
test(relay): EmitGuard coverage-breach self-test
Two unit tests in `crates/buzz-relay/src/conformance/mod.rs` that prove the structural fail-closed property of the [`EmitGuard`]: - `emit_guard_drop_records_exactly_one_impl_bug_when_no_emit` — drop the guard with zero recorded steps on the returned counting tracer → exactly one `ImplBug` step lands on the inner tracer, carrying the seam-name string passed to `EmitGuard::arm`. - `emit_guard_drop_is_silent_when_an_emit_reached_the_tracer` — record at least one step through the counting wrapper → Drop emits no `ImplBug`, only the original step. These pin the counting-tracer design against a future refactor to a "disarm" flag, which would only fail-close by author discipline. The counting wrapper makes coverage breach fire *structurally* regardless of what the request path did or didn't do — that's the whole point of the coverage-breach mode. Verify: `cargo test -p buzz-relay --lib conformance::` → 2/2. Full `cargo test -p buzz-relay --lib -- --test-threads=1` → 387/0. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
92f65ae0f2 |
feat(relay): wire conformance emitter into ingest seam
Add `crates/buzz-relay/src/conformance/` module:
- `Tracer` re-export, `NoopTracer` (production), `JsonlTracer` (test/CI).
- `EmitGuard::arm(tracer, state, kind) → (guard, counting_tracer)`:
RAII coverage breach. The guard wraps the original tracer in a
counting layer; production callers transparently use that wrapper.
If no emit reaches the wrapper before the guard drops, the guard
emits a synthetic `ImplBug` step on the underlying tracer — the
checker treats that as CoverageBreach. The wrapper design means
production paths never need to "disarm" or pass anything around;
a future new exit that forgets to emit will be caught
automatically.
- `state_for_request(tenant, actor)`: builds AbstractState. Pulls
community + host directly from server-resolved TenantContext.
- `claimed_community_from_event`: reads the event's h tag for the
trace's `claimed_community` field — recorded SEPARATELY from
`resolved_community` so M2 (claim≠resolved) and M8 (host/channel
disagreement) bite at the checker.
- `sanitized_reason_for(&IngestError) → SanitizedReason`: 1:1 map
of IngestError variants (Rejected/AuthFailed/Internal) onto the
closed SanitizedReason alphabet (Invalid/Restricted/ServerError).
Adding a fourth IngestError variant breaks this match — CI catches
it before it ships.
Emitter wiring in `crates/buzz-relay/src/handlers/ingest.rs`:
- `ingest_event` is now a thin wrapper: arms EmitGuard, calls
`ingest_event_inner`, and on Err maps to SanitizedError. All 36
early-Err returns and 6 Ok returns in the inner fn are covered
by this single outer mapping.
- At `check_channel_membership` call site (line 1401): emits
`AuthCheck { channel, claimed_community, verdict }` with
Allow on Ok, Deny on Err. The verdict basis is `tenant.community()`
server-resolved — confirmed at ingest.rs:424's `is_member_cached`
call signature (not event-derived).
- At each `dispatch_persistent_event` call site (lines 1908, 2014):
emits `WriteInsert` (channel + was_inserted=true), `WriteDuplicate`
(channel + was_inserted=false), or `WriteInsertGlobal` (no channel).
This is the entire write side of the ingest seam.
`crates/buzz-relay/src/handlers/event.rs` `dispatch_persistent_event`:
- No emit added. Documented why inline: the spec has no separate
fan-out action — acceptance is recorded at ingest's WriteInsert;
fan-out surfaces as ReadMessageRows on the subscriber side (the
read seam in req.rs, lands in the held-back additive diff).
AppState carries `tracer: Arc<dyn buzz_conformance::Tracer>`,
defaulting to NoopTracer (zero cost). Test contexts overwrite this
field with a JsonlTracer after construction.
Verify:
- cargo check -p buzz-relay green
- cargo test -p buzz-relay --lib: 378/378 (no regressions)
- cargo test -p buzz-conformance: 9/9 (checker still bites all four
failure modes)
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
|
||
|
|
caae4cdbb8 |
feat(conformance): runtime trace schema + independent replay checker
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>
|
||
|
|
7f191ccb1e |
fix(relay): clear clippy on integrated multi-tenant stack
The H1 fanout fix threaded a server-resolved CommunityId through the connection registry and two membership caches, tipping three clippy lints: register() to 8 args (too_many_arguments) and the moka cache keys to type_complexity. Allow both locally with rationale, matching existing repo convention (observer_owner_cache already carries the same allow in this file; buzz-db/buzz-cli use too_many_arguments allows). Also relocate topic_for_subscription above the req.rs test module to clear items_after_test_module surfaced by --all-targets. No behavior change; buzz-relay 385/0, clippy --all-targets -D warnings clean. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
5f03ea42c3 |
fix(relay): scope DM command writes by community
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> |
||
|
|
a7a2270216 |
fix(relay): bind fanout to receiver community
Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
9b0bd814ce |
fix(relay): fail closed on empty/whitespace host in bind_community
The host-binding seam (Inv_RowZero) must derive a community only from a
request's actual Host. An empty raw_host carries no community evidence,
yet bind_community passed it straight to the resolver. The schema does
not forbid an `host = ''` row in communities (0001_initial_schema.sql:
`host VARCHAR(255) NOT NULL`, unique index on `lower(host)`), so a
misconfigured/empty-host row plus a request with a missing, unreadable,
or whitespace-only Host header would silently bind to that community.
Guard before the resolver lookup: if normalize_host(raw_host) is empty,
return BindError::UnmappedHost. Reuses the existing variant so the
rejection is byte-identical to any other unmapped host — an
unauthenticated caller cannot probe whether an empty-host row exists.
Red-team finding (Attack 2, MEDIUM defense-in-depth) by Sami; her three
proof tests (empty, whitespace-only, plus a non-empty negative control)
are included un-ignored. Verified mutate->red->restore: removing the
guard reds both empty-host gates with the literal fence collapse
(TenantContext{community: X, host: ""}). Full cargo test -p buzz-relay
--lib: 381 passed, 0 failed.
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
|
||
|
|
fb0d6a4ea0 |
fix(relay): use deployment tenant binding for startup membership
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> |
||
|
|
139d6dbda5 |
fix(relay): preserve relay URL port for deployment binding
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> |
||
|
|
3e57144b48 |
fix(relay-mt): clear clippy -D warnings introduced by tenant threading
CI Rust Lint + Windows Rust run `cargo clippy --workspace --all-targets -- -D warnings`; the community_id/tenant args pushed six fns to 8/7 and the new NIP-98 replay code tripped clamp/const-assert lints. Resolve at the bar, matching existing repo conventions: - 6x #[allow(clippy::too_many_arguments)] on the fns that gained a tenant/community arg (same convention already used across buzz-db/relay). - buzz-pubsub replay TTL: .max().min() -> .clamp() (floor 120 < ceiling 3600, cannot panic; behavior identical, incl. the u64::MAX clamp test). - buzz-auth replay const-drift tripwires: scoped #[allow(clippy::assertions_on_constants)] — the assert-on-constant IS the design (fails if someone drifts the TTL constants). Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> |
||
|
|
a8a9dd9e1a |
feat(relay): wire relay + admin call sites to community-scoped v3 API
Threads server-resolved `community_id`/`TenantContext` through the whole relay call graph and the operator CLI against the v3 DB/pubsub API, so every scoped row read and every Redis publish names a community the relay derived from data, never from caller input. Relay (`crates/buzz-relay`): - Read-path caches take `CommunityId`; write/invalidate publishers take `&TenantContext` (the Redis topic key needs the host). The cross-node fan-out path only has the community, so caches stay constructible there. - Doors fail closed: WS/bridge/media/NIP-05 bind community from the request host via `bind_community`, falling through to an empty/404 response on an unmapped host — no default tenant, no host echo. - Background loops get tenant from the DB row they act on: the reaper builds `TenantContext::resolved(row.community_id, row.host)` per archived channel from the reaper RETURNING; the dev/CI reconciler and reminder scheduler resolve the one configured community from `relay_url`, fail-closed. - Deployment-community cases with no connection tenant (git hook/finalize, workflow sink) resolve via the same host-resolution seam. - Drop the Typesense-only `reindex_kind0` backfill binary, obsolete under the Postgres FTS migration and referenced nowhere. Admin CLI (`crates/buzz-admin`): - New `resolve_admin_tenant` reads `RELAY_URL` host (the CLI runs `compose exec relay buzz-admin`, sharing the relay's env) and resolves it via `lookup_community_by_host`, fail-closed on an unmapped host. - Scope the NIP-43 membership-list publish (`EventTopic::Global`), channel reconcile, `get_members`, and the kind:39000 existence `EventQuery` (`..EventQuery::for_community`). Drop the now-dead `uuid` dep. Workspace gate: `cargo check --workspace` green; buzz-db 97/97, buzz-audit 13/13, buzz-relay 375 + main 1 (`--include-ignored --test-threads=1`), buzz-admin compiles, fmt + buzz-admin clippy clean. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> |
||
|
|
031b84e255 |
fix(db): scope archived identities by community
Archived identity state is tenant-local; a pubkey archived in one community must not read as archived in another. Thread CommunityId through the archived identity queries and DB wrappers, and bind the composite key used by the migration. Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> |
||
|
|
ec22cdb117 |
feat(relay): RelayInfo::build static-input fence (conformance NIP-11 row)
The conformance obligation for the NIP-11 surface: RelayInfo::build must not grow unscoped DB/search/audit inputs, so an unauthenticated NIP-11 read can never become a cross-community enumeration oracle. Binds RelayInfo::build to its exact allowed signature via a const fn pointer. Adding a &Db / &AppState / search / audit input makes the function-pointer type stop matching and breaks the build at the fence — a silent cross-tenant leak becomes a hard compile error, deny-lint style. Adversarially proven: injecting a &AppState param into build() produces error[E0308] mismatched types at the fence const (plus E0061 at the call sites); reverted to confirm the fence, not the call sites alone, is the guard. buzz-relay package 374 green at --test-threads=1. (cherry picked from commit 76a4044c7cfb1c96a6817be1e81c7ae42d1ea3da) Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> |
||
|
|
787774f3b4 |
test(conformance): multi-tenant A/B isolation harness skeleton
Executable form of docs/multi-tenant-conformance.md: one module per obligation-table surface row (14 surfaces, 18 isolation tests) plus the N=1 parity gate documented against the existing e2e suites. Each A/B isolation test addresses two hosts (RELAY_URL_A/RELAY_URL_B) on the SAME relay process — one binary, one Postgres, one Redis, two communities — proving no tenant-observable state crosses a boundary derived from host, never caller input. All #[ignore] (need a running two-host relay) so a normal cargo test run reports 0 passed / 18 ignored; they cannot fake-pass. Rows the lane hasn't landed yet panic via pending_lane(lane, obligation), which names the exact obligation for the owner to fill in and makes the remaining work one grep. Lane ownership tagged per module. (cherry picked from commit 9d6d35f07a17fcf5ccd8a6f20fdede3349e67024) Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> |
||
|
|
be56652d63 |
feat(relay): huddle-audio-unavailable guardrail under horizontal scaling (§5b)
Plan §5b, decided by Tyler: rather than sticky-route huddles or ship a
silent split-room, a horizontally-scaled deployment surfaces a clear,
client-handleable unavailable signal on huddle join.
- config: huddle_audio_available bool, env BUZZ_HUDDLE_AUDIO_AVAILABLE.
Defaults true so single-pod (N=1) deployments keep today's huddle
behavior unchanged. Operators running multiple relay pods set it false.
- audio handler: after auth + membership pass and BEFORE get_or_create joins
a room, if huddle_audio_available is false we send
{type:error, code:huddle_audio_unavailable, message:...} and return — no
silent room join whose frames never cross pods.
Why a config flag and not pod-count self-detection: the relay can't reliably
count its own pods; an explicit operator flag is the honest model and keeps
the §4 fork-B (any-pod-any-connection) generic routing free of huddle
stickiness. The real fix is the out-of-relay media/SFU service (Tyler's
long-term target), out of scope for this rewrite.
Tests: default-true (N=1 compat) and env-false-disables, both green. Full
buzz-relay --lib green at --test-threads=1 (374). Note for this lane: there
is a pre-existing parallel-run env-var race (global_presence_pubsub test
calls Config::from_env without the config tests' ENV_MUTEX guard) — not a
regression from this change; flagged to fix in the wiring lane.
(cherry picked from commit cc2bc29d4429da9e1a3e80217936340a4c1ca721)
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
|
||
|
|
c6ec9a33fe |
feat(relay): row-zero host-binding seam (HostResolver + fail-closed bind)
Conformance row zero: req.community = resolve_host(connection.host), bound before any handler observes tenant data. This lands the relay-side seam: - HostResolver trait (native async fn, no async-trait dep) — buzz-db's Db::resolve_host satisfies it; the relay depends on the trait, not the query, so the binding is testable without a database. Callers are generic over R, no dyn dispatch (the relay holds a concrete Db). - bind_community(): normalizes the host with the one shared rule, resolves it, and fails closed on BOTH unmapped host AND lookup error — there is no path that yields a default/fallback community. UnmappedHost is a distinct variant the call site turns into a GENERIC reject (no host echo, no unmapped-vs-error distinction) so an unauthenticated caller can't probe which hosts exist. - TenantContext carries the normalized host, so downstream NIP-05/audit labelling and the NIP-98 u-host check all see the canonical form the community was resolved from. Tests (4, green) cover known-host bind, variant normalization (case/dot/ default-port can't split a tenant), unmapped fail-closed, and lookup-error fail-closed-not-default. Adversarially verified: mutating the None arm to fall through to a nil default community turns unmapped_host_fails_closed RED. Seam contract for the buzz-db lane (Mari): Db::resolve_host(&self, normalized_host: &str) -> Result<Option<CommunityId>, DbError>, a SELECT id FROM communities WHERE host = $1 on the normalized key. Router call site (nip11_or_ws_handler) + threading TenantContext through handle_connection land next in this lane. (cherry picked from commit 0be8532e0e94e5ecd6529f2f3f52255dd36f6009) Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> |
||
|
|
fec1834caf |
feat(audit): widen NewAuditEntry.community_id to CommunityId
Make the provenance fence visible in the type signature, not a per-call-site convention. `NewAuditEntry.community_id` becomes `CommunityId` (the server-resolved newtype) instead of a raw `Uuid`, so a wiring call site can no longer pass an arbitrary UUID off the event/channel being acted on — the only doors to a `CommunityId` are host resolution or a server-scoped DB row, never client input. The DB-row type `AuditEntry` stays `Uuid`: sqlx reads/writes it directly and `compute_hash` does `.as_bytes()` on it, so the stored hash bytes are byte-for-byte identical and the already-integrated chain stays valid — no migration, no re-hash. The `as_uuid()` dereference moves inside `AuditService::log` at the DB boundary, where the column is written; the advisory-lock key is unchanged (CommunityId's Display delegates to Uuid). Drop the now-orphaned `Serialize`/`Deserialize` derive (and the `#[serde(default)]` on `detail`) from `NewAuditEntry`: it has no serde consumer — it travels only through the in-process audit sink (mpsc), never a wire/DB boundary. Keeping it non-deserializable reinforces the fence: no client blob can mint a NewAuditEntry. Full package green (13/13, incl. the 6 PG isolation tests and the community_id_is_part_of_identity fence); clippy -D warnings + fmt clean. Adversarially verified the fence is non-vacuous: dropping community_id from compute_hash turns community_id_is_part_of_identity RED, restored. (cherry picked from commit 284cc699b2b04c9078456ffda849315fb4562763) Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> |
||
|
|
81b064fb19 |
feat(audit): per-community hash chain on the frozen audit_log DDL
Convert the audit log from one global hash chain to an independent
per-community chain, conforming to the frozen Lane-0 0001 schema.
- Collapse to one DDL: delete schema.rs / AUDIT_SCHEMA_SQL and their
lib.rs exports. The 0001 migration is the sole owner of audit_log.
- Chain shape: PK (community_id, seq), seq monotonic per-community,
UNIQUE (community_id, hash); hash/prev_hash/actor_pubkey as BYTEA;
object_id TEXT generalizes the old event_id/channel_id; detail JSONB.
- community_id is folded into the SHA-256 (leads the hash) so a row
cannot be lifted out of one community's chain and re-verified in
another. Per-community advisory lock — communities never serialize
each other's audit writes (no throughput bottleneck, no timing oracle).
- verify_chain / get_entries scoped to a CommunityId.
- Error variants carry only per-community seq (meaningless without its
chain) — never community_id, hash values, or raw action strings.
- AUTH-body protection becomes caller discipline + the AuditAction enum
(AuthSuccess/AuthFailure carry outcome metadata, never the token);
the dropped event_kind column is not persisted.
13/13 green (7 unit + 6 Postgres isolation). Adversarial: disabling the
community_id line in compute_hash turns community_id_is_part_of_identity
RED (two communities hash identically); restored to green.
(cherry picked from commit
|
||
|
|
38708b84ad |
rewrite(search): ChannelScope enum closes ChannelLessOnly fence hole
The legacy 2x2 `(channel_ids: Option<Vec<Uuid>>, include_channel_less: bool)` shape could not unambiguously express "channel-less events only" — both `Some(vec![]) + true` and `None + true` fell into the no-constraint branch, silently broadening to all community channels rather than restricting to `channel_id IS NULL`. That matched the legacy Typesense `channel_id:=__global__` sentinel one way (per-channel + global) but not the other (global only).
Replace with a single `ChannelScope` enum whose four variants are 1-to-1 with the legacy `(accessible_channels, include_global)` matrix:
- non-empty + true -> ChannelsOrChannelLess(accessible)
- non-empty + false -> Channels(accessible)
- empty + true -> ChannelLessOnly (the variant the old shape could not express)
- empty + false -> caller short-circuits to EOSE, doesn't call search
Emitted SQL fragments are byte-identical to the legacy match for the three carry-over cases; `ChannelLessOnly` adds `AND channel_id IS NULL` — the fence the old type could not express.
Verification:
- Full package `cargo test -p buzz-search -- --include-ignored --test-threads=1`: 9/9 green (8 existing + 1 new `channel_less_only_excludes_per_channel_events`).
- Adversarial mutation: replaced the `ChannelLessOnly` SQL emission with a no-op (the buggy semantic the old shape produced); new test went RED with 3 hits instead of 1, restored, green again. The fix is the emitted predicate, not the variant name.
- clippy -D warnings clean; fmt clean.
- Empty-vec edge cases are intentionally not special-cased: `Channels(vec![])` emits `channel_id = ANY('{}')` (false-for-all, zero hits, preserves the old early-return semantic via SQL); `ChannelsOrChannelLess(vec![])` is equivalent to `ChannelLessOnly`.
Coordinated with Eva ahead of relay-wiring sweep at req.rs and bridge.rs so call sites land against the final type, not the buggy one.
(cherry picked from commit
|
||
|
|
c2477ba34f |
rewrite(search): Postgres FTS, community-scoped, drop Typesense
The Lane-0 freeze landed `events.search_tsv TSVECTOR GENERATED ALWAYS AS
(to_tsvector('simple', content)) STORED` + `GIN (search_tsv)` directly in
the schema. With that in place the entire Typesense apparatus is dead
weight: there is nothing to index out-of-band, no consistency window to
reason about, no client-forgeable index/content drift. Indexing is the
SQL write.
This rewrites `crates/buzz-search/` from scratch around that:
- `query.rs`: one SQL builder. `community_id = $ctx` is the first
predicate of every executed statement and is unconditional —
`SearchQuery` requires a `CommunityId` at the type level (no
construction path omits it). `search_tsv @@ websearch_to_tsquery(...)`
is the FTS predicate; `ts_rank_cd DESC, created_at DESC, id` is the
order. Channel scope replaces today's `__global__` sentinel with
`channel_id IS NULL`. Empty query short-circuits without a roundtrip.
- `lib.rs`: thin `SearchService { pool }`. Takes `&PgPool` directly so
the crate stays a leaf — no buzz-db dependency. Re-exports
`CommunityId` for callers that need to mint the fence.
- `error.rs`: collapsed to one variant (`Db(sqlx::Error)`); empty
queries are not errors.
- Deleted `collection.rs` and `index.rs` (Typesense HTTP client and
indexer). Dropped `reqwest`/`serde`/`serde_json`/`chrono`/`nostr`
from `Cargo.toml`.
- Added `tests/fts_integration.rs` — 8 integration tests against real
Postgres, each on its own throwaway schema applying the frozen
`migrations/0001_initial_schema.sql` via `include_str!`. The
load-bearing one is `search_does_not_return_other_community_events`:
mutating the `community_id = $ctx` predicate to `1=1` makes that
test go red (verified, then reverted) — the fence bites where it
has to.
Conformance row 50 — search re-auth and one-shot NIP-50 — is unchanged
in shape: the relay refetches canonical events per hit through buzz-db's
scoped fetcher and runs the access predicate. Search is never the
access boundary; this crate just returns candidate ids. The row's
Typesense prose rewrite is owned by Eva's integration lane (one writer
per path).
EXPLAIN ANALYZE evidence on a 200k-row community confirms the planner
picks `Bitmap Index Scan on events_p<...>_search_tsv_idx` for the
populated partition (full plan in RESEARCH/SEARCH_LANE_FTS_EXPLAIN.md
in the workspace). Single-column `GIN (search_tsv)` is sufficient at
this scale — no `btree_gin` needed (Max's caveat holds).
Cross-lane removals owed to Eva (relay-wiring lane, not this commit):
- relay state.rs: remove `search_index_tx` mpsc + worker
- relay main.rs: remove `search.ensure_collection()` call
- relay handlers/event.rs: remove `search_index_tx.send()`
- relay api/bridge.rs::handle_bridge_search: rewrite to new API
- relay handlers/req.rs::handle_search_req: rewrite to new API
- relay handlers/req.rs::build_search_channel_scope_filter: delete
- relay bin/reindex_kind0.rs: delete
- docker-compose.yml: drop typesense service + volume
- docs/multi-tenant-conformance.md row 50: rewrite Typesense prose
Tests: `cargo test -p buzz-search --test fts_integration --
--include-ignored --test-threads=1` — 8 passed, 0 failed.
Clippy: `cargo clippy -p buzz-search --all-targets -- -D warnings` — clean.
(cherry picked from commit
|