Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d2c2a74
fix(relay): revoke invitations durably and fence restricted claims
bradseiler Oct 9, 2026
d0441db
test(relay): select invite boundaries and seed fenced upgrade fixtures
bradseiler Oct 9, 2026
e6a811a
test(relay): include the v1 verifier cutoff boundary in CI
bradseiler Oct 9, 2026
91305ec
fix(relay): retain mesh receives across housekeeping ticks
bradseiler Oct 9, 2026
4432ca2
test(relay): select mesh receive regression in both CI paths
bradseiler Oct 9, 2026
f6ba546
test(relay): initialize scraper errors from each observation
bradseiler Oct 9, 2026
19ecad9
test(relay): extract exclusive invite fixture without redundant conve…
bradseiler Oct 10, 2026
34795a3
test(relay): expose invitation restrictions to PostgreSQL discovery
bradseiler Oct 10, 2026
e6b0021
test(db): derive push lease fixture time from persisted receipt
bradseiler Oct 10, 2026
c674d64
fix(db): commit invitation ownership on admission
bradseiler Oct 12, 2026
1a4f366
test(relay): guard destructive invitation fixture
bradseiler Oct 12, 2026
d6b9fbc
fix(db): distinguish issuer timeout denials
bradseiler Oct 12, 2026
c83c9bb
fix(db): log transactional claim denials
bradseiler Oct 12, 2026
2ab99e9
fix(db): own invite revocation in writer transactions
Oct 13, 2026
a3b7b18
fix(db): reconcile invitation admission with main
Oct 15, 2026
313cbe2
test(relay): reconcile custom-schema writer fixtures
Oct 15, 2026
22b5282
Repair strict legacy invite revocation
bradseiler Oct 17, 2026
66fabf9
test(db): upgrade invite repair from schema 66
bradseiler Oct 17, 2026
28fdf29
Merge pinned main into invite revocation candidate
bradseiler Oct 19, 2026
0b18470
Merge latest pinned main into invitation repair candidate
bradseiler Oct 20, 2026
083118f
Fix invite restriction test fixture
bradseiler Oct 20, 2026
ee0ecfa
Classify invite upgrade fixture as migration schema
bradseiler Oct 21, 2026
9017435
Separate invite history repair from schema rollout
bradseiler Oct 21, 2026
96f9eef
fix(invites): distinguish owner mismatch and explain claim recovery
bradseiler Oct 21, 2026
e1afe12
Synchronize local task signal tests with pending input
bradseiler Oct 21, 2026
88aaa4d
Synchronize descendant timeout fixture before measuring cleanup
bradseiler Oct 21, 2026
51b8e9c
Disarm timeout fixture guards through their mutable receiver
bradseiler Oct 21, 2026
60d4441
Merge main into invitation repair candidate
bradseiler Oct 22, 2026
85f08b9
fix(db): order invitation expansion after channel join roles
bradseiler Oct 22, 2026
2fabeed
Merge main into invitation revocation candidate
bradseiler Oct 22, 2026
1a9d70f
Merge pinned main primary-key repair into invitation candidate
bradseiler Oct 22, 2026
b577ac2
fix(desktop): keep community actions stable across hover transitions
bradseiler Oct 23, 2026
d3be9cd
test(desktop): sample content gutters in one browser frame
bradseiler Oct 23, 2026
c410138
Merge main into invitation revocation candidate
TheSentinel454 Oct 26, 2026
8dad74f
test(db): run invite upgrade pgschema from the repository root
TheSentinel454 Oct 26, 2026
5af546b
test(relay): run the invite fixture guard in the unit lane
TheSentinel454 Oct 26, 2026
6916ef1
Merge main into invitation revocation candidate
TheSentinel454 Oct 26, 2026
9ae8509
fix(invites): name the restricted claimant in recovery copy
TheSentinel454 Oct 26, 2026
1163e20
Merge main into invitation revocation candidate
TheSentinel454 Oct 26, 2026
49db2d8
docs(invites): correct restriction wording and owner-link doc comment
TheSentinel454 Oct 26, 2026
12f462c
Merge origin/main 3ed07e5c into BUZZ-268 invite revocation
TheSentinel454 Oct 28, 2026
989412f
fix(invites): reject unknown claim codes before the admission lock
TheSentinel454 Oct 28, 2026
c16538d
Merge origin/main 2c28e071 into BUZZ-268; renumber invite migration t…
TheSentinel454 Oct 28, 2026
3610381
fix(invites): run the unknown-code check inside the admitted transaction
TheSentinel454 Oct 28, 2026
b0c230e
Merge origin/main 0a362eef into BUZZ-268
TheSentinel454 Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,7 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz
# BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX=100000
# Concurrent live lookups; clamped below the database pool size.
# BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT=8

