diff --git a/Cargo.lock b/Cargo.lock index 4b0eb16..c07301b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,7 +771,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] diff --git a/crates/persona-state/Cargo.toml b/crates/persona-state/Cargo.toml index e084ae8..b9949a7 100644 --- a/crates/persona-state/Cargo.toml +++ b/crates/persona-state/Cargo.toml @@ -13,8 +13,10 @@ serde.workspace = true bincode = "1" ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] } blake3 = "1" -x25519-dalek = { version = "2", features = ["static_secrets"] } -chacha20poly1305 = "0.10" +x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "alloc", "precomputed-tables"] } +# no default features: the OS-RNG feature would drag getrandom into the +# wasm32 contract build; this crate draws no entropy of its own anyway +chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] } [dev-dependencies] ed25519-dalek = { version = "2", features = ["rand_core"] } diff --git a/crates/persona-state/src/lib.rs b/crates/persona-state/src/lib.rs index be111cf..2073bfc 100644 --- a/crates/persona-state/src/lib.rs +++ b/crates/persona-state/src/lib.rs @@ -19,6 +19,7 @@ pub mod slsk_link; pub mod friend; pub mod playlist; pub mod inbox; +pub mod space; pub const SCHEMA_VERSION: u32 = 1; pub const SIGN_DOMAIN: &str = "soulstream.profile/v1"; diff --git a/crates/persona-state/src/space.rs b/crates/persona-state/src/space.rs new file mode 100644 index 0000000..230754d --- /dev/null +++ b/crates/persona-state/src/space.rs @@ -0,0 +1,386 @@ +//! A SoulStream Space — the persistent community the F6 decision chose to +//! build on River's patterns rather than River's format: ONE contract per +//! Space addressed by (owner, space id); an owner-signed CHECKPOINT that IS +//! the authority (name, mode, the member roster, the pinned playlist) with +//! a monotonic epoch, so a removal is a new epoch without that member and +//! nothing about membership is ever inferred from who talked recently; a +//! bounded window of member-signed MESSAGES admitted only from the current +//! roster; and, for a private Space, message bodies sealed to a per-epoch +//! space key the owner hands members through their encrypted inboxes — +//! rotated on every removal. Deterministic merge, canonical-only +//! validation: the F5 bouncer, again. BYTE-COMPATIBILITY IS THE CONTRACT: +//! `crates/space-contract` compiles this very module into the wasm. +//! +//! Signing rides `soulstream.friend/v1` like the inbox does (the delegate +//! generation in the app has no space domain yet). + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const SCHEMA_VERSION: u32 = 1; +pub const PARAMS_FAMILY: &[u8] = b"soulstream.space.v1"; +pub const SIGN_DOMAIN: &str = "soulstream.friend/v1"; +pub const MAX_STATE_BYTES: usize = 1024 * 1024; +pub const MAX_MEMBERS: usize = 256; +pub const MAX_MESSAGES: usize = 500; +pub const DEFAULT_MESSAGE_CAP: u32 = 200; +pub const MAX_BODY_BYTES: usize = 4096; +pub const MAX_NAME_BYTES: usize = 64; +pub const MAX_DESC_BYTES: usize = 512; +pub const MAX_PER_AUTHOR_UNCAPPED: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Role { + Owner, + Member, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Member { + pub key: [u8; 32], + pub role: Role, + pub since_epoch: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlaylistRef { + pub owner: [u8; 32], + pub uuid: [u8; 16], +} + +/// The owner's word on what the Space is and who is in it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Checkpoint { + pub schema_version: u32, + pub space_id: [u8; 16], + pub owner: [u8; 32], + pub epoch: u64, + pub name: String, + pub description: String, + pub private: bool, + pub members: Vec, + pub pinned: Option, + pub message_cap: u32, + pub sig: Vec, +} + +#[derive(Serialize)] +struct CheckpointSigBody<'a> { + schema_version: u32, + space_id: &'a [u8; 16], + owner: &'a [u8; 32], + epoch: u64, + name: &'a str, + description: &'a str, + private: bool, + members: &'a [Member], + pinned: &'a Option, + message_cap: u32, +} + +pub fn checkpoint_sig_body(c: &Checkpoint) -> Vec { + bincode::serialize(&CheckpointSigBody { + schema_version: c.schema_version, + space_id: &c.space_id, + owner: &c.owner, + epoch: c.epoch, + name: &c.name, + description: &c.description, + private: c.private, + members: &c.members, + pinned: &c.pinned, + message_cap: c.message_cap, + }) + .expect("serializes") +} +pub fn checkpoint_sign_payload(c: &Checkpoint) -> Vec { + blake3::hash(&checkpoint_sig_body(c)).as_bytes().to_vec() +} + +/// One line in the Space. Public: `body` is UTF-8. Private: `body` is the +/// ChaCha20-Poly1305 seal of the UTF-8 under the epoch's space key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Message { + pub space_id: [u8; 16], + pub epoch: u64, + pub msg_id: [u8; 16], + pub author: [u8; 32], + pub created_at: u64, + pub body: Vec, + pub sig: Vec, +} + +#[derive(Serialize)] +struct MessageSigBody<'a> { + space_id: &'a [u8; 16], + epoch: u64, + msg_id: &'a [u8; 16], + author: &'a [u8; 32], + created_at: u64, + body: &'a [u8], +} + +pub fn message_sig_body(m: &Message) -> Vec { + bincode::serialize(&MessageSigBody { + space_id: &m.space_id, + epoch: m.epoch, + msg_id: &m.msg_id, + author: &m.author, + created_at: m.created_at, + body: &m.body, + }) + .expect("serializes") +} +pub fn message_sign_payload(m: &Message) -> Vec { + blake3::hash(&message_sig_body(m)).as_bytes().to_vec() +} + +/// The AAD for a sealed body: everything about the message but the body. +fn message_aad(m: &Message) -> Vec { + let mut v = Vec::with_capacity(80); + v.extend_from_slice(&m.space_id); + v.extend_from_slice(&m.epoch.to_le_bytes()); + v.extend_from_slice(&m.msg_id); + v.extend_from_slice(&m.author); + v.extend_from_slice(&m.created_at.to_le_bytes()); + v +} +fn body_key(space_key: &[u8; 32]) -> [u8; 32] { + blake3::derive_key("soulstream.space-body.v1", space_key) +} +fn body_nonce(msg_id: &[u8; 16]) -> [u8; 12] { + blake3::hash(msg_id).as_bytes()[..12].try_into().unwrap() +} + +/// Seal a private message body. Call BEFORE signing (the signature covers +/// the sealed bytes). `m.body` must hold the plaintext going in. +pub fn seal_body(space_key: &[u8; 32], m: &Message) -> Vec { + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + let key = body_key(space_key); + ChaCha20Poly1305::new(Key::from_slice(&key)) + .encrypt( + Nonce::from_slice(&body_nonce(&m.msg_id)), + Payload { msg: &m.body, aad: &message_aad(m) }, + ) + .expect("seal") +} + +/// Open a private message body. None = wrong key for this epoch, or tampered. +pub fn open_body(space_key: &[u8; 32], m: &Message) -> Option> { + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + let key = body_key(space_key); + ChaCha20Poly1305::new(Key::from_slice(&key)) + .decrypt( + Nonce::from_slice(&body_nonce(&m.msg_id)), + Payload { msg: &m.body, aad: &message_aad(m) }, + ) + .ok() +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpaceState { + pub schema_version: u32, + pub space_id: [u8; 16], + pub checkpoint: Option, + pub messages: Vec, +} + +pub fn make_params(owner: &[u8; 32], space_id: &[u8; 16]) -> Vec { + let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48); + p.extend_from_slice(PARAMS_FAMILY); + p.extend_from_slice(owner); + p.extend_from_slice(space_id); + p +} + +pub fn params_parts(params: &[u8]) -> Option<([u8; 32], [u8; 16])> { + let t = PARAMS_FAMILY.len(); + if params.len() != t + 48 || ¶ms[..t] != PARAMS_FAMILY { + return None; + } + Some(( + params[t..t + 32].try_into().unwrap(), + params[t + 32..t + 48].try_into().unwrap(), + )) +} + +pub fn checkpoint_valid(owner: &[u8; 32], space_id: &[u8; 16], c: &Checkpoint) -> bool { + c.schema_version == SCHEMA_VERSION + && &c.space_id == space_id + && &c.owner == owner + && c.epoch >= 1 + && c.name.len() <= MAX_NAME_BYTES + && c.description.len() <= MAX_DESC_BYTES + && !c.members.is_empty() + && c.members.len() <= MAX_MEMBERS + && c.message_cap >= 1 + && c.message_cap as usize <= MAX_MESSAGES + && c.members.iter().any(|m| &m.key == owner && m.role == Role::Owner) + && crate::verify_sig_in(SIGN_DOMAIN, owner, &checkpoint_sign_payload(c), &c.sig) +} + +pub fn message_valid(space_id: &[u8; 16], c: &Checkpoint, m: &Message) -> bool { + &m.space_id == space_id + && m.epoch <= c.epoch + && m.body.len() <= MAX_BODY_BYTES + 16 + && c.members.iter().any(|mem| mem.key == m.author) + && crate::verify_sig_in(SIGN_DOMAIN, &m.author, &message_sign_payload(m), &m.sig) +} + +/// Deterministic multi-writer merge: the highest valid (epoch, hash) +/// checkpoint wins; messages are admitted only from ITS roster, deduped by +/// id (smaller content hash wins a collision), fairly bounded per author, +/// then the newest `message_cap` kept in (created_at, id) order. +pub fn merge_space(owner: &[u8; 32], space_id: &[u8; 16], candidates: &[&[u8]]) -> SpaceState { + let mut best: Option<(u64, [u8; 32], Checkpoint)> = None; + let mut parsed: Vec = Vec::new(); + for bytes in candidates { + if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES { + continue; + } + let Ok(st) = bincode::deserialize::(bytes) else { continue }; + if st.schema_version != SCHEMA_VERSION || &st.space_id != space_id { + continue; + } + if let Some(c) = &st.checkpoint { + if checkpoint_valid(owner, space_id, c) { + let rank = (c.epoch, *blake3::hash(&checkpoint_sig_body(c)).as_bytes()); + let better = match &best { + None => true, + Some((e, h, _)) => rank > (*e, *h), + }; + if better { + best = Some((rank.0, rank.1, c.clone())); + } + } + } + parsed.push(st); + } + let Some((_, _, checkpoint)) = best else { + return SpaceState { schema_version: SCHEMA_VERSION, space_id: *space_id, checkpoint: None, messages: vec![] }; + }; + + let mut by_id: BTreeMap<[u8; 16], (Message, [u8; 32])> = BTreeMap::new(); + for st in &parsed { + for m in &st.messages { + if !message_valid(space_id, &checkpoint, m) { + continue; + } + let h = *blake3::hash(&bincode::serialize(m).unwrap()).as_bytes(); + match by_id.get(&m.msg_id) { + Some((_, existing)) if existing <= &h => {} + _ => { + by_id.insert(m.msg_id, (m.clone(), h)); + } + } + } + } + let mut all: Vec = by_id.into_values().map(|(m, _)| m).collect(); + all.sort_by_key(|m| (m.created_at, m.msg_id)); + // Per-author fairness before the global window: one voice cannot fill + // the whole window and push everyone else out. + let mut per_author: BTreeMap<[u8; 32], usize> = BTreeMap::new(); + let mut kept: Vec = Vec::with_capacity(all.len()); + for m in all.into_iter().rev() { + let n = per_author.entry(m.author).or_insert(0); + if *n < MAX_PER_AUTHOR_UNCAPPED { + *n += 1; + kept.push(m); + } + } + kept.reverse(); + let cap = checkpoint.message_cap as usize; + if kept.len() > cap { + kept.drain(..kept.len() - cap); + } + SpaceState { schema_version: SCHEMA_VERSION, space_id: *space_id, checkpoint: Some(checkpoint), messages: kept } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + fn sign(key: &SigningKey, payload: &[u8]) -> Vec { + key.sign(&crate::signable(SIGN_DOMAIN, payload)).to_bytes().to_vec() + } + fn checkpoint(owner: &SigningKey, epoch: u64, members: &[&SigningKey], private: bool) -> Checkpoint { + let mut c = Checkpoint { + schema_version: SCHEMA_VERSION, + space_id: [4; 16], + owner: owner.verifying_key().to_bytes(), + epoch, + name: "Late Night Ambient".into(), + description: String::new(), + private, + members: std::iter::once(Member { key: owner.verifying_key().to_bytes(), role: Role::Owner, since_epoch: 1 }) + .chain(members.iter().map(|k| Member { key: k.verifying_key().to_bytes(), role: Role::Member, since_epoch: epoch })) + .collect(), + pinned: None, + message_cap: DEFAULT_MESSAGE_CAP, + sig: vec![], + }; + c.sig = sign(owner, &checkpoint_sign_payload(&c)); + c + } + fn message(author: &SigningKey, epoch: u64, id: u8, at: u64, key: Option<&[u8; 32]>) -> Message { + let mut m = Message { + space_id: [4; 16], + epoch, + msg_id: [id; 16], + author: author.verifying_key().to_bytes(), + created_at: at, + body: b"hello".to_vec(), + sig: vec![], + }; + if let Some(k) = key { + m.body = seal_body(k, &m); + } + m.sig = sign(author, &message_sign_payload(&m)); + m + } + fn state(c: Option<&Checkpoint>, msgs: &[Message]) -> Vec { + bincode::serialize(&SpaceState { schema_version: SCHEMA_VERSION, space_id: [4; 16], checkpoint: c.cloned(), messages: msgs.to_vec() }).unwrap() + } + + #[test] + fn roster_is_the_authority_and_removal_is_an_epoch() { + let owner = SigningKey::from_bytes(&[1; 32]); + let bob = SigningKey::from_bytes(&[2; 32]); + let mallory = SigningKey::from_bytes(&[3; 32]); + let ok = owner.verifying_key().to_bytes(); + let c1 = checkpoint(&owner, 1, &[&bob], false); + let m_bob = message(&bob, 1, 10, 100, None); + let m_mal = message(&mallory, 1, 11, 101, None); + let merged = merge_space(&ok, &[4; 16], &[&state(Some(&c1), &[]), &state(None, &[m_bob.clone(), m_mal])]); + assert_eq!(merged.messages.len(), 1, "an off-roster author never enters"); + // the canonical state merges to itself + let canon = bincode::serialize(&merged).unwrap(); + assert_eq!(bincode::serialize(&merge_space(&ok, &[4; 16], &[&canon])).unwrap(), canon); + // a newer epoch without bob drops bob's messages; an old checkpoint cannot restore him + let c2 = checkpoint(&owner, 2, &[], false); + let after = merge_space(&ok, &[4; 16], &[&canon, &state(Some(&c2), &[])]); + assert_eq!(after.checkpoint.as_ref().map(|c| c.epoch), Some(2)); + assert!(after.messages.is_empty()); + let replay = merge_space(&ok, &[4; 16], &[&bincode::serialize(&after).unwrap(), &state(Some(&c1), &[m_bob])]); + assert_eq!(replay.checkpoint.as_ref().map(|c| c.epoch), Some(2)); + // a checkpoint signed by a non-owner is not a checkpoint + let mut forged = checkpoint(&owner, 3, &[&mallory], false); + forged.sig = sign(&mallory, &checkpoint_sign_payload(&forged)); + assert!(!checkpoint_valid(&ok, &[4; 16], &forged)); + } + + #[test] + fn private_bodies_open_only_with_the_epoch_key() { + let owner = SigningKey::from_bytes(&[1; 32]); + let key = [9u8; 32]; + let m = message(&owner, 1, 7, 100, Some(&key)); + assert_eq!(open_body(&key, &m).as_deref(), Some(&b"hello"[..])); + assert_eq!(open_body(&[8u8; 32], &m), None); + let mut t = m.clone(); + t.created_at += 1; + assert_eq!(open_body(&key, &t), None, "the AAD binds the header"); + } +} diff --git a/crates/space-contract/Cargo.lock b/crates/space-contract/Cargo.lock new file mode 100644 index 0000000..bc37639 --- /dev/null +++ b/crates/space-contract/Cargo.lock @@ -0,0 +1,1449 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flatbuffers" +version = "24.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096" +dependencies = [ + "bitflags", + "rustc_version", +] + +[[package]] +name = "freenet-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26e5c6bee9ffa0e2683bc8b41a7bd9f1e57e7fd49ac4f6ae433e5a82e9006df" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "freenet-stdlib" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62aeba7634d1ae5c2d02f78db33ee40442511858275be5be2099d24a13b2559" +dependencies = [ + "bincode", + "blake3", + "bs58", + "byteorder", + "bytes", + "chrono", + "flatbuffers", + "freenet-macros", + "futures", + "semver", + "serde", + "serde_json", + "serde_with", + "thiserror", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "persona-state" +version = "1.6.0-alpha" +dependencies = [ + "bincode", + "blake3", + "chacha20poly1305", + "ed25519-dalek", + "serde", + "x25519-dalek", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.1", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "soulstream-space-contract" +version = "0.1.0" +dependencies = [ + "bincode", + "blake3", + "freenet-stdlib", + "persona-state", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/space-contract/Cargo.toml b/crates/space-contract/Cargo.toml new file mode 100644 index 0000000..fb09f83 --- /dev/null +++ b/crates/space-contract/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "soulstream-space-contract" +version = "0.1.0" +edition = "2021" +description = "The SoulStream Space contract — a thin shell over persona_state::space::merge_space. Built to wasm32-unknown-unknown and bundled into the app; its code hash is a commitment (see src-tauri/assets)." + +[lib] +crate-type = ["cdylib"] + +[dependencies] +freenet-stdlib = { version = "=0.8.5" } +persona-state = { path = "../persona-state" } +bincode = "1" +blake3 = "1" + +[features] +default = ["freenet-main-contract", "contract"] +freenet-main-contract = [] +contract = ["freenet-stdlib/contract"] +trace = ["freenet-stdlib/trace"] + +[profile.release] +strip = true +lto = true + +# Its own workspace: this crate targets wasm32 and must never join the +# app's host build. +[workspace] diff --git a/crates/space-contract/src/lib.rs b/crates/space-contract/src/lib.rs new file mode 100644 index 0000000..bbf6862 --- /dev/null +++ b/crates/space-contract/src/lib.rs @@ -0,0 +1,118 @@ +//! The SoulStream Space contract. Multi-writer: the owner offers +//! checkpoints, members offer messages; `persona_state::space::merge_space` +//! is the bouncer (owner signature, roster, dedup, per-author fairness, +//! the window). Deterministic by construction, so every delivery order +//! converges; a state that does not merge to itself is refused. +//! +//! Build: `cargo build --release --target wasm32-unknown-unknown` in this +//! directory; copy `target/wasm32-unknown-unknown/release/ +//! soulstream_space_contract.wasm` to `src-tauri/assets/space_contract.wasm` +//! and re-pin the code hash in `src-tauri/src/spaces.rs`. + +use freenet_stdlib::prelude::*; +use persona_state::space as t; + +pub struct Contract; + +fn parts(parameters: &Parameters<'static>) -> Result<([u8; 32], [u8; 16]), ContractError> { + t::params_parts(parameters.as_ref()) + .ok_or_else(|| ContractError::Other("params are not family+owner+space".into())) +} + +fn collect<'a>(state: &'a State<'static>, data: &'a [UpdateData<'static>]) -> (Vec<&'a [u8]>, bool) { + let mut candidates: Vec<&[u8]> = Vec::new(); + if !state.as_ref().is_empty() { + candidates.push(state.as_ref()); + } + let mut any_parse = false; + let mut push = |b: &'a [u8]| { + if !b.is_empty() { + any_parse |= bincode::deserialize::(b).is_ok(); + candidates.push(b); + } + }; + for ud in data { + match ud { + UpdateData::State(s) => push(s.as_ref()), + UpdateData::Delta(d) => push(d.as_ref()), + UpdateData::StateAndDelta { state, delta } => { + push(state.as_ref()); + push(delta.as_ref()); + } + _ => {} + } + } + (candidates, any_parse) +} + +#[contract] +impl ContractInterface for Contract { + fn validate_state( + parameters: Parameters<'static>, + state: State<'static>, + _related: RelatedContracts<'static>, + ) -> Result { + let (owner, space_id) = parts(¶meters)?; + let bytes = state.as_ref(); + if bytes.is_empty() { + return Ok(ValidateResult::Valid); + } + if bytes.len() > t::MAX_STATE_BYTES { + return Err(ContractError::Other(format!( + "space state {} bytes over cap {}", + bytes.len(), + t::MAX_STATE_BYTES + ))); + } + let merged = t::merge_space(&owner, &space_id, &[bytes]); + let canon = bincode::serialize(&merged).map_err(|e| ContractError::Deser(e.to_string()))?; + if canon != bytes { + return Err(ContractError::Other( + "state is not canonical (unsigned, off-roster, or over the window)".into(), + )); + } + Ok(ValidateResult::Valid) + } + + fn update_state( + parameters: Parameters<'static>, + state: State<'static>, + data: Vec>, + ) -> Result, ContractError> { + let (owner, space_id) = parts(¶meters)?; + let (candidates, any_parse) = collect(&state, &data); + if !any_parse && !data.is_empty() { + return Err(ContractError::InvalidUpdate); + } + let merged = t::merge_space(&owner, &space_id, &candidates); + let bytes = bincode::serialize(&merged).map_err(|e| ContractError::Deser(e.to_string()))?; + Ok(UpdateModification::valid(State::from(bytes))) + } + + fn summarize_state( + parameters: Parameters<'static>, + state: State<'static>, + ) -> Result, ContractError> { + let _ = parts(¶meters)?; + if state.as_ref().is_empty() { + return Ok(StateSummary::from(Vec::new())); + } + Ok(StateSummary::from(blake3::hash(state.as_ref()).as_bytes().to_vec())) + } + + fn get_state_delta( + parameters: Parameters<'static>, + state: State<'static>, + summary: StateSummary<'static>, + ) -> Result, ContractError> { + let _ = parts(¶meters)?; + if state.as_ref().is_empty() { + return Ok(StateDelta::from(Vec::new())); + } + if summary.as_ref() == blake3::hash(state.as_ref()).as_bytes() { + Ok(StateDelta::from(Vec::new())) + } else { + Ok(StateDelta::from(state.as_ref().to_vec())) + } + } +} diff --git a/src-tauri/assets/space_contract.wasm b/src-tauri/assets/space_contract.wasm new file mode 100644 index 0000000..752a5d4 Binary files /dev/null and b/src-tauri/assets/space_contract.wasm differ diff --git a/src-tauri/src/inbox.rs b/src-tauri/src/inbox.rs index 77a294d..dc82000 100644 --- a/src-tauri/src/inbox.rs +++ b/src-tauri/src/inbox.rs @@ -49,6 +49,29 @@ pub enum Cargo { FriendAssertion(fr::FriendAssertion), FriendDecline([u8; 16]), Share(ShareCargo), + SpaceInvite(SpaceInvite), + SpaceKey(SpaceKeyCargo), +} + +/// "You are on the roster now, here is where the Space is" — and, for a +/// private Space, the epoch's key. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceInvite { + pub space_id: [u8; 16], + pub owner: [u8; 32], + pub name: String, + pub private: bool, + pub epoch: u64, + pub key: Option<[u8; 32]>, +} + +/// A rotated space key for members who stay after a removal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceKeyCargo { + pub space_id: [u8; 16], + pub owner: [u8; 32], + pub epoch: u64, + pub key: [u8; 32], } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -410,6 +433,8 @@ async fn dispatch(app: &tauri::AppHandle, sender: [u8; 32], cargo: Cargo) { Cargo::FriendAssertion(a) => crate::friends::on_inbox_assertion(app, sender, a).await, Cargo::FriendDecline(id) => crate::friends::on_inbox_decline(app, sender, id), Cargo::Share(s) => crate::shared_playlists::on_inbox_share(app, sender, s), + Cargo::SpaceInvite(i) => crate::spaces::on_inbox_invite(app, sender, i), + Cargo::SpaceKey(k) => crate::spaces::on_inbox_space_key(app, sender, k), } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5939d85..69fd79a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,6 +26,7 @@ mod shared_playlists; mod shazam; mod slsk_link; mod smtc; +mod spaces; mod state; mod thumbbar; mod vocal_sep; @@ -150,6 +151,14 @@ pub fn run() { inbox::inbox_status, inbox::inbox_publish, inbox::inbox_check, + spaces::spaces_list, + spaces::space_create, + spaces::space_open, + spaces::space_post, + spaces::space_invite, + spaces::space_remove_member, + spaces::space_set_pinned, + spaces::space_leave, commands::buddy_list, commands::buddy_add, commands::buddy_remove, diff --git a/src-tauri/src/spaces.rs b/src-tauri/src/spaces.rs new file mode 100644 index 0000000..10cde56 --- /dev/null +++ b/src-tauri/src/spaces.rs @@ -0,0 +1,637 @@ +//! 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, SpaceKeyCargo}; +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, +} + +#[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(app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("freenet") + .join("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) +} +async fn sign(app: &tauri::AppHandle, payload: Vec) -> Result, String> { + let adapter = app.state::().freenet.clone(); + persona::sign(&adapter, sp::SIGN_DOMAIN, payload).await +} + +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(), + }, + username: slsk_link::username_for_key(app, &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, &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, + }; + 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, &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 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 changed = 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. + if c.private && !r.keys.contains_key(&c.epoch.to_string()) { + if let Some((_, k)) = r.keys.iter().filter(|(e, _)| e.parse::().unwrap_or(0) < c.epoch).last() { + let k = k.clone(); + r.keys.insert(c.epoch.to_string(), k); + } + } + if changed { + let _ = save(app, &l); + } + } + let rec = l.spaces.get(&rid).cloned().unwrap_or(rec); + Ok(view_of(app, &rec, &state, &me, 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")?; + if !c.members.iter().any(|m| m.key == me) { + 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: me, + created_at: now_secs(), + body: text.as_bytes().to_vec(), + sig: vec![], + }; + if c.private { + let l = load(app); + let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?; + let key = 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")?; + m.body = sp::seal_body(&key, &m); + } + m.sig = 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()); + } + let c = bump(app, me, owner, space_id, |c| { + if !c.members.iter().any(|m| m.key == their) { + let e = c.epoch; + c.members.push(sp::Member { key: their, role: sp::Role::Member, since_epoch: e }); + } + }) + .await?; + let rid = record_id(owner_b58, space_b58); + 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 { + let prev = rec.keys.get(&(c.epoch - 1).to_string()).cloned().or_else(|| rec.keys.values().last().cloned()); + let k = prev.unwrap_or_else(|| b58(&rand32())); + rec.keys.insert(c.epoch.to_string(), k.clone()); + key = Some(key_of(&k)?); + } + rec.last_epoch = c.epoch; + 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 {:?} (epoch {})", c.name, c.epoch); + 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 gone = key_of(persona_key)?; + if gone == me { + return Err("the owner cannot be removed".into()); + } + 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 mut l = load(app); + let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?; + rec.last_epoch = c.epoch; + 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)); + save(app, &l)?; + for m in c.members.iter().filter(|m| m.key != me) { + let cargo = Cargo::SpaceKey(SpaceKeyCargo { space_id, owner, epoch: c.epoch, key: k }); + if let Err(e) = inbox::send(app, &m.key, &cargo).await { + log::warn!("spaces: could not hand the epoch-{} key to {}: {e}", c.epoch, b58(&m.key)); + } + } + } else { + save(app, &l)?; + } + open(app, owner_b58, space_b58).await +} + +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 fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> { + 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) +} + +// ---------------------------------------------------------------- inbox cargo + +pub(crate) 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 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, + }); + 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)); + } + if let Err(e) = save(app, &l) { + log::warn!("spaces: could not keep an invitation: {e}"); + } else { + log::info!("spaces: invited to {:?} by {}", inv.name, b58(&inv.owner)); + } +} + +pub(crate) 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 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 fn space_leave(app: tauri::AppHandle, owner: String, space: String) -> Result<(), String> { + leave(&app, &owner, &space) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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"); + } +} diff --git a/src/lib/api.js b/src/lib/api.js index 40a27d8..4ffce01 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -174,6 +174,14 @@ export const shareDismiss = (id) => invoke("share_dismiss", { id }); export const inboxStatus = () => invoke("inbox_status"); export const inboxPublish = () => invoke("inbox_publish"); export const inboxCheck = () => invoke("inbox_check"); +export const spacesList = () => invoke("spaces_list"); +export const spaceCreate = (name, description, isPrivate) => invoke("space_create", { name, description, private: isPrivate }); +export const spaceOpen = (owner, space) => invoke("space_open", { owner, space }); +export const spacePost = (owner, space, text) => invoke("space_post", { owner, space, text }); +export const spaceInvite = (owner, space, personaKey) => invoke("space_invite", { owner, space, personaKey }); +export const spaceRemoveMember = (owner, space, personaKey) => invoke("space_remove_member", { owner, space, personaKey }); +export const spaceSetPinned = (owner, space, playlistOwner, playlistUuid) => invoke("space_set_pinned", { owner, space, playlistOwner, playlistUuid }); +export const spaceLeave = (owner, space) => invoke("space_leave", { owner, space }); export const buddyList = () => invoke("buddy_list"); export const buddyAdd = (username) => invoke("buddy_add", { username }); export const buddyRemove = (username) => invoke("buddy_remove", { username }); diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js index 13b6241..5242e64 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -75,6 +75,11 @@ export const community = $state({ inbox: null, /** What the last check found ({new_items, quarantined, pruned}). */ inboxLast: null, + /** spaces_list: the Spaces this device owns or was invited to. */ + spaces: [], + /** The open Space (space_open), or null. */ + space: null, + spaceError: "", /** persona key → ProfileView | {none} | {error} | {pending}: other * people's signed profiles, read once per session when surfaced. */ remoteProfiles: {}, @@ -319,6 +324,10 @@ export async function initCommunity() { else if (community.persona) community.persona.available = false; }); api.onEvent("community:friends", () => void refreshFriends()); + api.onEvent("community:spaces", () => { + void refreshSpaces(); + if (community.space) void openSpace(community.space.owner, community.space.space_id); + }); api.onEvent("community:shares", () => { void refreshShares(); void refreshPublished(); @@ -327,7 +336,7 @@ export async function initCommunity() { if (p.status === 1 || p.status === 2) noteSeen(p.username); }); } - await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox()]); + await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox(), refreshSpaces()]); await refreshPersona(); } @@ -519,7 +528,57 @@ export const friendshipForKey = (key) => { }; +// ---- spaces ------------------------------------------------------------------------- +export async function refreshSpaces() { + try { + community.spaces = await api.spacesList(); + } catch { + // stays as it was + } +} +/** Read a Space through the node; the view is what the checkpoint says. */ +export async function openSpace(owner, space) { + community.spaceError = ""; + community.busy["space:" + space] = true; + try { + community.space = await api.spaceOpen(owner, space); + } catch (e) { + community.spaceError = String(e?.message ?? e); + } finally { + delete community.busy["space:" + space]; + } +} +export const createSpace = (name, description, isPrivate) => + run("space:create", async () => { + community.space = await api.spaceCreate(name, description, isPrivate); + await refreshSpaces(); + }); +export const postToSpace = (owner, space, text) => + run("space:post", async () => { + await api.spacePost(owner, space, text); + await openSpace(owner, space); + }); +export const inviteToSpace = (owner, space, personaKey) => + run("space:invite", async () => { + community.space = await api.spaceInvite(owner, space, personaKey); + }); +export const removeFromSpace = (owner, space, personaKey) => + run("space:remove", async () => { + community.space = await api.spaceRemoveMember(owner, space, personaKey); + }); +export const pinToSpace = (owner, space, playlistOwner, playlistUuid) => + run("space:pin", async () => { + community.space = await api.spaceSetPinned(owner, space, playlistOwner, playlistUuid); + }); +export const leaveSpace = (owner, space) => + run("space:leave", async () => { + await api.spaceLeave(owner, space); + community.space = null; + await refreshSpaces(); + }); + + // Dev handle beside `__soulstream` (state.svelte.js): probes reach the store // through this, never through a bare import (which would be a second copy). if (import.meta.env.DEV) globalThis.__community = community; -if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox }; +if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace }; diff --git a/src/views/Community.svelte b/src/views/Community.svelte index 2cee5f8..4cd8a82 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -80,6 +80,14 @@ publishInbox, checkInbox, friendRequestByKey, + refreshSpaces, + openSpace, + createSpace, + postToSpace, + inviteToSpace, + removeFromSpace, + pinToSpace, + leaveSpace, } from "../lib/community.svelte.js"; onMount(() => { @@ -445,6 +453,53 @@ return () => clearInterval(t); }); let addKey = $state(""); + // ---- spaces ------------------------------------------------------------ + let spaceName = $state(""); + let spacePrivate = $state(true); + let spaceText = $state(""); + let spaceInviteKey = $state(""); + let spaceTab = $state("chat"); + let spaceCreating = $state(false); + const sv = $derived(community.space); + const spaceOwner = $derived(sv?.my_role === "owner"); + let spaceEl = $state(null); + $effect(() => { + const n = sv?.messages.length; + if (n && spaceEl) spaceEl.scrollTop = spaceEl.scrollHeight; + }); + // The open Space is re-read every 30 s while the section is showing. + $effect(() => { + if (community.section !== "spaces" || !sv || !nodeUp) return; + const { owner, space_id } = sv; + const t = setInterval(() => void openSpace(owner, space_id), 30_000); + return () => clearInterval(t); + }); + async function sayInSpace() { + const t = spaceText.trim(); + if (!t || !sv) return; + await postToSpace(sv.owner, sv.space_id, t); + spaceText = ""; + } + async function makeSpace() { + const n = spaceName.trim(); + if (!n) return; + await createSpace(n, "", spacePrivate); + spaceName = ""; + spaceCreating = false; + } + const whoIs = (m) => (m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key ?? m.author)); + const pinnedPreview = $derived(sv?.pinned_uuid ? community.sharePreviews["pin:" + sv.pinned_uuid] : null); + $effect(() => { + const s0 = sv; + if (s0?.pinned_uuid && !community.sharePreviews["pin:" + s0.pinned_uuid]) { + untrack(() => sharePreview({ id: "pin:" + s0.pinned_uuid, owner_key: s0.pinned_owner, playlist_uuid: s0.pinned_uuid })); + } + }); + async function importPinned() { + if (!sv?.pinned_uuid) return; + const id = await shareImport({ id: "pin:" + sv.pinned_uuid, owner_key: sv.pinned_owner, playlist_uuid: sv.pinned_uuid, name: pinnedPreview?.name ?? "Pinned playlist" }); + if (id) goToView("playlist", { playlistId: id, label: pinnedPreview?.name ?? "Pinned playlist" }); + } const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase(); $effect(() => { const lk = drawerLink; @@ -1005,15 +1060,120 @@ {:else if community.section === "spaces"} -
- -
No Spaces yet
-
- A Space is a persistent SoulStream community on Freenet with its own member keys — invitation only, encrypted - when private, and never a Soulseek Room in disguise. The research behind it is done; the Space contract itself - is the next piece of the Freenet track, so nothing here is live yet. +
+
+
Your Spaces {community.spaces.length}
+ {#each community.spaces as sp0 (sp0.id)} + + {/each} + {#if spaceCreating} +
{ e.preventDefault(); void makeSpace(); }}> +
+ +
+
+ {:else} + + {/if} +

