mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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!(
|
||||
|
||||
@@ -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")?,
|
||||
})
|
||||
}
|
||||
@@ -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 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<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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user