the proposal board: a Space's pinned playlist takes signed proposals, and an accept republishes it

This commit is contained in:
KeelrLeek committed 2026-09-02 16:33:38 -05:00
1 parent 8b511b20e7
commit bf81792c49
15 files changed
+3207 -10

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ pub mod friend;
pub mod playlist; pub mod playlist;
pub mod inbox; pub mod inbox;
pub mod space; pub mod space;
pub mod proposals;
pub const SCHEMA_VERSION: u32 = 1; pub const SCHEMA_VERSION: u32 = 1;
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1"; pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
+613
View File
@@ -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<u8> {
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<u8>, // owner
}
#[derive(Serialize)]
struct CheckpointSigBody<'a> {
epoch: u64,
contributors: &'a [[u8; 32]],
}
pub fn checkpoint_sig_body(c: &AuthorityCheckpoint) -> Vec<u8> {
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<ProposalOp>,
pub note: String,
pub created_at: u64,
pub sig: Vec<u8>,
}
#[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<u8> {
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<u8>, // 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<u8> {
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<AuthorityCheckpoint>,
pub proposals: Vec<Proposal>,
pub decisions: Vec<Decision>,
}
pub fn make_params(owner: &[u8; 32], uuid: &[u8; 16]) -> Vec<u8> {
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 || &params[..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<BoardState> = Vec::new();
for bytes in candidates {
if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES {
continue;
}
let Ok(st) = bincode::deserialize::<BoardState>(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<Decision> = 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<Proposal> = 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<Proposal>> = BTreeMap::new();
for p in all {
per_author.entry(p.author).or_default().push(p);
}
let mut proposals: Vec<Proposal> = 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<Vec<f3::EntryV1>, 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<u8> {
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<AuthorityCheckpoint>, proposals: Vec<Proposal>, decisions: Vec<Decision>) -> Vec<u8> {
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<usize>, rest: &mut Vec<usize>, 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<Proposal> = (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(&current, &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(&current, &[8u8; 32], &p), Err(ApplyError::StaleBase { .. })));
p.ops = vec![ProposalOp::Remove { entry_id: [0xAB; 16] }];
assert!(matches!(apply_proposal(&current, &hash, &p), Err(ApplyError::RemoveMissing(_))));
}
}
+1449
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -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]
+118
View File
@@ -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::<t::BoardState>(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<ValidateResult, ContractError> {
let (owner, uuid) = parts(&parameters)?;
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<UpdateData<'static>>,
) -> Result<UpdateModification<'static>, ContractError> {
let (owner, uuid) = parts(&parameters)?;
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<StateSummary<'static>, ContractError> {
let _ = parts(&parameters)?;
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<StateDelta<'static>, ContractError> {
let _ = parts(&parameters)?;
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()))
}
}
}
Binary file not shown.
+4
View File
@@ -27,6 +27,7 @@ mod shazam;
mod slsk_link; mod slsk_link;
mod smtc; mod smtc;
mod spaces; mod spaces;
mod proposals;
mod state; mod state;
mod thumbbar; mod thumbbar;
mod vocal_sep; mod vocal_sep;
@@ -159,6 +160,9 @@ pub fn run() {
spaces::space_remove_member, spaces::space_remove_member,
spaces::space_set_pinned, spaces::space_set_pinned,
spaces::space_leave, spaces::space_leave,
proposals::space_board,
proposals::space_propose,
proposals::space_decide,
commands::buddy_list, commands::buddy_list,
commands::buddy_add, commands::buddy_add,
commands::buddy_remove, commands::buddy_remove,
+1 -1
View File
@@ -22,7 +22,7 @@ fn now_secs() -> i64 {
.unwrap_or(0) .unwrap_or(0)
} }
fn changed(app: &tauri::AppHandle) { pub(crate) fn changed(app: &tauri::AppHandle) {
let _ = app.emit("soulseek:playlists-changed", ()); let _ = app.emit("soulseek:playlists-changed", ());
} }
+646
View File
@@ -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<String, BoardRecord>,
}
// ---------------------------------------------------------------- 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<u32>,
}
#[derive(Debug, Serialize)]
pub struct OpView {
/// "add" | "remove" | "move"
pub kind: String,
pub position: Option<u32>,
pub entry_id: Option<String>,
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<String>,
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<OpView>,
}
#[derive(Debug, Serialize)]
pub struct DecisionView {
pub proposal_id: String,
pub accept: bool,
pub seq: u64,
pub reason: String,
pub result_hash: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ContributorView {
pub key: String,
pub username: Option<String>,
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<BoardEntry>,
/// The board contract exists on the network.
pub reachable: bool,
pub epoch: u64,
pub contributors: Vec<ContributorView>,
pub i_am_owner: bool,
pub can_propose: bool,
pub proposals: Vec<ProposalView>,
/// Newest first.
pub decisions: Vec<DecisionView>,
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<u32>,
#[serde(default)]
track_id: Option<String>,
/// 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<String>,
},
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<PathBuf, String> {
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::<AppState>().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<u8>) -> Result<Vec<u8>, String> {
let adapter = app.state::<AppState>().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::<AppState>().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::<AppState>().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(), &params, bytes, false).await.map(|_| ())
} else {
adapter.get_then_update(BOARD_WASM.to_vec(), &params, bytes).await.map(|_| ())
}
.map_err(|e| e.to_string())
}
fn partial(owner: &[u8; 32], uuid: &[u8; 16], authority: Option<pr::AuthorityCheckpoint>, proposals: Vec<pr::Proposal>, decisions: Vec<pr::Decision>) -> 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<Pinned, String> {
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<BoardEntry> = 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<BoardView, String> {
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::<BTreeSet<_>>() == have.iter().collect::<BTreeSet<_>>();
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<OpInput>, note: &str) -> Result<BoardView, String> {
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<String, [u8; 16]> = BTreeMap::new();
let resolve = |fresh: &BTreeMap<String, [u8; 16]>, 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<BoardView, String> {
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<pl::EntryV1>) -> Result<Published, String> {
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::<LibraryState>();
let l = lib.library.clone();
let items: Vec<PlaylistItem> = 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<i64> = 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<NewPlaylistItem> = 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<i64> = items.iter().map(|it| it.id).collect();
let now = now_secs() as i64;
let l = lib.library.clone();
let after: Vec<PlaylistItem> = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<PlaylistItem>, 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<i64> = 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<BoardView, String> {
open(&app, &owner, &space).await
}
#[tauri::command]
pub async fn space_propose(app: tauri::AppHandle, owner: String, space: String, base: String, ops: Vec<OpInput>, note: String) -> Result<BoardView, String> {
propose(&app, &owner, &space, &base, ops, &note).await
}
#[tauri::command]
pub async fn space_decide(app: tauri::AppHandle, owner: String, space: String, proposal: String, accept: bool, reason: String) -> Result<BoardView, String> {
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");
}
}
+80 -6
View File
@@ -98,6 +98,8 @@ struct ShareRef {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ShareEntry { pub struct ShareEntry {
/// The occurrence id (b58) — what a proposal's remove or move names.
pub entry_id: String,
pub position: u32, pub position: u32,
pub title: String, pub title: String,
pub artist: String, pub artist: String,
@@ -245,12 +247,12 @@ pub async fn publish(app: &tauri::AppHandle, lib: &LibraryState, playlist_id: i6
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, it)| pl::EntryV1 { .map(|(i, it)| pl::EntryV1 {
entry_id: rand16(), entry_id: entry_id_for(&uuid, it.id),
position: i as u32, position: i as u32,
recording: pl::RecordingRef { recording: pl::RecordingRef {
musicbrainz_recording_id: None, musicbrainz_recording_id: None,
isrcs: vec![], isrcs: vec![],
deezer_id: None, deezer_id: it.track_id.clone().filter(|t| !t.is_empty()),
apple_music_id: None, apple_music_id: None,
title: cap(&it.title, pl::MAX_FIELD_BYTES), title: cap(&it.title, pl::MAX_FIELD_BYTES),
artists: vec![cap(&it.artist, 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) .contract_put(REV_WASM.to_vec(), &pl::make_rev_params(&owner, &hash), bytes.clone(), false)
.await .await
.map_err(|e| e.to_string())?; .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 pointer_seq = prev.as_ref().map(|p| p.pointer_seq + 1).unwrap_or(1);
let mut p = pl::PointerState { let mut p = pl::PointerState {
schema_version: pl::SCHEMA_VERSION, 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![], entries: vec![],
}); });
}; };
let rev_ids = FreenetAdapter::contract_ids(REV_WASM.to_vec(), &pl::make_rev_params(&owner, &r.content_hash)); let (m, _) = revision_by_hash(app, &owner, &r.content_hash).await?;
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}"))?;
Ok(ShareView { Ok(ShareView {
owner_key: owner_b58.to_string(), owner_key: owner_b58.to_string(),
playlist_uuid: uuid_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 .entries
.into_iter() .into_iter()
.map(|e| ShareEntry { .map(|e| ShareEntry {
entry_id: b58(&e.entry_id),
position: e.position, position: e.position,
title: e.recording.title, title: e.recording.title,
artist: e.recording.artists.join(", "), 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<PathBuf> {
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<u8>), 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::<AppState>().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<Option<(pl::ManifestV1, [u8; 32])>, String> {
let adapter = app.state::<AppState>().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<Published> {
load(app).published.into_values().find(|p| p.uuid == uuid_b58)
}
/// A verified shared playlist staged through the app's own import path. /// A verified shared playlist staged through the app's own import path.
pub async fn import( pub async fn import(
app: &tauri::AppHandle, app: &tauri::AppHandle,
+1 -1
View File
@@ -191,7 +191,7 @@ async fn sign(app: &tauri::AppHandle, payload: Vec<u8>) -> Result<Vec<u8>, Strin
persona::sign(&adapter, sp::SIGN_DOMAIN, payload).await 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::<AppState>().freenet.clone(); let adapter = app.state::<AppState>().freenet.clone();
let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(owner, space_id)); let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(owner, space_id));
match adapter.contract_get(&ids.instance, false).await { match adapter.contract_get(&ids.instance, false).await {
+1
View File
@@ -57,6 +57,7 @@
"chevron-left": '<path d="m15 18-6-6 6-6"/>', "chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>', "chevron-right": '<path d="m9 18 6-6-6-6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>', "chevron-down": '<path d="m6 9 6 6 6-6"/>',
"chevron-up": '<path d="m18 15-6-6-6 6"/>',
// Lucide layers-2: its ink fills the viewBox symmetrically, so the // Lucide layers-2: its ink fills the viewBox symmetrically, so the
// glyph centers correctly inside small pills (the 3-layer variant // glyph centers correctly inside small pills (the 3-layer variant
// rides high when trimmed). // rides high when trimmed).
+3
View File
@@ -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 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 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 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 buddyList = () => invoke("buddy_list");
export const buddyAdd = (username) => invoke("buddy_add", { username }); export const buddyAdd = (username) => invoke("buddy_add", { username });
export const buddyRemove = (username) => invoke("buddy_remove", { username }); export const buddyRemove = (username) => invoke("buddy_remove", { username });
+41 -1
View File
@@ -80,6 +80,9 @@ export const community = $state({
/** The open Space (space_open), or null. */ /** The open Space (space_open), or null. */
space: null, space: null,
spaceError: "", 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 /** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */ * people's signed profiles, read once per session when surfaced. */
remoteProfiles: {}, remoteProfiles: {},
@@ -574,11 +577,48 @@ export const leaveSpace = (owner, space) =>
run("space:leave", async () => { run("space:leave", async () => {
await api.spaceLeave(owner, space); await api.spaceLeave(owner, space);
community.space = null; community.space = null;
community.board = null;
await refreshSpaces(); 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 // Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
// through this, never through a bare import (which would be a second copy). // 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.__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 };
+221 -1
View File
@@ -88,6 +88,9 @@
removeFromSpace, removeFromSpace,
pinToSpace, pinToSpace,
leaveSpace, leaveSpace,
openBoard,
proposeToBoard,
decideProposal,
} from "../lib/community.svelte.js"; } from "../lib/community.svelte.js";
onMount(() => { onMount(() => {
@@ -495,6 +498,91 @@
untrack(() => sharePreview({ id: "pin:" + s0.pinned_uuid, owner_key: s0.pinned_owner, playlist_uuid: s0.pinned_uuid })); 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() { async function importPinned() {
if (!sv?.pinned_uuid) return; 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" }); 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 @@
<button class="subtab" class:on={spaceTab === "chat"} onclick={() => (spaceTab = "chat")}>Chat<b>{sv.messages.length}</b></button> <button class="subtab" class:on={spaceTab === "chat"} onclick={() => (spaceTab = "chat")}>Chat<b>{sv.messages.length}</b></button>
<button class="subtab" class:on={spaceTab === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.length}</b></button> <button class="subtab" class:on={spaceTab === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.length}</b></button>
<button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button> <button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button>
<span class="subtab" style="opacity:0.55" title="The F7 proposal board is the next piece">Proposals · next</span> <button class="subtab" class:on={spaceTab === "proposals"} onclick={() => (spaceTab = "proposals")}>Proposals{#if bv && bv.uuid === sv.pinned_uuid && bv.proposals.length}<b>{bv.proposals.length}</b>{/if}</button>
</div> </div>
{#if community.spaceError}<p class="hint warn">{community.spaceError}</p>{/if} {#if community.spaceError}<p class="hint warn">{community.spaceError}</p>{/if}
{#if spaceTab === "chat"} {#if spaceTab === "chat"}
@@ -1158,6 +1246,102 @@
{/if} {/if}
<p class="hint" style="margin-top:12px">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}.</p> <p class="hint" style="margin-top:12px">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}.</p>
</div> </div>
{:else if spaceTab === "proposals"}
{#if !sv.pinned_uuid}
<div class="panel">
<h3><Icon name="list-plus" size={13} /> Proposals</h3>
<p class="hint">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}</p>
</div>
{:else if !bv && community.boardError}
<div class="panel">
<h3><Icon name="list-plus" size={13} /> Proposals</h3>
<p class="hint warn">{community.boardError}</p>
<div class="bbtns"><button class="ax-ghost sm" onclick={() => openBoard(sv.owner, sv.space_id)} disabled={boardBusy}><Icon name="refresh" size={12} /> Try again</button></div>
</div>
{:else if !bv}
<div class="panel"><p class="hint"><span class="bl"></span> Reading the board through your node…</p></div>
{:else}
<div class="panel">
<h3><Icon name="playlist" size={13} /> {bv.name || "Pinned playlist"}{#if bv.withdrawn}<span class="chip warn">withdrawn by its owner</span>{:else}<span class="chip acc"><Icon name="disc" size={10} />revision {bv.revision_seq}</span>{/if}</h3>
<p class="hint">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} · <span class="chip warn">the owner has not opened the board yet</span>{/if}</p>
<div class="bbtns">
{#if bv.can_propose && !boardDraft}<button class="ax-fill sm" onclick={startDraft} disabled={!nodeUp || boardBusy}><Icon name="list-plus" size={12} /> Propose changes</button>{/if}
{#if !bv.can_propose && !bv.i_am_owner && bv.reachable && !bv.withdrawn}<span class="hint">You are not on this board's roster yet — the owner refreshes it from the members when they open the board.</span>{/if}
<button class="ax-ghost sm" onclick={() => openBoard(sv.owner, sv.space_id)} disabled={boardBusy}><Icon name="refresh" size={12} /> {boardBusy ? "Working…" : "Refresh"}</button>
</div>
{#if community.boardError}<p class="hint warn">{community.boardError}</p>{/if}
</div>
{#if boardDraft}
<div class="panel">
<h3><Icon name="list-plus" size={13} /> Your proposal <span class="chip acc">{boardDraft.ops.length} / 32 changes</span></h3>
<p class="hint">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.</p>
<ol class="dlist">
{#each boardDraft.list as e, i (e.entry_id)}
<li class:fresh={e.fresh}>
<span class="n">{i + 1}</span>
<span class="tx"><span class="t">{e.title}</span><span class="a">{e.artist}{#if e.fresh} · added{/if}</span></span>
<span class="b">
<button class="ibtn sm" title="Move up" disabled={i === 0} onclick={() => draftApply({ kind: "move", entry_id: e.entry_id, to_position: i - 1 })}><Icon name="chevron-up" size={12} /></button>
<button class="ibtn sm" title="Move down" disabled={i === boardDraft.list.length - 1} onclick={() => draftApply({ kind: "move", entry_id: e.entry_id, to_position: i + 1 })}><Icon name="chevron-down" size={12} /></button>
<button class="ibtn sm" title="Remove" onclick={() => draftApply({ kind: "remove", entry_id: e.entry_id })}><Icon name="x" size={12} /></button>
</span>
</li>
{/each}
{#if !boardDraft.list.length}<li class="none">Empty — add something below.</li>{/if}
</ol>
<div class="qbox slim"><Icon name="search" size={13} /><input placeholder="Add from your library — title or artist" bind:value={boardQuery} oninput={searchLibrary} /></div>
{#if boardHits.length}
<div class="hits">{#each boardHits.slice(0, 8) as h (h.path)}<button class="hit" onclick={() => draftAdd(h)}><Icon name="plus" size={11} /><span class="t">{h.title}</span><span class="a">{h.artist}{h.album ? ` · ${h.album}` : ""}</span></button>{/each}</div>
{/if}
<div class="field"><input placeholder="A note for the owner — optional" bind:value={boardDraft.note} maxlength="280" /></div>
<div class="bbtns">
<button class="ax-fill sm" onclick={sendDraft} disabled={!boardDraft.ops.length || !nodeUp || boardBusy}><Icon name="send" size={12} /> {community.busy["board:propose"] ? "Signing & offering…" : "Send proposal"}</button>
<button class="ax-ghost sm" onclick={draftUndo} disabled={!boardDraft.ops.length}>Undo</button>
<button class="ax-ghost sm" onclick={() => (boardDraft = null)}>Discard</button>
</div>
</div>
{/if}
<div class="panel">
<h3>Pending <span class="chip off">{bv.proposals.length}</span></h3>
{#if !bv.proposals.length}<p class="hint">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}</p>{/if}
{#each bv.proposals as p (p.id)}
<div class="prop" class:stale={p.stale}>
<div class="ph">
<Identicon seed={p.author} size={30} />
<div class="who"><span class="nm">{whoProposed(p)}</span><span class="hint">{fmtWhen(p.created_at * 1000)} · {p.ops.length} {p.ops.length === 1 ? "change" : "changes"}</span></div>
{#if p.stale}<span class="chip warn">stale · an older revision</span>{:else}<span class="chip ok"><Icon name="check" size={10} />current revision</span>{/if}
</div>
{#if p.note}<p class="note">{p.note}</p>{/if}
<ul class="ops">
{#each p.ops as op, i (i)}
<li class={op.kind}>
<b>{op.kind === "add" ? "+" : op.kind === "remove" ? "−" : "↕"}</b>
<span>
{#if op.kind === "add"}add <i>{op.title}</i> — {op.artist} at #{op.position + 1}
{:else if op.kind === "remove"}remove {#if op.known}<i>{op.title}</i> — {op.artist}{:else}an entry from an older revision{/if}
{:else}move {#if op.known}<i>{op.title}</i>{:else}an entry from an older revision{/if} to #{op.position + 1}{/if}
</span>
</li>
{/each}
</ul>
{#if bv.i_am_owner}
<div class="decide">
{#if !p.stale}<button class="ax-fill sm" onclick={() => decideOn(p, true)} disabled={!nodeUp || boardBusy}><Icon name="check" size={12} /> {community.busy["board:decide"] ? "Working…" : "Accept & publish"}</button>{/if}
<input placeholder={p.stale ? "Reason — optional; it is marked stale either way" : "Reason — optional"} bind:value={boardReason[p.id]} maxlength="128" />
<button class="ax-ghost sm" onclick={() => decideOn(p, false)} disabled={!nodeUp || boardBusy}>{p.stale ? "Reject as stale" : "Reject"}</button>
</div>
{/if}
</div>
{/each}
</div>
<div class="panel">
<h3>Decisions <span class="chip off">{bv.decisions.length}</span></h3>
{#if !bv.decisions.length}<p class="hint">None yet. Each decision is owner-signed with a sequence number; the last 256 are kept.</p>{/if}
{#each bv.decisions as d (d.seq)}
<div class="dec"><span class="chip {d.accept ? 'ok' : 'off'}">#{d.seq} · {d.accept ? "accepted" : "rejected"}</span><span class="rs">{d.reason}</span>{#if d.result_hash}<span class="mono hint" title={d.result_hash}>→ revision {fpShort(d.result_hash)}</span>{/if}</div>
{/each}
</div>
{/if}
{:else} {:else}
<div class="panel"> <div class="panel">
<h3>About</h3> <h3>About</h3>
@@ -1861,6 +2045,42 @@
.sphead .nm { font-size: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; } .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; } .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; } .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; } .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 { padding: 6px 13px; border-radius: 8px; font-size: 12px; font-weight: 800; color: var(--text-secondary); display: inline-flex; gap: 6px; align-items: center; }
.subtab.on { background: var(--surf-hi); color: #fff; } .subtab.on { background: var(--surf-hi); color: #fff; }