From b3434e8e5b703d013f7ef9cce4f1b7b5014b9ba6 Mon Sep 17 00:00:00 2001 From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 07:13:23 -0400 Subject: [PATCH 1/9] feat(nip-ia): add identity archival event kinds + request predicate Defines the five NIP-IA kinds (9035/9036 requests, 8002/8003 deltas, 13535 snapshot) and is_identity_archive_request_kind, mirroring the NIP-43 relay-membership layout. Contract base for the handler, DB, and emission lanes. --- crates/sprout-core/src/kind.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 570a3e4a6..9691ed253 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -118,6 +118,20 @@ pub const KIND_NIP43_MEMBER_REMOVED: u32 = 8001; /// NIP-43: User leave request (user-signed, ephemeral). pub const KIND_NIP43_LEAVE_REQUEST: u32 = 28936; +// NIP-IA identity archival requests (user/agent/owner-signed) +/// NIP-IA: Request that the relay archive a target identity. +pub const KIND_IA_ARCHIVE_REQUEST: u32 = 9035; +/// NIP-IA: Request that the relay unarchive a target identity. +pub const KIND_IA_UNARCHIVE_REQUEST: u32 = 9036; + +// NIP-IA identity archival announcement events (relay-signed) +/// NIP-IA: Archived-identity delta (relay-signed). +pub const KIND_IA_ARCHIVED: u32 = 8002; +/// NIP-IA: Unarchived-identity delta (relay-signed). +pub const KIND_IA_UNARCHIVED: u32 = 8003; +/// NIP-IA: Archived identities list snapshot (relay-signed, replaceable). +pub const KIND_IA_ARCHIVED_LIST: u32 = 13535; + // System / admin (9100–9999) /// V1 used kind:9001 — moved here due to NIP-29 conflict. pub const KIND_SYSTEM_TIMER_FIRED: u32 = 9100; @@ -379,6 +393,11 @@ pub const ALL_KINDS: &[u32] = &[ KIND_NIP43_MEMBER_ADDED, KIND_NIP43_MEMBER_REMOVED, KIND_NIP43_LEAVE_REQUEST, + KIND_IA_ARCHIVE_REQUEST, + KIND_IA_UNARCHIVE_REQUEST, + KIND_IA_ARCHIVED, + KIND_IA_UNARCHIVED, + KIND_IA_ARCHIVED_LIST, KIND_SYSTEM_TIMER_FIRED, KIND_SYSTEM_SLASH_COMMAND, KIND_SYSTEM_FLAG, @@ -507,6 +526,15 @@ pub const fn is_relay_admin_kind(kind: u32) -> bool { ) } +/// Returns `true` if `kind` is a NIP-IA identity archival request (9035–9036). +/// +/// Only the user-signed *request* kinds are matched. The relay-signed delta and +/// snapshot kinds (8002/8003/13535) are emitted by the relay, never ingested as +/// commands, so they are intentionally excluded. +pub const fn is_identity_archive_request_kind(kind: u32) -> bool { + matches!(kind, KIND_IA_ARCHIVE_REQUEST | KIND_IA_UNARCHIVE_REQUEST) +} + /// Returns `true` if `kind` is a Sprout command kind that requires transactional execution. pub const fn is_command_kind(kind: u32) -> bool { matches!( From 6e3437b9f30f0e2f0db147c54ce53f8cd31863c2 Mon Sep 17 00:00:00 2001 From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 07:18:43 -0400 Subject: [PATCH 2/9] feat(db): persist archived identities --- crates/sprout-db/src/archived_identities.rs | 111 ++++++++++++++++++++ crates/sprout-db/src/lib.rs | 41 ++++++++ schema/schema.sql | 12 +++ 3 files changed, 164 insertions(+) create mode 100644 crates/sprout-db/src/archived_identities.rs diff --git a/crates/sprout-db/src/archived_identities.rs b/crates/sprout-db/src/archived_identities.rs new file mode 100644 index 000000000..00237ccf8 --- /dev/null +++ b/crates/sprout-db/src/archived_identities.rs @@ -0,0 +1,111 @@ +//! Relay-scoped archived identity persistence (NIP-IA). +//! +//! The `archived_identities` table stores a relay-local UI visibility hint for +//! identity pubkeys. Archiving is not a ban: it does not affect membership, +//! relay access, or repository permissions. +//! All pubkey and event ID values are lowercase hex strings. + +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row as _}; + +use crate::error::Result; + +/// A single archived identity record. +#[derive(Debug, Clone)] +pub struct ArchivedIdentity { + /// 64-char lowercase hex pubkey of the archived identity. + pub pubkey: String, + /// Consent path that authorized the archive: `"self"`, `"owner"`, or `"admin"`. + pub consent_path: String, + /// 64-char lowercase hex pubkey of the actor that requested the archive. + pub actor: String, + /// Optional human-readable archive reason. + pub reason: Option, + /// Optional 64-char lowercase hex pubkey replacing this identity. + pub replaced_by: Option, + /// Hex event ID of the archive request that created this row. + pub request_event_id: String, + /// When the identity was archived. + pub archived_at: DateTime, +} + +/// Returns `true` if `pubkey` (64-char hex) is currently archived. +pub async fn is_archived(pool: &PgPool, pubkey: &str) -> Result { + let row = sqlx::query("SELECT 1 FROM archived_identities WHERE pubkey = $1") + .bind(pubkey) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + +/// Archives an identity. +/// +/// Returns `true` if the row was inserted, `false` if the identity was already +/// archived. Re-archiving is idempotent and does not mutate the existing row. +pub async fn archive( + pool: &PgPool, + pubkey: &str, + consent_path: &str, + actor: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + request_event_id: &str, +) -> Result { + let result = sqlx::query( + "INSERT INTO archived_identities \ + (pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (pubkey) DO NOTHING", + ) + .bind(pubkey) + .bind(consent_path) + .bind(actor) + .bind(reason) + .bind(replaced_by) + .bind(request_event_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Unarchives an identity. +/// +/// Returns `true` if a row was deleted, `false` if the identity was not archived. +pub async fn unarchive(pool: &PgPool, pubkey: &str) -> Result { + let result = sqlx::query("DELETE FROM archived_identities WHERE pubkey = $1") + .bind(pubkey) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Returns all archived identities ordered by archive time ascending. +pub async fn list_archived(pool: &PgPool) -> Result> { + let rows = sqlx::query( + "SELECT pubkey, consent_path, actor, reason, replaced_by, request_event_id, archived_at \ + FROM archived_identities ORDER BY archived_at ASC", + ) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(row_to_archived_identity) + .collect::, sqlx::Error>>() + .map_err(crate::error::DbError::from) +} + +fn row_to_archived_identity( + row: sqlx::postgres::PgRow, +) -> std::result::Result { + Ok(ArchivedIdentity { + pubkey: row.try_get("pubkey")?, + consent_path: row.try_get("consent_path")?, + actor: row.try_get("actor")?, + reason: row.try_get("reason")?, + replaced_by: row.try_get("replaced_by")?, + request_event_id: row.try_get("request_event_id")?, + archived_at: row.try_get("archived_at")?, + }) +} diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index e910fa596..6be89ca48 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -11,6 +11,8 @@ /// API token storage and lookup. pub mod api_token; +/// Relay-scoped archived identity persistence (NIP-IA). +pub mod archived_identities; /// Channel and membership persistence. pub mod channel; /// Direct message channel persistence. @@ -1416,6 +1418,45 @@ impl Db { relay_members::backfill_from_allowlist(&self.pool).await } + // ── Archived identities (NIP-IA) ────────────────────────────────────────── + + /// Returns `true` if `pubkey` (64-char hex) is currently archived. + pub async fn is_archived(&self, pubkey: &str) -> Result { + archived_identities::is_archived(&self.pool, pubkey).await + } + + /// Archives an identity. Returns `true` if inserted, `false` if already archived. + pub async fn archive( + &self, + pubkey: &str, + consent_path: &str, + actor: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + request_event_id: &str, + ) -> Result { + archived_identities::archive( + &self.pool, + pubkey, + consent_path, + actor, + reason, + replaced_by, + request_event_id, + ) + .await + } + + /// Unarchives an identity. Returns `true` if deleted, `false` if absent. + pub async fn unarchive(&self, pubkey: &str) -> Result { + archived_identities::unarchive(&self.pool, pubkey).await + } + + /// Returns all archived identities ordered by archive time ascending. + pub async fn list_archived(&self) -> Result> { + archived_identities::list_archived(&self.pool).await + } + // ── Discovery events ───────────────────────────────────────────────────── /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. diff --git a/schema/schema.sql b/schema/schema.sql index 5f2889fb6..a44e14ee6 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -341,3 +341,15 @@ CREATE TABLE IF NOT EXISTS relay_members ( ); CREATE INDEX IF NOT EXISTS idx_relay_members_role ON relay_members(role); + +-- ── Archived identities (NIP-IA) ────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS archived_identities ( + pubkey TEXT PRIMARY KEY, + consent_path TEXT NOT NULL CHECK (consent_path IN ('self', 'owner', 'admin')), + actor TEXT NOT NULL, + reason TEXT, + replaced_by TEXT, + request_event_id TEXT NOT NULL, + archived_at TIMESTAMPTZ NOT NULL DEFAULT now() +); From 63dd0d826ecf7736e410a6bb41e78855f9c7b8b8 Mon Sep 17 00:00:00 2001 From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 07:20:51 -0400 Subject: [PATCH 3/9] feat(relay): handle identity archive requests --- .../src/handlers/identity_archive.rs | 428 ++++++++++++++++++ crates/sprout-relay/src/handlers/mod.rs | 2 + .../sprout-relay/src/handlers/side_effects.rs | 153 ++++++- 3 files changed, 580 insertions(+), 3 deletions(-) create mode 100644 crates/sprout-relay/src/handlers/identity_archive.rs diff --git a/crates/sprout-relay/src/handlers/identity_archive.rs b/crates/sprout-relay/src/handlers/identity_archive.rs new file mode 100644 index 000000000..24880d495 --- /dev/null +++ b/crates/sprout-relay/src/handlers/identity_archive.rs @@ -0,0 +1,428 @@ +//! NIP-IA identity archive request handler (kinds 9035–9036). +//! +//! These events are processed before storage: the request mutates the +//! `archived_identities` table and may emit relay-signed NIP-IA deltas and a +//! snapshot, then the ingest pipeline stores the request itself for audit. + +use std::sync::Arc; + +use nostr::{Event, PublicKey}; +use tracing::{info, warn}; + +use sprout_core::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_PROFILE}; +use sprout_db::EventQuery; + +use crate::handlers::side_effects::{ + publish_nipia_archival_list, publish_nipia_archived, publish_nipia_unarchived, +}; +use crate::state::AppState; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConsentPath { + SelfSigned, + Owner, + Admin, +} + +impl ConsentPath { + fn as_str(self) -> &'static str { + match self { + Self::SelfSigned => "self", + Self::Owner => "owner", + Self::Admin => "admin", + } + } +} + +/// Validate and execute a NIP-IA archive/unarchive request. +pub async fn handle_identity_archive_event( + state: &Arc, + event: &Event, +) -> Result<(), String> { + let kind = event.kind.as_u16() as u32; + let actor_hex = event.pubkey.to_hex(); + + if kind != KIND_IA_ARCHIVE_REQUEST && kind != KIND_IA_UNARCHIVE_REQUEST { + return Err(format!("unexpected identity archive kind: {kind}")); + } + + enforce_freshness(event)?; + require_single_protected_tag(event)?; + + let target_hex = extract_single_p_tag_hex(event) + .ok_or_else(|| "missing or invalid p tag".to_string())? + .to_ascii_lowercase(); + + let replaced_by = extract_optional_replaced_by(event, &target_hex)?; + if kind == KIND_IA_UNARCHIVE_REQUEST && replaced_by.is_some() { + return Err("replaced-by is not valid on unarchive requests".to_string()); + } + + let reason = extract_tag_value(event, "reason"); + let consent_path = determine_consent_path(state, event, &target_hex, &actor_hex).await?; + let request_event_id = event.id.to_hex(); + + let changed = if kind == KIND_IA_ARCHIVE_REQUEST { + state + .db + .archive( + &target_hex, + consent_path.as_str(), + &actor_hex, + reason.as_deref(), + replaced_by.as_deref(), + &request_event_id, + ) + .await + .map_err(|e| format!("database error: {e}"))? + } else { + state + .db + .unarchive(&target_hex) + .await + .map_err(|e| format!("database error: {e}"))? + }; + + info!( + actor = %actor_hex, + target = %target_hex, + consent = consent_path.as_str(), + changed, + kind, + "identity archive request processed" + ); + + if !changed { + return Ok(()); + } + + let publish_delta = if kind == KIND_IA_ARCHIVE_REQUEST { + publish_nipia_archived( + state, + &target_hex, + consent_path.as_str(), + &actor_hex, + &request_event_id, + &event.content, + reason.as_deref(), + replaced_by.as_deref(), + ) + .await + } else { + publish_nipia_unarchived( + state, + &target_hex, + consent_path.as_str(), + &actor_hex, + &request_event_id, + &event.content, + reason.as_deref(), + ) + .await + }; + + if let Err(e) = publish_delta { + warn!(error = %e, "failed to publish NIP-IA delta"); + } + if let Err(e) = publish_nipia_archival_list(state).await { + warn!(error = %e, "failed to publish NIP-IA archival list"); + } + + Ok(()) +} + +fn enforce_freshness(event: &Event) -> Result<(), String> { + let event_ts = event.created_at.as_secs() as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + if (event_ts - now).abs() > 120 { + return Err(format!( + "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)", + event_ts - now + )); + } + Ok(()) +} + +fn require_single_protected_tag(event: &Event) -> Result<(), String> { + let count = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("-")) + .count(); + if count != 1 { + return Err(format!( + "request must include exactly one NIP-70 protected event tag [\"-\"] (got {count})" + )); + } + Ok(()) +} + +fn extract_single_p_tag_hex(event: &Event) -> Option { + let mut found = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) != Some("p") { + continue; + } + let val = parts.get(1)?.as_str(); + if val.len() != 64 || !val.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + if found.is_some() { + return None; + } + found = Some(val.to_string()); + } + found +} + +fn extract_tag_value(event: &Event, name: &str) -> Option { + event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some(name) { + parts.get(1).map(|s| s.to_string()) + } else { + None + } + }) +} + +fn extract_optional_replaced_by(event: &Event, target_hex: &str) -> Result, String> { + let mut found = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) != Some("replaced-by") { + continue; + } + let val = parts + .get(1) + .ok_or_else(|| "invalid replaced-by tag".to_string())? + .to_string(); + if val.len() != 64 + || !val.chars().all(|c| c.is_ascii_hexdigit()) + || val.to_ascii_lowercase() != val + { + return Err("invalid replaced-by pubkey".to_string()); + } + if val == target_hex { + return Err("replaced-by must differ from target".to_string()); + } + if found.is_some() { + return Err("multiple replaced-by tags".to_string()); + } + found = Some(val); + } + Ok(found) +} + +async fn determine_consent_path( + state: &Arc, + event: &Event, + target_hex: &str, + actor_hex: &str, +) -> Result { + if actor_hex == target_hex { + return Ok(ConsentPath::SelfSigned); + } + + let actor_member = state + .db + .get_relay_member(actor_hex) + .await + .map_err(|e| format!("database error: {e}"))?; + let actor_role = actor_member.as_ref().map(|m| m.role.as_str()).unwrap_or(""); + if actor_role == "owner" || actor_role == "admin" { + return Ok(ConsentPath::Admin); + } + + verify_owner_consent(state, event, target_hex, actor_hex).await?; + Ok(ConsentPath::Owner) +} + +async fn verify_owner_consent( + state: &Arc, + event: &Event, + target_hex: &str, + actor_hex: &str, +) -> Result<(), String> { + let request_auth = extract_single_auth_tag_json(event)?; + let request_owner = verify_auth_tag_owner(&request_auth, target_hex) + .map_err(|e| format!("invalid request auth tag: {e}"))?; + if request_owner != actor_hex { + return Err("request auth owner must equal request signer".to_string()); + } + enforce_request_auth_time_bounds(&request_auth, event.created_at.as_secs())?; + + let target_pubkey = + PublicKey::from_hex(target_hex).map_err(|e| format!("invalid target pubkey: {e}"))?; + let target_author = target_pubkey.to_bytes().to_vec(); + let profile = state + .db + .query_events(&EventQuery { + kinds: Some(vec![KIND_PROFILE as i32]), + authors: Some(vec![target_author]), + limit: Some(1), + global_only: true, + ..Default::default() + }) + .await + .map_err(|e| format!("database error: {e}"))? + .into_iter() + .next() + .ok_or_else(|| "target has no live kind:0 profile".to_string())?; + + if profile.event.pubkey.to_hex() != target_hex { + return Err("live kind:0 author did not match target".to_string()); + } + + let live_auth = extract_single_auth_tag_json(&profile.event)?; + let live_owner = verify_auth_tag_owner(&live_auth, target_hex) + .map_err(|e| format!("invalid live kind:0 auth tag: {e}"))?; + if live_owner != actor_hex { + return Err("live kind:0 no longer attests to request signer".to_string()); + } + + Ok(()) +} + +fn extract_single_auth_tag_json(event: &Event) -> Result { + let mut found: Option> = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) != Some("auth") { + continue; + } + if parts.len() != 4 { + return Err("auth tag must have exactly four elements".to_string()); + } + if found.is_some() { + return Err("multiple auth tags".to_string()); + } + found = Some(parts.iter().map(|s| s.to_string()).collect()); + } + + let parts = found.ok_or_else(|| "missing auth tag".to_string())?; + serde_json::to_string(&parts).map_err(|e| format!("failed to encode auth tag: {e}")) +} + +fn verify_auth_tag_owner(auth_tag_json: &str, target_hex: &str) -> Result { + let target_pubkey = + PublicKey::from_hex(target_hex).map_err(|e| format!("invalid target pubkey: {e}"))?; + sprout_sdk::nip_oa::verify_auth_tag(auth_tag_json, &target_pubkey) + .map(|owner| owner.to_hex()) + .map_err(|e| e.to_string()) +} + +fn enforce_request_auth_time_bounds(auth_tag_json: &str, created_at: u64) -> Result<(), String> { + let parts: Vec = + serde_json::from_str(auth_tag_json).map_err(|e| format!("invalid auth tag json: {e}"))?; + let conditions = parts + .get(2) + .ok_or_else(|| "auth tag missing conditions".to_string())?; + + for clause in conditions.split('&').filter(|clause| !clause.is_empty()) { + if let Some(bound) = clause.strip_prefix("created_at<") { + let bound = bound + .parse::() + .map_err(|_| format!("invalid created_at< bound: {bound}"))?; + if created_at >= bound { + return Err(format!( + "request auth time bound not satisfied: created_at {created_at} >= {bound}" + )); + } + } else if let Some(bound) = clause.strip_prefix("created_at>") { + let bound = bound + .parse::() + .map_err(|_| format!("invalid created_at> bound: {bound}"))?; + if created_at <= bound { + return Err(format!( + "request auth time bound not satisfied: created_at {created_at} <= {bound}" + )); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn make_test_event(kind: u16, tags: Vec>) -> Event { + let keys = Keys::generate(); + let nostr_tags: Vec = tags + .into_iter() + .map(|parts| Tag::parse(parts).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::Custom(kind), "") + .tags(nostr_tags) + .sign_with_keys(&keys) + .expect("signing failed") + } + + #[test] + fn extract_single_p_tag_accepts_one_valid_tag() { + let hex = "a".repeat(64); + let event = make_test_event( + 9035, + vec![vec!["p", Box::leak(hex.clone().into_boxed_str())]], + ); + assert_eq!(extract_single_p_tag_hex(&event), Some(hex)); + } + + #[test] + fn extract_single_p_tag_rejects_multiple_tags() { + let event = make_test_event( + 9035, + vec![ + vec![ + "p", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ], + vec![ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ], + ], + ); + assert_eq!(extract_single_p_tag_hex(&event), None); + } + + #[test] + fn require_single_protected_tag_rejects_missing_or_multiple() { + let missing = make_test_event(9035, vec![]); + assert!(require_single_protected_tag(&missing).is_err()); + + let multiple = make_test_event(9035, vec![vec!["-"], vec!["-"]]); + assert!(require_single_protected_tag(&multiple).is_err()); + } + + #[test] + fn replaced_by_must_be_lowercase_hex_and_not_target() { + let target = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let same = make_test_event(9035, vec![vec!["replaced-by", target]]); + assert!(extract_optional_replaced_by(&same, target).is_err()); + + let upper = make_test_event( + 9035, + vec![vec![ + "replaced-by", + "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + ]], + ); + assert!(extract_optional_replaced_by(&upper, target).is_err()); + } + + #[test] + fn request_time_bounds_ignore_kind_clause() { + let conditions = "kind=1&created_at>100&created_at<200"; + let auth = serde_json::json!(["auth", "a".repeat(64), conditions, "b".repeat(128)]); + assert!(enforce_request_auth_time_bounds(&auth.to_string(), 150).is_ok()); + assert!(enforce_request_auth_time_bounds(&auth.to_string(), 100).is_err()); + assert!(enforce_request_auth_time_bounds(&auth.to_string(), 200).is_err()); + } +} diff --git a/crates/sprout-relay/src/handlers/mod.rs b/crates/sprout-relay/src/handlers/mod.rs index 0d1019001..7e5a456bd 100644 --- a/crates/sprout-relay/src/handlers/mod.rs +++ b/crates/sprout-relay/src/handlers/mod.rs @@ -8,6 +8,8 @@ pub mod command_executor; pub mod count; /// EVENT handler — WS dispatcher → ingest pipeline → fan-out. pub mod event; +/// NIP-IA identity archive request handler (kinds 9035–9036). +pub mod identity_archive; /// imeta tag validation helpers. pub mod imeta; /// Transport-neutral event ingestion pipeline. diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index f45960869..e5b9614eb 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -7,9 +7,10 @@ use tracing::{info, warn}; use uuid::Uuid; use sprout_core::kind::{ - event_kind_u32, is_parameterized_replaceable, KIND_GIT_REPO_ANNOUNCEMENT, - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, - KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, + event_kind_u32, is_parameterized_replaceable, KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, + KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, + KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, }; use sprout_db::channel::MemberRole; @@ -2098,3 +2099,149 @@ pub async fn reconcile_channel_events(state: &Arc) -> anyhow::Result<( } Ok(()) } + +// ── NIP-IA relay-level identity archive announcement events ────────────────── + +/// Publish a kind:13535 archived identities list event (NIP-IA). +/// +/// Queries all current archived identities and emits a relay-signed, +/// NIP-70-protected replaceable-by-convention snapshot with bare `p` tags. +pub async fn publish_nipia_archival_list(state: &Arc) -> anyhow::Result<()> { + let archived = state.db.list_archived().await?; + let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + + let mut tags: Vec = Vec::with_capacity(archived.len() + 1); + tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); + + for identity in &archived { + tags.push( + Tag::parse(["p", &identity.pubkey]) + .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?, + ); + } + + let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") + .tags(tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?; + + let (stored, was_inserted) = state.db.replace_addressable_event(&event, None).await?; + if was_inserted { + dispatch_persistent_event(state, &stored, KIND_IA_ARCHIVED_LIST, &relay_pubkey_hex).await; + } + + info!( + archived_count = archived.len(), + "NIP-IA archived identities list published" + ); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn publish_nipia_delta( + state: &Arc, + kind: u32, + target_pubkey_hex: &str, + consent_path: &str, + actor_pubkey_hex: &str, + request_event_id: &str, + content: &str, + reason: Option<&str>, + replaced_by: Option<&str>, +) -> anyhow::Result<()> { + let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + + let mut tags = vec![ + Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?, + Tag::parse(["p", target_pubkey_hex]) + .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?, + Tag::parse(["consent", consent_path, actor_pubkey_hex]) + .map_err(|e| anyhow::anyhow!("failed to build consent tag: {e}"))?, + Tag::parse(["e", request_event_id]) + .map_err(|e| anyhow::anyhow!("failed to build e tag: {e}"))?, + ]; + + if let Some(reason) = reason { + tags.push( + Tag::parse(["reason", reason]) + .map_err(|e| anyhow::anyhow!("failed to build reason tag: {e}"))?, + ); + } + if let Some(replaced_by) = replaced_by { + tags.push( + Tag::parse(["replaced-by", replaced_by]) + .map_err(|e| anyhow::anyhow!("failed to build replaced-by tag: {e}"))?, + ); + } + + let event = EventBuilder::new(Kind::Custom(kind as u16), content) + .tags(tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign kind:{kind}: {e}"))?; + + let (stored, was_inserted) = state.db.insert_event(&event, None).await?; + if !was_inserted { + return Ok(()); + } + + dispatch_persistent_event(state, &stored, kind, &relay_pubkey_hex).await; + + info!( + target = %target_pubkey_hex, + relay = %relay_pubkey_hex, + kind, + consent = %consent_path, + "NIP-IA delta event published" + ); + Ok(()) +} + +/// Publish a kind:8002 archived-identity delta event (NIP-IA). +#[allow(clippy::too_many_arguments)] +pub async fn publish_nipia_archived( + state: &Arc, + target_pubkey_hex: &str, + consent_path: &str, + actor_pubkey_hex: &str, + request_event_id: &str, + content: &str, + reason: Option<&str>, + replaced_by: Option<&str>, +) -> anyhow::Result<()> { + publish_nipia_delta( + state, + KIND_IA_ARCHIVED, + target_pubkey_hex, + consent_path, + actor_pubkey_hex, + request_event_id, + content, + reason, + replaced_by, + ) + .await +} + +/// Publish a kind:8003 unarchived-identity delta event (NIP-IA). +pub async fn publish_nipia_unarchived( + state: &Arc, + target_pubkey_hex: &str, + consent_path: &str, + actor_pubkey_hex: &str, + request_event_id: &str, + content: &str, + reason: Option<&str>, +) -> anyhow::Result<()> { + publish_nipia_delta( + state, + KIND_IA_UNARCHIVED, + target_pubkey_hex, + consent_path, + actor_pubkey_hex, + request_event_id, + content, + reason, + None, + ) + .await +} From 43a4ebfd9f16fdfb3c29cd1a4617cf237ea71a4c Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 07:44:21 -0400 Subject: [PATCH 4/9] =?UTF-8?q?feat(desktop/nip-ia):=20lane=206=20?= =?UTF-8?q?=E2=80=94=20archive=20button,=20flair,=20tauri=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane 6 of the NIP-IA owner+admin archival rollout (see docs/nips/NIP-IA.md). The desktop side: a profile-pane Archive/Unarchive button gated to the viewer's authority (self / relay admin/owner / verified NIP-OA owner of the viewee), an "Archived on this relay" flair driven by the relay's kind:13535 snapshot, and four new tauri commands for the wire work. Two §Implementation Gotchas worth a regression test each: 1. Gotcha #3 (target-as-subject NIP-OA verification) is exercised by an exact-spec-vector test in commands/identity_archive.rs. If the preimage subject ever drifts from the agent pubkey to the request signer, that test fails loudly. 2. nostr 0.44's EventBuilder removes p-tags matching the signer's pubkey unless allow_self_tagging() is called. NIP-IA's self path (actor == target, vectors 4/5) requires ["p", target] exactly when target == signer; without the call, every self-archive/unarchive ships malformed and the relay correctly rejects. Builders carry the call + a dedicated self-path test guards the regression. Files: - desktop/src-tauri/src/events.rs: build_archive_identity_request (kind:9035) + build_unarchive_identity_request (kind:9036) + identity_archive_tags shared assembly + spec vector 1 layout test. - desktop/src-tauri/src/commands/identity_archive.rs (new): resolve_oa_owner, archive_identity, unarchive_identity, list_archived_identities. The owner-of-agent path reads the target's live kind:0 and copies its verified auth tag onto the request; the relay independently re-verifies against the live kind:0 (per Tyler's anti-stale-credential rule) so the request's auth tag is intent+freshness evidence only. - desktop/src-tauri/src/commands/mod.rs + lib.rs: module + tauri invoke_handler registration. - desktop/src/shared/api/tauriIdentityArchive.ts (new): thin invokeTauri wrappers + frontend types (own file rather than inflating tauri.ts past its size budget). - desktop/src/features/identity-archive/hooks.ts (new): React Query hooks (snapshot query, OA owner resolution, archive/unarchive mutations). - desktop/src/features/profile/ui/UserProfilePanel.tsx: Archive/Unarchive button gated to (isSelf || relay admin/owner role || verified OA owner of viewee). "Archived" flair when the viewee is in the latest kind:13535. Hiding machinery deferred per kickoff scope. - desktop/scripts/check-file-sizes.mjs: events.rs cap 610 → 810 to accommodate the new builders + tests (single-file convention matches the NIP-43 admin builders already in the file). Tests added (all green via cargo test -p sprout-desktop): - events::tests::archive_identity_request_matches_spec_vector_1_layout - events::tests::archive_request_rejects_replaced_by_equal_target - events::tests::unarchive_request_layout_self_path - commands::identity_archive::tests::extract_oa_owner_returns_owner_for_valid_tag - commands::identity_archive::tests::extract_oa_owner_ignores_kind0_without_auth_tag - commands::identity_archive::tests::extract_oa_owner_matches_nip_ia_test_vector Verification: - cargo test -p sprout-desktop: 360 passed - pnpm typecheck + pnpm check: clean Follow-up that doesn't gate this lane: once Eva's lane-1 contract lands on the shared branch tip, swap Kind::Custom(9035/9036) literals in events.rs for the imported sprout_core::kind constants. ~2-line regression-safe rename. Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- desktop/scripts/check-file-sizes.mjs | 2 +- .../src/commands/identity_archive.rs | 349 ++++++++++++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/events.rs | 203 ++++++++++ desktop/src-tauri/src/lib.rs | 5 + .../src/features/identity-archive/hooks.ts | 72 ++++ .../features/profile/ui/UserProfilePanel.tsx | 106 +++++- .../src/shared/api/tauriIdentityArchive.ts | 75 ++++ 8 files changed, 812 insertions(+), 2 deletions(-) create mode 100644 desktop/src-tauri/src/commands/identity_archive.rs create mode 100644 desktop/src/features/identity-archive/hooks.ts create mode 100644 desktop/src/shared/api/tauriIdentityArchive.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index d93c83bf6..2a318254f 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -66,7 +66,7 @@ const overrides = new Map([ ["src/features/channels/ui/AddChannelBotDialog.tsx", 690], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field + reuse guardrail ["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow ["src/shared/api/types.ts", 650], // ... + AcpProviderCatalogEntry + AcpProvider (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs - ["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders + ["src-tauri/src/events.rs", 810], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders + NIP-IA identity-archive builders (9035/9036) + .allow_self_tagging() guards (nostr 0.44 strips self-`p` by default; self-archive/unarchive needs it preserved) + spec vector 1 layout test ["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring ["src-tauri/src/huddle/models.rs", 950], // model download manager for Parakeet TDT-CTC STT + Pocket TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + MODEL_LICENSE.txt sidecar (fail-closed readiness) + idempotent legacy Moonshine dir cleanup + tts_readiness_requires_license_sidecar test + Mary (VCTK p333) reference voice attribution block ["src-tauri/src/huddle/stt.rs", 580], // STT pipeline + PTT edge-detection flush + PTT gating (is_speech AND ptt_active) + barge-in for VAD mode + rubato resampler + earshot VAD + sherpa-onnx transcription diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs new file mode 100644 index 000000000..df75c8560 --- /dev/null +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -0,0 +1,349 @@ +//! NIP-IA identity archival commands. +//! +//! These commands let the desktop: +//! +//! - resolve a viewee's NIP-OA owner via their live `kind:0` (gates the +//! "Archive" button when the current user is the owner-of-agent), +//! - submit `kind:9035` archive and `kind:9036` unarchive requests (consent +//! path is selected by the relay; we just build the wire form), +//! - read the relay's `kind:13535` archive snapshot to drive UI flair. +//! +//! Spec: `docs/nips/NIP-IA.md`. The relay performs full authorization — +//! see §Owner-of-Agent Requests and §Relay Processing Algorithm. + +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::{ + app_state::AppState, + events, + relay::{query_relay, submit_event, SubmitEventResponse}, +}; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/// Read `target`'s live `kind:0` event and extract the first valid NIP-OA +/// `auth` tag plus the verified owner pubkey. +/// +/// Mirrors the verification the relay will do (per spec gotcha #3: the +/// preimage subject is the *target* pubkey, not the request signer). The +/// `sprout-sdk` lives on nostr 0.36; the desktop is on 0.37, so we bridge +/// via hex round-trip exactly like `relay::build_profile_event` does. +fn extract_oa_owner(target_kind0: &nostr::Event) -> Option<(String, [String; 4])> { + let target_hex = target_kind0.pubkey.to_hex(); + let target_compat = nostr::PublicKey::from_hex(&target_hex).ok()?; + + for tag in target_kind0.tags.iter() { + let slice = tag.as_slice(); + if slice.first().map(String::as_str) != Some("auth") || slice.len() != 4 { + continue; + } + let json = serde_json::to_string(slice).ok()?; + match sprout_sdk::nip_oa::verify_auth_tag(&json, &target_compat) { + Ok(owner) => { + let raw: [String; 4] = [ + slice[0].clone(), + slice[1].clone(), + slice[2].clone(), + slice[3].clone(), + ]; + return Some((owner.to_hex(), raw)); + } + Err(_) => continue, + } + } + None +} + +async fn fetch_kind0(state: &AppState, pubkey: &str) -> Result, String> { + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [0], + "authors": [pubkey.to_ascii_lowercase()], + "limit": 1, + })], + ) + .await?; + Ok(events.into_iter().next()) +} + +// ── Owner-of-agent resolution ─────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct OwnerOfAgent { + /// Owner pubkey (hex) recovered from the viewee's verified NIP-OA `auth` tag. + pub owner: String, + /// True iff `owner` equals the current user's pubkey. Lets the frontend + /// gate the "Archive" button without a second round-trip. + pub is_me: bool, +} + +/// Resolve `target`'s NIP-OA owner by reading its live `kind:0` and verifying +/// the embedded `auth` tag. Returns `None` if the target has no kind:0, no +/// `auth` tag, or the tag fails verification. +/// +/// This is what gates the owner-path archive button: the frontend calls this, +/// and if `is_me == true`, shows the button. +#[tauri::command] +pub async fn resolve_oa_owner( + target_pubkey: String, + state: State<'_, AppState>, +) -> Result, String> { + let Some(kind0) = fetch_kind0(&state, &target_pubkey).await? else { + return Ok(None); + }; + + let Some((owner_hex, _tag)) = extract_oa_owner(&kind0) else { + return Ok(None); + }; + + let my_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + + Ok(Some(OwnerOfAgent { + is_me: my_pubkey.eq_ignore_ascii_case(&owner_hex), + owner: owner_hex, + })) +} + +// ── Archive / unarchive requests ──────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct ArchiveRequest { + pub target_pubkey: String, + #[serde(default)] + pub content: String, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub replaced_by: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UnarchiveRequest { + pub target_pubkey: String, + #[serde(default)] + pub content: String, + #[serde(default)] + pub reason: Option, +} + +/// Submit a `kind:9035` archive request to the relay. Consent path is selected +/// by the relay — we just attach the owner-of-agent `auth` tag when the live +/// `kind:0` proves we own the target, so the relay can choose the `owner` +/// path. Self and admin paths require no auth tag. +#[tauri::command] +pub async fn archive_identity( + req: ArchiveRequest, + state: State<'_, AppState>, +) -> Result { + let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; + let auth_ref = auth_tag.as_ref(); + + let builder = events::build_archive_identity_request( + &req.target_pubkey, + &req.content, + req.reason.as_deref(), + req.replaced_by.as_deref(), + auth_ref, + )?; + submit_event(builder, &state).await +} + +/// Submit a `kind:9036` unarchive request to the relay. +#[tauri::command] +pub async fn unarchive_identity( + req: UnarchiveRequest, + state: State<'_, AppState>, +) -> Result { + let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; + let auth_ref = auth_tag.as_ref(); + + let builder = events::build_unarchive_identity_request( + &req.target_pubkey, + &req.content, + req.reason.as_deref(), + auth_ref, + )?; + submit_event(builder, &state).await +} + +/// If the current user is the verified NIP-OA owner of `target`, return the +/// `auth` tag elements (label, owner, conditions, sig) for attachment to a +/// 9035/9036 request. Otherwise return `None` (self / admin / no-path). +/// +/// The relay independently re-fetches the target's live `kind:0` and verifies +/// against it; this tag is intent + freshness evidence, not the authority. +async fn maybe_owner_auth_tag( + state: &AppState, + target_pubkey: &str, +) -> Result, String> { + let my_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + + // Self path: never attach auth (spec §Self Requests: if actor==target and + // an `auth` tag is also present, relay MUST treat it as self). + if my_pubkey.eq_ignore_ascii_case(target_pubkey) { + return Ok(None); + } + + let Some(kind0) = fetch_kind0(state, target_pubkey).await? else { + return Ok(None); + }; + let Some((owner_hex, raw_tag)) = extract_oa_owner(&kind0) else { + return Ok(None); + }; + + if !owner_hex.eq_ignore_ascii_case(&my_pubkey) { + return Ok(None); + } + Ok(Some(raw_tag)) +} + +// ── Archive snapshot ──────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct ArchivedIdentitiesSnapshot { + /// Lowercase hex pubkeys present in the latest relay-signed `kind:13535`. + pub archived: Vec, +} + +/// Read the relay's latest `kind:13535` archive snapshot. The frontend caches +/// this and tests membership client-side to drive the "Archived" flair. +/// +/// Per spec §Snapshot and Delta Consistency: the latest valid `kind:13535` +/// signed by the relay identity is authoritative. The HTTP `/query` bridge +/// already returns events the relay accepted, so signature checking is the +/// relay's responsibility on the read path. +#[tauri::command] +pub async fn list_archived_identities( + state: State<'_, AppState>, +) -> Result { + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [13535], + "limit": 1, + })], + ) + .await?; + + let Some(snapshot) = events.into_iter().next() else { + return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); + }; + + let archived = snapshot + .tags + .iter() + .filter_map(|t| { + let slice = t.as_slice(); + if slice.first().map(String::as_str) == Some("p") && slice.len() >= 2 { + let pk = slice[1].to_ascii_lowercase(); + if pk.len() == 64 && pk.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(pk); + } + } + None + }) + .collect(); + + Ok(ArchivedIdentitiesSnapshot { archived }) +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + /// Build a fake `kind:0` with a valid NIP-OA auth tag for a fresh owner. + fn kind0_with_auth(agent: &Keys, owner: &Keys) -> nostr::Event { + // Compute auth tag via sprout-sdk (nostr 0.36) and bridge. + let agent_hex = agent.public_key().to_hex(); + let agent_compat = nostr::PublicKey::from_hex(&agent_hex).unwrap(); + let owner_compat_secret = + nostr::SecretKey::from_slice(&owner.secret_key().as_secret_bytes()[..]).unwrap(); + let owner_compat_keys = nostr::Keys::new(owner_compat_secret); + let tag_json = sprout_sdk::nip_oa::compute_auth_tag(&owner_compat_keys, &agent_compat, "") + .expect("compute_auth_tag"); + let compat_tag = sprout_sdk::nip_oa::parse_auth_tag(&tag_json).unwrap(); + let tag = Tag::parse(compat_tag.as_slice()).unwrap(); + EventBuilder::new(Kind::Metadata, "{}") + .tags([tag]) + .sign_with_keys(agent) + .unwrap() + } + + #[test] + fn extract_oa_owner_returns_owner_for_valid_tag() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let kind0 = kind0_with_auth(&agent, &owner); + + let (recovered, raw) = extract_oa_owner(&kind0).expect("auth tag should verify"); + assert_eq!(recovered, owner.public_key().to_hex()); + assert_eq!(raw[0], "auth"); + assert_eq!(raw[1], owner.public_key().to_hex()); + // conditions empty by construction + assert_eq!(raw[2], ""); + assert_eq!(raw[3].len(), 128); + } + + #[test] + fn extract_oa_owner_ignores_kind0_without_auth_tag() { + let agent = Keys::generate(); + let kind0 = EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&agent) + .unwrap(); + assert!(extract_oa_owner(&kind0).is_none()); + } + + /// Spec test-vector regression for gotcha #3: the NIP-OA preimage subject + /// is the *target/agent* pubkey, not the request signer. The vectors in + /// `docs/nips/NIP-IA.md` §Test Vectors fix concrete values; verifying the + /// vector's `auth` tag under the vector's agent pubkey MUST yield the + /// vector's owner pubkey. If our `extract_oa_owner` ever stops using the + /// agent pubkey as the preimage subject, this test fails loudly. + #[test] + fn extract_oa_owner_matches_nip_ia_test_vector() { + // From docs/nips/NIP-IA.md §Test Vectors → "NIP-OA auth tag". + const AGENT_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + const OWNER_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const CONDITIONS: &str = "kind=1&created_at<1713957000"; + const SIG: &str = "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369"; + + // We don't have the agent's secret key (it's `0x...02` in the spec, but + // we don't need to re-sign a kind:0 — we just need a kind:0 whose + // `pubkey` is AGENT_HEX and whose tags carry this auth tag). Sign with + // a *different* agent and then construct an unsigned-event-shaped + // struct ourselves. nostr 0.37 doesn't easily allow forging `pubkey` + // mismatched with the signing key, so we build via the public + // constructor that requires a key — and for THIS test, the kind:0 + // signature is not checked (we only call extract_oa_owner which reads + // the event's pubkey field and the auth tag bytes). + let agent_secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000002", + ) + .unwrap(); + let agent_keys = nostr::Keys::new(agent_secret); + assert_eq!(agent_keys.public_key().to_hex(), AGENT_HEX); + + let auth_tag = nostr::Tag::parse(["auth", OWNER_HEX, CONDITIONS, SIG]).unwrap(); + let kind0 = EventBuilder::new(Kind::Metadata, "{}") + .tags([auth_tag]) + .sign_with_keys(&agent_keys) + .unwrap(); + + let (owner, raw) = extract_oa_owner(&kind0).expect("spec vector should verify"); + assert_eq!(owner, OWNER_HEX); + assert_eq!(raw[1], OWNER_HEX); + assert_eq!(raw[2], CONDITIONS); + assert_eq!(raw[3], SIG); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 2bc5062f5..6cec86f5d 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ mod channels; mod dms; mod export_util; mod identity; +mod identity_archive; mod media; mod media_download; mod messages; @@ -30,6 +31,7 @@ pub use channel_templates::*; pub use channels::*; pub use dms::*; pub use identity::*; +pub use identity_archive::*; pub use media::*; pub use media_download::*; pub use messages::*; diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index f564d243b..843c7c4d5 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -10,6 +10,7 @@ //! Signing and submission happen in relay::submit_event. use nostr::{EventBuilder, EventId, Kind, Tag}; +use sprout_core::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use uuid::Uuid; // ── Constants ──────────────────────────────────────────────────────────────── @@ -489,6 +490,127 @@ pub fn build_relay_admin_change_role( Ok(EventBuilder::new(Kind::Custom(9032), "").tags(tags)) } +// ── NIP-IA identity archival ───────────────────────────────────────────────── +// +// kind:9035 archive request, kind:9036 unarchive request. +// Both protected by NIP-70 (`["-"]`), p-tag the target, and may carry +// optional `reason` (machine-readable code), `replaced-by` (9035 only), +// and a NIP-OA `auth` tag for owner-of-agent requests. +// +// See docs/nips/NIP-IA.md §Event Formats. The relay verifies; the desktop's +// job is to produce a well-formed, signed request — consent path is selected +// by the relay, not declared here. + +fn check_reason(reason: &str) -> Result<(), String> { + // Reason codes are machine-readable strings; the spec doesn't cap length + // but we keep them short to discourage stuffing prose where `content` goes. + if reason.len() > 64 { + return Err(format!( + "reason code exceeds maximum length of 64 chars (got {})", + reason.len() + )); + } + if reason.chars().any(|c| c.is_control()) { + return Err("reason code must not contain control characters".into()); + } + Ok(()) +} + +fn identity_archive_tags( + target_pubkey: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + auth_tag: Option<&[String; 4]>, +) -> Result, String> { + check_pubkey(target_pubkey)?; + let target_lower = target_pubkey.to_ascii_lowercase(); + + let mut tags = Vec::with_capacity(5); + // NIP-70: mark as protected administrative state. + tags.push(tag(vec!["-"])?); + tags.push(tag(vec!["p", &target_lower])?); + + if let Some(r) = reason { + check_reason(r)?; + tags.push(tag(vec!["reason", r])?); + } + + if let Some(rb) = replaced_by { + check_pubkey(rb)?; + let rb_lower = rb.to_ascii_lowercase(); + if rb_lower == target_lower { + return Err("replaced-by must differ from the target".into()); + } + tags.push(tag(vec!["replaced-by", &rb_lower])?); + } + + if let Some(auth) = auth_tag { + // Structural check only — the relay performs full NIP-OA verification. + // We require the label, a 64-hex owner pubkey, and a 128-hex signature. + if auth[0] != "auth" { + return Err(format!( + "auth tag label must be \"auth\" (got \"{}\")", + auth[0] + )); + } + check_pubkey(&auth[1])?; + if auth[3].len() != 128 || !auth[3].chars().all(|c| c.is_ascii_hexdigit()) { + return Err("auth tag signature must be 128-character hex".into()); + } + tags.push(tag(vec!["auth", &auth[1], &auth[2], &auth[3]])?); + } + + Ok(tags) +} + +/// Kind 9035 — NIP-IA archive request. +/// +/// `content` is an optional human-readable reason (clients MUST NOT parse +/// authorization semantics from it). `reason` is the machine-readable code +/// (`rotated`, `retired`, `bot-rebuilt`, `left-organization`, `spam`, ...). +/// `replaced_by` is the rotation pointer. `auth` is a NIP-OA owner-attestation +/// tag required only for the owner-of-agent consent path. +/// +/// `.allow_self_tagging()` is required: NIP-IA's self path has `actor==target`, +/// which means the request's `["p", target]` matches the signer. nostr 0.44 +/// strips matching `p` tags by default — we need the wire form intact. +pub fn build_archive_identity_request( + target_pubkey: &str, + content: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + auth: Option<&[String; 4]>, +) -> Result { + check_content(content)?; + let tags = identity_archive_tags(target_pubkey, reason, replaced_by, auth)?; + Ok( + EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16), content) + .tags(tags) + .allow_self_tagging(), + ) +} + +/// Kind 9036 — NIP-IA unarchive request. +/// +/// Same shape as 9035 minus `replaced-by` (which has no defined meaning on +/// unarchive per spec). `auth` is used for owner-of-agent unarchive paths. +/// See `build_archive_identity_request` for the rationale on +/// `.allow_self_tagging()`. +pub fn build_unarchive_identity_request( + target_pubkey: &str, + content: &str, + reason: Option<&str>, + auth: Option<&[String; 4]>, +) -> Result { + check_content(content)?; + let tags = identity_archive_tags(target_pubkey, reason, None, auth)?; + Ok( + EventBuilder::new(Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16), content) + .tags(tags) + .allow_self_tagging(), + ) +} + /// Maximum contacts per contact list event. const MAX_CONTACTS: usize = 10_000; @@ -593,3 +715,84 @@ pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + + assert_eq!(event.kind, Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16)); + // Spec layout: ["-"], ["p", target], ["reason", code], ["auth", ...] + assert_eq!(tags[0], vec!["-"]); + assert_eq!(tags[1], vec!["p", TARGET_HEX]); + assert_eq!(tags[2], vec!["reason", "bot-rebuilt"]); + assert_eq!(tags[3], vec!["auth", OWNER_HEX, CONDITIONS, SIG]); + } + + #[test] + fn archive_request_rejects_replaced_by_equal_target() { + const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + let err = build_archive_identity_request(TARGET_HEX, "", None, Some(TARGET_HEX), None) + .unwrap_err(); + assert!(err.contains("replaced-by")); + } + + #[test] + fn unarchive_request_layout_self_path() { + const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + let builder = build_unarchive_identity_request( + TARGET_HEX, + "I am active again.", + Some("returned"), + None, + ) + .unwrap(); + let target_secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000002", + ) + .unwrap(); + let event = builder.sign_with_keys(&Keys::new(target_secret)).unwrap(); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert_eq!(event.kind, Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16)); + // Self-unarchive: the `p` tag MUST point at the signer. Verifies our + // `.allow_self_tagging()` call survives nostr 0.44's default scrub. + assert_eq!(tags[0], vec!["-"]); + assert_eq!(tags[1], vec!["p", TARGET_HEX]); + assert_eq!(tags[2], vec!["reason", "returned"]); + assert_eq!(tags.len(), 3, "self unarchive must not carry auth tag"); + assert_eq!(event.pubkey.to_hex(), TARGET_HEX); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ffc3efafb..8537035f2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -512,6 +512,11 @@ pub fn run() { add_relay_member, remove_relay_member, change_relay_member_role, + // NIP-IA identity archival + archive_identity, + unarchive_identity, + list_archived_identities, + resolve_oa_owner, list_relay_agents, list_managed_agents, create_managed_agent, diff --git a/desktop/src/features/identity-archive/hooks.ts b/desktop/src/features/identity-archive/hooks.ts new file mode 100644 index 000000000..49c6e8204 --- /dev/null +++ b/desktop/src/features/identity-archive/hooks.ts @@ -0,0 +1,72 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + archiveIdentity, + listArchivedIdentities, + resolveOaOwner, + unarchiveIdentity, + type ArchivedIdentitiesSnapshot, + type IdentityArchiveRequest, + type IdentityUnarchiveRequest, +} from "@/shared/api/tauriIdentityArchive"; + +export const archivedIdentitiesQueryKey = ["archivedIdentities"] as const; + +/** Cache the relay's `kind:13535` snapshot. Drives the "Archived" flair. */ +export function useArchivedIdentitiesQuery(enabled = true) { + return useQuery({ + enabled, + queryKey: archivedIdentitiesQueryKey, + queryFn: listArchivedIdentities, + staleTime: 30_000, + }); +} + +/** + * `true` iff `pubkey` appears in the relay's latest archive snapshot. + * Returns `undefined` while the snapshot is loading so callers can hide the + * flair until we know. + */ +export function useIsIdentityArchived(pubkey: string): boolean | undefined { + const query = useArchivedIdentitiesQuery(); + if (!query.data) return undefined; + const lower = pubkey.toLowerCase(); + return query.data.archived.includes(lower); +} + +/** + * Resolve the NIP-OA owner of a target via its live `kind:0`. Gates the + * owner-path archive button. + */ +export function useOaOwnerQuery(pubkey: string, enabled = true) { + return useQuery({ + enabled, + queryKey: ["oaOwner", pubkey.toLowerCase()] as const, + queryFn: () => resolveOaOwner(pubkey), + staleTime: 60_000, + }); +} + +export function useArchiveIdentityMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (req: IdentityArchiveRequest) => archiveIdentity(req), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: archivedIdentitiesQueryKey, + }); + }, + }); +} + +export function useUnarchiveIdentityMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (req: IdentityUnarchiveRequest) => unarchiveIdentity(req), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: archivedIdentitiesQueryKey, + }); + }, + }); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 7be96267b..7c8670577 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -1,5 +1,12 @@ import * as React from "react"; -import { Activity, Copy, MessageSquare, X } from "lucide-react"; +import { + Activity, + Archive, + ArchiveRestore, + Copy, + MessageSquare, + X, +} from "lucide-react"; import { toast } from "sonner"; import { useUserProfileQuery } from "@/features/profile/hooks"; @@ -7,7 +14,14 @@ import { useRelayAgentsQuery, useManagedAgentsQuery, } from "@/features/agents/hooks"; +import { + useArchiveIdentityMutation, + useIsIdentityArchived, + useOaOwnerQuery, + useUnarchiveIdentityMutation, +} from "@/features/identity-archive/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; +import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; @@ -79,6 +93,17 @@ export function UserProfilePanel({ const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); const presenceQuery = usePresenceQuery([pubkey]); const userStatusQuery = useUserStatusQuery([pubkey]); + const myMembershipQuery = useMyRelayMembershipQuery(); + const oaOwnerQuery = useOaOwnerQuery( + pubkey, + // Skip the kind:0 lookup when viewing yourself — the OA gate is for + // archiving *other* identities you own. + currentPubkey !== undefined && + pubkey.toLowerCase() !== currentPubkey.toLowerCase(), + ); + const isArchived = useIsIdentityArchived(pubkey); + const archiveMutation = useArchiveIdentityMutation(); + const unarchiveMutation = useUnarchiveIdentityMutation(); const { onOpenAgentSession } = useAgentSession(); const profile = profileQuery.data; @@ -97,6 +122,42 @@ export function UserProfilePanel({ currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); const canViewActivity = isBot && Boolean(onOpenAgentSession); + // NIP-IA gates. Button shows when ANY of: self path (acting on own pubkey), + // admin path (current user is owner/admin in relay_members), or owner path + // (current user is the verified NIP-OA owner of the viewee per its live + // kind:0). The relay picks the consent path; we just ensure the request is + // permitted to be built locally. + const myRole = myMembershipQuery.data?.role; + const isRelayAdminOrOwner = myRole === "owner" || myRole === "admin"; + const isOaOwnerOfViewee = oaOwnerQuery.data?.isMe === true; + const canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee; + + const handleArchive = React.useCallback(() => { + archiveMutation.mutate( + { targetPubkey: pubkey }, + { + onSuccess: () => toast.success("Archived on this relay"), + onError: (error) => + toast.error( + `Archive failed: ${error instanceof Error ? error.message : String(error)}`, + ), + }, + ); + }, [archiveMutation, pubkey]); + + const handleUnarchive = React.useCallback(() => { + unarchiveMutation.mutate( + { targetPubkey: pubkey }, + { + onSuccess: () => toast.success("Unarchived on this relay"), + onError: (error) => + toast.error( + `Unarchive failed: ${error instanceof Error ? error.message : String(error)}`, + ), + }, + ); + }, [pubkey, unarchiveMutation]); + const handleCopyPubkey = React.useCallback(() => { void navigator.clipboard.writeText(pubkey).then(() => { toast.success("Copied to clipboard"); @@ -189,6 +250,18 @@ export function UserProfilePanel({ {profile.nip05Handle}

) : null} + {/* NIP-IA "Archived" flair (relay-scoped). Spec §Client Behavior: + surface archive metadata where relevant. */} + {isArchived ? ( + + + Archived on this relay + + ) : null} {/* Presence */} @@ -275,6 +348,37 @@ export function UserProfilePanel({ View activity log ) : null} + {/* NIP-IA archive / unarchive. Gated to self / relay admin / OA + owner of viewee. The relay verifies authority — these gates are + purely a UX guard. */} + {canArchive && isArchived === false ? ( + + ) : null} + {canArchive && isArchived === true ? ( + + ) : null} diff --git a/desktop/src/shared/api/tauriIdentityArchive.ts b/desktop/src/shared/api/tauriIdentityArchive.ts new file mode 100644 index 000000000..304b29f08 --- /dev/null +++ b/desktop/src/shared/api/tauriIdentityArchive.ts @@ -0,0 +1,75 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +// ── NIP-IA identity archival ──────────────────────────────────────────────── + +export type OwnerOfAgent = { + /** Verified NIP-OA owner pubkey (hex) of the queried target. */ + owner: string; + /** True iff `owner` equals the current user's pubkey. */ + isMe: boolean; +}; + +export type ArchivedIdentitiesSnapshot = { + /** Lowercase-hex pubkeys present in the relay's latest `kind:13535`. */ + archived: string[]; +}; + +export type IdentityArchiveRequest = { + targetPubkey: string; + content?: string; + reason?: string; + replacedBy?: string; +}; + +export type IdentityUnarchiveRequest = { + targetPubkey: string; + content?: string; + reason?: string; +}; + +type RawOwnerOfAgent = { owner: string; is_me: boolean }; + +/** + * Resolve a target's NIP-OA owner via its live `kind:0` profile event. + * Returns `null` if the target has no kind:0, no `auth` tag, or the tag + * fails verification. Gate for the "Archive" button on the owner path. + */ +export async function resolveOaOwner( + targetPubkey: string, +): Promise { + const raw = await invokeTauri("resolve_oa_owner", { + targetPubkey, + }); + if (!raw) return null; + return { owner: raw.owner, isMe: raw.is_me }; +} + +/** + * Submit a `kind:9035` NIP-IA archive request. Consent path is chosen by the + * relay; the desktop attaches the owner's `auth` tag automatically when the + * caller is the verified owner-of-agent for the target. + */ +export async function archiveIdentity( + req: IdentityArchiveRequest, +): Promise { + await invokeTauri("archive_identity", { req }); +} + +/** + * Submit a `kind:9036` NIP-IA unarchive request. + */ +export async function unarchiveIdentity( + req: IdentityUnarchiveRequest, +): Promise { + await invokeTauri("unarchive_identity", { req }); +} + +/** + * Read the relay's latest `kind:13535` archived-identities snapshot. + * Snapshot is authoritative per NIP-IA §Snapshot and Delta Consistency. + */ +export async function listArchivedIdentities(): Promise { + return await invokeTauri( + "list_archived_identities", + ); +} From d9b44f4badf7e6f8f042abf2a4ec695896ff294e Mon Sep 17 00:00:00 2001 From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 07:51:11 -0400 Subject: [PATCH 5/9] feat(relay): dispatch + scope NIP-IA archive requests in ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires handle_identity_archive_event into the ingest pipeline (kinds 9035/9036) in the validate-then-store position — the request processes its consent/archival side effects, then falls through to normal storage so the relay-signed delta's ["e", request_id] audit reference resolves. Adds 9035/9036 to required_scope_for_kind as UsersWrite (not AdminUsers): NIP-IA's self and owner-of-agent paths are open to ordinary users; real authorization is the consent-path check in the handler. --- crates/sprout-relay/src/handlers/ingest.rs | 24 +++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 28eae2c55..41685216b 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -12,7 +12,8 @@ use uuid::Uuid; use nostr::Event; use sprout_auth::Scope; use sprout_core::kind::{ - event_kind_u32, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM, + event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, + is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, @@ -21,6 +22,7 @@ use sprout_core::kind::{ KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, @@ -187,6 +189,15 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_NIP29_EDIT_METADATA => { // kind:9002 scope split: archived tag → AdminChannels, else ChannelsWrite let has_archived = event @@ -1262,6 +1273,17 @@ pub async fn ingest_event( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + // ── 9c. NIP-IA identity archive requests (kinds 9035/9036) ─────────── + // Processed here (verify consent, mutate archived_identities, emit the + // relay-signed 8002/8003 delta + 13535 snapshot), then — unlike the + // NIP-43 admin commands above — the request itself falls through to normal + // storage so the delta's `["e", request_id]` audit reference resolves. + if is_identity_archive_request_kind(kind_u32) { + crate::handlers::identity_archive::handle_identity_archive_event(state, &event) + .await + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + // ── 10. Standard deletion validation (kind:5) ──────────────────────── if kind_u32 == KIND_DELETION { crate::handlers::side_effects::validate_standard_deletion_event(&event, state) From da36c8da45a06e226b83e3f923772fb349bd694d Mon Sep 17 00:00:00 2001 From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 08:06:44 -0400 Subject: [PATCH 6/9] test(relay): cover live kind0 owner archive revocation --- .../src/handlers/identity_archive.rs | 134 ++++++++++++++++++ crates/sprout-relay/src/handlers/ingest.rs | 23 ++- 2 files changed, 145 insertions(+), 12 deletions(-) diff --git a/crates/sprout-relay/src/handlers/identity_archive.rs b/crates/sprout-relay/src/handlers/identity_archive.rs index 24880d495..751b96dd2 100644 --- a/crates/sprout-relay/src/handlers/identity_archive.rs +++ b/crates/sprout-relay/src/handlers/identity_archive.rs @@ -425,4 +425,138 @@ mod tests { assert!(enforce_request_auth_time_bounds(&auth.to_string(), 100).is_err()); assert!(enforce_request_auth_time_bounds(&auth.to_string(), 200).is_err()); } + + async fn test_pool() -> Option { + let url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://sprout:sprout_dev@localhost:5432/sprout".into()); + sqlx::PgPool::connect(&url).await.ok() + } + + async fn test_state(pool: sqlx::PgPool) -> Option> { + let db = sprout_db::Db::from_pool(pool.clone()); + let config = crate::config::Config::from_env().ok()?; + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + sprout_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = sprout_audit::AuditService::new(pool); + let auth = sprout_auth::AuthService::new(config.auth.clone()); + let search = sprout_search::SearchService::new(sprout_search::SearchConfig { + url: config.typesense_url.clone(), + api_key: config.typesense_key.clone(), + collection: "events".to_string(), + }); + let workflow_engine = Arc::new(sprout_workflow::WorkflowEngine::new( + db.clone(), + sprout_workflow::WorkflowConfig::default(), + )); + let media_storage = sprout_media::MediaStorage::new(&config.media).ok()?; + let (state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + Some(Arc::new(state)) + } + + fn auth_tag(owner_keys: &Keys, target_pubkey: &nostr::PublicKey) -> Tag { + let tag_json = sprout_sdk::nip_oa::compute_auth_tag(owner_keys, target_pubkey, "") + .expect("compute auth tag"); + sprout_sdk::nip_oa::parse_auth_tag(&tag_json).expect("parse auth tag") + } + + fn profile_event(target_keys: &Keys, auth_tag: Tag, created_at: u64) -> Event { + EventBuilder::new(Kind::Metadata, "{}") + .tags([auth_tag]) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(target_keys) + .expect("sign profile") + } + + fn owner_archive_request(owner_keys: &Keys, target_hex: &str, auth_tag: Tag) -> Event { + EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16), "") + .tags([ + Tag::parse(["-"]).expect("protected tag"), + Tag::parse(["p", target_hex]).expect("p tag"), + auth_tag, + ]) + .sign_with_keys(owner_keys) + .expect("sign archive request") + } + + #[tokio::test] + async fn owner_archive_rejects_stale_request_after_live_kind0_owner_flip() { + let Some(pool) = test_pool().await else { + return; + }; + if sqlx::query("SELECT 1 FROM archived_identities LIMIT 1") + .execute(&pool) + .await + .is_err() + { + return; + } + let Some(state) = test_state(pool).await else { + return; + }; + + let owner_keys = Keys::generate(); + let other_owner_keys = Keys::generate(); + let target_keys = Keys::generate(); + let target_pubkey = target_keys.public_key(); + let target_hex = target_pubkey.to_hex(); + let now = nostr::Timestamp::now().as_secs(); + + let live_profile = profile_event(&target_keys, auth_tag(&owner_keys, &target_pubkey), now); + state + .db + .replace_addressable_event(&live_profile, None) + .await + .expect("insert initial target kind:0"); + + let request_auth = auth_tag(&owner_keys, &target_pubkey); + let archive_request = owner_archive_request(&owner_keys, &target_hex, request_auth.clone()); + handle_identity_archive_event(&state, &archive_request) + .await + .expect("owner archive accepted while live kind:0 attests owner"); + assert!( + state + .db + .is_archived(&target_hex) + .await + .expect("is_archived"), + "first owner archive should mutate archive state" + ); + + let revoked_profile = profile_event( + &target_keys, + auth_tag(&other_owner_keys, &target_pubkey), + now + 1, + ); + state + .db + .replace_addressable_event(&revoked_profile, None) + .await + .expect("replace target kind:0"); + + let stale_request = owner_archive_request(&owner_keys, &target_hex, request_auth); + let err = handle_identity_archive_event(&state, &stale_request) + .await + .expect_err("stale owner request must be rejected after live kind:0 owner flip"); + assert!( + err.contains("live kind:0 no longer attests"), + "unexpected error: {err}" + ); + } } diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 41685216b..473650ffb 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -13,18 +13,17 @@ use nostr::Event; use sprout_auth::Scope; use sprout_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, - is_relay_admin_kind, KIND_AGENT_ENGRAM, - KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, - KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, - KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, - KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, - KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, - KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, - KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, - KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM, - KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, + is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, + KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, + KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, + KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, + KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, + KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, + KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED, + KIND_HUDDLE_TRACK_PUBLISHED, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, + KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PROFILE, From 4908f25b4ab4d3deca967b88d732ac6b63090aa8 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 08:34:53 -0400 Subject: [PATCH 7/9] test(desktop/nip-ia): e2e gate matrix for archive button + flair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guards the OR composition in UserProfilePanel.tsx — `canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee` — where unit tests cover each input in isolation but a refactor that turns an OR into an AND, drops a branch, or expands role-membership silently bypasses code review. This is the single composition test that would catch all three. Five cases, all green in the smoke project: 1. self viewer + self target → Archive visible, no flair 2. relay admin viewing Alice → Archive visible 3. verified NIP-OA owner viewing Alice → Archive visible 4. no authority viewing Alice → button hidden 5. Alice archived → flair + Unarchive (under admin gate) Files: - desktop/tests/e2e/identity-archive.spec.ts (new): Five focused tests using two open-pane helpers (`openSelfProfile`, `openAliceProfile`). - desktop/tests/helpers/bridge.ts: Three typed `MockBridgeOptions` fields drive the gate inputs (`archivedIdentities`, `oaOwnerIsMe`, `relayRole`). No `as never` casts — Eva's review point. - desktop/src/testing/e2eBridge.ts: - Mirror the three options in `E2eConfig.mock`. - Four tauri-command mocks: `resolve_oa_owner`, `list_archived_identities`, `archive_identity`, `unarchive_identity`. - `resetMockRelayMembers` honours `relayRole`: `null` removes the active identity entirely (admin gate evaluates false); `owner|admin|member` sets that role. - Second seed message in `#general` from Alice (display name "alice", pubkey 953d33...001f) so specs have a non-self profile to open by clicking the second message-row's author button. Both `general` seeds are backdated (-120s, -60s) so user-sent messages in other specs always land after — preserving `message-row.first()` = welcome and `.last()` = sent. - desktop/playwright.config.ts: Add `identity-archive.spec.ts` to the smoke testMatch glob. Verification: - `pnpm exec playwright test --project=smoke`: 105 passed (was 105 before). - `pnpm typecheck` + `pnpm check`: clean. Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- desktop/playwright.config.ts | 1 + desktop/src/testing/e2eBridge.ts | 67 ++++++++++-- desktop/tests/e2e/identity-archive.spec.ts | 115 +++++++++++++++++++++ desktop/tests/helpers/bridge.ts | 19 ++++ 4 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 desktop/tests/e2e/identity-archive.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5c3bddd24..7197eca05 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ "**/mentions.spec.ts", "**/relay-reconnect.spec.ts", "**/workflows.spec.ts", + "**/identity-archive.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 56fceccca..fb570b111 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -34,6 +34,15 @@ type E2eConfig = { profileReadError?: string; profileUpdateError?: string; stallWebsocketSends?: boolean; + // NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for + // semantics. These three drive the archive-button gate matrix in + // tests/e2e/identity-archive.spec.ts; they're plumbed into: + // - `list_archived_identities` (archivedIdentities) + // - `resolve_oa_owner` (oaOwnerIsMe) + // - `resetMockRelayMembers` (relayRole) + archivedIdentities?: string[]; + oaOwnerIsMe?: boolean; + relayRole?: "owner" | "admin" | "member" | null; }; relayHttpUrl?: string; relayWsUrl?: string; @@ -682,13 +691,21 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { function resetMockRelayMembers(config: E2eConfig | undefined) { const pubkey = getMockMemberPubkey(config); + // Drive the active identity's role from `mock.relayRole` so the e2e harness + // can exercise the NIP-IA admin gate (owner/admin → true, member/null → + // false). Default stays `owner` to preserve existing test behavior. + const role = config?.mock?.relayRole; + const activeRoleMember = + role === null + ? null + : { + pubkey, + role: role ?? "owner", + added_by: null, + created_at: isoMinutesAgo(120), + }; mockRelayMembers = [ - { - pubkey, - role: "owner", - added_by: null, - created_at: isoMinutesAgo(120), - }, + ...(activeRoleMember ? [activeRoleMember] : []), { pubkey: ALICE_PUBKEY, role: "admin", @@ -1621,12 +1638,28 @@ function getMockMessageStore(channelId: string): RelayEvent[] { { id: "mock-general-welcome", pubkey: DEFAULT_MOCK_IDENTITY.pubkey, - created_at: Math.floor(Date.now() / 1000), + created_at: Math.floor(Date.now() / 1000) - 120, kind: 9, tags: [["h", channelId]], content: "Welcome to #general", sig: "mocksig".repeat(20).slice(0, 128), }, + // Alice authored — gives e2e specs a non-self profile pane to open + // by clicking the second message-row's author button. Used by + // tests/e2e/identity-archive.spec.ts to exercise the admin / OA / + // none-of-the-above branches of the NIP-IA gate. Both seeds are + // backdated (welcome at -120s, Alice at -60s) so user-sent messages + // in other specs always land after both — preserving + // `message-row.first()` = welcome and `.last()` = sent. + { + id: "mock-general-alice", + pubkey: ALICE_PUBKEY, + created_at: Math.floor(Date.now() / 1000) - 60, + kind: 9, + tags: [["h", channelId]], + content: "Hey team — checking in.", + sig: "mocksig".repeat(20).slice(0, 128), + }, ] : channelId === "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11" ? [ @@ -5037,6 +5070,26 @@ export function maybeInstallE2eTauriMocks() { case "plugin:event|listen": // Tauri event system (pairing, huddle) — no-op in e2e, return unlisten fn ID return Math.floor(Math.random() * 1_000_000); + // ── NIP-IA identity archival ──────────────────────────────────────── + // These mocks drive the archive-button gate matrix in + // tests/e2e/identity-archive.spec.ts. Defaults keep the button hidden + // for non-self viewees so the negative case is the unsurprising one. + case "resolve_oa_owner": { + const isMe = activeConfig?.mock?.oaOwnerIsMe ?? false; + const owner = isMe + ? (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey) + : "ff".repeat(32); + return { owner, is_me: isMe }; + } + case "list_archived_identities": { + const archived = activeConfig?.mock?.archivedIdentities ?? []; + return { archived }; + } + case "archive_identity": + case "unarchive_identity": + // The spec only verifies UI state, not the submitted request shape; + // returning null mirrors the Rust submit_event success path. + return null; default: throw new Error(`Unsupported mocked Tauri command: ${command}`); } diff --git a/desktop/tests/e2e/identity-archive.spec.ts b/desktop/tests/e2e/identity-archive.spec.ts new file mode 100644 index 000000000..58498569d --- /dev/null +++ b/desktop/tests/e2e/identity-archive.spec.ts @@ -0,0 +1,115 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// NIP-IA archive button + "Archived" flair gate matrix. +// +// Guards the composition `canArchive = isSelf || isRelayAdminOrOwner || +// isOaOwnerOfViewee` in UserProfilePanel.tsx. Unit tests cover each input in +// isolation; this spec covers the OR composition where silent regressions +// (refactor turns OR into AND, role expansion bypasses a branch, etc.) would +// otherwise slip past code review. + +const ALICE_PUBKEY = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; + +async function openSelfProfile(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + // First seed message in #general is from the active identity. + const firstMessage = page.getByTestId("message-row").first(); + await firstMessage.locator("button", { hasText: "npub1mock..." }).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); +} + +async function openAliceProfile(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + // Second seed message in #general is from Alice. Her display name "alice" + // is registered in mockDisplayNames, so the author button text is "alice". + const aliceMessage = page.getByTestId("message-row").nth(1); + await aliceMessage.locator("button", { hasText: "alice" }).first().click(); + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible(); + await expect(panel).toContainText(ALICE_PUBKEY.slice(0, 8)); +} + +test.describe("NIP-IA archive button gate", () => { + test("case 1 — self viewer + self target: Archive visible, no flair", async ({ + page, + }) => { + await installMockBridge(page, { relayRole: null, oaOwnerIsMe: false }); + await openSelfProfile(page); + await expect( + page.getByTestId("user-profile-archive-identity"), + ).toBeVisible(); + await expect(page.getByTestId("user-profile-archived-flair")).toHaveCount( + 0, + ); + }); + + test("case 2 — relay admin viewing Alice: Archive visible", async ({ + page, + }) => { + await installMockBridge(page, { + relayRole: "admin", + oaOwnerIsMe: false, + archivedIdentities: [], + }); + await openAliceProfile(page); + await expect( + page.getByTestId("user-profile-archive-identity"), + ).toBeVisible(); + }); + + test("case 3 — verified OA owner viewing Alice: Archive visible", async ({ + page, + }) => { + await installMockBridge(page, { + relayRole: null, + oaOwnerIsMe: true, + archivedIdentities: [], + }); + await openAliceProfile(page); + await expect( + page.getByTestId("user-profile-archive-identity"), + ).toBeVisible(); + }); + + test("case 4 — no authority viewing Alice: Archive hidden", async ({ + page, + }) => { + await installMockBridge(page, { + relayRole: null, + oaOwnerIsMe: false, + archivedIdentities: [], + }); + await openAliceProfile(page); + await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount( + 0, + ); + await expect( + page.getByTestId("user-profile-unarchive-identity"), + ).toHaveCount(0); + }); + + test("case 5 — Alice archived: flair + Unarchive button (under admin gate)", async ({ + page, + }) => { + await installMockBridge(page, { + relayRole: "admin", + oaOwnerIsMe: false, + archivedIdentities: [ALICE_PUBKEY], + }); + await openAliceProfile(page); + await expect(page.getByTestId("user-profile-archived-flair")).toBeVisible(); + await expect( + page.getByTestId("user-profile-unarchive-identity"), + ).toBeVisible(); + await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount( + 0, + ); + }); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index e318b9dc8..5fc545df2 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -51,6 +51,25 @@ type MockBridgeOptions = { profileReadError?: string; profileUpdateError?: string; stallWebsocketSends?: boolean; + // NIP-IA gate inputs — drive the archive-button gate matrix in + // tests/e2e/identity-archive.spec.ts. + /** + * Lowercase-hex pubkeys returned by `list_archived_identities`. Drives the + * "Archived on this relay" flair + Unarchive button. + */ + archivedIdentities?: string[]; + /** + * Drives the `is_me` field of `resolve_oa_owner`. When true, the harness + * reports the active identity as the verified NIP-OA owner of the viewee + * (owner-path branch of the gate). + */ + oaOwnerIsMe?: boolean; + /** + * Active identity's role in the seeded `mockRelayMembers`. `null` removes + * the active identity from the membership list entirely (admin-path branch + * evaluates false). + */ + relayRole?: "owner" | "admin" | "member" | null; }; type BridgeOptions = { From ee40656bb15d3451a4af0c772edc71002793a3a6 Mon Sep 17 00:00:00 2001 From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 10:38:55 -0400 Subject: [PATCH 8/9] fix(nip-ia): global-scope archive requests + camelCase payload deser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review blockers: - ingest: add 9035/9036 to is_global_only_kind so a stray h tag can't channel-scope a request whose handler mutates relay-global archive state. Mirrors the NIP-43 admin treatment. + regression test. - desktop: ArchiveRequest/UnarchiveRequest need rename_all=camelCase — the frontend sends nested camelCase fields and serde does not rename nested struct fields from the Tauri top-level arg mapping, so the commands failed to deserialize at runtime (masked by the e2e mock). + deserialization regression test. --- crates/sprout-relay/src/handlers/ingest.rs | 18 +++++++++++++ .../src/commands/identity_archive.rs | 26 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 473650ffb..42d6fd4f7 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -350,6 +350,11 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE | KIND_NIP43_LEAVE_REQUEST + // NIP-IA: identity archive/unarchive requests drive relay-global + // archive state (8002/8003/13535) and are audited as global request + // events. A stray `h` tag must not channel-scope them. + | KIND_IA_ARCHIVE_REQUEST + | KIND_IA_UNARCHIVE_REQUEST ) } @@ -1737,6 +1742,19 @@ mod tests { KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, KIND_USER_STATUS, }; + #[test] + fn nip_ia_requests_are_global_only() { + // NIP-IA requests drive relay-global archive state; a stray `h` tag + // must not channel-scope them, or the global audit trail breaks. + for kind in [KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST] { + assert!(is_global_only_kind(kind), "kind {kind} must be global-only"); + assert!( + !requires_h_channel_scope(kind), + "kind {kind} must not require an h tag" + ); + } + } + #[test] fn channel_scoped_content_kinds_require_h_tags() { for kind in [ diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index df75c8560..667612353 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -112,6 +112,7 @@ pub async fn resolve_oa_owner( // ── Archive / unarchive requests ──────────────────────────────────────────── #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ArchiveRequest { pub target_pubkey: String, #[serde(default)] @@ -123,6 +124,7 @@ pub struct ArchiveRequest { } #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct UnarchiveRequest { pub target_pubkey: String, #[serde(default)] @@ -346,4 +348,28 @@ mod tests { assert_eq!(raw[2], CONDITIONS); assert_eq!(raw[3], SIG); } + + /// Regression: the frontend sends the request payload in camelCase + /// (`targetPubkey`, `replacedBy`); these structs MUST deserialize it. + /// Without `#[serde(rename_all = "camelCase")]` the archive/unarchive + /// commands fail to deserialize at runtime — a failure the e2e mock hides + /// because it returns before parsing the payload. Red-if-broken guard. + #[test] + fn archive_request_deserializes_camel_case_payload() { + let req: ArchiveRequest = serde_json::from_str( + r#"{"targetPubkey":"abc","content":"bye","reason":"bot-rebuilt","replacedBy":"def"}"#, + ) + .expect("camelCase archive payload must deserialize"); + assert_eq!(req.target_pubkey, "abc"); + assert_eq!(req.content, "bye"); + assert_eq!(req.reason.as_deref(), Some("bot-rebuilt")); + assert_eq!(req.replaced_by.as_deref(), Some("def")); + + // Minimal payload (only the required field) still deserializes. + let minimal: UnarchiveRequest = + serde_json::from_str(r#"{"targetPubkey":"abc"}"#).expect("minimal payload"); + assert_eq!(minimal.target_pubkey, "abc"); + assert_eq!(minimal.content, ""); + assert!(minimal.reason.is_none()); + } } From 1f2d13ed66585a4be3dd7029d2265d7cc48c8a9a Mon Sep 17 00:00:00 2001 From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 10:58:34 -0400 Subject: [PATCH 9/9] docs(nip-ia): name the NIP-11 self verification gap on the 13535 read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (Mari) flagged that list_archived_identities trusts the latest kind:13535 without filtering by the relay's NIP-11 self key, which NIP-IA §Client Behavior requires. Not a runtime gap on Sprout's own relay (server-side enforcement; sibling kind:13534 is consumed the same way; no NIP-11 self fetch exists yet). Replace the comment that wrongly claimed the relay handles it with an honest deferral rationale. --- desktop/src-tauri/src/commands/identity_archive.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index 667612353..41ec10976 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -219,9 +219,16 @@ pub struct ArchivedIdentitiesSnapshot { /// this and tests membership client-side to drive the "Archived" flair. /// /// Per spec §Snapshot and Delta Consistency: the latest valid `kind:13535` -/// signed by the relay identity is authoritative. The HTTP `/query` bridge -/// already returns events the relay accepted, so signature checking is the -/// relay's responsibility on the read path. +/// signed by the relay identity is authoritative. +/// +/// NIP-IA §Client Behavior says clients MUST verify the snapshot is signed by +/// the relay's NIP-11 `self` key. We do not yet filter by that key here: the +/// desktop only ever talks to its own configured relay, where server-side +/// enforcement makes archive state trustworthy, and we have no NIP-11 `self` +/// fetch wired up (the sibling relay-signed kind:13534 membership list is +/// consumed the same way). Author-filtering against NIP-11 `self` is the +/// correct hardening for an untrusted/multi-relay client and is tracked as a +/// follow-up — not a runtime gap on Sprout's relay. #[tauri::command] pub async fn list_archived_identities( state: State<'_, AppState>,