Land the auth fences from buzz-auth/buzz-pubsub into the relay's request
paths, replacing the per-pod moka NIP-98 cache that does not carry the
freshness proof under any-pod-any-connection (bus-scoping B).
- AppState: add Arc<RedisNip98ReplayGuard> + Arc<RedisRateLimiter>,
constructed from the existing redis_pool. Drop the dead nip98_seen moka
cache (its only caller was the bridge replay check).
- bridge.rs: check_nip98_replay is now async + tenant-scoped, calling the
shared seen-set's try_mark under the resolved community (the seen-set is
community-scoped per S1). Resolve the tenant BEFORE the replay check in
all three NIP-98 handlers; DRY the duplicated host-resolve into
resolve_request_tenant. /count now resolves a tenant (required for the
per-community replay check). Replay and invalid responses are
wire-indistinguishable (Quinn P2) and fail closed on Redis error.
- router.rs: per-IP connection fence (check_ip_connection) runs in the WS
upgrade path BEFORE host resolution, so an unmappable Host cannot bypass
the cap. Operator-global, tenant-free; fail-closed -> 429.
- config.rs: BUZZ_MAX_CONNECTIONS_PER_IP (default 60) +
BUZZ_IP_CONNECTION_WINDOW_SECS (default 60).
cargo test -p buzz-relay: 378 passed / 0 failed. clippy clean (one
pre-existing unrelated warning in side_effects.rs).
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Integrates Sami's auth lane (15dd8dfc3) into relay-wiring: community-scoped
rate_limit_key, RedisNip98ReplayGuard, and the IpConnections fence primitives.
Brings RateLimiter + Nip98ReplayGuard into the workspace so the relay can wire
the IP-fence-before-host ordering and the NIP-98 verify-then-mark call site.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
* origin/rewrite/auth-tenant-scope:
feat(auth): NIP-98 replay seen-set — shared, community-scoped, atomic
feat(auth): community-scope RateLimiter pubkey quotas
The dynamic refcounted Redis subscriber (Max's pubsub seam) only SUBSCRIBEs
a community-scoped topic once a pod declares local interest via retain_topic,
and debounce-UNSUBSCRIBEs on the last release. Until now nothing drove those
calls from real subscription activity, so cross-pod delivery only worked via
a manual retain in the presence test setup.
The SubscriptionRegistry stays synchronous and tenant-agnostic — it does not
grow a PubSubManager or TenantContext. Instead each mutator now reports the
routing topic(s) it added/removed:
- register -> Option<EventTopic> displaced by an in-place sub_id replace
- remove_subscription -> Option<EventTopic> removed
- remove_connection -> Vec<EventTopic>, one per removed sub
The call sites — where &conn.tenant and state.pubsub already live — apply the
delta: retain the new topic on REQ, release on CLOSE / connection drop /
channel-access revocation. The pubsub manager's own (community, topic)
refcount collapses N releases to one debounced UNSUBSCRIBE and keeps two
communities sharing a channel UUID isolated, since the community is bound at
the call site via ctx. Every subscription contributes exactly one retain at
creation and one release at teardown — balanced, no registry-side counter.
The presence cross-pod test now drives retain through the real register()
path (register_global_sub is async) instead of a manual workaround, proving
delivery from genuine subscription interest. Five new registry unit tests
pin the topic-reporting contract including the replace-displaces-old case.
cargo test -p buzz-relay: 378 passed, 0 failed. Redis round-trip presence
test exercised against local Redis.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Completes the &TenantContext threading for the relay-wiring lane so
buzz-relay compiles against Mari's buzz-db tenant seam and Max's
community-scoped pubsub seam. The remaining unscoped publish/dispatch
sites are now request- or startup-tenant bound:
- mesh_signaling: ctx threaded through handle_mesh_event_http,
handle_connect_request, handle_status_report, and
publish_channelless_ephemeral. publish_event(uuid::nil) ->
publish_event(ctx, EventTopic::Global); echo-cache invalidate keyed
by (community, event_id). WS callers pass &conn.tenant; the HTTP
bridge now resolves the tenant once before mesh/ingest routing.
- mesh_status_publisher: ctx threaded through publish_mesh_status* into
dispatch_persistent_event.
- workflow_sink: cron has no request host, so it resolves the
configured-host startup tenant (N=1) for dispatch; side effects stay
best-effort, logging+skipping on resolve failure. TODO(multi-tenant):
resolve per-channel community for N>1.
- main.rs: ephemeral reaper and NIP-ER reminder scheduler resolve the
startup tenant per tick (TODO multi-tenant per-channel); the cache-
invalidation receiver unwraps ScopedCacheInvalidation and threads
community_id into apply_cache_invalidation (moka cache keys still need
community-prefixing, flagged as follow-up).
- bridge.rs: hoisted tenant resolution above the mesh branch so one
resolve_tenant call binds both paths.
Tests updated for the new pubsub contract: mesh handler tests pass a
test_ctx(); the cross-pod presence round-trip test now retain_topic()s
the Global topic on both relays (the dynamic-subscription seam the
registry wiring will drive). cargo test -p buzz-relay: 373 passed.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Adds the §5 pre-build gate for multi-tenant replay protection.
buzz-auth gains a Nip98ReplayGuard trait plus the
nip98_replay_key(ctx, event_id) helper. The trait's try_mark contract
requires atomic set-if-absent semantics; an in-process cache (moka,
DashMap) does not carry the freshness proof across pods under the
"any pod, any connection" architecture (§4B), so the production
implementation MUST be shared state. The Redis-backed impl lives in
buzz-pubsub as RedisNip98ReplayGuard and uses a single SET key 1 NX
EX <ttl> per claim.
Key shape: buzz:{community}:nip98:{event_id_hex}. Event ids are
content-addressed so natural cross-community collision is zero, but
the gate is fail-closed isolation — a same-id replay across
communities must consult two distinct seen-set rows, not one shared
row. Tests pin both the prefix and the cross-community isolation
guarantee.
TTL floor is DEFAULT_REPLAY_TTL_SECS = 120, matching the §5 gate
requirement and the doubled NIP-98 ±60s timestamp tolerance.
Implementations MAY clamp sub-floor TTLs up to the floor; they MUST
NOT honor smaller values. The Redis impl clamps.
Caller contract documented in the trait: verify first, then mark.
Burning a seen-set slot on a forgery would let an attacker who learns
a future event id DoS the legitimate event. On Err (Redis
unreachable) callers MUST fail closed.
Not wired into a call site in this commit — there is no NIP-98 HTTP
handler in Lane 0 yet. Eva's relay-wiring lane will consume the trait
when the HTTP path lands; the contract is documented for that
integration.
Validation:
- cargo test -p buzz-auth --lib ✅ 40 passed (4 new in nip98_replay).
- cargo test -p buzz-pubsub --lib ✅ 3 passed, 9 Redis-required
ignored (3 new in nip98_replay).
- cargo test -p buzz-pubsub --lib nip98_replay -- --ignored against
local Redis ✅ 3 passed: first-claim/replay, cross-community
isolation, sub-floor TTL lifted to floor.
- Workspace check not run locally (sqlx 0.9.0 / rustc 1.94 vs local
1.89); CI catches it.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
RateLimiter::check_and_increment now takes &TenantContext, and
rate_limit_key emits buzz:{community}:ratelimit:{pubkey_hex}:{suffix}.
Same pubkey active in two communities consumes two independent quotas,
matching the S1 cross-community isolation fence in the buzz-relay
rewrite spec.
check_ip_connection stays operator-global by design. The IP fence runs
at connection acceptance, before host->community resolution has
completed (or, on resolve failure, instead of it). Threading
&TenantContext through it would invert the order of operations. Per-
(community, IP) caps, if ever needed as a tenant-fairness signal,
belong in an additive LimitType keyed on (community, ip) — not in this
trait.
RedisRateLimiter in buzz-pubsub follows the new trait signature.
AlwaysAllowRateLimiter test impl mirrors it. Two new tests pin the
behavior: the key includes the community prefix, and same-pubkey-two-
communities yields two distinct Redis keys.
Local cargo test -p buzz-auth: 36 passed. Local cargo test -p
buzz-pubsub: 3 passed, 6 Redis-required ignored. Workspace-wide check
not run locally (sqlx 0.9.0 requires rustc 1.94, local toolchain is
1.89 — same constraint Max hit on the pubsub lane); relying on CI for
the full integration compile.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Resolve the community from the connection host BEFORE the WebSocket upgrade
(conformance row-zero) and carry it as ConnectionState.tenant for the whole
connection lifetime. Handlers read &conn.tenant and pass it into scoped
DB/pub-sub calls; nothing downstream can mint or change it.
- RelayError::HostNotMapped: fail-closed, generic message (no host oracle).
- normalize_host(&HeaderMap, fallback): request Host header, lowercased,
port-stripped; falls back to the configured relay_url host (N=1 parity).
- AppState::resolve_tenant: the ONLY mint site, via the buzz-db lookup seam.
Integration seam: depends on buzz-db Db::lookup_community_by_host (Mari's lane).
Until that lands on rebase, the crate has exactly that one unresolved symbol;
everything else (normalize_host + tests, the field plumbing) compiles clean.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
buzz-core gets the zero-I/O tenant identity types every scoped layer
shares. TenantContext encodes conformance row-zero in the type system:
no Default, no Deserialize, no public constructor except resolved(),
which is meant to be called only from host resolution. Downstream code
holds &TenantContext and can read but not mint a community, so
client-chosen-community cannot type-check outside resolution.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>