feat(relay): import owner section workspace

Co-authored-by: Larry <loganj+larry@squareup.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-08-21 03:54:47 +00:00
parent 24ec6a468e
commit a7db357295
19 changed files with 1090 additions and 14 deletions

View File

@@ -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);

View File

@@ -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
)

View File

@@ -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.

View File

@@ -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<String>,
}
#[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<ImportSection>,
pub assignments: Vec<ImportAssignment>,
}
#[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<ImportSection>,
pub assignments: Vec<ProjectedAssignment>,
}
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(
&section.encrypted_label,
MAX_ENCRYPTED_METADATA_BYTES,
"encrypted_label",
)?;
if let Some(icon) = &section.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<Vec<String>> = 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),
}

View File

@@ -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"));

View File

@@ -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,

View File

@@ -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");

View File

@@ -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<String> = owner_only_kind
.then(|| {
let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P);

View File

@@ -449,6 +449,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
// Command kinds — DM management, workflows, approvals
KIND_DM_OPEN | KIND_DM_ADD_MEMBER | KIND_DM_HIDE => 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
)
}

View File

@@ -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;

View File

@@ -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()) {

View File

@@ -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<AppState>,
event: &Event,
) -> Result<IngestResult, IngestError> {
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<bool, IngestError> {
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<u8> = 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<uuid::Uuid> = 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<Arc<AppState>> {
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::<String>(&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>(&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());
}
}

View File

@@ -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;
}

View File

@@ -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)),

View File

@@ -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<Event> {
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<Value> {
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::<Value>().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
);
}

View File

@@ -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"]
]
}
}

52
docs/nips/NIP-SW.md Normal file
View File

@@ -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", "<owner-pubkey>"], ["action", "<action-uuid>"]]
```
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": "<uuid>",
"source_event_id": "<kind-30078-event-id>",
"source_hash": "<sha256-lower-hex-of-decrypted-legacy-state>",
"key_epoch": 1,
"owner_key_envelope": "<nip44-wrapped-workspace-key>",
"sections": [{"id":"<uuid>","rank":0,"encrypted_label":"<ciphertext>","encrypted_icon":null}],
"assignments": [{"channel_id":"<uuid>","section_id":"<uuid>"}]
}
```
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=<owner-pubkey>:<reader-pubkey>
p=<reader-pubkey>
```
There is exactly one `p` tag and it matches the reader component of `d`. The owner projection therefore uses `<owner>:<owner>`. 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.

View File

@@ -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 $$;

View File

@@ -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,