/** * Nostr Profile Management (NIP-01 kind:0) * * Profile events are "replaceable" - the latest created_at wins. * This module handles profile event creation and publishing. */ import { finalizeEvent, SimplePool, type Event } from "nostr-tools"; import { type NostrProfile, NostrProfileSchema } from "./config-schema.js"; // ============================================================================ // Types // ============================================================================ /** Result of a profile publish attempt */ export interface ProfilePublishResult { /** Event ID of the published profile */ eventId: string; /** Relays that successfully received the event */ successes: string[]; /** Relays that failed with their error messages */ failures: Array<{ relay: string; error: string }>; /** Unix timestamp when the event was created */ createdAt: number; } /** NIP-01 profile content (JSON inside kind:0 event) */ export interface ProfileContent { name?: string; display_name?: string; about?: string; picture?: string; banner?: string; website?: string; nip05?: string; lud16?: string; } // ============================================================================ // Profile Content Conversion // ============================================================================ /** * Convert our config profile schema to NIP-01 content format. * Strips undefined fields and validates URLs. */ export function profileToContent(profile: NostrProfile): ProfileContent { const validated = NostrProfileSchema.parse(profile); const content: ProfileContent = {}; if (validated.name !== undefined) content.name = validated.name; if (validated.displayName !== undefined) content.display_name = validated.displayName; if (validated.about !== undefined) content.about = validated.about; if (validated.picture !== undefined) content.picture = validated.picture; if (validated.banner !== undefined) content.banner = validated.banner; if (validated.website !== undefined) content.website = validated.website; if (validated.nip05 !== undefined) content.nip05 = validated.nip05; if (validated.lud16 !== undefined) content.lud16 = validated.lud16; return content; } /** * Convert NIP-01 content format back to our config profile schema. * Useful for importing existing profiles from relays. */ export function contentToProfile(content: ProfileContent): NostrProfile { const profile: NostrProfile = {}; if (content.name !== undefined) profile.name = content.name; if (content.display_name !== undefined) profile.displayName = content.display_name; if (content.about !== undefined) profile.about = content.about; if (content.picture !== undefined) profile.picture = content.picture; if (content.banner !== undefined) profile.banner = content.banner; if (content.website !== undefined) profile.website = content.website; if (content.nip05 !== undefined) profile.nip05 = content.nip05; if (content.lud16 !== undefined) profile.lud16 = content.lud16; return profile; } // ============================================================================ // Event Creation // ============================================================================ /** * Create a signed kind:0 profile event. * * @param sk - Private key as Uint8Array (32 bytes) * @param profile - Profile data to include * @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee) * @returns Signed Nostr event */ export function createProfileEvent( sk: Uint8Array, profile: NostrProfile, lastPublishedAt?: number ): Event { const content = profileToContent(profile); const contentJson = JSON.stringify(content); // Ensure monotonic timestamp (new event > previous) const now = Math.floor(Date.now() / 1000); const createdAt = lastPublishedAt !== undefined ? Math.max(now, lastPublishedAt + 1) : now; const event = finalizeEvent( { kind: 0, content: contentJson, tags: [], created_at: createdAt, }, sk ); return event; } // ============================================================================ // Profile Publishing // ============================================================================ /** Per-relay publish timeout (ms) */ const RELAY_PUBLISH_TIMEOUT_MS = 5000; /** * Publish a profile event to multiple relays. * * Best-effort: publishes to all relays in parallel, reports per-relay results. * Does NOT retry automatically - caller should handle retries if needed. * * @param pool - SimplePool instance for relay connections * @param relays - Array of relay WebSocket URLs * @param event - Signed profile event (kind:0) * @returns Publish results with successes and failures */ export async function publishProfileEvent( pool: SimplePool, relays: string[], event: Event ): Promise { const successes: string[] = []; const failures: Array<{ relay: string; error: string }> = []; // Publish to each relay in parallel with timeout const publishPromises = relays.map(async (relay) => { try { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS); }); await Promise.race([pool.publish([relay], event), timeoutPromise]); successes.push(relay); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); failures.push({ relay, error: errorMessage }); } }); await Promise.all(publishPromises); return { eventId: event.id, successes, failures, createdAt: event.created_at, }; } /** * Create and publish a profile event in one call. * * @param pool - SimplePool instance * @param sk - Private key as Uint8Array * @param relays - Array of relay URLs * @param profile - Profile data * @param lastPublishedAt - Previous timestamp for monotonic ordering * @returns Publish results */ export async function publishProfile( pool: SimplePool, sk: Uint8Array, relays: string[], profile: NostrProfile, lastPublishedAt?: number ): Promise { const event = createProfileEvent(sk, profile, lastPublishedAt); return publishProfileEvent(pool, relays, event); } // ============================================================================ // Profile Validation Helpers // ============================================================================ /** * Validate a profile without throwing (returns result object). */ export function validateProfile(profile: unknown): { valid: boolean; profile?: NostrProfile; errors?: string[]; } { const result = NostrProfileSchema.safeParse(profile); if (result.success) { return { valid: true, profile: result.data }; } return { valid: false, errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`), }; } /** * Sanitize profile text fields to prevent XSS when displaying in UI. * Escapes HTML special characters. */ export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile { const escapeHtml = (str: string | undefined): string | undefined => { if (str === undefined) return undefined; return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }; return { name: escapeHtml(profile.name), displayName: escapeHtml(profile.displayName), about: escapeHtml(profile.about), picture: profile.picture, // URLs already validated by schema banner: profile.banner, website: profile.website, nip05: escapeHtml(profile.nip05), lud16: escapeHtml(profile.lud16), }; }