//! Spaces, product side (F6 decision → the app). One contract per Space at //! (owner, space id): the owner's signed CHECKPOINT is the authority — //! name, mode, roster, pinned playlist — with an epoch that moves on every //! roster change; members post signed MESSAGES into a bounded window; a //! private Space seals bodies to a per-epoch space key the owner hands out //! through members' encrypted inboxes and rotates on every removal. //! Invitations are owner-authored: to invite is to add the key to the //! roster and tell the person, sealed, where the Space is. //! //! What this device knows about its Spaces — which ones, its role, the //! epoch keys it was given — lives in `/freenet/spaces.json`. //! Losing that file loses the keys; the owner can re-send them. //! //! v1 keeps members as their persona keys (a joined Space is a linked //! identity); the room-local member key the design describes for //! unlinkability is the next step, not a different contract. use std::collections::BTreeMap; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use freenet_adapter::FreenetAdapter; use persona_state::space as sp; use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager}; use crate::inbox::{self, Cargo, SpaceInvite, SpaceJoin, SpaceKeyCargo, SpaceLeave}; use crate::ledger; use crate::persona; use crate::slsk_link; use crate::state::AppState; const SPACE_WASM: &[u8] = include_bytes!("../assets/space_contract.wasm"); pub const EVENT: &str = "community:spaces"; #[derive(Debug, Clone, Serialize, Deserialize)] struct SpaceRecord { owner: String, space_id: String, name: String, private: bool, my_role: String, joined_at: u64, /// epoch → space key (b58), for private Spaces. #[serde(default)] keys: BTreeMap, #[serde(default)] last_epoch: u64, /// The key we wear in THIS Space (b58 secret), minted on the invitation. /// A member is on the roster under this and nothing else, so the roster /// says nothing about who they are anywhere else. Absent for a Space we /// own: an owner is their persona there by construction, since the /// Space's address is derived from it. #[serde(default)] room_secret: Option, /// OWNER ONLY, and never published: room key (b58) → the persona that /// proved it. This is the mapping the whole scheme exists to keep off the /// network, so it lives here and travels nowhere. #[serde(default)] roster: BTreeMap, /// OWNER ONLY: personas we have invited and will accept a join from. #[serde(default)] invited: BTreeMap, /// When we last told the owner which key we would wear. The answer is one /// sealed message and it can simply fail to land — the owner's descriptor /// may not resolve on a young node — and a member who is never seated has /// no way back in. Opening the Space re-sends after this goes stale. #[serde(default)] join_sent_at: u64, } #[derive(Default, Serialize, Deserialize)] struct Ledger { #[serde(default)] spaces: BTreeMap, } #[derive(Debug, Serialize)] pub struct SpaceListItem { pub id: String, pub owner: String, pub space_id: String, pub name: String, pub private: bool, pub my_role: String, pub last_epoch: u64, } #[derive(Debug, Serialize)] pub struct MemberView { pub key: String, pub role: String, pub username: Option, pub is_me: bool, pub since_epoch: u64, } #[derive(Debug, Serialize)] pub struct MessageView { pub id: String, pub author: String, pub username: Option, pub is_me: bool, pub created_at: u64, pub epoch: u64, /// None when sealed to a key this device does not hold. pub text: Option, } #[derive(Debug, Serialize)] pub struct SpaceView { pub id: String, pub owner: String, pub space_id: String, pub name: String, pub description: String, pub private: bool, pub epoch: u64, pub my_role: String, pub message_cap: u32, pub members: Vec, pub pinned_owner: Option, pub pinned_uuid: Option, pub messages: Vec, pub can_post: bool, pub has_key: bool, /// The contract exists on the network (a Space someone invited us to /// may not have propagated to this node yet). pub reachable: bool, } fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } fn rand16() -> [u8; 16] { use rand::RngCore; let mut n = [0u8; 16]; rand::thread_rng().fill_bytes(&mut n); n } fn rand32() -> [u8; 32] { use rand::RngCore; let mut n = [0u8; 32]; rand::thread_rng().fill_bytes(&mut n); n } fn b58(bytes: &[u8]) -> String { bs58::encode(bytes).into_string() } fn key_of(s: &str) -> Result<[u8; 32], String> { inbox::key_of(s) } fn id_of(s: &str) -> Result<[u8; 16], String> { bs58::decode(s) .into_vec() .map_err(|e| format!("bad id: {e}"))? .as_slice() .try_into() .map_err(|_| "id is not 16 bytes".to_string()) } fn cap(s: &str, max: usize) -> String { if s.len() <= max { return s.to_string(); } let mut end = max; while end > 0 && !s.is_char_boundary(end) { end -= 1; } s[..end].to_string() } fn record_id(owner: &str, space_id: &str) -> String { format!("{owner}:{space_id}") } fn path(app: &tauri::AppHandle) -> Result { Ok(crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet") .join(LEDGER_FILE)) } /// Both the file the ledger lives in and the key its write lock is taken on. const LEDGER_FILE: &str = "spaces.json"; fn load(app: &tauri::AppHandle) -> Ledger { path(app) .ok() .and_then(|p| std::fs::read(p).ok()) .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default() } fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> { let p = path(app)?; if let Some(dir) = p.parent() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } std::fs::write(&p, serde_json::to_vec_pretty(l).map_err(|e| e.to_string())?) .map_err(|e| e.to_string())?; let _ = app.emit(EVENT, ()); Ok(()) } async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> { let adapter = app.state::().freenet.clone(); let info = persona::current(&adapter) .await? .ok_or("create a SoulStream identity first")?; key_of(&info.public_key) } /// The key from the newest epoch below `epoch`. The map is keyed by a /// STRING, so it iterates lexicographically — `.last()` picks "9" over "10" /// and hands back a key from six epochs ago the moment a Space gets busy. fn key_below(rec: &SpaceRecord, epoch: u64) -> Option { rec.keys .iter() .filter_map(|(e, k)| e.parse::().ok().filter(|n| *n < epoch).map(|n| (n, k.clone()))) .max_by_key(|(n, _)| *n) .map(|(_, k)| k) } /// The secret we sign with inside one Space, minted the first time we need /// it. Custody matches the inbox box key: it sits in the app folder, not in /// the delegate — a persona restored on another device mints fresh room keys /// and has to be re-admitted. fn room_secret_of(rec: &SpaceRecord) -> Option<[u8; 32]> { rec.room_secret.as_deref().and_then(|k| key_of(k).ok()) } /// The key we wear in this Space: our persona when we own it, our room key /// otherwise. Every membership and authorship check is against THIS. fn worn_key(rec: &SpaceRecord, me: &[u8; 32]) -> [u8; 32] { if rec.my_role == "owner" { return *me; } room_secret_of(rec).map(|s| sp::member_public(&s)).unwrap_or(*me) } /// The identity to use inside one Space: the key we wear, and the room /// secret behind it when that is not our persona. The proposal board rides a /// Space's roster, so it has to author under exactly the same key. pub(crate) fn worn_identity( app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, me: &[u8; 32], ) -> ([u8; 32], Option<[u8; 32]>) { let l = load(app); match l.spaces.get(&record_id(owner_b58, space_b58)) { Some(rec) if rec.my_role != "owner" => (worn_key(rec, me), room_secret_of(rec)), _ => (*me, None), } } /// The persona behind a room key, when this device is the one that seated it. /// Nobody else can answer this, which is the point. pub(crate) fn persona_behind(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, room: &str) -> Option { let l = load(app); l.spaces.get(&record_id(owner_b58, space_b58))?.roster.get(room).cloned() } async fn sign(app: &tauri::AppHandle, payload: Vec) -> Result, String> { let adapter = app.state::().freenet.clone(); persona::sign(&adapter, sp::SIGN_DOMAIN, payload).await } pub(crate) async fn fetch_state(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16]) -> Result<(sp::SpaceState, bool), String> { let adapter = app.state::().freenet.clone(); let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(owner, space_id)); match adapter.contract_get(&ids.instance, false).await { Ok(bytes) => Ok((sp::merge_space(owner, space_id, &[&bytes]), true)), Err(e) if e.to_string().contains("not found") => Ok((sp::SpaceState::default(), false)), Err(e) => Err(e.to_string()), } } async fn offer(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16], state: &sp::SpaceState, first: bool) -> Result<(), String> { let adapter = app.state::().freenet.clone(); let bytes = bincode::serialize(state).map_err(|e| e.to_string())?; let params = sp::make_params(owner, space_id); if first { adapter.contract_put(SPACE_WASM.to_vec(), ¶ms, bytes, false).await.map(|_| ()) } else { adapter.get_then_update(SPACE_WASM.to_vec(), ¶ms, bytes).await.map(|_| ()) } .map_err(|e| e.to_string()) } fn empty_state(space_id: [u8; 16], checkpoint: Option, messages: Vec) -> sp::SpaceState { sp::SpaceState { schema_version: sp::SCHEMA_VERSION, space_id, checkpoint, messages } } // ---------------------------------------------------------------- views fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me: &[u8; 32], reachable: bool) -> SpaceView { let me_b58 = b58(me); let key_for = |epoch: u64| -> Option<[u8; 32]> { rec.keys.get(&epoch.to_string()).and_then(|k| key_of(k).ok()) }; let Some(c) = &state.checkpoint else { return SpaceView { id: record_id(&rec.owner, &rec.space_id), owner: rec.owner.clone(), space_id: rec.space_id.clone(), name: rec.name.clone(), description: String::new(), private: rec.private, epoch: 0, my_role: rec.my_role.clone(), message_cap: sp::DEFAULT_MESSAGE_CAP, members: vec![], pinned_owner: None, pinned_uuid: None, messages: vec![], can_post: false, has_key: false, reachable, }; }; let is_member = c.members.iter().any(|m| &m.key == me); let has_key = !c.private || key_for(c.epoch).is_some(); SpaceView { id: record_id(&rec.owner, &rec.space_id), owner: rec.owner.clone(), space_id: rec.space_id.clone(), name: c.name.clone(), description: c.description.clone(), private: c.private, epoch: c.epoch, my_role: if &c.owner == me { "owner".into() } else if is_member { "member".into() } else { "none".into() }, message_cap: c.message_cap, members: c .members .iter() .map(|m| MemberView { key: b58(&m.key), role: match m.role { sp::Role::Owner => "owner".into(), sp::Role::Member => "member".into(), }, // Only the owner holds room key → persona, so only the owner // can put a name to a member. To everyone else they are the // key they wear here, which is the whole point. username: slsk_link::username_for_key( app, rec.roster.get(&b58(&m.key)).map(String::as_str).unwrap_or(&b58(&m.key)), ), is_me: &m.key == me, since_epoch: m.since_epoch, }) .collect(), pinned_owner: c.pinned.as_ref().map(|p| b58(&p.owner)), pinned_uuid: c.pinned.as_ref().map(|p| b58(&p.uuid)), messages: state .messages .iter() .map(|m| { let text = if c.private { key_for(m.epoch).and_then(|k| sp::open_body(&k, m)).and_then(|b| String::from_utf8(b).ok()) } else { String::from_utf8(m.body.clone()).ok() }; MessageView { id: b58(&m.msg_id), author: b58(&m.author), username: slsk_link::username_for_key( app, rec.roster.get(&b58(&m.author)).map(String::as_str).unwrap_or(&b58(&m.author)), ), is_me: &m.author == me, created_at: m.created_at, epoch: m.epoch, text, } }) .collect(), can_post: is_member && has_key, has_key, reachable, } .with_me(me_b58) } impl SpaceView { fn with_me(self, _me: String) -> Self { self } } pub fn list(app: &tauri::AppHandle) -> Vec { let l = load(app); let mut v: Vec<_> = l .spaces .values() .map(|r| SpaceListItem { id: record_id(&r.owner, &r.space_id), owner: r.owner.clone(), space_id: r.space_id.clone(), name: r.name.clone(), private: r.private, my_role: r.my_role.clone(), last_epoch: r.last_epoch, }) .collect(); v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); v } // ---------------------------------------------------------------- actions pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, private: bool) -> Result { let me = my_key(app).await?; let name = cap(name.trim(), sp::MAX_NAME_BYTES); if name.is_empty() { return Err("a Space needs a name".into()); } let space_id = rand16(); let mut c = sp::Checkpoint { schema_version: sp::SCHEMA_VERSION, space_id, owner: me, epoch: 1, name: name.clone(), description: cap(description.trim(), sp::MAX_DESC_BYTES), private, members: vec![sp::Member { key: me, role: sp::Role::Owner, since_epoch: 1 }], pinned: None, message_cap: sp::DEFAULT_MESSAGE_CAP, sig: vec![], }; c.sig = sign(app, sp::checkpoint_sign_payload(&c)).await?; let state = empty_state(space_id, Some(c), vec![]); offer(app, &me, &space_id, &state, true).await?; let mut keys = BTreeMap::new(); if private { keys.insert("1".to_string(), b58(&rand32())); } let rec = SpaceRecord { owner: b58(&me), space_id: b58(&space_id), name, private, my_role: "owner".into(), joined_at: now_secs(), keys, last_epoch: 1, room_secret: None, roster: BTreeMap::new(), invited: BTreeMap::new(), join_sent_at: 0, }; let _ledger = ledger::hold(LEDGER_FILE).await; let mut l = load(app); l.spaces.insert(record_id(&rec.owner, &rec.space_id), rec.clone()); save(app, &l)?; log::info!("spaces: created {:?} ({})", rec.name, if private { "private" } else { "public" }); Ok(view_of(app, &rec, &state, &worn_key(&rec, &me), true)) } pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result { let me = my_key(app).await?; let owner = key_of(owner_b58)?; let space_id = id_of(space_b58)?; let _ledger = ledger::hold(LEDGER_FILE).await; let mut l = load(app); let rid = record_id(owner_b58, space_b58); let rec = l.spaces.get(&rid).cloned().ok_or("not a Space this device knows")?; let (state, reachable) = fetch_state(app, &owner, &space_id).await?; if let Some(c) = &state.checkpoint { let r = l.spaces.get_mut(&rid).unwrap(); let mut dirty = r.name != c.name || r.last_epoch != c.epoch || r.private != c.private; r.name = c.name.clone(); r.last_epoch = c.epoch; r.private = c.private; // Adding members never rotates the key: the previous epoch's key // carries forward until a removal issues a new one. A removal DOES // rotate, and the real key arrives sealed — `on_inbox_space_key` // overwrites this one when it lands. if c.private && !r.keys.contains_key(&c.epoch.to_string()) { if let Some(k) = key_below(r, c.epoch) { r.keys.insert(c.epoch.to_string(), k); dirty = true; } } if dirty { let _ = save(app, &l); } } let rec = l.spaces.get(&rid).cloned().unwrap_or(rec); let worn = worn_key(&rec, &me); let seated = state.checkpoint.as_ref().is_some_and(|c| c.members.iter().any(|m| m.key == worn)); retry_join_if_stranded(app, &rec, seated); Ok(view_of(app, &rec, &state, &worn, reachable)) } pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text: &str) -> Result<(), String> { let me = my_key(app).await?; let owner = key_of(owner_b58)?; let space_id = id_of(space_b58)?; let text = text.trim(); if text.is_empty() { return Err("nothing to say".into()); } if text.len() > sp::MAX_BODY_BYTES { return Err(format!("over {} bytes", sp::MAX_BODY_BYTES)); } let (state, _) = fetch_state(app, &owner, &space_id).await?; let c = state.checkpoint.as_ref().ok_or("the Space has not reached this node yet")?; let (worn, room, epoch_key) = { let l = load(app); let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?; let epoch_key = if c.private { Some( rec.keys .get(&c.epoch.to_string()) .and_then(|k| key_of(k).ok()) .ok_or("no key for this epoch yet — the owner has to hand it to you")?, ) } else { None }; (worn_key(rec, &me), room_secret_of(rec), epoch_key) }; if !c.members.iter().any(|m| m.key == worn) { return Err("you are not in this Space's roster".into()); } let mut m = sp::Message { space_id, epoch: c.epoch, msg_id: rand16(), author: worn, created_at: now_secs(), body: text.as_bytes().to_vec(), sig: vec![], }; if let Some(key) = epoch_key { m.body = sp::seal_body(&key, &m); } // A member signs with the key they wear; only an owner reaches the // delegate, because only an owner is their persona here. m.sig = match room { Some(secret) if worn != me => sp::sign_as_member(&secret, &sp::message_sign_payload(&m)), _ => sign(app, sp::message_sign_payload(&m)).await?, }; offer(app, &owner, &space_id, &empty_state(space_id, None, vec![m]), false).await } /// The owner rewrites the checkpoint: a new epoch with `mutate` applied. async fn bump(app: &tauri::AppHandle, me: [u8; 32], owner: [u8; 32], space_id: [u8; 16], mutate: impl FnOnce(&mut sp::Checkpoint)) -> Result { if me != owner { return Err("only the owner can change a Space".into()); } let (state, _) = fetch_state(app, &owner, &space_id).await?; let mut c = state.checkpoint.ok_or("the Space has not reached this node yet")?; c.epoch += 1; mutate(&mut c); if c.members.len() > sp::MAX_MEMBERS { return Err(format!("over {} members", sp::MAX_MEMBERS)); } c.sig = sign(app, sp::checkpoint_sign_payload(&c)).await?; offer(app, &owner, &space_id, &empty_state(space_id, Some(c.clone()), vec![]), false).await?; Ok(c) } pub async fn invite(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, persona_key: &str) -> Result { let me = my_key(app).await?; let owner = key_of(owner_b58)?; let space_id = id_of(space_b58)?; let their = key_of(persona_key)?; if their == me { return Err("that is your own identity".into()); } if !inbox::reachable(app, &their).await { return Err("they have not published an encrypted inbox — the invitation has nowhere to go".into()); } // Nobody joins the roster here any more. We do not yet know the key they // will wear — they mint it — so the invitation is an offer, and the roster // moves when their signed answer comes back (on_inbox_join). let (state, _) = fetch_state(app, &owner, &space_id).await?; let c = state.checkpoint.ok_or("the Space has not reached this node yet")?; if c.owner != me { return Err("only the owner can invite".into()); } 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")?; let mut key = None; if c.private { // They need this to read while they answer; adding them does not // rotate, so the current epoch's key is the right one to hand over. let k = rec .keys .get(&c.epoch.to_string()) .cloned() .or_else(|| key_below(rec, c.epoch + 1)) .unwrap_or_else(|| b58(&rand32())); rec.keys.insert(c.epoch.to_string(), k.clone()); key = Some(key_of(&k)?); } // Only a persona we actually asked can join, or anyone who learned the // Space's address could put themselves on the roster. rec.invited.insert(b58(&their), now_secs()); save(app, &l)?; inbox::send( app, &their, &Cargo::SpaceInvite(SpaceInvite { space_id, owner, name: c.name.clone(), private: c.private, epoch: c.epoch, key }), ) .await?; log::info!("spaces: invited {persona_key} to {:?} — waiting for the key they will wear", c.name); // open() takes this same ledger; the write is done, so let go first. drop(ledger); open(app, owner_b58, space_b58).await } pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, persona_key: &str) -> Result { let me = my_key(app).await?; let owner = key_of(owner_b58)?; let space_id = id_of(space_b58)?; let asked = key_of(persona_key)?; if asked == me { return Err("the owner cannot be removed".into()); } // The caller names a person — a persona key, or the room key the UI shows. // Only the owner's private map can turn one into the other, which is the // point; a Space seated before room keys still holds personas directly. let gone = { let l = load(app); let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?; rec.roster .iter() .find(|(_, persona)| persona.as_str() == persona_key) .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 { 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(); rec.keys.insert(c.epoch.to_string(), b58(&k)); // An inbox is addressed by PERSONA, and since members became // room-local the roster no longer holds one. Only this device can // bridge the two; sealing to the roster key reaches nobody, and every // member who stayed silently keeps a stale key. let targets: Vec<[u8; 32]> = c .members .iter() .filter(|m| m.key != me) .map(|m| rec.roster.get(&b58(&m.key)).and_then(|p| key_of(p).ok()).unwrap_or(m.key)) .collect(); save(app, &l)?; for to in targets { let cargo = Cargo::SpaceKey(SpaceKeyCargo { space_id, owner, epoch: c.epoch, key: k }); if let Err(e) = inbox::send(app, &to, &cargo).await { log::warn!("spaces: could not hand the epoch-{} key to {}: {e}", c.epoch, b58(&to)); } } } else { save(app, &l)?; } // open() takes this same ledger; callers go straight back into it. drop(ledger); Ok(c) } pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pl_owner: Option, pl_uuid: Option) -> Result { let me = my_key(app).await?; let owner = key_of(owner_b58)?; let space_id = id_of(space_b58)?; let pinned = match (pl_owner, pl_uuid) { (Some(o), Some(u)) => Some(sp::PlaylistRef { owner: key_of(&o)?, uuid: id_of(&u)? }), _ => None, }; bump(app, me, owner, space_id, |c| c.pinned = pinned).await?; open(app, owner_b58, space_b58).await } 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); l.spaces.remove(&rid); save(app, &l) } // ---------------------------------------------------------------- inbox cargo pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], inv: SpaceInvite) { if sender != inv.owner { log::warn!("spaces: an invitation was sealed by someone other than the Space's owner"); return; } let rid = record_id(&b58(&inv.owner), &b58(&inv.space_id)); let room = { let _ledger = ledger::hold(LEDGER_FILE).await; let mut l = load(app); let rec = l.spaces.entry(rid).or_insert_with(|| SpaceRecord { owner: b58(&inv.owner), space_id: b58(&inv.space_id), name: cap(&inv.name, sp::MAX_NAME_BYTES), private: inv.private, my_role: "member".into(), joined_at: now_secs(), keys: BTreeMap::new(), last_epoch: 0, room_secret: None, roster: BTreeMap::new(), invited: BTreeMap::new(), join_sent_at: 0, }); rec.name = cap(&inv.name, sp::MAX_NAME_BYTES); rec.private = inv.private; if let Some(k) = inv.key { rec.keys.insert(inv.epoch.to_string(), b58(&k)); } // One key per Space, minted once and kept. Re-minting on a second // invitation would strand the roster entry we already have. let secret = match room_secret_of(rec) { Some(s) => s, None => { let s = rand32(); rec.room_secret = Some(b58(&s)); s } }; if let Err(e) = save(app, &l) { log::warn!("spaces: could not keep an invitation: {e}"); return; } secret }; log::info!("spaces: invited to {:?} by {}", inv.name, b58(&inv.owner)); send_join(app, &inv.owner, &inv.space_id, &room).await; } /// Tell the owner which key we will wear: the key itself, and a persona /// signature over it so they know it is us. Sealed — that pairing is exactly /// what must never be published. Best-effort, and stamped so `open` can try /// again; being un-seated is the one state a member cannot fix by hand. async fn send_join(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16], room: &[u8; 32]) { let room_key = sp::member_public(room); let proof = match sign(app, sp::join_proof_payload(space_id, owner, &room_key)).await { Ok(p) => p, Err(e) => { log::warn!("spaces: could not sign a join: {e}"); return; } }; let join = Cargo::SpaceJoin(SpaceJoin { space_id: *space_id, owner: *owner, room_key, proof }); match inbox::send(app, owner, &join).await { Ok(()) => { let _ledger = ledger::hold(LEDGER_FILE).await; let mut l = load(app); if let Some(rec) = l.spaces.get_mut(&record_id(&b58(owner), &b58(space_id))) { rec.join_sent_at = now_secs(); let _ = save(app, &l); } log::info!("spaces: told {} which key we will wear", b58(owner)); } Err(e) => log::warn!("spaces: could not answer an invitation: {e} — will try again on open"), } } /// 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; fn retry_join_if_stranded(app: &tauri::AppHandle, rec: &SpaceRecord, seated: bool) { if seated || rec.my_role == "owner" { return; } let Some(room) = room_secret_of(rec) else { return }; if now_secs().saturating_sub(rec.join_sent_at) < JOIN_RETRY_SECS { return; } let (Ok(owner), Ok(space_id)) = (key_of(&rec.owner), id_of(&rec.space_id)) else { return }; let handle = app.clone(); tauri::async_runtime::spawn(async move { send_join(&handle, &owner, &space_id, &room).await; }); } /// The owner's side of the handshake: someone we invited says which key they /// will wear. Verify it is really them, then put THAT key — and only that key /// — on the published roster. pub(crate) async fn on_inbox_join(app: &tauri::AppHandle, sender: [u8; 32], j: SpaceJoin) { let rid = record_id(&b58(&j.owner), &b58(&j.space_id)); let owner_b58 = b58(&j.owner); let space_b58 = b58(&j.space_id); { 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; } if !rec.invited.contains_key(&b58(&sender)) { log::warn!("spaces: a join arrived from someone this Space never invited"); return; } } if !sp::join_proof_valid(&j.space_id, &j.owner, &j.room_key, &sender, &j.proof) { log::warn!("spaces: a join did not prove the room key belongs to its sender"); return; } let me = match my_key(app).await { Ok(k) => k, Err(_) => return, }; let c = match bump(app, me, j.owner, j.space_id, |c| { if !c.members.iter().any(|m| m.key == j.room_key) { let e = c.epoch; c.members.push(sp::Member { key: j.room_key, role: sp::Role::Member, since_epoch: e }); } }) .await { Ok(c) => c, Err(e) => { log::warn!("spaces: could not seat a joiner: {e}"); return; } }; let _ledger = ledger::hold(LEDGER_FILE).await; let mut l = load(app); let Some(rec) = l.spaces.get_mut(&rid) else { return }; // Who is behind the key, kept here and nowhere else. rec.roster.insert(b58(&j.room_key), b58(&sender)); rec.invited.remove(&b58(&sender)); rec.last_epoch = c.epoch; if let Err(e) = save(app, &l) { log::warn!("spaces: seated a joiner but could not record it: {e}"); } log::info!("spaces: seated a member in {:?} at epoch {} ({owner_b58}/{space_b58})", c.name, c.epoch); } pub(crate) async fn on_inbox_space_key(app: &tauri::AppHandle, sender: [u8; 32], sk: SpaceKeyCargo) { if sender != sk.owner { return; } let rid = record_id(&b58(&sk.owner), &b58(&sk.space_id)); let _ledger = ledger::hold(LEDGER_FILE).await; let mut l = load(app); let Some(rec) = l.spaces.get_mut(&rid) else { return }; rec.keys.insert(sk.epoch.to_string(), b58(&sk.key)); let _ = save(app, &l); } // ---------------------------------------------------------------- commands #[tauri::command] pub fn spaces_list(app: tauri::AppHandle) -> Vec { list(&app) } #[tauri::command] pub async fn space_create(app: tauri::AppHandle, name: String, description: String, private: bool) -> Result { create(&app, &name, &description, private).await } #[tauri::command] pub async fn space_open(app: tauri::AppHandle, owner: String, space: String) -> Result { open(&app, &owner, &space).await } #[tauri::command] pub async fn space_post(app: tauri::AppHandle, owner: String, space: String, text: String) -> Result<(), String> { post(&app, &owner, &space, &text).await } #[tauri::command] pub async fn space_invite(app: tauri::AppHandle, owner: String, space: String, persona_key: String) -> Result { invite(&app, &owner, &space, &persona_key).await } #[tauri::command] pub async fn space_remove_member(app: tauri::AppHandle, owner: String, space: String, persona_key: String) -> Result { remove_member(&app, &owner, &space, &persona_key).await } #[tauri::command] pub async fn space_set_pinned(app: tauri::AppHandle, owner: String, space: String, playlist_owner: Option, playlist_uuid: Option) -> Result { set_pinned(&app, &owner, &space, playlist_owner, playlist_uuid).await } #[tauri::command] pub async fn space_leave(app: tauri::AppHandle, owner: String, space: String) -> Result<(), String> { leave(&app, &owner, &space).await } #[cfg(test)] mod tests { use super::*; fn rec(role: &str, secret: Option<[u8; 32]>) -> SpaceRecord { SpaceRecord { owner: b58(&[1u8; 32]), space_id: b58(&[2u8; 16]), name: "Late Night Ambient".into(), private: true, my_role: role.into(), joined_at: 0, keys: BTreeMap::new(), last_epoch: 1, room_secret: secret.map(|s| b58(&s)), roster: BTreeMap::new(), invited: BTreeMap::new(), join_sent_at: 0, } } /// Which key we wear where. An owner is their persona — the Space's /// address is derived from it, so there is nothing to hide there — and a /// member is the key minted for that Space and no other. #[test] fn a_member_wears_a_room_key_and_an_owner_wears_their_persona() { let me = [5u8; 32]; assert_eq!(worn_key(&rec("owner", None), &me), me, "an owner is their persona here"); assert_eq!(worn_key(&rec("owner", Some([9u8; 32])), &me), me, "even holding a room key"); let worn = worn_key(&rec("member", Some([9u8; 32])), &me); assert_eq!(worn, sp::member_public(&[9u8; 32])); assert_ne!(worn, me, "a member must not appear as their persona"); // Two Spaces, two keys: one roster cannot be correlated with another. assert_ne!( worn_key(&rec("member", Some([9u8; 32])), &me), worn_key(&rec("member", Some([8u8; 32])), &me), ); // Before the mint there is nothing to wear but the persona, and the // roster check then simply fails rather than posting as one. assert_eq!(worn_key(&rec("member", None), &me), me); } /// The mapping the design exists to keep off the network stays local, and /// is only ever answerable by the device that seated the member. #[test] fn only_the_seating_device_can_name_a_room_key() { let mut r = rec("owner", None); let room = b58(&sp::member_public(&[9u8; 32])); r.roster.insert(room.clone(), b58(&[7u8; 32])); assert_eq!(r.roster.get(&room).map(String::as_str), Some(b58(&[7u8; 32]).as_str())); // A record that never seated them knows nothing. assert!(rec("member", Some([9u8; 32])).roster.get(&room).is_none()); } /// Epoch keys are keyed by a STRING, and a BTreeMap orders those /// lexicographically — "9" sorts after "10". A Space only has to reach its /// tenth epoch for the wrong key to carry forward, and the symptom is /// messages that simply will not open. #[test] fn the_newest_epoch_key_is_found_by_number_not_by_string() { let mut r = rec("member", None); for (e, k) in [(1u64, "one"), (9, "nine"), (10, "ten"), (11, "eleven")] { r.keys.insert(e.to_string(), k.into()); } assert_eq!(key_below(&r, 12).as_deref(), Some("eleven")); assert_eq!(key_below(&r, 11).as_deref(), Some("ten"), "lexicographic order would say \"nine\""); assert_eq!(key_below(&r, 10).as_deref(), Some("nine")); assert_eq!(key_below(&r, 1), None, "nothing precedes the first epoch"); } #[test] fn contract_generation_is_pinned() { let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(&[7u8; 32], &[1u8; 16])); assert_eq!(ids.code_hash, "BQc5bg1QEfTsgkog1WsS3di7fkwVSd8VJQpNUHdEMH3a"); } }