# Optional absolute legacy-v1 invite cutoff, UTC RFC3339 whole seconds.
# Unset preserves natural token expiry; never changes v2 invitations.
# BUZZ_INVITE_V1_INVALID_AFTER=2026-12-01T00:00:00Z
6 changes: 5 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,7 @@ test-unit:
# never connects.
# `^config::` is anchored so it does not also select nip_fi_config.
cargo nextest run -p buzz-relay --lib --bin buzz-relay \
-E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::coalesced_refresh::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^nip_fi_resolver::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^mesh_boot::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
-E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::coalesced_refresh::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^nip_fi_resolver::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^mesh_boot::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + test(=api::invites::postgres_tests::restriction_tests::postgres_tests::destructive_fixture_rejects_shared_database_urls) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
# Note on audio::join::tests scope: the full suite is infra-free (no DB,
# no Redis). The infra-free audio/FI regression witnesses — bootstrap
# ordering barrier, CommitConfirmed arm, pending-close invisibility,
Expand Down Expand Up @@ -624,6 +624,10 @@ test-unit:
+ test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame)
+ test(=state::tests::manager_disconnect_sets_reason_enqueues_frame_then_cancels)
+ test(/^api::nip_fi::/)'
# Invitation drain/cutoff boundary contracts are infra-free.
cargo nextest run -p buzz-relay --lib -E 'test(/^invite_token::/) + test(/^config::invite_cutoff_tests::/)'
# Retain partially consumed mesh receives across housekeeping ticks.
cargo nextest run -p buzz-relay --lib -E 'test(=mesh_boot::tests::demo_echo_retains_pending_receive_across_housekeeping_ticks)'
# boot_lifecycle spawns the real relay binary and asserts its startup
# lifecycle, including that buzz_startup_phase_* reaches /metrics. Its
# non-ignored tests need no Postgres or Redis; the Postgres cases are
Expand Down
86 changes: 84 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,88 @@ backgrounded or you lost the terminal: `pkill -f buzz-relay`. Leaving
it running will collide with the next reviewer who follows this doc on
the same machine.

### Invitation compatibility and revocation

New invitations are durable v2 bearers. A community ban permanently revokes
outstanding v2 invitations issued by that principal or its recorded agents.
Unban does not restore them; an unrestricted issuer can mint new invitations.
Existing durable rows default to legacy admission. Until the separately
reviewed historical repair processes them, any retained `community_bans` row
for a canonical issuer or its currently recorded owner permanently revokes a
legacy row, including expired, lifted, and timeout-only restrictions. The
repair clears that legacy marker after deciding the row's historical state.
Afterward, only an active ban permanently revokes it; timeouts and mutes block
claims only while active. New mints explicitly mark their rows as protocol
native. Audit and action records alone do not establish enforcement.
An issuer or verified owner timeout also blocks claims on existing invitations
while it lasts, without durable revocation. Claims resume after timeout expiry.
Claim credentials are verified without user writes. Successful claims commit
verified ownership with membership, policy evidence, and v2 consumption.
Rejected claims create no users or owner links.
Minting with a verified owner that differs from an agent's recorded owner
returns HTTP 403 `invite_owner_mismatch`; a storage failure while establishing
that link remains HTTP 503. Web, desktop, and mobile claim errors explain
`invite_restricted` as a restriction on the claiming account, or on the account
that owns it, in this community: contact a community administrator, or retry
after a temporary restriction ends. An issuer restriction returns
`invite_invalid`, so a claim never reveals the issuer's moderation state.
`invite_owner_mismatch` asks for the correct account or owner identity. Neither
error suggests a fresh link as a workaround, and neither automatically retries.
Claim and mint restriction reads use the writer inside a transaction serialized
with community restriction and agent-owner writes. Admission committed first
precedes the ban; a ban committed first prevents subsequent admission. Restriction
lookup failures deny admission, and onboarding does not require prior membership.

