From edd951612f82498f55320b367a16697ef3258843 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Jan 2026 11:57:18 +0000 Subject: [PATCH] Refactor NIP-17 adapter to use applesauce helpers properly - Fix encrypted content storage to use event.id as key (matching applesauce) - Replace custom rumor storage with simple key-value encrypted content cache - Simplify NIP-17 adapter to use isGiftWrapUnlocked/getGiftWrapRumor helpers - Implement sendMessage using applesauce's SendWrappedMessage action - Remove redundant getPrivateInboxRelays (handled by applesauce action) - Clean up unused imports and constants --- src/lib/chat/adapters/nip-17-adapter.ts | 303 ++++++++++-------------- src/services/db.ts | 34 +-- src/services/event-store.ts | 12 +- src/services/rumor-storage.ts | 168 +++---------- 4 files changed, 173 insertions(+), 344 deletions(-) diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts index 861fad3..24c6225 100644 --- a/src/lib/chat/adapters/nip-17-adapter.ts +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -13,10 +13,10 @@ * * Caching: * - Gift wraps are cached to Dexie events table - * - Decrypted rumors are cached to avoid re-decryption + * - Decrypted content persisted via applesauce's persistEncryptedContent */ import { Observable, firstValueFrom, BehaviorSubject } from "rxjs"; -import { map, first } from "rxjs/operators"; +import { map, first, distinctUntilChanged } from "rxjs/operators"; import { nip19 } from "nostr-tools"; import type { Filter } from "nostr-tools"; import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; @@ -31,28 +31,26 @@ import type { NostrEvent } from "@/types/nostr"; import eventStore from "@/services/event-store"; import pool from "@/services/relay-pool"; import accountManager from "@/services/accounts"; +import { hub } from "@/services/hub"; import { isNip05, resolveNip05 } from "@/lib/nip05"; import { getDisplayName } from "@/lib/nostr-utils"; import { isValidHexPubkey } from "@/lib/nostr-validation"; import { getProfileContent } from "applesauce-core/helpers"; import { unlockGiftWrap, + isGiftWrapUnlocked, + getGiftWrapRumor, getConversationParticipants, getConversationIdentifierFromMessage, type Rumor, } from "applesauce-common/helpers"; -import { - getDecryptedRumors, - isGiftWrapDecrypted, - storeDecryptedRumor, -} from "@/services/rumor-storage"; +import { SendWrappedMessage } from "applesauce-actions/actions"; /** * Kind constants */ const GIFT_WRAP_KIND = 1059; const DM_RUMOR_KIND = 14; -const DM_RELAY_LIST_KIND = 10050; /** * NIP-17 Adapter - Gift Wrapped Private DMs @@ -61,11 +59,11 @@ export class Nip17Adapter extends ChatProtocolAdapter { readonly protocol = "nip-17" as const; readonly type = "dm" as const; - /** Observable of all decrypted rumors for the current user */ - private rumors$ = new BehaviorSubject([]); - /** Track pending (undecrypted) gift wrap IDs */ - private pendingGiftWraps$ = new BehaviorSubject([]); + private pendingGiftWraps$ = new BehaviorSubject>(new Set()); + + /** Observable of gift wrap events from event store */ + private giftWraps$ = new BehaviorSubject([]); /** * Parse identifier - accepts npub, nprofile, hex pubkey, or NIP-05 @@ -193,35 +191,50 @@ export class Nip17Adapter extends ChatProtocolAdapter { // Expected participants for this conversation const expectedParticipants = [activePubkey, partner.pubkey].sort(); - // Load initial rumors from cache - this.loadCachedRumors(activePubkey); - // Subscribe to gift wraps for this user this.subscribeToGiftWraps(activePubkey); - // Filter rumors to this conversation and convert to messages - return this.rumors$.pipe( - map((rumors) => { - // Filter rumors that belong to this conversation - const conversationRumors = rumors.filter((rumor) => { - // Only kind 14 DM rumors - if (rumor.kind !== DM_RUMOR_KIND) return false; + // Get rumors from unlocked gift wraps and filter to this conversation + return this.giftWraps$.pipe( + map((giftWraps) => { + const messages: Message[] = []; - // Get participants from rumor - const rumorParticipants = getConversationParticipants(rumor).sort(); + for (const gift of giftWraps) { + // Skip locked gift wraps + if (!isGiftWrapUnlocked(gift)) continue; - // Check if participants match - return ( - rumorParticipants.length === expectedParticipants.length && - rumorParticipants.every((p, i) => p === expectedParticipants[i]) - ); - }); + try { + const rumor = getGiftWrapRumor(gift); - // Convert to messages and sort by timestamp - return conversationRumors - .map((rumor) => this.rumorToMessage(rumor, conversation.id)) - .sort((a, b) => a.timestamp - b.timestamp); + // Only kind 14 DM rumors + if (rumor.kind !== DM_RUMOR_KIND) continue; + + // Get participants from rumor + const rumorParticipants = getConversationParticipants(rumor).sort(); + + // Check if participants match this conversation + if ( + rumorParticipants.length !== expectedParticipants.length || + !rumorParticipants.every((p, i) => p === expectedParticipants[i]) + ) { + continue; + } + + messages.push(this.rumorToMessage(rumor, conversation.id)); + } catch (error) { + console.warn( + `[NIP-17] Failed to get rumor from gift wrap ${gift.id}:`, + error, + ); + } + } + + // Sort by timestamp + return messages.sort((a, b) => a.timestamp - b.timestamp); }), + distinctUntilChanged( + (a, b) => a.length === b.length && a.every((m, i) => m.id === b[i].id), + ), ); } @@ -232,18 +245,23 @@ export class Nip17Adapter extends ChatProtocolAdapter { _conversation: Conversation, _before: number, ): Promise { - // For now, return empty - pagination to be implemented // Gift wraps don't paginate well since we need to decrypt all return []; } /** * Send a gift-wrapped DM + * + * Uses applesauce's SendWrappedMessage action which: + * 1. Creates kind 14 rumor with message content + * 2. Wraps in seal (kind 13) encrypted to each participant + * 3. Wraps seal in gift wrap (kind 1059) with ephemeral key + * 4. Publishes to each participant's private inbox relays (kind 10050) */ async sendMessage( conversation: Conversation, content: string, - options?: SendMessageOptions, + _options?: SendMessageOptions, ): Promise { const activePubkey = accountManager.active$.value?.pubkey; const activeSigner = accountManager.active$.value?.signer; @@ -259,31 +277,15 @@ export class Nip17Adapter extends ChatProtocolAdapter { throw new Error("No conversation partner found"); } - // Build rumor tags - const tags: string[][] = [["p", partner.pubkey]]; - if (options?.replyTo) { - tags.push(["e", options.replyTo, "", "reply"]); - } + // Use applesauce's SendWrappedMessage action + // This handles: + // - Creating the wrapped message rumor + // - Gift wrapping for all participants (partner + self) + // - Publishing to each participant's inbox relays + await hub.run(SendWrappedMessage, partner.pubkey, content); - // Get recipient's private inbox relays - const inboxRelays = await this.getPrivateInboxRelays(partner.pubkey); - if (inboxRelays.length === 0) { - throw new Error( - "Recipient has no private inbox relays configured (kind 10050)", - ); - } - - // TODO: Implement gift wrap creation and sending - // 1. Create the DM rumor (kind 14, unsigned) with: activePubkey, tags, content - // 2. Use SendWrappedMessage action from applesauce-actions to create and send gift wraps - // 3. Publish to each recipient's private inbox relays - void inboxRelays; // Will be used when implemented - void tags; - void content; - void activePubkey; - - throw new Error( - "Send not yet implemented - use applesauce SendWrappedMessage action", + console.log( + `[NIP-17] Sent wrapped message to ${partner.pubkey.slice(0, 8)}...`, ); } @@ -309,16 +311,24 @@ export class Nip17Adapter extends ChatProtocolAdapter { _conversation: Conversation, eventId: string, ): Promise { - // Check if we have a cached rumor with this ID - const rumors = this.rumors$.value; - const rumor = rumors.find((r) => r.id === eventId); + // Check if we have an unlocked gift wrap with a rumor matching this ID + const giftWraps = this.giftWraps$.value; - if (rumor) { - // Convert rumor to a pseudo-event for display - return { - ...rumor, - sig: "", // Rumors are unsigned - } as NostrEvent; + for (const gift of giftWraps) { + if (!isGiftWrapUnlocked(gift)) continue; + + try { + const rumor = getGiftWrapRumor(gift); + if (rumor.id === eventId) { + // Return as pseudo-event + return { + ...rumor, + sig: "", + } as NostrEvent; + } + } catch { + // Skip + } } return null; @@ -328,14 +338,14 @@ export class Nip17Adapter extends ChatProtocolAdapter { * Get count of pending (undecrypted) gift wraps */ getPendingCount(): number { - return this.pendingGiftWraps$.value.length; + return this.pendingGiftWraps$.value.size; } /** * Get observable of pending gift wrap count */ getPendingCount$(): Observable { - return this.pendingGiftWraps$.pipe(map((ids) => ids.length)); + return this.pendingGiftWraps$.pipe(map((set) => set.size)); } /** @@ -349,7 +359,7 @@ export class Nip17Adapter extends ChatProtocolAdapter { throw new Error("No active account"); } - const pendingIds = this.pendingGiftWraps$.value; + const pendingIds = Array.from(this.pendingGiftWraps$.value); let success = 0; let failed = 0; @@ -365,23 +375,28 @@ export class Nip17Adapter extends ChatProtocolAdapter { continue; } - // Decrypt using signer - const rumor = await unlockGiftWrap(giftWrap, signer); - - if (rumor) { - // Store decrypted rumor - await storeDecryptedRumor(giftWrapId, rumor, pubkey); - - // Add to rumors list - const currentRumors = this.rumors$.value; - if (!currentRumors.find((r) => r.id === rumor.id)) { - this.rumors$.next([...currentRumors, rumor]); - } - + // Already unlocked? + if (isGiftWrapUnlocked(giftWrap)) { + // Remove from pending + const pending = new Set(this.pendingGiftWraps$.value); + pending.delete(giftWrapId); + this.pendingGiftWraps$.next(pending); success++; - } else { - failed++; + continue; } + + // Decrypt using signer - applesauce handles caching automatically + await unlockGiftWrap(giftWrap, signer); + + // Remove from pending + const pending = new Set(this.pendingGiftWraps$.value); + pending.delete(giftWrapId); + this.pendingGiftWraps$.next(pending); + + // Refresh gift wraps list + this.giftWraps$.next([...this.giftWraps$.value]); + + success++; } catch (error) { console.error( `[NIP-17] Failed to decrypt gift wrap ${giftWrapId}:`, @@ -391,12 +406,6 @@ export class Nip17Adapter extends ChatProtocolAdapter { } } - // Clear pending list for successfully decrypted - const remainingPending = this.pendingGiftWraps$.value.filter( - (id) => !pendingIds.includes(id) || failed > 0, - ); - this.pendingGiftWraps$.next(remainingPending); - return { success, failed }; } @@ -409,23 +418,30 @@ export class Nip17Adapter extends ChatProtocolAdapter { return new BehaviorSubject([]); } - return this.rumors$.pipe( - map((rumors) => { + return this.giftWraps$.pipe( + map((giftWraps) => { // Group rumors by conversation const conversationMap = new Map< string, { participants: string[]; lastRumor: Rumor } >(); - for (const rumor of rumors) { - if (rumor.kind !== DM_RUMOR_KIND) continue; + for (const gift of giftWraps) { + if (!isGiftWrapUnlocked(gift)) continue; - const convId = getConversationIdentifierFromMessage(rumor); - const participants = getConversationParticipants(rumor); + try { + const rumor = getGiftWrapRumor(gift); + if (rumor.kind !== DM_RUMOR_KIND) continue; - const existing = conversationMap.get(convId); - if (!existing || rumor.created_at > existing.lastRumor.created_at) { - conversationMap.set(convId, { participants, lastRumor: rumor }); + const convId = getConversationIdentifierFromMessage(rumor); + const participants = getConversationParticipants(rumor); + + const existing = conversationMap.get(convId); + if (!existing || rumor.created_at > existing.lastRumor.created_at) { + conversationMap.set(convId, { participants, lastRumor: rumor }); + } + } catch { + // Skip invalid gift wraps } } @@ -464,14 +480,6 @@ export class Nip17Adapter extends ChatProtocolAdapter { // ==================== Private Methods ==================== - /** - * Load cached rumors from Dexie - */ - private async loadCachedRumors(pubkey: string): Promise { - const rumors = await getDecryptedRumors(pubkey); - this.rumors$.next(rumors); - } - /** * Subscribe to gift wraps for the user */ @@ -490,7 +498,7 @@ export class Nip17Adapter extends ChatProtocolAdapter { const subscription = pool .subscription([], [filter], { eventStore }) .subscribe({ - next: async (response) => { + next: (response) => { if (typeof response === "string") { // EOSE console.log("[NIP-17] EOSE received for gift wraps"); @@ -500,15 +508,17 @@ export class Nip17Adapter extends ChatProtocolAdapter { `[NIP-17] Received gift wrap: ${response.id.slice(0, 8)}...`, ); - // Check if already decrypted - const isDecrypted = await isGiftWrapDecrypted(response.id, pubkey); + // Add to gift wraps list + const current = this.giftWraps$.value; + if (!current.find((g) => g.id === response.id)) { + this.giftWraps$.next([...current, response]); + } - if (!isDecrypted) { - // Add to pending list - const pending = this.pendingGiftWraps$.value; - if (!pending.includes(response.id)) { - this.pendingGiftWraps$.next([...pending, response.id]); - } + // Check if unlocked (cached) or pending + if (!isGiftWrapUnlocked(response)) { + const pending = new Set(this.pendingGiftWraps$.value); + pending.add(response.id); + this.pendingGiftWraps$.next(pending); } } }, @@ -517,61 +527,6 @@ export class Nip17Adapter extends ChatProtocolAdapter { this.subscriptions.set(conversationId, subscription); } - /** - * Get private inbox relays for a user (kind 10050) - */ - private async getPrivateInboxRelays(pubkey: string): Promise { - // Try to fetch from EventStore first - const existing = await firstValueFrom( - eventStore.replaceable(DM_RELAY_LIST_KIND, pubkey, ""), - { defaultValue: undefined }, - ); - - if (existing) { - return this.extractRelaysFromEvent(existing); - } - - // Fetch from relays - const filter: Filter = { - kinds: [DM_RELAY_LIST_KIND], - authors: [pubkey], - limit: 1, - }; - - const events: NostrEvent[] = []; - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 5000); - const sub = pool.subscription([], [filter], { eventStore }).subscribe({ - next: (response) => { - if (typeof response === "string") { - clearTimeout(timeout); - sub.unsubscribe(); - resolve(); - } else { - events.push(response); - } - }, - error: () => { - clearTimeout(timeout); - resolve(); - }, - }); - }); - - if (events.length > 0) { - return this.extractRelaysFromEvent(events[0]); - } - - return []; - } - - /** - * Extract relay URLs from kind 10050 event - */ - private extractRelaysFromEvent(event: NostrEvent): string[] { - return event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]); - } - /** * Convert a rumor to a Message */ diff --git a/src/services/db.ts b/src/services/db.ts index 4320bb4..b17d114 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -90,25 +90,17 @@ export interface CachedEvent { } /** - * Decrypted rumor from gift wrap (NIP-59) - * Stored separately so we don't have to re-decrypt + * Encrypted content cache for gift wraps and seals (NIP-59) + * Stores decrypted content strings so we don't have to re-decrypt. + * This matches applesauce's persistEncryptedContent expectations. */ -export interface DecryptedRumor { - /** Gift wrap event ID */ - giftWrapId: string; - /** The decrypted rumor (unsigned event) */ - rumor: { - id: string; - pubkey: string; - created_at: number; - kind: number; - tags: string[][]; - content: string; - }; - /** Pubkey that decrypted this (for multi-account support) */ - decryptedBy: string; - /** When it was decrypted */ - decryptedAt: number; +export interface EncryptedContentEntry { + /** Event ID (gift wrap or seal) */ + id: string; + /** The decrypted content string */ + content: string; + /** When it was cached */ + cachedAt: number; } class GrimoireDb extends Dexie { @@ -122,7 +114,7 @@ class GrimoireDb extends Dexie { spells!: Table; spellbooks!: Table; events!: Table; - decryptedRumors!: Table; + encryptedContent!: Table; constructor(name: string) { super(name); @@ -345,7 +337,7 @@ class GrimoireDb extends Dexie { spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", }); - // Version 15: Add event cache and decrypted rumor storage for NIP-59 gift wraps + // Version 15: Add event cache and encrypted content storage for NIP-59 gift wraps this.version(15).stores({ profiles: "&pubkey", nip05: "&nip05", @@ -357,7 +349,7 @@ class GrimoireDb extends Dexie { spells: "&id, alias, createdAt, isPublished, deletedAt", spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", events: "&id, cachedAt", - decryptedRumors: "&giftWrapId, decryptedBy", + encryptedContent: "&id", }); } } diff --git a/src/services/event-store.ts b/src/services/event-store.ts index 9b70e71..998792d 100644 --- a/src/services/event-store.ts +++ b/src/services/event-store.ts @@ -2,9 +2,7 @@ import { EventStore } from "applesauce-core"; import { persistEventsToCache } from "applesauce-core/helpers"; import { persistEncryptedContent } from "applesauce-common/helpers"; import { cacheEvents } from "./event-cache"; -import { rumorStorage, setCurrentPubkey } from "./rumor-storage"; -import accountManager from "./accounts"; -import { of } from "rxjs"; +import { encryptedContentStorage } from "./rumor-storage"; const eventStore = new EventStore(); @@ -13,11 +11,7 @@ persistEventsToCache(eventStore, cacheEvents); // Persist decrypted gift wrap content to Dexie // This ensures we don't have to re-decrypt messages on every page load -persistEncryptedContent(eventStore, of(rumorStorage)); - -// Sync current pubkey for rumor storage when account changes -accountManager.active$.subscribe((account) => { - setCurrentPubkey(account?.pubkey ?? null); -}); +// The storage handles both gift wraps (decrypted seal) and seals (decrypted rumor) +persistEncryptedContent(eventStore, encryptedContentStorage); export default eventStore; diff --git a/src/services/rumor-storage.ts b/src/services/rumor-storage.ts index fe1f608..510784f 100644 --- a/src/services/rumor-storage.ts +++ b/src/services/rumor-storage.ts @@ -1,165 +1,53 @@ /** - * Rumor storage service for caching decrypted gift wrap content + * Encrypted content storage for gift wraps and seals (NIP-59) * - * When a gift wrap (kind 1059) is decrypted, the inner rumor is cached - * so we don't have to decrypt it again. This is especially important - * because decryption requires the signer (browser extension interaction). + * This implements the EncryptedContentCache interface expected by + * applesauce's persistEncryptedContent helper. * - * Storage format matches applesauce's persistEncryptedContent expectations: - * - Key: `rumor:${giftWrapId}` - * - Value: The decrypted rumor object + * Storage format: + * - Key: event.id (the gift wrap or seal event ID) + * - Value: decrypted content string (the JSON string from decryption) */ -import type { Rumor } from "applesauce-common/helpers"; -import db, { type DecryptedRumor } from "./db"; -import { BehaviorSubject, type Observable } from "rxjs"; +import db from "./db"; +import type { EncryptedContentCache } from "applesauce-common/helpers"; /** - * Current user pubkey for multi-account support - * Set this when account changes + * Dexie-backed encrypted content storage + * Implements applesauce's EncryptedContentCache interface */ -const currentPubkey$ = new BehaviorSubject(null); - -export function setCurrentPubkey(pubkey: string | null): void { - currentPubkey$.next(pubkey); -} - -export function getCurrentPubkey(): string | null { - return currentPubkey$.value; -} - -/** - * Storage interface compatible with applesauce's persistEncryptedContent - * - * The keys are in format "rumor:{giftWrapId}" or "seal:{giftWrapId}" - */ -export const rumorStorage = { +export const encryptedContentStorage: EncryptedContentCache = { async getItem(key: string): Promise { - const pubkey = currentPubkey$.value; - if (!pubkey) return null; - - // Parse key format: "rumor:{giftWrapId}" or "seal:{giftWrapId}" - const match = key.match(/^(rumor|seal):(.+)$/); - if (!match) return null; - - const [, type, giftWrapId] = match; - - if (type === "rumor") { - const entry = await db.decryptedRumors.get(giftWrapId); - if (entry && entry.decryptedBy === pubkey) { - return JSON.stringify(entry.rumor); - } - } - - // For seals, we don't cache them separately (they're intermediate) - return null; + const entry = await db.encryptedContent.get(key); + return entry?.content ?? null; }, async setItem(key: string, value: string): Promise { - const pubkey = currentPubkey$.value; - if (!pubkey) return; - - // Parse key format - const match = key.match(/^(rumor|seal):(.+)$/); - if (!match) return; - - const [, type, giftWrapId] = match; - - if (type === "rumor") { - const rumor = JSON.parse(value) as Rumor; - const entry: DecryptedRumor = { - giftWrapId, - rumor, - decryptedBy: pubkey, - decryptedAt: Date.now(), - }; - await db.decryptedRumors.put(entry); - } - - // We don't persist seals - they're just intermediate decryption steps - }, - - async removeItem(key: string): Promise { - const match = key.match(/^(rumor|seal):(.+)$/); - if (!match) return; - - const [, , giftWrapId] = match; - await db.decryptedRumors.delete(giftWrapId); + await db.encryptedContent.put({ + id: key, + content: value, + cachedAt: Date.now(), + }); }, }; /** - * Get all decrypted rumors for the current user + * Check if we have cached encrypted content for an event */ -export async function getDecryptedRumors(pubkey: string): Promise { - const entries = await db.decryptedRumors - .where("decryptedBy") - .equals(pubkey) - .toArray(); - - return entries.map((e) => e.rumor); +export async function hasEncryptedContent(eventId: string): Promise { + const count = await db.encryptedContent.where("id").equals(eventId).count(); + return count > 0; } /** - * Get a specific decrypted rumor by gift wrap ID + * Get count of cached encrypted content entries */ -export async function getDecryptedRumor( - giftWrapId: string, - pubkey: string, -): Promise { - const entry = await db.decryptedRumors.get(giftWrapId); - if (entry && entry.decryptedBy === pubkey) { - return entry.rumor; - } - return null; +export async function getEncryptedContentCount(): Promise { + return db.encryptedContent.count(); } /** - * Check if a gift wrap has already been decrypted + * Clear all cached encrypted content */ -export async function isGiftWrapDecrypted( - giftWrapId: string, - pubkey: string, -): Promise { - const entry = await db.decryptedRumors.get(giftWrapId); - return entry !== null && entry !== undefined && entry.decryptedBy === pubkey; -} - -/** - * Store a decrypted rumor directly (for manual decryption flows) - */ -export async function storeDecryptedRumor( - giftWrapId: string, - rumor: Rumor, - decryptedBy: string, -): Promise { - const entry: DecryptedRumor = { - giftWrapId, - rumor, - decryptedBy, - decryptedAt: Date.now(), - }; - await db.decryptedRumors.put(entry); -} - -/** - * Get count of decrypted rumors for a user - */ -export async function getDecryptedRumorCount(pubkey: string): Promise { - return db.decryptedRumors.where("decryptedBy").equals(pubkey).count(); -} - -/** - * Clear all decrypted rumors for a user - * Useful for "forget me" functionality - */ -export async function clearDecryptedRumors(pubkey: string): Promise { - return db.decryptedRumors.where("decryptedBy").equals(pubkey).delete(); -} - -/** - * Observable storage for applesauce's persistEncryptedContent - * Returns an observable that emits the storage when pubkey is set - */ -export function getRumorStorage$(): Observable { - return new BehaviorSubject(rumorStorage); +export async function clearEncryptedContent(): Promise { + await db.encryptedContent.clear(); }