Commit Graph

17 Commits

Author SHA1 Message Date
Luke Tornquist
b6d4403149 perf(redis): index mesh ready records and share one Redis connection (#9113) 2026-11-05 18:42:02 -05:00
Brad Seiler
64d12e2975 BUZZ-511: Honor relay mode for community NIP-FI enforcement (#8988)
## Summary

Apply relay mode consistently across protected HTTP, root/audio
WebSocket, Git, media and V1 admission. Off requires no NIP-FI,
including for stored required communities. Shadow enforces only stored
community requirements and observes other communities. Enforce requires
NIP-FI for every community. Durable requirements survive all mode
changes.

Off retains ordinary tenant routing and authentication. It skips FI
policy/dependency reads for admission, uses cached WebSocket tenant
routing, and loads no FI binding snapshot at startup. Discovery reports
`requires_nip_fi=false` and no FI capability in Off. Evaluating modes
retain same-row WebSocket binding and immutable HTTP policy tenants,
including host remaps, pending deletion and unarchive races. Global
Enforce discovery retains its existing compatibility behavior.

Host-specific root NIP-11 and `/info` expose the additive
`requires_nip_fi` field with no-store. In evaluating modes, an
unavailable policy returns 503 and an unknown or non-serving host
returns 404. Off uses ordinary routing; a discovery lookup failure
returns 404. Stored requirements in Shadow require usable FI
dependencies.

Admission does not establish activation. BUZZ-512 owns existing-session
application, drain and mode-aware readback; its separate revision must
report stored requirements as suspended in Off. Production baseline
verification and human acceptance remain open.

Community status now shares the bounded host cache: 10,000 entries,
one-second leases measured before checkout, coalesced fills, and no
cached misses or errors. Git reuses one request policy. Root/audio
retain the final primary binding check: two reads on a cold successful
upgrade, one on a cache hit. Off creates no verifier or JWKS source and
reads no FI policy. Proof metrics count stored-required Shadow
enforcement.

Acceptance locks and validates the community row, takes the matching
host advisory lock, and drains optional leases for 1.1 seconds before
commit. Reads begun after commit see required across participating relay
instances, even without Redis invalidation. The row lock closes single
and double remap races. Admissions already in flight remain subject to
BUZZ-512 activation fencing.

### Related issue


[BUZZ-511](https://linear.app/squareup/issue/BUZZ-511/relay-enforce-per-community-nip-fi-policy-and-publish-compatible).
[BUZZ-510 / #8985](https://github.com/block/buzz/pull/8985) is merged;
this PR now targets main.

The restack retains main’s #8974 denial telemetry and artifact
capabilities. Related work includes #8526 tenant lookup errors and #8444
media authorization.

### Rollout and containment

Keep `BUZZ_NIP_FI_UPGRADES_ENABLED=false`. [The rollout
plan](docs/community-auth-upgrades.md#staging-and-containment) starts in
staging with zero required rows and a 30-minute before/after traffic
comparison. Initial abort limits include any policy timeout/503,
policy-read p99 above 50 ms, writer acquisition p99 above 20 ms or twice
baseline, and a five-minute 503 increase above 0.1 percentage point.
These are initial limits to validate against measured staging results,
not production capacity claims.

Verify signed/unsigned free controls, real required root/audio upgrades,
dependency failures, restart behavior and discovery before expansion.
Also watch binding timeouts/errors and their current generic 404
responses on known serving hosts alongside writer-pool pressure;
evaluating root/audio binding has a 500 ms budget; Off uses ordinary
cached routing. In Shadow, a pre-511 rollback loses enforcement of
accepted community requirements. Stop new upgrade calls, retain accepted
policy and receipts, and repair with an enforcing binary or
operator-approved containment. Never clear durable policy to restore
access. Off deliberately suspends FI enforcement and must be an explicit
operator mode choice. No production activation is claimed.

All acceptance paths must implement the lease protocol before upgrades
are enabled. Keep upgrades disabled through a mixed-version rollout. For
rollback, disable new acceptance before removing the protocol while
caching pods remain. A cold policy fill uses a six-statement transaction
and a 400 ms server timeout within the 500 ms lookup budget. A contended
policy fill returns 503. During the drain, archive, deletion, ownership,
issuer-binding and competing acceptance writers for that community may
wait; ordinary serving reads and writes retain their existing behavior.
Host-binding changes retain the existing one-second routing bound. Local
and Redis invalidation accelerate refresh; safety does not depend on
delivery. Deploy the verified Shadow revocation behavior from #8991
before accepting required communities in Shadow.

### Testing

Published review-fix head: `39dfe79b1fc04f10ee3eb94d6ea42cf686bafe1e`,
tree `1e2e8d3abd39ee1d6821defe8c8d34de73726e08`. McKeon cleared this
cache delta. Full affected auth (291), DB (192), pubsub (31) and relay
(1,682) package tests passed, including integration binaries. Two
previously documented hardcoded-Redis audit tests remained excluded.
All-target clippy, all 1,297 native PostgreSQL tests and the full pubsub
Redis suite passed. The full ACP package and `just ci` passed after
rebuilding cached test executables that embedded a removed checkout
path. No ACP source changed. Tauri's generated lockfile delta was
archived and restored; it is excluded from this candidate.

Both positive lease regressions passed. Four deliberately broken
variants failed at the intended assertions: removing the drain left an
optional lease alive after 202.935667 ms; skipping the fill lock failed
contention refusal; moving the timestamp after the read failed the
anchor check; taking the host lock before the row failed the
remap-blocking check. Exact source was restored after each mutation.
These are local validation results, not a deployed-fleet acceptance
claim. All applicable normal push hooks passed. GitHub reports the exact
tested head and tree; the published code has no delta from the reviewed
candidate.

Earlier mode-correction evidence follows for context.

Current mode-correction head:
`aa9abf07284b56a88eb78a2014e590f39d705288`, on main
`af54258f19ac32667c027f889652bdeb479c8e13`. Its code equals reviewed
`f1e7da5fc5`, plus the independently reviewed operator documentation
follow-up; all 19 earlier remote patches are preserved. McKeon verified
the correction and its mapping to the tested candidate.

The mode-correction candidate passed full auth (291) and relay (1,679)
package tests, clippy, and four explicit native PostgreSQL policy
workflows. There were 623 ignored relay tests and two excluded Redis
tests. Real PostgreSQL checks cover an ordinary unsigned caller on a
stored-required host in Off with no FI token or dependencies, discovery
false, durable policy retained, root/audio upgrades, host remaps and the
unarchive race in evaluating modes. The `STORED_OFF` mutation fails the
enum truth table only; `OFF_PRIMARY` fails cached upgrade routing.
Restored source hashes match. Earlier failed compile/fixture attempts
remain recorded.

The first normal push hook attempt passed Tauri checks but failed the
gateway startup verifier test (82/83 gateway tests passed). The remote
remained unchanged. The retry rebuilt gateway artifacts with separate
root and Tauri caches, then passed every normal push hook and published
this head. No gateway source changes or hook bypasses were needed.
Current-head hosted CI is tracked separately. Human acceptance and the
staging binding-timeout/404 workflow remain open. The following retained
validation and controls are historical and describe behavior before the
mode correction; their Off-enforcement statements are superseded by the
Summary above.


Published head: `9a717fa89725f9430ff4a8bb78cd600affb8836d`, on main
`1055f79d048ef22c878d02a7afb04f49c753564f`. CI, three schema checks,
2,136 ordinary tests, all 291 auth tests, 12 Redis tests, all 1,300
native PostgreSQL tests and discovery passed. The required wrapper
passed units, then exited 1 at unavailable Docker bootstrap. BUZZ-510 is
complete at `95c8d31c835d6f7ff6e432fb1ed248771d4f06dd`: CI, three schema
checks, 2,127 ordinary tests, 12 Redis tests, all 1,295 native Postgres
tests and discovery passed; its wrapper passed units and failed at
unavailable Docker bootstrap.

The first native run at historical BUZZ-510 `5380cb72` passed 1,291
tests and failed two event-follow-up queue assertions and the scale-sync
case. All three passed serially, then the full 1,294-test run passed
with normal concurrency. The original cause remains unresolved; failed
runs and resource samples are retained.

Standalone BUZZ-511 checks exposed access-slot callers, a Clippy warning
and early HTTP 503 attribution that had remained in the child. All are
now corrected in the parent. The unchanged real-router refusal
regression passed before the latest commit. BUZZ-512's final tree is
unchanged by moving these parent prerequisites. The remaining 46-file
child delta was audited; no additional parent prerequisite was found.
Earlier compile and native failures remain archived.

The following earlier-head results and controls are historical; the
current-head validation is reported above.

At `48388834f99b8d045e243b619904cb3e27b47146`, based on BUZZ-510
`130f14a675edd7cbe6a69d1c9d7818bd1a51f46c` and reconciled main
`152441ae0a56b204b42b6faa56a36ad2b98218b1`:

- `just ci`: passed.
- Four-package ordinary suite: 2,069 passed; full `buzz-auth` package:
291 passed.
- Native Redis: 12 passed. Native PostgreSQL: 1,261 passed. PostgreSQL
discovery checks passed.
- Required `just test`: units passed, then exited 1 because Docker
service bootstrap was unavailable. Native service results above are
separate.
- Real-router remap checks reject an A-only member under B policy, avoid
A data after B admission, and advertise B in discovery despite a stale A
cache. Removing each tenant pin separately fails its intended witness;
restored source passes. An unmapped host cannot reuse a cached tenant.
- Deadline witnesses advance their clock during real HTTP signature
verification and retain socket primary-lookup barriers. Removing either
final enforced or shadow HTTP deadline check separately fails its
intended witness; restored source passes both deadlines and the
single-resolution control.
- Telemetry regression: unsigned dev proofs now expect the earlier
NIP-98 `missing` denial; signed mismatched-key proofs retain pairing
denial and signer attribution. Removing unsigned-proof rejection fails
the exact two-counter assertion. Restored full relay package: 1,654
passed.
- Preserved failed runs: two prior bottom-layer desktop gates failed an
immediate inventory assertion, repaired with a bounded wait for the same
observable arrays and verified with delayed-notification and
frozen-provider controls. An earlier BUZZ-511 native run passed 1,242
tests and failed two deadline fixtures that relied on the tenant lookup
removed by the policy pin; the test-only adaptation above replaces that
seam. These failures remain archived. The first native run on
bottom-layer `130f14a6` passed 1,255 tests and failed partition
contention; its focused original test and fresh full 1,256-test run
passed without source changes. The cause remains unresolved. The first
CI run at `d8d88433` failed the old telemetry expectation; the test-only
correction above preserves exact denial counts and attribution.

Earlier evidence at `dddf05d8be8195d567db0f989f70825b1246a6c1`
(historical head):

- `cargo nextest run -p buzz-auth -p buzz-db -p buzz-relay --lib
--tests`: 2,139/2,139 passed.
- `scripts/postgres-test-run.sh -p buzz-db -p buzz-relay --lib --tests
--success-output final`: 1,236/1,236 passed with native PostgreSQL 16
and Redis. Docker's legacy `just test` wrapper was unavailable.
- `just ci`: passed, including repository checks, tests and client
builds.
- **Pre-correction behavior (superseded):** Real ES256 assertions
through TCP root/audio upgrades: valid evidence upgrades; missing
evidence denies in Off/Shadow, including submitted/inventoried/approved
deletion stages. Restoring the old query makes the regression fail on an
assertion-less Off/submitted/root HTTP 101.
- **Pre-correction behavior (superseded):** Deterministic unarchive
race: Notify barriers pause an actual archived-host policy miss; the
same required row becomes serving before binding. Both root/audio deny
missing assertions and admit valid ES256 assertions in Off/Shadow.
Ignoring bound-row policy makes this test fail on an assertion-less HTTP
101.
- The full rerun exposed an existing cancellation fixture whose
synthetic five-second replica fence expired between probes. Refreshing
that test fence per operation preserves its timeout assertions; the full
suite now passes.
- Local probe: 100 serial uncached primary policy reads took 12.33 ms.
Production latency is unmeasured.

No packaged-app, human or deployed-fleet acceptance run. Live activation
awaits BUZZ-512 and the staging verification tickets.

Published with the three-layer stack in one atomic push through normal
hooks. The published stack includes main
`1055f79d048ef22c878d02a7afb04f49c753564f`, including access logging,
storage reporting and mobile read-state changes. Access-slot integration
and early HTTP refusal attribution belong to BUZZ-511; activation
callback refusal close owners belong to BUZZ-512. Every changed layer
requires review at its published head and new-head CI. Fresh trunk,
remote membership, queue and immutable-lease guards passed before
publication. All nine normal pre-push hook lanes passed; no hooks were
bypassed. Remote heads, chained bases, membership, ready/draft states
and native tracking were verified after publication.

Trunk’s merged 0081 is unchanged. The parent upgrade expansion is 0082
and activation is 0083, with contiguous migrator versions, updated
documentation and a populated-upgrade fixture starting at 81. Trunk’s
storage-test repair supersedes our earlier local repair.

Before publication, main advanced three commits from
`1055f79d048ef22c878d02a7afb04f49c753564f` to `ba7d7825ea` (desktop
deep-link settling and mobile pairing/sign-in). The normal branch-skew
guard passed. Those later trunk commits are outside this validated
published head; current-trunk CI and delta review remain required before
merge.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-05 03:17:19 +00:00
Logan Johnson
a56966b777 feat(relay): NIP-AR sync subscriptions (#8522)
🤖
## Summary
Clients that show a list of artifacts (tasks, config entries and similar
records) had no way to get the complete list and then stay current. They
could only page through query results, and a page is not proof that the
list is complete. This PR adds **sync**. A client sends one subscription
and gets every matching artifact as one consistent snapshot, then a
signal that the set is complete. After that it gets the current version
of each artifact that changes. When the relay cannot guarantee an exact
copy, it closes the subscription with a `resync` reason, so the client
never misses a change without knowing it.

It also adds a **head lookup**. It returns the current version of named
artifacts, deleted ones included, so that "create or restore" flows can
retry safely.

This implements the Sync section of NIP-AR (the artifact protocol spec),
which is the first commit of this PR.

## Details
- **Request.** A normal `REQ` whose only filter has `"artifact":
"current"` and at least one channel in `#h`. This is the same
current-state filter as HTTP `/query`; on a `REQ` it becomes a sync,
because a `REQ` already means "stored set, `EOSE`, then live". The
client does not page: the relay streams the whole set until `EOSE`, so
`limit` and `offset` are refused. Other artifact views (`history`,
`head`) stay HTTP-only, as on main. Other clients and other `REQ` shapes
do not change.
- **Initial set.** The relay first registers for live changes and waits
until Redis confirms that registration. Then it reads every matching
current version in pages ordered by artifact ID, all inside one
read-only `REPEATABLE READ` transaction. So all pages come from the same
database snapshot. `EOSE` comes only after a complete read. A failed
read, an unreadable channel, or a read that takes too long ends with
`CLOSED` and no `EOSE`.
- **Live changes.** Fan-out never sends the published event to a sync.
It only records which artifact changed. The sync then reads that
artifact's current version and sends it, one artifact at a time. So a
client never gets an older version after a newer one. Changes that
arrive during the initial read are held and sent after `EOSE`. Changes
are signalled per channel, so a sync skips an artifact whose `type` or
`d` does not match its filter: those never change, so it was never in
the set. A board that syncs three record types in the same channels no
longer gets each change three times.
- **When the relay sends `resync`.** The Redis link drops, reconnects,
or skips a message it cannot decode. Local fan-out lags. The
connection's send queue is full. A moderator redacts an event in the
channel. The reader's channel access changes (remove, leave, metadata
edit, channel delete). An access change ends the whole sync, also when
the sync names other channels. A sync cannot drop one channel and still
claim an exact set.
- **Publish retry.** When a Redis publish fails for an event kind that
syncs use, the relay retries it in the background until it succeeds.
- **Relay info (NIP-11).** The relay now lists `sync` (`max_events:
4096`), adds the `head` mode, and sets `websocket_query_extensions:
true`.
- **No migration.** Sync reads the existing `artifact_heads` table.
- **Next to incremental reads (#8523).** #8523 added a separate HTTP
protocol, `artifacts.incremental` / `"artifact":"incremental"`. It keeps
a saved store up to date after a disconnect, but it does not prove a
complete set. The two protocols use different NIP-11 keys and filter
values, so they do not collide. They share one copy of the current-state
access rule: `artifact_query::predicates`, which #8523 reads through,
now applies the view join and calls `push_scope`, and the sync snapshot
and head lookup call `push_scope` too.
- **A failed access lookup in fan-out resyncs.** The access gate fails
closed when a visibility or membership lookup fails. For a sync, that
would drop a change with no signal, so the gate also reports those
recipients and each one gets `resync: access check failed`.

### Known cost
To find out which redactions remove an artifact that a sync covers, the
relay would need an extra database read. So every redaction in a synced
channel resyncs that channel's syncs, and so does every channel metadata
edit. Each resync reads the set again, up to 4,096 artifacts. This is
correct, but it adds load to channels where people often delete chat
messages. We can narrow it later if it becomes a problem.

### Related issue
None found. The design was reviewed as NIP-AR Sync r28.

### Testing
- New Postgres tests: keyset pages see one snapshot, and an artifact
created after the snapshot opens is in no page; the head view includes
deletions; a private or unknown channel is refused.
- New relay end-to-end tests on real Postgres and Redis, using the real
`REQ` handler, ingest and fan-out: initial set in order, then `EOSE`,
then current versions for an update and a delete; a chat message causes
no frame; `CLOSE` stops the sync. Other tests: removing the reader from
one channel resyncs the whole sync; an unreadable or unknown channel
gets `CLOSED restricted` with no `EOSE`; a node-wide loss resyncs.
- Unit tests for the parser, request routing, fan-out diversion, the
held-change limit, and access-change signals that must arrive before the
access filter.
- Lifecycle tests from an adversarial review: every frame is checked
against the subscription owner, also when a `CLOSE` or a replacing `REQ`
arrives while the frame waits for queue room or while a live head read
is in progress. A resync recorded while `EOSE` waits ends the sync with
no `EOSE`. The held list refuses a change before it stores it (4096
entries, 4 MiB of held removals).
- End to end: a relay container built from this branch, with the
buzz-app #1523 browser build and the Work plugin. An edit from another
client showed in the open board 4 to 25 ms after the write returned,
with no refresh.
- `cargo clippy --all-targets -D warnings` is clean for the four changed
crates. All `buzz-relay` lib tests pass except
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo`. That
test returns 504 on this machine. It is a mesh networking test, and this
PR does not touch that code.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:31:30 -04:00
Bradley Axen
1d9f2c81a5 feat(relay): accept an owner's presence for its agents (kind 20003) (#8261)
## Summary

App-hosted agents (buzz-app agents2) run inside the desktop app without
a socket per agent, so they can't send their own kind 20001 presence and
always read as offline — including to their owner on another device.

This adds **kind 20003, an owner's presence for its agents**:

- Owner-signed; `content` is the status (`online` / `away` / `offline`);
each `p` tag names an agent (1–256 distinct).
- WebSocket only (rejected on HTTP ingest, like 20001).
- The relay keeps only the agents the signer owns
(`users.agent_owner_pubkey`) — cached `true` from
`observer_owner_cache`, the rest resolved with one
`list_agents_for_owner` query. Only positive links are cached, so a
just-linked agent isn't hidden for the cache TTL and the observer path
isn't poisoned.
- Sets (180s TTL) or clears each agent's presence in one Redis round
trip, the same keys kind 20001 writes, so the presence snapshot reports
them with no client change.
- Not fanned out: live presence is matched by author, and the author
here is the owner.

A new kind rather than `p` tags on 20001, so a relay without this
ignores the batch instead of storing it as the owner's own presence.

## Testing

- `agent_presence_request_names_distinct_agents_with_a_known_status`
(unit).
-
`presence_storage_postgres_tests::agent_presence_stores_only_owned_agents`
(Postgres + Redis): only the owner's agent goes online, another owner's
agent doesn't, a pre-cached `false` doesn't hide a linked agent, and
`offline` clears it. All 5 presence Postgres tests pass via
`scripts/postgres-test-run.sh -p buzz-relay --lib -E
'test(/presence/)'`.
- `buzz-pubsub` ignored Redis test
`test_presence_many_sets_with_ttl_and_clears` passes.
- clippy clean. Plain `cargo test` failures locally were environmental
only (buzz-media "role buzz does not exist", mesh_demo 504).
- Not yet exercised end to end against a local relay + app.

App side: block/buzz-app#862.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:43:24 -04:00
Luke Tornquist
1972b7d256 feat(admin): add community archive commands (#7107) 2026-10-07 18:31:00 -04:00
Will Pfleger
df3f28a2c4 feat(relay): add NIP-FI shadow mode for HTTP, WebSocket and admin (#8034)
Stack: this PR → #8062

🤖 Follows #8028 (merged).

Adds `BUZZ_NIP_FI_MODE=shadow`. The relay loads and validates the full
enforce configuration (issuers, communities, lifetimes, command
issuers), evaluates NIP-FI evidence wherever enforce would decide, and
records what enforce would have done, while every request, upgrade and
session behaves exactly as in `off`. The one approved exception is admin
disconnect: shadow verifies the command and returns `200`, where Off has
no verifier and returns `503`. It lets an operator measure the
would-deny rate, including Hosts missing from `BUZZ_NIP_FI_COMMUNITIES`,
before switching to `enforce`.

- **Mode predicates.** `NipFiMode` gains `is_off`, `restricts`,
`evaluates`, `enforces`, `denies_unconditionally` and `observes_only`,
built on two exhaustive `match` bases (`restricts`, `evaluates`);
`is_off` is a `matches!` and the rest derive from the bases, so a new
mode cannot silently fall into one side. Every call site uses them,
including the Git membership-lookup failure from #8005, which uses
`restricts()` so shadow keeps Off's body. A test scans each crate's
`src/` and fails on any `NipFiMode::<variant>` in production code
outside the predicate definitions and the env parser, which catches
`==`, `matches!` on any number of lines, `if let` and or-patterns.
- **Startup.** Shadow goes through enforce's path, so every enforce
startup refusal applies, including the required connection lifetime.
Missing-setting errors name the configured mode through one formatter
(`mode_requires`); the enforce text is byte-identical. Shadow builds the
assertion verifier and claims command replays under its own Redis
prefix, both through the production `AppState::new`.
- **HTTP and Blossom.** `admit_nip_fi_http` keeps Off's result in shadow
and additionally replays enforce in enforce's order: first the router
guard's steps (community, then assertion), then the handler's steps
(NIP-98, pairing, deny set) on the same NIP-98 proof, through
`evaluate_enforce_steps`, which enforce shares. A Host mapped in config
but missing from the communities table, with a valid assertion, records
an admit, because enforce's guard passes and its 404 is not a NIP-FI
denial. A dev-mode `X-Pubkey` identity is marked unsigned and is never
an enforce proof, so shadow records it as a `nip98` would-deny. On
bridge and Blossom routes, the strict proof check (payload tag, Blossom
`Strict`) runs as a separate pure check that consumes no replay entry
and records on its own series, so it never counts as an admission
verdict. Shadow records exactly one verdict for every identity,
community or credential refusal enforce would make, with Off's status,
body and challenge unchanged: bridge, workflows, Blossom, Git transport
and settings, GIF search and share, and invite minting record the
`community` would-deny when the Host is unmapped. Git's missing or
malformed credentials still reject before any database work, and shadow
records the enforce verdict for that failed proof. The router guard
stays transparent in shadow.
- **WebSocket, root and audio.** The upgrade check evaluates the
attached assertion once. An upgrade enforce would refuse records that
would-deny; otherwise the assertion goes into a shadow-only session
object and the upgrade returns as in Off. The assertion never reaches
the connection, the identity registries, the admission gate, terminal
frames or cancellation. Each session records at most one admission
verdict. **Pairing** is recorded on both ingresses right after a valid
NIP-42 proof, before ordinary relay policy, where enforce pairs: a key
mismatch or claimless assertion is a `pairing` denial. **The deny-set
check** runs where enforce runs it, after registration: a hit is a
`deny_set` denial, and a clean read keeps the session pending and
registered. **The admit** is recorded only when the connection is
actually admitted: on root at the AUTH commit, after ordinary policy; on
audio when the participant join transaction commits, after relay
membership, channel membership, the final ban and membership decision,
mesh routing and the join's own checks, and before publication. A NIP-42
failure, which comes before pairing, and any refusal between pairing and
the admit (ordinary policy, mesh, or a join refusal inside the join
transaction) retires the observer when the refusal is decided, before
any refusal frame, rollback or cleanup is awaited, so it leaves no
record even if the deadline passes during that cleanup. A record-only
deadline task, armed at upgrade with enforce's
`compute_session_deadline`, records `expired` when enforce would have
closed an admitted session. If the deadline passes while the session is
still pending, that is its single admission verdict, a denial with
`stage=deadline`, and a later AUTH records nothing. A deny for the key
(admin or cross-pod) records `revoked` on an admitted session; if it
arrives after the deny-set check but before the admit, including a deny
refused for capacity, the session keeps a pending-revocation mark and
records the admit followed by exactly one `revoked` end, matching
enforce's phase-independent close, and the deadline cannot take that
end. The phase, the mark and retirement change together under one
session lock. Each session records at most one end. The session holds
the socket's cancellation token for recording only: once the socket is
cancelled, no transition records anything, even while connection
references remain (a cancel fence; the token never feeds a close
action). Upgrade would-denies carry the ingress route, `ws` or `audio`.
- **Admin disconnect.** Shadow verifies the command and records the deny
entry, so later admissions record would-denies, but closes no session;
the response stays `{"disconnected": true}`. Shadow publishes cross-pod
on its own channel, `buzz:nip-fi:shadow-disconnect`, which only shadow
pods subscribe to, so no enforce pod (including older builds that know
only `buzz:nip-fi:disconnect`) acts on a shadow command. Shadow pods
also keep listening on `buzz:nip-fi:disconnect`, so their deny record
includes enforce's real disconnects. Shadow claims command replays under
`buzz:nip-fi:shadow-command:{hash}`: replays are still rejected within
shadow, and a shadow accept never uses up the enforce claim. Enforce and
Off keep the same channel, keys and close behavior.
- **NIP-11.** Shadow serves the same document as enforce, including
`limitation.federated_identity`, because clients only attach evidence to
a relay that advertises it.

## Recording

| Series | Labels | One increment per |
|---|---|---|
| `buzz_nip_fi_shadow_total` | `route` (`http`, `ws`, `audio`), `stage`,
`outcome`, `community` | decision enforce would make: an HTTP admission,
a refused upgrade, a session's NIP-FI AUTH decision, or its deadline
passing before AUTH (`stage=deadline`) |
| `buzz_nip_fi_shadow_strict_proof_total` | `route` (`bridge`,
`blossom`), `outcome` (`pass`, `rejected`), `community` | strict NIP-98
side check |
| `buzz_nip_fi_shadow_session_end_total` | `route` (`ws`, `audio`),
`reason` (`expired`, `revoked`), `community` | admitted session enforce
would have ended (admitted sessions only) |
| `buzz_nip_fi_shadow_disconnect_total` | `route` (`admin`,
`cross_pod`), `outcome` | disconnect-path event, in place of the real
disconnect, lag, capacity and poison counters |

`community` is the configured canonical URI or `unmapped`, never the raw
`Host`; an unmapped or empty Host records `stage=community,
outcome=unavailable`. A shadow pod never moves an enforce disconnect or
failure counter. Strict-proof rates use their own `pass + rejected`
total, and session-end rates divide by the `admit` count for the same
route. There is no per-verdict `info` log; a would-deny writes a `debug`
line with no token, claim or issuer. The pre-existing enforce `debug`
line for the proven NIP-98 key is shared and also fires in shadow. Admin
commands rejected for a bad header, body or proof, a replay, or a
dependency error record no shadow verdict; only capacity rejection is
counted.

## Not observed

- Handler-side path-validation exits: an invalid Git owner or repo name
on default-branch, a media path that is not a valid hash, and a Git pack
URL with a query string record nothing from the handler. The router
guard still records what enforce would refuse at the guard (for example
a missing assertion), because enforce refuses those requests before it
validates the path.
- A refusal that is not a NIP-FI decision, such as a tenant-binding 404
behind a passing guard, or an ordinary WebSocket refusal after pairing
(a ban, membership or join refusal before admission).

## Follow-ups

- A bounded `route` label per HTTP surface (bridge, Blossom, Git, …) on
`buzz_nip_fi_shadow_total`; today every HTTP verdict carries
`route=http`.
- One shared helper for the ten copied `observe_unbound` recording
hooks, stacked on this PR.

The shadow recorder and session suites and the root pairing witness run
in `just test-unit` and the `scripts/run-tests.sh` fallback; the
`AppState::new` shadow witness, which exercises the command verifier
against real Redis, runs in the `external_infra_redis` lane. NIP-FI env
tests recover a poisoned env lock so one panic cannot cascade.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-02 18:32:17 -04:00
Will Pfleger
a38707d35c fix(relay): close community ban gaps across audio, agents and HTTP (#8005)
🤖 Community bans had gaps. Audio sockets never checked them: the audio
join path didn't look up ban state, and a ban closed only the member's
root sockets, so a banned member could join a huddle or stay in one. An
agent's access comes from its owner, but the ban and timeout checks only
looked at the pubkey that signed the event. Several write paths returned
before those checks ran, admission could miss a ban that landed
mid-AUTH, and removing or losing a member left live sockets open. This
PR closes those gaps.

## Audio

- `handlers/auth.rs`: the root socket's ban lookup, including the NIP-OA
owner cascade and fail-closed DB handling, is `community_ban_outcome`.
Root auth and audio join both call it, so the two checks can't drift
apart.
- `audio/handler.rs`: audio join runs the ban check after the write-free
channel membership check and before any huddle lease or durable write. A
ban is refused with `AuthorizationDenied` and a DB error with
`AuthorizationUnavailable`. With no FI assertion, the refusal is the
root route's text (`blocked: you are banned from this community` /
`error: internal error checking restriction state`).
- `state.rs`: `CommunityConnectionControl` holds the authenticated
pubkey. `CommunityConnectionRegistry::disconnect_pubkey` closes bound
audio sockets for one pubkey in one community with a 1008 `access
revoked` close. `AppState::disconnect_pubkey_local` closes everything a
pubkey has open in a community on this pod (root and audio); the
clusterwide disconnect and the conn-control Redis subscriber in
`main.rs` both use it, so remote pods close audio sockets too. Root
sockets are still closed through `ConnectionManager`, so nothing is
closed twice. A socket that proved a NIP-FI identity instead gets the
standard NIP-FI denial (the restricted frame, then 1008 `authorization
denied`), and a ban never overwrites a close reason already chosen.

## Behavior

- **Owner restrictions carry to agents.** `restriction_state` folds in
the restriction row of the agent's owner (`users.agent_owner_pubkey`) at
read time. Nothing is copied onto the agent, so unbanning or
un-timing-out the owner restores the agent immediately. Every hot-path
caller (AUTH, ingest, audio, HTTP) picks this up.
- **Admission is fenced against a concurrent ban.** Root AUTH and audio
admission bind the proven pubkey to the socket first, then make the
final ban and relay-membership decision
(`handlers::auth::final_admission_denial`), then refuse if a disconnect
already cancelled the socket. A ban or removal whose disconnect ran
before the bind is visible to those fresh reads; one that runs after it
finds and closes the bound socket. Both reads come from the writer,
never the bounded-stale replica, so a removal that committed before the
bind cannot be undone by a lagging replica. Both fail closed, and the
check covers a delegated agent's owner. Each admitted socket records the
agent's owner, from the NIP-OA tag or the stored link.
- **Agents are always linked to their owner before admission.** Root
AUTH and audio both record `users.agent_owner_pubkey` for a NIP-OA agent
before admitting it, and refuse admission (`error: internal error
recording agent owner`) if that write fails, because revoking the owner
finds the agent's sockets through that link.
- **Revoking live access covers agents without a database read.**
`AppState::revoke_live_access` sends one clusterwide disconnect that
closes, on every pod, each root and audio socket in the community whose
principal **or recorded owner** is the target. An agent socket admitted
before its owner link existed carries no recorded owner, so recording an
owner for the first time (`materialize_nip_oa_owner`, the single writer
used by root AUTH, audio admission and HTTP NIP-OA) closes that agent's
ownerless root and audio sockets clusterwide; they reconnect with the
owner attached. That disconnect skips owner-bound sockets. Admission
with a NIP-OA owner records it on the socket before binding the pubkey,
so the socket survives its own link; admission without one binds the
pubkey first and then reads the stored owner, so a concurrent link
either closes the socket or is seen by the read. A
`users.agent_owner_pubkey` sweep still runs as a secondary path; if its
lookup fails, the error is returned and there is no retry task. The
kind-9040 ban, the report-action ban (left non-terminal so recovery
re-runs it), relay-admin removal (9031), and NIP-43 self-leave all use
it and report an incomplete revoke instead of claiming success. Each
revokes right after its change commits; the ban no longer waits on its
audit insert, whose error is still returned.
- **NIP-43 self-leave works over WebSocket.** Kind 28936 is in the
ephemeral range, so `handle_event` used to fan it out and answer `OK
true` without removing membership. It is now routed to ingest; no other
ephemeral kind changes.
- **The write gate runs before every kind-specific branch.**
`enforce_write_restriction` runs ahead of command routing (DM open, DM
add member, DM hide, workflow definitions and triggers, approvals),
product feedback, reports, and moderation or relay-admin commands.
Ephemeral events and agent observer frames return before ingest in
`handle_event`, so they go through the same gate there.
  - A ban exempts nothing.
- A timeout admits only reports (kind 1984), unban (9041) and untimeout
(9043). A timed-out admin cannot send relay-admin commands (9030–9033)
until the timeout ends.
- **HTTP refuses banned principals.** `enforce_relay_membership` runs a
ban check after the membership decision, including on open relays: 403
`blocked: you are banned from this community` for a ban, 503 for a
failed lookup. Media upload, media GET/HEAD and Git transport keep that
503 instead of reporting it as "not a relay member": in NIP-FI Enforce
mode they send the canonical `AuthorizationUnavailable` response
(`text/plain; charset=utf-8`, `authorization unavailable\n`), and in Off
mode each keeps its legacy unavailable response. Media storage failures
are not reclassified. Git transport checks membership and then runs its
own ban check once per request. The NIP-OA owner is resolved from
`x-auth-tag` when present. HTTP checks **bans only**: a timeout blocks
writes, HTTP writes already reach the ingest gate, and reading stays
allowed under a timeout.

Unchanged by design: a lost Redis fan-out can leave another pod's socket
open until it reconnects, and there is no recurring ban recheck on open
sockets.

## Path table

| Path | Category | Ban check |
|---|---|---|
| Root WebSocket AUTH and audio admission, including NIP-OA owner
binding | community admission | final fenced check after bind; owner
link required |
| `POST /events`, `/query`, `/count` (`bridge.rs`) | community HTTP |
existing `enforce_relay_membership` caller |
| GIF search and `POST /gifs/share` (`gifs.rs`), media upload and fetch
(`media.rs`), workflows (`workflows.rs`) | community HTTP | existing
caller; media keeps 503 on lookup failure |
| Git transport (`git/transport.rs`), git settings (`git/settings.rs`) |
community HTTP | existing caller; transport keeps 503 on lookup failure
|
| `GET /moderation/reports`, `/moderation/audit`,
`/moderation/restricted` | community HTTP | **newly covered** |
| `POST /api/invites` (invite mint) | community HTTP | **newly covered**
|
| NIP-43 self-leave (WebSocket and `POST /events`, both via ingest) |
roster change | revokes live access like admin removal |
| Invite claim, accept-policy, `/api/join-policy*` | pre-membership
invite and policy flows | none; ban state is checked when the caller
authenticates |
| `/`, `/info`, `/.well-known/nostr.json`, `/health`, `/_liveness`,
`/_readiness` | public metadata and health | none |
| `/hooks/{id}` | secret-authenticated webhooks | none; no community
pubkey |
| `/operator/*`, `/api/admin/v1`, `/internal/git/policy` | operator,
admin and internal routes | none; separate auth plane |
| `/_mesh/demo/echo` | demo only (mesh plus `mesh_demo_echo`) | none |

## Known limits

- Cross-pod closure depends on the Redis fan-out; the tests prove the
pod-local close by principal and recorded owner.
- Live delivery to existing subscriptions depends on the disconnect.
Events are not rechecked one by one.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-01 23:24:56 +00:00
Will Pfleger
16839a0773 feat(nip-fi): admin disconnect/deny API with in-memory deny-until-TTL map (S4) (#7977)
This adds the NIP-FI S4 admin command surface. An authorized issuer
principal can deny an `(issuer, pubkey)` pair until a TTL expires. When
that happens, the relay records the entry in a bounded in-memory deny
map, closes matching live sockets on every pod, and refuses new
admissions while the entry is live.

Supersedes #7265

## Breaking

In `BUZZ_NIP_FI_MODE=enforce`, every entry in `BUZZ_NIP_FI_ISSUERS` now
has to carry `maximum_command_age_seconds` (1–60) and a non-empty
`authorized_principals` list, and startup fails if any issuer is missing
either one. That means an S3-only enforce config that boots on `main`
won't boot with this change. No deployment sets `BUZZ_NIP_FI_*` yet. The
per-issuer field table and a working example are in the
`nip_fi_config.rs` module docs.

## What changes

- **`buzz-auth`**:
- `nip_fi/command.rs` verifies signed admin commands. It checks issuer
policy, command age, the authorized principal (exact, case-sensitive
`sub` match), and a `jti` of at most 512 bytes.
- `nip_fi/deny_map.rs` is a per-issuer, capacity-bounded deny-until-TTL
map on top of `DashMap`. Expired entries and `jti` reservations are only
swept when a write would otherwise hit capacity. The sweep is O(n) and
holds the lock every admission check takes, so this keeps it off the
admission path. Reads compare against `now`, so an expired entry that
hasn't been swept yet never counts as live.
- **Cross-pod command replay fence**: `nip_fi/command_replay.rs` adds a
`CommandReplayGuard` trait, and `buzz-pubsub` implements it in Redis
(`RedisCommandReplayGuard`), following the NIP-98 replay guard. Once a
command is authenticated, the verifier takes an atomic `SET
buzz:nip-fi:command:{sha256(len(iss)‖iss‖len(jti)‖jti)} 1 NX EX <ttl>`
claim, with the TTL set to the command's remaining validity rounded up
to whole seconds plus skew, clamped like NIP-98, so the claim outlives
acceptance by a verifier whose clock trails by the full skew. Before the
claim, the verifier reserves a pending `jti` and deny-entry slot in this
pod's deny map under the issuer lock, so a full deny set returns `503
deny set full` without touching Redis and the same signed command can be
retried. The claim is taken only after the signature and claims verify,
so a forged token cannot burn a real `jti`. A `jti` already claimed on
any pod returns `403`. A Redis error fails closed with `503
authorization unavailable`: no deny entry, no session close, no publish.
A replayed claim, a Redis error, or a cancelled request drops the
reservation. After a successful claim the reservation commits to the
`jti` record and deny entry, which cannot fail on capacity. A claim can
still remain with no deny entry if Redis applied `SET` but the reply was
lost, or the pod stops between claim and commit; that fails closed, and
a retry gets `403` until the TTL expires.
- **`api/nip_fi.rs`**: the admin disconnect/deny endpoint. It verifies
the command, inserts the deny entry, disconnects locally, and publishes
over `buzz-pubsub` `conn_control` so every other pod runs the same
disconnect. A receiving pod clamps `until` to its own ceiling instead of
rejecting it. Otherwise a pod whose clock trails the origin would drop a
command the origin already answered with 200, and the target's sockets
on that pod would stay open. Malformed pubkeys, unknown issuers, and
unrepresentable timestamps are still rejected.
- **Admission and live enforcement**:
- WS upgrade (`router.rs`) and HTTP (`nip_fi_http.rs`) refuse keys that
have a live entry with `403 authorization_denied`.
- Root NIP-42 auth and audio auth register the proven identity (pubkey +
admitting issuer) first, and only then check the deny map. With that
order, a concurrent disconnect either finds the socket in its close scan
or the check finds the entry. `OK(true)` isn't sent if a concurrent
disconnect already cancelled the session.
- `ConnectionManager::disconnect_nip_fi` and
`CommunityConnectionRegistry::disconnect_nip_fi` are scoped to the
issuer.
- **One wire form for `authorization_denied`** (FI-TRACE-DENIAL-ORACLE):
every root and audio WebSocket `authorization_denied` denial now goes
through one first-writer-wins transition on
`CommunityConnectionControl`. That covers the S4 deny-set hit and admin
disconnect, and also the existing S3 key-mismatch, lease-expiry, and
membership refusals. The transition sets the `AuthorizationDenied`
reason only if nothing else has set a reason, and only the winner
enqueues the denial frame. So every one of these denials is the class's
denial frame followed by `Close(1008, "authorization denied")`, and a
concurrent registry scan can't queue a second one. Before this, S3 key
mismatch and expiry closed with `Close(None)`, which made them
distinguishable from a deny-set hit on the wire. `evidence_rejected` and
`authorization_unavailable` keep the bare close.
- **Config**: see Breaking. `deny_set_capacity` is optional and defaults
to 50000. Setting any command field without a command age fails closed.
- **Boot** (`main.rs`): installs the command verifier and deny map. It
subscribes to cross-pod disconnects before the Redis subscriber starts,
so disconnects relayed during boot are buffered instead of dropped.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-01 19:40:09 +00:00
Will Pfleger
0808ab485c fix(cli): enrich template cardinality error with per-candidate presence and profile hints (#4825)
## Problem

`buzz channels create --template` fails with a bare-pubkey
duplicate-instance error when a persona has more than one live instance.
When duplicate instances share the same display name and avatar, the
error gives the operator no signal to identify which instance is stale.

## What changed

**`crates/buzz-cli/src/commands/channels.rs`** (boundary) + one
visibility line in **`crates/buzz-cli/src/commands/users.rs`** (`fn
presence_subject` → `pub(crate)`), plus a relay behavior change in
**`crates/buzz-relay/src/api/bridge.rs`** and a supporting test in
**`crates/buzz-pubsub/src/presence.rs`**.

### Relay: presence-lookup failure is now surfaced as a failure

`synthesize_presence` previously collapsed a Redis `get_presence_bulk`
error into `unwrap_or_default()` → HTTP 200 `[]`, making a backend
outage indistinguishable from an authoritative all-offline snapshot. It
now returns `Option<Result<Vec<Value>, (StatusCode, Json<Value>)>>`:
`None` means the request is not a presence query (fall through),
`Some(Ok)` is a real snapshot (an empty vec is an authoritative
all-offline result), and `Some(Err)` is a backend failure surfaced as a
non-2xx response. The event tag-parse and sign paths were converted from
`.ok()?` fall-throughs to explicit `Some(Err(...))` for the same reason
— a silent fall-through would have reintroduced the fake-empty-success
anti-pattern. The function takes `pubsub` and `relay_keypair` directly
rather than the whole `AppState`, which keeps the Redis-error seam
unit-testable without live infrastructure; its single production caller
was updated accordingly, and no other caller consumes
`synthesize_presence`.

### Hint fetch architecture

**`assemble_roster_resolution<F, Fut>`** — post-fetch stage extracted
from `build_roster_resolution` as an injectable-fetcher async generic.
It contains: archive-filter-based duplicate detection, the conditional
`fetch_hints` call, and `finalize_roster_resolution` delegation.
Accepting the fetcher as a closure makes the wiring directly testable
without a relay.

`build_roster_resolution` reduces to: gather slugs → `tokio::join!(scan,
archive)` → delegate with the real `fetch_candidate_hints` closure.

**Zero hint queries on the happy path**: `assemble_roster_resolution`
only invokes the fetcher when duplicate live instances exist after
archive filtering. Untrusted archive snapshot (`Err`) conservatively
treats all found instances as live.

When duplicates exist, `fetch_candidate_hints` runs the presence
(kind:40902) and profile (kind:0) queries concurrently, bounding **each
independently** through `join_bounded_queries`: a per-query 3-second
timeout maps to `Err`, so a lookup that completes is never discarded
because its sibling hung. On total failure or timeout, bare pubkeys are
printed promptly.

### Trusted-snapshot boundary — `hints_from_results`

The relay drops the Redis presence key when an identity goes offline, so
the bulk presence snapshot never returns an event for an offline
instance — precisely the stale duplicate an operator needs flagged.
`hints_from_results` therefore treats a *trusted* presence snapshot as a
**complete snapshot** for the requested pubkeys: it seeds all of them
`offline`, then overlays returned statuses.

A response is trusted only when `trusted_presence_snapshot` validates
**every** array element as the actual snapshot contract: each must parse
as a complete signed `nostr::Event` of the presence-update kind carrying
exactly one `p` tag whose subject is one of the requested pubkeys.
Requiring the *sole* `p`-tag subject — the exact value `build_hint_map`
later reads via `presence_subject` — is what stops a mixed-tag event
(`[["p","<unrequested>"],["p","<requested>"]]`) from passing the gate
yet overlaying a different subject downstream. Any element that is not
such an event — a vacuous object like
`{"pubkey":"…","content":"online"}`, `[{}]`, `[null]`, an event of the
wrong kind, one for an unrequested subject, or one carrying more than
one `p` tag — makes presence enrichment untrusted: nothing is seeded
`offline`, so absence is never falsely inferred as offline. A failed,
timed-out, or non-array presence response is likewise untrusted. In
every untrusted case the successful profile sibling still contributes
its hints.

### Response-to-map conversion — `build_hint_map`

Sync production function taking the offline seed plus raw
presence/profile event slices. Relay-signed presence events carry the
agent pubkey in the `p` tag (not the event author); `build_hint_map`
calls `presence_subject` from `users.rs` (now `pub(crate)`) to resolve
the correct key.

### Hint display — `format_candidate`

Appends `[online/offline, profile updated YYYY-MM-DD]` (or subset) to
each candidate pubkey in the error. `profile_updated_at` names the field
correctly: kind:0 is replaceable state that desktop republishes on
rename and profile reconciliation, so `created_at` reflects the last
profile update, not provisioning time. Date formatted via
`chrono::DateTime::from_timestamp`; out-of-range timestamps omit the
date. Missing entries fall back to bare pubkeys.

### Cardinality rule

`apply_cardinality_rule` remains pure and relay-free; zero/one/many
semantics unchanged.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-27 14:40:53 -04:00
Tyler
bf139e8d0b perf(presence): reduce heartbeat frequency (#3783)
## Summary

- send desktop presence heartbeats every 60 seconds instead of every 30
seconds
- extend presence TTL from 90 to 180 seconds to preserve the existing
three-heartbeat expiry window
- add mutation-sensitive tests that pin the one-minute / three-window
timing contract
- update presence documentation to match

This halves steady-state **desktop** presence `SET` + `PUBLISH` traffic
while retaining tolerance for two missed heartbeats. Mobile already uses
a 60-second heartbeat, so the fleet-wide reduction depends on desktop's
share of connected clients.

## Rollout order

Deploy the relay TTL increase before shipping the desktop heartbeat
change. Old desktop + new relay is safe; new desktop + old relay leaves
only a 90-second TTL on a 60-second cadence and can flap after one
missed heartbeat.

## Verification

At initial live-test commit `00816e233b187bc5ba12c667d675ed050a8cc1c9`:

- isolated clean-room relay built from the exact SHA against fresh
Postgres, Redis, and MinIO
- live Redis `MONITOR` observed kind-20001 writes as `SET ... EX 180`,
global `PUBLISH`, and clean-disconnect / explicit-offline `DEL`
- normal workflows passed: channel create/update/archive/unarchive;
message send/get/reply/thread/search; archived-channel write rejection
and resumed write after unarchive

At follow-up commit `bf38a8c5c96f196ff8ee46e48d4141ee7811f186`:

- `pnpm -C desktop test` — 3829 passed
- `pnpm -C desktop typecheck`
- `cargo test -p buzz-pubsub` — 24 passed, 11 Redis-dependent tests
ignored
- mutation probes fail when the server TTL changes to `999999` or the
desktop heartbeat changes back to 30 seconds
- `git diff --check`

The pre-push suite's relevant checks passed, but its unrelated Tauri
clippy step fails on current `origin/main`:
`desktop/src-tauri/src/linux_media.rs` has three dead-code warnings on
macOS. This PR does not modify that file, so the branch was pushed after
independently running the suites above.

## Buzz context

Originating channel: `buzz-redis-cluster-mode`
(`f4e36d32-afdb-447f-8c87-ab003e069d18`)

---------

Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
2026-07-30 15:26:11 -04:00
Kalvin C
2b15a72675 feat(relay): add durable community archival (#1834)
Signed-off-by: npub16ynjrn8tm9gxg4kpgfgj24eup8met48z58m9g0cvmpq9msjht0lq7r96r6 <d12721ccebd9506456c1425125573c09f795d4e2a1f6543f0cd8405dc2575bfe@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1ks3yhswfl2z70myka7wltdg39qpvqhspt2w3l2ajtaee8tmz5qkqrvl4et <b4224bc1c9fa85e7ec96ef9df5b5112802c05e015a9d1fabb25f7393af62a02c@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub16ynjrn8tm9gxg4kpgfgj24eup8met48z58m9g0cvmpq9msjht0lq7r96r6 <d12721ccebd9506456c1425125573c09f795d4e2a1f6543f0cd8405dc2575bfe@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ks3yhswfl2z70myka7wltdg39qpvqhspt2w3l2ajtaee8tmz5qkqrvl4et <b4224bc1c9fa85e7ec96ef9df5b5112802c05e015a9d1fabb25f7393af62a02c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
2026-07-14 16:02:12 +00:00
Kalvin C
1de916bdd2 feat(relay): add atomic create-only community provisioning (#1689)
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub16ynjrn8tm9gxg4kpgfgj24eup8met48z58m9g0cvmpq9msjht0lq7r96r6 <d12721ccebd9506456c1425125573c09f795d4e2a1f6543f0cd8405dc2575bfe@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub16ynjrn8tm9gxg4kpgfgj24eup8met48z58m9g0cvmpq9msjht0lq7r96r6 <d12721ccebd9506456c1425125573c09f795d4e2a1f6543f0cd8405dc2575bfe@sprout-oss.stage.blox.sqprod.co>
2026-07-09 20:16:02 -07:00
Tyler
863aeb79f3 Community moderation Phase 1: reports, bans/timeouts, audit, tombstones, relay-DM notices (#1616)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
2026-07-08 13:36:46 -04:00
Tyler
14fba21e57 Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-29 12:39:02 -04:00
Tyler
6284454298 fix(relay): multi-pod subscription coherence (one access-gated fan-out path + cross-pod cache invalidation + REQ/COUNT DB guard) (#1261)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
2026-06-24 22:17:25 -04:00
Will Pfleger
79fcfd82bc feat(relay): implement NIP-ER event reminder support (kind:30300) (#934)
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-06-17 10:24:02 -04:00
Will Pfleger
d99ad131f1 refactor: rename sprout backend to buzz (#958)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
2026-06-10 19:29:51 -04:00