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!(
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/crates/sprout-relay/src/handlers/identity_archive.rs b/crates/sprout-relay/src/handlers/identity_archive.rs
new file mode 100644
index 000000000..751b96dd2
--- /dev/null
+++ b/crates/sprout-relay/src/handlers/identity_archive.rs
@@ -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,
+ 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",
+ );
+}
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 = {
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()
+);