spaces: leaving a room says so, so an owner stops publishing a seat nobody sits in

This commit is contained in:
KeelrLeek committed 2026-09-04 09:56:39 -05:00
1 parent d6102101af
commit 06baa5a805
3 files changed
+147 -7

No files matched your search

+87 -7
View File
@@ -24,7 +24,7 @@ use persona_state::space as sp;
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
use crate::inbox::{self, Cargo, SpaceInvite, SpaceJoin, SpaceKeyCargo};
use crate::inbox::{self, Cargo, SpaceInvite, SpaceJoin, SpaceKeyCargo, SpaceLeave};
use crate::ledger;
use crate::persona;
use crate::slsk_link;
@@ -635,12 +635,32 @@ pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: &
.and_then(|(room, _)| key_of(room).ok())
.unwrap_or(asked)
};
unseat(app, owner_b58, space_b58, me, owner, space_id, gone).await?;
open(app, owner_b58, space_b58).await
}
/// Take one key off the roster and re-key everyone who stays. The owner does
/// this both when they remove someone and when someone leaves of their own
/// accord, and it must look identical from the outside: a member who walks
/// away is only trusted to have DELETED the epoch key, never proven to.
async fn unseat(
app: &tauri::AppHandle,
owner_b58: &str,
space_b58: &str,
me: [u8; 32],
owner: [u8; 32],
space_id: [u8; 16],
gone: [u8; 32],
) -> Result<sp::Checkpoint, String> {
let c = bump(app, me, owner, space_id, |c| c.members.retain(|m| m.key != gone)).await?;
let rid = record_id(owner_b58, space_b58);
let ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?;
rec.last_epoch = c.epoch;
// Nobody sits under that key any more, so neither does the private
// mapping. Keeping it would hold a persona this device no longer needs.
rec.roster.remove(&b58(&gone));
if c.private {
// A removal rotates: everyone who stays gets the new epoch's key.
let k = rand32();
@@ -665,9 +685,9 @@ pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: &
} else {
save(app, &l)?;
}
// open() takes this same ledger; the write is done, so let go first.
// open() takes this same ledger; callers go straight back into it.
drop(ledger);
open(app, owner_b58, space_b58).await
Ok(c)
}
pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pl_owner: Option<String>, pl_uuid: Option<String>) -> Result<SpaceView, String> {
@@ -683,12 +703,34 @@ pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str
}
pub async fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
let me = my_key(app).await?;
let owner = key_of(owner_b58)?;
let space_id = id_of(space_b58)?;
let rid = record_id(owner_b58, space_b58);
// Signed BEFORE the record goes: the room secret is the only thing that
// can prove this seat asked to be vacated, and leaving destroys it.
let (worn, room) = {
let l = load(app);
let rec = l.spaces.get(&rid).ok_or("not a Space this device knows")?;
if rec.my_role == "owner" {
return Err("an owner keeps the Space; remove members instead".into());
}
(worn_key(rec, &me), room_secret_of(rec))
};
let payload = sp::leave_proof_payload(&space_id, &owner, &worn);
let proof = match room {
Some(secret) => sp::sign_as_member(&secret, &payload),
None => sign(app, payload).await?,
};
// Best-effort, and deliberately so: the person asked to go, so they go
// whether or not the owner is reachable. A lost notice leaves a seat on
// the roster that can no longer post — the owner can still remove it.
let cargo = Cargo::SpaceLeave(SpaceLeave { space_id, owner, worn, proof });
if let Err(e) = inbox::send(app, &owner, &cargo).await {
log::warn!("spaces: left {space_b58} but could not tell its owner: {e}");
}
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let rid = record_id(owner_b58, space_b58);
if l.spaces.get(&rid).is_some_and(|r| r.my_role == "owner") {
return Err("an owner keeps the Space; remove members instead".into());
}
l.spaces.remove(&rid);
save(app, &l)
}
@@ -771,6 +813,44 @@ async fn send_join(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16]
}
}
/// Sealed cargo: a member gave up their seat. Three things have to hold —
/// the seat exists, its holder signed the request, and the sender is the
/// person this device seated under it. The last one is what stops anyone who
/// merely READ the roster (the keys are published) from unseating a stranger.
pub(crate) async fn on_inbox_leave(app: &tauri::AppHandle, sender: [u8; 32], lv: SpaceLeave) {
let owner_b58 = b58(&lv.owner);
let space_b58 = b58(&lv.space_id);
let rid = record_id(&owner_b58, &space_b58);
{
let _ledger = ledger::hold(LEDGER_FILE).await;
let l = load(app);
let Some(rec) = l.spaces.get(&rid) else { return };
if rec.my_role != "owner" {
return;
}
// Room-local seats map back to a persona here and nowhere else; a
// Space seated before room keys holds the persona on the roster
// itself, and then the sender IS the seat.
let behind = rec.roster.get(&b58(&lv.worn)).map(String::as_str);
if behind.unwrap_or(&b58(&lv.worn)) != b58(&sender) {
log::warn!("spaces: a leave came from someone other than the member seated under that key");
return;
}
}
if !sp::leave_proof_valid(&lv.space_id, &lv.owner, &lv.worn, &lv.proof) {
log::warn!("spaces: a leave did not prove it came from the seat it gives up");
return;
}
let me = match my_key(app).await {
Ok(k) => k,
Err(_) => return,
};
match unseat(app, &owner_b58, &space_b58, me, lv.owner, lv.space_id, lv.worn).await {
Ok(c) => log::info!("spaces: a member left {:?}, roster at epoch {}", c.name, c.epoch),
Err(e) => log::warn!("spaces: could not unseat a member who left: {e}"),
}
}
/// A member who holds a room key but is not on the roster never got seated.
/// Say so again, spaced out, rather than leaving them stranded.
const JOIN_RETRY_SECS: u64 = 120;