diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d591..e3c232a67 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -369,6 +369,20 @@ pub const KIND_MODERATION_UNTIMEOUT: u32 = 9043; /// `handlers/moderation_commands.rs` for the pinned vocabulary). pub const KIND_MODERATION_RESOLVE_REPORT: u32 = 9044; +// Owner-scoped channel-section workspace commands. These user-signed commands +// are consumed transactionally by the relay and are never stored as ordinary +// events. Separate kinds make the authorized mutation scope unambiguous. +/// Section workspace: revision-zero import of a legacy kind-30078 store. +pub const KIND_SECTION_WORKSPACE_IMPORT: u32 = 9050; +/// Section workspace: owner-only grant or role update. +pub const KIND_SECTION_WORKSPACE_GRANT: u32 = 9051; +/// Section workspace: owner-only revoke with content-key epoch rotation. +pub const KIND_SECTION_WORKSPACE_REVOKE: u32 = 9052; +/// Section workspace: assign or unassign one channel (stage 2). +pub const KIND_SECTION_WORKSPACE_MOVE: u32 = 9053; +/// Section workspace: create, rename, delete, or reorder sections (stage 3). +pub const KIND_SECTION_WORKSPACE_MANAGE: u32 = 9054; + /// Returns `true` for community moderation command kinds (9040–9044). /// /// The canonical route check — use this instead of scattering @@ -447,6 +461,8 @@ pub const KIND_WORKFLOW_DEF: u32 = 30620; /// the latest event is always the authoritative hidden set. The relay knows /// `hidden_at` per viewer; this is the only Nostr-visible projection of it. pub const KIND_DM_VISIBILITY: u32 = 30622; +/// Relay-signed current projection of an owner-scoped section workspace. +pub const KIND_SECTION_WORKSPACE_PROJECTION: u32 = 30623; /// Lower bound of the NIP-33 parameterized replaceable range (30000–39999). pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000; @@ -673,6 +689,11 @@ pub const ALL_KINDS: &[u32] = &[ KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNTIMEOUT, KIND_MODERATION_RESOLVE_REPORT, + KIND_SECTION_WORKSPACE_IMPORT, + KIND_SECTION_WORKSPACE_GRANT, + KIND_SECTION_WORKSPACE_REVOKE, + KIND_SECTION_WORKSPACE_MOVE, + KIND_SECTION_WORKSPACE_MANAGE, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_CHANGE_ROLE, @@ -712,6 +733,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_CHANNEL_SUMMARY, KIND_PRESENCE_SNAPSHOT, KIND_DM_VISIBILITY, + KIND_SECTION_WORKSPACE_PROJECTION, KIND_DM_OPEN, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 7424915c8..c356b5b6f 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -36,6 +36,8 @@ pub mod presence; pub mod private_managed_agent; /// Canonical relay runtime identities. pub mod relay; +/// Owner-scoped channel-section workspace protocol. +pub mod section_workspace; /// Tenant identity — the server-resolved community key carried on scoped paths. pub mod tenant; /// Schnorr signature and event ID verification. diff --git a/crates/buzz-core/src/section_workspace.rs b/crates/buzz-core/src/section_workspace.rs new file mode 100644 index 000000000..2102aec70 --- /dev/null +++ b/crates/buzz-core/src/section_workspace.rs @@ -0,0 +1,772 @@ +//! Owner-scoped channel-section workspace protocol types. +//! +//! The relay is authoritative for structure, assignments, grants, revisions, +//! and command ordering. Human-readable labels and icons remain opaque +//! ciphertext encrypted by clients with a per-workspace content key. + +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use thiserror::Error; +use uuid::Uuid; + +/// Maximum active sections in one workspace. +pub const MAX_SECTIONS: usize = 100; +/// Maximum channel assignments in one workspace. +pub const MAX_ASSIGNMENTS: usize = 1_000; +/// Maximum active delegate grants in one workspace. +pub const MAX_GRANTS: usize = 256; +/// Maximum bytes in one encrypted label or icon field. +pub const MAX_ENCRYPTED_METADATA_BYTES: usize = 65_535; +/// Maximum bytes in one pairwise-wrapped content-key envelope. +pub const MAX_KEY_ENVELOPE_BYTES: usize = 4_096; +/// Wire schema version implemented by this protocol. +pub const SECTION_WORKSPACE_VERSION: u32 = 1; + +/// Delegated authority over an owner's section workspace. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SectionWorkspaceRole { + /// Read and decrypt the current projection. + Viewer, + /// Viewer rights plus assign and unassign channels. + Mover, + /// Mover rights plus section create, rename, delete, and reorder. + Manager, +} + +impl SectionWorkspaceRole { + /// Stable database and wire spelling. + pub const fn as_str(self) -> &'static str { + match self { + Self::Viewer => "viewer", + Self::Mover => "mover", + Self::Manager => "manager", + } + } +} + +/// An encrypted section supplied during revision-zero migration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWorkspaceImportSection { + /// Existing client section UUID, preserved across migration. + pub id: Uuid, + /// Zero-based display order. + pub rank: u32, + /// Authenticated ciphertext for the section label. + pub encrypted_label: String, + /// Optional authenticated ciphertext for the section icon. + pub encrypted_icon: Option, +} + +/// A channel-to-section assignment supplied during migration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWorkspaceImportAssignment { + /// Channel UUID in the same server-resolved community. + pub channel_id: Uuid, + /// Destination section UUID in this import. + pub section_id: Uuid, +} + +/// Revision-zero import command body. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWorkspaceImport { + /// Wire schema version. + pub version: u32, + /// Stable retry identity independent of the signed transport event id. + pub action_id: Uuid, + /// Legacy kind-30078 event id whose plaintext was migrated. + pub source_event_id: String, + /// Lowercase SHA-256 hex of the canonical legacy plaintext. + pub source_hash: String, + /// Initial content-key epoch. Version 1 imports require epoch 1. + pub key_epoch: u64, + /// Owner's pairwise-wrapped content-key envelope. + pub owner_key_envelope: String, + /// Ordered encrypted sections. + pub sections: Vec, + /// Channel assignments. + pub assignments: Vec, +} + +/// Wire projection migration marker. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWorkspaceProjectionMigration { + /// Imported legacy event ID. + pub source_event_id: String, + /// Canonical legacy plaintext hash. + pub source_hash: String, +} + +/// One section in the relay's reader-specific projection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWorkspaceProjectedSection { + /// Stable section identifier. + pub id: Uuid, + /// Zero-based display order. + pub rank: u32, + /// Authenticated label ciphertext. + pub encrypted_label: String, + /// Optional authenticated icon ciphertext. + pub encrypted_icon: Option, +} + +/// One assignment in the relay's reader-specific projection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWorkspaceProjectedAssignment { + /// Assigned channel identifier. + pub channel_id: Uuid, + /// Destination section identifier. + pub section_id: Uuid, + /// Per-assignment revision. + pub revision: u64, +} + +/// Complete reader-specific current projection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWorkspaceProjection { + /// Wire schema version. + pub version: u32, + /// Workspace owner as lowercase hex. + pub owner_pubkey: String, + /// Monotonic workspace revision. + pub revision: u64, + /// Monotonic layout revision. + pub layout_revision: u64, + /// Current content-key epoch. + pub key_epoch: u64, + /// Imported legacy source marker. + pub migration: SectionWorkspaceProjectionMigration, + /// Pairwise envelope for this projection reader. + pub reader_key_envelope: String, + /// Ordered active sections. + pub sections: Vec, + /// Current channel assignments. + pub assignments: Vec, +} + +/// Client action for an incoming projection revision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectionRevisionAction { + /// Persist and render the projection. + Accept, + /// Discard the delta and fetch the current full projection. + Refetch, + /// Discard a stale or duplicate projection. + Ignore, +} + +/// Apply the frozen projection revision rule. +pub fn projection_revision_action( + previous_revision: Option, + incoming_revision: u64, +) -> ProjectionRevisionAction { + match previous_revision { + None => ProjectionRevisionAction::Accept, + Some(previous) if incoming_revision <= previous => ProjectionRevisionAction::Ignore, + Some(previous) if incoming_revision == previous.saturating_add(1) => { + ProjectionRevisionAction::Accept + } + Some(_) => ProjectionRevisionAction::Refetch, + } +} + +/// Owner-only grant command body. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWorkspaceGrantCommand { + /// Wire schema version. + pub version: u32, + /// Stable retry identity. + pub action_id: Uuid, + /// Exact delegate pubkey as lowercase hex. + pub actor_pubkey: String, + /// Granted authority. + pub role: SectionWorkspaceRole, + /// Current workspace key epoch. + pub key_epoch: u64, + /// Pairwise NIP-44 wrapped content key. + pub key_envelope: String, +} + +/// One metadata replacement in a revoke-and-rotate command. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWorkspaceRotatedSection { + /// Existing section UUID. + pub section_id: Uuid, + /// Metadata envelope encrypted under the new workspace key. + pub encrypted_label: String, + /// Optional icon envelope encrypted under the new workspace key. + pub encrypted_icon: Option, +} + +/// One reader envelope in a revoke-and-rotate command. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWorkspaceReaderEnvelope { + /// Exact reader pubkey as lowercase hex. + pub reader_pubkey: String, + /// Pairwise NIP-44 wrapped content key. + pub key_envelope: String, +} + +/// Owner-only revoke-and-rotate command body. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWorkspaceRevokeCommand { + /// Wire schema version. + pub version: u32, + /// Stable retry identity. + pub action_id: Uuid, + /// Revoked delegate pubkey as lowercase hex. + pub actor_pubkey: String, + /// Exactly the current epoch plus one. + pub new_key_epoch: u64, + /// Every active section, re-encrypted exactly once. + pub sections: Vec, + /// Owner and every remaining reader, exactly once. + pub envelopes: Vec, +} + +/// Produce the byte-exact NIP-SW canonical JSON profile. +/// +/// Objects are recursively key-sorted, arrays retain order, no insignificant +/// whitespace is emitted, and only integer JSON numbers are accepted. Strings +/// use serde_json's UTF-8/escape encoding. Duplicate keys must be rejected by +/// the caller's strict typed deserializer before canonicalization. +pub fn canonical_json( + value: &serde_json::Value, +) -> Result { + fn write( + value: &serde_json::Value, + out: &mut String, + ) -> Result<(), SectionWorkspaceValidationError> { + match value { + serde_json::Value::Null => out.push_str("null"), + serde_json::Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }), + serde_json::Value::Number(v) if v.is_i64() || v.is_u64() => { + out.push_str(&v.to_string()) + } + serde_json::Value::Number(_) => { + return Err(SectionWorkspaceValidationError::Invalid( + "non_integer_number", + )) + } + serde_json::Value::String(v) => out.push_str( + &serde_json::to_string(v) + .map_err(|_| SectionWorkspaceValidationError::Invalid("json_string"))?, + ), + serde_json::Value::Array(values) => { + out.push('['); + for (index, value) in values.iter().enumerate() { + if index != 0 { + out.push(','); + } + write(value, out)?; + } + out.push(']'); + } + serde_json::Value::Object(values) => { + out.push('{'); + let mut keys = values.keys().collect::>(); + keys.sort_unstable(); + for (index, key) in keys.into_iter().enumerate() { + if index != 0 { + out.push(','); + } + out.push_str( + &serde_json::to_string(key) + .map_err(|_| SectionWorkspaceValidationError::Invalid("json_key"))?, + ); + out.push(':'); + write(&values[key], out)?; + } + out.push('}'); + } + } + Ok(()) + } + let mut out = String::new(); + write(value, &mut out)?; + Ok(out) +} + +/// Validate the exact routing-tag grammar shared by command handlers. +pub fn validate_command_tags( + tags: &[Vec], + owner_pubkey: &str, + actor_pubkey: Option<&str>, + action_id: Uuid, +) -> Result<(), SectionWorkspaceValidationError> { + let mut owner = None; + let mut actor = None; + let mut action = None; + for tag in tags { + if tag.len() != 2 { + return Err(SectionWorkspaceValidationError::Invalid("tag_grammar")); + } + let slot = match tag[0].as_str() { + "p" => &mut owner, + "actor" if actor_pubkey.is_some() => &mut actor, + "action" => &mut action, + _ => return Err(SectionWorkspaceValidationError::Invalid("tag_grammar")), + }; + if slot.replace(tag[1].as_str()).is_some() { + return Err(SectionWorkspaceValidationError::Invalid("tag_grammar")); + } + } + let expected_action = action_id.to_string(); + if owner != Some(owner_pubkey) + || actor != actor_pubkey + || action != Some(expected_action.as_str()) + { + return Err(SectionWorkspaceValidationError::Invalid("tag_grammar")); + } + Ok(()) +} + +/// Protocol validation failures independent of any transport or database. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SectionWorkspaceValidationError { + /// A collection exceeds its wire bound. + #[error("{field} exceeds maximum {maximum}")] + TooMany { + /// Field name. + field: &'static str, + /// Maximum accepted count. + maximum: usize, + }, + /// A required identifier is nil, malformed, or duplicated. + #[error("invalid {0}")] + Invalid(&'static str), + /// An encrypted field is empty or exceeds its byte bound. + #[error("invalid encrypted {0}")] + InvalidCiphertext(&'static str), +} + +impl SectionWorkspaceGrantCommand { + /// Validate semantic fields after strict decoding. + pub fn validate(&self) -> Result<(), SectionWorkspaceValidationError> { + if self.version != SECTION_WORKSPACE_VERSION { + return Err(SectionWorkspaceValidationError::Invalid("version")); + } + if self.action_id.is_nil() { + return Err(SectionWorkspaceValidationError::Invalid("action_id")); + } + validate_hex_32(&self.actor_pubkey, "actor_pubkey")?; + if self.key_epoch == 0 { + return Err(SectionWorkspaceValidationError::Invalid("key_epoch")); + } + validate_ciphertext(&self.key_envelope, MAX_KEY_ENVELOPE_BYTES, "key_envelope") + } +} + +impl SectionWorkspaceRevokeCommand { + /// Validate semantic fields after strict decoding. + pub fn validate(&self) -> Result<(), SectionWorkspaceValidationError> { + if self.version != SECTION_WORKSPACE_VERSION { + return Err(SectionWorkspaceValidationError::Invalid("version")); + } + if self.action_id.is_nil() { + return Err(SectionWorkspaceValidationError::Invalid("action_id")); + } + validate_hex_32(&self.actor_pubkey, "actor_pubkey")?; + if self.new_key_epoch == 0 { + return Err(SectionWorkspaceValidationError::Invalid("new_key_epoch")); + } + for section in &self.sections { + if section.section_id.is_nil() { + return Err(SectionWorkspaceValidationError::Invalid("section_id")); + } + validate_ciphertext( + §ion.encrypted_label, + MAX_ENCRYPTED_METADATA_BYTES, + "label", + )?; + if let Some(icon) = §ion.encrypted_icon { + validate_ciphertext(icon, MAX_ENCRYPTED_METADATA_BYTES, "icon")?; + } + } + for envelope in &self.envelopes { + validate_hex_32(&envelope.reader_pubkey, "reader_pubkey")?; + validate_ciphertext( + &envelope.key_envelope, + MAX_KEY_ENVELOPE_BYTES, + "key_envelope", + )?; + } + Ok(()) + } +} + +impl SectionWorkspaceProjection { + /// Validate the wire version before a projection is accepted. + pub fn validate(&self) -> Result<(), SectionWorkspaceValidationError> { + if self.version != SECTION_WORKSPACE_VERSION { + return Err(SectionWorkspaceValidationError::Invalid("version")); + } + Ok(()) + } +} + +impl SectionWorkspaceImport { + /// Validate bounds and referential shape before persistence. + pub fn validate(&self) -> Result<(), SectionWorkspaceValidationError> { + if self.version != SECTION_WORKSPACE_VERSION { + return Err(SectionWorkspaceValidationError::Invalid("version")); + } + if self.sections.len() > MAX_SECTIONS { + return Err(SectionWorkspaceValidationError::TooMany { + field: "sections", + maximum: MAX_SECTIONS, + }); + } + if self.assignments.len() > MAX_ASSIGNMENTS { + return Err(SectionWorkspaceValidationError::TooMany { + field: "assignments", + maximum: MAX_ASSIGNMENTS, + }); + } + if self.action_id.is_nil() { + return Err(SectionWorkspaceValidationError::Invalid("action_id")); + } + if self.key_epoch != 1 { + return Err(SectionWorkspaceValidationError::Invalid("key_epoch")); + } + validate_hex_32(&self.source_event_id, "source_event_id")?; + validate_hex_32(&self.source_hash, "source_hash")?; + validate_ciphertext( + &self.owner_key_envelope, + MAX_KEY_ENVELOPE_BYTES, + "owner_key_envelope", + )?; + + let mut ids = HashSet::with_capacity(self.sections.len()); + let mut ranks = HashSet::with_capacity(self.sections.len()); + for section in &self.sections { + if section.id.is_nil() || !ids.insert(section.id) { + return Err(SectionWorkspaceValidationError::Invalid("section_id")); + } + if section.rank as usize >= self.sections.len() || !ranks.insert(section.rank) { + return Err(SectionWorkspaceValidationError::Invalid("section_rank")); + } + validate_ciphertext( + §ion.encrypted_label, + MAX_ENCRYPTED_METADATA_BYTES, + "label", + )?; + if let Some(icon) = §ion.encrypted_icon { + validate_ciphertext(icon, MAX_ENCRYPTED_METADATA_BYTES, "icon")?; + } + } + let mut channels = HashSet::with_capacity(self.assignments.len()); + for assignment in &self.assignments { + if assignment.channel_id.is_nil() + || !channels.insert(assignment.channel_id) + || !ids.contains(&assignment.section_id) + { + return Err(SectionWorkspaceValidationError::Invalid("assignment")); + } + } + Ok(()) + } +} + +fn validate_hex_32( + value: &str, + field: &'static str, +) -> Result<(), SectionWorkspaceValidationError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(SectionWorkspaceValidationError::Invalid(field)); + } + Ok(()) +} + +fn validate_ciphertext( + value: &str, + maximum: usize, + field: &'static str, +) -> Result<(), SectionWorkspaceValidationError> { + if value.is_empty() || value.len() > maximum { + return Err(SectionWorkspaceValidationError::InvalidCiphertext(field)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_import() -> SectionWorkspaceImport { + let section_id = Uuid::new_v4(); + SectionWorkspaceImport { + version: SECTION_WORKSPACE_VERSION, + action_id: Uuid::new_v4(), + source_event_id: "11".repeat(32), + source_hash: "22".repeat(32), + key_epoch: 1, + owner_key_envelope: "wrapped-key".into(), + sections: vec![SectionWorkspaceImportSection { + id: section_id, + rank: 0, + encrypted_label: "ciphertext".into(), + encrypted_icon: None, + }], + assignments: vec![SectionWorkspaceImportAssignment { + channel_id: Uuid::new_v4(), + section_id, + }], + } + } + + #[test] + fn valid_revision_zero_import_passes() { + valid_import().validate().expect("valid import"); + } + + #[test] + fn duplicate_channel_assignment_is_rejected() { + let mut import = valid_import(); + import.assignments.push(import.assignments[0]); + assert_eq!( + import.validate(), + Err(SectionWorkspaceValidationError::Invalid("assignment")) + ); + } + + #[test] + fn ranks_must_be_a_complete_permutation() { + let mut import = valid_import(); + import.sections.push(SectionWorkspaceImportSection { + id: Uuid::new_v4(), + rank: 0, + encrypted_label: "ciphertext".into(), + encrypted_icon: None, + }); + assert_eq!( + import.validate(), + Err(SectionWorkspaceValidationError::Invalid("section_rank")) + ); + } +} + +#[cfg(test)] +mod fixture_tests { + use super::*; + use serde_json::Value; + + fn fixture() -> Value { + serde_json::from_str(include_str!("../../../docs/nips/NIP-SW.fixtures.json")) + .expect("valid NIP-SW fixture JSON") + } + + #[test] + fn shared_fixture_matches_protocol_constants_and_role_matrix() { + let fixture = fixture(); + assert_eq!(fixture["version"], SECTION_WORKSPACE_VERSION); + for (name, kind) in [ + ("import_v1", crate::kind::KIND_SECTION_WORKSPACE_IMPORT), + ("grant", crate::kind::KIND_SECTION_WORKSPACE_GRANT), + ("revoke", crate::kind::KIND_SECTION_WORKSPACE_REVOKE), + ("move", crate::kind::KIND_SECTION_WORKSPACE_MOVE), + ("manage", crate::kind::KIND_SECTION_WORKSPACE_MANAGE), + ("projection", crate::kind::KIND_SECTION_WORKSPACE_PROJECTION), + ] { + assert_eq!(fixture["kinds"][name], kind, "kind {name}"); + } + assert_eq!(fixture["limits"]["sections"], MAX_SECTIONS); + assert_eq!(fixture["limits"]["assignments"], MAX_ASSIGNMENTS); + assert_eq!(fixture["limits"]["grants"], MAX_GRANTS); + assert_eq!( + fixture["limits"]["encrypted_metadata_bytes"], + MAX_ENCRYPTED_METADATA_BYTES + ); + assert_eq!( + fixture["limits"]["key_envelope_bytes"], + MAX_KEY_ENVELOPE_BYTES + ); + assert_eq!( + fixture["roles"], + serde_json::json!(["viewer", "mover", "manager"]) + ); + assert_eq!( + fixture["role_matrix"], + serde_json::json!({ + "owner": ["read", "grant", "revoke", "move", "manage", "import_v1"], + "viewer": ["read"], + "mover": ["read", "move"], + "manager": ["read", "move", "manage"] + }) + ); + assert_eq!(fixture["metadata_crypto"]["algorithm"], "AES-256-GCM"); + } + + #[test] + fn projection_vectors_are_typed_and_drive_revision_behavior() { + let fixture = fixture(); + let cases = fixture["projection_cases"] + .as_array() + .expect("projection cases"); + let accepted = + serde_json::from_value::(cases[0]["projection"].clone()) + .expect("complete projection shape"); + accepted.validate().expect("supported projection version"); + assert_eq!(accepted.sections.len(), 2); + assert_eq!(accepted.assignments.len(), 1); + + for case in cases { + let incoming = case["projection"]["revision"].as_u64().expect("revision"); + let previous = case.get("previous_revision").and_then(Value::as_u64); + let actual = projection_revision_action(previous, incoming); + let expected = match case["expect"].as_str().expect("expect") { + "accept" => ProjectionRevisionAction::Accept, + "refetch" => ProjectionRevisionAction::Refetch, + "ignore" => ProjectionRevisionAction::Ignore, + other => panic!("unknown projection expectation {other}"), + }; + assert_eq!(actual, expected, "projection case {}", case["name"]); + } + + let mut unknown = cases[0]["projection"].clone(); + unknown["extra"] = serde_json::json!(true); + assert!(serde_json::from_value::(unknown).is_err()); + let mut wrong_version = cases[0]["projection"].clone(); + wrong_version["version"] = serde_json::json!(2); + assert_eq!( + serde_json::from_value::(wrong_version) + .expect("typed wrong version") + .validate(), + Err(SectionWorkspaceValidationError::Invalid("version")) + ); + } + + #[test] + fn canonicalization_and_every_typed_command_vector_match() { + use sha2::{Digest, Sha256}; + let fixture = fixture(); + let legacy = &fixture["canonicalization"]["legacy_plaintext"]; + let canonical = canonical_json(&legacy["input"]).expect("canonical legacy plaintext"); + assert_eq!(canonical, legacy["canonical"]); + assert_eq!( + hex::encode(Sha256::digest(canonical.as_bytes())), + legacy["sha256"] + ); + + let imported: SectionWorkspaceImport = serde_json::from_str( + fixture["canonicalization"]["import_command"]["canonical"] + .as_str() + .expect("canonical import"), + ) + .expect("typed canonical import"); + imported.validate().expect("valid import vector"); + let import_value = serde_json::to_value(&imported).expect("import value"); + let import_canonical = canonical_json(&import_value).expect("canonical typed import"); + assert_eq!( + import_canonical, + fixture["canonicalization"]["import_command"]["canonical"] + ); + assert_eq!( + hex::encode(Sha256::digest(import_canonical.as_bytes())), + fixture["canonicalization"]["import_command"]["sha256"] + ); + + for case in fixture["command_cases"].as_array().expect("command cases") { + let Some(command) = case.get("command") else { + continue; + }; + let parsed = + if case["name"].as_str().unwrap().starts_with("grant-") { + serde_json::from_value::(command.clone()) + .and_then(|value| { + value.validate().map_err(serde::de::Error::custom)?; + Ok(()) + }) + } else { + serde_json::from_value::(command.clone()) + .and_then(|value| { + value.validate().map_err(serde::de::Error::custom)?; + Ok(()) + }) + }; + assert_eq!( + parsed.is_ok(), + case["expect"] == "accept", + "command case {}", + case["name"] + ); + } + for mut command in [ + serde_json::to_value(&imported).unwrap(), + fixture["command_cases"][0]["command"].clone(), + fixture["command_cases"][2]["command"].clone(), + ] { + command["version"] = serde_json::json!(2); + let valid = if command.get("source_event_id").is_some() { + serde_json::from_value::(command).and_then(|value| { + value.validate().map_err(serde::de::Error::custom)?; + Ok(()) + }) + } else if command.get("role").is_some() { + serde_json::from_value::(command).and_then(|value| { + value.validate().map_err(serde::de::Error::custom)?; + Ok(()) + }) + } else { + serde_json::from_value::(command).and_then(|value| { + value.validate().map_err(serde::de::Error::custom)?; + Ok(()) + }) + }; + assert!(valid.is_err(), "wrong protocol version must fail"); + } + } + + #[test] + fn command_tag_grammar_rejects_missing_duplicate_unknown_and_mismatched_tags() { + let action_id = Uuid::parse_str("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee").unwrap(); + let owner = "11".repeat(32); + let actor = "44".repeat(32); + let valid = vec![ + vec!["p".into(), owner.clone()], + vec!["actor".into(), actor.clone()], + vec!["action".into(), action_id.to_string()], + ]; + assert!(validate_command_tags(&valid, &owner, Some(&actor), action_id).is_ok()); + for invalid in [ + valid[..2].to_vec(), + [valid.clone(), vec![valid[0].clone()]].concat(), + [valid.clone(), vec![vec!["extra".into(), "x".into()]]].concat(), + vec![ + valid[0].clone(), + valid[1].clone(), + vec!["action".into(), Uuid::nil().to_string()], + ], + ] { + assert_eq!( + validate_command_tags(&invalid, &owner, Some(&actor), action_id), + Err(SectionWorkspaceValidationError::Invalid("tag_grammar")) + ); + } + } + + #[test] + fn strict_command_decoding_rejects_duplicates_and_malformed_values() { + let duplicate = r#"{"version":1,"version":1,"action_id":"eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee","actor_pubkey":"4444444444444444444444444444444444444444444444444444444444444444","role":"viewer","key_epoch":1,"key_envelope":"wrapped"}"#; + assert!(serde_json::from_str::(duplicate).is_err()); + let mut malformed = fixture()["command_cases"][0]["command"].clone(); + malformed["role"] = serde_json::json!("owner"); + assert!(serde_json::from_value::(malformed).is_err()); + assert!(canonical_json(&serde_json::json!({"fraction": 1.5})).is_err()); + } +} diff --git a/crates/buzz-db/src/deletion.rs b/crates/buzz-db/src/deletion.rs index fbe69f22a..60d3167ab 100644 --- a/crates/buzz-db/src/deletion.rs +++ b/crates/buzz-db/src/deletion.rs @@ -76,6 +76,12 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ "relay_invites", "relay_members", "scheduled_workflow_fires", + "section_actions", + "section_assignments", + "section_grants", + "section_key_envelopes", + "section_workspaces", + "sections", "subscriptions", "thread_metadata", "users", @@ -88,6 +94,12 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ pub const PURGE_SCOPED_TABLES: &[&str] = &[ "workflow_approvals", "scheduled_workflow_fires", + "section_actions", + "section_assignments", + "section_grants", + "section_key_envelopes", + "sections", + "section_workspaces", "workflow_runs", "push_wake_outbox", "join_policy_acceptances", diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 1dd8b061c..e491f0a0a 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -47,6 +47,8 @@ pub mod relay_invite; pub mod relay_members; /// Replica freshness fence for keyset-cursor read routing. pub mod replica_fence; +/// Owner-scoped channel-section workspace persistence. +pub mod section_workspace; /// Thread metadata persistence. pub mod thread; /// Per-community usage rollup queries for Prometheus gauges. @@ -1224,6 +1226,11 @@ impl Db { deletion::DeletionStore::new(self.pool.clone()) } + /// Owner-scoped channel-section workspace persistence. + pub fn section_workspace_store(&self) -> section_workspace::SectionWorkspaceStore { + section_workspace::SectionWorkspaceStore::new(self.pool.clone()) + } + /// Begin a database transaction for atomic multi-statement operations. /// /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index be87faa1a..6b6fc49db 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -1036,6 +1036,33 @@ mod tests { assert_eq!(migrations[29].version, 30); let deletion_recovery = migrations[29].sql.as_str(); assert!(deletion_recovery.contains("SET LOCAL lock_timeout = '5s'")); + + // Owner-scoped section workspaces are normalized and community-fenced. + assert_eq!(migrations[30].version, 31); + let section_workspaces = migrations[30].sql.as_str(); + for table in [ + "section_workspaces", + "section_grants", + "section_key_envelopes", + "sections", + "section_assignments", + "section_actions", + ] { + assert!( + section_workspaces.contains(&format!("CREATE TABLE {table}")), + "migration 0031 must create {table}" + ); + assert!( + section_workspaces.contains(&format!("attach_community_write_fence('{table}')")), + "migration 0031 must attach the community write fence to {table}" + ); + assert!( + desired_schema.contains(&format!("CREATE TABLE {table}")), + "desired-state schema must create {table}" + ); + } + assert!(section_workspaces.contains("CREATE UNIQUE INDEX sections_active_rank")); + assert!(desired_schema.contains("CREATE UNIQUE INDEX sections_active_rank")); } #[test] @@ -1485,6 +1512,18 @@ mod tests { let mut expected_fences = migration.fence_attachments.clone(); expected_fences.remove("product_feedback"); expected_fences.remove("rate_limit_violations"); + expected_fences.extend( + [ + "section_actions", + "section_assignments", + "section_grants", + "section_key_envelopes", + "section_workspaces", + "sections", + ] + .into_iter() + .map(str::to_owned), + ); assert_eq!( expected_fences, schema.fence_attachments, "write-fence attachment targets differ after recovery policy" diff --git a/crates/buzz-db/src/section_workspace.rs b/crates/buzz-db/src/section_workspace.rs new file mode 100644 index 000000000..bec6aeeac --- /dev/null +++ b/crates/buzz-db/src/section_workspace.rs @@ -0,0 +1,1722 @@ +//! Normalized persistence for owner-scoped channel-section workspaces. + +use buzz_core::section_workspace::{ + canonical_json, validate_command_tags, SectionWorkspaceGrantCommand, SectionWorkspaceImport, + SectionWorkspaceReaderEnvelope, SectionWorkspaceRevokeCommand, SectionWorkspaceRole, + SectionWorkspaceRotatedSection, MAX_ENCRYPTED_METADATA_BYTES, MAX_GRANTS, + MAX_KEY_ENVELOPE_BYTES, +}; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use sha2::{Digest, Sha256}; +use sqlx::{PgPool, QueryBuilder, Row}; +use uuid::Uuid; + +use crate::{DbError, Result}; + +/// Result of an idempotent revision-zero import. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImportOutcome { + /// This transaction created the canonical migrated workspace. + Imported { + /// Canonical workspace revision. + revision: i64, + }, + /// The exact action and command hash had already committed. + AlreadyApplied { + /// Previously committed canonical workspace revision. + revision: i64, + }, +} + +/// Pairwise key envelope installed for one authorized reader. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyEnvelopeUpdate { + /// Reader pubkey. + pub reader_pubkey: Vec, + /// Opaque pairwise-wrapped content key. + pub envelope: String, +} + +/// Re-encrypted section metadata supplied during content-key rotation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RotatedSectionMetadata { + /// Existing section UUID. + pub section_id: Uuid, + /// Label encrypted under the new content key. + pub encrypted_label: String, + /// Optional icon encrypted under the new content key. + pub encrypted_icon: Option, +} + +/// Result of an idempotent grant or revoke transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GrantMutationOutcome { + /// This transaction changed canonical state. + Applied { + /// New workspace revision. + revision: i64, + }, + /// The exact action had already committed. + AlreadyApplied { + /// Previously committed workspace revision. + revision: i64, + }, +} + +/// Current authorization result for one reader. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceAccess { + /// The workspace owner has implicit full access. + Owner, + /// An active explicit grant. + Granted(SectionWorkspaceRole), +} + +/// One encrypted section in a current projection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectedSection { + /// Stable section UUID. + pub id: Uuid, + /// Zero-based display order. + pub rank: i32, + /// Opaque authenticated label ciphertext. + pub encrypted_label: String, + /// Optional opaque authenticated icon ciphertext. + pub encrypted_icon: Option, +} + +/// One channel assignment in a current projection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProjectedAssignment { + /// Channel UUID. + pub channel_id: Uuid, + /// Destination section UUID. + pub section_id: Uuid, + /// Per-assignment revision. + pub revision: i64, +} + +/// Durable marker proving which legacy store was imported. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SectionWorkspaceMigrationMarker { + /// Legacy kind-30078 event id. + pub source_event_id: Vec, + /// SHA-256 of the canonical legacy plaintext. + pub source_hash: Vec, +} + +/// Canonical read projection returned only after an authorization check. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SectionWorkspaceProjection { + /// Workspace owner. + pub owner_pubkey: Vec, + /// Monotonic workspace revision. + pub revision: i64, + /// Monotonic layout revision. + pub layout_revision: i64, + /// Content-key epoch for encrypted metadata. + pub key_epoch: i64, + /// Legacy source event id recorded by migration. + pub migration_source_event_id: Vec, + /// Legacy plaintext hash recorded by migration. + pub migration_source_hash: Vec, + /// Pairwise key envelope for the authenticated reader. + pub reader_key_envelope: String, + /// Ordered active sections. + pub sections: Vec, + /// Current channel assignments. + pub assignments: Vec, +} + +/// One active delegate grant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SectionWorkspaceGrant { + /// Delegate pubkey. + pub actor_pubkey: Vec, + /// Granted role. + pub role: SectionWorkspaceRole, + /// Grant timestamp. + pub granted_at: DateTime, +} + +/// Database adapter for section workspace transactions. +#[derive(Clone)] +pub struct SectionWorkspaceStore { + pool: PgPool, +} + +impl SectionWorkspaceStore { + /// Construct from the writer pool used by [`crate::Db`]. + pub(crate) fn new(pool: PgPool) -> Self { + Self { pool } + } + + /// Atomically import one legacy store at workspace revision zero. + /// + /// `actor_pubkey` must equal `owner_pubkey`. `signed_event` must be a valid + /// actor-authored import event and is preserved for audit/rebuild, while `command_hash` is the + /// SHA-256 of the canonical command body and binds `action_id` retries to + /// one exact operation. + #[allow(clippy::too_many_arguments)] + pub async fn import_v1( + &self, + community: CommunityId, + owner_pubkey: &[u8], + actor_pubkey: &[u8], + signed_event: &nostr::Event, + command_hash: &[u8; 32], + import: &SectionWorkspaceImport, + ) -> Result { + validate_pubkey(owner_pubkey)?; + validate_pubkey(actor_pubkey)?; + if actor_pubkey != owner_pubkey { + return Err(DbError::AccessDenied( + "only the workspace owner may import legacy sections".into(), + )); + } + let (parsed_import, signed_event_json, derived_hash) = + validate_command::( + signed_event, + actor_pubkey, + owner_pubkey, + None, + buzz_core::kind::KIND_SECTION_WORKSPACE_IMPORT, + )?; + parsed_import + .validate() + .map_err(|error| DbError::InvalidData(error.to_string()))?; + if &parsed_import != import || &derived_hash != command_hash { + return Err(DbError::InvalidData( + "signed command does not match import mutation or hash".into(), + )); + } + + let source_event_id = decode_hex_32(&import.source_event_id, "source_event_id")?; + let source_hash = decode_hex_32(&import.source_hash, "source_hash")?; + let mut tx = self.pool.begin().await?; + + sqlx::query( + "INSERT INTO section_workspaces (community_id, owner_pubkey) VALUES ($1, $2) \ + ON CONFLICT (community_id, owner_pubkey) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .execute(&mut *tx) + .await?; + + let workspace = sqlx::query( + "SELECT revision, migration_source_event_id, migration_source_hash \ + FROM section_workspaces WHERE community_id = $1 AND owner_pubkey = $2 FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .fetch_one(&mut *tx) + .await?; + let revision: i64 = workspace.try_get("revision")?; + if revision != 0 { + let existing = sqlx::query( + "SELECT command_hash, resulting_revision FROM section_actions \ + WHERE community_id = $1 AND owner_pubkey = $2 AND action_id = $3", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(import.action_id) + .fetch_optional(&mut *tx) + .await?; + if let Some(existing) = existing { + let existing_hash: Vec = existing.try_get("command_hash")?; + let resulting_revision: i64 = existing.try_get("resulting_revision")?; + if existing_hash.as_slice() == command_hash { + tx.commit().await?; + return Ok(ImportOutcome::AlreadyApplied { + revision: resulting_revision, + }); + } + return Err(DbError::InvalidData( + "action_id is already bound to a different command".into(), + )); + } + return Err(DbError::InvalidData( + "workspace has already been migrated".into(), + )); + } + + if !import.assignments.is_empty() { + let channel_ids = import + .assignments + .iter() + .map(|assignment| assignment.channel_id) + .collect::>(); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM channels \ + WHERE community_id = $1 AND id = ANY($2) AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(&channel_ids) + .fetch_one(&mut *tx) + .await?; + if count as usize != channel_ids.len() { + return Err(DbError::InvalidData( + "import contains a missing or deleted channel".into(), + )); + } + } + + if !import.sections.is_empty() { + let mut query = QueryBuilder::new( + "INSERT INTO sections (community_id, owner_pubkey, section_id, \ + encrypted_label, encrypted_icon, rank, revision) ", + ); + query.push_values(&import.sections, |mut row, section| { + row.push_bind(community.as_uuid()) + .push_bind(owner_pubkey) + .push_bind(section.id) + .push_bind(§ion.encrypted_label) + .push_bind(§ion.encrypted_icon) + .push_bind(section.rank as i32) + .push_bind(1_i64); + }); + query.build().execute(&mut *tx).await?; + } + + if !import.assignments.is_empty() { + let mut query = QueryBuilder::new( + "INSERT INTO section_assignments (community_id, owner_pubkey, channel_id, \ + section_id, revision, updated_by) ", + ); + query.push_values(&import.assignments, |mut row, assignment| { + row.push_bind(community.as_uuid()) + .push_bind(owner_pubkey) + .push_bind(assignment.channel_id) + .push_bind(assignment.section_id) + .push_bind(1_i64) + .push_bind(owner_pubkey); + }); + query.build().execute(&mut *tx).await?; + } + + sqlx::query( + "INSERT INTO section_key_envelopes \ + (community_id, owner_pubkey, reader_pubkey, key_epoch, envelope) \ + VALUES ($1, $2, $2, $3, $4)", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(import.key_epoch as i64) + .bind(&import.owner_key_envelope) + .execute(&mut *tx) + .await?; + + sqlx::query( + "UPDATE section_workspaces SET revision = 1, layout_revision = 1, key_epoch = $3, \ + migrated_at = now(), migration_source_event_id = $4, migration_source_hash = $5, \ + updated_at = now() WHERE community_id = $1 AND owner_pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(import.key_epoch as i64) + .bind(source_event_id) + .bind(source_hash) + .execute(&mut *tx) + .await?; + + sqlx::query( + "INSERT INTO section_actions (community_id, owner_pubkey, action_id, actor_pubkey, \ + command_kind, command_event_id, signed_event_json, command_hash, resulting_revision) \ + VALUES ($1, $2, $3, $2, 'import_v1', $4, $5, $6, 1)", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(import.action_id) + .bind(signed_event.id.as_bytes().as_slice()) + .bind(&signed_event_json) + .bind(command_hash.as_slice()) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(ImportOutcome::Imported { revision: 1 }) + } + + /// Owner-only grant or role update with a current-epoch key envelope. + #[allow(clippy::too_many_arguments)] + pub async fn grant( + &self, + community: CommunityId, + owner_pubkey: &[u8], + actor_pubkey: &[u8], + target_pubkey: &[u8], + role: SectionWorkspaceRole, + expected_key_epoch: i64, + key_envelope: &str, + action_id: Uuid, + signed_event: &nostr::Event, + command_hash: &[u8; 32], + ) -> Result { + validate_owner_mutation(owner_pubkey, actor_pubkey, action_id)?; + let supplied_command = SectionWorkspaceGrantCommand { + version: buzz_core::section_workspace::SECTION_WORKSPACE_VERSION, + action_id, + actor_pubkey: hex::encode(target_pubkey), + role, + key_epoch: u64::try_from(expected_key_epoch) + .map_err(|_| DbError::InvalidData("invalid key epoch".into()))?, + key_envelope: key_envelope.to_owned(), + }; + let (parsed_command, signed_event_json, derived_hash) = + validate_command::( + signed_event, + actor_pubkey, + owner_pubkey, + Some(supplied_command.actor_pubkey.as_str()), + buzz_core::kind::KIND_SECTION_WORKSPACE_GRANT, + )?; + parsed_command + .validate() + .map_err(|error| DbError::InvalidData(error.to_string()))?; + if parsed_command != supplied_command || &derived_hash != command_hash { + return Err(DbError::InvalidData( + "signed command does not match grant mutation or hash".into(), + )); + } + validate_pubkey(target_pubkey)?; + if target_pubkey == owner_pubkey { + return Err(DbError::InvalidData( + "owner access is implicit and cannot be granted".into(), + )); + } + validate_envelope(key_envelope)?; + let mut tx = self.pool.begin().await?; + let (revision, key_epoch) = + lock_migrated_workspace(&mut tx, community, owner_pubkey).await?; + if let Some(outcome) = + existing_action(&mut tx, community, owner_pubkey, action_id, command_hash).await? + { + tx.commit().await?; + return Ok(outcome); + } + if expected_key_epoch != key_epoch { + return Err(DbError::InvalidData( + "grant key epoch does not match current workspace epoch".into(), + )); + } + let grant_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM section_grants WHERE community_id = $1 AND owner_pubkey = $2 \ + AND status = 'active'", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .fetch_one(&mut *tx) + .await?; + let target_is_active: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM section_grants WHERE community_id = $1 \ + AND owner_pubkey = $2 AND actor_pubkey = $3 AND status = 'active')", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(target_pubkey) + .fetch_one(&mut *tx) + .await?; + if !target_is_active && grant_count >= MAX_GRANTS as i64 { + return Err(DbError::InvalidData(format!( + "section workspace grants exceed maximum {MAX_GRANTS}" + ))); + } + let next_revision = revision + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("workspace revision exhausted".into()))?; + sqlx::query( + "INSERT INTO section_grants (community_id, owner_pubkey, actor_pubkey, role, status, \ + granted_by, granted_at, revoked_at) VALUES ($1, $2, $3, $4, 'active', $2, now(), NULL) \ + ON CONFLICT (community_id, owner_pubkey, actor_pubkey) DO UPDATE SET \ + role = EXCLUDED.role, status = 'active', granted_by = EXCLUDED.granted_by, \ + granted_at = now(), revoked_at = NULL", + ) + .bind(community.as_uuid()).bind(owner_pubkey).bind(target_pubkey) + .bind(role.as_str()).execute(&mut *tx).await?; + sqlx::query( + "INSERT INTO section_key_envelopes \ + (community_id, owner_pubkey, reader_pubkey, key_epoch, envelope) \ + VALUES ($1, $2, $3, $4, $5) \ + ON CONFLICT (community_id, owner_pubkey, reader_pubkey, key_epoch) \ + DO UPDATE SET envelope = EXCLUDED.envelope, created_at = now()", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(target_pubkey) + .bind(key_epoch) + .bind(key_envelope) + .execute(&mut *tx) + .await?; + finish_grant_action( + &mut tx, + community, + owner_pubkey, + action_id, + actor_pubkey, + "grant", + signed_event, + &signed_event_json, + command_hash, + next_revision, + ) + .await?; + tx.commit().await?; + Ok(GrantMutationOutcome::Applied { + revision: next_revision, + }) + } + + /// Owner-only revoke with atomic content-key rotation. + /// + /// Every active section must be represented exactly once under + /// `new_key_epoch`, and envelopes must cover the owner plus every remaining + /// active grantee, excluding the revoked reader. + #[allow(clippy::too_many_arguments)] + pub async fn revoke_and_rotate( + &self, + community: CommunityId, + owner_pubkey: &[u8], + actor_pubkey: &[u8], + target_pubkey: &[u8], + new_key_epoch: i64, + sections: &[RotatedSectionMetadata], + envelopes: &[KeyEnvelopeUpdate], + action_id: Uuid, + signed_event: &nostr::Event, + command_hash: &[u8; 32], + ) -> Result { + validate_owner_mutation(owner_pubkey, actor_pubkey, action_id)?; + let supplied_command = SectionWorkspaceRevokeCommand { + version: buzz_core::section_workspace::SECTION_WORKSPACE_VERSION, + action_id, + actor_pubkey: hex::encode(target_pubkey), + new_key_epoch: u64::try_from(new_key_epoch) + .map_err(|_| DbError::InvalidData("invalid key epoch".into()))?, + sections: sections + .iter() + .map(|section| SectionWorkspaceRotatedSection { + section_id: section.section_id, + encrypted_label: section.encrypted_label.clone(), + encrypted_icon: section.encrypted_icon.clone(), + }) + .collect(), + envelopes: envelopes + .iter() + .map(|envelope| SectionWorkspaceReaderEnvelope { + reader_pubkey: hex::encode(&envelope.reader_pubkey), + key_envelope: envelope.envelope.clone(), + }) + .collect(), + }; + let (parsed_command, signed_event_json, derived_hash) = + validate_command::( + signed_event, + actor_pubkey, + owner_pubkey, + Some(supplied_command.actor_pubkey.as_str()), + buzz_core::kind::KIND_SECTION_WORKSPACE_REVOKE, + )?; + parsed_command + .validate() + .map_err(|error| DbError::InvalidData(error.to_string()))?; + if parsed_command != supplied_command || &derived_hash != command_hash { + return Err(DbError::InvalidData( + "signed command does not match revoke mutation or hash".into(), + )); + } + validate_pubkey(target_pubkey)?; + if target_pubkey == owner_pubkey { + return Err(DbError::InvalidData( + "owner access cannot be revoked".into(), + )); + } + let mut tx = self.pool.begin().await?; + let (revision, key_epoch) = + lock_migrated_workspace(&mut tx, community, owner_pubkey).await?; + if let Some(outcome) = + existing_action(&mut tx, community, owner_pubkey, action_id, command_hash).await? + { + tx.commit().await?; + return Ok(outcome); + } + if new_key_epoch + != key_epoch + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("key epoch exhausted".into()))? + { + return Err(DbError::InvalidData( + "new key epoch must be current epoch + 1".into(), + )); + } + let next_revision = revision + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("workspace revision exhausted".into()))?; + validate_rotation_shape( + &mut tx, + community, + owner_pubkey, + target_pubkey, + sections, + envelopes, + ) + .await?; + let affected = sqlx::query( + "UPDATE section_grants SET status = 'revoked', revoked_at = now() \ + WHERE community_id = $1 AND owner_pubkey = $2 AND actor_pubkey = $3 \ + AND status = 'active'", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(target_pubkey) + .execute(&mut *tx) + .await? + .rows_affected(); + if affected != 1 { + return Err(DbError::NotFound("active section workspace grant".into())); + } + for section in sections { + sqlx::query( + "UPDATE sections SET encrypted_label = $4, encrypted_icon = $5, revision = $6 \ + WHERE community_id = $1 AND owner_pubkey = $2 AND section_id = $3 \ + AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(section.section_id) + .bind(§ion.encrypted_label) + .bind(§ion.encrypted_icon) + .bind(next_revision) + .execute(&mut *tx) + .await?; + } + for envelope in envelopes { + sqlx::query( + "INSERT INTO section_key_envelopes \ + (community_id, owner_pubkey, reader_pubkey, key_epoch, envelope) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(&envelope.reader_pubkey) + .bind(new_key_epoch) + .bind(&envelope.envelope) + .execute(&mut *tx) + .await?; + } + sqlx::query( + "UPDATE section_workspaces SET revision = $3, key_epoch = $4, updated_at = now() \ + WHERE community_id = $1 AND owner_pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(next_revision) + .bind(new_key_epoch) + .execute(&mut *tx) + .await?; + finish_grant_action( + &mut tx, + community, + owner_pubkey, + action_id, + actor_pubkey, + "revoke", + signed_event, + &signed_event_json, + command_hash, + next_revision, + ) + .await?; + tx.commit().await?; + Ok(GrantMutationOutcome::Applied { + revision: next_revision, + }) + } + + /// Resolve current read authority. The owner is implicit; delegates require + /// an active exact-pubkey grant. + pub async fn access( + &self, + community: CommunityId, + owner_pubkey: &[u8], + reader_pubkey: &[u8], + ) -> Result> { + validate_pubkey(owner_pubkey)?; + validate_pubkey(reader_pubkey)?; + if owner_pubkey == reader_pubkey { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM section_workspaces \ + WHERE community_id = $1 AND owner_pubkey = $2 AND migrated_at IS NOT NULL)", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .fetch_one(&self.pool) + .await?; + return Ok(exists.then_some(WorkspaceAccess::Owner)); + } + let role: Option = sqlx::query_scalar( + "SELECT role FROM section_grants WHERE community_id = $1 AND owner_pubkey = $2 \ + AND actor_pubkey = $3 AND status = 'active'", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(reader_pubkey) + .fetch_optional(&self.pool) + .await?; + role.map(|role| parse_role(&role).map(WorkspaceAccess::Granted)) + .transpose() + } + + /// Lookup the migration marker without granting access to projection data. + /// + /// This is owner-authorized because the marker identifies the owner's + /// legacy event and plaintext hash. Unmigrated and unauthorized workspaces + /// both return `None`. + pub async fn migration_marker( + &self, + community: CommunityId, + owner_pubkey: &[u8], + reader_pubkey: &[u8], + ) -> Result> { + validate_pubkey(owner_pubkey)?; + validate_pubkey(reader_pubkey)?; + let mut tx = self.pool.begin().await?; + // Lock the workspace before resolving the grant. Revoke takes the + // corresponding UPDATE lock, so an authorized read linearizes wholly + // before or wholly after revocation rather than leaking a post-revoke + // marker through a check/use race. + let row = sqlx::query( + "SELECT migration_source_event_id, migration_source_hash FROM section_workspaces \ + WHERE community_id = $1 AND owner_pubkey = $2 AND migrated_at IS NOT NULL FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .fetch_optional(&mut *tx) + .await?; + let authorized = owner_pubkey == reader_pubkey + || active_grant_exists(&mut tx, community, owner_pubkey, reader_pubkey).await?; + if !authorized { + tx.commit().await?; + return Ok(None); + } + let marker = row + .map(|row| -> Result { + Ok(SectionWorkspaceMigrationMarker { + source_event_id: row.try_get("migration_source_event_id")?, + source_hash: row.try_get("migration_source_hash")?, + }) + }) + .transpose()?; + tx.commit().await?; + Ok(marker) + } + + /// Fetch the canonical projection for an owner or active delegate. + pub async fn projection( + &self, + community: CommunityId, + owner_pubkey: &[u8], + reader_pubkey: &[u8], + ) -> Result> { + validate_pubkey(owner_pubkey)?; + validate_pubkey(reader_pubkey)?; + let mut tx = self.pool.begin().await?; + // Keep authorization and every projected row under a shared workspace + // lock. Grant/revoke mutations serialize on the workspace's UPDATE + // lock, closing the authorization check/use race across these queries. + let workspace = sqlx::query( + "SELECT revision, layout_revision, key_epoch, migration_source_event_id, \ + migration_source_hash FROM section_workspaces \ + WHERE community_id = $1 AND owner_pubkey = $2 AND migrated_at IS NOT NULL FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .fetch_optional(&mut *tx) + .await?; + let Some(workspace) = workspace else { + tx.commit().await?; + return Ok(None); + }; + let authorized = owner_pubkey == reader_pubkey + || active_grant_exists(&mut tx, community, owner_pubkey, reader_pubkey).await?; + if !authorized { + tx.commit().await?; + return Ok(None); + } + let key_epoch: i64 = workspace.try_get("key_epoch")?; + let reader_key_envelope: String = sqlx::query_scalar( + "SELECT envelope FROM section_key_envelopes WHERE community_id = $1 \ + AND owner_pubkey = $2 AND reader_pubkey = $3 AND key_epoch = $4", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(reader_pubkey) + .bind(key_epoch) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + DbError::InvalidData("authorized reader has no current key envelope".into()) + })?; + let sections = sqlx::query( + "SELECT section_id, rank, encrypted_label, encrypted_icon FROM sections \ + WHERE community_id = $1 AND owner_pubkey = $2 AND deleted_at IS NULL ORDER BY rank", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .fetch_all(&mut *tx) + .await? + .into_iter() + .map(|row| { + Ok(ProjectedSection { + id: row.try_get("section_id")?, + rank: row.try_get("rank")?, + encrypted_label: row.try_get("encrypted_label")?, + encrypted_icon: row.try_get("encrypted_icon")?, + }) + }) + .collect::>>()?; + let assignments = sqlx::query( + "SELECT channel_id, section_id, revision FROM section_assignments \ + WHERE community_id = $1 AND owner_pubkey = $2 ORDER BY channel_id", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .fetch_all(&mut *tx) + .await? + .into_iter() + .map(|row| { + Ok(ProjectedAssignment { + channel_id: row.try_get("channel_id")?, + section_id: row.try_get("section_id")?, + revision: row.try_get("revision")?, + }) + }) + .collect::>>()?; + let projection = SectionWorkspaceProjection { + owner_pubkey: owner_pubkey.to_vec(), + revision: workspace.try_get("revision")?, + layout_revision: workspace.try_get("layout_revision")?, + key_epoch, + migration_source_event_id: workspace.try_get("migration_source_event_id")?, + migration_source_hash: workspace.try_get("migration_source_hash")?, + reader_key_envelope, + sections, + assignments, + }; + tx.commit().await?; + Ok(Some(projection)) + } +} + +async fn active_grant_exists( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community: CommunityId, + owner_pubkey: &[u8], + reader_pubkey: &[u8], +) -> Result { + Ok(sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM section_grants WHERE community_id = $1 \ + AND owner_pubkey = $2 AND actor_pubkey = $3 AND status = 'active')", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(reader_pubkey) + .fetch_one(&mut **tx) + .await?) +} + +async fn lock_migrated_workspace( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community: CommunityId, + owner_pubkey: &[u8], +) -> Result<(i64, i64)> { + let row = sqlx::query( + "SELECT revision, key_epoch FROM section_workspaces \ + WHERE community_id = $1 AND owner_pubkey = $2 AND migrated_at IS NOT NULL FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| DbError::NotFound("migrated section workspace".into()))?; + Ok((row.try_get("revision")?, row.try_get("key_epoch")?)) +} + +async fn existing_action( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community: CommunityId, + owner_pubkey: &[u8], + action_id: Uuid, + command_hash: &[u8; 32], +) -> Result> { + let row = sqlx::query( + "SELECT command_hash, resulting_revision FROM section_actions \ + WHERE community_id = $1 AND owner_pubkey = $2 AND action_id = $3", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(action_id) + .fetch_optional(&mut **tx) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let existing_hash: Vec = row.try_get("command_hash")?; + if existing_hash.as_slice() != command_hash { + return Err(DbError::InvalidData( + "action_id is already bound to a different command".into(), + )); + } + Ok(Some(GrantMutationOutcome::AlreadyApplied { + revision: row.try_get("resulting_revision")?, + })) +} + +#[allow(clippy::too_many_arguments)] +async fn finish_grant_action( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community: CommunityId, + owner_pubkey: &[u8], + action_id: Uuid, + actor_pubkey: &[u8], + command_kind: &str, + signed_event: &nostr::Event, + signed_event_json: &serde_json::Value, + command_hash: &[u8; 32], + revision: i64, +) -> Result<()> { + sqlx::query( + "INSERT INTO section_actions (community_id, owner_pubkey, action_id, actor_pubkey, \ + command_kind, command_event_id, signed_event_json, command_hash, resulting_revision) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(action_id) + .bind(actor_pubkey) + .bind(command_kind) + .bind(signed_event.id.as_bytes().as_slice()) + .bind(signed_event_json) + .bind(command_hash.as_slice()) + .bind(revision) + .execute(&mut **tx) + .await?; + sqlx::query( + "UPDATE section_workspaces SET revision = $3, updated_at = now() \ + WHERE community_id = $1 AND owner_pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(revision) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn validate_rotation_shape( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community: CommunityId, + owner_pubkey: &[u8], + target_pubkey: &[u8], + sections: &[RotatedSectionMetadata], + envelopes: &[KeyEnvelopeUpdate], +) -> Result<()> { + let active_sections: Vec = sqlx::query_scalar( + "SELECT section_id FROM sections WHERE community_id = $1 AND owner_pubkey = $2 \ + AND deleted_at IS NULL ORDER BY section_id", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .fetch_all(&mut **tx) + .await?; + let mut supplied_sections = Vec::with_capacity(sections.len()); + for section in sections { + validate_ciphertext_db( + §ion.encrypted_label, + MAX_ENCRYPTED_METADATA_BYTES, + "label", + )?; + if let Some(icon) = §ion.encrypted_icon { + validate_ciphertext_db(icon, MAX_ENCRYPTED_METADATA_BYTES, "icon")?; + } + supplied_sections.push(section.section_id); + } + supplied_sections.sort_unstable(); + if supplied_sections != active_sections { + return Err(DbError::InvalidData( + "rotation must re-encrypt every active section exactly once".into(), + )); + } + let mut required_readers: Vec> = sqlx::query_scalar( + "SELECT actor_pubkey FROM section_grants WHERE community_id = $1 AND owner_pubkey = $2 \ + AND status = 'active' AND actor_pubkey <> $3 ORDER BY actor_pubkey", + ) + .bind(community.as_uuid()) + .bind(owner_pubkey) + .bind(target_pubkey) + .fetch_all(&mut **tx) + .await?; + required_readers.push(owner_pubkey.to_vec()); + required_readers.sort(); + let mut supplied_readers = Vec::with_capacity(envelopes.len()); + for envelope in envelopes { + validate_pubkey(&envelope.reader_pubkey)?; + validate_envelope(&envelope.envelope)?; + supplied_readers.push(envelope.reader_pubkey.clone()); + } + supplied_readers.sort(); + if supplied_readers != required_readers { + return Err(DbError::InvalidData( + "rotation envelopes must cover owner and every remaining active grantee exactly once" + .into(), + )); + } + Ok(()) +} + +fn validate_command( + event: &nostr::Event, + actor_pubkey: &[u8], + owner_pubkey: &[u8], + target_pubkey: Option<&str>, + expected_kind: u32, +) -> Result<(T, serde_json::Value, [u8; 32])> +where + T: serde::de::DeserializeOwned + serde::Serialize, +{ + if event.kind.as_u16() as u32 != expected_kind { + return Err(DbError::InvalidData( + "signed command event has wrong kind".into(), + )); + } + if event.pubkey.to_bytes().as_slice() != actor_pubkey { + return Err(DbError::AccessDenied( + "signed command author does not match actor".into(), + )); + } + if !event.verify_id() || !event.verify_signature() { + return Err(DbError::InvalidData( + "signed command event has invalid id or signature".into(), + )); + } + let command: T = serde_json::from_str(&event.content) + .map_err(|error| DbError::InvalidData(format!("invalid command content: {error}")))?; + let value = serde_json::to_value(&command) + .map_err(|error| DbError::InvalidData(format!("cannot canonicalize command: {error}")))?; + let canonical = + canonical_json(&value).map_err(|error| DbError::InvalidData(error.to_string()))?; + if event.content != canonical { + return Err(DbError::InvalidData( + "command content is not canonical JSON".into(), + )); + } + let action_id = value + .get("action_id") + .and_then(serde_json::Value::as_str) + .and_then(|value| Uuid::parse_str(value).ok()) + .ok_or_else(|| DbError::InvalidData("invalid command action_id".into()))?; + let tags = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect::>(); + validate_command_tags(&tags, &hex::encode(owner_pubkey), target_pubkey, action_id) + .map_err(|error| DbError::InvalidData(error.to_string()))?; + let hash: [u8; 32] = Sha256::digest(canonical.as_bytes()).into(); + let signed_event_json = serde_json::to_value(event) + .map_err(|error| DbError::InvalidData(format!("cannot retain signed command: {error}")))?; + Ok((command, signed_event_json, hash)) +} + +fn validate_owner_mutation(owner: &[u8], actor: &[u8], action_id: Uuid) -> Result<()> { + validate_pubkey(owner)?; + validate_pubkey(actor)?; + if owner != actor { + return Err(DbError::AccessDenied( + "only the workspace owner may change grants".into(), + )); + } + if action_id.is_nil() { + return Err(DbError::InvalidData("action_id must not be nil".into())); + } + Ok(()) +} + +fn validate_envelope(envelope: &str) -> Result<()> { + validate_ciphertext_db(envelope, MAX_KEY_ENVELOPE_BYTES, "key envelope") +} + +fn validate_ciphertext_db(value: &str, maximum: usize, field: &str) -> Result<()> { + if value.is_empty() || value.len() > maximum { + return Err(DbError::InvalidData(format!("invalid encrypted {field}"))); + } + Ok(()) +} + +fn validate_pubkey(pubkey: &[u8]) -> Result<()> { + if pubkey.len() != 32 { + return Err(DbError::InvalidData("pubkey must be 32 bytes".into())); + } + Ok(()) +} + +fn decode_hex_32(value: &str, field: &str) -> Result> { + let decoded = + hex::decode(value).map_err(|_| DbError::InvalidData(format!("invalid {field}")))?; + if decoded.len() != 32 { + return Err(DbError::InvalidData(format!("invalid {field}"))); + } + Ok(decoded) +} + +fn parse_role(value: &str) -> Result { + match value { + "viewer" => Ok(SectionWorkspaceRole::Viewer), + "mover" => Ok(SectionWorkspaceRole::Mover), + "manager" => Ok(SectionWorkspaceRole::Manager), + _ => Err(DbError::InvalidData(format!( + "unknown section workspace role {value:?}" + ))), + } +} + +const _: () = assert!(MAX_ENCRYPTED_METADATA_BYTES <= 65_535); +const _: () = assert!(MAX_KEY_ENVELOPE_BYTES <= 4_096); + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::section_workspace::{ + SectionWorkspaceImportAssignment, SectionWorkspaceImportSection, + }; + use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind}; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + fn bound_signed_command( + keys: &Keys, + kind: u32, + owner: &[u8], + actor: Option<&[u8]>, + action_id: Uuid, + command: &T, + ) -> (Event, [u8; 32]) { + use nostr::Tag; + let value = serde_json::to_value(command).expect("command value"); + let content = canonical_json(&value).expect("canonical command"); + let mut tags = vec![Tag::parse(["p", &hex::encode(owner)]).expect("p tag")]; + if let Some(actor) = actor { + tags.push(Tag::parse(["actor", &hex::encode(actor)]).expect("actor tag")); + } + tags.push(Tag::parse(["action", &action_id.to_string()]).expect("action tag")); + let event = EventBuilder::new(Kind::Custom(kind as u16), &content) + .tags(tags) + .allow_self_tagging() + .sign_with_keys(keys) + .expect("sign command"); + let hash = Sha256::digest(content.as_bytes()).into(); + (event, hash) + } + + fn import_event( + keys: &Keys, + owner: &[u8], + import: &SectionWorkspaceImport, + ) -> (Event, [u8; 32]) { + bound_signed_command( + keys, + buzz_core::kind::KIND_SECTION_WORKSPACE_IMPORT, + owner, + None, + import.action_id, + import, + ) + } + + fn grant_event( + keys: &Keys, + owner: &[u8], + target: &[u8], + role: SectionWorkspaceRole, + epoch: i64, + envelope: &str, + action_id: Uuid, + ) -> (Event, [u8; 32]) { + let command = SectionWorkspaceGrantCommand { + version: buzz_core::section_workspace::SECTION_WORKSPACE_VERSION, + action_id, + actor_pubkey: hex::encode(target), + role, + key_epoch: u64::try_from(epoch).unwrap(), + key_envelope: envelope.into(), + }; + bound_signed_command( + keys, + buzz_core::kind::KIND_SECTION_WORKSPACE_GRANT, + owner, + Some(target), + action_id, + &command, + ) + } + + fn revoke_event( + keys: &Keys, + owner: &[u8], + target: &[u8], + epoch: i64, + sections: &[RotatedSectionMetadata], + envelopes: &[KeyEnvelopeUpdate], + action_id: Uuid, + ) -> (Event, [u8; 32]) { + let command = SectionWorkspaceRevokeCommand { + version: buzz_core::section_workspace::SECTION_WORKSPACE_VERSION, + action_id, + actor_pubkey: hex::encode(target), + new_key_epoch: u64::try_from(epoch).unwrap(), + sections: sections + .iter() + .map(|section| SectionWorkspaceRotatedSection { + section_id: section.section_id, + encrypted_label: section.encrypted_label.clone(), + encrypted_icon: section.encrypted_icon.clone(), + }) + .collect(), + envelopes: envelopes + .iter() + .map(|envelope| SectionWorkspaceReaderEnvelope { + reader_pubkey: hex::encode(&envelope.reader_pubkey), + key_envelope: envelope.envelope.clone(), + }) + .collect(), + }; + bound_signed_command( + keys, + buzz_core::kind::KIND_SECTION_WORKSPACE_REVOKE, + owner, + Some(target), + action_id, + &command, + ) + } + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect to test database"); + crate::migration::run_migrations(&pool) + .await + .expect("apply migrations"); + pool + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + let host = format!("section-workspace-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + async fn make_channel(pool: &PgPool, community: CommunityId, id: Uuid) { + sqlx::query( + "INSERT INTO channels (community_id, id, name, created_by) VALUES ($1, $2, $3, $4)", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(format!("channel-{}", id.simple())) + .bind(vec![7_u8; 32]) + .execute(pool) + .await + .expect("insert test channel"); + } + + fn import_fixture( + action_id: Uuid, + channel_id: Uuid, + section_id: Uuid, + ) -> SectionWorkspaceImport { + SectionWorkspaceImport { + version: buzz_core::section_workspace::SECTION_WORKSPACE_VERSION, + action_id, + source_event_id: "11".repeat(32), + source_hash: "22".repeat(32), + key_epoch: 1, + owner_key_envelope: "owner-envelope-epoch-1".into(), + sections: vec![SectionWorkspaceImportSection { + id: section_id, + rank: 0, + encrypted_label: "label-epoch-1".into(), + encrypted_icon: None, + }], + assignments: vec![SectionWorkspaceImportAssignment { + channel_id, + section_id, + }], + } + } + + #[test] + fn signed_command_validation_binds_content_tags_hash_author_kind_and_signature() { + let owner_keys = Keys::generate(); + let owner = owner_keys.public_key().to_bytes(); + let import = import_fixture(Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()); + let (event, expected_hash) = bound_signed_command( + &owner_keys, + buzz_core::kind::KIND_SECTION_WORKSPACE_IMPORT, + &owner, + None, + import.action_id, + &import, + ); + let (parsed, retained, hash) = validate_command::( + &event, + &owner, + &owner, + None, + buzz_core::kind::KIND_SECTION_WORKSPACE_IMPORT, + ) + .expect("valid signed command"); + assert_eq!(parsed, import); + assert_eq!(hash, expected_hash); + let reconstructed = Event::from_json(retained.to_string()).expect("reconstruct event"); + assert!(reconstructed.verify_id()); + assert!(reconstructed.verify_signature()); + + assert!(validate_command::( + &event, + &owner, + &owner, + None, + buzz_core::kind::KIND_SECTION_WORKSPACE_GRANT, + ) + .is_err()); + assert!(validate_command::( + &event, + &Keys::generate().public_key().to_bytes(), + &owner, + None, + buzz_core::kind::KIND_SECTION_WORKSPACE_IMPORT, + ) + .is_err()); + + let mut tampered_json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap(); + tampered_json["sig"] = serde_json::Value::String("0".repeat(128)); + let tampered = Event::from_json(tampered_json.to_string()).unwrap(); + assert!(validate_command::( + &tampered, + &owner, + &owner, + None, + buzz_core::kind::KIND_SECTION_WORKSPACE_IMPORT, + ) + .is_err()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn import_is_atomic_and_exact_replay_is_idempotent() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let owner_keys = Keys::generate(); + let owner = owner_keys.public_key().to_bytes(); + let channel_id = Uuid::new_v4(); + let section_id = Uuid::new_v4(); + make_channel(&pool, community, channel_id).await; + let store = SectionWorkspaceStore::new(pool.clone()); + let import = import_fixture(Uuid::new_v4(), channel_id, section_id); + let signed_import = import_event(&owner_keys, &owner, &import); + + assert_eq!( + store + .import_v1( + community, + &owner, + &owner, + &signed_import.0, + &signed_import.1, + &import + ) + .await + .expect("first import"), + ImportOutcome::Imported { revision: 1 } + ); + assert_eq!( + store + .import_v1( + community, + &owner, + &owner, + &signed_import.0, + &signed_import.1, + &import + ) + .await + .expect("exact retry"), + ImportOutcome::AlreadyApplied { revision: 1 } + ); + + let projection = store + .projection(community, &owner, &owner) + .await + .expect("owner projection") + .expect("migrated projection"); + assert_eq!(projection.revision, 1); + assert_eq!(projection.layout_revision, 1); + assert_eq!(projection.key_epoch, 1); + assert_eq!(projection.sections.len(), 1); + assert_eq!(projection.assignments.len(), 1); + assert_eq!(projection.reader_key_envelope, "owner-envelope-epoch-1"); + let retained_event: serde_json::Value = sqlx::query_scalar( + "SELECT signed_event_json FROM section_actions WHERE community_id = $1 \ + AND owner_pubkey = $2 AND action_id = $3", + ) + .bind(community.as_uuid()) + .bind(owner) + .bind(import.action_id) + .fetch_one(&pool) + .await + .expect("load durable signed command"); + let retained_event = Event::from_json(retained_event.to_string()) + .expect("reconstruct retained signed command"); + assert!(retained_event.verify_id()); + assert!(retained_event.verify_signature()); + assert_eq!(retained_event.pubkey, owner_keys.public_key()); + assert_eq!( + retained_event.kind, + Kind::Custom(buzz_core::kind::KIND_SECTION_WORKSPACE_IMPORT as u16) + ); + assert_eq!( + store + .migration_marker(community, &owner, &owner) + .await + .expect("owner migration marker"), + Some(SectionWorkspaceMigrationMarker { + source_event_id: vec![0x11; 32], + source_hash: vec![0x22; 32], + }) + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn failed_import_rolls_back_and_different_second_import_is_rejected() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let owner_keys = Keys::generate(); + let owner = owner_keys.public_key().to_bytes(); + let store = SectionWorkspaceStore::new(pool.clone()); + let missing_channel = Uuid::new_v4(); + let mut import = import_fixture(Uuid::new_v4(), missing_channel, Uuid::new_v4()); + let signed_import = import_event(&owner_keys, &owner, &import); + + assert!(matches!( + store + .import_v1( + community, + &owner, + &owner, + &signed_import.0, + &signed_import.1, + &import + ) + .await, + Err(DbError::InvalidData(_)) + )); + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM section_workspaces WHERE community_id = $1 AND owner_pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(owner) + .fetch_one(&pool) + .await + .expect("count rolled-back workspace"); + assert_eq!(rows, 0, "failed import must leave no partial workspace"); + + make_channel(&pool, community, missing_channel).await; + let signed_import = import_event(&owner_keys, &owner, &import); + store + .import_v1( + community, + &owner, + &owner, + &signed_import.0, + &signed_import.1, + &import, + ) + .await + .expect("valid import after rollback"); + import.action_id = Uuid::new_v4(); + let second_import_event = import_event(&owner_keys, &owner, &import); + assert!(matches!( + store + .import_v1(community, &owner, &owner, &second_import_event.0, &second_import_event.1, &import) + .await, + Err(DbError::InvalidData(message)) if message == "workspace has already been migrated" + )); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workspace_access_and_import_are_confined_to_community() { + let pool = setup_pool().await; + let community_a = make_community(&pool).await; + let community_b = make_community(&pool).await; + let owner_keys = Keys::generate(); + let owner = owner_keys.public_key().to_bytes(); + let viewer = [8_u8; 32]; + let shared_channel_id = Uuid::new_v4(); + let shared_section_id = Uuid::new_v4(); + make_channel(&pool, community_a, shared_channel_id).await; + make_channel(&pool, community_b, shared_channel_id).await; + let store = SectionWorkspaceStore::new(pool); + let import = import_fixture(Uuid::new_v4(), shared_channel_id, shared_section_id); + let signed_import = import_event(&owner_keys, &owner, &import); + store + .import_v1( + community_a, + &owner, + &owner, + &signed_import.0, + &signed_import.1, + &import, + ) + .await + .expect("community A import"); + let grant_action = Uuid::new_v4(); + let signed_grant_1 = grant_event( + &owner_keys, + &owner, + &viewer, + SectionWorkspaceRole::Viewer, + 1, + "viewer-envelope-epoch-1", + grant_action, + ); + store + .grant( + community_a, + &owner, + &owner, + &viewer, + SectionWorkspaceRole::Viewer, + 1, + "viewer-envelope-epoch-1", + grant_action, + &signed_grant_1.0, + &signed_grant_1.1, + ) + .await + .expect("community A viewer grant"); + + assert!(store + .projection(community_a, &owner, &viewer) + .await + .expect("community A projection") + .is_some()); + assert!(store + .projection(community_b, &owner, &viewer) + .await + .expect("community B projection") + .is_none()); + + let import_b = import_fixture(Uuid::new_v4(), shared_channel_id, shared_section_id); + let import_b_event = import_event(&owner_keys, &owner, &import_b); + assert_eq!( + store + .import_v1( + community_b, + &owner, + &owner, + &import_b_event.0, + &import_b_event.1, + &import_b + ) + .await + .expect("community B import"), + ImportOutcome::Imported { revision: 1 } + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn grant_epoch_and_revoke_rotation_are_atomic() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let owner_keys = Keys::generate(); + let owner = owner_keys.public_key().to_bytes(); + let revoked = [13_u8; 32]; + let remaining = [14_u8; 32]; + let channel_id = Uuid::new_v4(); + let section_id = Uuid::new_v4(); + make_channel(&pool, community, channel_id).await; + let store = SectionWorkspaceStore::new(pool); + let import = import_fixture(Uuid::new_v4(), channel_id, section_id); + let signed_import = import_event(&owner_keys, &owner, &import); + store + .import_v1( + community, + &owner, + &owner, + &signed_import.0, + &signed_import.1, + &import, + ) + .await + .expect("import"); + + let wrong_action = Uuid::new_v4(); + let signed_grant_2 = grant_event( + &owner_keys, + &owner, + &revoked, + SectionWorkspaceRole::Viewer, + 2, + "wrong-epoch-envelope", + wrong_action, + ); + assert!(matches!( + store + .grant( + community, + &owner, + &owner, + &revoked, + SectionWorkspaceRole::Viewer, + 2, + "wrong-epoch-envelope", + wrong_action, + &signed_grant_2.0, + &signed_grant_2.1, + ) + .await, + Err(DbError::InvalidData(_)) + )); + for reader in [&revoked, &remaining] { + let grant_action = Uuid::new_v4(); + let signed_grant_3 = grant_event( + &owner_keys, + &owner, + reader, + SectionWorkspaceRole::Viewer, + 1, + "viewer-envelope-epoch-1", + grant_action, + ); + store + .grant( + community, + &owner, + &owner, + reader, + SectionWorkspaceRole::Viewer, + 1, + "viewer-envelope-epoch-1", + grant_action, + &signed_grant_3.0, + &signed_grant_3.1, + ) + .await + .expect("viewer grant"); + } + + let action_id = Uuid::new_v4(); + let sections = [RotatedSectionMetadata { + section_id, + encrypted_label: "label-epoch-2".into(), + encrypted_icon: None, + }]; + let envelopes = [ + KeyEnvelopeUpdate { + reader_pubkey: owner.to_vec(), + envelope: "owner-envelope-epoch-2".into(), + }, + KeyEnvelopeUpdate { + reader_pubkey: remaining.to_vec(), + envelope: "remaining-envelope-epoch-2".into(), + }, + ]; + let signed_revoke_1 = revoke_event( + &owner_keys, + &owner, + &revoked, + 2, + §ions, + &envelopes, + action_id, + ); + assert_eq!( + store + .revoke_and_rotate( + community, + &owner, + &owner, + &revoked, + 2, + §ions, + &envelopes, + action_id, + &signed_revoke_1.0, + &signed_revoke_1.1, + ) + .await + .expect("revoke and rotate"), + GrantMutationOutcome::Applied { revision: 4 } + ); + assert!(store + .projection(community, &owner, &revoked) + .await + .expect("revoked projection") + .is_none()); + let remaining_projection = store + .projection(community, &owner, &remaining) + .await + .expect("remaining projection") + .expect("remaining reader stays authorized"); + assert_eq!(remaining_projection.revision, 4); + assert_eq!(remaining_projection.key_epoch, 2); + assert_eq!( + remaining_projection.sections[0].encrypted_label, + "label-epoch-2" + ); + assert_eq!( + remaining_projection.reader_key_envelope, + "remaining-envelope-epoch-2" + ); + let signed_revoke_2 = revoke_event( + &owner_keys, + &owner, + &revoked, + 2, + §ions, + &envelopes, + action_id, + ); + assert_eq!( + store + .revoke_and_rotate( + community, + &owner, + &owner, + &revoked, + 2, + §ions, + &envelopes, + action_id, + &signed_revoke_2.0, + &signed_revoke_2.1, + ) + .await + .expect("exact revoke retry"), + GrantMutationOutcome::AlreadyApplied { revision: 4 } + ); + } +} diff --git a/docs/nips/NIP-SW.fixtures.json b/docs/nips/NIP-SW.fixtures.json new file mode 100644 index 000000000..2f6e2a6f0 --- /dev/null +++ b/docs/nips/NIP-SW.fixtures.json @@ -0,0 +1,200 @@ +{ + "version": 1, + "kinds": { + "import_v1": 9050, + "grant": 9051, + "revoke": 9052, + "move": 9053, + "manage": 9054, + "projection": 30623 + }, + "roles": [ + "viewer", + "mover", + "manager" + ], + "limits": { + "sections": 100, + "assignments": 1000, + "grants": 256, + "encrypted_metadata_bytes": 65535, + "key_envelope_bytes": 4096 + }, + "projection_cases": [ + { + "name": "migrated-owner-and-viewer", + "projection": { + "version": 1, + "owner_pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "revision": 4, + "layout_revision": 1, + "key_epoch": 2, + "migration": { + "source_event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "source_hash": "3333333333333333333333333333333333333333333333333333333333333333" + }, + "reader_key_envelope": "nip44-envelope-reader-epoch-2", + "sections": [ + { + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "rank": 0, + "encrypted_label": "ciphertext-alpha", + "encrypted_icon": null + }, + { + "id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "rank": 1, + "encrypted_label": "ciphertext-beta", + "encrypted_icon": "ciphertext-icon" + } + ], + "assignments": [ + { + "channel_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "section_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "revision": 1 + } + ] + }, + "expect": "accept" + }, + { + "name": "revision-gap", + "previous_revision": 4, + "projection": { + "version": 1, + "revision": 6 + }, + "expect": "refetch" + }, + { + "name": "stale-projection", + "previous_revision": 4, + "projection": { + "version": 1, + "revision": 3 + }, + "expect": "ignore" + } + ], + "role_matrix": { + "owner": [ + "read", + "grant", + "revoke", + "move", + "manage", + "import_v1" + ], + "viewer": [ + "read" + ], + "mover": [ + "read", + "move" + ], + "manager": [ + "read", + "move", + "manage" + ] + }, + "canonicalization": { + "profile": "nip-sw-canonical-json-v1", + "legacy_plaintext": { + "input": { + "version": 1, + "sections": [ + { + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "name": "Alpha", + "icon": "folder", + "order": 0 + } + ], + "assignments": { + "cccccccc-cccc-4ccc-8ccc-cccccccccccc": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + } + }, + "canonical": "{\"assignments\":{\"cccccccc-cccc-4ccc-8ccc-cccccccccccc\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"},\"sections\":[{\"icon\":\"folder\",\"id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"name\":\"Alpha\",\"order\":0}],\"version\":1}", + "sha256": "336ba846d8a2aef9ca7de80e494201e503b64fa364ec1a629873f3fa83232d5d" + }, + "import_command": { + "canonical": "{\"action_id\":\"dddddddd-dddd-4ddd-8ddd-dddddddddddd\",\"assignments\":[{\"channel_id\":\"cccccccc-cccc-4ccc-8ccc-cccccccccccc\",\"section_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}],\"key_epoch\":1,\"owner_key_envelope\":\"nip44-owner-envelope\",\"sections\":[{\"encrypted_icon\":null,\"encrypted_label\":\"ciphertext-label\",\"id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"rank\":0}],\"source_event_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"source_hash\":\"336ba846d8a2aef9ca7de80e494201e503b64fa364ec1a629873f3fa83232d5d\",\"version\":1}", + "sha256": "c2801715fbdcd339b0ccf49a22c1efb111294e6af72cd0604a782ce692977099" + } + }, + "metadata_crypto": { + "algorithm": "AES-256-GCM", + "encoding": "aes256gcm::", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_base64url": "AAECAwQFBgcICQoL", + "plaintext_utf8": "Alpha", + "aad_canonical": "{\"community\":\"relay.example\",\"key_epoch\":1,\"owner_pubkey\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"purpose\":\"label\",\"section_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"version\":1}", + "envelope": "aes256gcm:AAECAwQFBgcICQoL:Bm6mc6SHvhxcEB6Q1Lc56VuJZjD7", + "reject_if_aad_changes": [ + "community", + "owner_pubkey", + "section_id", + "key_epoch", + "purpose" + ] + }, + "command_cases": [ + { + "name": "grant-accept", + "command": { + "version": 1, + "action_id": "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + "actor_pubkey": "4444444444444444444444444444444444444444444444444444444444444444", + "role": "viewer", + "key_epoch": 1, + "key_envelope": "nip44-viewer-envelope" + }, + "expect": "accept" + }, + { + "name": "grant-unknown-field", + "command": { + "version": 1, + "action_id": "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + "actor_pubkey": "4444444444444444444444444444444444444444444444444444444444444444", + "role": "viewer", + "key_epoch": 1, + "key_envelope": "nip44-viewer-envelope", + "extra": true + }, + "expect": "reject" + }, + { + "name": "revoke-accept", + "command": { + "version": 1, + "action_id": "ffffffff-ffff-4fff-8fff-ffffffffffff", + "actor_pubkey": "4444444444444444444444444444444444444444444444444444444444444444", + "new_key_epoch": 2, + "sections": [ + { + "section_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "encrypted_label": "ciphertext-label-epoch-2", + "encrypted_icon": null + } + ], + "envelopes": [ + { + "reader_pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "key_envelope": "nip44-owner-epoch-2" + } + ] + }, + "expect": "accept" + }, + { + "name": "action-id-different-hash", + "same_action_id": true, + "first_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "retry_hash": "0101010101010101010101010101010101010101010101010101010101010101", + "expect": "reject" + } + ] +} diff --git a/docs/nips/NIP-SW.md b/docs/nips/NIP-SW.md new file mode 100644 index 000000000..41be3c001 --- /dev/null +++ b/docs/nips/NIP-SW.md @@ -0,0 +1,134 @@ +NIP-SW +====== + +Owner-Scoped Channel-Section Workspaces +--------------------------------------- + +`draft` `optional` `relay` + +**Depends on**: NIP-01 (event format and addressable events), NIP-42 (authenticated relay connections), NIP-44 (pairwise key wrapping) + +## Abstract + +NIP-SW defines one relay-authoritative channel-section workspace per `(community, owner pubkey)`. Owners grant exact pubkeys `viewer`, `mover`, or `manager` access. Actors sign typed commands as themselves; the relay authorizes and applies commands transactionally to normalized state. The relay publishes a revisioned projection only to the owner and active grantees. + +The relay sees structural section UUIDs, ordering, and channel assignments. Section names and icons are ciphertext under a random per-workspace content key. That key is wrapped separately to each authorized reader using NIP-44; it grants confidentiality access, never mutation authority. + +This design deliberately does not permit delegates to replace the legacy kind-30078 `d=channel-sections` whole-store blob. Typed normalized commands prevent one stale writer from replacing unrelated state. + +## Kinds + +| Kind | Name | Signer | Storage | Stage | +|------|------|--------|---------|-------| +| `9050` | Import v1 | owner | command, not ordinary event storage | 1 | +| `9051` | Grant | owner | command, not ordinary event storage | 1 | +| `9052` | Revoke + rotate | owner | command, not ordinary event storage | 1 | +| `9053` | Move channel | owner, mover, manager | command, not ordinary event storage | 2 | +| `9054` | Manage sections | owner, manager | command, not ordinary event storage | 3 | +| `30623` | Workspace projection | relay | addressable/current projection | 1 | + +The allocation was checked against this repository and the upstream `nostr-protocol/nips` event-kind table at commit `656cecc7c0a815b6a2b218d3b5d6f078b3f4dbab`: `9050`–`9054` and `30623` were unassigned upstream and unused in Buzz. These are Buzz-specific kinds; a future upstream collision requires a protocol revision. + +## Coordinate and authority + +The server-resolved community and command's owner pubkey identify a workspace. A client never supplies a community UUID. The relay derives community from the authenticated connection's host. + +- Owner authority is implicit and immutable. +- A `viewer` may read the projection. +- A `mover` may read and submit kind 9053. +- A `manager` may read and submit kinds 9053 and 9054. +- Only the owner may submit kinds 9050, 9051, and 9052. + +Every command carries a UUID `action_id`. The durable action row binds `(community, owner, action_id)` to a canonical command SHA-256. An exact retry returns its original revision. Reusing an action ID for different bytes is rejected. The relay durably stores the complete verified signed event JSON beside its event ID and command hash; this is the rebuild source, while normalized rows are the serving projection. + +## Stage 1 command bodies + +Command content uses the `nip-sw-canonical-json-v1` profile: recursively sort object keys by Unicode code-point order, preserve array order, emit UTF-8 with JSON escaping and no insignificant whitespace, and permit only integer JSON numbers. Strict typed decoding rejects duplicate and unknown keys before canonicalization. SHA-256 is computed over these exact UTF-8 bytes. Legacy plaintext uses the same profile before `source_hash` is computed. `NIP-SW.fixtures.json` contains byte-exact plaintext and command hash vectors. Tags provide routing and coarse filtering, but content defines the command and is validated before mutation. Every command has exactly one `p` tag naming the owner and exactly one `action` tag matching content's `action_id`. + +### Revision-zero import (`kind:9050`) + +```jsonc +{ + "kind": 9050, + "tags": [ + ["p", ""], + ["action", ""] + ], + "content": "{\"version\":1,\"action_id\":\"\",\"source_event_id\":\"<64-lower-hex>\",\"source_hash\":\"\",\"key_epoch\":1,\"owner_key_envelope\":\"\",\"sections\":[...],\"assignments\":[...]}" +} +``` + +Import requirements: + +- signer equals owner; +- workspace revision is zero and has no migration marker; +- `key_epoch` is 1; +- section IDs are non-nil and unique; +- ranks are exactly the permutation `0..section_count`; +- channels are unique, exist in the same community, and are not deleted; +- assignment destinations name imported sections; +- maximum 100 sections, 1,000 assignments, and 256 active delegate grants; +- repeated identical `action_id` + command hash is idempotent; any different import after migration is rejected. + +The relay atomically writes normalized sections, assignments, the owner's envelope, revision 1, layout revision 1, and a migration marker containing the source event ID and canonical plaintext hash. + +### Grant (`kind:9051`) + +```jsonc +{ + "kind": 9051, + "tags": [["p", ""], ["actor", ""], ["action", ""]], + "content": "{\"version\":1,\"action_id\":\"\",\"role\":\"viewer\",\"key_epoch\":,\"key_envelope\":\"\"}" +} +``` + +The content also repeats `actor_pubkey` and it MUST equal the single `actor` tag. Stage 1 clients expose `viewer`, but the durable role vocabulary is frozen as `viewer|mover|manager` for later stages. The signer MUST equal owner. Grant and current-epoch envelope installation are one transaction. + +### Revoke and rotate (`kind:9052`) + +```jsonc +{ + "kind": 9052, + "tags": [["p", ""], ["actor", ""], ["action", ""]], + "content": "{\"version\":1,\"action_id\":\"\",\"new_key_epoch\":,\"sections\":[],\"envelopes\":[]}" +} +``` + +The content also repeats `actor_pubkey` and it MUST equal the single `actor` tag. Revocation is atomic: mark the exact grant revoked, advance the key epoch by one, replace every active section's encrypted label/icon, and install exactly one envelope for the owner and each remaining active grantee. The revoked reader must not receive a new envelope. Revocation prevents future reads and commands; it cannot erase plaintext or keys a reader already retained. + +## Metadata encryption + +The workspace content key is exactly 32 random bytes. Labels and icons use AES-256-GCM with a fresh random 12-byte nonce for every encryption. The wire envelope is `aes256gcm::`. Plaintext is UTF-8. + +AAD is the canonical JSON UTF-8 encoding of `{"version":1,"community":"","owner_pubkey":"<64-lower-hex>","section_id":"","key_epoch":,"purpose":"label|icon"}`. Clients MUST reject authentication failure and MUST NOT retry with weaker or omitted AAD. Binding community, owner, section, epoch, and purpose prevents cross-tenant, cross-owner, cross-section, stale-epoch, and label/icon substitution. `NIP-SW.fixtures.json` contains a deterministic AES-GCM vector and enumerates every AAD mutation that must fail. + +NIP-44 is used only to pairwise-wrap the 32-byte workspace key; its standard versioned encoding remains unchanged. + +## Projection (`kind:30623`) + +The relay signs a current projection after each accepted command. It is addressable by `d=` and carries one `p` tag per currently authorized reader, including the owner. Its JSON content follows `NIP-SW.fixtures.json`. + +Projection fields are `version`, `owner_pubkey`, monotonic `revision`, `layout_revision`, `key_epoch`, migration marker, the requesting reader's pairwise key envelope, ordered encrypted sections, and current assignments. Because envelopes differ per reader, the relay synthesizes a reader-specific response rather than exposing one shared stored event body. + +Read authorization is applied at every delivery surface: historical REQ, live subscription/fan-out, HTTP query, and search. A filter-level gate alone is insufficient because an attacker may query a known event ID. Revocation evicts affected live subscriptions immediately. + +Clients persist only verified projections. A projection at or below the cached revision is ignored. A gap greater than one causes a full authorized refetch. During relay outage, the last verified cache may render but is stale and cannot become a replacement write. + +## Migration and cutover + +An updated owner client decrypts the newest valid kind-30078 `d=channel-sections` event, computes the canonical plaintext hash, creates the workspace content key and owner envelope, then submits kind 9050. It switches authority only after reading back the matching migration marker and projection. Once the marker exists, updated clients MUST NOT publish the legacy section blob again. The old event remains a read-only rollback artifact during the compatibility window. + +Delegated moves remain feature-gated until supported desktop and mobile clients both honor this marker. Dual-writing is forbidden: an old whole blob cannot safely represent later normalized commands. + +## Shared fixtures + +`NIP-SW.fixtures.json` is the cross-platform oracle for kinds, roles, limits, projection shape, revision-gap behavior, and the role matrix. Rust, desktop, and mobile tests consume this same file. A protocol change is incomplete until the fixture and all consumers change together. + +## Security and privacy + +- The owner private key is never shared. Delegates sign as themselves. +- NIP-44 envelopes grant decryption only; relay authorization grants mutation. +- The relay learns delegation, section UUID/order, and assignments as an accepted product tradeoff, but not labels/icons. +- Authorization is checked at command ingest time, so a command created offline by a subsequently revoked actor fails. +- Community is server-resolved and included in every database key. Same owner/section/channel UUIDs in another community are unrelated. +- Signed command events and durable action hashes provide audit/rebuild evidence; wall-clock time never resolves semantic conflicts. diff --git a/migrations/0031_section_workspaces.sql b/migrations/0031_section_workspaces.sql new file mode 100644 index 000000000..bd7d56146 --- /dev/null +++ b/migrations/0031_section_workspaces.sql @@ -0,0 +1,107 @@ +-- Relay-authoritative owner-scoped channel-section workspaces. +SET LOCAL lock_timeout = '5s'; + +CREATE TABLE section_workspaces ( + community_id UUID NOT NULL REFERENCES communities(id), + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0), + layout_revision BIGINT NOT NULL DEFAULT 0 CHECK (layout_revision >= 0), + key_epoch BIGINT NOT NULL DEFAULT 0 CHECK (key_epoch >= 0), + migrated_at TIMESTAMPTZ, + migration_source_event_id BYTEA CHECK (migration_source_event_id IS NULL OR length(migration_source_event_id) = 32), + migration_source_hash BYTEA CHECK (migration_source_hash IS NULL OR length(migration_source_hash) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey), + CHECK ((migrated_at IS NULL) = (migration_source_event_id IS NULL)), + CHECK ((migrated_at IS NULL) = (migration_source_hash IS NULL)), + CHECK ((migrated_at IS NULL) = (revision = 0)) +); + +CREATE TABLE section_grants ( + community_id UUID NOT NULL, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), + role TEXT NOT NULL CHECK (role IN ('viewer', 'mover', 'manager')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')), + granted_by BYTEA NOT NULL CHECK (length(granted_by) = 32), + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + PRIMARY KEY (community_id, owner_pubkey, actor_pubkey), + FOREIGN KEY (community_id, owner_pubkey) + REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE, + CHECK (actor_pubkey <> owner_pubkey), + CHECK ((status = 'revoked') = (revoked_at IS NOT NULL)) +); +CREATE INDEX section_grants_actor_active + ON section_grants (community_id, actor_pubkey, owner_pubkey) + WHERE status = 'active'; + +CREATE TABLE section_key_envelopes ( + community_id UUID NOT NULL, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + reader_pubkey BYTEA NOT NULL CHECK (length(reader_pubkey) = 32), + key_epoch BIGINT NOT NULL CHECK (key_epoch > 0), + envelope TEXT NOT NULL CHECK (octet_length(envelope) BETWEEN 1 AND 4096), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey, reader_pubkey, key_epoch), + FOREIGN KEY (community_id, owner_pubkey) + REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE +); + +CREATE TABLE sections ( + community_id UUID NOT NULL, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + section_id UUID NOT NULL CHECK (section_id <> '00000000-0000-0000-0000-000000000000'::uuid), + encrypted_label TEXT NOT NULL CHECK (octet_length(encrypted_label) BETWEEN 1 AND 65535), + encrypted_icon TEXT CHECK (encrypted_icon IS NULL OR octet_length(encrypted_icon) BETWEEN 1 AND 65535), + rank INTEGER NOT NULL CHECK (rank BETWEEN 0 AND 99), + revision BIGINT NOT NULL CHECK (revision > 0), + deleted_at TIMESTAMPTZ, + PRIMARY KEY (community_id, owner_pubkey, section_id), + FOREIGN KEY (community_id, owner_pubkey) + REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE +); +CREATE UNIQUE INDEX sections_active_rank + ON sections (community_id, owner_pubkey, rank) WHERE deleted_at IS NULL; + +CREATE TABLE section_assignments ( + community_id UUID NOT NULL, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + channel_id UUID NOT NULL, + section_id UUID NOT NULL, + revision BIGINT NOT NULL CHECK (revision > 0), + updated_by BYTEA NOT NULL CHECK (length(updated_by) = 32), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey, channel_id), + FOREIGN KEY (community_id, owner_pubkey, section_id) + REFERENCES sections(community_id, owner_pubkey, section_id) ON DELETE CASCADE, + FOREIGN KEY (community_id, channel_id) + REFERENCES channels(community_id, id) ON DELETE CASCADE +); + +CREATE TABLE section_actions ( + community_id UUID NOT NULL, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + action_id UUID NOT NULL, + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), + command_kind TEXT NOT NULL CHECK (command_kind IN ( + 'import_v1', 'grant', 'revoke', 'assign_channel', 'unassign_channel', + 'create_section', 'rename_section', 'delete_section', 'reorder_sections' + )), + command_event_id BYTEA NOT NULL CHECK (length(command_event_id) = 32), + signed_event_json JSONB NOT NULL CHECK (jsonb_typeof(signed_event_json) = 'object'), + command_hash BYTEA NOT NULL CHECK (length(command_hash) = 32), + resulting_revision BIGINT NOT NULL CHECK (resulting_revision > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey, action_id), + FOREIGN KEY (community_id, owner_pubkey) + REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE +); + +SELECT attach_community_write_fence('section_workspaces'); +SELECT attach_community_write_fence('section_grants'); +SELECT attach_community_write_fence('section_key_envelopes'); +SELECT attach_community_write_fence('sections'); +SELECT attach_community_write_fence('section_assignments'); +SELECT attach_community_write_fence('section_actions'); diff --git a/schema/schema.sql b/schema/schema.sql index 9ef7bc0a4..7f37a5b36 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -160,6 +160,108 @@ CREATE TABLE channel_members ( CREATE INDEX idx_channel_members_pubkey ON channel_members (community_id, pubkey) WHERE removed_at IS NULL; +-- ── Section workspaces ─────────────────────────────────────────────────────── + +CREATE TABLE section_workspaces ( + community_id UUID NOT NULL REFERENCES communities(id), + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0), + layout_revision BIGINT NOT NULL DEFAULT 0 CHECK (layout_revision >= 0), + key_epoch BIGINT NOT NULL DEFAULT 0 CHECK (key_epoch >= 0), + migrated_at TIMESTAMPTZ, + migration_source_event_id BYTEA CHECK (migration_source_event_id IS NULL OR length(migration_source_event_id) = 32), + migration_source_hash BYTEA CHECK (migration_source_hash IS NULL OR length(migration_source_hash) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey), + CHECK ((migrated_at IS NULL) = (migration_source_event_id IS NULL)), + CHECK ((migrated_at IS NULL) = (migration_source_hash IS NULL)), + CHECK ((migrated_at IS NULL) = (revision = 0)) +); + +CREATE TABLE section_grants ( + community_id UUID NOT NULL, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), + role TEXT NOT NULL CHECK (role IN ('viewer', 'mover', 'manager')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')), + granted_by BYTEA NOT NULL CHECK (length(granted_by) = 32), + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + PRIMARY KEY (community_id, owner_pubkey, actor_pubkey), + FOREIGN KEY (community_id, owner_pubkey) + REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE, + CHECK (actor_pubkey <> owner_pubkey), + CHECK ((status = 'revoked') = (revoked_at IS NOT NULL)) +); +CREATE INDEX section_grants_actor_active + ON section_grants (community_id, actor_pubkey, owner_pubkey) + WHERE status = 'active'; + +CREATE TABLE section_key_envelopes ( + community_id UUID NOT NULL, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + reader_pubkey BYTEA NOT NULL CHECK (length(reader_pubkey) = 32), + key_epoch BIGINT NOT NULL CHECK (key_epoch > 0), + envelope TEXT NOT NULL CHECK (octet_length(envelope) BETWEEN 1 AND 4096), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey, reader_pubkey, key_epoch), + FOREIGN KEY (community_id, owner_pubkey) + REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE +); + +CREATE TABLE sections ( + community_id UUID NOT NULL, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + section_id UUID NOT NULL CHECK (section_id <> '00000000-0000-0000-0000-000000000000'::uuid), + encrypted_label TEXT NOT NULL CHECK (octet_length(encrypted_label) BETWEEN 1 AND 65535), + encrypted_icon TEXT CHECK (encrypted_icon IS NULL OR octet_length(encrypted_icon) BETWEEN 1 AND 65535), + rank INTEGER NOT NULL CHECK (rank BETWEEN 0 AND 99), + revision BIGINT NOT NULL CHECK (revision > 0), + deleted_at TIMESTAMPTZ, + PRIMARY KEY (community_id, owner_pubkey, section_id), + FOREIGN KEY (community_id, owner_pubkey) + REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE +); +CREATE UNIQUE INDEX sections_active_rank + ON sections (community_id, owner_pubkey, rank) WHERE deleted_at IS NULL; + +CREATE TABLE section_assignments ( + community_id UUID NOT NULL, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + channel_id UUID NOT NULL, + section_id UUID NOT NULL, + revision BIGINT NOT NULL CHECK (revision > 0), + updated_by BYTEA NOT NULL CHECK (length(updated_by) = 32), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey, channel_id), + FOREIGN KEY (community_id, owner_pubkey, section_id) + REFERENCES sections(community_id, owner_pubkey, section_id) ON DELETE CASCADE, + FOREIGN KEY (community_id, channel_id) + REFERENCES channels(community_id, id) ON DELETE CASCADE +); + +CREATE TABLE section_actions ( + community_id UUID NOT NULL, + owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32), + action_id UUID NOT NULL, + actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32), + command_kind TEXT NOT NULL CHECK (command_kind IN ( + 'import_v1', 'grant', 'revoke', 'assign_channel', 'unassign_channel', + 'create_section', 'rename_section', 'delete_section', 'reorder_sections' + )), + command_event_id BYTEA NOT NULL CHECK (length(command_event_id) = 32), + signed_event_json JSONB NOT NULL CHECK (jsonb_typeof(signed_event_json) = 'object'), + command_hash BYTEA NOT NULL CHECK (length(command_hash) = 32), + resulting_revision BIGINT NOT NULL CHECK (resulting_revision > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, owner_pubkey, action_id), + FOREIGN KEY (community_id, owner_pubkey) + REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE +); + + + -- ── Users ───────────────────────────────────────────────────────────────────── -- Conformance: "Users, profiles, NIP-05, and user search". One profile per -- (community, pubkey): the same key reposts kind:0 in each community it joins. @@ -1662,6 +1764,12 @@ SELECT attach_community_write_fence('reactions'); SELECT attach_community_write_fence('relay_invites'); SELECT attach_community_write_fence('relay_members'); SELECT attach_community_write_fence('scheduled_workflow_fires'); +SELECT attach_community_write_fence('section_actions'); +SELECT attach_community_write_fence('section_assignments'); +SELECT attach_community_write_fence('section_grants'); +SELECT attach_community_write_fence('section_key_envelopes'); +SELECT attach_community_write_fence('section_workspaces'); +SELECT attach_community_write_fence('sections'); SELECT attach_community_write_fence('subscriptions'); SELECT attach_community_write_fence('thread_metadata'); SELECT attach_community_write_fence('users');