Legacy v1 bearers contain no issuer identity. Unset `BUZZ_INVITE_V1_INVALID_AFTER`
preserves their natural expiry and claimant restriction checks, but cannot revoke
an individual legacy issuer's bearers. Drain for at most 30 days from the last
possible v1 issuance across **all serving and rollback versions**; do not use merge
time as the start. Full issuer-revocation coverage requires completing that drain
or an explicitly approved compatibility scope decision.

Operators may set `BUZZ_INVITE_V1_INVALID_AFTER` to an absolute UTC RFC3339
whole-second timestamp (for example `2026-12-01T00:00:00Z`). v1 verification
rejects at and after that instant, never extends bearer expiry, and leaves v2
unchanged. Invalid or empty values fail configuration. Apply the same setting to
all serving pods and retain it on compatible rollbacks; rolling back to a version
without this setting's enforcement restores legacy acceptance until natural expiry.
This change does not choose or deploy a cutoff.

The historical maintenance companion must be separately reviewed and approved
**before deploying this core change**. Both steps belong to the same controlled
rollout: leaving unrepaired rows serving indefinitely would extend historical
restriction rejection until their repair. Migration 0075 is schema-only: it adds
`revoked_at` and `legacy_admission` without rewriting existing invite rows.
Reconcile any previously applied invitation migration version with the schema
and migration history before applying it.

There is no invitation-maintenance feature switch. Operators must prevent
invitation mint/claim, ban lifts, and **new timeout/mute restrictions** throughout
the drain/repair interval, including signed events, HTTP, administrative tools,
background jobs and direct writers. If this cannot be enforced for every writer
with existing operational controls, use a maintenance window: remove public
traffic, stop all relay instances and other writers, drain in-flight work and
verify no old writer remains. Keep them stopped while applying schema 0075 and
running the separately approved repair with its isolated operator connection.
Prepare only new-code instances; do not reopen traffic or restart other writers
until repair validation succeeds. Readers that may run before repair must use
new code, which rejects historical restrictions lazily on unrepaired legacy rows.

The repair must report zero unrepaired invites in active communities and an
idempotent second run before reopening. Review every skipped/fenced community;
keep it closed until it is active and repaired, then recheck zero before admitting
traffic. After repair, timeouts/mutes only block temporarily; active bans still
revoke permanently. A rollback to code that ignores `revoked_at` requires
Comment thread
TheSentinel454 marked this conversation as resolved.
Resolved
invitation admission paused, using the same traffic/writer controls if needed.
It also requires `BUZZ_AUTO_MIGRATE` off: with auto-migration on (the Helm
default), the old binary fails at startup with SQLx `VersionMissing(75)`. See
[docs/database.md](docs/database.md) Rule 5.
Legacy owner attribution uses the agent's currently recorded
`agent_owner_pubkey`; deleting an owner user row out of band clears that link
through its foreign key, so a later repair cannot recover the former
relationship. Reconcile before any external user-row cleanup that could erase
this provenance.

### 4. Smoke test the CLI against the relay

End-to-end: generate an identity, create a channel, post a message, read it
Expand Down Expand Up @@ -190,7 +272,7 @@ relay key for authoritative replacement:

