diff --git a/crates/persona-state/src/lib.rs b/crates/persona-state/src/lib.rs index 2073bfc..7278f82 100644 --- a/crates/persona-state/src/lib.rs +++ b/crates/persona-state/src/lib.rs @@ -20,6 +20,7 @@ pub mod friend; pub mod playlist; pub mod inbox; pub mod space; +pub mod proposals; pub const SCHEMA_VERSION: u32 = 1; pub const SIGN_DOMAIN: &str = "soulstream.profile/v1"; diff --git a/crates/persona-state/src/proposals.rs b/crates/persona-state/src/proposals.rs new file mode 100644 index 0000000..d9068ce --- /dev/null +++ b/crates/persona-state/src/proposals.rs @@ -0,0 +1,613 @@ +//! The collaborative-playlist proposal board (F7 → the app). One board per +//! shared playlist, keyed by (owner, playlist uuid); a MULTI-WRITER contract +//! in the inbox mold — anyone may offer, `merge_board` is the bouncer. +//! +//! Three record families: +//! - **AuthorityCheckpoint** — the owner's signed contributor roster with a +//! monotonic epoch, co-located with the board. Highest (epoch, hash) wins; +//! an epoch that drops an author IS revocation, and that author's pending +//! proposals leave the board deterministically. +//! - **Proposal** — immutable, contributor-signed, bound to an EXACT base +//! revision (the F3 content hash) and valid only while its author is on +//! the CURRENT roster. Bounded ops, bounded size, per-author and total +//! caps (newest per author kept). +//! - **Decision** — owner-signed accept/reject with a monotonic sequence: +//! the audit trail. A decided proposal leaves the pending set and no stale +//! replica can bring it back. The canonical playlist never lives here — an +//! accepted proposal becomes an ordinary owner-signed revision + pointer +//! bump outside. +//! +//! BYTE-COMPATIBILITY IS THE CONTRACT: these shapes are bincode-identical to +//! the research crate the bundled `proposal_contract.wasm` was proven with +//! (research/freenet-f7); the entry types are `playlist::EntryV1` itself. + +use serde::{Deserialize, Serialize}; + +use crate::playlist as f3; + +pub const SCHEMA_VERSION: u32 = 1; +pub const PARAMS_FAMILY: &[u8] = b"soulstream.playlist-props.v1"; +/// Proposals and decisions sign under the playlist domain — the delegate +/// allowlist has no separate board entry, and a board IS playlist business. +pub const SIGN_DOMAIN: &str = f3::SIGN_DOMAIN; + +pub const MAX_STATE_BYTES: usize = 1024 * 1024; +pub const MAX_CONTRIBUTORS: usize = 64; +pub const MAX_OPS_PER_PROPOSAL: usize = 32; +pub const MAX_PROPOSAL_BYTES: usize = 64 * 1024; +pub const MAX_PROPOSALS: usize = 128; +pub const PER_AUTHOR_PENDING_CAP: usize = 8; +pub const MAX_DECISIONS: usize = 256; +pub const MAX_REASON_BYTES: usize = 128; + +fn verify(public: &[u8; 32], body: &[u8], sig: &[u8]) -> bool { + crate::verify_sig_in(SIGN_DOMAIN, public, &sign_payload(body), sig) +} + +/// What the delegate is handed for any board record: blake3 of its sig body. +pub fn sign_payload(body: &[u8]) -> Vec { + blake3::hash(body).as_bytes().to_vec() +} + +// ---------------------------------------------------------------- records + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthorityCheckpoint { + pub epoch: u64, + pub contributors: Vec<[u8; 32]>, + pub sig: Vec, // owner +} + +#[derive(Serialize)] +struct CheckpointSigBody<'a> { + epoch: u64, + contributors: &'a [[u8; 32]], +} + +pub fn checkpoint_sig_body(c: &AuthorityCheckpoint) -> Vec { + bincode::serialize(&CheckpointSigBody { epoch: c.epoch, contributors: &c.contributors }).expect("serializes") +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProposalOp { + Add { position: u32, entry: f3::EntryV1 }, + Remove { entry_id: [u8; 16] }, + Move { entry_id: [u8; 16], to_position: u32 }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Proposal { + pub proposal_id: [u8; 16], + pub author: [u8; 32], + pub authority_epoch: u64, + pub base_revision_hash: [u8; 32], + pub ops: Vec, + pub note: String, + pub created_at: u64, + pub sig: Vec, +} + +#[derive(Serialize)] +struct ProposalSigBody<'a> { + proposal_id: &'a [u8; 16], + author: &'a [u8; 32], + authority_epoch: u64, + base_revision_hash: &'a [u8; 32], + ops: &'a [ProposalOp], + note: &'a str, + created_at: u64, +} + +pub fn proposal_sig_body(p: &Proposal) -> Vec { + bincode::serialize(&ProposalSigBody { + proposal_id: &p.proposal_id, + author: &p.author, + authority_epoch: p.authority_epoch, + base_revision_hash: &p.base_revision_hash, + ops: &p.ops, + note: &p.note, + created_at: p.created_at, + }) + .expect("serializes") +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Decision { + pub proposal_id: [u8; 16], + pub accept: bool, + pub seq: u64, + pub reason: String, + /// When accepted: the revision the owner published applying it. + pub result_revision_hash: Option<[u8; 32]>, + pub sig: Vec, // owner +} + +#[derive(Serialize)] +struct DecisionSigBody<'a> { + proposal_id: &'a [u8; 16], + accept: bool, + seq: u64, + reason: &'a str, + result_revision_hash: &'a Option<[u8; 32]>, +} + +pub fn decision_sig_body(d: &Decision) -> Vec { + bincode::serialize(&DecisionSigBody { + proposal_id: &d.proposal_id, + accept: d.accept, + seq: d.seq, + reason: &d.reason, + result_revision_hash: &d.result_revision_hash, + }) + .expect("serializes") +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoardState { + pub schema_version: u32, + pub owner: [u8; 32], + pub playlist_uuid: [u8; 16], + pub authority: Option, + pub proposals: Vec, + pub decisions: Vec, +} + +pub fn make_params(owner: &[u8; 32], uuid: &[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(uuid); + 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())) +} + +// ---------------------------------------------------------------- merge + +pub fn checkpoint_valid(owner: &[u8; 32], c: &AuthorityCheckpoint) -> bool { + c.contributors.len() <= MAX_CONTRIBUTORS && verify(owner, &checkpoint_sig_body(c), &c.sig) +} + +pub fn proposal_valid(p: &Proposal) -> bool { + if p.ops.is_empty() || p.ops.len() > MAX_OPS_PER_PROPOSAL { + return false; + } + if p.note.len() > f3::MAX_DESC_BYTES { + return false; + } + if bincode::serialized_size(p).map(|n| n as usize > MAX_PROPOSAL_BYTES).unwrap_or(true) { + return false; + } + for op in &p.ops { + if let ProposalOp::Add { entry, .. } = op { + for s in [&entry.recording.title, &entry.recording.album] { + if s.len() > f3::MAX_FIELD_BYTES { + return false; + } + } + if entry.note.as_deref().map(str::len).unwrap_or(0) > f3::MAX_NOTE_BYTES { + return false; + } + if entry.slsk_hint.as_deref().map(str::len).unwrap_or(0) > f3::MAX_FIELD_BYTES { + return false; + } + } + } + verify(&p.author, &proposal_sig_body(p), &p.sig) +} + +pub fn decision_valid(owner: &[u8; 32], d: &Decision) -> bool { + d.reason.len() <= MAX_REASON_BYTES && verify(owner, &decision_sig_body(d), &d.sig) +} + +/// Deterministic multi-writer merge — the bouncer. Every delivery order of +/// the same candidates yields the same bytes. +pub fn merge_board(owner: &[u8; 32], uuid: &[u8; 16], candidates: &[&[u8]]) -> BoardState { + use std::collections::{BTreeMap, BTreeSet}; + + 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.owner != owner || &st.playlist_uuid != uuid { + continue; + } + parsed.push(st); + } + + // 1. The winning authority checkpoint: highest (epoch, blake3) valid one. + let mut best_auth: Option<(u64, [u8; 32], AuthorityCheckpoint)> = None; + for st in &parsed { + if let Some(c) = &st.authority { + if checkpoint_valid(owner, c) { + let rank = (c.epoch, *blake3::hash(&checkpoint_sig_body(c)).as_bytes()); + let better = match &best_auth { + None => true, + Some((e, h, _)) => rank > (*e, *h), + }; + if better { + best_auth = Some((rank.0, rank.1, c.clone())); + } + } + } + } + let authority = best_auth.map(|(_, _, c)| c); + + // 2. Decisions: union by proposal id, highest (seq, hash) per proposal; + // the oldest age out past the cap — the window is honest. + let mut dec_by_id: BTreeMap<[u8; 16], (u64, [u8; 32], Decision)> = BTreeMap::new(); + for st in &parsed { + for d in &st.decisions { + if !decision_valid(owner, d) { + continue; + } + let rank = (d.seq, *blake3::hash(&decision_sig_body(d)).as_bytes()); + match dec_by_id.get(&d.proposal_id) { + Some((s, h, _)) if (*s, *h) >= rank => {} + _ => { + dec_by_id.insert(d.proposal_id, (rank.0, rank.1, d.clone())); + } + } + } + } + let mut decisions: Vec = dec_by_id.into_values().map(|(_, _, d)| d).collect(); + decisions.sort_by_key(|d| (d.seq, d.proposal_id)); + if decisions.len() > MAX_DECISIONS { + let cut = decisions.len() - MAX_DECISIONS; + decisions.drain(0..cut); + } + + // 3. Proposals: union by id (a collision keeps the smaller hash), gated + // on the CURRENT roster, undecided only, then the caps (newest per + // author). + let decided: BTreeSet<[u8; 16]> = decisions.iter().map(|d| d.proposal_id).collect(); + let roster: Vec<[u8; 32]> = authority.as_ref().map(|a| a.contributors.clone()).unwrap_or_default(); + let mut prop_by_id: BTreeMap<[u8; 16], ([u8; 32], Proposal)> = BTreeMap::new(); + for st in &parsed { + for p in &st.proposals { + if decided.contains(&p.proposal_id) { + continue; + } + if !roster.contains(&p.author) { + continue; + } + if !proposal_valid(p) { + continue; + } + let h = *blake3::hash(&bincode::serialize(p).unwrap()).as_bytes(); + match prop_by_id.get(&p.proposal_id) { + Some((eh, _)) if eh <= &h => {} + _ => { + prop_by_id.insert(p.proposal_id, (h, p.clone())); + } + } + } + } + let mut all: Vec = prop_by_id.into_values().map(|(_, p)| p).collect(); + all.sort_by_key(|p| (p.created_at, p.proposal_id)); + let mut per_author: BTreeMap<[u8; 32], Vec> = BTreeMap::new(); + for p in all { + per_author.entry(p.author).or_default().push(p); + } + let mut proposals: Vec = Vec::new(); + for (_, mut list) in per_author { + let keep_from = list.len().saturating_sub(PER_AUTHOR_PENDING_CAP); + proposals.extend(list.drain(keep_from..)); + } + proposals.sort_by_key(|p| (p.created_at, p.proposal_id)); + proposals.truncate(MAX_PROPOSALS); + + BoardState { schema_version: SCHEMA_VERSION, owner: *owner, playlist_uuid: *uuid, authority, proposals, decisions } +} + +// ---------------------------------------------------------------- apply + +#[derive(Debug, PartialEq, Eq)] +pub enum ApplyError { + StaleBase { expected: [u8; 32], got: [u8; 32] }, + RemoveMissing([u8; 16]), + MoveMissing([u8; 16]), +} + +impl core::fmt::Display for ApplyError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + ApplyError::StaleBase { .. } => write!( + f, + "the proposal targets a revision that is no longer current; ask for a re-submit against the current one" + ), + ApplyError::RemoveMissing(_) => write!(f, "a remove targets an entry not in the base revision"), + ApplyError::MoveMissing(_) => write!(f, "a move targets an entry not in the base revision"), + } + } +} + +/// Pure application of a proposal's ops to the CURRENT canonical manifest. +/// A stale base is refused outright: exact-base binding is the whole +/// concurrency model — the owner rebases by asking for a re-submit, never +/// by guessing intent. +pub fn apply_proposal( + current: &f3::ManifestV1, + current_bytes_hash: &[u8; 32], + p: &Proposal, +) -> Result, ApplyError> { + if &p.base_revision_hash != current_bytes_hash { + return Err(ApplyError::StaleBase { expected: *current_bytes_hash, got: p.base_revision_hash }); + } + let mut entries = current.entries.clone(); + for op in &p.ops { + match op { + ProposalOp::Add { position, entry } => { + let at = (*position as usize).min(entries.len()); + entries.insert(at, entry.clone()); + } + ProposalOp::Remove { entry_id } => { + let Some(i) = entries.iter().position(|e| &e.entry_id == entry_id) else { + return Err(ApplyError::RemoveMissing(*entry_id)); + }; + entries.remove(i); + } + ProposalOp::Move { entry_id, to_position } => { + let Some(i) = entries.iter().position(|e| &e.entry_id == entry_id) else { + return Err(ApplyError::MoveMissing(*entry_id)); + }; + let e = entries.remove(i); + let at = (*to_position as usize).min(entries.len()); + entries.insert(at, e); + } + } + } + for (i, e) in entries.iter_mut().enumerate() { + e.position = i as u32; + } + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + fn kp(seed: u8) -> SigningKey { + SigningKey::from_bytes(&[seed; 32]) + } + fn sign(key: &SigningKey, body: &[u8]) -> Vec { + key.sign(&crate::signable(SIGN_DOMAIN, &sign_payload(body))).to_bytes().to_vec() + } + fn entry(i: u32, title: &str) -> f3::EntryV1 { + let mut id = [0u8; 16]; + id[..4].copy_from_slice(&i.to_le_bytes()); + id[15] = 0xEE; + f3::EntryV1 { + entry_id: id, + position: i, + recording: f3::RecordingRef { + musicbrainz_recording_id: None, + isrcs: vec![], + deezer_id: Some(format!("dz{i}")), + apple_music_id: None, + title: title.into(), + artists: vec!["Artist".into()], + album: "Album".into(), + duration_ms: Some(180_000), + release_year: Some(2000), + }, + note: None, + slsk_hint: None, + } + } + fn checkpoint(owner: &SigningKey, epoch: u64, contributors: &[&SigningKey]) -> AuthorityCheckpoint { + let mut c = AuthorityCheckpoint { + epoch, + contributors: contributors.iter().map(|k| k.verifying_key().to_bytes()).collect(), + sig: vec![], + }; + c.sig = sign(owner, &checkpoint_sig_body(&c)); + c + } + fn proposal(author: &SigningKey, id: u8, epoch: u64, base: [u8; 32], created: u64) -> Proposal { + let mut p = Proposal { + proposal_id: [id; 16], + author: author.verifying_key().to_bytes(), + authority_epoch: epoch, + base_revision_hash: base, + ops: vec![ProposalOp::Add { position: 0, entry: entry(90 + id as u32, "Proposed") }], + note: "please add".into(), + created_at: created, + sig: vec![], + }; + p.sig = sign(author, &proposal_sig_body(&p)); + p + } + fn decision(owner: &SigningKey, pid: [u8; 16], accept: bool, seq: u64) -> Decision { + let mut d = Decision { + proposal_id: pid, + accept, + seq, + reason: if accept { "applied".into() } else { "no".into() }, + result_revision_hash: accept.then_some([7u8; 32]), + sig: vec![], + }; + d.sig = sign(owner, &decision_sig_body(&d)); + d + } + fn state(owner: &SigningKey, auth: Option, proposals: Vec, decisions: Vec) -> Vec { + bincode::serialize(&BoardState { + schema_version: SCHEMA_VERSION, + owner: owner.verifying_key().to_bytes(), + playlist_uuid: [7u8; 16], + authority: auth, + proposals, + decisions, + }) + .unwrap() + } + fn permute(items: &[usize], f: &mut dyn FnMut(&[usize])) { + fn go(cur: &mut Vec, rest: &mut Vec, f: &mut dyn FnMut(&[usize])) { + if rest.is_empty() { + f(cur); + return; + } + for i in 0..rest.len() { + let x = rest.remove(i); + cur.push(x); + go(cur, rest, f); + cur.pop(); + rest.insert(i, x); + } + } + go(&mut Vec::new(), &mut items.to_vec(), f); + } + + #[test] + fn merge_converges_over_permutations_and_is_idempotent() { + let owner = kp(1); + let opub = owner.verifying_key().to_bytes(); + let b = kp(2); + let c = kp(3); + let base = [5u8; 32]; + let cp1 = checkpoint(&owner, 1, &[&b, &c]); + let cp2 = checkpoint(&owner, 2, &[&b]); + let p_b = proposal(&b, 10, 1, base, 100); + let p_c = proposal(&c, 11, 1, base, 101); + let d_b = decision(&owner, [10u8; 16], true, 1); + let cands = vec![ + state(&owner, Some(cp1.clone()), vec![p_b.clone(), p_c.clone()], vec![]), + state(&owner, Some(cp2.clone()), vec![], vec![]), + state(&owner, Some(cp1.clone()), vec![p_c.clone()], vec![d_b.clone()]), + state(&owner, None, vec![p_b.clone()], vec![d_b.clone()]), + ]; + let uuid = [7u8; 16]; + let reference = { + let refs: Vec<&[u8]> = cands.iter().map(|v| v.as_slice()).collect(); + merge_board(&opub, &uuid, &refs) + }; + permute(&[0, 1, 2, 3], &mut |order| { + let refs: Vec<&[u8]> = order.iter().map(|&i| cands[i].as_slice()).collect(); + assert_eq!(merge_board(&opub, &uuid, &refs), reference, "diverged for {order:?}"); + }); + assert_eq!(reference.authority.as_ref().unwrap().epoch, 2); + assert!(reference.proposals.is_empty()); + assert_eq!(reference.decisions.len(), 1); + let rb = bincode::serialize(&reference).unwrap(); + assert_eq!(merge_board(&opub, &uuid, &[&rb, &rb]), reference); + } + + #[test] + fn revoked_author_drops_and_never_re_enters() { + let owner = kp(1); + let opub = owner.verifying_key().to_bytes(); + let b = kp(2); + let c = kp(3); + let uuid = [7u8; 16]; + let cp1 = checkpoint(&owner, 1, &[&b, &c]); + let cp2 = checkpoint(&owner, 2, &[&b]); + let p_c_old = proposal(&c, 20, 1, [5u8; 32], 100); + let with_c = state(&owner, Some(cp1), vec![p_c_old.clone()], vec![]); + let revoked = state(&owner, Some(cp2), vec![], vec![]); + let m = merge_board(&opub, &uuid, &[&with_c, &revoked]); + assert!(m.proposals.is_empty()); + let mb = bincode::serialize(&m).unwrap(); + let offer = state(&owner, None, vec![proposal(&c, 21, 2, [5u8; 32], 200)], vec![]); + assert!(merge_board(&opub, &uuid, &[&mb, &offer]).proposals.is_empty()); + let replay = state(&owner, Some(checkpoint(&owner, 1, &[&b, &c])), vec![p_c_old], vec![]); + let m3 = merge_board(&opub, &uuid, &[&mb, &replay]); + assert_eq!(m3.authority.as_ref().unwrap().epoch, 2); + assert!(m3.proposals.is_empty()); + } + + #[test] + fn forged_oversized_and_non_owner_decisions_never_enter() { + let owner = kp(1); + let opub = owner.verifying_key().to_bytes(); + let b = kp(2); + let mallory = kp(66); + let uuid = [7u8; 16]; + let cp = checkpoint(&owner, 1, &[&b]); + let mut forged = proposal(&mallory, 30, 1, [5u8; 32], 100); + forged.author = b.verifying_key().to_bytes(); + let mut big = Proposal { + proposal_id: [31u8; 16], + author: b.verifying_key().to_bytes(), + authority_epoch: 1, + base_revision_hash: [5u8; 32], + ops: (0..(MAX_OPS_PER_PROPOSAL as u32 + 1)).map(|i| ProposalOp::Add { position: i, entry: entry(i, "x") }).collect(), + note: String::new(), + created_at: 101, + sig: vec![], + }; + big.sig = sign(&b, &proposal_sig_body(&big)); + let good = proposal(&b, 32, 1, [5u8; 32], 102); + let fake_dec = decision(&mallory, [32u8; 16], true, 1); + let st = state(&owner, Some(cp), vec![forged, big, good], vec![fake_dec]); + let m = merge_board(&opub, &uuid, &[&st]); + assert_eq!(m.proposals.len(), 1); + assert_eq!(m.proposals[0].proposal_id, [32u8; 16]); + assert!(m.decisions.is_empty()); + } + + #[test] + fn flood_and_sybil_bounded_newest_kept() { + let owner = kp(1); + let opub = owner.verifying_key().to_bytes(); + let b = kp(2); + let uuid = [7u8; 16]; + let cp = checkpoint(&owner, 1, &[&b]); + let mut props: Vec = (0..20).map(|i| proposal(&b, 100 + i, 1, [5u8; 32], 1000 + i as u64)).collect(); + for s in 0..50u8 { + props.push(proposal(&kp(150 + s), 200 + s, 1, [5u8; 32], 5000)); + } + let st = state(&owner, Some(cp), props, vec![]); + let m = merge_board(&opub, &uuid, &[&st]); + assert_eq!(m.proposals.len(), PER_AUTHOR_PENDING_CAP); + assert!(m.proposals.iter().all(|p| p.created_at >= 1000 + 12)); + } + + #[test] + fn apply_composes_and_refuses_stale_or_missing() { + let b = kp(2); + let current = f3::ManifestV1 { + schema_version: f3::SCHEMA_VERSION, + owner: [1u8; 32], + playlist_uuid: [7u8; 16], + revision_seq: 1, + prev_revision_hash: None, + name: "n".into(), + description: String::new(), + artwork: None, + entries: vec![entry(0, "First"), entry(1, "Middle"), entry(2, "Last")], + sig: vec![], + }; + let hash = [9u8; 32]; + let mut p = Proposal { + proposal_id: [1u8; 16], + author: b.verifying_key().to_bytes(), + authority_epoch: 1, + base_revision_hash: hash, + ops: vec![ + ProposalOp::Add { position: 99, entry: entry(50, "Appended") }, + ProposalOp::Move { entry_id: entry(1, "").entry_id, to_position: 0 }, + ProposalOp::Remove { entry_id: entry(0, "").entry_id }, + ], + note: String::new(), + created_at: 1, + sig: vec![], + }; + p.sig = sign(&b, &proposal_sig_body(&p)); + let out = apply_proposal(¤t, &hash, &p).unwrap(); + let titles: Vec<&str> = out.iter().map(|e| e.recording.title.as_str()).collect(); + assert_eq!(titles, ["Middle", "Last", "Appended"]); + assert!(out.iter().enumerate().all(|(i, e)| e.position == i as u32)); + assert!(matches!(apply_proposal(¤t, &[8u8; 32], &p), Err(ApplyError::StaleBase { .. }))); + p.ops = vec![ProposalOp::Remove { entry_id: [0xAB; 16] }]; + assert!(matches!(apply_proposal(¤t, &hash, &p), Err(ApplyError::RemoveMissing(_)))); + } +} diff --git a/crates/proposal-contract/Cargo.lock b/crates/proposal-contract/Cargo.lock new file mode 100644 index 0000000..cd6f987 --- /dev/null +++ b/crates/proposal-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-proposal-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/proposal-contract/Cargo.toml b/crates/proposal-contract/Cargo.toml new file mode 100644 index 0000000..1a2d415 --- /dev/null +++ b/crates/proposal-contract/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "soulstream-proposal-contract" +version = "0.1.0" +edition = "2021" +description = "The SoulStream proposal-board contract — a thin shell over persona_state::proposals::merge_board. 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/proposal-contract/src/lib.rs b/crates/proposal-contract/src/lib.rs new file mode 100644 index 0000000..183b68e --- /dev/null +++ b/crates/proposal-contract/src/lib.rs @@ -0,0 +1,118 @@ +//! The SoulStream proposal-board contract. Multi-writer: the owner offers +//! checkpoints and decisions, contributors offer proposals; `persona_state::proposals::merge_board` +//! is the bouncer (owner signature, the current roster, decided-tombstones, +//! the caps). 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_proposal_contract.wasm` to `src-tauri/assets/proposal_contract.wasm` +//! and re-pin the code hash in `src-tauri/src/proposals.rs`. + +use freenet_stdlib::prelude::*; +use persona_state::proposals 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+uuid".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, uuid) = 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!( + "board state {} bytes over cap {}", + bytes.len(), + t::MAX_STATE_BYTES + ))); + } + let merged = t::merge_board(&owner, &uuid, &[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, decided, or over a cap)".into(), + )); + } + Ok(ValidateResult::Valid) + } + + fn update_state( + parameters: Parameters<'static>, + state: State<'static>, + data: Vec>, + ) -> Result, ContractError> { + let (owner, uuid) = parts(¶meters)?; + let (candidates, any_parse) = collect(&state, &data); + if !any_parse && !data.is_empty() { + return Err(ContractError::InvalidUpdate); + } + let merged = t::merge_board(&owner, &uuid, &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/proposal_contract.wasm b/src-tauri/assets/proposal_contract.wasm new file mode 100644 index 0000000..ae4a77f Binary files /dev/null and b/src-tauri/assets/proposal_contract.wasm differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 69fd79a..09c35da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -27,6 +27,7 @@ mod shazam; mod slsk_link; mod smtc; mod spaces; +mod proposals; mod state; mod thumbbar; mod vocal_sep; @@ -159,6 +160,9 @@ pub fn run() { spaces::space_remove_member, spaces::space_set_pinned, spaces::space_leave, + proposals::space_board, + proposals::space_propose, + proposals::space_decide, commands::buddy_list, commands::buddy_add, commands::buddy_remove, diff --git a/src-tauri/src/playlists.rs b/src-tauri/src/playlists.rs index 91cadac..9f8298c 100644 --- a/src-tauri/src/playlists.rs +++ b/src-tauri/src/playlists.rs @@ -22,7 +22,7 @@ fn now_secs() -> i64 { .unwrap_or(0) } -fn changed(app: &tauri::AppHandle) { +pub(crate) fn changed(app: &tauri::AppHandle) { let _ = app.emit("soulseek:playlists-changed", ()); } diff --git a/src-tauri/src/proposals.rs b/src-tauri/src/proposals.rs new file mode 100644 index 0000000..d22e85b --- /dev/null +++ b/src-tauri/src/proposals.rs @@ -0,0 +1,646 @@ +//! The proposal board (F7 → the app). A Space's pinned playlist gets a +//! multi-writer board beside it, keyed by (playlist owner, uuid). Members +//! propose bounded edits — add, remove, move — against the EXACT revision +//! they were looking at; the playlist's owner accepts or rejects, and every +//! decision is a signed line in the audit trail. Accepting applies the ops +//! to the owner's local playlist and publishes it as the next revision; the +//! decision then names that revision's hash. The canonical playlist never +//! lives in here. +//! +//! The roster is the Space's: when the playlist owner opens the board and +//! the contributors differ from the Space's members, a new epoch is signed. +//! Dropping a member is revocation — their pending proposals leave the board +//! on every node, deterministically. +//! +//! The contract is a bundled commitment (`assets/proposal_contract.wasm`, +//! built from crates/proposal-contract; its code hash is pinned by a test). + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use freenet_adapter::FreenetAdapter; +use library::{NewPlaylistItem, PlaylistItem}; +use persona_state::playlist as pl; +use persona_state::proposals as pr; +use serde::{Deserialize, Serialize}; +use tauri::Manager; + +use crate::persona; +use crate::pipeline::LibraryState; +use crate::playlists; +use crate::shared_playlists::{self, Published}; +use crate::slsk_link; +use crate::spaces; +use crate::state::AppState; + +const BOARD_WASM: &[u8] = include_bytes!("../assets/proposal_contract.wasm"); + +#[derive(Default, Clone, Serialize, Deserialize)] +struct BoardRecord { + #[serde(default)] + last_epoch: u64, + #[serde(default)] + last_seq: u64, +} + +#[derive(Default, Serialize, Deserialize)] +struct Ledger { + #[serde(default)] + boards: BTreeMap, +} + +// ---------------------------------------------------------------- views + +#[derive(Debug, Serialize)] +pub struct BoardEntry { + pub entry_id: String, + pub position: u32, + pub title: String, + pub artist: String, + pub album: String, + pub duration_secs: Option, +} + +#[derive(Debug, Serialize)] +pub struct OpView { + /// "add" | "remove" | "move" + pub kind: String, + pub position: Option, + pub entry_id: Option, + pub title: String, + pub artist: String, + /// False when a remove/move names an entry the current revision no + /// longer has (a stale proposal). + pub known: bool, +} + +#[derive(Debug, Serialize)] +pub struct ProposalView { + pub id: String, + pub author: String, + pub username: Option, + pub is_me: bool, + pub created_at: u64, + pub note: String, + pub base: String, + /// Its base is not the current revision: the owner can only reject it + /// and ask for a re-submit. + pub stale: bool, + pub ops: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DecisionView { + pub proposal_id: String, + pub accept: bool, + pub seq: u64, + pub reason: String, + pub result_hash: Option, +} + +#[derive(Debug, Serialize)] +pub struct ContributorView { + pub key: String, + pub username: Option, + pub is_me: bool, +} + +#[derive(Debug, Serialize)] +pub struct BoardView { + pub owner: String, + pub uuid: String, + pub name: String, + pub revision_seq: u64, + /// The current revision's content hash (b58) — what a proposal binds to. + pub base: String, + pub withdrawn: bool, + pub entries: Vec, + /// The board contract exists on the network. + pub reachable: bool, + pub epoch: u64, + pub contributors: Vec, + pub i_am_owner: bool, + pub can_propose: bool, + pub proposals: Vec, + /// Newest first. + pub decisions: Vec, + pub pending_mine: u32, +} + +/// One op as the composer sends it. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum OpInput { + Add { + position: u32, + title: String, + artist: String, + #[serde(default)] + album: String, + #[serde(default)] + duration_secs: Option, + #[serde(default)] + track_id: Option, + /// The composer's handle for this row ("new:3"), so a later remove + /// or move in the same proposal can name a track it just added. + #[serde(default)] + token: Option, + }, + Remove { + entry_id: String, + }, + Move { + entry_id: String, + to_position: u32, + }, +} + +// ---------------------------------------------------------------- helpers + +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 b58(bytes: &[u8]) -> String { + bs58::encode(bytes).into_string() +} +fn key_of(s: &str) -> Result<[u8; 32], String> { + let v = bs58::decode(s.trim()).into_vec().map_err(|_| "not a base58 key")?; + v.try_into().map_err(|_| "a key is 32 bytes".to_string()) +} +fn id_of(s: &str) -> Result<[u8; 16], String> { + let v = bs58::decode(s.trim()).into_vec().map_err(|_| "not a base58 id")?; + v.try_into().map_err(|_| "an id is 16 bytes".to_string()) +} +fn cap(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_string() +} +fn board_id(owner: &[u8; 32], uuid: &[u8; 16]) -> String { + format!("{}/{}", b58(owner), b58(uuid)) +} + +fn path(app: &tauri::AppHandle) -> Result { + Ok(app.path().app_data_dir().map_err(|e| e.to_string())?.join("freenet").join("boards.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()) +} + +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, pr::SIGN_DOMAIN, payload).await +} + +async fn fetch_board(app: &tauri::AppHandle, owner: &[u8; 32], uuid: &[u8; 16]) -> Result<(pr::BoardState, bool), String> { + let adapter = app.state::().freenet.clone(); + let ids = FreenetAdapter::contract_ids(BOARD_WASM.to_vec(), &pr::make_params(owner, uuid)); + match adapter.contract_get(&ids.instance, false).await { + Ok(bytes) => Ok((pr::merge_board(owner, uuid, &[&bytes]), true)), + Err(e) if e.to_string().contains("not found") => Ok((pr::BoardState::default(), false)), + Err(e) => Err(e.to_string()), + } +} + +async fn offer_board(app: &tauri::AppHandle, owner: &[u8; 32], uuid: &[u8; 16], state: &pr::BoardState, first: bool) -> Result<(), String> { + let adapter = app.state::().freenet.clone(); + let bytes = bincode::serialize(state).map_err(|e| e.to_string())?; + let params = pr::make_params(owner, uuid); + if first { + adapter.contract_put(BOARD_WASM.to_vec(), ¶ms, bytes, false).await.map(|_| ()) + } else { + adapter.get_then_update(BOARD_WASM.to_vec(), ¶ms, bytes).await.map(|_| ()) + } + .map_err(|e| e.to_string()) +} + +fn partial(owner: &[u8; 32], uuid: &[u8; 16], authority: Option, proposals: Vec, decisions: Vec) -> pr::BoardState { + pr::BoardState { schema_version: pr::SCHEMA_VERSION, owner: *owner, playlist_uuid: *uuid, authority, proposals, decisions } +} + +/// What a Space pins, plus its roster — the board's owner and contributors. +struct Pinned { + owner: [u8; 32], + uuid: [u8; 16], + members: Vec<[u8; 32]>, +} + +async fn pinned_of(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str) -> Result { + let owner = key_of(space_owner_b58)?; + let space_id = id_of(space_b58)?; + let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?; + let c = state.checkpoint.ok_or("the Space has not reached this node yet")?; + let p = c.pinned.ok_or("this Space has no pinned playlist — a board lives beside one")?; + Ok(Pinned { owner: p.owner, uuid: p.uuid, members: c.members.iter().map(|m| m.key).collect() }) +} + +type Current = (pl::ManifestV1, [u8; 32]); + +fn view_of(app: &tauri::AppHandle, pin: &Pinned, current: Option<&Current>, board: &pr::BoardState, me: &[u8; 32], reachable: bool) -> BoardView { + let entries: Vec = current + .map(|(m, _)| { + m.entries + .iter() + .map(|e| BoardEntry { + entry_id: b58(&e.entry_id), + position: e.position, + title: e.recording.title.clone(), + artist: e.recording.artists.join(", "), + album: e.recording.album.clone(), + duration_secs: e.recording.duration_ms.map(|ms| ms / 1000), + }) + .collect() + }) + .unwrap_or_default(); + // A remove or move may name a row the same proposal added a step earlier. + let named = |p: &pr::Proposal, id: &[u8; 16]| -> Option<(String, String)> { + current + .and_then(|(m, _)| m.entries.iter().find(|e| &e.entry_id == id)) + .or_else(|| { + p.ops.iter().find_map(|op| match op { + pr::ProposalOp::Add { entry, .. } if &entry.entry_id == id => Some(entry), + _ => None, + }) + }) + .map(|e| (e.recording.title.clone(), e.recording.artists.join(", "))) + }; + let roster: Vec<[u8; 32]> = board.authority.as_ref().map(|a| a.contributors.clone()).unwrap_or_default(); + let proposals = board + .proposals + .iter() + .map(|p| ProposalView { + id: b58(&p.proposal_id), + author: b58(&p.author), + username: slsk_link::username_for_key(app, &b58(&p.author)), + is_me: &p.author == me, + created_at: p.created_at, + note: p.note.clone(), + base: b58(&p.base_revision_hash), + stale: current.map(|(_, h)| h != &p.base_revision_hash).unwrap_or(true), + ops: p + .ops + .iter() + .map(|op| match op { + pr::ProposalOp::Add { position, entry } => OpView { + kind: "add".into(), + position: Some(*position), + entry_id: None, + title: entry.recording.title.clone(), + artist: entry.recording.artists.join(", "), + known: true, + }, + pr::ProposalOp::Remove { entry_id } => { + let n = named(p, entry_id); + OpView { + kind: "remove".into(), + position: None, + entry_id: Some(b58(entry_id)), + title: n.as_ref().map(|x| x.0.clone()).unwrap_or_default(), + artist: n.as_ref().map(|x| x.1.clone()).unwrap_or_default(), + known: n.is_some(), + } + } + pr::ProposalOp::Move { entry_id, to_position } => { + let n = named(p, entry_id); + OpView { + kind: "move".into(), + position: Some(*to_position), + entry_id: Some(b58(entry_id)), + title: n.as_ref().map(|x| x.0.clone()).unwrap_or_default(), + artist: n.as_ref().map(|x| x.1.clone()).unwrap_or_default(), + known: n.is_some(), + } + } + }) + .collect(), + }) + .collect(); + let decisions = board + .decisions + .iter() + .rev() + .map(|d| DecisionView { + proposal_id: b58(&d.proposal_id), + accept: d.accept, + seq: d.seq, + reason: d.reason.clone(), + result_hash: d.result_revision_hash.as_ref().map(|h| b58(h)), + }) + .collect(); + BoardView { + owner: b58(&pin.owner), + uuid: b58(&pin.uuid), + name: current.map(|(m, _)| m.name.clone()).unwrap_or_default(), + revision_seq: current.map(|(m, _)| m.revision_seq).unwrap_or(0), + base: current.map(|(_, h)| b58(h)).unwrap_or_default(), + withdrawn: current.is_none(), + entries, + reachable, + epoch: board.authority.as_ref().map(|a| a.epoch).unwrap_or(0), + contributors: roster + .iter() + .map(|k| ContributorView { key: b58(k), username: slsk_link::username_for_key(app, &b58(k)), is_me: k == me }) + .collect(), + i_am_owner: &pin.owner == me, + can_propose: reachable && current.is_some() && roster.contains(me), + proposals, + decisions, + pending_mine: board.proposals.iter().filter(|p| &p.author == me).count() as u32, + } +} + +// ---------------------------------------------------------------- actions + +/// Read the board beside a Space's pinned playlist. The playlist's owner +/// also brings the roster in line with the Space's members here — a new +/// signed epoch whenever they differ. +pub async fn open(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str) -> Result { + let me = my_key(app).await?; + let pin = pinned_of(app, space_owner_b58, space_b58).await?; + let current = shared_playlists::current_revision(app, &pin.owner, &pin.uuid).await?; + let (mut board, mut exists) = fetch_board(app, &pin.owner, &pin.uuid).await?; + if pin.owner == me { + let want: Vec<[u8; 32]> = pin.members.iter().copied().take(pr::MAX_CONTRIBUTORS).collect(); + let have: Vec<[u8; 32]> = board.authority.as_ref().map(|a| a.contributors.clone()).unwrap_or_default(); + let same = want.iter().collect::>() == have.iter().collect::>(); + if !same { + let bid = board_id(&pin.owner, &pin.uuid); + let mut l = load(app); + let rec = l.boards.get(&bid).cloned().unwrap_or_default(); + let epoch = board.authority.as_ref().map(|a| a.epoch).unwrap_or(0).max(rec.last_epoch) + 1; + let mut c = pr::AuthorityCheckpoint { epoch, contributors: want, sig: vec![] }; + c.sig = sign(app, pr::sign_payload(&pr::checkpoint_sig_body(&c))).await?; + let offer = partial(&pin.owner, &pin.uuid, Some(c), vec![], vec![]); + offer_board(app, &pin.owner, &pin.uuid, &offer, !exists).await?; + l.boards.insert(bid, BoardRecord { last_epoch: epoch, ..rec }); + save(app, &l)?; + let a = bincode::serialize(&board).map_err(|e| e.to_string())?; + let b = bincode::serialize(&offer).map_err(|e| e.to_string())?; + board = pr::merge_board(&pin.owner, &pin.uuid, &[&a, &b]); + exists = true; + log::info!("proposals: roster epoch {} for board {}", epoch, b58(&pin.uuid)); + } + } + Ok(view_of(app, &pin, current.as_ref(), &board, &me, exists)) +} + +/// A member proposes: the ops are dry-run against the exact base first, so +/// a broken composition never leaves this device. +pub async fn propose(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str, base_b58: &str, ops: Vec, note: &str) -> Result { + let me = my_key(app).await?; + let pin = pinned_of(app, space_owner_b58, space_b58).await?; + if ops.is_empty() { + return Err("nothing proposed yet".into()); + } + if ops.len() > pr::MAX_OPS_PER_PROPOSAL { + return Err(format!("over {} changes in one proposal", pr::MAX_OPS_PER_PROPOSAL)); + } + let (manifest, hash) = shared_playlists::current_revision(app, &pin.owner, &pin.uuid) + .await? + .ok_or("the owner withdrew this playlist")?; + if b58(&hash) != base_b58.trim() { + return Err("the playlist moved to a newer revision while you were composing — reload the board and try again".into()); + } + let (board, exists) = fetch_board(app, &pin.owner, &pin.uuid).await?; + if !exists { + return Err("the owner has not opened this board yet".into()); + } + let auth = board.authority.as_ref().ok_or("the board has no roster yet")?; + if !auth.contributors.contains(&me) { + return Err("you are not on this board's roster — the owner refreshes it from the Space's members".into()); + } + let mut fresh: BTreeMap = BTreeMap::new(); + let resolve = |fresh: &BTreeMap, s: &str| -> Result<[u8; 16], String> { + match fresh.get(s.trim()) { + Some(id) => Ok(*id), + None => id_of(s), + } + }; + let mut pops = Vec::with_capacity(ops.len()); + for op in ops { + pops.push(match op { + OpInput::Add { position, title, artist, album, duration_secs, track_id, token } => { + let title = cap(title.trim(), pl::MAX_FIELD_BYTES); + let artist = cap(artist.trim(), pl::MAX_FIELD_BYTES); + if title.is_empty() { + return Err("an added track needs a title".into()); + } + let entry_id = rand16(); + if let Some(t) = token { + fresh.insert(t, entry_id); + } + pr::ProposalOp::Add { + position, + entry: pl::EntryV1 { + entry_id, + position, + recording: pl::RecordingRef { + musicbrainz_recording_id: None, + isrcs: vec![], + deezer_id: track_id.filter(|t| !t.is_empty()), + apple_music_id: None, + title: title.clone(), + artists: vec![artist.clone()], + album: cap(album.trim(), pl::MAX_FIELD_BYTES), + duration_ms: duration_secs.map(|s| s.saturating_mul(1000)), + release_year: None, + }, + note: None, + slsk_hint: Some(cap(&format!("{artist} {title}"), pl::MAX_FIELD_BYTES)), + }, + } + } + OpInput::Remove { entry_id } => pr::ProposalOp::Remove { entry_id: resolve(&fresh, &entry_id)? }, + OpInput::Move { entry_id, to_position } => pr::ProposalOp::Move { entry_id: resolve(&fresh, &entry_id)?, to_position }, + }); + } + let mut p = pr::Proposal { + proposal_id: rand16(), + author: me, + authority_epoch: auth.epoch, + base_revision_hash: hash, + ops: pops, + note: cap(note.trim(), pl::MAX_DESC_BYTES), + created_at: now_secs(), + sig: vec![], + }; + pr::apply_proposal(&manifest, &hash, &p).map_err(|e| e.to_string())?; + p.sig = sign(app, pr::sign_payload(&pr::proposal_sig_body(&p))).await?; + if !pr::proposal_valid(&p) { + return Err("the proposal did not validate (a size or field cap)".into()); + } + offer_board(app, &pin.owner, &pin.uuid, &partial(&pin.owner, &pin.uuid, None, vec![p], vec![]), false).await?; + log::info!("proposals: offered a proposal to board {}", b58(&pin.uuid)); + open(app, space_owner_b58, space_b58).await +} + +/// The owner decides. Accepting brings the local playlist to the proposed +/// order, publishes it as the next revision, and the decision names that +/// revision; rejecting records the reason. Either way it is signed. +pub async fn decide(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str, proposal_b58: &str, accept: bool, reason: &str) -> Result { + let me = my_key(app).await?; + let pin = pinned_of(app, space_owner_b58, space_b58).await?; + if pin.owner != me { + return Err("only the playlist's owner decides".into()); + } + let pid = id_of(proposal_b58)?; + let (board, exists) = fetch_board(app, &pin.owner, &pin.uuid).await?; + if !exists { + return Err("the board has not reached this node".into()); + } + let p = board.proposals.iter().find(|p| p.proposal_id == pid).cloned().ok_or("that proposal is no longer pending")?; + let bid = board_id(&pin.owner, &pin.uuid); + let mut l = load(app); + let rec = l.boards.get(&bid).cloned().unwrap_or_default(); + let seq = board.decisions.iter().map(|d| d.seq).max().unwrap_or(0).max(rec.last_seq) + 1; + let mut reason = cap(reason.trim(), pr::MAX_REASON_BYTES); + let result = if accept { + let (manifest, hash) = shared_playlists::current_revision(app, &pin.owner, &pin.uuid) + .await? + .ok_or("the playlist is withdrawn — publish it again before accepting")?; + let entries = pr::apply_proposal(&manifest, &hash, &p).map_err(|e| e.to_string())?; + let published = apply_locally(app, &pin.uuid, &manifest, entries).await?; + if reason.is_empty() { + reason = format!("applied as revision {}", published.revision_seq); + } + Some(key_of(published.current_hash.as_deref().unwrap_or_default())?) + } else { + if reason.is_empty() { + reason = "declined".into(); + } + None + }; + let mut d = pr::Decision { proposal_id: pid, accept, seq, reason, result_revision_hash: result, sig: vec![] }; + d.sig = sign(app, pr::sign_payload(&pr::decision_sig_body(&d))).await?; + offer_board(app, &pin.owner, &pin.uuid, &partial(&pin.owner, &pin.uuid, None, vec![], vec![d]), false).await?; + l.boards.insert(bid, BoardRecord { last_seq: seq, ..rec }); + save(app, &l)?; + log::info!("proposals: decision {} ({}) on board {}", seq, if accept { "accept" } else { "reject" }, b58(&pin.uuid)); + open(app, space_owner_b58, space_b58).await +} + +/// Bring the owner's local playlist to `entries`, then publish it as the +/// next revision. Refused when the local copy has drifted from the base +/// revision — the exact-base rule holds on this side too. +async fn apply_locally(app: &tauri::AppHandle, uuid: &[u8; 16], base: &pl::ManifestV1, entries: Vec) -> Result { + let published = shared_playlists::published_for_uuid(app, &b58(uuid)).ok_or("this device did not publish that playlist")?; + let playlist_id = published.playlist_id; + let lib = app.state::(); + let l = lib.library.clone(); + let items: Vec = tauri::async_runtime::spawn_blocking(move || l.playlist_items(playlist_id).map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())??; + let local_ids: Vec<[u8; 16]> = items.iter().map(|it| shared_playlists::entry_id_for(uuid, it.id)).collect(); + let base_ids: Vec<[u8; 16]> = base.entries.iter().map(|e| e.entry_id).collect(); + if local_ids != base_ids { + return Err(format!( + "your local copy of this playlist has changed since revision {} was published — publish it again, then ask for a re-submit", + base.revision_seq + )); + } + let uuid = *uuid; + let keep: BTreeSet<[u8; 16]> = entries.iter().map(|e| e.entry_id).collect(); + let remove: Vec = items.iter().zip(&local_ids).filter(|(_, id)| !keep.contains(*id)).map(|(it, _)| it.id).collect(); + let existing: BTreeSet<[u8; 16]> = local_ids.iter().copied().collect(); + let adds: Vec = entries + .iter() + .filter(|e| !existing.contains(&e.entry_id)) + .map(|e| NewPlaylistItem { + title: e.recording.title.clone(), + artist: e.recording.artists.join(", "), + album: Some(e.recording.album.clone()).filter(|a| !a.is_empty()), + duration_secs: e.recording.duration_ms.map(|ms| ms / 1000), + path: None, + art_path: None, + kind: String::new(), + source_line: e.slsk_hint.clone(), + start_ms: None, + note: e.note.clone(), + track_id: e.recording.deezer_id.clone(), + art_url: None, + }) + .collect(); + let before: BTreeSet = items.iter().map(|it| it.id).collect(); + let now = now_secs() as i64; + let l = lib.library.clone(); + let after: Vec = tauri::async_runtime::spawn_blocking(move || -> Result, String> { + if !remove.is_empty() { + l.remove_playlist_items(playlist_id, &remove, now).map_err(|e| e.to_string())?; + } + if !adds.is_empty() { + l.add_playlist_items(playlist_id, &adds, None, now).map_err(|e| e.to_string())?; + } + l.playlist_items(playlist_id).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + let mut fresh: VecDeque = after.iter().filter(|it| !before.contains(&it.id)).map(|it| it.id).collect(); + let by_entry: BTreeMap<[u8; 16], i64> = after.iter().map(|it| (shared_playlists::entry_id_for(&uuid, it.id), it.id)).collect(); + let mut ordered = Vec::with_capacity(entries.len()); + for e in &entries { + match by_entry.get(&e.entry_id) { + Some(id) => ordered.push(*id), + None => ordered.push(fresh.pop_front().ok_or("the added rows did not come back from the library")?), + } + } + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.set_playlist_order(playlist_id, &ordered, now).map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())??; + playlists::changed(app); + shared_playlists::publish(app, &lib, playlist_id).await +} + +// ---------------------------------------------------------------- commands + +#[tauri::command] +pub async fn space_board(app: tauri::AppHandle, owner: String, space: String) -> Result { + open(&app, &owner, &space).await +} +#[tauri::command] +pub async fn space_propose(app: tauri::AppHandle, owner: String, space: String, base: String, ops: Vec, note: String) -> Result { + propose(&app, &owner, &space, &base, ops, ¬e).await +} +#[tauri::command] +pub async fn space_decide(app: tauri::AppHandle, owner: String, space: String, proposal: String, accept: bool, reason: String) -> Result { + decide(&app, &owner, &space, &proposal, accept, &reason).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contract_generation_is_pinned() { + let ids = FreenetAdapter::contract_ids(BOARD_WASM.to_vec(), &pr::make_params(&[7u8; 32], &[1u8; 16])); + assert_eq!(ids.code_hash, "3oBYSW1pP9GfX7v4Q1CtP2ikiKcc1yT48U7wxMnTTFho"); + } +} diff --git a/src-tauri/src/shared_playlists.rs b/src-tauri/src/shared_playlists.rs index 29954fd..3b37b14 100644 --- a/src-tauri/src/shared_playlists.rs +++ b/src-tauri/src/shared_playlists.rs @@ -98,6 +98,8 @@ struct ShareRef { #[derive(Debug, Serialize)] pub struct ShareEntry { + /// The occurrence id (b58) — what a proposal's remove or move names. + pub entry_id: String, pub position: u32, pub title: String, pub artist: String, @@ -245,12 +247,12 @@ pub async fn publish(app: &tauri::AppHandle, lib: &LibraryState, playlist_id: i6 .iter() .enumerate() .map(|(i, it)| pl::EntryV1 { - entry_id: rand16(), + entry_id: entry_id_for(&uuid, it.id), position: i as u32, recording: pl::RecordingRef { musicbrainz_recording_id: None, isrcs: vec![], - deezer_id: None, + deezer_id: it.track_id.clone().filter(|t| !t.is_empty()), apple_music_id: None, title: cap(&it.title, pl::MAX_FIELD_BYTES), artists: vec![cap(&it.artist, pl::MAX_FIELD_BYTES)], @@ -281,6 +283,7 @@ pub async fn publish(app: &tauri::AppHandle, lib: &LibraryState, playlist_id: i6 .contract_put(REV_WASM.to_vec(), &pl::make_rev_params(&owner, &hash), bytes.clone(), false) .await .map_err(|e| e.to_string())?; + cache_revision(app, &hash, &bytes); let pointer_seq = prev.as_ref().map(|p| p.pointer_seq + 1).unwrap_or(1); let mut p = pl::PointerState { schema_version: pl::SCHEMA_VERSION, @@ -385,10 +388,7 @@ pub async fn fetch(app: &tauri::AppHandle, owner_b58: &str, uuid_b58: &str) -> R entries: vec![], }); }; - let rev_ids = FreenetAdapter::contract_ids(REV_WASM.to_vec(), &pl::make_rev_params(&owner, &r.content_hash)); - let rbytes = adapter.contract_get(&rev_ids.instance, false).await.map_err(|e| e.to_string())?; - let m = pl::validate_revision(&owner, &r.content_hash, &rbytes) - .map_err(|e| format!("revision failed verification: {e}"))?; + let (m, _) = revision_by_hash(app, &owner, &r.content_hash).await?; Ok(ShareView { owner_key: owner_b58.to_string(), playlist_uuid: uuid_b58.to_string(), @@ -401,6 +401,7 @@ pub async fn fetch(app: &tauri::AppHandle, owner_b58: &str, uuid_b58: &str) -> R .entries .into_iter() .map(|e| ShareEntry { + entry_id: b58(&e.entry_id), position: e.position, title: e.recording.title, artist: e.recording.artists.join(", "), @@ -412,6 +413,79 @@ pub async fn fetch(app: &tauri::AppHandle, owner_b58: &str, uuid_b58: &str) -> R }) } +/// The occurrence id a local row publishes under: derived from the playlist +/// uuid and the row id, so it survives re-publication — a proposal's remove +/// or move names the same row the owner has. +pub(crate) fn entry_id_for(uuid: &[u8; 16], item_id: i64) -> [u8; 16] { + let mut buf = Vec::with_capacity(48); + buf.extend_from_slice(b"soulstream.entry.v1"); + buf.extend_from_slice(uuid); + buf.extend_from_slice(&item_id.to_le_bytes()); + ps::content_hash(&buf)[..16].try_into().unwrap() +} + +fn revisions_dir(app: &tauri::AppHandle) -> Option { + app.path().app_data_dir().ok().map(|d| d.join("freenet").join("revisions")) +} + +/// Revisions are kept on disk by hash: the owner's accept path needs the +/// exact base bytes, and a network GET for them costs seconds. +fn cache_revision(app: &tauri::AppHandle, hash: &[u8; 32], bytes: &[u8]) { + if let Some(dir) = revisions_dir(app) { + let _ = std::fs::create_dir_all(&dir); + let _ = std::fs::write(dir.join(format!("{}.bin", b58(hash))), bytes); + } +} + +/// A revision by content hash — the local cache first, the network after — +/// verified against the owner's key either way. +pub(crate) async fn revision_by_hash( + app: &tauri::AppHandle, + owner: &[u8; 32], + hash: &[u8; 32], +) -> Result<(pl::ManifestV1, Vec), String> { + let cached = revisions_dir(app).and_then(|d| std::fs::read(d.join(format!("{}.bin", b58(hash)))).ok()); + let bytes = match cached { + Some(b) => b, + None => { + let adapter = app.state::().freenet.clone(); + let ids = FreenetAdapter::contract_ids(REV_WASM.to_vec(), &pl::make_rev_params(owner, hash)); + let b = adapter.contract_get(&ids.instance, false).await.map_err(|e| e.to_string())?; + cache_revision(app, hash, &b); + b + } + }; + let m = pl::validate_revision(owner, hash, &bytes).map_err(|e| format!("revision failed verification: {e}"))?; + Ok((m, bytes)) +} + +/// The current revision of (owner, uuid): the pointer, then the revision it +/// names. None when the pointer says withdrawn. +pub(crate) async fn current_revision( + app: &tauri::AppHandle, + owner: &[u8; 32], + uuid: &[u8; 16], +) -> Result, String> { + let adapter = app.state::().freenet.clone(); + let ptr_ids = FreenetAdapter::contract_ids(PTR_WASM.to_vec(), &pl::make_ptr_params(owner, uuid)); + let pbytes = adapter.contract_get(&ptr_ids.instance, false).await.map_err(|e| { + if e.to_string().contains("not found") { + "no such shared playlist on the network (or not reachable yet)".to_string() + } else { + e.to_string() + } + })?; + let p = pl::validate_pointer(owner, uuid, &pbytes).map_err(|e| format!("pointer failed verification: {e}"))?; + let Some(r) = p.current.filter(|_| !p.withdrawn) else { return Ok(None) }; + let (m, _) = revision_by_hash(app, owner, &r.content_hash).await?; + Ok(Some((m, r.content_hash))) +} + +/// The local playlist behind a published uuid, when this device published it. +pub(crate) fn published_for_uuid(app: &tauri::AppHandle, uuid_b58: &str) -> Option { + load(app).published.into_values().find(|p| p.uuid == uuid_b58) +} + /// A verified shared playlist staged through the app's own import path. pub async fn import( app: &tauri::AppHandle, diff --git a/src-tauri/src/spaces.rs b/src-tauri/src/spaces.rs index 10cde56..c420514 100644 --- a/src-tauri/src/spaces.rs +++ b/src-tauri/src/spaces.rs @@ -191,7 +191,7 @@ async fn sign(app: &tauri::AppHandle, payload: Vec) -> Result, Strin 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> { +pub(crate) async fn fetch_state(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16]) -> Result<(sp::SpaceState, bool), String> { let adapter = app.state::().freenet.clone(); let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(owner, space_id)); match adapter.contract_get(&ids.instance, false).await { diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index 30dcfed..2df52c0 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -57,6 +57,7 @@ "chevron-left": '', "chevron-right": '', "chevron-down": '', + "chevron-up": '', // Lucide layers-2: its ink fills the viewBox symmetrically, so the // glyph centers correctly inside small pills (the 3-layer variant // rides high when trimmed). diff --git a/src/lib/api.js b/src/lib/api.js index 4ffce01..e6c91c6 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -182,6 +182,9 @@ export const spaceInvite = (owner, space, personaKey) => invoke("space_invite", 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 spaceBoard = (owner, space) => invoke("space_board", { owner, space }); +export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note }); +export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason }); 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 5242e64..d23d87a 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -80,6 +80,9 @@ export const community = $state({ /** The open Space (space_open), or null. */ space: null, spaceError: "", + /** space_board: the proposal board beside the open Space's pinned playlist, or null. */ + board: null, + boardError: "", /** persona key → ProfileView | {none} | {error} | {pending}: other * people's signed profiles, read once per session when surfaced. */ remoteProfiles: {}, @@ -574,11 +577,48 @@ export const leaveSpace = (owner, space) => run("space:leave", async () => { await api.spaceLeave(owner, space); community.space = null; + community.board = null; await refreshSpaces(); }); +// ---- the proposal board --------------------------------------------------------- +/** Read the board beside the Space's pinned playlist; the playlist's owner also + * brings its roster in line with the members here. */ +export async function openBoard(owner, space) { + community.boardError = ""; + community.busy["board:" + space] = true; + try { + community.board = await api.spaceBoard(owner, space); + } catch (e) { + community.board = null; + community.boardError = String(e?.message ?? e); + } finally { + delete community.busy["board:" + space]; + } +} +async function boardRun(key, fn) { + community.busy[key] = true; + community.boardError = ""; + try { + return await fn(); + } catch (e) { + community.boardError = String(e?.message ?? e); + throw e; + } finally { + delete community.busy[key]; + } +} +export const proposeToBoard = (owner, space, base, ops, note) => + boardRun("board:propose", async () => { + community.board = await api.spacePropose(owner, space, base, ops, note); + }); +export const decideProposal = (owner, space, proposal, accept, reason) => + boardRun("board:decide", async () => { + community.board = await api.spaceDecide(owner, space, proposal, accept, reason); + }); + // 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, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace }; +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, openBoard, proposeToBoard, decideProposal }; diff --git a/src/views/Community.svelte b/src/views/Community.svelte index 4cd8a82..4c33f9b 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -88,6 +88,9 @@ removeFromSpace, pinToSpace, leaveSpace, + openBoard, + proposeToBoard, + decideProposal, } from "../lib/community.svelte.js"; onMount(() => { @@ -495,6 +498,91 @@ untrack(() => sharePreview({ id: "pin:" + s0.pinned_uuid, owner_key: s0.pinned_owner, playlist_uuid: s0.pinned_uuid })); } }); + // ---- the proposal board (F7). The composer is a working copy of the pinned + // playlist that records each act as an op; the Rust side dry-runs the same + // ops against the exact base before anything is signed. + const bv = $derived(community.board); + const boardKey = $derived(sv ? `${sv.id}|${sv.pinned_uuid ?? ""}` : ""); + const boardBusy = $derived(!!(community.busy["board:propose"] || community.busy["board:decide"] || (sv && community.busy["board:" + sv.space_id]))); + let boardDraft = $state(null); + let boardQuery = $state(""); + let boardHits = $state([]); + let boardReason = $state({}); + let freshCount = 0; + let boardTimer; + $effect(() => { + const k = boardKey; + if (spaceTab !== "proposals" || !k || k.endsWith("|")) return; + untrack(() => { + boardDraft = null; + void openBoard(sv.owner, sv.space_id); + }); + }); + function applyOp(list, op) { + if (op.kind === "add") { + list.splice(Math.min(op.position, list.length), 0, { entry_id: op.token, title: op.title, artist: op.artist, album: op.album, duration_secs: op.duration_secs, fresh: true }); + return true; + } + const i = list.findIndex((e) => e.entry_id === op.entry_id); + if (i < 0) return false; + if (op.kind === "remove") { + list.splice(i, 1); + return true; + } + const [e] = list.splice(i, 1); + list.splice(Math.min(op.to_position, list.length), 0, e); + return true; + } + function startDraft() { + if (!bv) return; + boardDraft = { base: bv.base, list: bv.entries.map((e) => ({ ...e })), ops: [], note: "" }; + boardQuery = ""; + boardHits = []; + } + function draftApply(op) { + if (!boardDraft || boardDraft.ops.length >= 32) return; + if (applyOp(boardDraft.list, op)) boardDraft.ops.push(op); + } + function draftUndo() { + if (!boardDraft?.ops.length) return; + boardDraft.ops.pop(); + const list = bv.entries.map((e) => ({ ...e })); + for (const op of boardDraft.ops) applyOp(list, op); + boardDraft.list = list; + } + function draftAdd(h) { + draftApply({ kind: "add", position: boardDraft.list.length, title: h.title, artist: h.artist, album: h.album ?? "", duration_secs: h.duration_secs ?? null, token: `new:${++freshCount}` }); + boardQuery = ""; + boardHits = []; + } + function searchLibrary() { + clearTimeout(boardTimer); + const q = boardQuery.trim(); + if (q.length < 2) { + boardHits = []; + return; + } + boardTimer = setTimeout(async () => { + try { + boardHits = await api.libraryTrackSearch(q); + } catch { + boardHits = []; + } + }, 220); + } + async function sendDraft() { + if (!boardDraft?.ops.length || !sv) return; + const ops = boardDraft.ops.map((o) => (o.kind === "add" ? { kind: "add", position: o.position, title: o.title, artist: o.artist, album: o.album, duration_secs: o.duration_secs, token: o.token } : { ...o })); + try { + await proposeToBoard(sv.owner, sv.space_id, boardDraft.base, ops, boardDraft.note); + boardDraft = null; + } catch { + // boardError shows it + } + } + const decideOn = (p, accept) => + decideProposal(sv.owner, sv.space_id, p.id, accept, boardReason[p.id] || (!accept && p.stale ? "stale base — re-submit against the current revision" : "")).catch(() => {}); + const whoProposed = (p) => (p.is_me ? "you" : p.username ? display(p.username) : `SoulStream ID ${fpShort(p.author)}`); 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" }); @@ -1106,7 +1194,7 @@ - Proposals · next + {#if community.spaceError}

