Caching Encrypted Content
The persistEncryptedContent function from applesauce-common/helpers provides automatic persistence and restoration of encrypted content for Nostr events. This allows your application to cache decrypted content so users don't need to decrypt the same events repeatedly.
Overview
When working with encrypted events (like direct messages, gift wraps, or wallet data), decrypting content can be expensive or require user interaction. The persistEncryptedContent helper automatically:
- Persists encrypted content to storage when events are unlocked/decrypted
- Restores encrypted content from storage when events are loaded
- Handles gift wrap seals automatically
- Works with any storage backend that implements the
EncryptedContentCacheinterface
Basic Setup
The simplest way to use encrypted content caching is to pass your event store and a storage observable. Here's an example using localforage:
import { persistEncryptedContent } from "applesauce-common/helpers";
import { EventStore } from "applesauce-core";
import { BehaviorSubject } from "rxjs";
import { defined } from "applesauce-core";
import localforage from "localforage";
// Create your event store
const eventStore = new EventStore();
// Create a localforage instance for encrypted content
const encryptedContentStorage = localforage.createInstance({
name: "encrypted-content",
});
// Start persisting and restoring encrypted content
persistEncryptedContent(eventStore, encryptedContentStorage);
// Insert an event into the store that can have encrypted content
eventStore.add(bookmarks);
eventStore.add(nip04Message);
eventStore.add(giftWrap);
// Decrypt the events content
await unlockHiddenBookmarks(bookmarks, signer);
await unlockLegacyMessage(nip04Message, signer);
await unlockGiftWrap(giftWrap, signer);
// The decrypted content is now stored in the cacheImplementing EncryptedContentCache
Your storage class must implement the EncryptedContentCache interface:
interface EncryptedContentCache {
getItem: (key: string) => Promise<string | null>;
setItem: (key: string, value: string) => Promise<any>;
}Complete Example
Here's a complete example showing how it's used in practice:
import { ProxySigner } from "applesauce-accounts";
import { ActionRunner } from "applesauce-actions";
import { defined, EventFactory, EventStore } from "applesauce-core";
import { persistEncryptedContent } from "applesauce-common/helpers";
import { persistEventsToCache } from "applesauce-core/helpers";
import { BehaviorSubject } from "rxjs";
import { ExtensionSigner } from "applesauce-signers";
import { addEvents, getEventsForFilters, openDB } from "nostr-idb";
// Setup application state
const storage$ = new BehaviorSubject<SecureStorage | null>(null);
const signer$ = new BehaviorSubject<ExtensionSigner | null>(null);
const pubkey$ = new BehaviorSubject<string | null>(null);
// Setup event store
const eventStore = new EventStore();
const factory = new EventFactory({
signer: new ProxySigner(signer$.pipe(defined())),
});
const actions = new ActionRunner(eventStore, factory);
// Persist encrypted content - this is the key line!
persistEncryptedContent(eventStore, storage$.pipe(defined()));
// Later, when storage is unlocked/initialized:
storage$.next(new SecureStorage(/* ... */));How It Works
- When events are inserted into the store,
persistEncryptedContentchecks if they have encrypted content that's locked - If locked, it attempts to restore the encrypted content from your storage
- When events are unlocked/decrypted, it automatically saves the encrypted content to storage
- For gift wraps, it also handles seals automatically - when a gift wrap is unlocked, it restores and persists the seal's encrypted content
Using with Observable Storage
You can pass either a storage instance directly or an Observable of storage. This is useful when storage needs to be unlocked asynchronously:
// Using Observable pattern (recommended)
const storage$ = new BehaviorSubject<SecureStorage | null>(null);
persistEncryptedContent(eventStore, storage$.pipe(defined()));
// Later, when storage is ready:
await storage.unlock(pin);
storage$.next(storage);Fallback Function
You can optionally provide a fallback function that will be called when encrypted content is not found in storage:
persistEncryptedContent(eventStore, storage$.pipe(defined()), async (event) => {
// This function is called when content is not in storage
// You could fetch from another source, decrypt on-demand, etc.
return await fetchEncryptedContentFromAnotherSource(event.id);
});Stopping the Process
The function returns a cleanup function that you can call to stop persisting/restoring:
const cleanup = persistEncryptedContent(eventStore, storage$.pipe(defined()));
// Later, when you want to stop:
cleanup();Best Practices
- Use Observable pattern - Pass an Observable of storage rather than the storage directly, so you can handle async initialization or multiple storage instances for each user
- Handle errors gracefully - The function logs errors but doesn't throw, so your app continues working even if caching fails
- Store securely - Since you're storing encrypted content, make sure your storage implementation is secure (encrypted at rest, etc.)
