diff --git a/pkgs/id/src/boards.rs b/pkgs/id/src/boards.rs index 59275feca..981fa4193 100644 --- a/pkgs/id/src/boards.rs +++ b/pkgs/id/src/boards.rs @@ -4,8 +4,12 @@ //! not a second signature or storage protocol. Each object is immutable; edits //! and deletions add [`BoardObject::Supersede`] and [`BoardObject::Tombstone`] //! records. [`BoardGraph`] checks signatures, canonical references, board scope, -//! and the authority rules needed to accept an object. It does not render a view, -//! enforce moderation, or fetch attachment/game payloads. +//! and the authority rules needed to accept an object. [`BoardObject::Theme`] +//! adds signed, portable themes a board applies by reference +//! ([`Board::theme_ref`]), and [`BoardGraph::compose_view`] projects the +//! three-layer view (personal filter / board view / host view) under the +//! transparency rule: hidden is never erased. It does not render markup, +//! serve content, or fetch attachment/game payloads. use std::collections::{BTreeMap, BTreeSet}; @@ -21,6 +25,10 @@ pub const BOARD_OBJECT_KIND: &str = "board_object.v1"; pub const MAX_BOARD_TEXT_BYTES: usize = 16 * 1024; /// Largest number of content references accepted in one object. pub const MAX_BOARD_REFERENCES: usize = 128; +/// Largest number of design tokens accepted in one theme. +pub const MAX_THEME_TOKENS: usize = 64; +/// Largest design-token value accepted in a theme. +pub const MAX_THEME_TOKEN_VALUE_BYTES: usize = 512; /// A canonical lower-case SHA-256 artifact/content hash. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] @@ -73,6 +81,13 @@ pub struct Board { pub board_id: String, /// Optional source root when this board is a successor fork. pub forked_from: Option, + /// Theme applied by reference, if the board names one. The named + /// [`Theme`] object must already be in the graph when the board root + /// is accepted: publish the theme first, then the board that applies + /// it. Older v1 board bodies written before this field existed decode + /// as `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme_ref: Option, } /// Versioned board rules. A revision is a new immutable object. @@ -239,6 +254,45 @@ pub struct Tombstone { pub reason: Option, } +/// Closed set of board layouts a theme may select. Custom HTML templates +/// are explicitly out of v1: a theme is data (tokens, one layout, asset +/// references), never markup or code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThemeLayout { + /// A single vertical list of threads. + List, + /// A grid of thread cards. + Grid, + /// A dense list with reduced spacing. + Compact, +} + +/// A signed, portable board theme: design tokens, one layout, and +/// content-addressed asset references. +/// +/// A theme names no board. It is a reusable signed package, and a board +/// applies one by reference through [`Board::theme_ref`]. That keeps the +/// content-addressed graph acyclic: a theme that named its board could +/// never be referenced from that board's own root hash. `forked_from` is +/// the fork lineage — the theme this one derives from, if any. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Theme { + /// Human-readable theme name. + pub name: String, + /// Design tokens by name (for example `accent`, `surface`); values + /// are plain data — colors, sizes, font names — never markup. + pub tokens: BTreeMap, + /// Layout selected from the closed v1 set. + pub layout: ThemeLayout, + /// Content-addressed asset blobs (logos, backgrounds); metadata + /// references only, payloads are never embedded. + pub assets: Vec, + /// Theme this one was forked from, if any (fork lineage). + pub forked_from: Option, +} + /// One typed body carried by a [`SignedBoardObject`]. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] @@ -267,6 +321,8 @@ pub enum BoardObject { Supersede(Supersede), /// Author deletion request. Tombstone(Tombstone), + /// Signed portable theme. + Theme(Theme), } impl BoardObject { @@ -284,12 +340,13 @@ impl BoardObject { Self::BoardFork(_) => "board_fork", Self::Supersede(_) => "supersede", Self::Tombstone(_) => "tombstone", + Self::Theme(_) => "theme", } } fn board_ref(&self) -> Option<&ArtifactHash> { match self { - Self::Board(_) | Self::BoardFork(_) => None, + Self::Board(_) | Self::BoardFork(_) | Self::Theme(_) => None, Self::Rules(value) => Some(&value.board), Self::Forum(value) => Some(&value.board), Self::Thread(value) => Some(&value.board), @@ -362,6 +419,31 @@ impl BoardObject { "duplicate source frontier reference" ); } + Self::Theme(value) => { + validate_text("theme name", &value.name, false)?; + ensure!( + value.tokens.len() <= MAX_THEME_TOKENS, + "a theme names at most {MAX_THEME_TOKENS} tokens" + ); + for (name, token) in &value.tokens { + validate_token_name(name)?; + ensure!( + !token.is_empty() + && token.len() <= MAX_THEME_TOKEN_VALUE_BYTES + && !token.contains('\0'), + "theme token value must be 1 to {MAX_THEME_TOKEN_VALUE_BYTES} bytes without NUL" + ); + } + ensure!( + value.assets.len() <= MAX_BOARD_REFERENCES, + "a theme names at most {MAX_BOARD_REFERENCES} assets" + ); + let unique: BTreeSet<_> = value.assets.iter().collect(); + ensure!( + unique.len() == value.assets.len(), + "duplicate theme asset reference" + ); + } Self::Supersede(value) => ensure!( value.previous != value.replacement, "an object cannot supersede itself" @@ -471,6 +553,16 @@ impl BoardGraph { if let Some(source) = &board.forked_from { self.require_board(source)?; } + if let Some(theme) = &board.theme_ref { + let theme_object = self + .objects + .get(theme) + .context("board theme reference is missing")?; + ensure!( + matches!(theme_object.object, BoardObject::Theme(_)), + "board theme reference is not a theme object" + ); + } } BoardObject::Rules(value) => { self.require_moderator(&value.board, author)?; @@ -601,6 +693,22 @@ impl BoardGraph { } } } + BoardObject::Theme(value) => { + if let Some(source) = &value.forked_from { + ensure!( + source != &signed.hash()?, + "a theme cannot fork from itself" + ); + let source_object = self + .objects + .get(source) + .context("theme fork lineage reference is missing")?; + ensure!( + matches!(source_object.object, BoardObject::Theme(_)), + "theme fork lineage reference is not a theme object" + ); + } + } BoardObject::Tombstone(value) => { let target = self.require_scoped(&value.target, &value.board, "")?; ensure!( @@ -694,6 +802,347 @@ impl BoardGraph { _ => false, }) } + + /// Compose the three-layer view of one board: the board layer + /// (signed moderation log + author tombstones + the board's theme), + /// the host layer (hashes the serving host will not serve), and the + /// personal layer (one viewer's own hides and muted authors). + /// + /// Transparency rule — hidden is not erased: a hidden object stays + /// in the graph, is omitted from [`BoardView::objects`], and is + /// reported in [`BoardView::hidden`] with the layer that hid it and, + /// for board-layer hides, the moderation action (or tombstone) that + /// did it. Every moderation action for the board is listed in + /// [`BoardView::moderation_log`] whether or not it currently hides + /// anything: the moderation log is public. + /// + /// Board-layer hide state uses the same order-independent existence + /// philosophy as role grants: a target is hidden while its signed + /// `Hide` records outnumber its signed `Restore` records, so every + /// peer holding the same log composes the same view in any arrival + /// order. When several layers hide one object, the reported layer + /// is the outermost applicable one: board, then host, then personal. + /// Superseded versions are not hidden and not shown — a projection + /// shows the replacement; the history stays in the graph. + /// + /// Two enforcement duties the substrate deliberately delegates to + /// this layer (see [`BoardGraph::has_role`]) are discharged here: + /// a moderation record has an effect only while its author holds + /// authority — the board owner, or a moderator whose grant is on + /// the current attestation frontier (a grant superseded by a + /// revocation no longer empowers; the frontier follows the signed + /// `supersedes` links, not arrival order) — and a superseded + /// moderation or tombstone record has no effect. Revoked or + /// superseded records stay in the public moderation log either + /// way. The board's theme is itself subject to the host and + /// personal layers: a suppressed theme is not returned, and the + /// suppression is reported in `hidden` like any other. + pub fn compose_view(&self, board: &ArtifactHash, layers: &ViewLayers) -> Result { + let root = self.require_board(board)?; + let BoardObject::Board(root_body) = &root.object else { + unreachable!("require_board checked board kind") + }; + let mut theme_hidden = None; + let theme = match &root_body.theme_ref { + Some(reference) => { + let theme_object = self + .objects + .get(reference) + .context("board theme reference is missing")?; + let BoardObject::Theme(theme) = &theme_object.object else { + bail!("board theme reference is not a theme object") + }; + if layers.host_hidden.contains(reference) { + theme_hidden = Some(HiddenObject { + object: reference.clone(), + layer: ViewLayer::Host, + action: None, + reason: None, + }); + None + } else if layers.personal_hidden.contains(reference) + || layers.muted_authors.contains(&theme_object.artifact.author) + { + theme_hidden = Some(HiddenObject { + object: reference.clone(), + layer: ViewLayer::Personal, + action: None, + reason: None, + }); + None + } else { + Some(theme.clone()) + } + } + None => None, + }; + + // Supersede relations first: a superseded record (a moderation + // action, a tombstone, any content object) has no effect in the + // projection, whatever it once said. A supersede record can + // itself be superseded, so the active records are a frontier, + // resolved to a fixed point: a record stays active while no + // active record names it as its previous version. (Supersede + // cycles cannot exist — insertion rejects an edge that would + // close one — so this terminates with the true frontier.) Only + // active records contribute their edges to the superseded set. + let supersede_records: Vec<(ArtifactHash, ArtifactHash)> = self + .objects + .iter() + .filter_map(|(hash, signed)| match &signed.object { + BoardObject::Supersede(value) if &value.board == board => { + Some((hash.clone(), value.previous.clone())) + } + _ => None, + }) + .collect(); + let mut active_records: BTreeSet = supersede_records + .iter() + .map(|(hash, _)| hash.clone()) + .collect(); + loop { + let named: BTreeSet = supersede_records + .iter() + .filter(|(hash, _)| active_records.contains(hash)) + .map(|(_, previous)| previous.clone()) + .collect(); + let next: BTreeSet = active_records + .difference(&named) + .cloned() + .collect(); + if next == active_records { + break; + } + active_records = next; + } + let superseded: BTreeSet = supersede_records + .iter() + .filter(|(hash, _)| active_records.contains(hash)) + .map(|(_, previous)| previous.clone()) + .collect(); + + // Current moderator frontier: a role attestation that another + // attestation supersedes is history, not authority. Following + // the signed supersedes links (never arrival order) keeps this + // identical on every peer holding the same set. + let mut attestation_superseded: BTreeSet = BTreeSet::new(); + for signed in self.objects.values() { + if let BoardObject::RoleAttestation(value) = &signed.object { + if &value.board == board { + if let Some(previous) = &value.supersedes { + attestation_superseded.insert(previous.clone()); + } + } + } + } + let current_moderators: BTreeSet = self + .objects + .iter() + .filter(|(hash, _)| { + !attestation_superseded.contains(hash) && !superseded.contains(hash) + }) + .filter_map(|(_, signed)| match &signed.object { + BoardObject::RoleAttestation(value) + if &value.board == board + && value.role == BoardRole::Moderator + && value.granted => + { + Some(value.member.clone()) + } + _ => None, + }) + .collect(); + let owner = root.artifact.author.as_str(); + let has_authority = |author: &str| { + author == owner || current_moderators.contains(author) + }; + + // Board layer facts, gathered in deterministic (content-hash) + // order from the map. + let mut moderation_log = Vec::new(); + let mut hide_balance: BTreeMap = BTreeMap::new(); + let mut hide_source: BTreeMap)> = + BTreeMap::new(); + let mut tombstones: BTreeMap)> = + BTreeMap::new(); + for (hash, signed) in &self.objects { + match &signed.object { + BoardObject::ModerationAction(value) if &value.board == board => { + // The log is public and complete: every action is + // listed, including ones that no longer (or never + // did) carry authority. Only empowered, current + // records change the projection. + moderation_log.push(hash.clone()); + if superseded.contains(hash) + || !has_authority(signed.artifact.author.as_str()) + { + continue; + } + match value.action { + ModerationActionKind::Hide => { + *hide_balance.entry(value.target.clone()).or_default() += 1; + hide_source.insert( + value.target.clone(), + (hash.clone(), value.reason.clone()), + ); + } + ModerationActionKind::Restore => { + *hide_balance.entry(value.target.clone()).or_default() -= 1; + } + } + } + BoardObject::Tombstone(value) if &value.board == board => { + if !superseded.contains(hash) { + tombstones + .entry(value.target.clone()) + .or_insert_with(|| (hash.clone(), value.reason.clone())); + } + } + _ => {} + } + } + + let mut objects = Vec::new(); + let mut hidden = Vec::new(); + if let Some(entry) = theme_hidden { + hidden.push(entry); + } + for (hash, signed) in &self.objects { + if hash != board && signed.object.board_ref() != Some(board) { + continue; + } + // Log and relation records are not view content: the log is + // reported as `moderation_log`, and supersede/tombstone + // records act on their targets instead of being shown. + if matches!( + signed.object, + BoardObject::ModerationAction(_) + | BoardObject::Supersede(_) + | BoardObject::Tombstone(_) + ) { + continue; + } + if superseded.contains(hash) { + continue; + } + let board_hide = hide_balance.get(hash).copied().unwrap_or(0) > 0; + let tombstone = tombstones.get(hash); + if board_hide || tombstone.is_some() { + let (action, reason) = if board_hide { + let (action, reason) = hide_source + .get(hash) + .cloned() + .unwrap_or((hash.clone(), None)); + (Some(action), reason) + } else { + let (action, reason) = + tombstone.cloned().unwrap_or((hash.clone(), None)); + (Some(action), reason) + }; + hidden.push(HiddenObject { + object: hash.clone(), + layer: ViewLayer::Board, + action, + reason, + }); + } else if layers.host_hidden.contains(hash) { + hidden.push(HiddenObject { + object: hash.clone(), + layer: ViewLayer::Host, + action: None, + reason: None, + }); + } else if layers.personal_hidden.contains(hash) + || layers.muted_authors.contains(&signed.artifact.author) + { + hidden.push(HiddenObject { + object: hash.clone(), + layer: ViewLayer::Personal, + action: None, + reason: None, + }); + } else { + objects.push(hash.clone()); + } + } + + Ok(BoardView { + board: board.clone(), + theme, + objects, + hidden, + moderation_log, + }) + } +} + +/// Which layer of a composed board view hid an object. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ViewLayer { + /// The viewer's own filter: personal hides and muted authors. + Personal, + /// The board's own signed record: moderation log and tombstones. + Board, + /// The serving host's policy set. + Host, +} + +/// One object a composed view hid, and the layer that hid it. +/// +/// The object itself stays in the graph — hidden is not erased — so +/// this entry (plus the public moderation log for board-layer hides) +/// is how a viewer, or an auditor, can tell that something is missing +/// from the projection and why. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HiddenObject { + /// Hash of the hidden object. + pub object: ArtifactHash, + /// Layer that hid it (outermost applicable: board, host, personal). + pub layer: ViewLayer, + /// Moderation action or tombstone that hid it, for board-layer + /// hides; `None` for host and personal hides, which are not signed + /// graph records. + pub action: Option, + /// Recorded reason, when the hiding record carries one. + pub reason: Option, +} + +/// Caller-supplied outer layers for [`BoardGraph::compose_view`]. +/// +/// Host and personal filters are serving/viewing state, not signed +/// graph objects — a host's policy and a viewer's mutes are theirs to +/// keep — so they are inputs to the projection, never inserted. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ViewLayers { + /// Objects this viewer has personally hidden. + pub personal_hidden: BTreeSet, + /// Authors (canonical Ed25519 public keys) this viewer has muted. + pub muted_authors: BTreeSet, + /// Objects the serving host will not serve. + pub host_hidden: BTreeSet, +} + +/// A composed three-layer view of one board. This is a projection of +/// the graph, not a copy of it: every hash names an object that stays +/// in the graph, hidden ones included. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BoardView { + /// Board root hash this view was composed for. + pub board: ArtifactHash, + /// Theme the board applies by reference, resolved from the graph. + pub theme: Option, + /// Visible current objects: the board root and its content objects, + /// in deterministic content-hash order. + pub objects: Vec, + /// Objects hidden by one of the three layers, with the layer and + /// (for board-layer hides) the record that hid them. + pub hidden: Vec, + /// Every moderation action recorded for this board, in + /// deterministic content-hash order. The moderation log is public: + /// it is listed whether or not each entry currently hides anything. + pub moderation_log: Vec, } fn validate_board_id(value: &str) -> Result<()> { @@ -717,6 +1166,17 @@ fn validate_text(name: &str, value: &str, allow_empty: bool) -> Result<()> { Ok(()) } +fn validate_token_name(value: &str) -> Result<()> { + ensure!( + (1..=64).contains(&value.len()) + && value.bytes().all(|byte| byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || b"-_.".contains(&byte)), + "theme token name must be 1-64 lower-case ASCII letters, digits, '-', '_' or '.'" + ); + Ok(()) +} + fn is_public_key(value: &str) -> bool { if value.len() != 64 || !value @@ -756,6 +1216,7 @@ mod tests { BoardObject::Board(Board { board_id: "retro-games".to_owned(), forked_from: None, + theme_ref: None, }), ) .unwrap() @@ -783,6 +1244,76 @@ mod tests { .unwrap() } + fn theme(key: &SigningKey, seq: u64, forked_from: Option) -> SignedBoardObject { + let mut tokens = BTreeMap::new(); + tokens.insert("accent".to_owned(), "#cc5500".to_owned()); + tokens.insert("surface".to_owned(), "#101418".to_owned()); + SignedBoardObject::sign( + key, + seq, + "", + BoardObject::Theme(Theme { + name: "Ember".to_owned(), + tokens, + layout: ThemeLayout::Grid, + assets: Vec::new(), + forked_from, + }), + ) + .unwrap() + } + + fn themed_board(key: &SigningKey, theme_ref: ArtifactHash) -> SignedBoardObject { + SignedBoardObject::sign( + key, + 1, + "", + BoardObject::Board(Board { + board_id: "themed-board".to_owned(), + forked_from: None, + theme_ref: Some(theme_ref), + }), + ) + .unwrap() + } + + fn thread(key: &SigningKey, board: ArtifactHash, title: &str) -> SignedBoardObject { + SignedBoardObject::sign( + key, + 1, + "", + BoardObject::Thread(Thread { + board, + forum_ref: None, + rules_ref: None, + title: title.to_owned(), + }), + ) + .unwrap() + } + + fn moderation( + key: &SigningKey, + seq: u64, + board: ArtifactHash, + target: ArtifactHash, + action: ModerationActionKind, + reason: Option<&str>, + ) -> SignedBoardObject { + SignedBoardObject::sign( + key, + seq, + "", + BoardObject::ModerationAction(ModerationAction { + board, + target, + action, + reason: reason.map(str::to_owned), + }), + ) + .unwrap() + } + #[test] fn signed_typed_objects_round_trip_and_reject_forgery() { let signer = key(1); @@ -1136,4 +1667,432 @@ mod tests { .unwrap(); assert!(graph.insert(ungranted_forum).is_err()); } -} + #[test] + fn theme_fork_lineage_must_name_an_existing_theme() { + let author = key(20); + let mut graph = BoardGraph::default(); + let base = graph.insert(theme(&author, 1, None)).unwrap(); + graph.insert(theme(&author, 2, Some(base))).unwrap(); + + let missing = ArtifactHash::new("b".repeat(64)).unwrap(); + assert!(graph.insert(theme(&author, 3, Some(missing))).is_err()); + + let root_hash = graph.insert(board(&author)).unwrap(); + assert!(graph.insert(theme(&author, 4, Some(root_hash))).is_err()); + } + + #[test] + fn theme_shape_rejects_bad_tokens_and_duplicate_assets() { + let author = key(21); + let mut bad_name = BTreeMap::new(); + bad_name.insert("Accent".to_owned(), "#ffffff".to_owned()); + assert!(SignedBoardObject::sign( + &author, + 1, + "", + BoardObject::Theme(Theme { + name: "Bad".to_owned(), + tokens: bad_name, + layout: ThemeLayout::List, + assets: Vec::new(), + forked_from: None, + }), + ) + .is_err()); + + let mut empty_value = BTreeMap::new(); + empty_value.insert("accent".to_owned(), String::new()); + assert!(SignedBoardObject::sign( + &author, + 1, + "", + BoardObject::Theme(Theme { + name: "Bad".to_owned(), + tokens: empty_value, + layout: ThemeLayout::List, + assets: Vec::new(), + forked_from: None, + }), + ) + .is_err()); + + let asset = ArtifactHash::new("c".repeat(64)).unwrap(); + assert!(SignedBoardObject::sign( + &author, + 1, + "", + BoardObject::Theme(Theme { + name: "Bad".to_owned(), + tokens: BTreeMap::new(), + layout: ThemeLayout::Compact, + assets: vec![asset.clone(), asset], + forked_from: None, + }), + ) + .is_err()); + } + + #[test] + fn board_theme_ref_applies_a_theme_by_reference() { + let author = key(22); + let mut graph = BoardGraph::default(); + let theme_signed = theme(&author, 1, None); + let theme_hash = graph.insert(theme_signed.clone()).unwrap(); + + // A board naming a theme that is not in the graph is rejected... + let missing = ArtifactHash::new("d".repeat(64)).unwrap(); + assert!(graph.insert(themed_board(&author, missing)).is_err()); + // ...as is a board whose theme_ref names a non-theme object. + let other_board = graph.insert(board(&author)).unwrap(); + assert!(graph.insert(themed_board(&author, other_board)).is_err()); + + let root_hash = graph.insert(themed_board(&author, theme_hash)).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + let BoardObject::Theme(expected) = theme_signed.object else { + unreachable!("theme helper builds a theme") + }; + assert_eq!(view.theme, Some(expected)); + assert!(view.objects.contains(&root_hash)); + assert!(view.hidden.is_empty()); + + // Board bodies written before theme_ref existed still decode. + let legacy: Board = + serde_json::from_str(r#"{"board_id":"legacy","forked_from":null}"#).unwrap(); + assert_eq!(legacy.theme_ref, None); + } + + #[test] + fn layered_view_hides_transparently_and_restore_unhides() { + let owner = key(23); + let author = key(24); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let post_hash = graph + .insert(post(&author, root_hash.clone(), thread_hash, "buy my stuff")) + .unwrap(); + + let hide = moderation( + &owner, + 2, + root_hash.clone(), + post_hash.clone(), + ModerationActionKind::Hide, + Some("spam"), + ); + let hide_hash = graph.insert(hide).unwrap(); + + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(!view.objects.contains(&post_hash)); + assert_eq!(view.moderation_log, vec![hide_hash.clone()]); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].object, post_hash); + assert_eq!(view.hidden[0].layer, ViewLayer::Board); + assert_eq!(view.hidden[0].action, Some(hide_hash)); + assert_eq!(view.hidden[0].reason.as_deref(), Some("spam")); + // Hidden is not erased: the object is still in the graph. + assert!(graph.get(&post_hash).is_some()); + + let restore = moderation( + &owner, + 3, + root_hash.clone(), + post_hash.clone(), + ModerationActionKind::Restore, + None, + ); + graph.insert(restore).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(view.objects.contains(&post_hash)); + assert!(view.hidden.is_empty()); + assert_eq!(view.moderation_log.len(), 2); + + // A second hide outnumbers the restore and hides it again. + let rehide = moderation( + &owner, + 4, + root_hash.clone(), + post_hash.clone(), + ModerationActionKind::Hide, + Some("spam again"), + ); + graph.insert(rehide).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].reason.as_deref(), Some("spam again")); + } + + #[test] + fn personal_and_host_layers_hide_without_a_board_record() { + let owner = key(25); + let alice = key(26); + let bob = key(27); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let alice_post = graph + .insert(post(&alice, root_hash.clone(), thread_hash.clone(), "alice")) + .unwrap(); + let bob_post = graph + .insert(post(&bob, root_hash.clone(), thread_hash, "bob")) + .unwrap(); + + let mut layers = ViewLayers::default(); + layers + .muted_authors + .insert(hex_encode(&alice.verifying_key().to_bytes())); + layers.host_hidden.insert(bob_post.clone()); + let view = graph.compose_view(&root_hash, &layers).unwrap(); + assert!(view.moderation_log.is_empty()); + assert_eq!(view.hidden.len(), 2); + let by_object: BTreeMap<_, _> = view + .hidden + .iter() + .map(|entry| (entry.object.clone(), entry.layer)) + .collect(); + assert_eq!(by_object[&alice_post], ViewLayer::Personal); + assert_eq!(by_object[&bob_post], ViewLayer::Host); + assert!(!view.objects.contains(&alice_post)); + assert!(!view.objects.contains(&bob_post)); + + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(view.objects.contains(&alice_post)); + assert!(view.objects.contains(&bob_post)); + assert!(view.hidden.is_empty()); + } + + #[test] + fn view_enforces_moderator_revocation_but_keeps_the_public_log() { + let owner = key(30); + let moderator = key(31); + let author = key(32); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let post_hash = graph + .insert(post(&author, root_hash.clone(), thread_hash, "hello")) + .unwrap(); + let grant = SignedBoardObject::sign( + &owner, + 2, + "", + BoardObject::RoleAttestation(RoleAttestation { + board: root_hash.clone(), + member: hex_encode(&moderator.verifying_key().to_bytes()), + role: BoardRole::Moderator, + granted: true, + supersedes: None, + }), + ) + .unwrap(); + let grant_hash = graph.insert(grant).unwrap(); + let hide_hash = graph + .insert(moderation( + &moderator, + 1, + root_hash.clone(), + post_hash.clone(), + ModerationActionKind::Hide, + Some("spam"), + )) + .unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].object, post_hash); + + let revoke = SignedBoardObject::sign( + &owner, + 3, + "", + BoardObject::RoleAttestation(RoleAttestation { + board: root_hash.clone(), + member: hex_encode(&moderator.verifying_key().to_bytes()), + role: BoardRole::Moderator, + granted: false, + supersedes: Some(grant_hash), + }), + ) + .unwrap(); + graph.insert(revoke).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + // The revoked moderator's hide no longer has an effect... + assert!(view.objects.contains(&post_hash)); + assert!(view.hidden.is_empty()); + // ...but it stays in the public moderation log. + assert_eq!(view.moderation_log, vec![hide_hash]); + } + + #[test] + fn superseded_tombstone_loses_effect_and_keeps_its_signed_reason() { + let owner = key(33); + let author = key(34); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let first = graph + .insert(post(&author, root_hash.clone(), thread_hash.clone(), "first")) + .unwrap(); + let second = graph + .insert(post(&author, root_hash.clone(), thread_hash, "second")) + .unwrap(); + let tombstone = |target: ArtifactHash, reason: &str| { + SignedBoardObject::sign( + &author, + 2, + "", + BoardObject::Tombstone(Tombstone { + board: root_hash.clone(), + target, + reason: Some(reason.to_owned()), + }), + ) + .unwrap() + }; + let first_tombstone = graph.insert(tombstone(first.clone(), "wrong thread")).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].object, first); + assert_eq!(view.hidden[0].reason.as_deref(), Some("wrong thread")); + + let second_tombstone = + graph.insert(tombstone(second.clone(), "duplicate")).unwrap(); + let supersede = SignedBoardObject::sign( + &author, + 3, + "", + BoardObject::Supersede(Supersede { + board: root_hash.clone(), + previous: first_tombstone, + replacement: second_tombstone, + }), + ) + .unwrap(); + graph.insert(supersede).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(view.objects.contains(&first)); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].object, second); + assert_eq!(view.hidden[0].reason.as_deref(), Some("duplicate")); + } + + #[test] + fn host_layer_can_suppress_the_board_theme() { + let author = key(35); + let mut graph = BoardGraph::default(); + let theme_hash = graph.insert(theme(&author, 1, None)).unwrap(); + let root_hash = graph.insert(themed_board(&author, theme_hash.clone())).unwrap(); + + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(view.theme.is_some()); + + let mut layers = ViewLayers::default(); + layers.host_hidden.insert(theme_hash); + let view = graph.compose_view(&root_hash, &layers).unwrap(); + assert!(view.theme.is_none()); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].layer, ViewLayer::Host); + } + + #[test] + fn generic_supersede_of_a_grant_also_revokes_authority() { + let owner = key(36); + let moderator = key(37); + let author = key(38); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let post_hash = graph + .insert(post(&author, root_hash.clone(), thread_hash, "hello")) + .unwrap(); + let attestation = |granted: bool| { + SignedBoardObject::sign( + &owner, + 2, + "", + BoardObject::RoleAttestation(RoleAttestation { + board: root_hash.clone(), + member: hex_encode(&moderator.verifying_key().to_bytes()), + role: BoardRole::Moderator, + granted, + supersedes: None, + }), + ) + .unwrap() + }; + let grant_hash = graph.insert(attestation(true)).unwrap(); + graph + .insert(moderation( + &moderator, + 1, + root_hash.clone(), + post_hash.clone(), + ModerationActionKind::Hide, + Some("spam"), + )) + .unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert_eq!(view.hidden.len(), 1); + + // Revocation by generic Supersede record (not the attestation's + // own supersedes field) must end the authority just the same. + let revoke_hash = graph.insert(attestation(false)).unwrap(); + let supersede = SignedBoardObject::sign( + &owner, + 3, + "", + BoardObject::Supersede(Supersede { + board: root_hash.clone(), + previous: grant_hash, + replacement: revoke_hash, + }), + ) + .unwrap(); + graph.insert(supersede).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(view.objects.contains(&post_hash)); + assert!(view.hidden.is_empty()); + assert_eq!(view.moderation_log.len(), 1); + } + + #[test] + fn superseded_supersede_record_has_no_effect() { + let owner = key(39); + let author = key(40); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let mut posts = Vec::new(); + for body in ["a", "b", "c", "d"] { + posts.push( + graph + .insert(post(&author, root_hash.clone(), thread_hash.clone(), body)) + .unwrap(), + ); + } + let supersede = |seq: u64, previous: ArtifactHash, replacement: ArtifactHash| { + SignedBoardObject::sign( + &author, + seq, + "", + BoardObject::Supersede(Supersede { + board: root_hash.clone(), + previous, + replacement, + }), + ) + .unwrap() + }; + let first = graph.insert(supersede(2, posts[0].clone(), posts[1].clone())).unwrap(); + let second = graph.insert(supersede(3, posts[2].clone(), posts[3].clone())).unwrap(); + graph.insert(supersede(4, first, second)).unwrap(); + + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + // The first supersede was itself superseded: post A is current + // again. The second supersede is active: post C is replaced. + assert!(view.objects.contains(&posts[0])); + assert!(view.objects.contains(&posts[1])); + assert!(!view.objects.contains(&posts[2])); + assert!(view.objects.contains(&posts[3])); + } + +} \ No newline at end of file