diff --git a/.env.example b/.env.example index 7ededb48cc6..e51e594039a 100644 --- a/.env.example +++ b/.env.example @@ -178,6 +178,11 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN=300 # BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN=600 +# Most channels one guarded task archive or restore may change: 1 to 1000, +# default 250. Any other value is a startup error. NIP-11 advertises it as +# `lifecycle_guard.max_subtree_channels`. +# BUZZ_LIFECYCLE_MAX_SUBTREE_CHANNELS=250 + # ----------------------------------------------------------------------------- # Git (NIP-34 bare repositories) # ----------------------------------------------------------------------------- diff --git a/Cargo.lock b/Cargo.lock index 92c8f60307a..082be6d0a96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11407,6 +11407,7 @@ dependencies = [ "getrandom 0.4.3", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/crates/buzz-core/Cargo.toml b/crates/buzz-core/Cargo.toml index c55225adf60..97a9acd3753 100644 --- a/crates/buzz-core/Cargo.toml +++ b/crates/buzz-core/Cargo.toml @@ -16,7 +16,7 @@ nostr = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -uuid = { workspace = true } +uuid = { workspace = true, features = ["v5"] } chrono = { workspace = true } hex = { workspace = true } hmac = { workspace = true } diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 99b4aa68032..93eccd85f5b 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -86,3 +86,5 @@ pub mod test_helpers { pub mod artifact; /// NIP-AR sync filter parsing and limits. pub mod artifact_sync; +/// Guarded task archive and restore: task profile, guard tags and digest. +pub mod lifecycle; diff --git a/crates/buzz-core/src/lifecycle.rs b/crates/buzz-core/src/lifecycle.rs new file mode 100644 index 00000000000..b66e78ba39b --- /dev/null +++ b/crates/buzz-core/src/lifecycle.rs @@ -0,0 +1,880 @@ +//! Guarded task archive and restore: the `nexus.task` hierarchy profile, the +//! kind:9002 lifecycle guard tags and the lifecycle digest. +//! +//! A guarded state request carries `["archived", "true"|"false"]`, +//! `["scope", "target"|"subtree"]` and `["expect", hex(D)]`. A fence carries +//! `["lifecycle", "fence"]` and `["expect-target", |"-"]`. The relay +//! enforces either the whole guard or refuses the request; it never applies a +//! guarded request as an unguarded one. +use crate::artifact::{canonical_uuid, ArtifactEnvelope, ArtifactOp}; +use nostr::Event; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// Artifact type of the records whose `parent` tags the relay validates. +pub const TASK_ARTIFACT_TYPE: &str = "nexus.task"; + +/// Domain separator, the first line of every lifecycle digest. +pub const DIGEST_DOMAIN: &str = "buzz.lifecycle.v1"; + +/// Digest text for a token that no lifecycle writer has set since the column +/// was added. +pub const NULL_TOKEN: &str = "-"; + +/// kind:9002 tags that change channel settings. A guarded request carries +/// none of them, so its event changes lifecycle state and nothing else. +const SETTINGS_TAGS: &[&str] = &[ + "name", + "about", + "topic", + "purpose", + "visibility", + "ttl", + "t", + "join_role", + "unlisted", +]; + +/// Tags whose presence makes a kind:9002 a guarded lifecycle request. +const GUARD_TAGS: &[&str] = &["lifecycle", "expect", "expect-target", "scope"]; + +/// The `d` of a channel's task record: +/// UUIDv5(RFC 4122 URL namespace, `"nexus.task:" + channel`). +pub fn task_d(channel: Uuid) -> Uuid { + Uuid::new_v5( + &Uuid::NAMESPACE_URL, + format!("{TASK_ARTIFACT_TYPE}:{channel}").as_bytes(), + ) +} + +/// The hierarchy fields of a validated `nexus.task` revision. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TaskEdge { + /// The task's channel (`h`). + pub channel: Uuid, + /// The parent task's channel, or `None` for a root. Always `None` on a + /// delete, which removes the edge. + pub parent: Option, +} + +/// The hierarchy fields of `nexus.task` content. A derived struct refuses a +/// repeated hierarchy key instead of keeping one of its values. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskContent { + v: serde_json::Value, + channel_id: String, + /// `None` only when the key is absent: an explicit `null` is kept, and + /// refused, rather than read as a root. + #[serde(default, deserialize_with = "present")] + parent_channel_id: Option, +} + +fn present<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + serde::Deserialize::deserialize(deserializer).map(Some) +} + +/// Validate the `nexus.task` profile of an artifact revision. Returns +/// `Ok(None)` for any other artifact type. +/// +/// The relay parses only the hierarchy fields; the rest of the content stays +/// opaque. A revision must name its channel's derived `d`, carry content with +/// `v = 0` and `channelId = h`, and carry at most one `parent` tag that equals +/// the content's `parentChannelId`. +pub fn task_edge(event: &Event, env: &ArtifactEnvelope) -> Result, &'static str> { + if env.artifact_type != TASK_ARTIFACT_TYPE { + return Ok(None); + } + if env.id != task_d(env.home) { + return Err("nexus.task d must be derived from its channel"); + } + if env.op == ArtifactOp::Delete { + // The envelope already refuses content and extra tags on a delete. + return Ok(Some(TaskEdge { + channel: env.home, + parent: None, + })); + } + let mut parent_tag = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("parent") { + continue; + } + if parts.len() != 2 || parent_tag.is_some() { + return Err("nexus.task allows one parent tag with exactly two elements"); + } + parent_tag = Some(canonical_uuid(&parts[1])?); + } + let content: TaskContent = serde_json::from_str(&event.content) + .map_err(|_| "nexus.task content is not a v0 record")?; + if content.v.as_u64() != Some(0) || content.v.is_f64() { + return Err("nexus.task content is not a v0 record"); + } + if content.channel_id != env.home.to_string() { + return Err("nexus.task content channelId must equal h"); + } + let content_parent = match content.parent_channel_id { + None => None, + Some(serde_json::Value::String(value)) => Some(canonical_uuid(&value)?), + Some(_) => return Err("nexus.task parentChannelId must be a UUID"), + }; + if content_parent != parent_tag { + return Err("nexus.task parent tag must equal content parentChannelId"); + } + if parent_tag == Some(env.home) { + return Err("a task cannot be its own parent"); + } + Ok(Some(TaskEdge { + channel: env.home, + parent: parent_tag, + })) +} + +/// Which channels a guarded restore writes. Archive always uses `Subtree`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LifecycleScope { + /// The target and its minimum archived ancestor chain. + Target, + /// The target, its minimum archived ancestor chain and its descendants. + Subtree, +} + +impl LifecycleScope { + /// Wire and digest text. + pub fn as_str(self) -> &'static str { + match self { + Self::Target => "target", + Self::Subtree => "subtree", + } + } +} + +/// A parsed kind:9002 lifecycle guard. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LifecycleGuard { + /// Archive (`archive = true`) or restore, applied only if the digest of + /// the current lifecycle state equals `expect`. + State { + /// `true` archives, `false` restores. + archive: bool, + /// Which channels the request writes. + scope: LifecycleScope, + /// The digest the client previewed. + expect: [u8; 32], + }, + /// Replace the target's token if it still equals `expect_target` + /// (`None` for a token that was never set). Changes no other state. + Fence { + /// The token the client last read. + expect_target: Option, + }, +} + +/// Parse the lifecycle guard of a kind:9002. `Ok(None)` means the event +/// carries no guard tag. Any guard tag makes the whole guard mandatory: a +/// malformed or incomplete guard is an error, never an unguarded request. +pub fn parse_lifecycle_guard(event: &Event) -> Result, &'static str> { + let mut lifecycle = None; + let mut expect = None; + let mut expect_target = None; + let mut scope = None; + let mut archived = Vec::new(); + let mut targets = Vec::new(); + let mut guarded = false; + let mut settings = false; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + let Some(name) = parts.first().map(String::as_str) else { + continue; + }; + if SETTINGS_TAGS.contains(&name) { + settings = true; + } + // Kept whole: a guarded request refuses any other arity. + if name == "archived" { + archived.push(parts); + } + if name == "h" { + targets.push(parts); + } + if !GUARD_TAGS.contains(&name) { + continue; + } + guarded = true; + if parts.len() != 2 { + return Err("lifecycle tags must have exactly two elements"); + } + let slot = match name { + "lifecycle" => &mut lifecycle, + "expect" => &mut expect, + "expect-target" => &mut expect_target, + _ => &mut scope, + }; + if slot.replace(parts[1].as_str()).is_some() { + return Err("lifecycle tags must occur once"); + } + } + if !guarded { + return Ok(None); + } + // One unambiguous target; the generic `h` lookup takes the first match. + match targets.as_slice() { + [[_, target]] => { + canonical_uuid(target)?; + } + _ => return Err("a lifecycle request requires one h tag with exactly two elements"), + } + if settings { + return Err("a lifecycle request cannot change channel settings"); + } + if let Some(kind) = lifecycle { + if kind != "fence" { + return Err("unknown lifecycle request"); + } + if !archived.is_empty() || expect.is_some() || scope.is_some() { + return Err("a lifecycle fence carries only expect-target"); + } + let expect_target = match expect_target.ok_or("a lifecycle fence requires expect-target")? { + NULL_TOKEN => None, + token => Some(canonical_uuid(token)?), + }; + return Ok(Some(LifecycleGuard::Fence { expect_target })); + } + if expect_target.is_some() { + return Err("expect-target belongs to a lifecycle fence"); + } + let archive = match archived.as_slice() { + [[_, value]] if value == "true" => true, + [[_, value]] if value == "false" => false, + _ => return Err("a guarded lifecycle request requires one archived tag"), + }; + let scope = match scope { + Some("target") => LifecycleScope::Target, + Some("subtree") => LifecycleScope::Subtree, + _ => return Err("a guarded lifecycle request requires scope target or subtree"), + }; + if archive && scope == LifecycleScope::Target { + return Err("archive scope must be subtree"); + } + let expect = expect.ok_or("a guarded lifecycle request requires expect")?; + let mut digest = [0u8; 32]; + if expect.len() != 64 + || !expect + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || hex::decode_to_slice(expect, &mut digest).is_err() + { + return Err("expect must be 64 lowercase hex characters"); + } + Ok(Some(LifecycleGuard::State { + archive, + scope, + expect: digest, + })) +} + +/// A channel and its lifecycle token, as bound by a digest. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct TokenEntry { + /// The channel. + pub channel: Uuid, + /// Its token, or `None` if never set. + pub token: Option, +} + +impl TokenEntry { + fn line(&self) -> String { + match self.token { + Some(token) => format!("{} {token}", self.channel), + None => format!("{} {NULL_TOKEN}", self.channel), + } + } +} + +/// The lifecycle digest D of a guarded state request: SHA-256 over these +/// UTF-8 lines joined by `\n`, with no trailing newline: +/// +/// 1. [`DIGEST_DOMAIN`] +/// 2. `archive` or `restore` +/// 3. the scope (`target` or `subtree`) +/// 4. the target channel +/// 5. `compared`: ` ` of the first active ancestor above the +/// written chain, or `root` when there is none +/// 6. one ` ` line per affected channel, sorted by channel +/// +/// UUIDs are lowercase and hyphenated; a token that was never set is `-`. +pub fn lifecycle_digest( + archive: bool, + scope: LifecycleScope, + target: Uuid, + compared: Option, + affected: &[TokenEntry], +) -> [u8; 32] { + let mut affected = affected.to_vec(); + affected.sort(); + let mut lines = vec![ + DIGEST_DOMAIN.to_string(), + if archive { "archive" } else { "restore" }.to_string(), + scope.as_str().to_string(), + target.to_string(), + compared.map_or_else(|| "root".to_string(), |entry| entry.line()), + ]; + lines.extend(affected.iter().map(TokenEntry::line)); + Sha256::digest(lines.join("\n").as_bytes()).into() +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn event(kind: u16, tags: &[&[&str]], content: &str) -> Event { + let tags = tags + .iter() + .map(|t| Tag::parse(t.iter().copied()).unwrap()) + .collect::>(); + EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + #[test] + fn task_d_matches_the_nexus_derivation() { + // Vector from Nexus `taskD` (src/store/tasks.ts:363-372, 62ceb64): + // UUIDv5 over the URL namespace and "nexus.task:". + let channel = Uuid::parse_str("8f5b6a1e-2f0d-4c1a-9b3e-7d4c2a1f0e9b").unwrap(); + let d = task_d(channel); + assert_eq!(d.get_version_num(), 5); + assert_eq!( + d, + Uuid::new_v5( + &Uuid::parse_str("6ba7b811-9dad-11d1-80b4-00c04fd430c8").unwrap(), + b"nexus.task:8f5b6a1e-2f0d-4c1a-9b3e-7d4c2a1f0e9b" + ) + ); + assert_eq!(d.to_string(), "c0b4b794-a2d3-5bc2-b435-74db64e475cb"); + } + + #[test] + fn lifecycle_digest_pins_its_byte_encoding() { + let id = |n: u8| Uuid::from_bytes([n; 16]); + let affected = [ + TokenEntry { + channel: id(3), + token: Some(id(0xa3)), + }, + TokenEntry { + channel: id(2), + token: None, + }, + ]; + let compared = TokenEntry { + channel: id(1), + token: Some(id(0xa1)), + }; + let text = "buzz.lifecycle.v1\nrestore\nsubtree\n02020202-0202-0202-0202-020202020202\n\ +01010101-0101-0101-0101-010101010101 a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1\n\ +02020202-0202-0202-0202-020202020202 -\n\ +03030303-0303-0303-0303-030303030303 a3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3"; + let expected: [u8; 32] = Sha256::digest(text.as_bytes()).into(); + let digest = lifecycle_digest( + false, + LifecycleScope::Subtree, + id(2), + Some(compared), + &affected, + ); + assert_eq!(digest, expected); + assert_eq!( + hex::encode(digest), + "3821bc3142cdbc0a678052580d5e103553669eb11ebd956d1678411d65938fd7" + ); + // Every bound field changes the digest. + let root = lifecycle_digest(false, LifecycleScope::Subtree, id(2), None, &affected); + let target = lifecycle_digest( + false, + LifecycleScope::Target, + id(2), + Some(compared), + &affected, + ); + let archive = lifecycle_digest( + true, + LifecycleScope::Subtree, + id(2), + Some(compared), + &affected, + ); + let bumped = lifecycle_digest( + false, + LifecycleScope::Subtree, + id(2), + Some(compared), + &[ + affected[0], + TokenEntry { + channel: id(2), + token: Some(id(9)), + }, + ], + ); + for other in [root, target, archive, bumped] { + assert_ne!(other, digest); + } + } + + /// Golden vectors for the client: archive of a subtree with an already + /// archived child, restore of a target with its archived ancestor chain, + /// and archive of a root. + #[test] + fn lifecycle_digest_golden_vectors() { + let id = |n: u8| Uuid::from_bytes([n; 16]); + let entry = |channel: u8, token: Option| TokenEntry { + channel: id(channel), + token: token.map(id), + }; + // 1 -> 2 (target) -> {3 -> 5, 4 archived}: affected 2, 3, 5. + let archive = lifecycle_digest( + true, + LifecycleScope::Subtree, + id(2), + Some(entry(1, Some(0xa1))), + &[entry(5, None), entry(2, Some(0xa2)), entry(3, Some(0xa3))], + ); + assert_eq!( + hex::encode(archive), + "e9fa8e584e36a45a6773703a48536e276fee69015545bbc3b40169a56cbaeef7" + ); + // 1 active -> 2 archived -> 3 archived -> 4 archived (target): + // affected 2, 3, 4; compared 1. + let restore = lifecycle_digest( + false, + LifecycleScope::Target, + id(4), + Some(entry(1, Some(0xa1))), + &[entry(4, Some(0xb4)), entry(2, Some(0xb2)), entry(3, None)], + ); + assert_eq!( + hex::encode(restore), + "32a700a437403b0695e028542a50e74705b4416095dee22366a8584fd7ce4fdd" + ); + let root = lifecycle_digest( + true, + LifecycleScope::Subtree, + id(1), + None, + &[entry(1, None)], + ); + assert_eq!( + hex::encode(root), + "948f7d0fd8015aa8354f4f6f193dfc60442ca6bcb485d53b7a36e9a4738eedc7" + ); + // The Nexus client's independently computed vectors. + let v4 = |text: &str| Uuid::parse_str(text).unwrap(); + let at = |channel: &str, token: Option<&str>| TokenEntry { + channel: v4(channel), + token: token.map(v4), + }; + let (c3, c4, c5, c6) = ( + "33333333-3333-4333-8333-333333333333", + "44444444-4444-4444-8444-444444444444", + "55555555-5555-4555-8555-555555555555", + "66666666-6666-4666-8666-666666666666", + ); + let client = [ + lifecycle_digest( + true, + LifecycleScope::Subtree, + v4(c3), + None, + &[ + at(c5, None), + at(c3, Some("bbbbbbbb-bbbb-4bbb-abbb-bbbbbbbbbbbb")), + at(c4, Some("cccccccc-cccc-4ccc-accc-cccccccccccc")), + ], + ), + lifecycle_digest( + false, + LifecycleScope::Target, + v4(c5), + Some(at( + "11111111-1111-4111-8111-111111111111", + Some("dddddddd-dddd-4ddd-addd-dddddddddddd"), + )), + &[ + at(c5, Some("eeeeeeee-eeee-4eee-aeee-eeeeeeeeeeee")), + at(c3, None), + ], + ), + lifecycle_digest( + false, + LifecycleScope::Subtree, + v4(c3), + None, + &[at(c6, None), at(c3, None)], + ), + ]; + assert_eq!( + client.map(hex::encode), + [ + "862bfa3b97e3e10856a18a942e547b386e2582e407d2b2c1601bfff7c3cb6db5", + "1dc896768fc8b72ccf195aa4eb9fd4689bbbb99e8031d24932932753d56a56bc", + "6a93b6009926c8e7bd731f926e96a02020fb4cf33084ef76a5517ea1a27990f2", + ] + ); + } + + #[test] + fn guard_parsing_never_degrades_to_unguarded() { + let h = Uuid::new_v4().to_string(); + let hex = "ab".repeat(32); + let token = Uuid::new_v4().to_string(); + let ok = |tags: &[&[&str]]| parse_lifecycle_guard(&event(9002, tags, "")); + assert_eq!(ok(&[&["h", &h], &["archived", "true"]]), Ok(None)); + assert!(matches!( + ok(&[ + &["h", &h], + &["archived", "true"], + &["scope", "subtree"], + &["expect", &hex] + ]), + Ok(Some(LifecycleGuard::State { + archive: true, + scope: LifecycleScope::Subtree, + .. + })) + )); + assert!(matches!( + ok(&[ + &["h", &h], + &["archived", "false"], + &["scope", "target"], + &["expect", &hex] + ]), + Ok(Some(LifecycleGuard::State { + archive: false, + scope: LifecycleScope::Target, + .. + })) + )); + assert_eq!( + ok(&[&["h", &h], &["lifecycle", "fence"], &["expect-target", "-"]]), + Ok(Some(LifecycleGuard::Fence { + expect_target: None + })) + ); + assert!(matches!( + ok(&[ + &["h", &h], + &["lifecycle", "fence"], + &["expect-target", &token] + ]), + Ok(Some(LifecycleGuard::Fence { + expect_target: Some(_) + })) + )); + let upper = hex.to_uppercase(); + let other = Uuid::new_v4().to_string(); + let h_upper = h.to_uppercase(); + let state = |target: &[&str]| -> Vec> { + [ + target, + &["archived", "true"], + &["scope", "subtree"], + &["expect", &hex], + ] + .iter() + .map(|t| t.iter().map(|v| v.to_string()).collect()) + .collect() + }; + // The target is one canonical `h` tag of exactly two elements. + for tags in [ + state(&["h", &h, "x"]), + state(&["h", &h_upper]), + state(&["h", "not-a-uuid"]), + [state(&["h", &h]), vec![vec!["h".into(), other.clone()]]].concat(), + state(&["d", &h]), + ] { + let tags: Vec> = tags + .iter() + .map(|t| t.iter().map(String::as_str).collect()) + .collect(); + let tags: Vec<&[&str]> = tags.iter().map(Vec::as_slice).collect(); + assert!(ok(&tags).is_err(), "{tags:?}"); + } + let refused: &[&[&[&str]]] = &[ + // Archive is always subtree. + &[ + &["archived", "true"], + &["scope", "target"], + &["expect", &hex], + ], + // An archived tag of any other arity. + &[ + &["archived", "true", "x"], + &["scope", "subtree"], + &["expect", &hex], + ], + &[&["archived"], &["scope", "subtree"], &["expect", &hex]], + // Missing or unknown parts. + &[&["archived", "true"], &["expect", &hex]], + &[&["archived", "true"], &["scope", "subtree"]], + &[&["archived", "true"], &["scope", "all"], &["expect", &hex]], + &[&["scope", "subtree"], &["expect", &hex]], + &[ + &["archived", "maybe"], + &["scope", "subtree"], + &["expect", &hex], + ], + &[ + &["archived", "true"], + &["archived", "true"], + &["scope", "subtree"], + &["expect", &hex], + ], + &[ + &["archived", "true"], + &["scope", "subtree"], + &["expect", &upper], + ], + &[ + &["archived", "true"], + &["scope", "subtree"], + &["expect", "abc"], + ], + &[ + &["archived", "true"], + &["scope", "subtree"], + &["expect", &hex], + &["expect", &hex], + ], + &[ + &["archived", "true"], + &["scope", "subtree", "x"], + &["expect", &hex], + ], + // Settings changes do not ride on a guarded request. + &[ + &["archived", "true"], + &["scope", "subtree"], + &["expect", &hex], + &["name", "x"], + ], + &[ + &["lifecycle", "fence"], + &["expect-target", "-"], + &["ttl", "60"], + ], + // Fence shape. + &[&["lifecycle", "fence"]], + &[&["lifecycle", "settle"], &["expect-target", "-"]], + &[ + &["lifecycle", "fence"], + &["expect-target", "-"], + &["archived", "true"], + ], + &[ + &["lifecycle", "fence"], + &["expect-target", "-"], + &["expect", &hex], + ], + &[ + &["lifecycle", "fence"], + &["expect-target", &token.to_uppercase()], + ], + &[ + &["archived", "true"], + &["scope", "subtree"], + &["expect", &hex], + &["expect-target", "-"], + ], + &[&["expect-target", "-"]], + ]; + let target = ["h", h.as_str()]; + for tags in refused { + let with_h: Vec<&[&str]> = std::iter::once(&target[..]) + .chain(tags.iter().copied()) + .collect(); + assert!(ok(&with_h).is_err(), "{tags:?}"); + } + } + + fn revision( + op: &str, + channel: Uuid, + parent_tag: Option<&str>, + content: &str, + ) -> (Event, ArtifactEnvelope) { + let mut tags = vec![ + vec!["ar".to_string(), "1".into()], + vec!["d".into(), task_d(channel).to_string()], + vec!["h".into(), channel.to_string()], + vec!["type".into(), "nexus.task".into()], + vec!["op".into(), op.into()], + ]; + if let Some(value) = parent_tag { + tags.push(vec!["parent".into(), value.into()]); + } + if op != "delete" { + tags.push(vec!["title".into(), "Task".into()]); + } + if op != "create" { + tags.push(vec!["prev".into(), "00".repeat(32)]); + } + let tags: Vec> = tags + .iter() + .map(|t| t.iter().map(String::as_str).collect()) + .collect(); + let tags: Vec<&[&str]> = tags.iter().map(Vec::as_slice).collect(); + let event = event(45010, &tags, content); + let env = crate::artifact::validate(&event).unwrap(); + (event, env) + } + + #[test] + fn task_profile_binds_d_content_and_parent() { + let channel = Uuid::new_v4(); + let parent = Uuid::new_v4(); + let root = format!(r#"{{"v":0,"channelId":"{channel}","title":"Task"}}"#); + let child = format!(r#"{{"v":0,"channelId":"{channel}","parentChannelId":"{parent}"}}"#); + let (e, env) = revision("create", channel, None, &root); + assert_eq!( + task_edge(&e, &env), + Ok(Some(TaskEdge { + channel, + parent: None + })) + ); + let (e, env) = revision("update", channel, Some(&parent.to_string()), &child); + assert_eq!( + task_edge(&e, &env), + Ok(Some(TaskEdge { + channel, + parent: Some(parent) + })) + ); + let (e, env) = revision("delete", channel, None, ""); + assert_eq!( + task_edge(&e, &env), + Ok(Some(TaskEdge { + channel, + parent: None + })) + ); + + let other = Uuid::new_v4(); + let refused = [ + // Tag without content parent, and the reverse. + revision("update", channel, Some(&parent.to_string()), &root), + revision("update", channel, None, &child), + // Disagreeing parents. + revision("update", channel, Some(&other.to_string()), &child), + // Content for another channel, wrong version, not JSON. + revision( + "update", + channel, + None, + &format!(r#"{{"v":0,"channelId":"{other}"}}"#), + ), + revision( + "update", + channel, + None, + &format!(r#"{{"v":1,"channelId":"{channel}"}}"#), + ), + revision( + "update", + channel, + None, + &format!(r#"{{"v":0.0,"channelId":"{channel}"}}"#), + ), + revision("update", channel, None, "not json"), + // Non-canonical parent. + revision( + "update", + channel, + Some(&parent.to_string().to_uppercase()), + &child, + ), + // An explicit null is not a root, with or without a parent tag. + revision( + "update", + channel, + None, + &format!(r#"{{"v":0,"channelId":"{channel}","parentChannelId":null}}"#), + ), + revision( + "update", + channel, + Some(&parent.to_string()), + &format!(r#"{{"v":0,"channelId":"{channel}","parentChannelId":null}}"#), + ), + // A repeated hierarchy key is refused, whichever value is last. + revision( + "update", + channel, + Some(&parent.to_string()), + &format!( + r#"{{"v":0,"channelId":"{channel}","parentChannelId":"{other}","parentChannelId":"{parent}"}}"# + ), + ), + revision( + "update", + channel, + None, + &format!(r#"{{"v":0,"channelId":"{other}","channelId":"{channel}"}}"#), + ), + revision( + "update", + channel, + None, + &format!(r#"{{"v":1,"v":0,"channelId":"{channel}"}}"#), + ), + // Self-parent. + revision( + "update", + channel, + Some(&channel.to_string()), + &format!(r#"{{"v":0,"channelId":"{channel}","parentChannelId":"{channel}"}}"#), + ), + ]; + for (e, env) in refused { + assert!(task_edge(&e, &env).is_err(), "{}", e.content); + } + + // A d that is not derived from h is refused; other types are ignored. + let wrong_d = event( + 45010, + &[ + &["ar", "1"], + &["d", &Uuid::new_v4().to_string()], + &["h", &channel.to_string()], + &["type", "nexus.task"], + &["op", "create"], + &["title", "Task"], + ], + &root, + ); + let env = crate::artifact::validate(&wrong_d).unwrap(); + assert!(task_edge(&wrong_d, &env).is_err()); + let other_type = event( + 45010, + &[ + &["ar", "1"], + &["d", &Uuid::new_v4().to_string()], + &["h", &channel.to_string()], + &["type", "nexus.note"], + &["op", "create"], + &["title", "Note"], + &["parent", "anything"], + ], + "opaque", + ); + let env = crate::artifact::validate(&other_type).unwrap(); + assert_eq!(task_edge(&other_type, &env), Ok(None)); + } +} diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index 39e03f5392e..d58c1976ab4 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -42,6 +42,11 @@ pub enum DbError { #[error("access denied: {0}")] AccessDenied(String), + /// A guarded lifecycle request or fence no longer matches the current + /// lifecycle state. Nothing was written. + #[error("lifecycle changed")] + LifecycleConflict, + /// Invitation admission or minting was denied by authoritative restrictions. #[error("invitation principal is restricted")] InviteRestricted, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index eb3f9ed7783..62cbf12d430 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -66,10 +66,10 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, artifact, artifact_sync, channel, - channel_members, community, deletion, dm, entity_deletion, event, feed, git_repo, moderation, - nip_fi_binding, operator_listener, partition, personal_read, product_feedback, push, reaction, - read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, - replaceable, storage_accounting, thread, thread_window, usage, user, user_deletion, + channel_members, community, deletion, dm, entity_deletion, event, feed, git_repo, lifecycle, + moderation, nip_fi_binding, operator_listener, partition, personal_read, product_feedback, + push, reaction, read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, + reminder, replaceable, storage_accounting, thread, thread_window, usage, user, user_deletion, user_deletion_fences, workflow, }; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index c78a99eab5b..8d37adc947f 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -777,7 +777,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 80); + assert_eq!(migrations.len(), 81); for (index, migration) in migrations.iter().enumerate() { assert_eq!(migration.version, (index + 1) as i64); } @@ -2892,6 +2892,7 @@ mod postgres_tests { "entity_deletion_approvals", "entity_deletion_requests", "artifact_heads", + "artifact_parents", "artifact_revisions", "channel_members", "communities", diff --git a/crates/buzz-db/src/store/artifact.rs b/crates/buzz-db/src/store/artifact.rs index 6f9f46eb174..c72383f67b7 100644 --- a/crates/buzz-db/src/store/artifact.rs +++ b/crates/buzz-db/src/store/artifact.rs @@ -3,8 +3,10 @@ //! Channel write permission is checked by the relay's ordinary ingest gates //! before this transaction; here the relay only serializes each identity and //! compares the expected head. +use crate::lifecycle::EdgeChange; use crate::{Db, DbError, Result}; use buzz_core::artifact::{ArtifactEnvelope, ArtifactOp}; +use buzz_core::lifecycle::{task_edge, TaskEdge, TASK_ARTIFACT_TYPE}; use buzz_core::{CommunityId, StoredEvent}; use nostr::{Event, EventBuilder, Keys, Kind, Tag}; use sqlx::Row; @@ -94,6 +96,11 @@ impl Db { /// the head, and on a move store the source removal. `authorized_source` is /// the move source the caller authorized; a head that has since moved elsewhere /// conflicts. Relay keys only sign removals. + /// + /// A `nexus.task` revision also maintains the task projection in the same + /// transaction, under its family locks. With `lifecycle_guard` off, a + /// `parent` tag is refused unless the channel already has that projected + /// parent, and a channel without a projection row keeps today's behavior. pub async fn accept_artifact( &self, community: CommunityId, @@ -101,11 +108,61 @@ impl Db { env: &ArtifactEnvelope, authorized_source: Option, relay_keys: &Keys, + lifecycle_guard: bool, ) -> Result { + // A family root that moves before its lock is taken is retried once. + for _ in 0..2 { + if let Some(outcome) = self + .accept_artifact_once( + community, + event, + env, + authorized_source, + relay_keys, + lifecycle_guard, + ) + .await? + { + return Ok(outcome); + } + } + Ok(ArtifactOutcome::Conflict("task family changed")) + } + + async fn accept_artifact_once( + &self, + community: CommunityId, + event: &Event, + env: &ArtifactEnvelope, + authorized_source: Option, + relay_keys: &Keys, + lifecycle_guard: bool, + ) -> Result> { let mut tx = self.begin_event_write_transaction(community).await?; sqlx::query("SET LOCAL statement_timeout='5s'") .execute(tx.conn()) .await?; + let is_task = env.artifact_type == TASK_ARTIFACT_TYPE; + // A task channel, or any task revision once the guard is on, takes + // the hierarchy path; the read is re-checked under the family lock. + let task = if is_task + && (lifecycle_guard || crate::lifecycle::is_task_channel(&mut tx, env.home).await?) + { + let edge = match task_edge(event, env) { + Ok(Some(edge)) => edge, + Ok(None) => return Ok(Some(ArtifactOutcome::Rejected("not a nexus.task"))), + Err(message) => return Ok(Some(ArtifactOutcome::Rejected(message))), + }; + let author = event.pubkey.to_bytes(); + match crate::lifecycle::lock_task_edge(&mut tx, edge.channel, edge.parent, &author) + .await? + { + Some(locks) => Some((edge, locks)), + None => return Ok(None), + } + } else { + None + }; // A coordinate lock handles the missing-row create race as well as edits. sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1,0))") .bind(format!("artifact:{}:{}", community.as_uuid(), env.id)) @@ -119,7 +176,7 @@ impl Db { .fetch_one(tx.conn()) .await?; if duplicate { - return Ok(ArtifactOutcome::Duplicate); + return Ok(Some(ArtifactOutcome::Duplicate)); } let head = sqlx::query( "SELECT event_id,channel_id,artifact_type,root,deleted FROM artifact_heads WHERE community_id=$1 AND artifact_id=$2 FOR UPDATE", @@ -134,10 +191,12 @@ impl Db { .and_then(|h| h.get::>, _>("root")); match (&head, env.op) { (Some(_), ArtifactOp::Create) => { - return Ok(ArtifactOutcome::Conflict("artifact identity is taken")) + return Ok(Some(ArtifactOutcome::Conflict( + "artifact identity is taken", + ))) } (None, ArtifactOp::Create) => {} - (None, _) => return Ok(ArtifactOutcome::Conflict(HEAD_UNAVAILABLE)), + (None, _) => return Ok(Some(ArtifactOutcome::Conflict(HEAD_UNAVAILABLE))), // The caller authorized writes only in `h`, or for a move in the // source it read before this transaction. A head anywhere else // answers like a missing one, before any check that describes it. @@ -148,28 +207,30 @@ impl Db { _ => Some(env.home), } => { - return Ok(ArtifactOutcome::Conflict(HEAD_UNAVAILABLE)) + return Ok(Some(ArtifactOutcome::Conflict(HEAD_UNAVAILABLE))) } (Some(head), op) => { let current: Vec = head.get("event_id"); if env.prev.as_deref() != Some(current.as_slice()) { - return Ok(ArtifactOutcome::Conflict("artifact head changed")); + return Ok(Some(ArtifactOutcome::Conflict("artifact head changed"))); } if env.artifact_type != head.get::("artifact_type") { - return Ok(ArtifactOutcome::Rejected("artifact type is immutable")); + return Ok(Some(ArtifactOutcome::Rejected( + "artifact type is immutable", + ))); } if head.get::("deleted") != (op == ArtifactOp::Restore) { - return Ok(ArtifactOutcome::Rejected( + return Ok(Some(ArtifactOutcome::Rejected( "deleted artifacts require restore; live artifacts cannot restore", - )); + ))); } if (op == ArtifactOp::Move) != (source != Some(env.home)) { - return Ok(ArtifactOutcome::Rejected( + return Ok(Some(ArtifactOutcome::Rejected( "only move changes home and move must change home", - )); + ))); } if op == ArtifactOp::Delete && env.root != old_root { - return Ok(ArtifactOutcome::Rejected("delete preserves root")); + return Ok(Some(ArtifactOutcome::Rejected("delete preserves root"))); } } } @@ -178,12 +239,19 @@ impl Db { let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM events WHERE community_id=$1 AND id=$2 AND channel_id=$3 AND deleted_at IS NULL AND kind IN (9,40002,45001,45003))") .bind(community.as_uuid()).bind(root).bind(env.home).fetch_one(tx.conn()).await?; if !exists { - return Ok(ArtifactOutcome::Rejected( + return Ok(Some(ArtifactOutcome::Rejected( "root must be an existing conversation anchor in home", - )); + ))); } } } + if is_task { + if let Some(refusal) = + check_task_revision(&mut tx, event, env, task.as_ref(), lifecycle_guard).await? + { + return Ok(Some(ArtifactOutcome::Rejected(refusal))); + } + } sqlx::query( "INSERT INTO artifact_revisions (community_id,event_id,artifact_id,source_channel_id,modified_at) VALUES ($1,$2,$3,$4,clock_timestamp())", ) @@ -195,6 +263,20 @@ impl Db { .await?; sqlx::query("INSERT INTO artifact_heads (community_id,artifact_id,event_id,channel_id,artifact_type,root,deleted,modified_at) VALUES ($1,$2,$3,$4,$5,$6,$7,clock_timestamp()) ON CONFLICT (community_id,artifact_id) DO UPDATE SET event_id=EXCLUDED.event_id,channel_id=EXCLUDED.channel_id,root=EXCLUDED.root,deleted=EXCLUDED.deleted,modified_at=clock_timestamp()") .bind(community.as_uuid()).bind(env.id).bind(event.id.as_bytes().as_slice()).bind(env.home).bind(&env.artifact_type).bind(&env.root).bind(env.op == ArtifactOp::Delete).execute(tx.conn()).await?; + if let Some((edge, _)) = &task { + let change = if env.op == ArtifactOp::Delete { + EdgeChange::Remove { + child: edge.channel, + } + } else { + EdgeChange::Upsert { + child: edge.channel, + parent: edge.parent, + artifact: env.id, + } + }; + crate::lifecycle::write_task_edge(&mut tx, change).await?; + } let (stored, _) = crate::event::insert_event_in_transaction(&mut tx, event, Some(env.home)).await?; crate::insert_mentions_in_transaction(&mut tx, event, Some(env.home)).await?; @@ -206,6 +288,42 @@ impl Db { accepted.push(stored); } tx.commit().await?; - Ok(ArtifactOutcome::Accepted(accepted)) + Ok(Some(ArtifactOutcome::Accepted(accepted))) + } +} + +/// The public refusal of a `nexus.task` revision, checked after the +/// duplicate check. `task` is the locked hierarchy path; `None` means the +/// guard is off and the channel has no projection row. +async fn check_task_revision( + tx: &mut crate::AdmittedTx, + event: &Event, + env: &ArtifactEnvelope, + task: Option<&(TaskEdge, crate::lifecycle::EdgeLocks)>, + lifecycle_guard: bool, +) -> Result> { + const GUARD_OFF: &str = "nexus.task parent requires the lifecycle guard"; + let Some((edge, locks)) = task else { + let has_parent = event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("parent")); + return Ok(has_parent.then_some(GUARD_OFF)); + }; + if env.op == ArtifactOp::Move { + return Ok(Some("nexus.task cannot move")); + } + // Rollback floor: with the guard off, only an existing edge survives. + if !lifecycle_guard && edge.parent.is_some() && locks.current_parent() != Some(edge.parent) { + return Ok(Some(GUARD_OFF)); } + Ok(crate::lifecycle::check_task_edge( + tx, + locks, + edge.channel, + edge.parent, + env.op == ArtifactOp::Delete, + ) + .await? + .err()) } diff --git a/crates/buzz-db/src/store/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index f858deeb182..73fae7f7e9d 100644 --- a/crates/buzz-db/src/store/artifact_postgres_tests.rs +++ b/crates/buzz-db/src/store/artifact_postgres_tests.rs @@ -97,7 +97,7 @@ impl Fixture { async fn accept(&self, e: &Event, source: Option) -> ArtifactOutcome { let env = artifact::validate(e).unwrap(); self.db - .accept_artifact(self.community, e, &env, source, &self.relay) + .accept_artifact(self.community, e, &env, source, &self.relay, false) .await .unwrap() } @@ -563,7 +563,7 @@ async fn quiescing_community_rejects_artifact_at_admission_before_coordinate_loc .unwrap(); let error = tokio::time::timeout( std::time::Duration::from_secs(5), - f.db.accept_artifact(f.community, &create, &env, None, &f.relay), + f.db.accept_artifact(f.community, &create, &env, None, &f.relay, false), ) .await .expect("admission must reject before waiting on the coordinate lock") diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 97f82d40c53..724b542e595 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -143,9 +143,9 @@ pub async fn create_channel( sqlx::query( r#" - INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels, unlisted) + INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels, unlisted, lifecycle_token) VALUES ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8, - CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9, $10) + CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9, $10, $11) "#, ) .bind(id) @@ -158,6 +158,7 @@ pub async fn create_channel( .bind(ttl_seconds) .bind(labels) .bind(unlisted) + .bind(crate::lifecycle::new_token()) .execute(tx.conn()) .await .map_err(|e| listed_name_taken(e, Some(name)))?; @@ -381,9 +382,9 @@ async fn insert_channel_rows( let rows_affected = sqlx::query( r#" - INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels, unlisted) + INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels, unlisted, lifecycle_token) VALUES ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8, - CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9, $10) + CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9, $10, $11) ON CONFLICT (community_id, id) DO NOTHING "#, ) @@ -397,6 +398,7 @@ async fn insert_channel_rows( .bind(ttl_seconds) .bind(labels) .bind(unlisted) + .bind(crate::lifecycle::new_token()) .execute(tx.conn()) .await .map_err(|e| listed_name_taken(e, Some(name)))? @@ -1400,7 +1402,9 @@ async fn set_purpose_on( Ok(()) } -/// Archives a channel. +/// Archives a channel, unless it is a task channel: a task channel changes +/// archive state only through a guarded request, so it is kept active. +/// Returns whether the channel was archived. Decided under the row lock. /// /// Returns `AccessDenied` if the channel is already archived. /// Returns `ChannelNotFound` if the channel does not exist or is deleted. @@ -1408,7 +1412,7 @@ pub async fn archive_channel( pool: &PgPool, community_id: CommunityId, channel_id: Uuid, -) -> Result<()> { +) -> Result { let mut tx = crate::begin_community_write_transaction( pool, community_id, @@ -1417,9 +1421,13 @@ pub async fn archive_channel( .await?; let mut locked = lock_channel_for_settings_write(&mut tx, channel_id, &ChannelUpdate::default()).await?; + if crate::lifecycle::is_task_channel(locked.tx(), channel_id).await? { + tx.rollback().await?; + return Ok(false); + } set_archived_on(&mut locked, true).await?; tx.commit().await?; - Ok(()) + Ok(true) } /// Unarchives a channel. @@ -1439,11 +1447,22 @@ pub async fn unarchive_channel( .await?; let mut locked = lock_channel_for_settings_write(&mut tx, channel_id, &ChannelUpdate::default()).await?; + refuse_task_channel(&mut locked).await?; set_archived_on(&mut locked, false).await?; tx.commit().await?; Ok(()) } +/// Refuse an unguarded archive or restore of a task channel (VD2-b). Its +/// archive state changes only through a guarded request, which keeps the +/// task family consistent. +async fn refuse_task_channel(w: &mut ChannelSettingsWriter<'_>) -> Result<()> { + if crate::lifecycle::is_task_channel(w.tx, w.channel_id).await? { + return Err(DbError::AccessDenied("lifecycle guard required".into())); + } + Ok(()) +} + /// Archives (`archive = true`) or restores the channel on `tx`. Refuses with /// `AccessDenied` when the channel is already in that state. Restoring an /// ephemeral channel restarts its TTL deadline. @@ -1475,10 +1494,10 @@ async fn set_archived_on(w: &mut ChannelSettingsWriter<'_>, archive: bool) -> Re } let sql = if archive { - "UPDATE channels SET archived_at = NOW() \ + "UPDATE channels SET archived_at = NOW(), lifecycle_token = $3 \ WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL AND archived_at IS NULL" } else { - "UPDATE channels SET archived_at = NULL, \ + "UPDATE channels SET archived_at = NULL, lifecycle_token = $3, \ ttl_deadline = CASE \ WHEN ttl_seconds IS NOT NULL THEN NOW() + (ttl_seconds || ' seconds')::interval \ ELSE ttl_deadline \ @@ -1488,6 +1507,7 @@ async fn set_archived_on(w: &mut ChannelSettingsWriter<'_>, archive: bool) -> Re sqlx::query(sql) .bind(community_id.as_uuid()) .bind(channel_id) + .bind(crate::lifecycle::new_token()) .execute(tx.conn()) .await?; Ok(()) @@ -1564,6 +1584,19 @@ pub(crate) async fn apply_channel_metadata_event( previous_visibility, }); } + // A task channel changes archive state only through a guarded request + // and never gets a TTL. Both are decided under the row lock, so a + // concurrent task record either committed first or sees this change. + if edit.archived.is_some() { + refuse_task_channel(&mut locked).await?; + } + if matches!(edit.columns.ttl_seconds, Some(Some(_))) + && crate::lifecycle::is_task_channel(locked.tx(), channel_id).await? + { + return Err(DbError::AccessDenied( + "task channels cannot have a ttl".to_string(), + )); + } // Decide the archive state and an operator's right to rename here, // after the duplicate check and under the row lock, not during // validation. A resend that races the first commit then sees that commit @@ -1671,6 +1704,84 @@ pub async fn soft_delete_channel( Ok(true) } +/// The result of [`delete_channel_with_event`]. +#[derive(Debug)] +pub struct ChannelDeleteWrite { + /// The stored kind:9008 event. + pub stored: StoredEvent, + /// `false` when the event was already stored. Nothing changed then: the + /// delete committed with it the first time. + pub inserted: bool, + /// `false` when the channel was already deleted or never existed. + pub deleted: bool, +} + +/// Store a kind:9008 and soft-delete its channel in one commit. A refusal +/// decided under the channel's locks (a task that has child tasks) stores +/// nothing. An event for a channel that is already gone is still stored, so +/// a resend of it is a duplicate. +pub(crate) async fn delete_channel_with_event( + pool: &PgPool, + push_enqueue: Option, + community_id: CommunityId, + channel_id: Uuid, + event: &nostr::Event, +) -> Result { + let mut tx = crate::begin_community_event_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + tx.set_push_enqueue(push_enqueue); + // The locks also order two copies of the same event: the second waits, + // then sees the first stored. + let write = match lock_channel_for_delete(&mut tx, channel_id).await { + Ok(mut writer) => { + let (stored, inserted) = crate::event::insert_event_with_thread_metadata_tx( + writer.tx(), + event, + Some(channel_id), + None, + ) + .await?; + if inserted { + crate::insert_mentions_in_transaction(writer.tx(), event, Some(channel_id)).await?; + soft_delete_on(&mut writer).await?; + } + ChannelDeleteWrite { + stored, + inserted, + deleted: inserted, + } + } + Err(DbError::ChannelNotFound(_)) => { + let (stored, inserted) = crate::event::insert_event_with_thread_metadata_tx( + &mut tx, + event, + Some(channel_id), + None, + ) + .await?; + if inserted { + crate::insert_mentions_in_transaction(&mut tx, event, Some(channel_id)).await?; + } + ChannelDeleteWrite { + stored, + inserted, + deleted: false, + } + } + Err(error) => return Err(error), + }; + if write.inserted { + tx.commit().await?; + } else { + tx.rollback().await?; + } + Ok(write) +} + async fn soft_delete_on(w: &mut ChannelSettingsWriter<'_>) -> Result<()> { if !w.admission { return Err(DbError::InvalidData( @@ -1679,11 +1790,23 @@ async fn soft_delete_on(w: &mut ChannelSettingsWriter<'_>) -> Result<()> { } let channel_id = w.channel_id; let community_id = w.tx.community(); - sqlx::query("UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 AND id = $2") - .bind(community_id.as_uuid()) - .bind(channel_id) - .execute(w.tx.conn()) - .await?; + // Under the row lock: a child attach holds this row `FOR SHARE`, so it + // either committed and is seen here, or finds the channel deleted. + if crate::lifecycle::has_child_tasks(w.tx, channel_id).await? { + return Err(DbError::AccessDenied( + crate::lifecycle::CHILD_TASKS_DELETE.into(), + )); + } + crate::lifecycle::remove_task_edge(w.tx, channel_id).await?; + sqlx::query( + "UPDATE channels SET deleted_at = NOW(), lifecycle_token = $3 \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(crate::lifecycle::new_token()) + .execute(w.tx.conn()) + .await?; sqlx::query("UPDATE communities SET artifact_policy_modified_at=clock_timestamp() WHERE id=$1") .bind(community_id.as_uuid()) .execute(w.tx.conn()) @@ -1731,16 +1854,28 @@ pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result Result<()> { + pub async fn archive_channel( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result { archive_channel(&self.pool, community_id, channel_id).await } @@ -2061,6 +2201,25 @@ impl Db { soft_delete_channel(&self.pool, community_id, channel_id).await } + /// Store a kind:9008 and soft-delete its channel in one commit. See + /// [`delete_channel_with_event`]. + #[datastore_span(name = "delete_channel_with_event", system = "postgresql")] + pub async fn delete_channel_with_event( + &self, + community_id: CommunityId, + channel_id: Uuid, + event: &nostr::Event, + ) -> Result { + delete_channel_with_event( + &self.pool, + self.push_enqueue.clone(), + community_id, + channel_id, + event, + ) + .await + } + /// Archive ephemeral channels whose TTL deadline has passed. #[datastore_span(name = "reap_expired_ephemeral_channels", system = "postgresql")] pub async fn reap_expired_ephemeral_channels(&self) -> Result> { diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 9c1eb2f433b..37d87ae9b3c 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -78,6 +78,7 @@ pub const RELAY_ADMIN_TABLES: &[(&str, &str)] = &[ /// approval and again before PostgreSQL purge. A new tenant table therefore /// blocks deletion until this manifest is intentionally updated. pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ + "artifact_parents", "nip_fi_profile_heads", "api_tokens", "archived_identities", @@ -130,6 +131,7 @@ const EXCLUDED_SCOPED_TABLES: &[&str] = &[ /// Foreign-key-safe child-before-parent order for the PostgreSQL purge. pub const PURGE_SCOPED_TABLES: &[&str] = &[ + "artifact_parents", "nip_fi_profile_heads", "personal_read_frontiers", "personal_read_accounts", @@ -8762,6 +8764,132 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); .expect("verify after straggler removed"); } + /// `artifact_parents` is tenant-owned: once deletion of its community + /// starts, a task edge write is refused at admission, and the purge + /// removes the community's edges. Run on both schema paths. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn artifact_parents_are_fenced_and_purged() { + assert_artifact_parents_fenced_and_purged().await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migration_schema_artifact_parents_are_fenced_and_purged() { + assert_artifact_parents_fenced_and_purged().await; + } + + async fn assert_artifact_parents_fenced_and_purged() { + use crate::store::artifact::ArtifactOutcome; + use buzz_core::channel::{ChannelType, ChannelVisibility}; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + /// A `nexus.task` create for `channel` under `parent`. + fn task(owner: &Keys, channel: Uuid, parent: Option) -> nostr::Event { + let mut tags = vec![ + vec!["ar".to_string(), "1".into()], + vec![ + "d".into(), + buzz_core::lifecycle::task_d(channel).to_string(), + ], + vec!["h".into(), channel.to_string()], + vec![ + "type".into(), + buzz_core::lifecycle::TASK_ARTIFACT_TYPE.into(), + ], + vec!["op".into(), "create".into()], + vec!["title".into(), "Task".into()], + ]; + let mut content = serde_json::json!({"v": 0, "channelId": channel.to_string()}); + if let Some(parent) = parent { + tags.push(vec!["parent".into(), parent.to_string()]); + content["parentChannelId"] = serde_json::json!(parent.to_string()); + } + EventBuilder::new(Kind::Custom(45010), content.to_string()) + .tags(tags.into_iter().map(|t| Tag::parse(t).expect("tag"))) + .sign_with_keys(owner) + .expect("sign task record") + } + async fn edges(db: &Db, community: CommunityId) -> i64 { + sqlx::query_scalar("SELECT count(*) FROM artifact_parents WHERE community_id = $1") + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("count edges") + } + + let (db, store) = store().await; + let (request, inventory) = inventoried_request(&db, &store).await; + let community = request.community_id; + let (owner, relay) = (Keys::generate(), Keys::generate()); + let mut channels = Vec::new(); + for name in ["root", "child", "late"] { + let channel = db + .create_channel( + community, + name, + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner.public_key().to_bytes(), + None, + ) + .await + .expect("create channel"); + channels.push(channel.id); + } + for (channel, parent) in [(channels[0], None), (channels[1], Some(channels[0]))] { + let event = task(&owner, channel, parent); + let env = buzz_core::artifact::validate(&event).expect("valid task record"); + let outcome = db + .accept_artifact(community, &event, &env, None, &relay, true) + .await + .expect("accept task record"); + assert!(matches!(outcome, ArtifactOutcome::Accepted(_))); + } + assert_eq!(edges(&db, community).await, 2); + + store + .approve(request.id, "approver", None) + .await + .expect("approve"); + let claim = store + .claim_specific(request.id, "executor", DEFAULT_LEASE_DURATION) + .await + .expect("claim") + .expect("won claim"); + store.begin_quiescing(&claim.lease).await.expect("quiesce"); + let late = task(&owner, channels[2], Some(channels[0])); + let env = buzz_core::artifact::validate(&late).expect("valid task record"); + let error = db + .accept_artifact(community, &late, &env, None, &relay, true) + .await + .expect_err("a quiescing community must refuse task edges"); + assert!( + crate::test_support::is_admission_rejection(&error), + "expected entry admission rejection, got: {error:#}" + ); + assert_eq!(edges(&db, community).await, 2); + + let generation = store.fence(&claim.lease).await.expect("fence"); + let token = LeaseToken { + fence_generation: Some(generation), + ..claim.lease + }; + store + .freeze_destructive_storage_manifest(&token, &inventory.storage) + .await + .expect("freeze destructive storage"); + store.mark_drained(&token).await.expect("drain"); + store + .mark_bindings_removed(&token, serde_json::json!({"keys": 0})) + .await + .expect("bindings"); + let deleted = store.purge_postgres(&token).await.expect("purge postgres"); + assert_eq!(deleted.get("artifact_parents"), Some(&2)); + assert_eq!(edges(&db, community).await, 0); + } + /// A database bootstrapped from `schema/schema.sql` (the pgschema /// desired-state path — no migrations) must carry the complete 0028 /// deletion surface and run a deletion through every stage. diff --git a/crates/buzz-db/src/store/dm.rs b/crates/buzz-db/src/store/dm.rs index ebdbc45d271..fa2dbf97b06 100644 --- a/crates/buzz-db/src/store/dm.rs +++ b/crates/buzz-db/src/store/dm.rs @@ -173,8 +173,8 @@ pub async fn create_dm( sqlx::query( r#" INSERT INTO channels - (id, community_id, name, channel_type, visibility, created_by, participant_hash) - VALUES ($1, $2, $3, 'dm', 'private', $4, $5) + (id, community_id, name, channel_type, visibility, created_by, participant_hash, lifecycle_token) + VALUES ($1, $2, $3, 'dm', 'private', $4, $5, $6) "#, ) .bind(id) @@ -182,6 +182,7 @@ pub async fn create_dm( .bind(&name) .bind(created_by) .bind(hash.as_slice()) + .bind(crate::lifecycle::new_token()) .execute(tx.conn()) .await?; diff --git a/crates/buzz-db/src/store/lifecycle.rs b/crates/buzz-db/src/store/lifecycle.rs new file mode 100644 index 00000000000..c7a089524ae --- /dev/null +++ b/crates/buzz-db/src/store/lifecycle.rs @@ -0,0 +1,930 @@ +//! Guarded task archive and restore, the `nexus.task` hierarchy projection, +//! and the authoritative lifecycle state read. +//! +//! Every writer here takes its locks in this order and never takes an +//! earlier lock after a later one: +//! +//! 1. community admission (the transaction begin); +//! 2. task family locks, sorted by family root; +//! 3. channel gates, exclusive, sorted by channel; +//! 4. channel TTL locks, shared, sorted by channel; +//! 5. channel rows, sorted by channel; +//! 6. the artifact coordinate lock, then the artifact head. +//! +//! A family is keyed by its root. Every edge write takes the family locks of +//! the child's and the new parent's roots, so a holder of a root's lock sees +//! a stable topology for that family. +use crate::{AdmittedTx, DbError, Result}; +use buzz_core::lifecycle::{lifecycle_digest, LifecycleScope, TokenEntry}; +use buzz_core::{CommunityId, StoredEvent}; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use sqlx::{PgPool, Row}; +use std::collections::{BTreeMap, BTreeSet}; +use uuid::Uuid; + +/// Longest parent chain a task family may have. An edge write that would +/// make a deeper chain is refused, so every walk is bounded. +pub const MAX_TASK_DEPTH: usize = 256; + +/// Most channels one state read returns. +pub const MAX_STATE_CHANNELS: usize = 1000; + +/// Refusal for deleting a task, as a channel or as a record, while it has +/// child tasks. +pub const CHILD_TASKS_DELETE: &str = "a task with child tasks cannot be deleted"; + +/// A fresh lifecycle token from the OS CSPRNG. Every lifecycle writer sets +/// one in the same transaction as its change. +pub(crate) fn new_token() -> Uuid { + Uuid::new_v4() +} + +fn family_lock_key(community: CommunityId, root: Uuid) -> String { + format!("buzz_task_family:{}:{root}", community.as_uuid()) +} + +/// The per-channel archive gate. Archive and restore take it exclusive. +pub(crate) fn gate_lock_key(community: CommunityId, channel: Uuid) -> String { + format!("buzz_channel_gate:{}:{channel}", community.as_uuid()) +} + +/// Take transaction-scoped advisory locks on `keys`, in order, in one +/// statement. +async fn advisory_locks(tx: &mut AdmittedTx, keys: &[String], shared: bool) -> Result<()> { + if keys.is_empty() { + return Ok(()); + } + let sql = if shared { + "SELECT pg_advisory_xact_lock_shared(hashtextextended(k, 0)) \ + FROM unnest($1::text[]) WITH ORDINALITY AS t(k, n) ORDER BY n" + } else { + "SELECT pg_advisory_xact_lock(hashtextextended(k, 0)) \ + FROM unnest($1::text[]) WITH ORDINALITY AS t(k, n) ORDER BY n" + }; + sqlx::query(sql).bind(keys).execute(tx.conn()).await?; + Ok(()) +} + +async fn lock_families(tx: &mut AdmittedTx, roots: &BTreeSet) -> Result<()> { + let community = tx.community(); + let keys: Vec<_> = roots + .iter() + .map(|root| family_lock_key(community, *root)) + .collect(); + advisory_locks(tx, &keys, false).await +} + +async fn lock_gates(tx: &mut AdmittedTx, channels: &BTreeSet) -> Result<()> { + let community = tx.community(); + let keys: Vec<_> = channels + .iter() + .map(|channel| gate_lock_key(community, *channel)) + .collect(); + advisory_locks(tx, &keys, false).await +} + +async fn lock_ttls_shared(tx: &mut AdmittedTx, channels: &BTreeSet) -> Result<()> { + let community = tx.community(); + let keys: Vec<_> = channels + .iter() + .map(|channel| crate::channel::channel_ttl_lock_key(community, *channel)) + .collect(); + advisory_locks(tx, &keys, true).await +} + +/// Whether `channel` is a task channel: it has a projection row. +pub(crate) async fn is_task_channel(tx: &mut AdmittedTx, channel: Uuid) -> Result { + Ok(projected_parent(tx, channel).await?.is_some()) +} + +/// The projected parent of `channel`: `None` when the channel has no +/// projection row, `Some(None)` for a root. +async fn projected_parent(tx: &mut AdmittedTx, channel: Uuid) -> Result>> { + let community = tx.community(); + Ok(sqlx::query_scalar( + "SELECT parent_channel_id FROM artifact_parents \ + WHERE community_id = $1 AND child_channel_id = $2", + ) + .bind(community.as_uuid()) + .bind(channel) + .fetch_optional(tx.conn()) + .await?) +} + +/// Whether any projection row names `channel` as its parent. +pub(crate) async fn has_child_tasks(tx: &mut AdmittedTx, channel: Uuid) -> Result { + let community = tx.community(); + Ok(sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM artifact_parents \ + WHERE community_id = $1 AND parent_channel_id = $2)", + ) + .bind(community.as_uuid()) + .bind(channel) + .fetch_one(tx.conn()) + .await?) +} + +/// The ancestors of a channel, nearest first, from the projection. +struct Walk { + ancestors: Vec, + /// `false` when the chain is deeper than [`MAX_TASK_DEPTH`] or ends at a + /// channel without a projection row. Edge writes keep both from + /// happening; a request that meets one is refused, never guessed. + intact: bool, +} + +impl Walk { + fn root(&self, channel: Uuid) -> Uuid { + self.ancestors.last().copied().unwrap_or(channel) + } +} + +async fn walk_up(tx: &mut AdmittedTx, channel: Uuid) -> Result { + let community = tx.community(); + let rows = sqlx::query( + "WITH RECURSIVE up(id, depth) AS ( \ + SELECT parent_channel_id, 1 FROM artifact_parents \ + WHERE community_id = $1 AND child_channel_id = $2 \ + AND parent_channel_id IS NOT NULL \ + UNION ALL \ + SELECT ap.parent_channel_id, up.depth + 1 FROM up \ + JOIN artifact_parents AS ap \ + ON ap.community_id = $1 AND ap.child_channel_id = up.id \ + WHERE ap.parent_channel_id IS NOT NULL AND up.depth <= $3 \ + ) \ + SELECT up.id, EXISTS (SELECT 1 FROM artifact_parents AS r \ + WHERE r.community_id = $1 AND r.child_channel_id = up.id) AS projected \ + FROM up ORDER BY up.depth", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(MAX_TASK_DEPTH as i32) + .fetch_all(tx.conn()) + .await?; + let mut ancestors = Vec::with_capacity(rows.len()); + let mut intact = rows.len() <= MAX_TASK_DEPTH; + for row in rows { + intact &= row.try_get::("projected")?; + ancestors.push(row.try_get("id")?); + } + Ok(Walk { ancestors, intact }) +} + +/// The descendants of `channel` from the projection, or `None` when there +/// are more than `cap` or the subtree is deeper than [`MAX_TASK_DEPTH`]. +async fn walk_down(tx: &mut AdmittedTx, channel: Uuid, cap: usize) -> Result>> { + let community = tx.community(); + let rows = sqlx::query( + "WITH RECURSIVE down(id, depth) AS ( \ + SELECT child_channel_id, 1 FROM artifact_parents \ + WHERE community_id = $1 AND parent_channel_id = $2 \ + UNION ALL \ + SELECT ap.child_channel_id, down.depth + 1 FROM down \ + JOIN artifact_parents AS ap \ + ON ap.community_id = $1 AND ap.parent_channel_id = down.id \ + WHERE down.depth <= $3 \ + ) \ + SELECT id, depth FROM down LIMIT $4", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(MAX_TASK_DEPTH as i32) + .bind(i64::try_from(cap.saturating_add(1)).unwrap_or(i64::MAX)) + .fetch_all(tx.conn()) + .await?; + if rows.len() > cap { + return Ok(None); + } + let mut descendants = Vec::with_capacity(rows.len()); + for row in rows { + if row.try_get::("depth")? as usize > MAX_TASK_DEPTH { + return Ok(None); + } + descendants.push(row.try_get("id")?); + } + Ok(Some(descendants)) +} + +/// A locked channel row's lifecycle state. +#[derive(Clone, Copy)] +struct RowState { + archived: bool, + deleted: bool, + token: Option, + ttl: bool, + created_by_actor: bool, +} + +impl RowState { + fn active(&self) -> bool { + !self.archived && !self.deleted + } +} + +/// Lock `channels` in id order and read their state. A purged channel has +/// no entry. +async fn lock_rows( + tx: &mut AdmittedTx, + channels: &BTreeSet, + actor: &[u8], + share: bool, +) -> Result> { + let community = tx.community(); + let ids: Vec = channels.iter().copied().collect(); + let sql = if share { + "SELECT id, archived_at IS NOT NULL AS archived, deleted_at IS NOT NULL AS deleted, \ + lifecycle_token, ttl_seconds IS NOT NULL AS ttl, created_by = $3 AS mine \ + FROM channels WHERE community_id = $1 AND id = ANY($2) ORDER BY id FOR SHARE" + } else { + "SELECT id, archived_at IS NOT NULL AS archived, deleted_at IS NOT NULL AS deleted, \ + lifecycle_token, ttl_seconds IS NOT NULL AS ttl, created_by = $3 AS mine \ + FROM channels WHERE community_id = $1 AND id = ANY($2) ORDER BY id FOR NO KEY UPDATE" + }; + let rows = sqlx::query(sql) + .bind(community.as_uuid()) + .bind(&ids) + .bind(actor) + .fetch_all(tx.conn()) + .await?; + let mut states = BTreeMap::new(); + for row in rows { + states.insert( + row.try_get("id")?, + RowState { + archived: row.try_get("archived")?, + deleted: row.try_get("deleted")?, + token: row.try_get("lifecycle_token")?, + ttl: row.try_get("ttl")?, + created_by_actor: row.try_get("mine")?, + }, + ); + } + Ok(states) +} + +/// The channels in `channels` where `actor` lacks today's archive right: +/// an owner or admin membership, or ownership of an owner-role agent member. +async fn unauthorized(tx: &mut AdmittedTx, channels: &[Uuid], actor: &[u8]) -> Result { + let community = tx.community(); + Ok(sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM unnest($2::uuid[]) AS c(id) WHERE NOT ( \ + EXISTS (SELECT 1 FROM channel_members AS m \ + WHERE m.community_id = $1 AND m.channel_id = c.id AND m.pubkey = $3 \ + AND m.removed_at IS NULL AND m.role IN ('owner', 'admin')) \ + OR EXISTS (SELECT 1 FROM channel_members AS m \ + JOIN users AS u ON u.community_id = m.community_id AND u.pubkey = m.pubkey \ + WHERE m.community_id = $1 AND m.channel_id = c.id \ + AND m.removed_at IS NULL AND m.role = 'owner' \ + AND u.agent_owner_pubkey = $3)))", + ) + .bind(community.as_uuid()) + .bind(channels) + .bind(actor) + .fetch_one(tx.conn()) + .await?) +} + +/// The result of a guarded lifecycle request. +#[derive(Debug)] +pub struct LifecycleWrite { + /// The stored kind:9002 event. + pub stored: StoredEvent, + /// `false` when the event was already stored: it applied the first time. + pub inserted: bool, + /// Channels whose archive state changed, the target first, each with + /// the kind:40099 notice stored for it. Empty for a fence and for a + /// duplicate. + pub changed: Vec<(Uuid, Event)>, +} + +/// A guarded archive or restore request. +pub struct StateRequest<'a> { + /// The target channel S. + pub target: Uuid, + /// The signed kind:9002. + pub event: &'a Event, + /// `true` archives, `false` restores. + pub archive: bool, + /// Which channels a restore writes. Archive is always `Subtree`. + pub scope: LifecycleScope, + /// The digest the client previewed. + pub expect: [u8; 32], + /// Most channels the request may lock. + pub max_channels: usize, +} + +enum Attempt { + Done(T), + /// The family root moved before its lock was taken. + Retry, +} + +/// Apply a guarded archive or restore (see [`buzz_core::lifecycle`]). +/// A family that changes root twice before its lock is taken is refused +/// with [`DbError::LifecycleConflict`]. +pub(crate) async fn apply_lifecycle_state( + pool: &PgPool, + push_enqueue: Option, + community: CommunityId, + request: &StateRequest<'_>, + relay_keys: &Keys, +) -> Result { + for _ in 0..2 { + let mut tx = crate::begin_community_event_write_transaction( + pool, + community, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + tx.set_push_enqueue(push_enqueue.clone()); + if let Attempt::Done(write) = state_attempt(tx, request, relay_keys).await? { + return Ok(write); + } + } + Err(DbError::LifecycleConflict) +} + +async fn state_attempt( + mut tx: AdmittedTx, + request: &StateRequest<'_>, + relay_keys: &Keys, +) -> Result> { + let target = request.target; + let actor = request.event.pubkey.to_bytes(); + let root = walk_up(&mut tx, target).await?.root(target); + lock_families(&mut tx, &BTreeSet::from([root])).await?; + let walk = walk_up(&mut tx, target).await?; + if walk.root(target) != root { + return Ok(Attempt::Retry); + } + // The family is stable from here on. + let projected = projected_parent(&mut tx, target).await?.is_some(); + let needs_descendants = request.archive || request.scope == LifecycleScope::Subtree; + let descendants = if projected && needs_descendants { + walk_down(&mut tx, target, request.max_channels).await? + } else { + Some(Vec::new()) + }; + let mut rows: BTreeSet = BTreeSet::from([target]); + let mut writable = rows.clone(); + let usable = projected && walk.intact; + if usable { + rows.extend(&walk.ancestors); + if !request.archive { + writable.extend(&walk.ancestors); + } + if let Some(descendants) = &descendants { + rows.extend(descendants); + writable.extend(descendants); + } + } + let too_large = descendants.is_none() || rows.len() > request.max_channels; + if too_large { + // Refused below; lock only the target so the duplicate check runs. + rows = BTreeSet::from([target]); + writable = rows.clone(); + } + lock_gates(&mut tx, &writable).await?; + lock_ttls_shared(&mut tx, &writable).await?; + let states = lock_rows(&mut tx, &rows, &actor, false).await?; + let target_state = states + .get(&target) + .filter(|state| !state.deleted) + .copied() + .ok_or(DbError::ChannelNotFound(target))?; + + let (stored, inserted) = crate::event::insert_event_with_thread_metadata_tx( + &mut tx, + request.event, + Some(target), + None, + ) + .await?; + if !inserted { + tx.rollback().await?; + return Ok(Attempt::Done(LifecycleWrite { + stored, + inserted, + changed: Vec::new(), + })); + } + // The same state refusals as an unguarded request. + match (target_state.archived, request.archive) { + (true, true) => return Err(DbError::AccessDenied("channel is archived".into())), + (false, false) => return Err(DbError::AccessDenied("channel is not archived".into())), + _ => {} + } + if too_large { + return Err(DbError::AccessDenied( + "lifecycle request exceeds max_subtree_channels".into(), + )); + } + if !usable { + return Err(DbError::LifecycleConflict); + } + let (compared, affected) = digest_sets( + request, + &walk.ancestors, + descendants.as_deref().unwrap_or_default(), + &states, + ) + .ok_or(DbError::LifecycleConflict)?; + let digest = lifecycle_digest(request.archive, request.scope, target, compared, &affected); + if digest != request.expect { + return Err(DbError::LifecycleConflict); + } + let mut changed: Vec = affected.iter().map(|entry| entry.channel).collect(); + if unauthorized(&mut tx, &changed, &actor).await? { + return Err(DbError::AccessDenied( + "actor not authorized for every affected channel".into(), + )); + } + write_states(&mut tx, &changed, request.archive).await?; + // Target first, for the caller's ordered side effects. + changed.sort_by_key(|channel| *channel != target); + let now = chrono::Utc::now(); + let mut notices = Vec::with_capacity(changed.len()); + for channel in changed { + let notice = system_notice(relay_keys, channel, target, request, now)?; + let (_, message_inserted) = + crate::event::insert_event_in_transaction(&mut tx, ¬ice, Some(channel)).await?; + if message_inserted { + crate::insert_mentions_in_transaction(&mut tx, ¬ice, Some(channel)).await?; + } + notices.push((channel, notice)); + } + tx.commit().await?; + Ok(Attempt::Done(LifecycleWrite { + stored, + inserted, + changed: notices, + })) +} + +/// The compared entry and the affected entries of a guarded request, from +/// locked rows. `None` when the topology does not match the rows (for +/// example a deleted ancestor), which is refused as a conflict. +fn digest_sets( + request: &StateRequest<'_>, + ancestors: &[Uuid], + descendants: &[Uuid], + states: &BTreeMap, +) -> Option<(Option, Vec)> { + let entry = |channel: Uuid| { + states.get(&channel).map(|state| TokenEntry { + channel, + token: state.token, + }) + }; + let target = states.get(&request.target)?; + let mut affected = vec![TokenEntry { + channel: request.target, + token: target.token, + }]; + let mut above = ancestors.iter(); + if !request.archive { + // The minimum archived ancestor chain. + let mut next = above.clone(); + while let Some(channel) = next.next() { + let state = states.get(channel)?; + if !state.archived { + break; + } + if state.deleted { + return None; + } + affected.push(entry(*channel)?); + above = next.clone(); + } + } + let mut compared = None; + for channel in above { + let state = states.get(channel)?; + if state.deleted { + return None; + } + if state.active() { + compared = entry(*channel); + break; + } + } + if request.archive || request.scope == LifecycleScope::Subtree { + for channel in descendants { + let state = states.get(channel)?; + let writes = if request.archive { + state.active() + } else { + state.archived && !state.deleted + }; + if writes { + affected.push(entry(*channel)?); + } + } + } + Some((compared, affected)) +} + +async fn write_states(tx: &mut AdmittedTx, channels: &[Uuid], archive: bool) -> Result<()> { + let community = tx.community(); + let tokens: Vec = channels.iter().map(|_| new_token()).collect(); + let sql = if archive { + "UPDATE channels AS ch SET archived_at = NOW(), lifecycle_token = t.token \ + FROM unnest($2::uuid[], $3::uuid[]) AS t(id, token) \ + WHERE ch.community_id = $1 AND ch.id = t.id \ + AND ch.deleted_at IS NULL AND ch.archived_at IS NULL" + } else { + "UPDATE channels AS ch SET archived_at = NULL, lifecycle_token = t.token, \ + ttl_deadline = CASE \ + WHEN ch.ttl_seconds IS NOT NULL \ + THEN NOW() + (ch.ttl_seconds || ' seconds')::interval \ + ELSE ch.ttl_deadline \ + END \ + FROM unnest($2::uuid[], $3::uuid[]) AS t(id, token) \ + WHERE ch.community_id = $1 AND ch.id = t.id \ + AND ch.deleted_at IS NULL AND ch.archived_at IS NOT NULL" + }; + let updated = sqlx::query(sql) + .bind(community.as_uuid()) + .bind(channels) + .bind(&tokens) + .execute(tx.conn()) + .await? + .rows_affected(); + // The rows are locked and were checked above. + if updated != channels.len() as u64 { + return Err(DbError::InvalidData( + "guarded lifecycle write changed an unexpected row count".into(), + )); + } + Ok(()) +} + +/// The kind:40099 notice for one changed channel. Channels other than the +/// target name it as `cascade_root`. +fn system_notice( + relay_keys: &Keys, + channel: Uuid, + target: Uuid, + request: &StateRequest<'_>, + now: chrono::DateTime, +) -> Result { + let mut content = serde_json::json!({ + "type": if request.archive { "channel_archived" } else { "channel_unarchived" }, + "actor": request.event.pubkey.to_hex(), + }); + if channel != target { + content["cascade_root"] = serde_json::Value::String(target.to_string()); + } + let tag = + Tag::parse(["h", &channel.to_string()]).map_err(|e| DbError::InvalidData(e.to_string()))?; + EventBuilder::new(Kind::Custom(40099), content.to_string()) + .tags([tag]) + .custom_created_at(nostr::Timestamp::from(now.timestamp() as u64)) + .sign_with_keys(relay_keys) + .map_err(|e| DbError::InvalidData(e.to_string())) +} + +/// Apply a fence: replace the target's token if it still equals +/// `expect_target`. It takes the target's shared TTL lock, then its row, and +/// changes nothing else. +pub(crate) async fn apply_lifecycle_fence( + pool: &PgPool, + push_enqueue: Option, + community: CommunityId, + target: Uuid, + event: &Event, + expect_target: Option, +) -> Result { + let mut tx = crate::begin_community_event_write_transaction( + pool, + community, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + tx.set_push_enqueue(push_enqueue); + let actor = event.pubkey.to_bytes(); + let channels = BTreeSet::from([target]); + lock_ttls_shared(&mut tx, &channels).await?; + let state = lock_rows(&mut tx, &channels, &actor, false) + .await? + .remove(&target) + .filter(|state| !state.deleted) + .ok_or(DbError::ChannelNotFound(target))?; + let (stored, inserted) = + crate::event::insert_event_with_thread_metadata_tx(&mut tx, event, Some(target), None) + .await?; + if !inserted { + tx.rollback().await?; + return Ok(LifecycleWrite { + stored, + inserted, + changed: Vec::new(), + }); + } + if unauthorized(&mut tx, &[target], &actor).await? { + return Err(DbError::AccessDenied( + "actor not authorized for every affected channel".into(), + )); + } + if state.token != expect_target { + return Err(DbError::LifecycleConflict); + } + sqlx::query("UPDATE channels SET lifecycle_token = $3 WHERE community_id = $1 AND id = $2") + .bind(tx.community().as_uuid()) + .bind(target) + .bind(new_token()) + .execute(tx.conn()) + .await?; + tx.commit().await?; + Ok(LifecycleWrite { + stored, + inserted, + changed: Vec::new(), + }) +} + +/// How an accepted `nexus.task` revision changes the projection. +pub(crate) enum EdgeChange { + /// Insert or replace the channel's row. + Upsert { + child: Uuid, + parent: Option, + artifact: Uuid, + }, + /// Remove the channel's row. + Remove { child: Uuid }, +} + +/// The families an edge write holds, and what it read under them. +pub(crate) struct EdgeLocks { + child_parent: Option>, + new_parent_chain: Option, + rows: BTreeMap, +} + +/// Lock what a `nexus.task` revision for `child` with parent `parent` +/// needs: the families of both, the TTL locks and the rows (`FOR SHARE`). +/// Returns `None` when a family root moved before its lock was taken; the +/// caller retries once. +pub(crate) async fn lock_task_edge( + tx: &mut AdmittedTx, + child: Uuid, + parent: Option, + author: &[u8], +) -> Result> { + let roots = |child_walk: &Walk, parent_walk: Option<&Walk>| { + let mut roots = BTreeSet::from([child_walk.root(child)]); + if let (Some(parent), Some(walk)) = (parent, parent_walk) { + roots.insert(walk.root(parent)); + } + roots + }; + let child_walk = walk_up(tx, child).await?; + let parent_walk = match parent { + Some(parent) => Some(walk_up(tx, parent).await?), + None => None, + }; + let locked = roots(&child_walk, parent_walk.as_ref()); + lock_families(tx, &locked).await?; + let child_walk = walk_up(tx, child).await?; + let parent_walk = match parent { + Some(parent) => Some(walk_up(tx, parent).await?), + None => None, + }; + if roots(&child_walk, parent_walk.as_ref()) != locked { + return Ok(None); + } + let mut channels = BTreeSet::from([child]); + channels.extend(parent); + lock_ttls_shared(tx, &channels).await?; + let rows = lock_rows(tx, &channels, author, true).await?; + Ok(Some(EdgeLocks { + child_parent: projected_parent(tx, child).await?, + new_parent_chain: parent_walk, + rows, + })) +} + +impl EdgeLocks { + /// The child's projected parent, if it has a row. + pub(crate) fn current_parent(&self) -> Option> { + self.child_parent + } +} + +/// Check a `nexus.task` revision against the locked family. `Err` carries a +/// public refusal. +pub(crate) async fn check_task_edge( + tx: &mut AdmittedTx, + locks: &EdgeLocks, + child: Uuid, + parent: Option, + delete: bool, +) -> Result> { + let Some(child_row) = locks.rows.get(&child).filter(|row| !row.deleted) else { + return Ok(Err("task channel is not live")); + }; + if !child_row.created_by_actor { + return Ok(Err("nexus.task author must own its channel")); + } + if delete { + if has_child_tasks(tx, child).await? { + return Ok(Err(CHILD_TASKS_DELETE)); + } + return Ok(Ok(())); + } + if child_row.archived { + return Ok(Err("task channel is not live")); + } + if child_row.ttl { + return Ok(Err("task channels cannot have a ttl")); + } + let Some(parent) = parent else { + return Ok(Ok(())); + }; + let Some(parent_row) = locks.rows.get(&parent).filter(|row| row.active()) else { + return Ok(Err("parent must be a live task")); + }; + if !parent_row.created_by_actor { + return Ok(Err("parent task has another owner")); + } + if parent_row.ttl || projected_parent(tx, parent).await?.is_none() { + return Ok(Err("parent must be a live task")); + } + let Some(chain) = locks.new_parent_chain.as_ref().filter(|walk| walk.intact) else { + return Ok(Err("parent must be a live task")); + }; + if chain.ancestors.contains(&child) { + return Ok(Err("task parent would form a cycle")); + } + // The deepest descendant of the child ends up this far below the root. + let height = if locks.current_parent().is_some() { + subtree_height(tx, child).await? + } else { + Some(0) + }; + match height { + Some(height) if chain.ancestors.len() + 1 + height < MAX_TASK_DEPTH => Ok(Ok(())), + _ => Ok(Err("task hierarchy is too deep")), + } +} + +/// The depth of the deepest projected descendant of `channel` (0 for a +/// leaf), or `None` beyond [`MAX_TASK_DEPTH`]. +async fn subtree_height(tx: &mut AdmittedTx, channel: Uuid) -> Result> { + let community = tx.community(); + let height: Option = sqlx::query_scalar( + "WITH RECURSIVE down(id, depth) AS ( \ + SELECT child_channel_id, 1 FROM artifact_parents \ + WHERE community_id = $1 AND parent_channel_id = $2 \ + UNION ALL \ + SELECT ap.child_channel_id, down.depth + 1 FROM down \ + JOIN artifact_parents AS ap \ + ON ap.community_id = $1 AND ap.parent_channel_id = down.id \ + WHERE down.depth <= $3 \ + ) \ + SELECT max(depth) FROM down", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(MAX_TASK_DEPTH as i32) + .fetch_one(tx.conn()) + .await?; + let height = height.unwrap_or(0) as usize; + Ok((height <= MAX_TASK_DEPTH).then_some(height)) +} + +/// Write an accepted revision's projection change. +pub(crate) async fn write_task_edge(tx: &mut AdmittedTx, change: EdgeChange) -> Result<()> { + let community = tx.community(); + match change { + EdgeChange::Upsert { + child, + parent, + artifact, + } => { + sqlx::query( + "INSERT INTO artifact_parents \ + (community_id, child_channel_id, parent_channel_id, artifact_id) \ + VALUES ($1, $2, $3, $4) \ + ON CONFLICT (community_id, child_channel_id) DO UPDATE \ + SET parent_channel_id = EXCLUDED.parent_channel_id, \ + artifact_id = EXCLUDED.artifact_id", + ) + .bind(community.as_uuid()) + .bind(child) + .bind(parent) + .bind(artifact) + .execute(tx.conn()) + .await?; + } + EdgeChange::Remove { child } => { + remove_task_edge(tx, child).await?; + } + } + Ok(()) +} + +/// Remove `channel`'s projection row, if any. +pub(crate) async fn remove_task_edge(tx: &mut AdmittedTx, channel: Uuid) -> Result<()> { + let community = tx.community(); + sqlx::query("DELETE FROM artifact_parents WHERE community_id = $1 AND child_channel_id = $2") + .bind(community.as_uuid()) + .bind(channel) + .execute(tx.conn()) + .await?; + Ok(()) +} + +/// One channel's lifecycle state, as the state read returns it. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct ChannelLifecycleState { + /// The channel. + pub id: Uuid, + /// When it was archived, in unix seconds, or `None` when active. + pub archived_at: Option, + /// Whether it is deleted. Only this marks a channel as gone. + pub deleted: bool, + /// Its lifecycle token, or `None` if never set. + pub lifecycle_token: Option, +} + +impl crate::Db { + /// Apply a guarded archive or restore. See [`apply_lifecycle_state`]. + pub async fn apply_lifecycle_state( + &self, + community: CommunityId, + request: &StateRequest<'_>, + relay_keys: &Keys, + ) -> Result { + apply_lifecycle_state( + &self.pool, + self.push_enqueue.clone(), + community, + request, + relay_keys, + ) + .await + } + + /// Apply a lifecycle fence. See [`apply_lifecycle_fence`]. + pub async fn apply_lifecycle_fence( + &self, + community: CommunityId, + target: Uuid, + event: &Event, + expect_target: Option, + ) -> Result { + apply_lifecycle_fence( + &self.pool, + self.push_enqueue.clone(), + community, + target, + event, + expect_target, + ) + .await + } + + /// The lifecycle state of the `channels` that `actor` created or is an + /// active member of, sorted by id, from one statement. Other ids are + /// omitted, which means "unknown", never "gone". + pub async fn lifecycle_state( + &self, + community: CommunityId, + actor: &[u8], + channels: &[Uuid], + ) -> Result> { + let mut conn = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; + let rows = sqlx::query( + "SELECT c.id, EXTRACT(EPOCH FROM c.archived_at)::bigint AS archived_at, \ + c.deleted_at IS NOT NULL AS deleted, c.lifecycle_token \ + FROM channels AS c \ + WHERE c.community_id = $1 AND c.id = ANY($2) \ + AND (c.created_by = $3 OR EXISTS ( \ + SELECT 1 FROM channel_members AS m \ + WHERE m.community_id = c.community_id AND m.channel_id = c.id \ + AND m.pubkey = $3 AND m.removed_at IS NULL)) \ + ORDER BY c.id", + ) + .bind(community.as_uuid()) + .bind(channels) + .bind(actor) + .fetch_all(&mut *conn) + .await?; + rows.into_iter() + .map(|row| { + Ok(ChannelLifecycleState { + id: row.try_get("id")?, + archived_at: row.try_get("archived_at")?, + deleted: row.try_get("deleted")?, + lifecycle_token: row.try_get("lifecycle_token")?, + }) + }) + .collect() + } +} diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index bd2d709151b..7f6957b2bc7 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -24,6 +24,8 @@ pub mod entity_deletion; pub mod event; /// App-owned push enqueue and channel TTL refresh for inserted events. pub(crate) mod event_follow_up; +/// Guarded task archive and restore, the task projection and the state read. +pub mod lifecycle; /// Per-community NIP-FI issuer bindings. pub mod nip_fi_binding; /// User-specific deletion submission and inventory preparation. diff --git a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock index 089c0108ccc..b0b9a313903 100644 --- a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock +++ b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock @@ -2091,6 +2091,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -2440,6 +2446,7 @@ dependencies = [ "getrandom 0.4.3", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs index a5025b8dca3..b7b559ede57 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs @@ -49,7 +49,8 @@ async fn accept(f: &Fixture, event: &Event) { event, &envelope, source, - &f.state.relay_keypair + &f.state.relay_keypair, + f.state.config.lifecycle_guard_enabled, ) .await .unwrap(), diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs index f3dbf095654..3f1ed92331a 100644 --- a/crates/buzz-relay/src/api/buzz_v1/handlers.rs +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -209,6 +209,57 @@ pub(super) async fn write_intent( } } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LifecycleStateRequest { + channel_ids: Vec, +} + +/// 1..=MAX_STATE_CHANNELS unique ids in canonical lowercase form. +fn parse_lifecycle_ids(raw: &[String]) -> Option> { + let ids = raw + .iter() + .map(|id| Uuid::parse_str(id).ok().filter(|u| u.to_string() == *id)) + .collect::>>()?; + let unique: BTreeSet<_> = ids.iter().collect(); + ((1..=buzz_db::lifecycle::MAX_STATE_CHANNELS).contains(&ids.len()) && unique.len() == ids.len()) + .then_some(ids) +} + +/// Current lifecycle state of the requested channels the actor created or +/// is an active member of, in one statement. An omitted id is unknown to +/// the actor. Body POST, so a large id list never becomes a long URI. +pub(super) async fn lifecycle_state( + State(state): State>, + headers: HeaderMap, + OriginalUri(uri): OriginalUri, + body: Bytes, +) -> Result { + if body.len() > 64 * 1024 { + return Err(Error::invalid()); + } + tokio::time::timeout(Duration::from_secs(8), async { + let principal = auth::authorize(&state, &headers, &uri, "POST", Some(&body)).await?; + let request: LifecycleStateRequest = + serde_json::from_slice(&body).map_err(|_| Error::invalid())?; + let ids = parse_lifecycle_ids(&request.channel_ids).ok_or_else(Error::invalid)?; + let channels = state + .db + .lifecycle_state( + principal.tenant.community(), + &principal.actor.to_bytes(), + &ids, + ) + .await + .map_err(|_| Error::unavailable())?; + // Release rows only after admission and bans are rechecked. + auth::recheck(&state, &headers, &principal).await?; + auth::response(json!({ "channels": channels })) + }) + .await + .map_err(|_| Error::unavailable())? +} + #[cfg(test)] mod tests { use super::*; @@ -243,4 +294,43 @@ mod tests { let bytes = serde_json::to_vec(&body).unwrap().len(); assert!(bytes <= auth::MAX_RESPONSE_BYTES, "{bytes} bytes"); } + + #[test] + fn lifecycle_ids_are_canonical_unique_and_bounded() { + let id = |n: u128| Uuid::from_u128(n).to_string(); + assert_eq!( + parse_lifecycle_ids(&[id(1)]), + Some(vec![Uuid::from_u128(1)]) + ); + assert_eq!(parse_lifecycle_ids(&[]), None); + assert_eq!(parse_lifecycle_ids(&[id(1), id(1)]), None); + assert_eq!(parse_lifecycle_ids(&[id(10).to_uppercase()]), None); + assert_eq!(parse_lifecycle_ids(&[id(1).replace('-', "")]), None); + assert_eq!(parse_lifecycle_ids(&[format!("{{{}}}", id(1))]), None); + let max = buzz_db::lifecycle::MAX_STATE_CHANNELS as u128; + let full: Vec<_> = (0..max).map(id).collect(); + assert_eq!( + parse_lifecycle_ids(&full).map(|ids| ids.len()), + Some(full.len()) + ); + let over: Vec<_> = (0..=max).map(id).collect(); + assert_eq!(parse_lifecycle_ids(&over), None); + } + + /// The largest state response fits the response limit. + #[test] + fn largest_lifecycle_state_response_fits_the_response_limit() { + let channels: Vec<_> = (0..buzz_db::lifecycle::MAX_STATE_CHANNELS) + .map(|_| buzz_db::lifecycle::ChannelLifecycleState { + id: Uuid::max(), + archived_at: Some(i64::MAX), + deleted: true, + lifecycle_token: Some(Uuid::max()), + }) + .collect(); + let bytes = serde_json::to_vec(&json!({ "channels": channels })) + .unwrap() + .len(); + assert!(bytes <= auth::MAX_RESPONSE_BYTES, "{bytes} bytes"); + } } diff --git a/crates/buzz-relay/src/api/buzz_v1/mod.rs b/crates/buzz-relay/src/api/buzz_v1/mod.rs index d462ac15aae..4a83f5abc28 100644 --- a/crates/buzz-relay/src/api/buzz_v1/mod.rs +++ b/crates/buzz-relay/src/api/buzz_v1/mod.rs @@ -13,11 +13,17 @@ use std::sync::Arc; /// Public accessory prefix, shared by routing and capability discovery. pub const BASE_PATH: &str = "/buzz/v1"; +/// Full path of the lifecycle state read, advertised in NIP-11. +pub const LIFECYCLE_STATE_PATH: &str = "/buzz/v1/lifecycle/state"; + /// Narrow versioned router; unknown accessory paths never return SPA HTML. pub fn router(state: Arc) -> Router { Router::new() .route("/me/sidebar", get(handlers::sidebar)) .route("/me/read-state", post(handlers::write)) + // Needs only `buzz_v1`: a client recovers its journal after the + // lifecycle guard is turned off. + .route("/lifecycle/state", post(handlers::lifecycle_state)) .fallback(|| async { auth::Error::new(axum::http::StatusCode::NOT_FOUND, "not_found") }) .with_state(state) } diff --git a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs index 8c235e90d89..27920467626 100644 --- a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -368,6 +368,138 @@ async fn accessory_batch_outcomes_are_independent_and_return_applied_rows() { assert_ne!(response.status(), StatusCode::OK); } +/// The lifecycle state read: a signed, bounded body POST that returns the +/// actor's channels in one sorted list, deleted ones included, and omits +/// every other id. Mutations: dropping the membership condition leaks +/// `foreign`; dropping the canonical-id check accepts the uppercase id; +/// reading the body after authorization lets an unsigned body through. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn lifecycle_state_read_is_signed_bounded_and_actor_scoped() { + let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .unwrap(); + let mut state = (*fixture).clone(); + // Needs only buzz_v1: journal recovery works with the guard off. + Arc::make_mut(&mut state.config).buzz_v1_enabled = true; + let state = Arc::new(state); + let host = format!("bff-lifecycle-{}.local", uuid::Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let actor = Keys::generate(); + let create = |name: &'static str, owner: &Keys| { + let (state, owner) = (state.clone(), owner.public_key().to_bytes()); + async move { + state + .db + .create_channel( + community, + name, + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Private, + None, + &owner, + None, + ) + .await + .unwrap() + .id + } + }; + let active = create("active", &actor).await; + let archived = create("archived", &actor).await; + let deleted = create("deleted", &actor).await; + let foreign = create("foreign", &Keys::generate()).await; + state.db.archive_channel(community, archived).await.unwrap(); + state + .db + .soft_delete_channel(community, deleted) + .await + .unwrap(); + let path = "/buzz/v1/lifecycle/state"; + let post = |body: Vec, signed: Option>| { + let (state, host, actor) = (state.clone(), host.clone(), actor.clone()); + async move { + let auth = proof( + &actor, + &host, + path, + "POST", + Some(signed.as_ref().unwrap_or(&body)), + ); + request(state, &host, path, "POST", Some(&auth), &body).await + } + }; + let ids = [active, archived, deleted, foreign, uuid::Uuid::new_v4()]; + let body = serde_json::to_vec(&json!({ "channel_ids": ids })).unwrap(); + let (status, value) = post(body, None).await; + assert_eq!(status, StatusCode::OK, "{value}"); + let rows: Vec<(String, Value)> = sqlx::query_as::<_, (uuid::Uuid, Option)>( + "SELECT id, lifecycle_token FROM channels WHERE id = ANY($1) ORDER BY id", + ) + .bind([active, archived, deleted].as_slice()) + .fetch_all(state.db.pool()) + .await + .unwrap() + .into_iter() + .map(|(id, token)| (id.to_string(), json!(token))) + .collect(); + let channels = value["channels"].as_array().unwrap(); + assert_eq!( + channels.len(), + 3, + "foreign and unknown ids are omitted: {value}" + ); + for ((id, token), row) in rows.iter().zip(channels) { + assert_eq!(row["id"], json!(id)); + assert_eq!(&row["lifecycle_token"], token); + assert!(token.is_string(), "every lifecycle writer sets a token"); + let channel: uuid::Uuid = id.parse().unwrap(); + assert_eq!(row["archived_at"].is_i64(), channel == archived, "{row}"); + assert_eq!(row["deleted"], json!(channel == deleted), "{row}"); + } + + let canonical = active.to_string(); + let over: Vec = (0..=buzz_db::lifecycle::MAX_STATE_CHANNELS) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect(); + for (case, body) in [ + ("empty", json!({ "channel_ids": [] })), + ( + "duplicate", + json!({ "channel_ids": [canonical, canonical] }), + ), + ( + "uppercase", + json!({ "channel_ids": [canonical.to_uppercase()] }), + ), + ( + "unknown field", + json!({ "channel_ids": [canonical], "x": 1 }), + ), + ("too many", json!({ "channel_ids": over })), + ] { + let (status, value) = post(serde_json::to_vec(&body).unwrap(), None).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{case}: {value}"); + } + let huge = vec![b' '; 64 * 1024 + 1]; + assert_eq!(post(huge, None).await.0, StatusCode::BAD_REQUEST); + // The proof binds the body. + let body = serde_json::to_vec(&json!({ "channel_ids": [active] })).unwrap(); + let other = serde_json::to_vec(&json!({ "channel_ids": [foreign] })).unwrap(); + assert_eq!(post(body, Some(other)).await.0, StatusCode::UNAUTHORIZED); + assert_eq!( + request(state.clone(), &host, path, "POST", None, b"{}") + .await + .0, + StatusCode::UNAUTHORIZED + ); +} + #[tokio::test] #[ignore = "requires Postgres"] async fn accessory_write_revocation_is_terminal_before_persistence() { @@ -473,11 +605,13 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { .unwrap(); let host = format!("bff-discovery-{}.local", uuid::Uuid::new_v4()); fixture.db.ensure_configured_community(&host).await.unwrap(); - for enabled in [false, true] { + for (enabled, guard) in [(false, false), (false, true), (true, false), (true, true)] { let mut state = (*fixture).clone(); let config = Arc::make_mut(&mut state.config); config.buzz_v1_enabled = enabled; config.buzz_v1_retention_seconds = 1234; + config.lifecycle_guard_enabled = guard; + config.lifecycle_max_subtree_channels = 37; let state = Arc::new(state); for known_host in [true, false] { let request_host = if known_host { @@ -505,6 +639,20 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { enabled && known_host, "enabled={enabled} known_host={known_host} path={path}" ); + // The lifecycle capability needs the guard flag, buzz_v1 and + // a resolved host; the subtree limit is read from config. + assert_eq!( + doc.get("lifecycle_guard").is_some(), + guard && enabled && known_host, + "guard={guard} enabled={enabled} known_host={known_host} path={path}" + ); + if guard && enabled && known_host { + assert_eq!( + doc["lifecycle_guard"], + json!({"version":1,"state_path":"/buzz/v1/lifecycle/state", + "max_state_channels":1000,"max_subtree_channels":37}) + ); + } if enabled && known_host { // Exactly these fields: context and thread-cap limits are gone. assert_eq!( diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 2cbb6b59351..5ff34bdc74a 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -2288,7 +2288,12 @@ async fn run_active_audio_connection( room.clear_ended(); room_emptied = false; } - Ok(()) => { + Ok(archived) => { + // A task channel stays active: only a guarded request changes + // its archive state. The huddle still ends normally. + if !archived { + info!(channel_id = %channel_id, "huddle ended; task channel stays active"); + } room_emptied = state .audio_rooms .cleanup_if_empty(tenant.community(), channel_id); diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index cbed06b641b..dfb18345e3b 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -191,6 +191,14 @@ pub struct Config { pub buzz_v1_enabled: bool, /// Author-time unread tracking duration, independent of NIP-RS retention. pub buzz_v1_retention_seconds: u32, + /// Guarded task archive and restore (NEXUS-4111). Off by default. When + /// off, the relay neither advertises the capability nor accepts a new + /// task parent edge, but it still enforces every guard and maintains + /// the existing task projection. + pub lifecycle_guard_enabled: bool, + /// Largest number of channels one guarded archive or restore changes. + /// Bounded by the measured lock and transaction cost. + pub lifecycle_max_subtree_channels: usize, /// Comma-separated list of allowed CORS origins. /// If empty, permissive CORS is used (dev mode). /// Example: "tauri://localhost,http://localhost:3000" @@ -590,6 +598,12 @@ fn parse_operator_listener_delivery_urls( Ok(endpoints) } +/// Default for `BUZZ_LIFECYCLE_MAX_SUBTREE_CHANNELS`. +pub const DEFAULT_LIFECYCLE_SUBTREE_CHANNELS: usize = 250; +/// Upper bound for `BUZZ_LIFECYCLE_MAX_SUBTREE_CHANNELS`. A guarded request +/// takes two advisory locks per channel, which share the server lock table. +pub const MAX_LIFECYCLE_SUBTREE_CHANNELS: usize = 1000; + fn parse_bool(name: &str, default: bool) -> Result { match std::env::var(name) { Err(std::env::VarError::NotPresent) => Ok(default), @@ -1412,6 +1426,22 @@ impl Config { _ => buzz_db::personal_read::DEFAULT_RETENTION_SECONDS, }; + let lifecycle_guard_enabled = parse_bool("BUZZ_LIFECYCLE_GUARD_ENABLED", false)?; + let lifecycle_max_subtree_channels = match std::env::var( + "BUZZ_LIFECYCLE_MAX_SUBTREE_CHANNELS", + ) { + Ok(v) => v + .parse() + .ok() + .filter(|n| (1..=MAX_LIFECYCLE_SUBTREE_CHANNELS).contains(n)) + .ok_or_else(|| { + ConfigError::InvalidValue(format!( + "BUZZ_LIFECYCLE_MAX_SUBTREE_CHANNELS must be 1..={MAX_LIFECYCLE_SUBTREE_CHANNELS}" + )) + })?, + Err(_) => DEFAULT_LIFECYCLE_SUBTREE_CHANNELS, + }; + let nip_fi = crate::nip_fi_config::NipFiRelayConfig::from_env()?; let nip_fi_provision_binding = crate::nip_fi_config::parse_provision_binding()?; let nip_fi_resolver_bounds = @@ -1447,6 +1477,8 @@ impl Config { require_auth_token, buzz_v1_enabled, buzz_v1_retention_seconds, + lifecycle_guard_enabled, + lifecycle_max_subtree_channels, cors_origins, relay_private_key, uds_path, @@ -2090,6 +2122,43 @@ mod tests { assert!(matches!(admin.auth, crate::config::AdminAuth::Nip98)); } + /// The subtree limit defaults to 250 and accepts 1..=1000. Mutation: + /// change the default or either bound and a row fails. + #[test] + fn lifecycle_subtree_limit_defaults_to_250_and_is_bounded() { + const KEY: &str = "BUZZ_LIFECYCLE_MAX_SUBTREE_CHANNELS"; + let _guards = env_guards(); + let previous = std::env::var_os(KEY); + let mut results = Vec::new(); + for value in [None, Some("1"), Some("1000"), Some("0"), Some("1001")] { + match value { + Some(value) => std::env::set_var(KEY, value), + None => std::env::remove_var(KEY), + } + let limit = Config::from_env().map(|config| config.lifecycle_max_subtree_channels); + results.push((value, limit)); + } + match previous { + Some(value) => std::env::set_var(KEY, value), + None => std::env::remove_var(KEY), + } + for (value, limit) in results { + match value { + None => assert_eq!(limit.ok(), Some(250), "unset"), + Some("1") => assert_eq!(limit.ok(), Some(1)), + Some("1000") => assert_eq!(limit.ok(), Some(1000)), + Some(value) => assert!( + matches!( + limit, + Err(ConfigError::InvalidValue(ref message)) + if message == "BUZZ_LIFECYCLE_MAX_SUBTREE_CHANNELS must be 1..=1000" + ), + "{value}: {limit:?}" + ), + } + } + } + #[test] fn buzz_v1_retention_is_validated_only_when_enabled() { let _guards = env_guards(); diff --git a/crates/buzz-relay/src/handlers/artifact.rs b/crates/buzz-relay/src/handlers/artifact.rs index c52e71d6d4d..d5780e3f0a2 100644 --- a/crates/buzz-relay/src/handlers/artifact.rs +++ b/crates/buzz-relay/src/handlers/artifact.rs @@ -44,6 +44,7 @@ pub(crate) async fn accept( &env, source, &state.relay_keypair, + state.config.lifecycle_guard_enabled, ) .await .map_err(|e| IngestError::Internal(format!("artifact storage: {e}")))?; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9cae758646e..9cb33a61ade 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3636,11 +3636,27 @@ async fn ingest_event_inner( Some(buzz_db::DbError::AccessDenied(reason)) => { IngestError::Rejected(format!("invalid: {reason}")) } + // The only 409 a kind:9002 can return (see CanvasConflict). + Some(buzz_db::DbError::LifecycleConflict) => { + IngestError::CanvasConflict("conflict: lifecycle changed".into()) + } Some(taken @ buzz_db::DbError::ChannelNameTaken(_)) => { IngestError::Rejected(format!("invalid: {taken}")) } _ => IngestError::Internal(format!("error: channel update failed: {e}")), })? + } else if kind_u32 == KIND_NIP29_DELETE_GROUP { + // One commit stores the event and deletes the channel. A refusal + // decided under the lock (a task with child tasks) stores nothing and + // is the client's error; anything else is ours. + crate::handlers::side_effects::persist_delete_group(tenant, &event, state) + .await + .map_err(|e| match e { + buzz_db::DbError::AccessDenied(reason) => { + IngestError::Rejected(format!("invalid: {reason}")) + } + other => IngestError::Internal(format!("error: channel delete failed: {other}")), + })? } else if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. @@ -6823,7 +6839,14 @@ mod postgres_tests { let env = buzz_core::artifact::validate(&create).expect("valid create"); healthy .db - .accept_artifact(community, &create, &env, None, &healthy.relay_keypair) + .accept_artifact( + community, + &create, + &env, + None, + &healthy.relay_keypair, + false, + ) .await .map_err(db_err)?; diff --git a/crates/buzz-relay/src/handlers/lifecycle_postgres_tests.rs b/crates/buzz-relay/src/handlers/lifecycle_postgres_tests.rs new file mode 100644 index 00000000000..531625e4bfc --- /dev/null +++ b/crates/buzz-relay/src/handlers/lifecycle_postgres_tests.rs @@ -0,0 +1,1246 @@ +//! Postgres-backed tests for guarded task archive and restore (NEXUS-4111). +//! +//! Every request goes through `ingest_event`, so validation, routing, the +//! atomic write and the reply mapping are the production path. Races are +//! ordered by locks that a second session holds, never by sleeps: a test +//! takes a lock, starts the contenders, waits until they queue on it, then +//! releases it. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use buzz_core::channel::MemberRole; +use buzz_core::lifecycle::{lifecycle_digest, task_d, LifecycleScope, TokenEntry}; +use buzz_core::TenantContext; +use nostr::{Event, EventBuilder, Keys, Kind, Tag, Timestamp}; +use serde_json::json; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +use crate::handlers::group_state_postgres_tests::{community, create_channel, http_auth, state}; +use crate::handlers::ingest::{ingest_event, IngestError, IngestResult}; +use crate::state::AppState; + +const GUARD_REQUIRED: &str = "invalid: lifecycle guard required"; +const CHANGED: &str = "conflict: lifecycle changed"; +const NOT_AUTHORIZED_S: &str = "invalid: actor not authorized for name/about/archived/visibility/ttl/label/join_role/unlisted changes"; +const NOT_AUTHORIZED_ALL: &str = "invalid: actor not authorized for every affected channel"; + +/// One reply, as a client classifies it. +#[derive(Debug, PartialEq, Eq)] +enum Reply { + Applied, + Duplicate, + /// HTTP 409. + Conflict(String), + /// HTTP 400. + Invalid(String), + Other(String), +} + +fn classify(result: Result) -> Reply { + match result { + Ok(r) if r.accepted && r.message.starts_with("duplicate:") => Reply::Duplicate, + Ok(r) if r.accepted => Reply::Applied, + Ok(r) => Reply::Other(r.message), + Err(IngestError::CanvasConflict(m)) => Reply::Conflict(m), + Err(IngestError::Rejected(m)) => Reply::Invalid(m), + Err(e) => Reply::Other(format!("{e:?}")), + } +} + +fn conflict() -> Reply { + Reply::Conflict(CHANGED.into()) +} + +fn invalid(message: &str) -> Reply { + Reply::Invalid(message.into()) +} + +/// A strictly increasing `created_at`, so two requests with the same tags +/// are distinct events. Event ids are content hashes: the same tags in the +/// same second would be one event, a duplicate. +fn stamp() -> Timestamp { + static NEXT: AtomicU64 = AtomicU64::new(0); + Timestamp::from(Timestamp::now().as_secs() + NEXT.fetch_add(1, Ordering::Relaxed)) +} + +/// Lifecycle columns of one channel row. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Row { + archived: bool, + deleted: bool, + token: Option, +} + +#[derive(Clone)] +struct Fixture { + state: Arc, + tenant: TenantContext, + owner: Keys, +} + +impl Fixture { + async fn new(label: &str, guard: bool) -> Self { + let mut state = (*state().await).clone(); + Arc::make_mut(&mut state.config).lifecycle_guard_enabled = guard; + let state = Arc::new(state); + let tenant = community(&state, label).await; + Self { + state, + tenant, + owner: Keys::generate(), + } + } + + /// The same community and owner on a relay with the flag set to `guard`. + fn with_guard(&self, guard: bool) -> Self { + let mut state = (*self.state).clone(); + Arc::make_mut(&mut state.config).lifecycle_guard_enabled = guard; + Self { + state: Arc::new(state), + ..self.clone() + } + } + + fn pool(&self) -> &PgPool { + self.state.db.pool() + } + + async fn send(&self, keys: &Keys, event: Event) -> Reply { + classify(ingest_event(&self.state, &self.tenant, event, http_auth(keys)).await) + } + + /// Send in the background, for a race. + fn spawn(&self, keys: &Keys, event: Event) -> tokio::task::JoinHandle { + let (fixture, keys) = (self.clone(), keys.clone()); + tokio::spawn(async move { fixture.send(&keys, event).await }) + } + + async fn channel(&self) -> Uuid { + create_channel(&self.state, &self.tenant, &self.owner, &[]).await + } + + /// A new channel with an accepted task record. + async fn task(&self, parent: Option) -> (Uuid, Event) { + let channel = self.channel().await; + let record = task_record(&self.owner, channel, parent, "create", None); + assert_eq!(self.send(&self.owner, record.clone()).await, Reply::Applied); + (channel, record) + } + + async fn row(&self, channel: Uuid) -> Row { + let (archived, deleted, token) = sqlx::query_as::<_, (bool, bool, Option)>( + "SELECT archived_at IS NOT NULL, deleted_at IS NOT NULL, lifecycle_token \ + FROM channels WHERE community_id = $1 AND id = $2", + ) + .bind(self.tenant.community().as_uuid()) + .bind(channel) + .fetch_one(self.pool()) + .await + .expect("channel row"); + Row { + archived, + deleted, + token, + } + } + + async fn rows(&self, channels: &[Uuid]) -> Vec { + let mut rows = Vec::new(); + for channel in channels { + rows.push(self.row(*channel).await); + } + rows + } + + /// The projected parent: `None` without a projection row. + async fn projected(&self, channel: Uuid) -> Option> { + sqlx::query_scalar( + "SELECT parent_channel_id FROM artifact_parents \ + WHERE community_id = $1 AND child_channel_id = $2", + ) + .bind(self.tenant.community().as_uuid()) + .bind(channel) + .fetch_optional(self.pool()) + .await + .expect("projection row") + } + + async fn count(&self, channel: Uuid, kind: i32) -> i64 { + sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = $3", + ) + .bind(self.tenant.community().as_uuid()) + .bind(channel) + .bind(kind) + .fetch_one(self.pool()) + .await + .expect("count events") + } + + /// Kind:40099 notices of `kind` (`channel_archived`, …) in `channel`. + async fn notices(&self, channel: Uuid, kind: &str) -> i64 { + sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 40099 \ + AND content::jsonb ->> 'type' = $3", + ) + .bind(self.tenant.community().as_uuid()) + .bind(channel) + .bind(kind) + .fetch_one(self.pool()) + .await + .expect("count notices") + } + + /// The digest a client previews, from the tokens it reads now. The + /// test names `compared` and `affected` from the family it built. + async fn expect( + &self, + archive: bool, + scope: LifecycleScope, + target: Uuid, + compared: Option, + affected: &[Uuid], + ) -> String { + let compared = match compared { + Some(channel) => Some(TokenEntry { + channel, + token: self.row(channel).await.token, + }), + None => None, + }; + let mut entries = Vec::new(); + for channel in affected { + entries.push(TokenEntry { + channel: *channel, + token: self.row(*channel).await.token, + }); + } + hex::encode(lifecycle_digest(archive, scope, target, compared, &entries)) + } + + /// A guarded archive (`archive`) or restore, previewed now. + async fn request( + &self, + archive: bool, + scope: LifecycleScope, + target: Uuid, + compared: Option, + affected: &[Uuid], + ) -> Event { + let expect = self + .expect(archive, scope, target, compared, affected) + .await; + request_event(&self.owner, target, archive, scope.as_str(), &expect) + } + + async fn add_admin(&self, channel: Uuid, keys: &Keys) { + self.state + .db + .add_member( + self.tenant.community(), + channel, + &keys.public_key().to_bytes(), + MemberRole::Admin, + Some(&self.owner.public_key().to_bytes()), + ) + .await + .expect("add admin"); + } + + /// A second session that holds locks until it commits. + async fn holder(&self) -> Transaction<'static, Postgres> { + self.pool().begin().await.expect("begin holder") + } + + fn key(&self, namespace: &str, channel: Uuid) -> String { + format!( + "{namespace}:{}:{channel}", + self.tenant.community().as_uuid() + ) + } + + /// Wait until `n` sessions of this test's database wait on a lock. + async fn waiters(&self, n: i64) { + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let waiting: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_stat_activity \ + WHERE datname = current_database() AND wait_event_type = 'Lock'", + ) + .fetch_one(self.pool()) + .await + .expect("inspect lock waits"); + if waiting >= n { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("{n} sessions never queued on a lock")); + } +} + +async fn advisory(tx: &mut Transaction<'static, Postgres>, key: &str) { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(key) + .execute(&mut **tx) + .await + .expect("hold advisory lock"); +} + +async fn lock_row(tx: &mut Transaction<'static, Postgres>, channel: Uuid) { + sqlx::query("SELECT 1 FROM channels WHERE id = $1 FOR UPDATE") + .bind(channel) + .execute(&mut **tx) + .await + .expect("hold row lock"); +} + +fn task_record( + keys: &Keys, + channel: Uuid, + parent: Option, + op: &str, + prev: Option<&Event>, +) -> Event { + let mut tags = vec![ + vec!["ar".to_string(), "1".into()], + vec!["d".into(), task_d(channel).to_string()], + vec!["h".into(), channel.to_string()], + vec!["type".into(), "nexus.task".into()], + vec!["op".into(), op.into()], + ]; + let mut content = String::new(); + if op != "delete" { + tags.push(vec!["title".into(), "Task".into()]); + let mut body = json!({"v": 0, "channelId": channel.to_string()}); + if let Some(parent) = parent { + tags.push(vec!["parent".into(), parent.to_string()]); + body["parentChannelId"] = json!(parent.to_string()); + } + content = body.to_string(); + } + if let Some(prev) = prev { + tags.push(vec!["prev".into(), prev.id.to_hex()]); + } + EventBuilder::new(Kind::Custom(45010), content) + .tags(tags.into_iter().map(|t| Tag::parse(t).unwrap())) + .custom_created_at(stamp()) + .sign_with_keys(keys) + .unwrap() +} + +fn admin_event(keys: &Keys, kind: u16, target: Uuid, tags: &[&[&str]]) -> Event { + let mut built = vec![Tag::parse(["h", &target.to_string()]).unwrap()]; + built.extend(tags.iter().map(|t| Tag::parse(t.iter().copied()).unwrap())); + EventBuilder::new(Kind::Custom(kind), "") + .tags(built) + .custom_created_at(stamp()) + .sign_with_keys(keys) + .unwrap() +} + +fn edit(keys: &Keys, target: Uuid, tags: &[&[&str]]) -> Event { + admin_event(keys, 9002, target, tags) +} + +fn request_event(keys: &Keys, target: Uuid, archive: bool, scope: &str, expect: &str) -> Event { + let archived = if archive { "true" } else { "false" }; + edit( + keys, + target, + &[ + &["archived", archived], + &["scope", scope], + &["expect", expect], + ], + ) +} + +fn fence_event(keys: &Keys, target: Uuid, token: Option) -> Event { + let token = token.map_or("-".to_string(), |t| t.to_string()); + edit( + keys, + target, + &[&["lifecycle", "fence"], &["expect-target", &token]], + ) +} + +use LifecycleScope::{Subtree, Target}; + +/// Archive and restore of a family (A ← B ← C, A ← D): affected sets, +/// tokens, notices, duplicates, stale digests, state refusals, VD2-b, +/// the TTL exclusion and all-or-nothing permission. +/// Mutations: skipping the digest compare applies the stale request; +/// writing only the target leaves C active; dropping the per-channel +/// permission check archives B and C for an admin of A alone. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn guarded_archive_and_restore_journey() { + let f = Fixture::new("lifecycle-journey", true).await; + let (a, _) = f.task(None).await; + let (b, _) = f.task(Some(a)).await; + let (c, _) = f.task(Some(b)).await; + let (d, _) = f.task(Some(a)).await; + assert_eq!(f.projected(c).await, Some(Some(b))); + assert_eq!(f.projected(a).await, Some(None)); + let before = f.rows(&[a, b, c, d]).await; + assert!( + before.iter().all(|r| r.token.is_some()), + "create sets a token" + ); + + // A stale preview: B's token changes before the request arrives. + let stale = f.request(true, Subtree, b, Some(a), &[b, c]).await; + assert_eq!( + f.send(&f.owner, fence_event(&f.owner, b, before[1].token)) + .await, + Reply::Applied + ); + let stored = f.count(b, 9002).await; + assert_eq!(f.send(&f.owner, stale).await, conflict()); + assert_eq!(f.count(b, 9002).await, stored, "a refusal stores nothing"); + + // Archive B: the subtree B, C; A and D are untouched. + let archive_b = f.request(true, Subtree, b, Some(a), &[b, c]).await; + assert_eq!(f.send(&f.owner, archive_b.clone()).await, Reply::Applied); + let after = f.rows(&[a, b, c, d]).await; + assert_eq!( + after.iter().map(|r| r.archived).collect::>(), + [false, true, true, false] + ); + assert_eq!( + (after[0].token, after[3].token), + (before[0].token, before[3].token) + ); + assert_ne!(after[2].token, before[2].token); + assert_eq!( + ( + f.notices(b, "channel_archived").await, + f.notices(c, "channel_archived").await + ), + (1, 1) + ); + let notice: String = sqlx::query_scalar( + "SELECT content FROM events \ + WHERE channel_id = $1 AND kind = 40099 AND content::jsonb ->> 'type' = 'channel_archived'", + ) + .bind(c) + .fetch_one(f.pool()) + .await + .unwrap(); + let notice: serde_json::Value = serde_json::from_str(¬ice).unwrap(); + assert_eq!(notice["type"], "channel_archived"); + assert_eq!(notice["cascade_root"], json!(b.to_string())); + // A lost reply: the same event proves it applied. + assert_eq!(f.send(&f.owner, archive_b).await, Reply::Duplicate); + assert_eq!(f.rows(&[a, b, c, d]).await, after); + + // State refusals keep today's text. + let again = f.request(true, Subtree, b, Some(a), &[b, c]).await; + assert_eq!( + f.send(&f.owner, again).await, + invalid("invalid: channel is archived") + ); + let active = f.request(false, Target, d, Some(a), &[d]).await; + assert_eq!( + f.send(&f.owner, active).await, + invalid("invalid: channel is not archived") + ); + + // Restore C, target scope: C plus the archived ancestor B. + let restore_c = f.request(false, Target, c, Some(a), &[b, c]).await; + assert_eq!(f.send(&f.owner, restore_c).await, Reply::Applied); + assert!(f.rows(&[a, b, c, d]).await.iter().all(|r| !r.archived)); + + // Archive the whole family, then restore only A, then B's subtree. + let all = f.request(true, Subtree, a, None, &[a, b, c, d]).await; + assert_eq!(f.send(&f.owner, all).await, Reply::Applied); + let restore_a = f.request(false, Target, a, None, &[a]).await; + assert_eq!(f.send(&f.owner, restore_a).await, Reply::Applied); + assert_eq!( + f.rows(&[a, b, c, d]) + .await + .iter() + .map(|r| r.archived) + .collect::>(), + [false, true, true, true] + ); + let restore_b = f.request(false, Subtree, b, Some(a), &[b, c]).await; + assert_eq!(f.send(&f.owner, restore_b).await, Reply::Applied); + assert_eq!( + f.rows(&[a, b, c, d]) + .await + .iter() + .map(|r| r.archived) + .collect::>(), + [false, false, false, true] + ); + + // VD2-b and the TTL exclusion; other settings keep today's rules. + assert_eq!( + f.send(&f.owner, edit(&f.owner, b, &[&["archived", "true"]])) + .await, + invalid(GUARD_REQUIRED) + ); + assert_eq!( + f.send(&f.owner, edit(&f.owner, b, &[&["ttl", "3600"]])) + .await, + invalid("invalid: task channels cannot have a ttl") + ); + assert_eq!( + f.send(&f.owner, edit(&f.owner, b, &[&["topic", "still editable"]])) + .await, + Reply::Applied + ); + assert!(!f.row(b).await.archived); + + // Permission: a stranger is refused on S before the transaction, and + // an admin of A alone is refused for the whole subtree under the lock. + let stranger = Keys::generate(); + let expect = f.expect(true, Subtree, a, None, &[a, b, c]).await; + assert_eq!( + f.send( + &stranger, + request_event(&stranger, a, true, "subtree", &expect) + ) + .await, + invalid(NOT_AUTHORIZED_S) + ); + let admin = Keys::generate(); + f.add_admin(a, &admin).await; + let snapshot = f.rows(&[a, b, c, d]).await; + let stored = f.count(a, 9002).await; + let expect = f.expect(true, Subtree, a, None, &[a, b, c]).await; + assert_eq!( + f.send(&admin, request_event(&admin, a, true, "subtree", &expect)) + .await, + invalid(NOT_AUTHORIZED_ALL) + ); + assert_eq!(f.rows(&[a, b, c, d]).await, snapshot, "nothing changed"); + assert_eq!( + f.count(a, 9002).await, + stored, + "the refused request is not stored" + ); +} + +/// F replaces only the target's token when it still matches, on any +/// live channel, with or without a projection row. A permission refusal +/// is never a 409, even with a stale token. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn fence_journey() { + let f = Fixture::new("lifecycle-fence", true).await; + let (task, _) = f.task(None).await; + let plain = f.channel().await; + + // A row from before the token column: NULL, fenced with "-". + sqlx::query("UPDATE channels SET lifecycle_token = NULL WHERE id = $1") + .bind(plain) + .execute(f.pool()) + .await + .unwrap(); + let fence = fence_event(&f.owner, plain, None); + assert_eq!(f.send(&f.owner, fence.clone()).await, Reply::Applied); + let fenced = f.row(plain).await; + assert!(fenced.token.is_some()); + assert_eq!(f.send(&f.owner, fence).await, Reply::Duplicate); + assert_eq!(f.row(plain).await, fenced); + assert_eq!( + f.send(&f.owner, fence_event(&f.owner, plain, None)).await, + conflict() + ); + + // On a task channel and on an archived channel; nothing else changes. + let before = f.row(task).await; + assert_eq!( + f.send(&f.owner, fence_event(&f.owner, task, before.token)) + .await, + Reply::Applied + ); + let after = f.row(task).await; + assert_ne!(after.token, before.token); + assert_eq!((after.archived, after.deleted), (false, false)); + assert_eq!( + f.notices(task, "channel_archived").await, + 0, + "a fence posts no notice" + ); + let archive = f.request(true, Subtree, task, None, &[task]).await; + assert_eq!(f.send(&f.owner, archive).await, Reply::Applied); + let archived = f.row(task).await; + assert_eq!( + f.send(&f.owner, fence_event(&f.owner, task, archived.token)) + .await, + Reply::Applied + ); + assert!(f.row(task).await.archived); + + // A member without the archive right gets 400 for a stale and for a + // current token alike. + let member = Keys::generate(); + f.state + .db + .add_member( + f.tenant.community(), + plain, + &member.public_key().to_bytes(), + MemberRole::Member, + None, + ) + .await + .unwrap(); + for token in [None, f.row(plain).await.token] { + assert_eq!( + f.send(&member, fence_event(&member, plain, token)).await, + invalid(NOT_AUTHORIZED_S) + ); + } +} + +/// Any guard tag makes the whole guard mandatory: each malformed shape is +/// refused, on a task channel and on a plain one, and never applies as an +/// unguarded edit. A well-formed request without a projection row is a +/// conflict, never a fall-through. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn malformed_guards_never_fall_through() { + let f = Fixture::new("lifecycle-malformed", true).await; + let (task, _) = f.task(None).await; + let plain = f.channel().await; + let digest = "0".repeat(64); + let upper = "A".repeat(64); + for target in [task, plain] { + let h = target.to_string(); + let cases: Vec<(&str, Vec>)> = vec![ + ( + "no expect", + vec![vec!["archived", "true"], vec!["scope", "subtree"]], + ), + ( + "archive target scope", + vec![ + vec!["archived", "true"], + vec!["scope", "target"], + vec!["expect", &digest], + ], + ), + ( + "uppercase expect", + vec![ + vec!["archived", "true"], + vec!["scope", "subtree"], + vec!["expect", &upper], + ], + ), + ( + "fence with archived", + vec![ + vec!["lifecycle", "fence"], + vec!["expect-target", "-"], + vec!["archived", "true"], + ], + ), + ( + "unknown lifecycle", + vec![vec!["lifecycle", "settle"], vec!["expect-target", "-"]], + ), + ( + "fence without expect-target", + vec![vec!["lifecycle", "fence"]], + ), + ( + "expect-target alone", + vec![vec!["expect-target", "-"], vec!["archived", "true"]], + ), + ( + "settings tag", + vec![ + vec!["archived", "true"], + vec!["scope", "subtree"], + vec!["expect", &digest], + vec!["name", "renamed"], + ], + ), + ( + "second h", + vec![ + vec!["h", &h], + vec!["archived", "true"], + vec!["scope", "subtree"], + vec!["expect", &digest], + ], + ), + ( + "archived arity", + vec![ + vec!["archived", "true", "x"], + vec!["scope", "subtree"], + vec!["expect", &digest], + ], + ), + ( + "scope arity", + vec![ + vec!["archived", "true"], + vec!["scope", "subtree", "x"], + vec!["expect", &digest], + ], + ), + ( + "repeated expect", + vec![ + vec!["archived", "true"], + vec!["scope", "subtree"], + vec!["expect", &digest], + vec!["expect", &digest], + ], + ), + ( + "no archived", + vec![vec!["scope", "subtree"], vec!["expect", &digest]], + ), + ]; + for (case, tags) in cases { + let tags: Vec<&[&str]> = tags.iter().map(Vec::as_slice).collect(); + let before = f.row(target).await; + let reply = f.send(&f.owner, edit(&f.owner, target, &tags)).await; + assert!( + matches!(&reply, Reply::Invalid(m) if m.starts_with("invalid: ")), + "{case} on {target}: {reply:?}" + ); + assert_eq!(f.row(target).await, before, "{case}"); + } + // An `h` with extra elements is malformed too. + let event = EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", &h, "x"]).unwrap(), + Tag::parse(["archived", "true"]).unwrap(), + Tag::parse(["scope", "subtree"]).unwrap(), + Tag::parse(["expect", &digest]).unwrap(), + ]) + .custom_created_at(stamp()) + .sign_with_keys(&f.owner) + .unwrap(); + assert!(matches!(f.send(&f.owner, event).await, Reply::Invalid(_))); + assert_eq!( + f.count(target, 9002).await, + 0, + "no malformed guard is stored" + ); + } + // Well formed, but the plain channel has no projection row. + let request = f.request(true, Subtree, plain, None, &[plain]).await; + assert_eq!(f.send(&f.owner, request).await, conflict()); + assert!(!f.row(plain).await.archived); +} + +/// The flag gates only new parent edges (and the capability). With it +/// off, existing projection rows are kept and maintained, guards stay +/// enforced and a new root is not a task channel (no adoption bridge). +#[tokio::test] +#[ignore = "requires Postgres"] +async fn flag_off_keeps_the_rollback_floor() { + let on = Fixture::new("lifecycle-floor", true).await; + let (a, a_record) = on.task(None).await; + let (b, b_record) = on.task(Some(a)).await; + let (other, _) = on.task(None).await; + let off = on.with_guard(false); + const GUARD_OFF: &str = "invalid: nexus.task parent requires the lifecycle guard"; + + // A root written with the flag off is accepted but not projected, so + // it keeps today's unguarded archive. + let (solo, _) = off.task(None).await; + assert_eq!(off.projected(solo).await, None); + assert_eq!( + off.send(&off.owner, edit(&off.owner, solo, &[&["archived", "true"]])) + .await, + Reply::Applied + ); + // No new parent edge with the flag off. + let fresh = off.channel().await; + assert_eq!( + off.send( + &off.owner, + task_record(&off.owner, fresh, Some(a), "create", None) + ) + .await, + invalid(GUARD_OFF) + ); + assert_eq!(off.projected(fresh).await, None); + // An existing edge survives a revision; a changed one is refused. + let b_update = task_record(&off.owner, b, Some(a), "update", Some(&b_record)); + assert_eq!(off.send(&off.owner, b_update.clone()).await, Reply::Applied); + assert_eq!( + off.send( + &off.owner, + task_record(&off.owner, b, Some(other), "update", Some(&b_update)) + ) + .await, + invalid(GUARD_OFF) + ); + assert_eq!(off.projected(b).await, Some(Some(a))); + // A root revision keeps its projection row. + let a_update = task_record(&off.owner, a, None, "update", Some(&a_record)); + assert_eq!(off.send(&off.owner, a_update.clone()).await, Reply::Applied); + assert_eq!(off.projected(a).await, Some(None)); + // Guards stay enforced: VD2-b, child delete, and R itself. + assert_eq!( + off.send(&off.owner, edit(&off.owner, b, &[&["archived", "true"]])) + .await, + invalid(GUARD_REQUIRED) + ); + assert_eq!( + off.send( + &off.owner, + task_record(&off.owner, a, None, "delete", Some(&a_update)) + ) + .await, + invalid("invalid: a task with child tasks cannot be deleted") + ); + let archive = off.request(true, Subtree, a, None, &[a, b]).await; + assert_eq!(off.send(&off.owner, archive).await, Reply::Applied); + assert!(off.rows(&[a, b]).await.iter().all(|r| r.archived)); +} + +/// A child attach and an archive of its parent's family serialize on the +/// family lock in both orders: the archive never leaves an active child +/// under an archived parent. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn child_attach_and_archive_serialize_in_both_orders() { + let f = Fixture::new("lifecycle-attach", true).await; + let (a, _) = f.task(None).await; + let (b, _) = f.task(Some(a)).await; + let (c, _) = f.task(Some(b)).await; + + // Attach first: the attach holds the family while it waits on B's row; + // the archive, previewed without the new child, queues on the family. + let e = f.channel().await; + let archive = f.request(true, Subtree, b, Some(a), &[b, c]).await; + let mut holder = f.holder().await; + lock_row(&mut holder, b).await; + let attach = f.spawn(&f.owner, task_record(&f.owner, e, Some(b), "create", None)); + f.waiters(1).await; + let archived = f.spawn(&f.owner, archive); + f.waiters(2).await; + holder.commit().await.unwrap(); + assert_eq!(attach.await.unwrap(), Reply::Applied); + assert_eq!( + archived.await.unwrap(), + conflict(), + "the new child changes the digest" + ); + assert!(f.rows(&[b, c, e]).await.iter().all(|r| !r.archived)); + let archive = f.request(true, Subtree, b, Some(a), &[b, c, e]).await; + assert_eq!(f.send(&f.owner, archive).await, Reply::Applied); + assert!(f.rows(&[b, c, e]).await.iter().all(|r| r.archived)); + + // Archive first: the archive holds the family while it waits on D's + // row; the attach queues on the family and then finds D archived. + let (d, _) = f.task(Some(a)).await; + let g = f.channel().await; + let archive = f.request(true, Subtree, d, Some(a), &[d]).await; + let mut holder = f.holder().await; + lock_row(&mut holder, d).await; + let archived = f.spawn(&f.owner, archive); + f.waiters(1).await; + let attach = f.spawn(&f.owner, task_record(&f.owner, g, Some(d), "create", None)); + f.waiters(2).await; + holder.commit().await.unwrap(); + assert_eq!(archived.await.unwrap(), Reply::Applied); + assert_eq!( + attach.await.unwrap(), + invalid("invalid: parent must be a live task") + ); + assert_eq!(f.projected(g).await, None); +} + +/// Overlapping requests and stale replays. The second of two overlapping +/// archives, previewed before the first applied, is a conflict. A stored +/// request replays as a duplicate; once its event is cleaned up it is a +/// conflict, never a second archive. A fence settles a request whose reply +/// was lost while it is still in flight. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn overlapping_and_stale_requests_never_apply_twice() { + let f = Fixture::new("lifecycle-overlap", true).await; + let (a, _) = f.task(None).await; + let (b, _) = f.task(Some(a)).await; + let (c, _) = f.task(Some(b)).await; + let (d, _) = f.task(Some(a)).await; + + let inner = f.request(true, Subtree, b, Some(a), &[b, c]).await; + let outer = f.request(true, Subtree, a, None, &[a, b, c, d]).await; + let mut holder = f.holder().await; + lock_row(&mut holder, c).await; + let first = f.spawn(&f.owner, inner.clone()); + f.waiters(1).await; + let second = f.spawn(&f.owner, outer); + f.waiters(2).await; + holder.commit().await.unwrap(); + assert_eq!(first.await.unwrap(), Reply::Applied); + assert_eq!(second.await.unwrap(), conflict()); + assert_eq!( + f.rows(&[a, b, c, d]) + .await + .iter() + .map(|r| r.archived) + .collect::>(), + [false, true, true, false] + ); + + // Restore B's subtree, then replay the archive. + let restore = f.request(false, Subtree, b, Some(a), &[b, c]).await; + assert_eq!(f.send(&f.owner, restore).await, Reply::Applied); + assert_eq!(f.send(&f.owner, inner.clone()).await, Reply::Duplicate); + assert!(!f.row(b).await.archived); + // Retention removed the stored event: the replay is a conflict. + sqlx::query("DELETE FROM events WHERE id = $1") + .bind(inner.id.as_bytes().as_slice()) + .execute(f.pool()) + .await + .unwrap(); + assert_eq!(f.send(&f.owner, inner).await, conflict()); + assert!(f.rows(&[b, c]).await.iter().all(|r| !r.archived)); + + // A request queued on B's gate; the client gave up, read B's token + // and fenced it. The fence takes no gate, so it commits first and the + // queued request can no longer apply. + let pending = f.request(true, Subtree, b, Some(a), &[b, c]).await; + let mut holder = f.holder().await; + advisory(&mut holder, &f.key("buzz_channel_gate", b)).await; + let queued = f.spawn(&f.owner, pending); + f.waiters(1).await; + let read = f + .state + .db + .lifecycle_state(f.tenant.community(), &f.owner.public_key().to_bytes(), &[b]) + .await + .unwrap(); + assert_eq!(read[0].lifecycle_token, f.row(b).await.token); + assert_eq!( + f.send(&f.owner, fence_event(&f.owner, b, read[0].lifecycle_token)) + .await, + Reply::Applied + ); + holder.commit().await.unwrap(); + assert_eq!(queued.await.unwrap(), conflict()); + assert!(f.rows(&[b, c]).await.iter().all(|r| !r.archived)); +} + +/// F and a TTL change on the same channel serialize in both orders +/// without a deadlock, and F's stored event refreshes the TTL deadline. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn fence_and_ttl_change_serialize_in_both_orders() { + let f = Fixture::new("lifecycle-ttl", true).await; + let channel = f.channel().await; + let deadline = |f: Fixture| async move { + sqlx::query_scalar::<_, Option>>( + "SELECT ttl_deadline FROM channels WHERE id = $1", + ) + .bind(channel) + .fetch_one(f.pool()) + .await + .unwrap() + }; + for (ttl, fence_first) in [("3600", false), ("7200", true)] { + let token = f.row(channel).await.token; + let mut holder = f.holder().await; + lock_row(&mut holder, channel).await; + let change = edit(&f.owner, channel, &[&["ttl", ttl]]); + let fence = fence_event(&f.owner, channel, token); + let (first, second) = if fence_first { + (fence, change) + } else { + (change, fence) + }; + let first = f.spawn(&f.owner, first); + f.waiters(1).await; + let second = f.spawn(&f.owner, second); + f.waiters(2).await; + holder.commit().await.unwrap(); + assert_eq!( + first.await.unwrap(), + Reply::Applied, + "fence_first={fence_first}" + ); + assert_eq!( + second.await.unwrap(), + Reply::Applied, + "fence_first={fence_first}" + ); + let ttl_seconds: Option = + sqlx::query_scalar("SELECT ttl_seconds FROM channels WHERE id = $1") + .bind(channel) + .fetch_one(f.pool()) + .await + .unwrap(); + assert_eq!(ttl_seconds.map(|s| s.to_string()).as_deref(), Some(ttl)); + assert_ne!(f.row(channel).await.token, token); + } + let before = deadline(f.clone()) + .await + .expect("TTL channel has a deadline"); + let token = f.row(channel).await.token; + assert_eq!( + f.send(&f.owner, fence_event(&f.owner, channel, token)) + .await, + Reply::Applied + ); + assert!(deadline(f.clone()).await.expect("deadline") > before); +} + +/// Deleting a task with child tasks is refused, as a channel (9008) and +/// as a record; a leaf delete removes its projection row. A child attach +/// that commits while a 9008 waits on the channel's locks makes that 9008 +/// refused, and nothing is stored: the 9008 and its delete share one commit. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn task_delete_with_children_is_refused() { + let f = Fixture::new("lifecycle-delete", true).await; + let (a, a_record) = f.task(None).await; + let (b, b_record) = f.task(Some(a)).await; + let delete_group = |target| admin_event(&f.owner, 9008, target, &[]); + + assert_eq!( + f.send(&f.owner, delete_group(a)).await, + invalid("invalid: a task with child tasks cannot be deleted") + ); + assert_eq!(f.count(a, 9008).await, 0); + assert_eq!( + f.send( + &f.owner, + task_record(&f.owner, a, None, "delete", Some(&a_record)) + ) + .await, + invalid("invalid: a task with child tasks cannot be deleted") + ); + assert_eq!( + f.send( + &f.owner, + task_record(&f.owner, b, None, "delete", Some(&b_record)) + ) + .await, + Reply::Applied + ); + assert_eq!( + f.projected(b).await, + None, + "a record delete removes the row" + ); + assert_eq!(f.send(&f.owner, delete_group(a)).await, Reply::Applied); + assert!(f.row(a).await.deleted); + assert_eq!( + f.projected(a).await, + None, + "a channel delete removes the row" + ); + + // The attach commits while the delete waits on the channel's + // membership lock. + let (p, _) = f.task(None).await; + let child = f.channel().await; + let mut holder = f.holder().await; + advisory(&mut holder, &f.key("buzz_channel_membership", p)).await; + let deleting = f.spawn(&f.owner, delete_group(p)); + f.waiters(1).await; + assert_eq!( + f.send( + &f.owner, + task_record(&f.owner, child, Some(p), "create", None) + ) + .await, + Reply::Applied + ); + holder.commit().await.unwrap(); + assert_eq!( + deleting.await.unwrap(), + invalid("invalid: a task with child tasks cannot be deleted") + ); + assert_eq!(f.count(p, 9008).await, 0, "a refusal stores nothing"); + assert!(!f.row(p).await.deleted); + assert_eq!(f.projected(child).await, Some(Some(p))); +} + +/// A channel without a projection row keeps today's behavior (Solo). +#[tokio::test] +#[ignore = "requires Postgres"] +async fn plain_channels_keep_todays_behavior() { + let f = Fixture::new("lifecycle-solo", true).await; + let channel = f.channel().await; + for archived in ["true", "false"] { + assert_eq!( + f.send( + &f.owner, + edit(&f.owner, channel, &[&["archived", archived]]) + ) + .await, + Reply::Applied + ); + assert_eq!(f.row(channel).await.archived, archived == "true"); + } + assert_eq!(f.notices(channel, "channel_archived").await, 1); + assert_eq!(f.notices(channel, "channel_unarchived").await, 1); + assert_eq!( + f.send(&f.owner, edit(&f.owner, channel, &[&["ttl", "3600"]])) + .await, + Reply::Applied + ); + // A task record cannot attach to a TTL channel. + assert_eq!( + f.send( + &f.owner, + task_record(&f.owner, channel, None, "create", None) + ) + .await, + invalid("invalid: task channels cannot have a ttl") + ); + assert_eq!(f.projected(channel).await, None); + assert_eq!( + f.send(&f.owner, admin_event(&f.owner, 9008, channel, &[])) + .await, + Reply::Applied + ); + assert!(f.row(channel).await.deleted); + // The huddle path keeps a task channel active and archives a plain one. + let (task, _) = f.task(None).await; + let other = f.channel().await; + for (channel, archives) in [(task, false), (other, true)] { + let archived = f + .state + .db + .archive_channel(f.tenant.community(), channel) + .await + .unwrap(); + assert_eq!(archived, archives); + assert_eq!(f.row(channel).await.archived, archives); + } +} + +/// Join `channel`'s huddle as the owner, leave as its last peer and wait +/// until the relay's teardown has returned. +async fn join_and_leave_huddle(f: &Fixture, channel: Uuid) { + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind huddle listener"); + let addr = listener.local_addr().expect("huddle listener address"); + let (done_tx, mut done_rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + let assertion = buzz_auth::VerifiedAssertion::for_test( + Some(f.owner.public_key()), + vec![chrono::Utc::now() + chrono::Duration::hours(1)], + ); + let (state, tenant) = (Arc::clone(&f.state), f.tenant.clone()); + let app = axum::Router::new().route( + "/", + axum::routing::get(move |ws: axum::extract::ws::WebSocketUpgrade| { + let (state, tenant) = (Arc::clone(&state), tenant.clone()); + let (assertion, done_tx) = (assertion.clone(), done_tx.clone()); + async move { + ws.on_upgrade(move |socket| async move { + crate::audio::handler::handle_active_audio_connection( + socket, + state, + tenant, + channel, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), + Some(assertion), + chrono::Utc::now(), + None, + ) + .await; + let _ = done_tx.send(()); + }) + } + }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + + let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/")) + .await + .expect("connect huddle client"); + let next_json = |frame: Option>| match frame { + Some(Ok(Message::Text(text))) => { + serde_json::from_str::(&text).expect("huddle frame is JSON") + } + other => panic!("expected a text frame, got {other:?}"), + }; + let wait = std::time::Duration::from_secs(10); + let challenge = next_json( + tokio::time::timeout(wait, client.next()) + .await + .expect("challenge"), + ); + let auth = EventBuilder::new(Kind::Authentication, "") + .tags([ + Tag::parse(["relay", &format!("ws://{}", f.tenant.host())]).unwrap(), + Tag::parse(["challenge", challenge["challenge"].as_str().unwrap()]).unwrap(), + ]) + .sign_with_keys(&f.owner) + .unwrap(); + let auth = json!({ + "type": "auth", + "event": auth, + "parent_channel_id": null, + "protocol_version": 1, + }); + client + .send(Message::Text(auth.to_string().into())) + .await + .expect("send huddle auth"); + loop { + let frame = tokio::time::timeout(wait, client.next()) + .await + .expect("joined frame"); + if let Some(Ok(Message::Binary(_) | Message::Ping(_) | Message::Pong(_))) = frame { + continue; + } + let frame = next_json(frame); + if frame["type"] == "joined" { + break; + } + } + client.close(None).await.expect("leave the huddle"); + tokio::time::timeout(wait, done_rx.recv()) + .await + .expect("huddle teardown finished"); + server.abort(); + let _ = server.await; +} + +/// The last peer leaving a huddle that a channel hosts directly ends the +/// huddle (kind:48103, room removed) and archives that channel. A task +/// channel ends the huddle the same way but stays active. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn huddle_auto_end_leaves_a_task_channel_active() { + let f = Fixture::new("lifecycle-huddle", true).await; + let (task, _) = f.task(None).await; + let plain = f.channel().await; + for (channel, is_task) in [(plain, false), (task, true)] { + join_and_leave_huddle(&f, channel).await; + assert_eq!(f.count(channel, 48101).await, 1, "the peer joined"); + assert_eq!(f.count(channel, 48102).await, 1, "the peer left"); + assert_eq!(f.row(channel).await.archived, !is_task, "task={is_task}"); + assert_eq!(f.count(channel, 48103).await, 1, "task={is_task}"); + assert!( + f.state + .audio_rooms + .get(f.tenant.community(), channel) + .is_none(), + "task={is_task}: the room is removed" + ); + } +} diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index cb18b8525c8..c0260118bc6 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -32,6 +32,8 @@ pub mod identity_archive; pub mod imeta; /// Transport-neutral event ingestion pipeline. pub mod ingest; +#[cfg(test)] +mod lifecycle_postgres_tests; /// Community moderation authorization seam (capability helper). pub mod moderation_authz; /// Community moderation command handler (kinds 9040–9044). diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 61228f4e136..a97000924e9 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -673,12 +673,19 @@ pub async fn validate_admin_event( } } 9002 => { + // A lifecycle guard is all or nothing: a malformed guard is + // refused here and never falls through to an unguarded edit. + // Its state, digest and the permission on every other affected + // channel are decided in its atomic write. + let guarded = buzz_core::lifecycle::parse_lifecycle_guard(event) + .map_err(|reason| anyhow::anyhow!(reason))? + .is_some(); // EDIT_METADATA: require at least one recognized metadata tag. let has_recognized = event .tags .iter() .any(|t| is_metadata_tag(&t.kind().to_string())); - if !has_recognized { + if !guarded && !has_recognized { return Err(anyhow::anyhow!( "kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, t, join_role, unlisted, auto_join)" )); @@ -828,17 +835,19 @@ pub async fn validate_admin_event( } // name/about/archived/visibility/ttl/labels/join_role/unlisted - // require owner/admin; topic/purpose allow any member who may - // post; auto_join was decided above by relay role, so an event + // and every lifecycle guard require owner/admin on the `h` + // channel; topic/purpose allow any member who may post; + // auto_join was decided above by relay role, so an event // carrying only that tag needs neither. let has_member_tag = event .tags .iter() .any(|t| MEMBER_METADATA_TAGS.contains(&t.kind().to_string().as_str())); - let has_privileged_tag = event - .tags - .iter() - .any(|t| PRIVILEGED_METADATA_TAGS.contains(&t.kind().to_string().as_str())); + let has_privileged_tag = guarded + || event + .tags + .iter() + .any(|t| PRIVILEGED_METADATA_TAGS.contains(&t.kind().to_string().as_str())); if has_privileged_tag { if actor_has_channel_authority(state, tenant.community(), channel_id, &actor_bytes) .await? @@ -2169,6 +2178,12 @@ pub(crate) async fn persist_edit_metadata( ) -> anyhow::Result<(buzz_core::StoredEvent, bool)> { let channel_id = extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; + // Parsed again so a guard can never reach the unguarded path below. + if let Some(guard) = + buzz_core::lifecycle::parse_lifecycle_guard(event).map_err(|e| anyhow::anyhow!(e))? + { + return persist_lifecycle(tenant, event, state, channel_id, guard).await; + } let actor_bytes = event.pubkey.to_bytes().to_vec(); let actor_hex = hex::encode(&actor_bytes); @@ -2348,6 +2363,89 @@ pub(crate) async fn persist_edit_metadata( Ok((write.stored, true)) } +/// Persist a guarded lifecycle request (archive, restore or fence). Its +/// one transaction decides the state, the digest or token, and the +/// permission on every affected channel. After the commit this runs the +/// same live effects as an unguarded archive or restore, for each changed +/// channel: the notice fan-out, the restore resubscribe and discovery. Like +/// an unguarded archive, it evicts no subscription. +async fn persist_lifecycle( + tenant: &TenantContext, + event: &Event, + state: &Arc, + channel_id: Uuid, + guard: buzz_core::lifecycle::LifecycleGuard, +) -> anyhow::Result<(buzz_core::StoredEvent, bool)> { + use buzz_core::lifecycle::LifecycleGuard; + let write = match guard { + LifecycleGuard::Fence { expect_target } => { + state + .db + .apply_lifecycle_fence(tenant.community(), channel_id, event, expect_target) + .await? + } + LifecycleGuard::State { + archive, + scope, + expect, + } => { + let request = buzz_db::lifecycle::StateRequest { + target: channel_id, + event, + archive, + scope, + expect, + max_channels: state.config.lifecycle_max_subtree_channels, + }; + state + .db + .apply_lifecycle_state(tenant.community(), &request, &state.relay_keypair) + .await? + } + }; + if !write.inserted || write.changed.is_empty() { + return Ok((write.stored, write.inserted)); + } + let restore = matches!(guard, LifecycleGuard::State { archive: false, .. }); + let actor = event.pubkey.to_bytes().to_vec(); + // The target first, before the reply. The rest of the family runs in + // the background so a large subtree cannot delay the reply. + let mut changed = write.changed.into_iter(); + if let Some((channel, notice)) = changed.next() { + lifecycle_live_effects(tenant, state, channel, ¬ice, restore, &actor).await; + } + let rest: Vec<_> = changed.collect(); + if !rest.is_empty() { + let tenant = tenant.clone(); + let state = Arc::clone(state); + tokio::spawn(async move { + for (channel, notice) in rest { + lifecycle_live_effects(&tenant, &state, channel, ¬ice, restore, &actor).await; + } + }); + } + Ok((write.stored, true)) +} + +/// Post-commit effects of one channel's guarded archive or restore. Each +/// failure is logged; the next read sees the committed channel. +async fn lifecycle_live_effects( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + notice: &Event, + restore: bool, + actor: &[u8], +) { + fan_out_system_message(tenant, state, channel_id, notice).await; + if restore { + resubscribe_members_after_unarchive(tenant, state, channel_id, actor).await; + } + if let Err(e) = emit_group_discovery_events(tenant, state, channel_id).await { + warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed"); + } +} + /// Resubscribe connected agents after restore: archiving evicts their /// live subscriptions (CLOSED "channel access revoked") and unarchive /// otherwise emits no signal that makes a connected agent resubscribe. @@ -2704,6 +2802,27 @@ async fn handle_create_group( Ok(()) } +/// Store a kind:9008 and soft-delete its channel in one commit. A refusal +/// decided under the channel's locks stores nothing, so the client is told. +pub(crate) async fn persist_delete_group( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> Result<(buzz_core::StoredEvent, bool), buzz_db::DbError> { + let channel_id = extract_h_tag_channel(event) + .ok_or_else(|| buzz_db::DbError::InvalidData("missing h tag".into()))?; + let write = state + .db + .delete_channel_with_event(tenant.community(), channel_id, event) + .await?; + if write.inserted && !write.deleted { + warn!(channel = %channel_id, "channel already deleted or not found"); + } + Ok((write.stored, write.inserted)) +} + +/// The effects of a stored kind:9008 after its commit, which deleted the +/// channel (see [`persist_delete_group`]). async fn handle_delete_group( tenant: &TenantContext, event: &Event, @@ -2713,17 +2832,6 @@ async fn handle_delete_group( extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; let actor_bytes = event.pubkey.to_bytes().to_vec(); - // Soft-delete the channel. - let deleted = state - .db - .soft_delete_channel(tenant.community(), channel_id) - .await - .map_err(|e| anyhow::anyhow!("soft_delete_channel failed: {e}"))?; - - if !deleted { - warn!(channel = %channel_id, "channel already deleted or not found"); - } - // Clean up NIP-29 discovery events for the deleted group. if let Err(e) = state .db diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 0f880fb5e56..49b71902512 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -37,6 +37,10 @@ pub struct RelayInfo { /// Opt-in, host-bound private accessory API; separate from legacy NIP-RS. #[serde(skip_serializing_if = "Option::is_none")] pub buzz_v1: Option, + /// Guarded task archive and restore; present only when the guard and + /// `buzz_v1` are both enabled on a resolved host. + #[serde(skip_serializing_if = "Option::is_none")] + pub lifecycle_guard: Option, /// NIP-AR artifact query transport and enforced resource limits. pub artifacts: serde_json::Value, /// Relay operator's public key (hex), if published. @@ -283,6 +287,7 @@ impl RelayInfo { icon: icon.filter(|s| !s.is_empty()).map(|s| s.to_string()), read_state_snapshot: None, buzz_v1: None, + lifecycle_guard: None, artifacts: serde_json::json!({ "version": 1, "revision_kind": 45010, "removal_kind": 45011, "query": "/query", "count": "/count", @@ -414,6 +419,14 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st max_intents: MAX_INTENTS, eligible_kinds: ELIGIBLE_KINDS, }); + if state.config.lifecycle_guard_enabled { + info.lifecycle_guard = Some(serde_json::json!({ + "version": 1, + "state_path": crate::api::buzz_v1::LIFECYCLE_STATE_PATH, + "max_state_channels": buzz_db::lifecycle::MAX_STATE_CHANNELS, + "max_subtree_channels": state.config.lifecycle_max_subtree_channels, + })); + } } info.read_state_snapshot = Some(serde_json::json!({ "version": 1, diff --git a/migrations/0081_channel_lifecycle_token_and_task_parents.sql b/migrations/0081_channel_lifecycle_token_and_task_parents.sql new file mode 100644 index 00000000000..99ca3be3283 --- /dev/null +++ b/migrations/0081_channel_lifecycle_token_and_task_parents.sql @@ -0,0 +1,26 @@ +-- Guarded task archive and restore (NEXUS-4111). +-- +-- `channels.lifecycle_token` is nullable with no default, so adding it changes +-- only the catalog: no table rewrite and no backfill. NULL means the row has +-- not changed lifecycle state since this migration. The relay writes a fresh +-- random value, generated in Rust, on every create, archive, restore, delete, +-- reap and lifecycle fence. Clients compare tokens for equality only. +-- +-- `artifact_parents` projects the hierarchy of relay-validated `nexus.task` +-- records: one row per live task head, written in the record's +-- compare-and-swap transaction. `parent_channel_id` is NULL for a root. Old +-- relays never read or write it. +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; + +ALTER TABLE channels ADD COLUMN lifecycle_token UUID; + +CREATE TABLE artifact_parents ( + community_id UUID NOT NULL, + child_channel_id UUID NOT NULL, + parent_channel_id UUID, + artifact_id UUID NOT NULL, + PRIMARY KEY (community_id, child_channel_id) +); +CREATE INDEX artifact_parents_parent ON artifact_parents (community_id, parent_channel_id) + WHERE parent_channel_id IS NOT NULL; diff --git a/schema/schema.sql b/schema/schema.sql index 710345e5275..adf30bf5c81 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -217,3 +217,4 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \i seeds/storage_accounting_snapshots.sql \i tables/public/nip_fi_profile_heads.sql +\i tables/public/artifact_parents.sql diff --git a/schema/tables/public/artifact_parents.sql b/schema/tables/public/artifact_parents.sql new file mode 100644 index 00000000000..ddcd20473a8 --- /dev/null +++ b/schema/tables/public/artifact_parents.sql @@ -0,0 +1,12 @@ +-- Hierarchy projection of relay-validated `nexus.task` records: one row per +-- live task head, written in the record's compare-and-swap transaction. +-- `parent_channel_id` is NULL for a root. +CREATE TABLE artifact_parents ( + community_id UUID NOT NULL, + child_channel_id UUID NOT NULL, + parent_channel_id UUID, + artifact_id UUID NOT NULL, + PRIMARY KEY (community_id, child_channel_id) +); +CREATE INDEX artifact_parents_parent ON artifact_parents (community_id, parent_channel_id) + WHERE parent_channel_id IS NOT NULL; diff --git a/schema/tables/public/channels.sql b/schema/tables/public/channels.sql index 859bd3d3d3d..b28ec4fe854 100644 --- a/schema/tables/public/channels.sql +++ b/schema/tables/public/channels.sql @@ -42,6 +42,10 @@ CREATE TABLE channels ( -- channel listings for non-members. Matters only while the channel is open. unlisted BOOLEAN NOT NULL DEFAULT FALSE, auto_join BOOLEAN NOT NULL DEFAULT false, + -- A fresh random value on every lifecycle change (create, archive, + -- restore, delete, reap, fence). NULL: unchanged since migration 0081. + -- Compared for equality only. + lifecycle_token UUID, PRIMARY KEY (community_id, id), CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) );