Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -369,3 +369,7 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz
# BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here."
# BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here."
# BUZZ_AGE_ATTESTATION_REQUIRED=true

# 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
4 changes: 4 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,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
30 changes: 30 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,36 @@ 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.
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.
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.

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

End-to-end: generate an identity, create a channel, post a message, read it
Expand Down
8 changes: 8 additions & 0 deletions crates/buzz-db/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ pub enum DbError {
#[error("access denied: {0}")]
AccessDenied(String),

/// Invitation admission or minting was denied by authoritative restrictions.
#[error("invitation principal is restricted")]
InviteRestricted,

/// A verified invitation owner conflicts with the recorded owner.
#[error("invitation owner does not match the recorded owner")]
InviteOwnerMismatch,

/// JSON serialization or deserialization failed.
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
Expand Down
7 changes: 6 additions & 1 deletion crates/buzz-db/src/runtime/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,12 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 60);
assert_eq!(migrations.len(), 61);
assert_eq!(migrations[60].version, 61);
assert!(migrations[60]
.sql
.as_str()
.contains("ADD COLUMN revoked_at"));
assert_eq!(migrations[58].version, 59);
assert!(migrations[58].sql.as_str().contains("buzz.push_enabled"));
assert_eq!(migrations[59].version, 60);
Expand Down
19 changes: 18 additions & 1 deletion crates/buzz-db/src/store/event_follow_up_postgres_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1018,13 +1018,30 @@ async fn assert_async_isolation(db: &Db, community: CommunityId, keys: &Keys) {
endpoint_grant, max_class, subscriptions, expires_at, updated_at) \
SELECT $1, author, installation_id, source_event_id, source_created_at, generation, \
active, endpoint_enabled, endpoint_hash, endpoint_grant, max_class, \
subscriptions, expires_at, clock_timestamp() FROM push_leases WHERE community_id=$2",
subscriptions, expires_at, \
(SELECT received_at + interval '1 second' FROM events WHERE community_id=$1 AND id=$3) \
FROM push_leases WHERE community_id=$2",
)
.bind(later.as_uuid())
.bind(community.as_uuid())
.bind(before_enrollment.id.as_bytes().as_slice())
.execute(&mut *activation)
.await
.unwrap();
let leases_strictly_newer: bool = sqlx::query_scalar(
"SELECT count(*) > 0 AND bool_and(l.updated_at > e.received_at) \
FROM push_leases l JOIN events e ON e.community_id=l.community_id \
WHERE l.community_id=$1 AND e.id=$2",
)
.bind(later.as_uuid())
.bind(before_enrollment.id.as_bytes().as_slice())
.fetch_one(&mut *activation)
.await
.unwrap();
assert!(
leases_strictly_newer,
"fixture leases must follow recorded receipt"
);
activation.commit().await.unwrap();
assert_eq!(match_count(&isolated, later, &before_enrollment).await, 0);
assert_eq!(
Expand Down
Loading
Loading