mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(sections): add workspace contract and persistence
Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -369,6 +369,20 @@ pub const KIND_MODERATION_UNTIMEOUT: u32 = 9043;
|
||||
/// `handlers/moderation_commands.rs` for the pinned vocabulary).
|
||||
pub const KIND_MODERATION_RESOLVE_REPORT: u32 = 9044;
|
||||
|
||||
// Owner-scoped channel-section workspace commands. These user-signed commands
|
||||
// are consumed transactionally by the relay and are never stored as ordinary
|
||||
// events. Separate kinds make the authorized mutation scope unambiguous.
|
||||
/// Section workspace: revision-zero import of a legacy kind-30078 store.
|
||||
pub const KIND_SECTION_WORKSPACE_IMPORT: u32 = 9050;
|
||||
/// Section workspace: owner-only grant or role update.
|
||||
pub const KIND_SECTION_WORKSPACE_GRANT: u32 = 9051;
|
||||
/// Section workspace: owner-only revoke with content-key epoch rotation.
|
||||
pub const KIND_SECTION_WORKSPACE_REVOKE: u32 = 9052;
|
||||
/// Section workspace: assign or unassign one channel (stage 2).
|
||||
pub const KIND_SECTION_WORKSPACE_MOVE: u32 = 9053;
|
||||
/// Section workspace: create, rename, delete, or reorder sections (stage 3).
|
||||
pub const KIND_SECTION_WORKSPACE_MANAGE: u32 = 9054;
|
||||
|
||||
/// Returns `true` for community moderation command kinds (9040–9044).
|
||||
///
|
||||
/// The canonical route check — use this instead of scattering
|
||||
@@ -447,6 +461,8 @@ pub const KIND_WORKFLOW_DEF: u32 = 30620;
|
||||
/// the latest event is always the authoritative hidden set. The relay knows
|
||||
/// `hidden_at` per viewer; this is the only Nostr-visible projection of it.
|
||||
pub const KIND_DM_VISIBILITY: u32 = 30622;
|
||||
/// Relay-signed current projection of an owner-scoped section workspace.
|
||||
pub const KIND_SECTION_WORKSPACE_PROJECTION: u32 = 30623;
|
||||
|
||||
/// Lower bound of the NIP-33 parameterized replaceable range (30000–39999).
|
||||
pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000;
|
||||
@@ -673,6 +689,11 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_MODERATION_TIMEOUT,
|
||||
KIND_MODERATION_UNTIMEOUT,
|
||||
KIND_MODERATION_RESOLVE_REPORT,
|
||||
KIND_SECTION_WORKSPACE_IMPORT,
|
||||
KIND_SECTION_WORKSPACE_GRANT,
|
||||
KIND_SECTION_WORKSPACE_REVOKE,
|
||||
KIND_SECTION_WORKSPACE_MOVE,
|
||||
KIND_SECTION_WORKSPACE_MANAGE,
|
||||
RELAY_ADMIN_ADD_MEMBER,
|
||||
RELAY_ADMIN_REMOVE_MEMBER,
|
||||
RELAY_ADMIN_CHANGE_ROLE,
|
||||
@@ -712,6 +733,7 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_CHANNEL_SUMMARY,
|
||||
KIND_PRESENCE_SNAPSHOT,
|
||||
KIND_DM_VISIBILITY,
|
||||
KIND_SECTION_WORKSPACE_PROJECTION,
|
||||
KIND_DM_OPEN,
|
||||
KIND_DM_ADD_MEMBER,
|
||||
KIND_DM_HIDE,
|
||||
|
||||
@@ -36,6 +36,8 @@ pub mod presence;
|
||||
pub mod private_managed_agent;
|
||||
/// Canonical relay runtime identities.
|
||||
pub mod relay;
|
||||
/// Owner-scoped channel-section workspace protocol.
|
||||
pub mod section_workspace;
|
||||
/// Tenant identity — the server-resolved community key carried on scoped paths.
|
||||
pub mod tenant;
|
||||
/// Schnorr signature and event ID verification.
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
//! Owner-scoped channel-section workspace protocol types.
|
||||
//!
|
||||
//! The relay is authoritative for structure, assignments, grants, revisions,
|
||||
//! and command ordering. Human-readable labels and icons remain opaque
|
||||
//! ciphertext encrypted by clients with a per-workspace content key.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Maximum active sections in one workspace.
|
||||
pub const MAX_SECTIONS: usize = 100;
|
||||
/// Maximum channel assignments in one workspace.
|
||||
pub const MAX_ASSIGNMENTS: usize = 1_000;
|
||||
/// Maximum active delegate grants in one workspace.
|
||||
pub const MAX_GRANTS: usize = 256;
|
||||
/// Maximum bytes in one encrypted label or icon field.
|
||||
pub const MAX_ENCRYPTED_METADATA_BYTES: usize = 65_535;
|
||||
/// Maximum bytes in one pairwise-wrapped content-key envelope.
|
||||
pub const MAX_KEY_ENVELOPE_BYTES: usize = 4_096;
|
||||
/// Wire schema version implemented by this protocol.
|
||||
pub const SECTION_WORKSPACE_VERSION: u32 = 1;
|
||||
|
||||
/// Delegated authority over an owner's section workspace.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SectionWorkspaceRole {
|
||||
/// Read and decrypt the current projection.
|
||||
Viewer,
|
||||
/// Viewer rights plus assign and unassign channels.
|
||||
Mover,
|
||||
/// Mover rights plus section create, rename, delete, and reorder.
|
||||
Manager,
|
||||
}
|
||||
|
||||
impl SectionWorkspaceRole {
|
||||
/// Stable database and wire spelling.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Viewer => "viewer",
|
||||
Self::Mover => "mover",
|
||||
Self::Manager => "manager",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An encrypted section supplied during revision-zero migration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWorkspaceImportSection {
|
||||
/// Existing client section UUID, preserved across migration.
|
||||
pub id: Uuid,
|
||||
/// Zero-based display order.
|
||||
pub rank: u32,
|
||||
/// Authenticated ciphertext for the section label.
|
||||
pub encrypted_label: String,
|
||||
/// Optional authenticated ciphertext for the section icon.
|
||||
pub encrypted_icon: Option<String>,
|
||||
}
|
||||
|
||||
/// A channel-to-section assignment supplied during migration.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWorkspaceImportAssignment {
|
||||
/// Channel UUID in the same server-resolved community.
|
||||
pub channel_id: Uuid,
|
||||
/// Destination section UUID in this import.
|
||||
pub section_id: Uuid,
|
||||
}
|
||||
|
||||
/// Revision-zero import command body.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWorkspaceImport {
|
||||
/// Wire schema version.
|
||||
pub version: u32,
|
||||
/// Stable retry identity independent of the signed transport event id.
|
||||
pub action_id: Uuid,
|
||||
/// Legacy kind-30078 event id whose plaintext was migrated.
|
||||
pub source_event_id: String,
|
||||
/// Lowercase SHA-256 hex of the canonical legacy plaintext.
|
||||
pub source_hash: String,
|
||||
/// Initial content-key epoch. Version 1 imports require epoch 1.
|
||||
pub key_epoch: u64,
|
||||
/// Owner's pairwise-wrapped content-key envelope.
|
||||
pub owner_key_envelope: String,
|
||||
/// Ordered encrypted sections.
|
||||
pub sections: Vec<SectionWorkspaceImportSection>,
|
||||
/// Channel assignments.
|
||||
pub assignments: Vec<SectionWorkspaceImportAssignment>,
|
||||
}
|
||||
|
||||
/// Wire projection migration marker.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWorkspaceProjectionMigration {
|
||||
/// Imported legacy event ID.
|
||||
pub source_event_id: String,
|
||||
/// Canonical legacy plaintext hash.
|
||||
pub source_hash: String,
|
||||
}
|
||||
|
||||
/// One section in the relay's reader-specific projection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWorkspaceProjectedSection {
|
||||
/// Stable section identifier.
|
||||
pub id: Uuid,
|
||||
/// Zero-based display order.
|
||||
pub rank: u32,
|
||||
/// Authenticated label ciphertext.
|
||||
pub encrypted_label: String,
|
||||
/// Optional authenticated icon ciphertext.
|
||||
pub encrypted_icon: Option<String>,
|
||||
}
|
||||
|
||||
/// One assignment in the relay's reader-specific projection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWorkspaceProjectedAssignment {
|
||||
/// Assigned channel identifier.
|
||||
pub channel_id: Uuid,
|
||||
/// Destination section identifier.
|
||||
pub section_id: Uuid,
|
||||
/// Per-assignment revision.
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
/// Complete reader-specific current projection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWorkspaceProjection {
|
||||
/// Wire schema version.
|
||||
pub version: u32,
|
||||
/// Workspace owner as lowercase hex.
|
||||
pub owner_pubkey: String,
|
||||
/// Monotonic workspace revision.
|
||||
pub revision: u64,
|
||||
/// Monotonic layout revision.
|
||||
pub layout_revision: u64,
|
||||
/// Current content-key epoch.
|
||||
pub key_epoch: u64,
|
||||
/// Imported legacy source marker.
|
||||
pub migration: SectionWorkspaceProjectionMigration,
|
||||
/// Pairwise envelope for this projection reader.
|
||||
pub reader_key_envelope: String,
|
||||
/// Ordered active sections.
|
||||
pub sections: Vec<SectionWorkspaceProjectedSection>,
|
||||
/// Current channel assignments.
|
||||
pub assignments: Vec<SectionWorkspaceProjectedAssignment>,
|
||||
}
|
||||
|
||||
/// Client action for an incoming projection revision.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProjectionRevisionAction {
|
||||
/// Persist and render the projection.
|
||||
Accept,
|
||||
/// Discard the delta and fetch the current full projection.
|
||||
Refetch,
|
||||
/// Discard a stale or duplicate projection.
|
||||
Ignore,
|
||||
}
|
||||
|
||||
/// Apply the frozen projection revision rule.
|
||||
pub fn projection_revision_action(
|
||||
previous_revision: Option<u64>,
|
||||
incoming_revision: u64,
|
||||
) -> ProjectionRevisionAction {
|
||||
match previous_revision {
|
||||
None => ProjectionRevisionAction::Accept,
|
||||
Some(previous) if incoming_revision <= previous => ProjectionRevisionAction::Ignore,
|
||||
Some(previous) if incoming_revision == previous.saturating_add(1) => {
|
||||
ProjectionRevisionAction::Accept
|
||||
}
|
||||
Some(_) => ProjectionRevisionAction::Refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Owner-only grant command body.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWorkspaceGrantCommand {
|
||||
/// Wire schema version.
|
||||
pub version: u32,
|
||||
/// Stable retry identity.
|
||||
pub action_id: Uuid,
|
||||
/// Exact delegate pubkey as lowercase hex.
|
||||
pub actor_pubkey: String,
|
||||
/// Granted authority.
|
||||
pub role: SectionWorkspaceRole,
|
||||
/// Current workspace key epoch.
|
||||
pub key_epoch: u64,
|
||||
/// Pairwise NIP-44 wrapped content key.
|
||||
pub key_envelope: String,
|
||||
}
|
||||
|
||||
/// One metadata replacement in a revoke-and-rotate command.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWorkspaceRotatedSection {
|
||||
/// Existing section UUID.
|
||||
pub section_id: Uuid,
|
||||
/// Metadata envelope encrypted under the new workspace key.
|
||||
pub encrypted_label: String,
|
||||
/// Optional icon envelope encrypted under the new workspace key.
|
||||
pub encrypted_icon: Option<String>,
|
||||
}
|
||||
|
||||
/// One reader envelope in a revoke-and-rotate command.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWorkspaceReaderEnvelope {
|
||||
/// Exact reader pubkey as lowercase hex.
|
||||
pub reader_pubkey: String,
|
||||
/// Pairwise NIP-44 wrapped content key.
|
||||
pub key_envelope: String,
|
||||
}
|
||||
|
||||
/// Owner-only revoke-and-rotate command body.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWorkspaceRevokeCommand {
|
||||
/// Wire schema version.
|
||||
pub version: u32,
|
||||
/// Stable retry identity.
|
||||
pub action_id: Uuid,
|
||||
/// Revoked delegate pubkey as lowercase hex.
|
||||
pub actor_pubkey: String,
|
||||
/// Exactly the current epoch plus one.
|
||||
pub new_key_epoch: u64,
|
||||
/// Every active section, re-encrypted exactly once.
|
||||
pub sections: Vec<SectionWorkspaceRotatedSection>,
|
||||
/// Owner and every remaining reader, exactly once.
|
||||
pub envelopes: Vec<SectionWorkspaceReaderEnvelope>,
|
||||
}
|
||||
|
||||
/// Produce the byte-exact NIP-SW canonical JSON profile.
|
||||
///
|
||||
/// Objects are recursively key-sorted, arrays retain order, no insignificant
|
||||
/// whitespace is emitted, and only integer JSON numbers are accepted. Strings
|
||||
/// use serde_json's UTF-8/escape encoding. Duplicate keys must be rejected by
|
||||
/// the caller's strict typed deserializer before canonicalization.
|
||||
pub fn canonical_json(
|
||||
value: &serde_json::Value,
|
||||
) -> Result<String, SectionWorkspaceValidationError> {
|
||||
fn write(
|
||||
value: &serde_json::Value,
|
||||
out: &mut String,
|
||||
) -> Result<(), SectionWorkspaceValidationError> {
|
||||
match value {
|
||||
serde_json::Value::Null => out.push_str("null"),
|
||||
serde_json::Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
|
||||
serde_json::Value::Number(v) if v.is_i64() || v.is_u64() => {
|
||||
out.push_str(&v.to_string())
|
||||
}
|
||||
serde_json::Value::Number(_) => {
|
||||
return Err(SectionWorkspaceValidationError::Invalid(
|
||||
"non_integer_number",
|
||||
))
|
||||
}
|
||||
serde_json::Value::String(v) => out.push_str(
|
||||
&serde_json::to_string(v)
|
||||
.map_err(|_| SectionWorkspaceValidationError::Invalid("json_string"))?,
|
||||
),
|
||||
serde_json::Value::Array(values) => {
|
||||
out.push('[');
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
if index != 0 {
|
||||
out.push(',');
|
||||
}
|
||||
write(value, out)?;
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
serde_json::Value::Object(values) => {
|
||||
out.push('{');
|
||||
let mut keys = values.keys().collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
for (index, key) in keys.into_iter().enumerate() {
|
||||
if index != 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(
|
||||
&serde_json::to_string(key)
|
||||
.map_err(|_| SectionWorkspaceValidationError::Invalid("json_key"))?,
|
||||
);
|
||||
out.push(':');
|
||||
write(&values[key], out)?;
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut out = String::new();
|
||||
write(value, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Validate the exact routing-tag grammar shared by command handlers.
|
||||
pub fn validate_command_tags(
|
||||
tags: &[Vec<String>],
|
||||
owner_pubkey: &str,
|
||||
actor_pubkey: Option<&str>,
|
||||
action_id: Uuid,
|
||||
) -> Result<(), SectionWorkspaceValidationError> {
|
||||
let mut owner = None;
|
||||
let mut actor = None;
|
||||
let mut action = None;
|
||||
for tag in tags {
|
||||
if tag.len() != 2 {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("tag_grammar"));
|
||||
}
|
||||
let slot = match tag[0].as_str() {
|
||||
"p" => &mut owner,
|
||||
"actor" if actor_pubkey.is_some() => &mut actor,
|
||||
"action" => &mut action,
|
||||
_ => return Err(SectionWorkspaceValidationError::Invalid("tag_grammar")),
|
||||
};
|
||||
if slot.replace(tag[1].as_str()).is_some() {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("tag_grammar"));
|
||||
}
|
||||
}
|
||||
let expected_action = action_id.to_string();
|
||||
if owner != Some(owner_pubkey)
|
||||
|| actor != actor_pubkey
|
||||
|| action != Some(expected_action.as_str())
|
||||
{
|
||||
return Err(SectionWorkspaceValidationError::Invalid("tag_grammar"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Protocol validation failures independent of any transport or database.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum SectionWorkspaceValidationError {
|
||||
/// A collection exceeds its wire bound.
|
||||
#[error("{field} exceeds maximum {maximum}")]
|
||||
TooMany {
|
||||
/// Field name.
|
||||
field: &'static str,
|
||||
/// Maximum accepted count.
|
||||
maximum: usize,
|
||||
},
|
||||
/// A required identifier is nil, malformed, or duplicated.
|
||||
#[error("invalid {0}")]
|
||||
Invalid(&'static str),
|
||||
/// An encrypted field is empty or exceeds its byte bound.
|
||||
#[error("invalid encrypted {0}")]
|
||||
InvalidCiphertext(&'static str),
|
||||
}
|
||||
|
||||
impl SectionWorkspaceGrantCommand {
|
||||
/// Validate semantic fields after strict decoding.
|
||||
pub fn validate(&self) -> Result<(), SectionWorkspaceValidationError> {
|
||||
if self.version != SECTION_WORKSPACE_VERSION {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("version"));
|
||||
}
|
||||
if self.action_id.is_nil() {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("action_id"));
|
||||
}
|
||||
validate_hex_32(&self.actor_pubkey, "actor_pubkey")?;
|
||||
if self.key_epoch == 0 {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("key_epoch"));
|
||||
}
|
||||
validate_ciphertext(&self.key_envelope, MAX_KEY_ENVELOPE_BYTES, "key_envelope")
|
||||
}
|
||||
}
|
||||
|
||||
impl SectionWorkspaceRevokeCommand {
|
||||
/// Validate semantic fields after strict decoding.
|
||||
pub fn validate(&self) -> Result<(), SectionWorkspaceValidationError> {
|
||||
if self.version != SECTION_WORKSPACE_VERSION {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("version"));
|
||||
}
|
||||
if self.action_id.is_nil() {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("action_id"));
|
||||
}
|
||||
validate_hex_32(&self.actor_pubkey, "actor_pubkey")?;
|
||||
if self.new_key_epoch == 0 {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("new_key_epoch"));
|
||||
}
|
||||
for section in &self.sections {
|
||||
if section.section_id.is_nil() {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("section_id"));
|
||||
}
|
||||
validate_ciphertext(
|
||||
§ion.encrypted_label,
|
||||
MAX_ENCRYPTED_METADATA_BYTES,
|
||||
"label",
|
||||
)?;
|
||||
if let Some(icon) = §ion.encrypted_icon {
|
||||
validate_ciphertext(icon, MAX_ENCRYPTED_METADATA_BYTES, "icon")?;
|
||||
}
|
||||
}
|
||||
for envelope in &self.envelopes {
|
||||
validate_hex_32(&envelope.reader_pubkey, "reader_pubkey")?;
|
||||
validate_ciphertext(
|
||||
&envelope.key_envelope,
|
||||
MAX_KEY_ENVELOPE_BYTES,
|
||||
"key_envelope",
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SectionWorkspaceProjection {
|
||||
/// Validate the wire version before a projection is accepted.
|
||||
pub fn validate(&self) -> Result<(), SectionWorkspaceValidationError> {
|
||||
if self.version != SECTION_WORKSPACE_VERSION {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("version"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SectionWorkspaceImport {
|
||||
/// Validate bounds and referential shape before persistence.
|
||||
pub fn validate(&self) -> Result<(), SectionWorkspaceValidationError> {
|
||||
if self.version != SECTION_WORKSPACE_VERSION {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("version"));
|
||||
}
|
||||
if self.sections.len() > MAX_SECTIONS {
|
||||
return Err(SectionWorkspaceValidationError::TooMany {
|
||||
field: "sections",
|
||||
maximum: MAX_SECTIONS,
|
||||
});
|
||||
}
|
||||
if self.assignments.len() > MAX_ASSIGNMENTS {
|
||||
return Err(SectionWorkspaceValidationError::TooMany {
|
||||
field: "assignments",
|
||||
maximum: MAX_ASSIGNMENTS,
|
||||
});
|
||||
}
|
||||
if self.action_id.is_nil() {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("action_id"));
|
||||
}
|
||||
if self.key_epoch != 1 {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("key_epoch"));
|
||||
}
|
||||
validate_hex_32(&self.source_event_id, "source_event_id")?;
|
||||
validate_hex_32(&self.source_hash, "source_hash")?;
|
||||
validate_ciphertext(
|
||||
&self.owner_key_envelope,
|
||||
MAX_KEY_ENVELOPE_BYTES,
|
||||
"owner_key_envelope",
|
||||
)?;
|
||||
|
||||
let mut ids = HashSet::with_capacity(self.sections.len());
|
||||
let mut ranks = HashSet::with_capacity(self.sections.len());
|
||||
for section in &self.sections {
|
||||
if section.id.is_nil() || !ids.insert(section.id) {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("section_id"));
|
||||
}
|
||||
if section.rank as usize >= self.sections.len() || !ranks.insert(section.rank) {
|
||||
return Err(SectionWorkspaceValidationError::Invalid("section_rank"));
|
||||
}
|
||||
validate_ciphertext(
|
||||
§ion.encrypted_label,
|
||||
MAX_ENCRYPTED_METADATA_BYTES,
|
||||
"label",
|
||||
)?;
|
||||
if let Some(icon) = §ion.encrypted_icon {
|
||||
validate_ciphertext(icon, MAX_ENCRYPTED_METADATA_BYTES, "icon")?;
|
||||
}
|
||||
}
|
||||
let mut channels = HashSet::with_capacity(self.assignments.len());
|
||||
for assignment in &self.assignments {
|
||||
if assignment.channel_id.is_nil()
|
||||
|| !channels.insert(assignment.channel_id)
|
||||
|| !ids.contains(&assignment.section_id)
|
||||
{
|
||||
return Err(SectionWorkspaceValidationError::Invalid("assignment"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_hex_32(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
) -> Result<(), SectionWorkspaceValidationError> {
|
||||
if value.len() != 64
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
return Err(SectionWorkspaceValidationError::Invalid(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_ciphertext(
|
||||
value: &str,
|
||||
maximum: usize,
|
||||
field: &'static str,
|
||||
) -> Result<(), SectionWorkspaceValidationError> {
|
||||
if value.is_empty() || value.len() > maximum {
|
||||
return Err(SectionWorkspaceValidationError::InvalidCiphertext(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn valid_import() -> SectionWorkspaceImport {
|
||||
let section_id = Uuid::new_v4();
|
||||
SectionWorkspaceImport {
|
||||
version: SECTION_WORKSPACE_VERSION,
|
||||
action_id: Uuid::new_v4(),
|
||||
source_event_id: "11".repeat(32),
|
||||
source_hash: "22".repeat(32),
|
||||
key_epoch: 1,
|
||||
owner_key_envelope: "wrapped-key".into(),
|
||||
sections: vec![SectionWorkspaceImportSection {
|
||||
id: section_id,
|
||||
rank: 0,
|
||||
encrypted_label: "ciphertext".into(),
|
||||
encrypted_icon: None,
|
||||
}],
|
||||
assignments: vec![SectionWorkspaceImportAssignment {
|
||||
channel_id: Uuid::new_v4(),
|
||||
section_id,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_revision_zero_import_passes() {
|
||||
valid_import().validate().expect("valid import");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_channel_assignment_is_rejected() {
|
||||
let mut import = valid_import();
|
||||
import.assignments.push(import.assignments[0]);
|
||||
assert_eq!(
|
||||
import.validate(),
|
||||
Err(SectionWorkspaceValidationError::Invalid("assignment"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranks_must_be_a_complete_permutation() {
|
||||
let mut import = valid_import();
|
||||
import.sections.push(SectionWorkspaceImportSection {
|
||||
id: Uuid::new_v4(),
|
||||
rank: 0,
|
||||
encrypted_label: "ciphertext".into(),
|
||||
encrypted_icon: None,
|
||||
});
|
||||
assert_eq!(
|
||||
import.validate(),
|
||||
Err(SectionWorkspaceValidationError::Invalid("section_rank"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fixture_tests {
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
|
||||
fn fixture() -> Value {
|
||||
serde_json::from_str(include_str!("../../../docs/nips/NIP-SW.fixtures.json"))
|
||||
.expect("valid NIP-SW fixture JSON")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_fixture_matches_protocol_constants_and_role_matrix() {
|
||||
let fixture = fixture();
|
||||
assert_eq!(fixture["version"], SECTION_WORKSPACE_VERSION);
|
||||
for (name, kind) in [
|
||||
("import_v1", crate::kind::KIND_SECTION_WORKSPACE_IMPORT),
|
||||
("grant", crate::kind::KIND_SECTION_WORKSPACE_GRANT),
|
||||
("revoke", crate::kind::KIND_SECTION_WORKSPACE_REVOKE),
|
||||
("move", crate::kind::KIND_SECTION_WORKSPACE_MOVE),
|
||||
("manage", crate::kind::KIND_SECTION_WORKSPACE_MANAGE),
|
||||
("projection", crate::kind::KIND_SECTION_WORKSPACE_PROJECTION),
|
||||
] {
|
||||
assert_eq!(fixture["kinds"][name], kind, "kind {name}");
|
||||
}
|
||||
assert_eq!(fixture["limits"]["sections"], MAX_SECTIONS);
|
||||
assert_eq!(fixture["limits"]["assignments"], MAX_ASSIGNMENTS);
|
||||
assert_eq!(fixture["limits"]["grants"], MAX_GRANTS);
|
||||
assert_eq!(
|
||||
fixture["limits"]["encrypted_metadata_bytes"],
|
||||
MAX_ENCRYPTED_METADATA_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
fixture["limits"]["key_envelope_bytes"],
|
||||
MAX_KEY_ENVELOPE_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
fixture["roles"],
|
||||
serde_json::json!(["viewer", "mover", "manager"])
|
||||
);
|
||||
assert_eq!(
|
||||
fixture["role_matrix"],
|
||||
serde_json::json!({
|
||||
"owner": ["read", "grant", "revoke", "move", "manage", "import_v1"],
|
||||
"viewer": ["read"],
|
||||
"mover": ["read", "move"],
|
||||
"manager": ["read", "move", "manage"]
|
||||
})
|
||||
);
|
||||
assert_eq!(fixture["metadata_crypto"]["algorithm"], "AES-256-GCM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_vectors_are_typed_and_drive_revision_behavior() {
|
||||
let fixture = fixture();
|
||||
let cases = fixture["projection_cases"]
|
||||
.as_array()
|
||||
.expect("projection cases");
|
||||
let accepted =
|
||||
serde_json::from_value::<SectionWorkspaceProjection>(cases[0]["projection"].clone())
|
||||
.expect("complete projection shape");
|
||||
accepted.validate().expect("supported projection version");
|
||||
assert_eq!(accepted.sections.len(), 2);
|
||||
assert_eq!(accepted.assignments.len(), 1);
|
||||
|
||||
for case in cases {
|
||||
let incoming = case["projection"]["revision"].as_u64().expect("revision");
|
||||
let previous = case.get("previous_revision").and_then(Value::as_u64);
|
||||
let actual = projection_revision_action(previous, incoming);
|
||||
let expected = match case["expect"].as_str().expect("expect") {
|
||||
"accept" => ProjectionRevisionAction::Accept,
|
||||
"refetch" => ProjectionRevisionAction::Refetch,
|
||||
"ignore" => ProjectionRevisionAction::Ignore,
|
||||
other => panic!("unknown projection expectation {other}"),
|
||||
};
|
||||
assert_eq!(actual, expected, "projection case {}", case["name"]);
|
||||
}
|
||||
|
||||
let mut unknown = cases[0]["projection"].clone();
|
||||
unknown["extra"] = serde_json::json!(true);
|
||||
assert!(serde_json::from_value::<SectionWorkspaceProjection>(unknown).is_err());
|
||||
let mut wrong_version = cases[0]["projection"].clone();
|
||||
wrong_version["version"] = serde_json::json!(2);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<SectionWorkspaceProjection>(wrong_version)
|
||||
.expect("typed wrong version")
|
||||
.validate(),
|
||||
Err(SectionWorkspaceValidationError::Invalid("version"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalization_and_every_typed_command_vector_match() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let fixture = fixture();
|
||||
let legacy = &fixture["canonicalization"]["legacy_plaintext"];
|
||||
let canonical = canonical_json(&legacy["input"]).expect("canonical legacy plaintext");
|
||||
assert_eq!(canonical, legacy["canonical"]);
|
||||
assert_eq!(
|
||||
hex::encode(Sha256::digest(canonical.as_bytes())),
|
||||
legacy["sha256"]
|
||||
);
|
||||
|
||||
let imported: SectionWorkspaceImport = serde_json::from_str(
|
||||
fixture["canonicalization"]["import_command"]["canonical"]
|
||||
.as_str()
|
||||
.expect("canonical import"),
|
||||
)
|
||||
.expect("typed canonical import");
|
||||
imported.validate().expect("valid import vector");
|
||||
let import_value = serde_json::to_value(&imported).expect("import value");
|
||||
let import_canonical = canonical_json(&import_value).expect("canonical typed import");
|
||||
assert_eq!(
|
||||
import_canonical,
|
||||
fixture["canonicalization"]["import_command"]["canonical"]
|
||||
);
|
||||
assert_eq!(
|
||||
hex::encode(Sha256::digest(import_canonical.as_bytes())),
|
||||
fixture["canonicalization"]["import_command"]["sha256"]
|
||||
);
|
||||
|
||||
for case in fixture["command_cases"].as_array().expect("command cases") {
|
||||
let Some(command) = case.get("command") else {
|
||||
continue;
|
||||
};
|
||||
let parsed =
|
||||
if case["name"].as_str().unwrap().starts_with("grant-") {
|
||||
serde_json::from_value::<SectionWorkspaceGrantCommand>(command.clone())
|
||||
.and_then(|value| {
|
||||
value.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(())
|
||||
})
|
||||
} else {
|
||||
serde_json::from_value::<SectionWorkspaceRevokeCommand>(command.clone())
|
||||
.and_then(|value| {
|
||||
value.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(())
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
parsed.is_ok(),
|
||||
case["expect"] == "accept",
|
||||
"command case {}",
|
||||
case["name"]
|
||||
);
|
||||
}
|
||||
for mut command in [
|
||||
serde_json::to_value(&imported).unwrap(),
|
||||
fixture["command_cases"][0]["command"].clone(),
|
||||
fixture["command_cases"][2]["command"].clone(),
|
||||
] {
|
||||
command["version"] = serde_json::json!(2);
|
||||
let valid = if command.get("source_event_id").is_some() {
|
||||
serde_json::from_value::<SectionWorkspaceImport>(command).and_then(|value| {
|
||||
value.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(())
|
||||
})
|
||||
} else if command.get("role").is_some() {
|
||||
serde_json::from_value::<SectionWorkspaceGrantCommand>(command).and_then(|value| {
|
||||
value.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(())
|
||||
})
|
||||
} else {
|
||||
serde_json::from_value::<SectionWorkspaceRevokeCommand>(command).and_then(|value| {
|
||||
value.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(())
|
||||
})
|
||||
};
|
||||
assert!(valid.is_err(), "wrong protocol version must fail");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_tag_grammar_rejects_missing_duplicate_unknown_and_mismatched_tags() {
|
||||
let action_id = Uuid::parse_str("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee").unwrap();
|
||||
let owner = "11".repeat(32);
|
||||
let actor = "44".repeat(32);
|
||||
let valid = vec![
|
||||
vec!["p".into(), owner.clone()],
|
||||
vec!["actor".into(), actor.clone()],
|
||||
vec!["action".into(), action_id.to_string()],
|
||||
];
|
||||
assert!(validate_command_tags(&valid, &owner, Some(&actor), action_id).is_ok());
|
||||
for invalid in [
|
||||
valid[..2].to_vec(),
|
||||
[valid.clone(), vec![valid[0].clone()]].concat(),
|
||||
[valid.clone(), vec![vec!["extra".into(), "x".into()]]].concat(),
|
||||
vec![
|
||||
valid[0].clone(),
|
||||
valid[1].clone(),
|
||||
vec!["action".into(), Uuid::nil().to_string()],
|
||||
],
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_command_tags(&invalid, &owner, Some(&actor), action_id),
|
||||
Err(SectionWorkspaceValidationError::Invalid("tag_grammar"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_command_decoding_rejects_duplicates_and_malformed_values() {
|
||||
let duplicate = r#"{"version":1,"version":1,"action_id":"eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee","actor_pubkey":"4444444444444444444444444444444444444444444444444444444444444444","role":"viewer","key_epoch":1,"key_envelope":"wrapped"}"#;
|
||||
assert!(serde_json::from_str::<SectionWorkspaceGrantCommand>(duplicate).is_err());
|
||||
let mut malformed = fixture()["command_cases"][0]["command"].clone();
|
||||
malformed["role"] = serde_json::json!("owner");
|
||||
assert!(serde_json::from_value::<SectionWorkspaceGrantCommand>(malformed).is_err());
|
||||
assert!(canonical_json(&serde_json::json!({"fraction": 1.5})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,12 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[
|
||||
"relay_invites",
|
||||
"relay_members",
|
||||
"scheduled_workflow_fires",
|
||||
"section_actions",
|
||||
"section_assignments",
|
||||
"section_grants",
|
||||
"section_key_envelopes",
|
||||
"section_workspaces",
|
||||
"sections",
|
||||
"subscriptions",
|
||||
"thread_metadata",
|
||||
"users",
|
||||
@@ -88,6 +94,12 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[
|
||||
pub const PURGE_SCOPED_TABLES: &[&str] = &[
|
||||
"workflow_approvals",
|
||||
"scheduled_workflow_fires",
|
||||
"section_actions",
|
||||
"section_assignments",
|
||||
"section_grants",
|
||||
"section_key_envelopes",
|
||||
"sections",
|
||||
"section_workspaces",
|
||||
"workflow_runs",
|
||||
"push_wake_outbox",
|
||||
"join_policy_acceptances",
|
||||
|
||||
@@ -47,6 +47,8 @@ pub mod relay_invite;
|
||||
pub mod relay_members;
|
||||
/// Replica freshness fence for keyset-cursor read routing.
|
||||
pub mod replica_fence;
|
||||
/// Owner-scoped channel-section workspace persistence.
|
||||
pub mod section_workspace;
|
||||
/// Thread metadata persistence.
|
||||
pub mod thread;
|
||||
/// Per-community usage rollup queries for Prometheus gauges.
|
||||
@@ -1224,6 +1226,11 @@ impl Db {
|
||||
deletion::DeletionStore::new(self.pool.clone())
|
||||
}
|
||||
|
||||
/// Owner-scoped channel-section workspace persistence.
|
||||
pub fn section_workspace_store(&self) -> section_workspace::SectionWorkspaceStore {
|
||||
section_workspace::SectionWorkspaceStore::new(self.pool.clone())
|
||||
}
|
||||
|
||||
/// Begin a database transaction for atomic multi-statement operations.
|
||||
///
|
||||
/// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally.
|
||||
|
||||
@@ -1036,6 +1036,33 @@ mod tests {
|
||||
assert_eq!(migrations[29].version, 30);
|
||||
let deletion_recovery = migrations[29].sql.as_str();
|
||||
assert!(deletion_recovery.contains("SET LOCAL lock_timeout = '5s'"));
|
||||
|
||||
// Owner-scoped section workspaces are normalized and community-fenced.
|
||||
assert_eq!(migrations[30].version, 31);
|
||||
let section_workspaces = migrations[30].sql.as_str();
|
||||
for table in [
|
||||
"section_workspaces",
|
||||
"section_grants",
|
||||
"section_key_envelopes",
|
||||
"sections",
|
||||
"section_assignments",
|
||||
"section_actions",
|
||||
] {
|
||||
assert!(
|
||||
section_workspaces.contains(&format!("CREATE TABLE {table}")),
|
||||
"migration 0031 must create {table}"
|
||||
);
|
||||
assert!(
|
||||
section_workspaces.contains(&format!("attach_community_write_fence('{table}')")),
|
||||
"migration 0031 must attach the community write fence to {table}"
|
||||
);
|
||||
assert!(
|
||||
desired_schema.contains(&format!("CREATE TABLE {table}")),
|
||||
"desired-state schema must create {table}"
|
||||
);
|
||||
}
|
||||
assert!(section_workspaces.contains("CREATE UNIQUE INDEX sections_active_rank"));
|
||||
assert!(desired_schema.contains("CREATE UNIQUE INDEX sections_active_rank"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1485,6 +1512,18 @@ mod tests {
|
||||
let mut expected_fences = migration.fence_attachments.clone();
|
||||
expected_fences.remove("product_feedback");
|
||||
expected_fences.remove("rate_limit_violations");
|
||||
expected_fences.extend(
|
||||
[
|
||||
"section_actions",
|
||||
"section_assignments",
|
||||
"section_grants",
|
||||
"section_key_envelopes",
|
||||
"section_workspaces",
|
||||
"sections",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned),
|
||||
);
|
||||
assert_eq!(
|
||||
expected_fences, schema.fence_attachments,
|
||||
"write-fence attachment targets differ after recovery policy"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"version": 1,
|
||||
"kinds": {
|
||||
"import_v1": 9050,
|
||||
"grant": 9051,
|
||||
"revoke": 9052,
|
||||
"move": 9053,
|
||||
"manage": 9054,
|
||||
"projection": 30623
|
||||
},
|
||||
"roles": [
|
||||
"viewer",
|
||||
"mover",
|
||||
"manager"
|
||||
],
|
||||
"limits": {
|
||||
"sections": 100,
|
||||
"assignments": 1000,
|
||||
"grants": 256,
|
||||
"encrypted_metadata_bytes": 65535,
|
||||
"key_envelope_bytes": 4096
|
||||
},
|
||||
"projection_cases": [
|
||||
{
|
||||
"name": "migrated-owner-and-viewer",
|
||||
"projection": {
|
||||
"version": 1,
|
||||
"owner_pubkey": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"revision": 4,
|
||||
"layout_revision": 1,
|
||||
"key_epoch": 2,
|
||||
"migration": {
|
||||
"source_event_id": "2222222222222222222222222222222222222222222222222222222222222222",
|
||||
"source_hash": "3333333333333333333333333333333333333333333333333333333333333333"
|
||||
},
|
||||
"reader_key_envelope": "nip44-envelope-reader-epoch-2",
|
||||
"sections": [
|
||||
{
|
||||
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
"rank": 0,
|
||||
"encrypted_label": "ciphertext-alpha",
|
||||
"encrypted_icon": null
|
||||
},
|
||||
{
|
||||
"id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
"rank": 1,
|
||||
"encrypted_label": "ciphertext-beta",
|
||||
"encrypted_icon": "ciphertext-icon"
|
||||
}
|
||||
],
|
||||
"assignments": [
|
||||
{
|
||||
"channel_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
"section_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
"revision": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"expect": "accept"
|
||||
},
|
||||
{
|
||||
"name": "revision-gap",
|
||||
"previous_revision": 4,
|
||||
"projection": {
|
||||
"version": 1,
|
||||
"revision": 6
|
||||
},
|
||||
"expect": "refetch"
|
||||
},
|
||||
{
|
||||
"name": "stale-projection",
|
||||
"previous_revision": 4,
|
||||
"projection": {
|
||||
"version": 1,
|
||||
"revision": 3
|
||||
},
|
||||
"expect": "ignore"
|
||||
}
|
||||
],
|
||||
"role_matrix": {
|
||||
"owner": [
|
||||
"read",
|
||||
"grant",
|
||||
"revoke",
|
||||
"move",
|
||||
"manage",
|
||||
"import_v1"
|
||||
],
|
||||
"viewer": [
|
||||
"read"
|
||||
],
|
||||
"mover": [
|
||||
"read",
|
||||
"move"
|
||||
],
|
||||
"manager": [
|
||||
"read",
|
||||
"move",
|
||||
"manage"
|
||||
]
|
||||
},
|
||||
"canonicalization": {
|
||||
"profile": "nip-sw-canonical-json-v1",
|
||||
"legacy_plaintext": {
|
||||
"input": {
|
||||
"version": 1,
|
||||
"sections": [
|
||||
{
|
||||
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
"name": "Alpha",
|
||||
"icon": "folder",
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"assignments": {
|
||||
"cccccccc-cccc-4ccc-8ccc-cccccccccccc": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
"canonical": "{\"assignments\":{\"cccccccc-cccc-4ccc-8ccc-cccccccccccc\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"},\"sections\":[{\"icon\":\"folder\",\"id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"name\":\"Alpha\",\"order\":0}],\"version\":1}",
|
||||
"sha256": "336ba846d8a2aef9ca7de80e494201e503b64fa364ec1a629873f3fa83232d5d"
|
||||
},
|
||||
"import_command": {
|
||||
"canonical": "{\"action_id\":\"dddddddd-dddd-4ddd-8ddd-dddddddddddd\",\"assignments\":[{\"channel_id\":\"cccccccc-cccc-4ccc-8ccc-cccccccccccc\",\"section_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}],\"key_epoch\":1,\"owner_key_envelope\":\"nip44-owner-envelope\",\"sections\":[{\"encrypted_icon\":null,\"encrypted_label\":\"ciphertext-label\",\"id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"rank\":0}],\"source_event_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"source_hash\":\"336ba846d8a2aef9ca7de80e494201e503b64fa364ec1a629873f3fa83232d5d\",\"version\":1}",
|
||||
"sha256": "c2801715fbdcd339b0ccf49a22c1efb111294e6af72cd0604a782ce692977099"
|
||||
}
|
||||
},
|
||||
"metadata_crypto": {
|
||||
"algorithm": "AES-256-GCM",
|
||||
"encoding": "aes256gcm:<base64url-no-pad-12-byte-nonce>:<base64url-no-pad-ciphertext-and-16-byte-tag>",
|
||||
"key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
||||
"nonce_base64url": "AAECAwQFBgcICQoL",
|
||||
"plaintext_utf8": "Alpha",
|
||||
"aad_canonical": "{\"community\":\"relay.example\",\"key_epoch\":1,\"owner_pubkey\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"purpose\":\"label\",\"section_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"version\":1}",
|
||||
"envelope": "aes256gcm:AAECAwQFBgcICQoL:Bm6mc6SHvhxcEB6Q1Lc56VuJZjD7",
|
||||
"reject_if_aad_changes": [
|
||||
"community",
|
||||
"owner_pubkey",
|
||||
"section_id",
|
||||
"key_epoch",
|
||||
"purpose"
|
||||
]
|
||||
},
|
||||
"command_cases": [
|
||||
{
|
||||
"name": "grant-accept",
|
||||
"command": {
|
||||
"version": 1,
|
||||
"action_id": "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
||||
"actor_pubkey": "4444444444444444444444444444444444444444444444444444444444444444",
|
||||
"role": "viewer",
|
||||
"key_epoch": 1,
|
||||
"key_envelope": "nip44-viewer-envelope"
|
||||
},
|
||||
"expect": "accept"
|
||||
},
|
||||
{
|
||||
"name": "grant-unknown-field",
|
||||
"command": {
|
||||
"version": 1,
|
||||
"action_id": "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
||||
"actor_pubkey": "4444444444444444444444444444444444444444444444444444444444444444",
|
||||
"role": "viewer",
|
||||
"key_epoch": 1,
|
||||
"key_envelope": "nip44-viewer-envelope",
|
||||
"extra": true
|
||||
},
|
||||
"expect": "reject"
|
||||
},
|
||||
{
|
||||
"name": "revoke-accept",
|
||||
"command": {
|
||||
"version": 1,
|
||||
"action_id": "ffffffff-ffff-4fff-8fff-ffffffffffff",
|
||||
"actor_pubkey": "4444444444444444444444444444444444444444444444444444444444444444",
|
||||
"new_key_epoch": 2,
|
||||
"sections": [
|
||||
{
|
||||
"section_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
"encrypted_label": "ciphertext-label-epoch-2",
|
||||
"encrypted_icon": null
|
||||
}
|
||||
],
|
||||
"envelopes": [
|
||||
{
|
||||
"reader_pubkey": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"key_envelope": "nip44-owner-epoch-2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"expect": "accept"
|
||||
},
|
||||
{
|
||||
"name": "action-id-different-hash",
|
||||
"same_action_id": true,
|
||||
"first_hash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"retry_hash": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"expect": "reject"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
NIP-SW
|
||||
======
|
||||
|
||||
Owner-Scoped Channel-Section Workspaces
|
||||
---------------------------------------
|
||||
|
||||
`draft` `optional` `relay`
|
||||
|
||||
**Depends on**: NIP-01 (event format and addressable events), NIP-42 (authenticated relay connections), NIP-44 (pairwise key wrapping)
|
||||
|
||||
## Abstract
|
||||
|
||||
NIP-SW defines one relay-authoritative channel-section workspace per `(community, owner pubkey)`. Owners grant exact pubkeys `viewer`, `mover`, or `manager` access. Actors sign typed commands as themselves; the relay authorizes and applies commands transactionally to normalized state. The relay publishes a revisioned projection only to the owner and active grantees.
|
||||
|
||||
The relay sees structural section UUIDs, ordering, and channel assignments. Section names and icons are ciphertext under a random per-workspace content key. That key is wrapped separately to each authorized reader using NIP-44; it grants confidentiality access, never mutation authority.
|
||||
|
||||
This design deliberately does not permit delegates to replace the legacy kind-30078 `d=channel-sections` whole-store blob. Typed normalized commands prevent one stale writer from replacing unrelated state.
|
||||
|
||||
## Kinds
|
||||
|
||||
| Kind | Name | Signer | Storage | Stage |
|
||||
|------|------|--------|---------|-------|
|
||||
| `9050` | Import v1 | owner | command, not ordinary event storage | 1 |
|
||||
| `9051` | Grant | owner | command, not ordinary event storage | 1 |
|
||||
| `9052` | Revoke + rotate | owner | command, not ordinary event storage | 1 |
|
||||
| `9053` | Move channel | owner, mover, manager | command, not ordinary event storage | 2 |
|
||||
| `9054` | Manage sections | owner, manager | command, not ordinary event storage | 3 |
|
||||
| `30623` | Workspace projection | relay | addressable/current projection | 1 |
|
||||
|
||||
The allocation was checked against this repository and the upstream `nostr-protocol/nips` event-kind table at commit `656cecc7c0a815b6a2b218d3b5d6f078b3f4dbab`: `9050`–`9054` and `30623` were unassigned upstream and unused in Buzz. These are Buzz-specific kinds; a future upstream collision requires a protocol revision.
|
||||
|
||||
## Coordinate and authority
|
||||
|
||||
The server-resolved community and command's owner pubkey identify a workspace. A client never supplies a community UUID. The relay derives community from the authenticated connection's host.
|
||||
|
||||
- Owner authority is implicit and immutable.
|
||||
- A `viewer` may read the projection.
|
||||
- A `mover` may read and submit kind 9053.
|
||||
- A `manager` may read and submit kinds 9053 and 9054.
|
||||
- Only the owner may submit kinds 9050, 9051, and 9052.
|
||||
|
||||
Every command carries a UUID `action_id`. The durable action row binds `(community, owner, action_id)` to a canonical command SHA-256. An exact retry returns its original revision. Reusing an action ID for different bytes is rejected. The relay durably stores the complete verified signed event JSON beside its event ID and command hash; this is the rebuild source, while normalized rows are the serving projection.
|
||||
|
||||
## Stage 1 command bodies
|
||||
|
||||
Command content uses the `nip-sw-canonical-json-v1` profile: recursively sort object keys by Unicode code-point order, preserve array order, emit UTF-8 with JSON escaping and no insignificant whitespace, and permit only integer JSON numbers. Strict typed decoding rejects duplicate and unknown keys before canonicalization. SHA-256 is computed over these exact UTF-8 bytes. Legacy plaintext uses the same profile before `source_hash` is computed. `NIP-SW.fixtures.json` contains byte-exact plaintext and command hash vectors. Tags provide routing and coarse filtering, but content defines the command and is validated before mutation. Every command has exactly one `p` tag naming the owner and exactly one `action` tag matching content's `action_id`.
|
||||
|
||||
### Revision-zero import (`kind:9050`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 9050,
|
||||
"tags": [
|
||||
["p", "<owner-pubkey>"],
|
||||
["action", "<uuid>"]
|
||||
],
|
||||
"content": "{\"version\":1,\"action_id\":\"<uuid>\",\"source_event_id\":\"<64-lower-hex>\",\"source_hash\":\"<sha256-lower-hex>\",\"key_epoch\":1,\"owner_key_envelope\":\"<nip44>\",\"sections\":[...],\"assignments\":[...]}"
|
||||
}
|
||||
```
|
||||
|
||||
Import requirements:
|
||||
|
||||
- signer equals owner;
|
||||
- workspace revision is zero and has no migration marker;
|
||||
- `key_epoch` is 1;
|
||||
- section IDs are non-nil and unique;
|
||||
- ranks are exactly the permutation `0..section_count`;
|
||||
- channels are unique, exist in the same community, and are not deleted;
|
||||
- assignment destinations name imported sections;
|
||||
- maximum 100 sections, 1,000 assignments, and 256 active delegate grants;
|
||||
- repeated identical `action_id` + command hash is idempotent; any different import after migration is rejected.
|
||||
|
||||
The relay atomically writes normalized sections, assignments, the owner's envelope, revision 1, layout revision 1, and a migration marker containing the source event ID and canonical plaintext hash.
|
||||
|
||||
### Grant (`kind:9051`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 9051,
|
||||
"tags": [["p", "<owner>"], ["actor", "<delegate>"], ["action", "<uuid>"]],
|
||||
"content": "{\"version\":1,\"action_id\":\"<uuid>\",\"role\":\"viewer\",\"key_epoch\":<current>,\"key_envelope\":\"<nip44>\"}"
|
||||
}
|
||||
```
|
||||
|
||||
The content also repeats `actor_pubkey` and it MUST equal the single `actor` tag. Stage 1 clients expose `viewer`, but the durable role vocabulary is frozen as `viewer|mover|manager` for later stages. The signer MUST equal owner. Grant and current-epoch envelope installation are one transaction.
|
||||
|
||||
### Revoke and rotate (`kind:9052`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 9052,
|
||||
"tags": [["p", "<owner>"], ["actor", "<revoked-reader>"], ["action", "<uuid>"]],
|
||||
"content": "{\"version\":1,\"action_id\":\"<uuid>\",\"new_key_epoch\":<current+1>,\"sections\":[<all-active-sections-reencrypted>],\"envelopes\":[<owner-and-every-remaining-reader>]}"
|
||||
}
|
||||
```
|
||||
|
||||
The content also repeats `actor_pubkey` and it MUST equal the single `actor` tag. Revocation is atomic: mark the exact grant revoked, advance the key epoch by one, replace every active section's encrypted label/icon, and install exactly one envelope for the owner and each remaining active grantee. The revoked reader must not receive a new envelope. Revocation prevents future reads and commands; it cannot erase plaintext or keys a reader already retained.
|
||||
|
||||
## Metadata encryption
|
||||
|
||||
The workspace content key is exactly 32 random bytes. Labels and icons use AES-256-GCM with a fresh random 12-byte nonce for every encryption. The wire envelope is `aes256gcm:<base64url-no-pad nonce>:<base64url-no-pad ciphertext||16-byte-tag>`. Plaintext is UTF-8.
|
||||
|
||||
AAD is the canonical JSON UTF-8 encoding of `{"version":1,"community":"<canonical relay authority>","owner_pubkey":"<64-lower-hex>","section_id":"<lowercase UUID>","key_epoch":<integer>,"purpose":"label|icon"}`. Clients MUST reject authentication failure and MUST NOT retry with weaker or omitted AAD. Binding community, owner, section, epoch, and purpose prevents cross-tenant, cross-owner, cross-section, stale-epoch, and label/icon substitution. `NIP-SW.fixtures.json` contains a deterministic AES-GCM vector and enumerates every AAD mutation that must fail.
|
||||
|
||||
NIP-44 is used only to pairwise-wrap the 32-byte workspace key; its standard versioned encoding remains unchanged.
|
||||
|
||||
## Projection (`kind:30623`)
|
||||
|
||||
The relay signs a current projection after each accepted command. It is addressable by `d=<owner-pubkey>` and carries one `p` tag per currently authorized reader, including the owner. Its JSON content follows `NIP-SW.fixtures.json`.
|
||||
|
||||
Projection fields are `version`, `owner_pubkey`, monotonic `revision`, `layout_revision`, `key_epoch`, migration marker, the requesting reader's pairwise key envelope, ordered encrypted sections, and current assignments. Because envelopes differ per reader, the relay synthesizes a reader-specific response rather than exposing one shared stored event body.
|
||||
|
||||
Read authorization is applied at every delivery surface: historical REQ, live subscription/fan-out, HTTP query, and search. A filter-level gate alone is insufficient because an attacker may query a known event ID. Revocation evicts affected live subscriptions immediately.
|
||||
|
||||
Clients persist only verified projections. A projection at or below the cached revision is ignored. A gap greater than one causes a full authorized refetch. During relay outage, the last verified cache may render but is stale and cannot become a replacement write.
|
||||
|
||||
## Migration and cutover
|
||||
|
||||
An updated owner client decrypts the newest valid kind-30078 `d=channel-sections` event, computes the canonical plaintext hash, creates the workspace content key and owner envelope, then submits kind 9050. It switches authority only after reading back the matching migration marker and projection. Once the marker exists, updated clients MUST NOT publish the legacy section blob again. The old event remains a read-only rollback artifact during the compatibility window.
|
||||
|
||||
Delegated moves remain feature-gated until supported desktop and mobile clients both honor this marker. Dual-writing is forbidden: an old whole blob cannot safely represent later normalized commands.
|
||||
|
||||
## Shared fixtures
|
||||
|
||||
`NIP-SW.fixtures.json` is the cross-platform oracle for kinds, roles, limits, projection shape, revision-gap behavior, and the role matrix. Rust, desktop, and mobile tests consume this same file. A protocol change is incomplete until the fixture and all consumers change together.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
- The owner private key is never shared. Delegates sign as themselves.
|
||||
- NIP-44 envelopes grant decryption only; relay authorization grants mutation.
|
||||
- The relay learns delegation, section UUID/order, and assignments as an accepted product tradeoff, but not labels/icons.
|
||||
- Authorization is checked at command ingest time, so a command created offline by a subsequently revoked actor fails.
|
||||
- Community is server-resolved and included in every database key. Same owner/section/channel UUIDs in another community are unrelated.
|
||||
- Signed command events and durable action hashes provide audit/rebuild evidence; wall-clock time never resolves semantic conflicts.
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Relay-authoritative owner-scoped channel-section workspaces.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
CREATE TABLE section_workspaces (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
|
||||
layout_revision BIGINT NOT NULL DEFAULT 0 CHECK (layout_revision >= 0),
|
||||
key_epoch BIGINT NOT NULL DEFAULT 0 CHECK (key_epoch >= 0),
|
||||
migrated_at TIMESTAMPTZ,
|
||||
migration_source_event_id BYTEA CHECK (migration_source_event_id IS NULL OR length(migration_source_event_id) = 32),
|
||||
migration_source_hash BYTEA CHECK (migration_source_hash IS NULL OR length(migration_source_hash) = 32),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, owner_pubkey),
|
||||
CHECK ((migrated_at IS NULL) = (migration_source_event_id IS NULL)),
|
||||
CHECK ((migrated_at IS NULL) = (migration_source_hash IS NULL)),
|
||||
CHECK ((migrated_at IS NULL) = (revision = 0))
|
||||
);
|
||||
|
||||
CREATE TABLE section_grants (
|
||||
community_id UUID NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
|
||||
role TEXT NOT NULL CHECK (role IN ('viewer', 'mover', 'manager')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
|
||||
granted_by BYTEA NOT NULL CHECK (length(granted_by) = 32),
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (community_id, owner_pubkey, actor_pubkey),
|
||||
FOREIGN KEY (community_id, owner_pubkey)
|
||||
REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE,
|
||||
CHECK (actor_pubkey <> owner_pubkey),
|
||||
CHECK ((status = 'revoked') = (revoked_at IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX section_grants_actor_active
|
||||
ON section_grants (community_id, actor_pubkey, owner_pubkey)
|
||||
WHERE status = 'active';
|
||||
|
||||
CREATE TABLE section_key_envelopes (
|
||||
community_id UUID NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
reader_pubkey BYTEA NOT NULL CHECK (length(reader_pubkey) = 32),
|
||||
key_epoch BIGINT NOT NULL CHECK (key_epoch > 0),
|
||||
envelope TEXT NOT NULL CHECK (octet_length(envelope) BETWEEN 1 AND 4096),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, owner_pubkey, reader_pubkey, key_epoch),
|
||||
FOREIGN KEY (community_id, owner_pubkey)
|
||||
REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE sections (
|
||||
community_id UUID NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
section_id UUID NOT NULL CHECK (section_id <> '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
encrypted_label TEXT NOT NULL CHECK (octet_length(encrypted_label) BETWEEN 1 AND 65535),
|
||||
encrypted_icon TEXT CHECK (encrypted_icon IS NULL OR octet_length(encrypted_icon) BETWEEN 1 AND 65535),
|
||||
rank INTEGER NOT NULL CHECK (rank BETWEEN 0 AND 99),
|
||||
revision BIGINT NOT NULL CHECK (revision > 0),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (community_id, owner_pubkey, section_id),
|
||||
FOREIGN KEY (community_id, owner_pubkey)
|
||||
REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX sections_active_rank
|
||||
ON sections (community_id, owner_pubkey, rank) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE section_assignments (
|
||||
community_id UUID NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
channel_id UUID NOT NULL,
|
||||
section_id UUID NOT NULL,
|
||||
revision BIGINT NOT NULL CHECK (revision > 0),
|
||||
updated_by BYTEA NOT NULL CHECK (length(updated_by) = 32),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, owner_pubkey, channel_id),
|
||||
FOREIGN KEY (community_id, owner_pubkey, section_id)
|
||||
REFERENCES sections(community_id, owner_pubkey, section_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (community_id, channel_id)
|
||||
REFERENCES channels(community_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE section_actions (
|
||||
community_id UUID NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
action_id UUID NOT NULL,
|
||||
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
|
||||
command_kind TEXT NOT NULL CHECK (command_kind IN (
|
||||
'import_v1', 'grant', 'revoke', 'assign_channel', 'unassign_channel',
|
||||
'create_section', 'rename_section', 'delete_section', 'reorder_sections'
|
||||
)),
|
||||
command_event_id BYTEA NOT NULL CHECK (length(command_event_id) = 32),
|
||||
signed_event_json JSONB NOT NULL CHECK (jsonb_typeof(signed_event_json) = 'object'),
|
||||
command_hash BYTEA NOT NULL CHECK (length(command_hash) = 32),
|
||||
resulting_revision BIGINT NOT NULL CHECK (resulting_revision > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, owner_pubkey, action_id),
|
||||
FOREIGN KEY (community_id, owner_pubkey)
|
||||
REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
SELECT attach_community_write_fence('section_workspaces');
|
||||
SELECT attach_community_write_fence('section_grants');
|
||||
SELECT attach_community_write_fence('section_key_envelopes');
|
||||
SELECT attach_community_write_fence('sections');
|
||||
SELECT attach_community_write_fence('section_assignments');
|
||||
SELECT attach_community_write_fence('section_actions');
|
||||
@@ -160,6 +160,108 @@ CREATE TABLE channel_members (
|
||||
CREATE INDEX idx_channel_members_pubkey ON channel_members (community_id, pubkey)
|
||||
WHERE removed_at IS NULL;
|
||||
|
||||
-- ── Section workspaces ───────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE section_workspaces (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
|
||||
layout_revision BIGINT NOT NULL DEFAULT 0 CHECK (layout_revision >= 0),
|
||||
key_epoch BIGINT NOT NULL DEFAULT 0 CHECK (key_epoch >= 0),
|
||||
migrated_at TIMESTAMPTZ,
|
||||
migration_source_event_id BYTEA CHECK (migration_source_event_id IS NULL OR length(migration_source_event_id) = 32),
|
||||
migration_source_hash BYTEA CHECK (migration_source_hash IS NULL OR length(migration_source_hash) = 32),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, owner_pubkey),
|
||||
CHECK ((migrated_at IS NULL) = (migration_source_event_id IS NULL)),
|
||||
CHECK ((migrated_at IS NULL) = (migration_source_hash IS NULL)),
|
||||
CHECK ((migrated_at IS NULL) = (revision = 0))
|
||||
);
|
||||
|
||||
CREATE TABLE section_grants (
|
||||
community_id UUID NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
|
||||
role TEXT NOT NULL CHECK (role IN ('viewer', 'mover', 'manager')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
|
||||
granted_by BYTEA NOT NULL CHECK (length(granted_by) = 32),
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (community_id, owner_pubkey, actor_pubkey),
|
||||
FOREIGN KEY (community_id, owner_pubkey)
|
||||
REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE,
|
||||
CHECK (actor_pubkey <> owner_pubkey),
|
||||
CHECK ((status = 'revoked') = (revoked_at IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX section_grants_actor_active
|
||||
ON section_grants (community_id, actor_pubkey, owner_pubkey)
|
||||
WHERE status = 'active';
|
||||
|
||||
CREATE TABLE section_key_envelopes (
|
||||
community_id UUID NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
reader_pubkey BYTEA NOT NULL CHECK (length(reader_pubkey) = 32),
|
||||
key_epoch BIGINT NOT NULL CHECK (key_epoch > 0),
|
||||
envelope TEXT NOT NULL CHECK (octet_length(envelope) BETWEEN 1 AND 4096),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, owner_pubkey, reader_pubkey, key_epoch),
|
||||
FOREIGN KEY (community_id, owner_pubkey)
|
||||
REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE sections (
|
||||
community_id UUID NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
section_id UUID NOT NULL CHECK (section_id <> '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
encrypted_label TEXT NOT NULL CHECK (octet_length(encrypted_label) BETWEEN 1 AND 65535),
|
||||
encrypted_icon TEXT CHECK (encrypted_icon IS NULL OR octet_length(encrypted_icon) BETWEEN 1 AND 65535),
|
||||
rank INTEGER NOT NULL CHECK (rank BETWEEN 0 AND 99),
|
||||
revision BIGINT NOT NULL CHECK (revision > 0),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (community_id, owner_pubkey, section_id),
|
||||
FOREIGN KEY (community_id, owner_pubkey)
|
||||
REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX sections_active_rank
|
||||
ON sections (community_id, owner_pubkey, rank) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE section_assignments (
|
||||
community_id UUID NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
channel_id UUID NOT NULL,
|
||||
section_id UUID NOT NULL,
|
||||
revision BIGINT NOT NULL CHECK (revision > 0),
|
||||
updated_by BYTEA NOT NULL CHECK (length(updated_by) = 32),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, owner_pubkey, channel_id),
|
||||
FOREIGN KEY (community_id, owner_pubkey, section_id)
|
||||
REFERENCES sections(community_id, owner_pubkey, section_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (community_id, channel_id)
|
||||
REFERENCES channels(community_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE section_actions (
|
||||
community_id UUID NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL CHECK (length(owner_pubkey) = 32),
|
||||
action_id UUID NOT NULL,
|
||||
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
|
||||
command_kind TEXT NOT NULL CHECK (command_kind IN (
|
||||
'import_v1', 'grant', 'revoke', 'assign_channel', 'unassign_channel',
|
||||
'create_section', 'rename_section', 'delete_section', 'reorder_sections'
|
||||
)),
|
||||
command_event_id BYTEA NOT NULL CHECK (length(command_event_id) = 32),
|
||||
signed_event_json JSONB NOT NULL CHECK (jsonb_typeof(signed_event_json) = 'object'),
|
||||
command_hash BYTEA NOT NULL CHECK (length(command_hash) = 32),
|
||||
resulting_revision BIGINT NOT NULL CHECK (resulting_revision > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, owner_pubkey, action_id),
|
||||
FOREIGN KEY (community_id, owner_pubkey)
|
||||
REFERENCES section_workspaces(community_id, owner_pubkey) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
|
||||
-- ── Users ─────────────────────────────────────────────────────────────────────
|
||||
-- Conformance: "Users, profiles, NIP-05, and user search". One profile per
|
||||
-- (community, pubkey): the same key reposts kind:0 in each community it joins.
|
||||
@@ -1662,6 +1764,12 @@ SELECT attach_community_write_fence('reactions');
|
||||
SELECT attach_community_write_fence('relay_invites');
|
||||
SELECT attach_community_write_fence('relay_members');
|
||||
SELECT attach_community_write_fence('scheduled_workflow_fires');
|
||||
SELECT attach_community_write_fence('section_actions');
|
||||
SELECT attach_community_write_fence('section_assignments');
|
||||
SELECT attach_community_write_fence('section_grants');
|
||||
SELECT attach_community_write_fence('section_key_envelopes');
|
||||
SELECT attach_community_write_fence('section_workspaces');
|
||||
SELECT attach_community_write_fence('sections');
|
||||
SELECT attach_community_write_fence('subscriptions');
|
||||
SELECT attach_community_write_fence('thread_metadata');
|
||||
SELECT attach_community_write_fence('users');
|
||||
|
||||
Reference in New Issue
Block a user