Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions crates/sprout-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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!(
Expand Down
111 changes: 111 additions & 0 deletions crates/sprout-db/src/archived_identities.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 41 additions & 0 deletions crates/sprout-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading