Nostr WoT

Inbox, history and encrypted storage

Discover inbox relays, backfill history, track reads and encrypt local storage.

This detailed API reference is maintained in English and follows the linked SDK source. Code examples with application variables need your own signer, keys, storage or UI.

Layer 2 — Cache (/cache)

A complete inbox subsystem: subscribes to inbound events, auto-decrypts via your signer, dedupes, ingests into in-memory observables, and (optionally) persists to a DMStorage backend. Built on nostr-wot-sdk/data's sharedCoalescer, so DM subscriptions piggyback on the same connection pool used by profile/note fetchers.

Bootstrap a session

ts
import {
  initDMSession,
  subscribeInbox,
  sendDM,
  localStorageDMStorage,
} from "@nostr-wot/dm/cache";

const session = await initDMSession({
  myPubkey,
  signer,
  relays: ["wss://relay.damus.io", "wss://nos.lol"],
  storage: localStorageDMStorage(),  // optional persistence
  discoverInboxRelays: true,         // auto-resolve kind 10050
});

const stop = subscribeInbox(session); // live; returns teardown

await sendDM(session, partnerPubkey, "hi", { scheme: "nip17" }); // default

subscribeInbox opens three coalesced subscriptions: NIP-04 inbound (#p == me), NIP-04 outbound echo (authors == me), and NIP-17 gift wraps (#p == me). Every event is decrypted and ingested through the same ingestMessage pipeline — UI subscribers see one consistent stream.

Inbox-relay discovery + publishing (kind 10050)

ts
import { fetchInboxRelays, publishInboxRelays } from "@nostr-wot/dm/cache";

// Where do my DMs go?
const inbox = await fetchInboxRelays(myPubkey, defaultRelays);

// Tell other clients where to send my DMs
await publishInboxRelays(signer, /* publish to */ relays, /* inbox = */ inbox);

Without a kind-10050, your NIP-17 DMs only arrive from senders who happen to share a relay with you.

Historical backfill

subscribeInbox only catches new events. To populate threads on first login (or after a long offline period), walk past kind-1059 wraps:

ts
import { backfillInbox } from "@nostr-wot/dm/cache";

const { partners, ingested, windowsWalked } = await backfillInbox(session, {
  windows: 12,         // 12 × 30-day = ≈1 year
  limit: 200,          // events per window per filter
  minPartners: 50,     // stop once enough partners discovered
  timeoutMs: 30_000,   // hard wallclock budget
});

Read cursors (device-local)

Read state is intentionally never synced to relays — no server should know who you're DMing. Read cursors live in localStorage, keyed per account.

ts
import {
  markRead,
  setReadCursor,
  getReadCursor,
  getUnreadCount,
  getUnreadCounts,
  detectScheme,
} from "@nostr-wot/dm/cache";

markRead(myPubkey, partnerPubkey);
const unread = getUnreadCount(myPubkey, partnerPubkey);
const allUnreads = getUnreadCounts(myPubkey);  // { partnerHex: n, ... }

// Predict whether to send NIP-04 or NIP-17 to a partner based on
// the most recent cached messages.
const scheme = detectScheme(messages); // "nip04" | "nip17" | null

Encrypted-at-rest storage

Wrap any DMStorage with AES-GCM encryption keyed by a NIP-44-self-encrypted KEK. The raw key is imported as a non-extractable WebCrypto key — XSS can call your encrypt/decrypt helpers but cannot exfiltrate the bytes.

ts
import {
  initDMSession,
  localStorageDMStorage,
  getOrCreateCacheKey,
  wrapStorageWithEncryption,
} from "@nostr-wot/dm/cache";

const key = await getOrCreateCacheKey(myPubkey, signer);  // one-time signer prompt
const storage = wrapStorageWithEncryption(localStorageDMStorage()!, key);

await initDMSession({ myPubkey, signer, relays, storage });

Custom storage backends

ts
import type { DMStorage } from "@nostr-wot/dm/cache";

const indexedDBStorage: DMStorage = {
  async load(myPubkey) { /* ... */ },
  async save(myPubkey, conversations) { /* ... */ },
};

Auto-persist + eviction

When you pass a storage to initDMSession, the session auto-persists on a debounce (default 500ms) after every cache mutation. Set autoPersist: false for manual control, or tune the debounce with autoPersistDebounceMs.

After each mutation the session also calls evictIfNeeded(myPubkey, evictionCap) (default cap 2000). Eviction is a no-op until you register a follow set — without one, every partner is treated as protected:

ts
import { setFollowSet } from "@nostr-wot/dm/cache";
import { fetchFollows } from "nostr-wot-sdk/data";

const follows = await fetchFollows(myPubkey);
setFollowSet(myPubkey, new Set(follows.pubkeys));   // enables eviction
// or pass null to explicitly mark "follows un-hydrated → protect everything"
setFollowSet(myPubkey, null);

Followed partners are never evicted; oldest non-followed messages drop first once the cap is exceeded.

Logout / account switch

ts
import { closeDMSession, clearDMSession } from "@nostr-wot/dm/cache";

closeDMSession(session);                               // stop subscriptions
await clearDMSession(myPubkey, { storage, clearStorage: true }); // wipe state

Other signer backends

An existing signer integration can implement NostrSigner from nostr-wot-sdk/signers and pass it to initDMSession. Supply the NIP-44 methods needed for NIP-17; the package does not export an NDK adapter.


API reference