`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>
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>
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>
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>
Add EventQuery::for_community so relay call sites can keep concise
struct updates without restoring a tenantless Default. The constructor
requires the server-resolved CommunityId and preserves the old optional
filter defaults everywhere else.
Return the owning community host from the ephemeral-channel reaper by
joining communities in the archive UPDATE. Reaper consumers can now build
TenantContext per archived row from DB-resolved community+host instead of
hoisting or forging a batch-level tenant.
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>