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
This commit is contained in:
Claude
2026-01-14 11:57:18 +00:00
parent cd052d657f
commit edd951612f
4 changed files with 173 additions and 344 deletions

View File

@@ -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<Rumor[]>([]);
/** Track pending (undecrypted) gift wrap IDs */
private pendingGiftWraps$ = new BehaviorSubject<string[]>([]);
private pendingGiftWraps$ = new BehaviorSubject<Set<string>>(new Set());
/** Observable of gift wrap events from event store */
private giftWraps$ = new BehaviorSubject<NostrEvent[]>([]);
/**
* 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<Message[]> {
// 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<void> {
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<NostrEvent | null> {
// 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<number> {
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<void> {
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<string[]> {
// 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<void>((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
*/

View File

@@ -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<LocalSpell>;
spellbooks!: Table<LocalSpellbook>;
events!: Table<CachedEvent>;
decryptedRumors!: Table<DecryptedRumor>;
encryptedContent!: Table<EncryptedContentEntry>;
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",
});
}
}

View File

@@ -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;

View File

@@ -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<string | null>(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<string | null> {
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<void> {
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<void> {
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<Rumor[]> {
const entries = await db.decryptedRumors
.where("decryptedBy")
.equals(pubkey)
.toArray();
return entries.map((e) => e.rumor);
export async function hasEncryptedContent(eventId: string): Promise<boolean> {
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<Rumor | null> {
const entry = await db.decryptedRumors.get(giftWrapId);
if (entry && entry.decryptedBy === pubkey) {
return entry.rumor;
}
return null;
export async function getEncryptedContentCount(): Promise<number> {
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<boolean> {
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<void> {
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<number> {
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<number> {
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<typeof rumorStorage | null> {
return new BehaviorSubject(rumorStorage);
export async function clearEncryptedContent(): Promise<void> {
await db.encryptedContent.clear();
}