feat: NIP-IA identity archival (relay backend + desktop) (#733)

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-05-23 12:03:38 -04:00
committed by GitHub
parent 4278eb5202
commit 79399d9b8e
20 changed files with 1996 additions and 23 deletions
+28
View File
@@ -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 (91009999)
/// 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 (90359036).
///
/// 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!(
+111
View File
@@ -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<String>,
/// Optional 64-char lowercase hex pubkey replacing this identity.
pub replaced_by: Option<String>,
/// 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<Utc>,
}
/// Returns `true` if `pubkey` (64-char hex) is currently archived.
pub async fn is_archived(pool: &PgPool, pubkey: &str) -> Result<bool> {
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<bool> {
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<bool> {
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<Vec<ArchivedIdentity>> {
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::<std::result::Result<Vec<_>, sqlx::Error>>()
.map_err(crate::error::DbError::from)
}
fn row_to_archived_identity(
row: sqlx::postgres::PgRow,
) -> std::result::Result<ArchivedIdentity, sqlx::Error> {
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")?,
})
}
+41
View File
@@ -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<bool> {
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<bool> {
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<bool> {
archived_identities::unarchive(&self.pool, pubkey).await
}
/// Returns all archived identities ordered by archive time ascending.
pub async fn list_archived(&self) -> Result<Vec<archived_identities::ArchivedIdentity>> {
archived_identities::list_archived(&self.pool).await
}
// ── Discovery events ─────────────────────────────────────────────────────
/// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey.
@@ -0,0 +1,562 @@
//! NIP-IA identity archive request handler (kinds 90359036).
//!
//! 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<AppState>,
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<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("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<String> {
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<Option<String>, 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<AppState>,
event: &Event,
target_hex: &str,
actor_hex: &str,
) -> Result<ConsentPath, String> {
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<AppState>,
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<String, String> {
let mut found: Option<Vec<String>> = 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<String, String> {
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<String> =
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::<u64>()
.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::<u64>()
.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<Vec<&'static str>>) -> Event {
let keys = Keys::generate();
let nostr_tags: Vec<Tag> = 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());
}
async fn test_pool() -> Option<sqlx::PgPool> {
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<Arc<AppState>> {
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}"
);
}
}
+50 -11
View File
@@ -12,17 +12,18 @@ 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,
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_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MUTE_LIST,
KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
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_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,
@@ -187,6 +188,15 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
{
Ok(Scope::AdminUsers)
}
// NIP-IA: identity archive/unarchive requests (9035/9036).
// Scope is intentionally UsersWrite, not AdminUsers: NIP-IA's self and
// owner-of-agent paths are open to ordinary users (a user retiring their
// own key, or an owner archiving their agent). Real authorization is the
// consent-path check inside handle_identity_archive_event — the relay
// verifies self / admin-role / owner-via-live-kind:0 there. This gate
// only ensures the actor can write user-scoped state, which any
// profile-publishing user already holds.
KIND_IA_ARCHIVE_REQUEST | KIND_IA_UNARCHIVE_REQUEST => Ok(Scope::UsersWrite),
KIND_NIP29_EDIT_METADATA => {
// kind:9002 scope split: archived tag → AdminChannels, else ChannelsWrite
let has_archived = event
@@ -340,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
)
}
@@ -1262,6 +1277,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)
@@ -1716,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 [
+2
View File
@@ -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 90359036).
pub mod identity_archive;
/// imeta tag validation helpers.
pub mod imeta;
/// Transport-neutral event ingestion pipeline.
@@ -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<AppState>) -> 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<AppState>) -> anyhow::Result<()> {
let archived = state.db.list_archived().await?;
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
let mut tags: Vec<Tag> = 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<AppState>,
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<AppState>,
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<AppState>,
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
}
+1
View File
@@ -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"],
+1 -1
View File
@@ -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
@@ -0,0 +1,382 @@
//! 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<Option<nostr::Event>, 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<Option<OwnerOfAgent>, 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)]
#[serde(rename_all = "camelCase")]
pub struct ArchiveRequest {
pub target_pubkey: String,
#[serde(default)]
pub content: String,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub replaced_by: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UnarchiveRequest {
pub target_pubkey: String,
#[serde(default)]
pub content: String,
#[serde(default)]
pub reason: Option<String>,
}
/// 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<SubmitEventResponse, String> {
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<SubmitEventResponse, String> {
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<Option<[String; 4]>, 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<String>,
}
/// 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.
///
/// 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>,
) -> Result<ArchivedIdentitiesSnapshot, String> {
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);
}
/// 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());
}
}
+2
View File
@@ -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::*;
+203
View File
@@ -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<Vec<Tag>, 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<EventBuilder, String> {
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<EventBuilder, String> {
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<EventBuild
}
// ── Transport ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use nostr::Keys;
/// Builder layout regression for the NIP-IA owner-of-agent archive flow.
/// Compares against `docs/nips/NIP-IA.md` §Vector 1.
#[test]
fn archive_identity_request_matches_spec_vector_1_layout() {
const OWNER_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
const CONDITIONS: &str = "kind=1&created_at<1713957000";
const SIG: &str = "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369";
let auth: [String; 4] = [
"auth".into(),
OWNER_HEX.into(),
CONDITIONS.into(),
SIG.into(),
];
let builder = build_archive_identity_request(
TARGET_HEX,
"Archiving zombie agent after rebuild.",
Some("bot-rebuilt"),
None,
Some(&auth),
)
.expect("build_archive_identity_request");
let owner_secret = nostr::SecretKey::from_hex(
"0000000000000000000000000000000000000000000000000000000000000001",
)
.unwrap();
let owner_keys = Keys::new(owner_secret);
let event = builder.sign_with_keys(&owner_keys).unwrap();
let tags: Vec<Vec<String>> = 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<Vec<String>> = 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);
}
}
+5
View File
@@ -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,
@@ -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<ArchivedIdentitiesSnapshot>({
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,
});
},
});
}
@@ -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}
</p>
) : null}
{/* NIP-IA "Archived" flair (relay-scoped). Spec §Client Behavior:
surface archive metadata where relevant. */}
{isArchived ? (
<span
className="mt-1 inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-300"
data-testid="user-profile-archived-flair"
title="This identity is archived on this relay. Historical events remain attributed to it."
>
<Archive className="h-3 w-3" />
Archived on this relay
</span>
) : null}
</div>
{/* Presence */}
@@ -275,6 +348,37 @@ export function UserProfilePanel({
View activity log
</button>
) : 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 ? (
<Button
className="w-full"
data-testid="user-profile-archive-identity"
disabled={archiveMutation.isPending}
onClick={handleArchive}
type="button"
variant="secondary"
>
<Archive className="h-4 w-4" />
{archiveMutation.isPending ? "Archiving…" : "Archive identity"}
</Button>
) : null}
{canArchive && isArchived === true ? (
<Button
className="w-full"
data-testid="user-profile-unarchive-identity"
disabled={unarchiveMutation.isPending}
onClick={handleUnarchive}
type="button"
variant="secondary"
>
<ArchiveRestore className="h-4 w-4" />
{unarchiveMutation.isPending
? "Unarchiving…"
: "Unarchive identity"}
</Button>
) : null}
</div>
</div>
</aside>
@@ -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<OwnerOfAgent | null> {
const raw = await invokeTauri<RawOwnerOfAgent | null>("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<void> {
await invokeTauri("archive_identity", { req });
}
/**
* Submit a `kind:9036` NIP-IA unarchive request.
*/
export async function unarchiveIdentity(
req: IdentityUnarchiveRequest,
): Promise<void> {
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<ArchivedIdentitiesSnapshot> {
return await invokeTauri<ArchivedIdentitiesSnapshot>(
"list_archived_identities",
);
}
+60 -7
View File
@@ -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}`);
}
+115
View File
@@ -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,
);
});
});
+19
View File
@@ -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 = {
+12
View File
@@ -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()
);