{community.spaceError}

{/if} {#if spaceTab === "chat"} @@ -1158,6 +1246,102 @@ {/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 if spaceTab === "proposals"} + {#if !sv.pinned_uuid} +
+

Proposals

+

A board lives beside a pinned playlist: members propose adds, removes and moves against the exact revision they see, and the playlist's owner decides. {#if spaceOwner}Pin one of your public playlists from the Chat tab and the board opens here.{:else}The owner has not pinned a playlist yet.{/if}

+
+ {:else if !bv && community.boardError} +
+

Proposals

+

{community.boardError}

+
+
+ {:else if !bv} +

Reading the board through your node…

+ {:else} +
+

{bv.name || "Pinned playlist"}{#if bv.withdrawn}withdrawn by its owner{:else}revision {bv.revision_seq}{/if}

+

Proposals bind to revision {bv.revision_seq} exactly · {bv.i_am_owner ? "you decide" : "the playlist's owner decides"} · every decision is signed · {bv.contributors.length} {bv.contributors.length === 1 ? "contributor" : "contributors"} on epoch {bv.epoch}{#if !bv.reachable} · the owner has not opened the board yet{/if}

+
+ {#if bv.can_propose && !boardDraft}{/if} + {#if !bv.can_propose && !bv.i_am_owner && bv.reachable && !bv.withdrawn}You are not on this board's roster yet — the owner refreshes it from the members when they open the board.{/if} + +
+ {#if community.boardError}

{community.boardError}

{/if} +
+ {#if boardDraft} +
+

Your proposal {boardDraft.ops.length} / 32 changes

+

Move rows, take some out, add from your library. The owner receives titles, artists and albums — never a file, never a path. Nothing here plays or downloads.

+
    + {#each boardDraft.list as e, i (e.entry_id)} +
  1. + {i + 1} + {e.title}{e.artist}{#if e.fresh} · added{/if} + + + + + +
  2. + {/each} + {#if !boardDraft.list.length}
  3. Empty — add something below.
  4. {/if} +
+
+ {#if boardHits.length} +
{#each boardHits.slice(0, 8) as h (h.path)}{/each}
+ {/if} +
+
+ + + +
+
+ {/if} +
+

Pending {bv.proposals.length}

+ {#if !bv.proposals.length}

Nothing pending. {#if bv.can_propose}Propose an add, a remove or a move against the current revision — up to 32 changes each, 8 pending per person.{/if}

{/if} + {#each bv.proposals as p (p.id)} +
+
+ +
{whoProposed(p)}{fmtWhen(p.created_at * 1000)} · {p.ops.length} {p.ops.length === 1 ? "change" : "changes"}
+ {#if p.stale}stale · an older revision{:else}current revision{/if} +
+ {#if p.note}

{p.note}

{/if} +
    + {#each p.ops as op, i (i)} +
  • + {op.kind === "add" ? "+" : op.kind === "remove" ? "−" : "↕"} + + {#if op.kind === "add"}add {op.title} — {op.artist} at #{op.position + 1} + {:else if op.kind === "remove"}remove {#if op.known}{op.title} — {op.artist}{:else}an entry from an older revision{/if} + {:else}move {#if op.known}{op.title}{:else}an entry from an older revision{/if} to #{op.position + 1}{/if} + +
  • + {/each} +
+ {#if bv.i_am_owner} +
+ {#if !p.stale}{/if} + + +
+ {/if} +
+ {/each} +
+
+

Decisions {bv.decisions.length}

+ {#if !bv.decisions.length}

None yet. Each decision is owner-signed with a sequence number; the last 256 are kept.

{/if} + {#each bv.decisions as d (d.seq)} +
#{d.seq} · {d.accept ? "accepted" : "rejected"}{d.reason}{#if d.result_hash}→ revision {fpShort(d.result_hash)}{/if}
+ {/each} +
+ {/if} {:else}

About

@@ -1861,6 +2045,42 @@ .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; } + /* the proposal board */ + .bbtns { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; } + .dlist { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; } + .dlist li { display: grid; grid-template-columns: 26px 1fr auto; gap: 8px; align-items: center; padding: 5px 8px; border-radius: 8px; font-size: 12.5px; } + .dlist li:hover { background: var(--surf-hi); } + .dlist li.fresh { background: var(--accent-soft); } + .dlist li.none { display: block; color: var(--text-muted); font-size: 11.5px; } + .dlist .n { font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; } + .dlist .tx { display: flex; flex-direction: column; min-width: 0; } + .dlist .t { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .dlist .a { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .dlist .b { display: flex; gap: 2px; } + .hits { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; } + .hit { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 8px; text-align: left; font-size: 12.5px; color: var(--text-secondary); min-width: 0; } + .hit:hover { background: var(--surf-hi); color: #fff; } + .hit .t { font-weight: 700; white-space: nowrap; } + .hit .a { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .prop { padding: 12px 12px 10px; border: 1px solid var(--hair); border-radius: 10px; margin-top: 10px; } + .prop.stale { opacity: 0.8; border-style: dashed; } + .prop .ph { display: flex; align-items: center; gap: 10px; } + .prop .who { display: flex; flex-direction: column; min-width: 0; } + .prop .who .nm { font-weight: 800; font-size: 13px; } + .prop .ph .chip { margin-left: auto; } + .prop .note { margin: 8px 0 0; font-size: 12.5px; color: var(--text-secondary); font-style: italic; } + .ops { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 12.5px; } + .ops li { display: flex; gap: 8px; align-items: baseline; } + .ops li b { width: 14px; text-align: center; font-weight: 900; flex: none; } + .ops li.add b { color: #5ee89a; } + .ops li.remove b { color: var(--err); } + .ops li.move b { color: var(--accent-hover); } + .ops i { font-style: normal; font-weight: 700; } + .decide { display: flex; gap: 8px; align-items: center; margin-top: 10px; flex-wrap: wrap; } + .decide input { flex: 1; min-width: 160px; height: 30px; padding: 0 10px; border-radius: 8px; background: var(--surf-hi); border: none; color: var(--text-primary); font-size: 12px; outline: none; } + .dec { display: flex; gap: 10px; align-items: center; padding: 7px 0; border-top: 1px solid var(--hair); font-size: 12.5px; } + .dec:first-of-type { border-top: none; } + .dec .rs { flex: 1; } .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; }