What a Space is

A persistent SoulStream community on Freenet. The owner's signed checkpoint is the roster; a removal is a new epoch, and in a private Space every removal rotates the key. Not a Soulseek Room, and never mirrored to one. Invitations travel sealed through the encrypted inbox — the person you invite needs one.

- beta · not live + {#if sv} +
+
+
{sv.name.slice(0, 2).toUpperCase()}
+
+
{sv.name} Space
+
+ {#if sv.private} private · sealed{:else}public{/if}· + {sv.members.length} {sv.members.length === 1 ? "member" : "members"}· + epoch {sv.epoch}· + not a Soulseek Room + {#if !sv.reachable}·not on this node yet{:else}·read from the network{/if} +
+
+
+ + {#if !spaceOwner}{/if} +
+
+
+ + + + Proposals · next +
+ {#if community.spaceError}

{community.spaceError}

{/if} + {#if spaceTab === "chat"} + {#if sv.pinned_uuid} +

Pinned playlist {#if pinnedPreview && !pinnedPreview.pending && !pinnedPreview.error}revision {pinnedPreview.revision_seq}{/if}

+
+ +
+
{pinnedPreview?.name ?? "Reading…"}
+
{#if pinnedPreview?.entries}{pinnedPreview.entries.length} tracks · signed by {fpShort(sv.pinned_owner)}{:else if pinnedPreview?.error}{pinnedPreview.error}{:else if pinnedPreview?.withdrawn}withdrawn by its owner{:else}fetching through your node{/if}
+
{#if spaceOwner}{/if}
+
+
+
+ {:else if spaceOwner && community.published.some((p) => !p.withdrawn)} +
Pin one of your public playlists:{#each community.published.filter((p) => !p.withdrawn) as p (p.playlist_id)}{/each}
+ {/if} +
+
+ {#if !sv.messages.length}
Nothing said yet. The last {sv.message_cap} lines are kept; older ones fall away for everyone.
{/if} + {#each sv.messages as m (m.id)} +
{fmtClock(m.created_at * 1000)}{whoIs(m)}{#if m.text != null}{m.text}{:else} sealed to a key this device does not hold (epoch {m.epoch}){/if}
+ {/each} +
+
{ e.preventDefault(); void sayInSpace(); }}> +
+ {sv.private ? "sealed · members only" : "public · signed"} · last {sv.message_cap} kept + +
+
+ {:else if spaceTab === "members"} +
+
+ {#each sv.members as m (m.key)} +
+ + {m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key)} + {m.role}{#if m.since_epoch > 1} · since {m.since_epoch}{/if} + {#if spaceOwner && !m.is_me}{/if} +
+ {/each} +
+ {#if spaceOwner} +
{ e.preventDefault(); const k = spaceInviteKey.trim(); if (k) { void inviteToSpace(sv.owner, sv.space_id, k); spaceInviteKey = ""; } }}> + + +
+ {#if community.friends.some((f) => f.state === "friends")}

Friends: {#each community.friends.filter((f) => f.state === "friends") as f (f.id)} {/each}

{/if} + {/if} +

Members are their SoulStream identities here — a joined Space is a linked identity. Removing someone is a new epoch{#if sv.private}, and in this private Space it also rotates the key: those who stay receive the new one sealed{/if}.

+
+ {:else} +
+

About

+
Owner
{sv.owner}
Space id
{sv.space_id}
Mode
{sv.private ? "private — message bodies sealed to a per-epoch key" : "public — anyone who resolves the contract can read"}
Window
the last {sv.message_cap} messages
Epoch
{sv.epoch}
+

What the network can see: that the Space exists, its owner, its roster keys, and — for a private Space — how many sealed lines it holds. Leaving does not erase copies others hold.

+ {#if sv.description}

{sv.description}

{/if} +
+ {/if} +
+ {:else} +
+ +
{community.spaces.length ? "Pick a Space" : "No Spaces yet"}
+
A Space is a persistent SoulStream community on Freenet with its own roster — invitation only, sealed when private, never a Soulseek Room in disguise.
+
+ {/if}
@@ -1694,6 +1854,25 @@ .emptyshelf b { color: var(--text-secondary); } .emptyshelf .ax-ghost { margin-left: auto; } + /* ── spaces ── */ + .spmini { width: 40px; height: 40px; border-radius: 10px; flex: none; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 13px; font-weight: 900; } + .sphead { display: flex; align-items: center; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--hair); } + .spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; flex: none; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 20px; font-weight: 900; } + .sphead .nm { font-size: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; } + .sphead .sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; max-width: none; } + .src.space { background: color-mix(in srgb, var(--space) 14%, transparent); color: #d9a6ff; } + .sptabs { display: flex; gap: 2px; margin: 12px 0 14px; align-items: center; } + .subtab { padding: 6px 13px; border-radius: 8px; font-size: 12px; font-weight: 800; color: var(--text-secondary); display: inline-flex; gap: 6px; align-items: center; } + .subtab.on { background: var(--surf-hi); color: #fff; } + .subtab b { font-size: 10px; } + .pinned { display: flex; gap: 14px; align-items: center; } + .pinned .t { font-size: 15px; font-weight: 800; } + .pinned .s { font-size: 12px; color: var(--text-secondary); margin-top: 2px; } + .members { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 12px 8px; } + .mem { display: flex; flex-direction: column; align-items: center; gap: 5px; text-align: center; } + .mem .nm { font-size: 11.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 96px; } + .mem .role { font-size: 9.5px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); } + .mem .role.own { color: #d9a6ff; } /* ── you ── */ /* The You section sized by the PANE (container query), never the window: three columns from ~1240px of pane, two down to ~820, then one. */