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