```bash
export PATH="$PWD/target/release:$PATH"
export DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/buzz_roster_e2e"
export DATABASE_URL="${DATABASE_URL:?set an isolated local PostgreSQL URL}"
export BUZZ_RELAY_URL="http://localhost:3030" # match the relay from step 3
export RELAY_URL="ws://localhost:3030"
export BUZZ_RELAY_PRIVATE_KEY="<same key used by buzz-relay>"
Expand Down Expand Up @@ -331,7 +413,7 @@ out of the box with `just setup` or `just relay`. Common overrides:
| `BUZZ_HEALTH_PORT` | `8080` | `/_liveness`, `/_readiness` |
| `BUZZ_METRICS_PORT` | `9102` | Prometheus `/metrics` |
| `RELAY_URL` | `ws://localhost:3000` | Advertised in NIP-11 / NIP-42 challenges. **Note: no `BUZZ_` prefix.** |
| `DATABASE_URL` | `postgres://buzz:buzz_dev@localhost:5432/buzz` | |
| `DATABASE_URL` | Local PostgreSQL database `buzz` (development default) | |
| `REDIS_URL` | `redis://localhost:6379` | |
| `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) |
| `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect |
Expand Down
73 changes: 61 additions & 12 deletions crates/buzz-acp/tests/run_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
use serde_json::{json, Value};
use std::{
fs,
io::Write,
io::{ErrorKind, Write},
os::unix::{io::OwnedFd, net::UnixStream},
path::PathBuf,
process::{Child, Command, Output, Stdio},
time::{Duration, Instant},
Expand Down Expand Up @@ -97,7 +98,8 @@ fn terminal(output: &Output, code: i32, status: &str) -> Value {
assert_eq!(
output.status.code(),
Some(code),
"stderr={} stdout={}",
"exit={:?} stderr={} stdout={}",
output.status,
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
Expand Down Expand Up @@ -419,6 +421,32 @@ fn deadline_covers_initialize_session_and_turn_and_cleans_descendants() {
}
}

fn backpressured_stdin() -> (UnixStream, UnixStream) {
let (reader, mut writer) = UnixStream::pair().unwrap();
writer.set_nonblocking(true).unwrap();
// Fill before spawning: the next write cannot finish until the runner reads
// stdin, which happens after signal registration. Keep below the task limit
// and leave the writer open so read_to_end still awaits EOF when cancelled.
let mut queued = 0;
loop {
match writer.write(b" ") {
Ok(0) => panic!("stdin fixture stopped accepting bytes"),
Ok(n) => {
queued += n;
assert!(queued < 1024 * 1024 - 1, "stdin fixture exceeds task limit");
}
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
Err(error) if error.kind() == ErrorKind::WouldBlock => break,
Err(error) => panic!("cannot fill stdin fixture: {error}"),
}
}
writer.set_nonblocking(false).unwrap();
writer
.set_write_timeout(Some(Duration::from_secs(3)))
.unwrap();
(reader, writer)
}

#[test]
fn signals_cancel_startup_turn_and_pending_stdin_without_hanging() {
use nix::{
Expand All @@ -435,34 +463,55 @@ fn signals_cancel_startup_turn_and_pending_stdin_without_hanging() {
command
.env("TASK_AGENT_MODE", mode)
.args(["--no-memory", "--task", "-"]);
let mut held_stdin = if mode == "stdin" {
let (reader, writer) = backpressured_stdin();
command.stdin(Stdio::from(OwnedFd::from(reader)));
Some(writer)
} else {
None
};
let mut child = command.spawn().unwrap();
let held_stdin = if mode == "stdin" {
child.stdin.take()
if let Some(writer) = held_stdin.as_mut() {
if let Err(error) = writer.write_all(b" ") {
let status = child.try_wait();
let _ = child.kill();
panic!("{mode} never drained pending input: {error}; status={status:?}");
}
} else {
child
.stdin
.take()
.unwrap()
.write_all(f.task().to_string().as_bytes())
.unwrap();
None
};
let until = Instant::now() + Duration::from_secs(3);
if mode == "stdin" {
std::thread::sleep(Duration::from_millis(250));
} else {
let until = Instant::now() + Duration::from_secs(3);
let method = if mode == "turn-hang" {
"session/prompt"
} else {
"initialize"
};
while !f.wire().iter().any(|v| v["method"] == method) {
assert!(Instant::now() < until);
if Instant::now() >= until {
let status = child.try_wait();
let _ = child.kill();
panic!("{mode} never reached {method}: status={status:?}");
}
std::thread::sleep(Duration::from_millis(10));
}
}
eprintln!(
"cancelling case={mode} signal={signal:?} pid={}",
child.id()
);
kill(Pid::from_raw(child.id() as i32), signal).unwrap();
terminal(&wait(child), code, "cancelled");
let result = terminal(&wait(child), code, "cancelled");
if mode == "stdin" {
assert!(result["taskId"].is_null());
assert!(
f.wire().is_empty(),
"incomplete input must not start an agent"
);
}
drop(held_stdin);
for frame in f.wire() {
if let Some(pid) = frame["pid"].as_u64() {
Expand Down
7 changes: 7 additions & 0 deletions crates/buzz-db/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,10 @@ membership in `pg_read_all_stats`, or an ephemeral superuser as CI uses.
The runner creates one desired-state source database per invocation and clones
it for ordinary tests. Migration-mode tests start empty. Cleanup retries
transient disconnect races before reporting a warning.

Invite upgrade tests start from the complete schema through migration 0074,
including channel join roles, entity deletion approval, the unlisted channel setting and the user deletion target and fences. They exercise the
invitation schema expansion (migration 0075) through both migration and
desired-state paths against populated
data, retaining a non-default channel join role. Historical repair is validated
separately from the application/schema change.
Loading
Loading