From a7db35729566e8d5aed60e2ff4fcbe33421c66ea Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 21 Aug 2026 03:54:47 +0000 Subject: [PATCH] feat(relay): import owner section workspace Co-authored-by: Larry Signed-off-by: Logan Johnson --- crates/buzz-core/src/filter.rs | 2 +- crates/buzz-core/src/kind.rs | 21 +- crates/buzz-core/src/lib.rs | 1 + crates/buzz-core/src/section_workspace.rs | 181 +++++++ crates/buzz-db/src/migration.rs | 7 +- crates/buzz-relay/src/config.rs | 5 + .../src/handlers/command_executor.rs | 5 +- crates/buzz-relay/src/handlers/event.rs | 3 +- crates/buzz-relay/src/handlers/ingest.rs | 2 + crates/buzz-relay/src/handlers/mod.rs | 2 + crates/buzz-relay/src/handlers/req.rs | 6 +- .../src/handlers/section_workspace.rs | 490 ++++++++++++++++++ .../buzz-relay/src/handlers/side_effects.rs | 29 +- crates/buzz-search/tests/fts_integration.rs | 12 +- .../tests/e2e_section_workspace.rs | 239 +++++++++ docs/nips/NIP-SW.fixtures.json | 23 + docs/nips/NIP-SW.md | 52 ++ .../0033_section_workspace_projection_fts.sql | 20 + schema/schema.sql | 4 +- 19 files changed, 1090 insertions(+), 14 deletions(-) create mode 100644 crates/buzz-core/src/section_workspace.rs create mode 100644 crates/buzz-relay/src/handlers/section_workspace.rs create mode 100644 crates/buzz-test-client/tests/e2e_section_workspace.rs create mode 100644 docs/nips/NIP-SW.fixtures.json create mode 100644 docs/nips/NIP-SW.md create mode 100644 migrations/0033_section_workspace_projection_fts.sql diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index 32e3a7ad16..389b56b7ec 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -22,7 +22,7 @@ pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { /// a known event id) still cannot read another user's private event. pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str) -> bool { let kind = crate::kind::event_kind_u32(event); - if kind != crate::kind::KIND_DM_VISIBILITY && kind != crate::kind::KIND_AGENT_TURN_METRIC { + if !crate::kind::RESULT_GATED_KINDS.contains(&kind) { return true; } let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913..3403f1e9cb 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -73,6 +73,10 @@ pub const KIND_USER_STATUS: u32 = 30315; /// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped. /// Content is NIP-44 encrypted to the user's own keypair. pub const KIND_READ_STATE: u32 = 30078; +/// NIP-SW: owner-signed revision-zero section workspace import command. +pub const KIND_SECTION_WORKSPACE_IMPORT: u32 = 9050; +/// NIP-SW: relay-signed, reader-specific current section workspace projection. +pub const KIND_SECTION_WORKSPACE_PROJECTION: u32 = 30623; /// NIP-42 auth event — never stored (carries bearer tokens). pub const KIND_AUTH: u32 = 22242; /// BUD-01: Blossom upload auth (used in upload.rs, not stored). @@ -139,7 +143,12 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ /// /// Used by `filter_can_match_result_gated_kinds` to force the per-event /// fallback path in COUNT rather than the fast SQL `count_events()`. -pub const RESULT_GATED_KINDS: &[u32] = &[KIND_DM_VISIBILITY, KIND_AGENT_TURN_METRIC]; +pub const RESULT_GATED_KINDS: &[u32] = &[ + KIND_DM_VISIBILITY, + KIND_AGENT_TURN_METRIC, + KIND_SECTION_WORKSPACE_IMPORT, + KIND_SECTION_WORKSPACE_PROJECTION, +]; /// Kinds whose stored events have `#p`-bound read access — readable only by /// subscribers whose pubkey appears in the event's `#p` tag. @@ -162,12 +171,20 @@ pub const P_GATED_KINDS: &[u32] = &[ KIND_MEMBER_REMOVED_NOTIFICATION, KIND_GIFT_WRAP, KIND_DM_VISIBILITY, + KIND_SECTION_WORKSPACE_IMPORT, + KIND_SECTION_WORKSPACE_PROJECTION, // NIP-AM: agent turn metrics are encrypted to the owner and must not be // readable by any unauthenticated or non-owner party, including via `ids` // filters — see NIP-AM §Relay Behavior. KIND_AGENT_TURN_METRIC, ]; +/// Immutable Stage 1 section-workspace migration records. +pub const IMMUTABLE_SECTION_WORKSPACE_KINDS: &[u32] = &[ + KIND_SECTION_WORKSPACE_IMPORT, + KIND_SECTION_WORKSPACE_PROJECTION, +]; + /// NIP-AP: Agent Persona (parameterized replaceable, owner-authored). /// /// Persona definition event published by the workspace owner. Addressed by @@ -822,6 +839,7 @@ pub const fn is_command_kind(kind: u32) -> bool { | KIND_WORKFLOW_TRIGGER | KIND_APPROVAL_GRANT | KIND_APPROVAL_DENY + | KIND_SECTION_WORKSPACE_IMPORT ) } @@ -834,6 +852,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { | KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT | KIND_DM_VISIBILITY + | KIND_SECTION_WORKSPACE_PROJECTION | KIND_THREAD_SUMMARY | KIND_WINDOW_BOUNDS ) diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 36dc772da3..0b2b3e9644 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -38,6 +38,7 @@ pub mod presence; pub mod private_managed_agent; /// Canonical relay runtime identities. pub mod relay; +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 0000000000..11d3aa2571 --- /dev/null +++ b/crates/buzz-core/src/section_workspace.rs @@ -0,0 +1,181 @@ +//! Stage-one owner import and reader-specific section-workspace projection. +#![allow(missing_docs)] + +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use thiserror::Error; +use uuid::Uuid; +pub const VERSION: u32 = 1; +pub const MAX_SECTIONS: usize = 100; +pub const MAX_ASSIGNMENTS: usize = 1_000; +pub const MAX_ENCRYPTED_METADATA_BYTES: usize = 65_535; +pub const MAX_KEY_ENVELOPE_BYTES: usize = 4_096; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImportSection { + pub id: Uuid, + pub rank: u32, + pub encrypted_label: String, + pub encrypted_icon: Option, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImportAssignment { + pub channel_id: Uuid, + pub section_id: Uuid, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Import { + pub version: u32, + pub action_id: Uuid, + pub source_event_id: String, + pub source_hash: String, + pub key_epoch: u64, + pub owner_key_envelope: String, + pub sections: Vec, + pub assignments: Vec, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MigrationMarker { + pub source_event_id: String, + pub source_hash: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectedAssignment { + pub channel_id: Uuid, + pub section_id: Uuid, + pub revision: u64, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Projection { + pub version: u32, + pub owner_pubkey: String, + pub revision: u64, + pub layout_revision: u64, + pub key_epoch: u64, + pub migration: MigrationMarker, + pub reader_key_envelope: String, + pub sections: Vec, + pub assignments: Vec, +} +impl Import { + pub fn validate(&self) -> Result<(), ValidationError> { + if self.version != VERSION { + return Err(ValidationError::Invalid("version")); + } + if self.action_id.is_nil() { + return Err(ValidationError::Invalid("action_id")); + } + if self.key_epoch != 1 { + return Err(ValidationError::Invalid("key_epoch")); + } + hex32(&self.source_event_id, "source_event_id")?; + hex32(&self.source_hash, "source_hash")?; + bounded( + &self.owner_key_envelope, + MAX_KEY_ENVELOPE_BYTES, + "owner_key_envelope", + )?; + if self.sections.len() > MAX_SECTIONS { + return Err(ValidationError::TooMany("sections", MAX_SECTIONS)); + } + if self.assignments.len() > MAX_ASSIGNMENTS { + return Err(ValidationError::TooMany("assignments", MAX_ASSIGNMENTS)); + } + 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(ValidationError::Invalid("section id")); + } + if section.rank as usize >= self.sections.len() || !ranks.insert(section.rank) { + return Err(ValidationError::Invalid("section rank")); + } + bounded( + §ion.encrypted_label, + MAX_ENCRYPTED_METADATA_BYTES, + "encrypted_label", + )?; + if let Some(icon) = §ion.encrypted_icon { + bounded(icon, MAX_ENCRYPTED_METADATA_BYTES, "encrypted_icon")?; + } + } + let mut channels = HashSet::with_capacity(self.assignments.len()); + if self.assignments.iter().any(|assignment| { + assignment.channel_id.is_nil() + || !channels.insert(assignment.channel_id) + || !ids.contains(&assignment.section_id) + }) { + return Err(ValidationError::Invalid("assignment")); + } + Ok(()) + } + pub fn owner_projection(&self, owner_pubkey: String) -> Projection { + Projection { + version: VERSION, + owner_pubkey, + revision: 1, + layout_revision: 1, + key_epoch: self.key_epoch, + migration: MigrationMarker { + source_event_id: self.source_event_id.clone(), + source_hash: self.source_hash.clone(), + }, + reader_key_envelope: self.owner_key_envelope.clone(), + sections: self.sections.clone(), + assignments: self + .assignments + .iter() + .map(|assignment| ProjectedAssignment { + channel_id: assignment.channel_id, + section_id: assignment.section_id, + revision: 1, + }) + .collect(), + } + } +} +pub fn validate_import_tags( + tags: &[nostr::Tag], + owner: &str, + action_id: Uuid, +) -> Result<(), ValidationError> { + let expected_action = action_id.to_string(); + let fields: Vec> = tags.iter().map(|tag| tag.as_slice().to_vec()).collect(); + if fields + != [ + vec!["p".into(), owner.into()], + vec!["action".into(), expected_action], + ] + { + return Err(ValidationError::Invalid("tags")); + } + Ok(()) +} +fn hex32(value: &str, field: &'static str) -> Result<(), ValidationError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ValidationError::Invalid(field)); + } + Ok(()) +} +fn bounded(value: &str, max: usize, field: &'static str) -> Result<(), ValidationError> { + if value.is_empty() || value.len() > max { + return Err(ValidationError::Invalid(field)); + } + Ok(()) +} +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ValidationError { + #[error("invalid {0}")] + Invalid(&'static str), + #[error("{0} exceeds maximum {1}")] + TooMany(&'static str, usize), +} diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 94c7aea2fa..31eb447196 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -640,7 +640,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 32); + assert_eq!(migrations.len(), 33); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1061,6 +1061,11 @@ mod tests { assert!(roster_fence.contains("NEW.kind <> 39002")); assert!(roster_fence.contains("'buzz_channel_membership:'")); assert!(roster_fence.contains("cm.removed_at IS NULL")); + + assert_eq!(migrations[32].version, 33); + let workspace_fts = migrations[32].sql.as_str(); + assert!(workspace_fts.contains("kind IN (9050, 30623)")); + assert!(workspace_fts.contains("NULL::tsvector")); assert!(roster_fence.contains("cm.role::text")); assert!(roster_fence.contains("jsonb_array_length(roster_tag.tag_json) <> 4")); assert!(roster_fence.contains("roster_tag.tag_json->>3")); diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 037c6b1dd3..5a3f0079bc 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -231,6 +231,8 @@ pub struct Config { /// This does not control the separate `moderation_actions` audit trail. /// Set `BUZZ_AUDIT_ENABLED=false` for deployments that do not require it. pub audit_enabled: bool, + /// Enable the staged NIP-SW owner import path. Defaults off. + pub section_workspace_import_enabled: bool, /// Optional override for ephemeral channel TTL (in seconds). /// When set, any channel created with a TTL tag will use this value instead @@ -908,6 +910,8 @@ impl Config { let privacy_markdown = read_policy_markdown("BUZZ_PRIVACY_POLICY_MARKDOWN")?; let age_attestation_required = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED")?; let audit_enabled = parse_bool("BUZZ_AUDIT_ENABLED", true)?; + let section_workspace_import_enabled = + parse_optional_bool("BUZZ_SECTION_WORKSPACE_IMPORT_ENABLED")?; let join_policy = if terms_markdown.is_none() && privacy_markdown.is_none() && !age_attestation_required @@ -1024,6 +1028,7 @@ impl Config { media_max_concurrent_uploads_per_pubkey, media_uploads_per_minute, audit_enabled, + section_workspace_import_enabled, ephemeral_ttl_override, git_repo_path, git_pack_cache_path, diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index d8569a7a86..11cafbd697 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -70,6 +70,9 @@ pub async fn handle_command( KIND_WORKFLOW_TRIGGER => handle_workflow_trigger(tenant, state, &event, &auth).await, KIND_APPROVAL_GRANT => handle_approval_grant(tenant, state, &event, &auth).await, KIND_APPROVAL_DENY => handle_approval_deny(tenant, state, &event, &auth).await, + KIND_SECTION_WORKSPACE_IMPORT => { + super::section_workspace::handle_import(tenant, state, &event).await + } _ => Err(IngestError::Rejected(format!( "unknown command kind: {kind}" ))), @@ -1446,7 +1449,7 @@ mod tests { async fn persistence_test_context() -> (buzz_db::Db, TenantContext) { let url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 let pool = sqlx::PgPool::connect(&url) .await .expect("connect workflow persistence test database"); diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ccba40f328..893f84ab56 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -458,8 +458,7 @@ async fn dispatch_persistent_event_inner( // metrics), live fan-out must reach only the owner — a kindless `ids:[…]` // subscription can otherwise match it. Pull paths (HTTP /query, WS historical) // are gated separately by reader_authorized_for_event. - let owner_only_kind = kind_u32 == buzz_core::kind::KIND_DM_VISIBILITY - || kind_u32 == buzz_core::kind::KIND_AGENT_TURN_METRIC; + let owner_only_kind = buzz_core::kind::RESULT_GATED_KINDS.contains(&kind_u32); let private_event_owner: Option = owner_only_kind .then(|| { let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9629ffb0fd..1aca9addca 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -449,6 +449,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), KIND_WORKFLOW_DEF | KIND_WORKFLOW_TRIGGER => Ok(Scope::MessagesWrite), + buzz_core::kind::KIND_SECTION_WORKSPACE_IMPORT => Ok(Scope::UsersWrite), KIND_APPROVAL_GRANT | KIND_APPROVAL_DENY => Ok(Scope::MessagesWrite), _ => Err("restricted: unknown event kind"), } @@ -605,6 +606,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_AGENT_TURN_METRIC // NIP-PL leases are author-owned, addressable global state. | super::push_lease::KIND_PUSH_LEASE + | buzz_core::kind::KIND_SECTION_WORKSPACE_IMPORT ) } diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index 98a5e6c51d..d00b4ade6b 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -32,6 +32,8 @@ pub mod relay_admin; pub mod report; /// REQ handler — subscribe, deliver historical events, then EOSE. pub mod req; +/// NIP-SW owner import command. +pub mod section_workspace; /// NIP-29 and NIP-25 side-effect handlers. pub mod side_effects; diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 250fb4f9b9..1b41cf97b1 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -7,8 +7,8 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ - is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, - KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS, + is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, P_GATED_KINDS, + RESULT_GATED_KINDS, SHARED_GATED_KINDS, }; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; @@ -1202,7 +1202,7 @@ pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: let explicitly_no_ids_exemption = filter.kinds.as_ref().is_some_and(|ks| { ks.iter().any(|kind| { let k = kind.as_u16() as u32; - k == KIND_DM_VISIBILITY || k == KIND_AGENT_TURN_METRIC + RESULT_GATED_KINDS.contains(&k) }) }); if !explicitly_no_ids_exemption && filter.ids.as_ref().is_some_and(|ids| !ids.is_empty()) { diff --git a/crates/buzz-relay/src/handlers/section_workspace.rs b/crates/buzz-relay/src/handlers/section_workspace.rs new file mode 100644 index 0000000000..2a260c8c40 --- /dev/null +++ b/crates/buzz-relay/src/handlers/section_workspace.rs @@ -0,0 +1,490 @@ +//! NIP-SW revision-zero owner import. + +use super::event::dispatch_persistent_event; +use super::ingest::{IngestError, IngestResult}; +use crate::state::AppState; +use buzz_core::kind::{KIND_SECTION_WORKSPACE_IMPORT, KIND_SECTION_WORKSPACE_PROJECTION}; +use buzz_core::section_workspace::{validate_import_tags, Import}; +use buzz_core::tenant::TenantContext; +use nostr::{Event, EventBuilder, Kind, Tag, Timestamp}; +use sqlx::Row; +use std::sync::Arc; +/// Validate and atomically persist one owner import and its owner projection. +pub async fn handle_import( + tenant: &TenantContext, + state: &Arc, + event: &Event, +) -> Result { + require_enabled(state.config.section_workspace_import_enabled)?; + let owner = event.pubkey.to_hex(); + let import: Import = serde_json::from_str(&event.content) + .map_err(|error| IngestError::Rejected(format!("invalid: import content: {error}")))?; + import + .validate() + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + validate_import_tags(event.tags.as_slice(), &owner, import.action_id) + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + let coordinate = format!("{owner}:{owner}"); + let projection_content = serde_json::to_string(&import.owner_projection(owner.clone())) + .map_err(|error| IngestError::Internal(format!("error: serialize projection: {error}")))?; + let projection = EventBuilder::new( + Kind::Custom(KIND_SECTION_WORKSPACE_PROJECTION as u16), + projection_content, + ) + .tags([ + Tag::parse(["d", coordinate.as_str()]).map_err(|error| { + IngestError::Internal(format!("error: build projection d tag: {error}")) + })?, + Tag::parse(["p", owner.as_str()]).map_err(|error| { + IngestError::Internal(format!("error: build projection p tag: {error}")) + })?, + ]) + .custom_created_at(Timestamp::now()) + .sign_with_keys(&state.relay_keypair) + .map_err(|error| IngestError::Internal(format!("error: sign projection: {error}")))?; + let duplicate = persist_import(tenant, state, event, &import, &projection, &coordinate).await?; + if duplicate { + return Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: "duplicate: exact import replay".into(), + }); + } + let stored = state + .db + .get_event_by_id(tenant.community(), projection.id.as_bytes()) + .await + .map_err(|error| IngestError::Internal(format!("error: load projection: {error}")))? + .ok_or_else(|| IngestError::Internal("error: committed projection is missing".into()))?; + dispatch_persistent_event( + tenant, + state, + &stored, + KIND_SECTION_WORKSPACE_PROJECTION, + &state.relay_keypair.public_key().to_hex(), + None, + ) + .await; + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: String::new(), + }) +} +fn require_enabled(enabled: bool) -> Result<(), IngestError> { + if enabled { + Ok(()) + } else { + Err(IngestError::Rejected( + "restricted: section workspace import is disabled".into(), + )) + } +} +async fn persist_import( + tenant: &TenantContext, + state: &AppState, + event: &Event, + import: &Import, + projection: &Event, + coordinate: &str, +) -> Result { + let mut tx = state + .db + .begin_transaction() + .await + .map_err(|error| IngestError::Internal(format!("error: begin import: {error}")))?; + buzz_deletion::store(&state.db) + .guard_transaction(&mut tx, tenant.community()) + .await + .map_err(|error| { + IngestError::Rejected(format!("restricted: community writes are fenced: {error}")) + })?; + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(import_lock( + *tenant.community().as_uuid(), + event.pubkey.as_bytes(), + )) + .execute(tx.as_mut()) + .await + .map_err(|error| IngestError::Internal(format!("error: lock import: {error}")))?; + let existing = sqlx::query( + "SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \ + LIMIT 1", + ) + .bind(tenant.community().as_uuid()) + .bind(KIND_SECTION_WORKSPACE_IMPORT as i32) + .bind(event.pubkey.as_bytes()) + .fetch_optional(tx.as_mut()) + .await + .map_err(|error| IngestError::Internal(format!("error: query existing import: {error}")))?; + if let Some(row) = existing { + let id: Vec = row.get("id"); + if id.as_slice() == event.id.as_bytes() { + return Ok(true); + } + return Err(IngestError::Rejected( + "conflict: section workspace has already been imported".into(), + )); + } + let source_id = hex::decode(&import.source_event_id) + .map_err(|_| IngestError::Rejected("invalid: source_event_id".into()))?; + let source_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM events WHERE community_id=$1 AND id=$2 AND kind=$3 \ + AND pubkey=$4 AND deleted_at IS NULL)", + ) + .bind(tenant.community().as_uuid()) + .bind(source_id) + .bind(buzz_core::kind::KIND_READ_STATE as i32) + .bind(event.pubkey.as_bytes()) + .fetch_one(tx.as_mut()) + .await + .map_err(|error| IngestError::Internal(format!("error: validate legacy source: {error}")))?; + if !source_exists { + return Err(IngestError::Rejected( + "invalid: legacy source is not owner-authored read state in this community".into(), + )); + } + let channel_ids: Vec = import.assignments.iter().map(|a| a.channel_id).collect(); + let valid_channels: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM channels WHERE community_id=$1 AND id=ANY($2) \ + AND deleted_at IS NULL", + ) + .bind(tenant.community().as_uuid()) + .bind(&channel_ids) + .fetch_one(tx.as_mut()) + .await + .map_err(|error| IngestError::Internal(format!("error: validate channels: {error}")))?; + if valid_channels as usize != channel_ids.len() { + return Err(IngestError::Rejected( + "invalid: assignment channel does not exist in this community".into(), + )); + } + insert_event(&mut tx, tenant, event, None).await?; + insert_event(&mut tx, tenant, projection, Some(coordinate)).await?; + insert_single_mention(&mut tx, tenant, event, &event.pubkey.to_hex()).await?; + insert_single_mention(&mut tx, tenant, projection, &event.pubkey.to_hex()).await?; + tx.commit() + .await + .map_err(|error| IngestError::Internal(format!("error: commit import: {error}")))?; + Ok(false) +} +async fn insert_event( + tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, + tenant: &TenantContext, + event: &Event, + d_tag: Option<&str>, +) -> Result<(), IngestError> { + let tags = serde_json::to_value(&event.tags) + .map_err(|error| IngestError::Internal(format!("error: serialize tags: {error}")))?; + let created_at = chrono::DateTime::from_timestamp(event.created_at.as_secs() as i64, 0) + .ok_or_else(|| IngestError::Rejected("invalid: timestamp".into()))?; + let inserted = sqlx::query( + "INSERT INTO events (community_id,id,pubkey,created_at,kind,tags,content,sig,received_at,channel_id,d_tag) \ + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NULL,$9) ON CONFLICT DO NOTHING", + ) + .bind(tenant.community().as_uuid()) + .bind(event.id.as_bytes()) + .bind(event.pubkey.as_bytes()) + .bind(created_at) + .bind(event.kind.as_u16() as i32) + .bind(tags) + .bind(&event.content) + .bind(event.sig.serialize().as_slice()) + .bind(d_tag) + .execute(tx.as_mut()) + .await + .map_err(|error| IngestError::Internal(format!("error: persist import event: {error}")))?; + if inserted.rows_affected() != 1 { + return Err(IngestError::Internal( + "error: unexpected event-id collision".into(), + )); + } + Ok(()) +} +async fn insert_single_mention( + tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, + tenant: &TenantContext, + event: &Event, + reader: &str, +) -> Result<(), IngestError> { + let created_at = chrono::DateTime::from_timestamp(event.created_at.as_secs() as i64, 0) + .ok_or_else(|| IngestError::Rejected("invalid: timestamp".into()))?; + sqlx::query( + "INSERT INTO event_mentions \ + (community_id,pubkey_hex,event_id,event_created_at,channel_id,event_kind) \ + VALUES ($1,$2,$3,$4,NULL,$5) ON CONFLICT DO NOTHING", + ) + .bind(tenant.community().as_uuid()) + .bind(reader) + .bind(event.id.as_bytes()) + .bind(created_at) + .bind(event.kind.as_u16() as i32) + .execute(tx.as_mut()) + .await + .map_err(|error| IngestError::Internal(format!("error: index private event: {error}")))?; + Ok(()) +} +fn import_lock(community: uuid::Uuid, owner: &[u8]) -> i64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in community.as_bytes().iter().chain(owner) { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash as i64 +} +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::{ + channel::{ChannelType, ChannelVisibility}, + section_workspace::{ImportAssignment, ImportSection, Projection, VERSION}, + tenant::CommunityId, + }; + use nostr::Keys; + #[test] + fn disabled_gate_rejects_before_database_work() { + assert!( + matches!(require_enabled(false), Err(IngestError::Rejected(message)) if message.contains("disabled")) + ); + require_enabled(true).unwrap(); + } + async fn state(pool: sqlx::PgPool) -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.section_workspace_import_enabled = true; + config.redis_url = "redis://127.0.0.1:6379".into(); + let redis = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + redis::cmd("PING") + .query_async::(&mut redis.get().await.ok()?) + .await + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis.clone()) + .await + .ok()?, + ); + let db = buzz_db::Db::from_pool(pool.clone()); + let workflow = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let (state, _) = AppState::new( + config, + db, + redis, + buzz_audit::AuditService::new(pool.clone()), + pubsub, + buzz_auth::AuthService::new(crate::config::Config::from_env().ok()?.auth), + buzz_search::SearchService::new(pool), + workflow, + Keys::generate(), + buzz_media::MediaStorage::new(&crate::config::Config::from_env().ok()?.media).ok()?, + ); + Some(Arc::new(state)) + } + fn coordinate_for_test(keys: &Keys) -> String { + let owner = keys.public_key().to_hex(); + format!("{owner}:{owner}") + } + + fn command(keys: &Keys, import: &Import) -> Event { + EventBuilder::new( + Kind::Custom(KIND_SECTION_WORKSPACE_IMPORT as u16), + serde_json::to_string(import).unwrap(), + ) + .tags([ + Tag::parse(["p", &keys.public_key().to_hex()]).unwrap(), + Tag::parse(["action", &import.action_id.to_string()]).unwrap(), + ]) + .allow_self_tagging() + .sign_with_keys(keys) + .unwrap() + } + #[tokio::test] + async fn owner_import_is_atomic_private_and_idempotent() { + let url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://buzz@localhost:5432/buzz".into()); + let Ok(pool) = sqlx::PgPool::connect(&url).await else { + return; + }; + let Some(state) = state(pool.clone()).await else { + return; + }; + let id = uuid::Uuid::new_v4(); + let tenant = TenantContext::resolved( + CommunityId::from_uuid(id), + format!("sections-{}.example", id.simple()), + ); + sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") + .bind(id) + .bind(tenant.host()) + .execute(&pool) + .await + .unwrap(); + let owner = Keys::generate(); + let channel = state + .db + .create_channel( + tenant.community(), + "imported", + ChannelType::Stream, + ChannelVisibility::Open, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .unwrap(); + let source = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), + "legacy", + ) + .sign_with_keys(&owner) + .unwrap(); + state + .db + .insert_event(tenant.community(), &source, None) + .await + .unwrap(); + let section = uuid::Uuid::new_v4(); + let import = Import { + version: VERSION, + action_id: uuid::Uuid::new_v4(), + source_event_id: source.id.to_hex(), + source_hash: "33".repeat(32), + key_epoch: 1, + owner_key_envelope: "owner-envelope".into(), + sections: vec![ImportSection { + id: section, + rank: 0, + encrypted_label: "encrypted".into(), + encrypted_icon: None, + }], + assignments: vec![ImportAssignment { + channel_id: channel.id, + section_id: section, + }], + }; + let event = command(&owner, &import); + assert!( + handle_import(&tenant, &state, &event) + .await + .unwrap() + .accepted + ); + assert!(handle_import(&tenant, &state, &event) + .await + .unwrap() + .message + .starts_with("duplicate:")); + assert!(state + .db + .get_event_by_id(tenant.community(), event.id.as_bytes()) + .await + .unwrap() + .is_some()); + assert!(buzz_core::filter::reader_authorized_for_event( + &event, + &owner.public_key().to_hex() + )); + assert!(!buzz_core::filter::reader_authorized_for_event( + &event, + &Keys::generate().public_key().to_hex() + )); + let rows = state + .db + .query_events(&buzz_db::EventQuery { + kinds: Some(vec![KIND_SECTION_WORKSPACE_PROJECTION as i32]), + global_only: true, + ..buzz_db::EventQuery::for_community(tenant.community()) + }) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + let projection = &rows[0].event; + assert!(sqlx::query_scalar::<_, bool>( + "SELECT search_tsv IS NULL FROM events WHERE community_id=$1 AND id=$2" + ) + .bind(id) + .bind(projection.id.as_bytes()) + .fetch_one(&pool) + .await + .unwrap()); + assert_eq!( + serde_json::from_str::(&projection.content) + .unwrap() + .revision, + 1 + ); + assert!(buzz_core::filter::reader_authorized_for_event( + projection, + &owner.public_key().to_hex() + )); + assert!(!buzz_core::filter::reader_authorized_for_event( + projection, + &Keys::generate().public_key().to_hex() + )); + + for target in [&event, projection] { + let deletion = EventBuilder::new(Kind::Custom(5), "") + .tags([Tag::parse(["e", &target.id.to_hex()]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let error = super::super::side_effects::validate_standard_deletion_event( + &tenant, &deletion, &state, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("immutable")); + } + let address = format!( + "{}:{}:{}", + KIND_SECTION_WORKSPACE_PROJECTION, + projection.pubkey.to_hex(), + coordinate_for_test(&owner) + ); + let address_deletion = EventBuilder::new(Kind::Custom(5), "") + .tags([Tag::parse(["a", &address]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let error = super::super::side_effects::validate_standard_deletion_event( + &tenant, + &address_deletion, + &state, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("immutable")); + + let mut conflicting = import.clone(); + conflicting.action_id = uuid::Uuid::new_v4(); + assert!( + matches!(handle_import(&tenant, &state, &command(&owner, &conflicting)).await, Err(IngestError::Rejected(message)) if message.starts_with("conflict:")) + ); + let fresh = Keys::generate(); + let fresh_source = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), + "legacy", + ) + .sign_with_keys(&fresh) + .unwrap(); + state + .db + .insert_event(tenant.community(), &fresh_source, None) + .await + .unwrap(); + let mut invalid = import; + invalid.action_id = uuid::Uuid::new_v4(); + invalid.source_event_id = fresh_source.id.to_hex(); + invalid.assignments[0].channel_id = uuid::Uuid::new_v4(); + let rejected = command(&fresh, &invalid); + assert!(handle_import(&tenant, &state, &rejected).await.is_err()); + assert!(state + .db + .get_event_by_id(tenant.community(), rejected.id.as_bytes()) + .await + .unwrap() + .is_none()); + } +} diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 89595fbee1..e316aab027 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -246,6 +246,14 @@ pub async fn validate_standard_deletion_event( if parts.len() < 2 { return Err(anyhow::anyhow!("invalid a-tag format")); } + let target_kind: u32 = parts[0] + .parse() + .map_err(|_| anyhow::anyhow!("invalid kind in a-tag"))?; + if buzz_core::kind::IMMUTABLE_SECTION_WORKSPACE_KINDS.contains(&target_kind) { + return Err(anyhow::anyhow!( + "section workspace migration records are immutable" + )); + } let target_pubkey_bytes = hex::decode(parts[1]).map_err(|_| anyhow::anyhow!("invalid pubkey in a-tag"))?; if target_pubkey_bytes != actor_bytes @@ -266,6 +274,12 @@ pub async fn validate_standard_deletion_event( .await? .ok_or_else(|| anyhow::anyhow!("target event not found"))?; + let target_kind = u32::from(target_event.event.kind.as_u16()); + if buzz_core::kind::IMMUTABLE_SECTION_WORKSPACE_KINDS.contains(&target_kind) { + return Err(anyhow::anyhow!( + "section workspace migration records are immutable" + )); + } let target_author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); if target_author != actor_bytes @@ -2155,6 +2169,13 @@ async fn handle_a_tag_deletion( let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key()); match kind_num { + kind if buzz_core::kind::IMMUTABLE_SECTION_WORKSPACE_KINDS.contains(&kind) => { + tracing::debug!( + kind, + d_tag, + "NIP-09 a-tag deletion ignored for immutable section workspace record" + ); + } // kind:30350 revocation is exclusively a higher-generation inactive replacement. super::push_lease::KIND_PUSH_LEASE => { tracing::debug!(d_tag, "NIP-09 deletion ignored for push lease"); @@ -2291,10 +2312,14 @@ async fn handle_standard_deletion_event( Some(target) => target, None => continue, }; - if u32::from(target_event.event.kind.as_u16()) == super::push_lease::KIND_PUSH_LEASE { + let target_kind = u32::from(target_event.event.kind.as_u16()); + if target_kind == super::push_lease::KIND_PUSH_LEASE + || buzz_core::kind::IMMUTABLE_SECTION_WORKSPACE_KINDS.contains(&target_kind) + { tracing::debug!( target_id = %hex::encode(&target_id), - "NIP-09 deletion ignored for push lease" + kind = target_kind, + "NIP-09 deletion ignored for immutable event" ); continue; } diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index e7c196ee3e..39c2508f2d 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -1,6 +1,6 @@ //! Integration tests for community-scoped Postgres FTS. //! -//! Run with a local PG: `BUZZ_TEST_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz cargo test -p buzz-search --tests -- --include-ignored` +//! Run with a local PG: `BUZZ_TEST_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz cargo test -p buzz-search --tests -- --include-ignored` // sadscan:disable np.postgres.1 //! //! Each test creates a uniquely-named schema, applies every FTS-affecting //! migration in order, exercises a scenario, and drops it. Tests are @@ -1488,6 +1488,16 @@ async fn p_gated_persistent_kinds_have_storage_null_tsvector() { "kind:9 control row MUST be searchable, got kinds={kinds:?}", ); + for required in [ + buzz_core::kind::KIND_SECTION_WORKSPACE_IMPORT, + buzz_core::kind::KIND_SECTION_WORKSPACE_PROJECTION, + ] { + assert!( + persistent.contains(&required), + "workspace kind:{required} must remain p-gated" + ); + } + for &kind in &persistent { assert!( !kinds.contains(&(kind as i32)), diff --git a/crates/buzz-test-client/tests/e2e_section_workspace.rs b/crates/buzz-test-client/tests/e2e_section_workspace.rs new file mode 100644 index 0000000000..7b5c24d668 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_section_workspace.rs @@ -0,0 +1,239 @@ +//! End-to-end privacy, immutability, and replay contract for Stage 1 section workspaces. + +use std::time::Duration; + +use buzz_test_client::BuzzTestClient; +use nostr::{Alphabet, Event, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag}; +use reqwest::Client; +use serde_json::{json, Value}; + +const IMPORT_KIND: u16 = 9050; +const PROJECTION_KIND: u16 = 30623; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".into()) +} + +fn http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .into() +} + +fn reader_filter(kind: u16, reader: &Keys) -> Filter { + Filter::new().kind(Kind::Custom(kind)).custom_tag( + SingleLetterTag::lowercase(Alphabet::P), + reader.public_key().to_hex(), + ) +} + +async fn ws_query(client: &mut BuzzTestClient, name: &str, filter: Filter) -> Vec { + client.subscribe(name, vec![filter]).await.unwrap(); + client + .collect_until_eose(name, Duration::from_secs(5)) + .await + .unwrap() +} + +async fn http_query(http: &Client, reader: &Keys, filter: Filter) -> Vec { + let response = http + .post(format!("{}/query", http_url())) + .header("X-Pubkey", reader.public_key().to_hex()) + .json(&vec![filter]) + .send() + .await + .unwrap(); + assert!( + response.status().is_success(), + "query status={}", + response.status() + ); + response.json().await.unwrap() +} + +async fn http_count(http: &Client, reader: &Keys, filter: Filter) -> u64 { + let response = http + .post(format!("{}/count", http_url())) + .header("X-Pubkey", reader.public_key().to_hex()) + .json(&vec![filter]) + .send() + .await + .unwrap(); + assert!( + response.status().is_success(), + "count status={}", + response.status() + ); + response.json::().await.unwrap()["count"] + .as_u64() + .unwrap() +} + +fn import_event(owner: &Keys, source: &Event, action_id: uuid::Uuid, marker: &str) -> Event { + let content = json!({ + "version": 1, + "action_id": action_id, + "source_event_id": source.id.to_hex(), + "source_hash": marker, + "key_epoch": 1, + "owner_key_envelope": format!("owner-envelope-{marker}"), + "sections": [], + "assignments": [] + }) + .to_string(); + EventBuilder::new(Kind::Custom(IMPORT_KIND), content) + .tags([ + Tag::parse(["p", &owner.public_key().to_hex()]).unwrap(), + Tag::parse(["action", &action_id.to_string()]).unwrap(), + ]) + .allow_self_tagging() + .sign_with_keys(owner) + .unwrap() +} + +#[tokio::test] +#[ignore] +async fn owner_import_projection_is_private_immutable_and_one_shot() { + let owner = Keys::generate(); + let stranger = Keys::generate(); + let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner).await.unwrap(); + let mut stranger_client = BuzzTestClient::connect(&relay_url(), &stranger) + .await + .unwrap(); + let http = Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + + let source = EventBuilder::new(Kind::Custom(30078), "legacy section state") + .sign_with_keys(&owner) + .unwrap(); + assert!( + owner_client + .send_event(source.clone()) + .await + .unwrap() + .accepted + ); + + let action_id = uuid::Uuid::new_v4(); + let import = import_event(&owner, &source, action_id, &"33".repeat(32)); + let accepted = owner_client.send_event(import.clone()).await.unwrap(); + assert!(accepted.accepted, "{}", accepted.message); + + let imports = ws_query( + &mut owner_client, + "owner-import", + reader_filter(IMPORT_KIND, &owner), + ) + .await; + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].id, import.id); + let projections = ws_query( + &mut owner_client, + "owner-projection", + reader_filter(PROJECTION_KIND, &owner), + ) + .await; + assert_eq!(projections.len(), 1); + let projection = projections[0].clone(); + + for (kind, event) in [(IMPORT_KIND, &import), (PROJECTION_KIND, &projection)] { + assert!(ws_query( + &mut stranger_client, + &format!("stranger-kind-{kind}"), + reader_filter(kind, &stranger), + ) + .await + .is_empty()); + assert!(http_query(&http, &stranger, reader_filter(kind, &stranger)) + .await + .is_empty()); + assert_eq!( + http_count(&http, &stranger, reader_filter(kind, &stranger)).await, + 0 + ); + assert!(ws_query( + &mut stranger_client, + &format!("stranger-id-{kind}"), + Filter::new().id(event.id), + ) + .await + .is_empty()); + assert!(http_query(&http, &stranger, Filter::new().id(event.id)) + .await + .is_empty()); + assert_eq!( + http_count(&http, &stranger, Filter::new().id(event.id)).await, + 0 + ); + assert!(ws_query( + &mut stranger_client, + &format!("stranger-search-{kind}"), + Filter::new().id(event.id).search("owner-envelope"), + ) + .await + .is_empty()); + assert!(http_query( + &http, + &stranger, + Filter::new().id(event.id).search("owner-envelope"), + ) + .await + .is_empty()); + + let deletion = EventBuilder::new(Kind::Custom(5), "") + .tags([Tag::parse(["e", &event.id.to_hex()]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let rejected = owner_client.send_event(deletion).await.unwrap(); + assert!( + !rejected.accepted, + "kind {kind} event-id deletion was accepted" + ); + } + + let coordinate = format!( + "{}:{}:{}:{}", + PROJECTION_KIND, + projection.pubkey.to_hex(), + owner.public_key().to_hex(), + owner.public_key().to_hex() + ); + let address_deletion = EventBuilder::new(Kind::Custom(5), "") + .tags([Tag::parse(["a", &coordinate]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + assert!( + !owner_client + .send_event(address_deletion) + .await + .unwrap() + .accepted + ); + + let replay = owner_client.send_event(import.clone()).await.unwrap(); + assert!(replay.accepted, "{}", replay.message); + assert!(replay.message.starts_with("duplicate:")); + assert_eq!( + ws_query( + &mut owner_client, + "projection-after-replay", + reader_filter(PROJECTION_KIND, &owner), + ) + .await + .len(), + 1 + ); + + let changed = import_event(&owner, &source, uuid::Uuid::new_v4(), &"44".repeat(32)); + let conflict = owner_client.send_event(changed).await.unwrap(); + assert!(!conflict.accepted); + assert!( + conflict.message.starts_with("conflict:"), + "{}", + conflict.message + ); +} diff --git a/docs/nips/NIP-SW.fixtures.json b/docs/nips/NIP-SW.fixtures.json new file mode 100644 index 0000000000..c60f4db0f0 --- /dev/null +++ b/docs/nips/NIP-SW.fixtures.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "kinds": {"import": 9050, "projection": 30623}, + "limits": {"sections": 100, "assignments": 1000, "encrypted_metadata_bytes": 65535, "key_envelope_bytes": 4096}, + "owner": "1111111111111111111111111111111111111111111111111111111111111111", + "import": { + "version": 1, + "action_id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "source_event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "source_hash": "3333333333333333333333333333333333333333333333333333333333333333", + "key_epoch": 1, + "owner_key_envelope": "nip44-owner-envelope", + "sections": [{"id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","rank":0,"encrypted_label":"ciphertext-label","encrypted_icon":null}], + "assignments": [{"channel_id":"cccccccc-cccc-4ccc-8ccc-cccccccccccc","section_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"}] + }, + "projection": { + "coordinate": "1111111111111111111111111111111111111111111111111111111111111111:1111111111111111111111111111111111111111111111111111111111111111", + "tags": [ + ["d", "1111111111111111111111111111111111111111111111111111111111111111:1111111111111111111111111111111111111111111111111111111111111111"], + ["p", "1111111111111111111111111111111111111111111111111111111111111111"] + ] + } +} diff --git a/docs/nips/NIP-SW.md b/docs/nips/NIP-SW.md new file mode 100644 index 0000000000..1734124c87 --- /dev/null +++ b/docs/nips/NIP-SW.md @@ -0,0 +1,52 @@ +# NIP-SW: Owner section-workspace import + +## Status + +Stage 1 is an opt-in migration tracer bullet. Set `BUZZ_SECTION_WORKSPACE_IMPORT_ENABLED=true` to accept imports. No client enables it yet. Viewer grants, revocation, moves, and section management are later protocol stages. + +## Kinds and authority + +- `9050`: owner-signed revision-zero import command. +- `30623`: relay-signed, reader-specific current projection. + +The relay resolves the community from the request host. It never accepts a community UUID from the command. The command signer is the immutable workspace owner. Import requires `users:write`, is accepted at most once for `(community, owner)`, and references an existing owner-authored kind-30078 event in that community. Exact signed-event replay is idempotent; every different later import conflicts. + +## Import + +Kind 9050 has exactly these tags: + +```json +[["p", ""], ["action", ""]] +``` + +The `p` value must equal the signer. Content is ordinary strict JSON (the signed Nostr event ID already binds its exact bytes): + +```json +{ + "version": 1, + "action_id": "", + "source_event_id": "", + "source_hash": "", + "key_epoch": 1, + "owner_key_envelope": "", + "sections": [{"id":"","rank":0,"encrypted_label":"","encrypted_icon":null}], + "assignments": [{"channel_id":"","section_id":""}] +} +``` + +The relay rejects unknown fields, unsupported versions, nil or duplicate IDs, non-permutation ranks, duplicate channel assignments, unknown sections, missing/cross-community/deleted channels, a missing/cross-community/wrong-author legacy source, more than 100 sections or 1,000 assignments, metadata over 65,535 bytes, and key envelopes over 4,096 bytes. + +Labels and icons remain client-encrypted. Stage 1 freezes neither a custom JSON canonicalization profile nor metadata encryption details; supported clients must agree on those before cutover. + +## Owner projection + +On the first accepted import, the relay atomically stores the verified command and a relay-signed kind-30623 projection at: + +```text +d=: +p= +``` + +There is exactly one `p` tag and it matches the reader component of `d`. The owner projection therefore uses `:`. Its content contains version 1, owner pubkey, workspace/layout/assignment revision 1, key epoch 1, the source migration marker, that reader's key envelope, sections, and assignments. + +Kind 30623 is relay-only and parameterized replaceable. Every historical REQ, HTTP query, count, search hydration, known-event-ID lookup, and live fan-out applies result-level `p` authorization. Knowing an event ID is not authorization. diff --git a/migrations/0033_section_workspace_projection_fts.sql b/migrations/0033_section_workspace_projection_fts.sql new file mode 100644 index 0000000000..6445ec5e4e --- /dev/null +++ b/migrations/0033_section_workspace_projection_fts.sql @@ -0,0 +1,20 @@ +-- Kinds 9050 and 30623 carry owner-private section workspace migration state. Keep it +-- out of full-text search at the storage boundary while preserving the expression +-- installed by fresh or brownfield databases. +DO $$ +DECLARE existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) INTO existing_expression + FROM pg_attrdef d JOIN pg_attribute a + ON a.attrelid = d.adrelid AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass AND a.attname = 'search_tsv'; + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind IN (9050, 30623) THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/schema/schema.sql b/schema/schema.sql index 6e14e6be1b..95695539ab 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -219,9 +219,9 @@ CREATE TABLE events ( -- Privacy: encrypted/private routing wrappers and p-gated membership notices -- must never be discoverable through NIP-50 full-text search. NULL tsvector -- never matches `@@`. - -- Keep in sync with migrations (final state: 0001 + 0005 + 0009). + -- Keep in sync with migrations (final state: 0001 + 0005 + 0008 + 0014 + 0032 + 0033). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 9050, 30300, 30350, 30622, 30623, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED,