VibeReader - Nostr + Blossom Architecture¶
Overview¶
VibeReader uses a decentralized storage architecture combining Nostr for metadata and state management with Blossom for file storage. This eliminates the need for traditional backend infrastructure while providing automatic multi-device sync and data portability.
Architecture Components¶
Nostr - Metadata & State¶
- Reading progress (replaceable)
- User settings (replaceable)
- Highlights (individually addressable & editable)
- Notes (individually addressable & editable)
- Book metadata (replaceable)
- Chat messages (append-only or replaceable)
Blossom (BUD-01) - File Storage¶
- EPUB files
- Cover images
- Exported annotations (PDF/Markdown)
Local Cache (IndexedDB)¶
- Downloaded EPUB files (for offline reading)
- Temporary data cache
Why This Architecture?¶
Benefits¶
No Backend Server Required¶
- ❌ No database (PostgreSQL, MongoDB, etc.)
- ❌ No user authentication system
- ❌ No REST API endpoints
- ❌ No session management
- ❌ No backup/restore logic
- ❌ No sync conflict resolution
What We Get Instead¶
- ✅ Automatic multi-device sync
- ✅ Data portability (user owns their data)
- ✅ Censorship resistance
- ✅ Built-in identity (Nostr keys)
- ✅ Decentralized infrastructure
- ✅ No hosting costs
For Highlights & Notes¶
- ✅ Editable: Change color, content, any property
- ✅ Addressable: Direct reference by ID
- ✅ Queryable: Filter by book, color, date
- ✅ Synced: Automatic multi-device sync
- ✅ No conflicts: Latest event wins (by
created_at) - ✅ Efficient: Only one event per annotation (not append-only)
For Files (Blossom)¶
- ✅ Decentralized: No single point of failure
- ✅ Content-addressed: SHA-256 hash verification
- ✅ Cacheable: Store locally, fetch on demand
- ✅ Portable: Move between Blossom servers
- ✅ Efficient: No event size limits
Nostr Event Kinds¶
We use Addressable Events (formerly NIP-33) for mutable state and NIP-84 for shareable highlights.
Event Kind Registry¶
| Kind | Type | Purpose | Replaceable | Standard |
|---|---|---|---|---|
| 30001 | Book Metadata | Store book info and Blossom references | Yes (per book) | Custom |
| 30002 | Reading Progress | Current reading position | Yes (per book) | Custom |
| 30003 | User Settings | Global app preferences | Yes (single) | Custom |
| 30004 | Personal Highlight | Editable highlight with color/notes | Yes (per highlight) | Custom |
| 30005 | Note | Individual note with content | Yes (per note) | Custom |
| 9802 | Shared Highlight | Immutable, shareable highlight | No (append-only) | NIP-84 |
| 30100 | Chat Message | AI chat messages | Optional | Custom |
Highlight Strategy: Dual Approach¶
Personal Annotations (Kind 30004): - Editable, private highlights - Can change color, add notes - Syncs across user's devices - Only latest version stored
Shared Highlights (Kind 9802 - NIP-84): - Immutable, public highlights - For sharing with commentary - Interoperable with other Nostr clients - Social features
See NIP84_HIGHLIGHTS_EXPLAINED.md for detailed comparison.
Data Models¶
1. Book Metadata (Kind 30001)¶
Stores book information and references to files on Blossom.
{
kind: 30001,
tags: [
["d", "book-{bookId}"], // Unique identifier
["title", "The Great Book"], // For filtering/search
["author", "Jane Author"], // For filtering/search
["isbn", "978-1234567890"], // Optional
["t", "book"], // Type tag
],
content: JSON.stringify({
blossomHash: "sha256-abc123...", // EPUB file hash
blossomUrl: "https://cdn.blossom.com/abc123",
coverBlossomHash: "sha256-def456...", // Cover image hash
fileSize: 2458624,
importDate: 1730234000,
lastReadDate: 1730234567,
metadata: {
publisher: "Great Publisher",
language: "en",
description: "A wonderful book about...",
}
}),
created_at: 1730234567,
pubkey: "user-npub...",
}
Key Features:
- d tag makes it replaceable (updates replace old metadata)
- Blossom hash provides content-addressed file reference
- Searchable via title/author tags
- Last read date updates automatically
2. Reading Progress (Kind 30002)¶
Tracks current reading position in each book. Updates replace previous position.
{
kind: 30002,
tags: [
["d", "progress-{bookId}"], // Unique per book
["book", "{bookId}"], // Reference to book
],
content: JSON.stringify({
cfi: "epubcfi(/6/4[chap01ref]!/4/2/2[id001]/1:0)", // EPUB CFI
chapter: 3,
chapterTitle: "Chapter Three",
percentage: 45.2,
scrollPosition: 1234,
timestamp: 1730234567,
}),
created_at: 1730234567,
pubkey: "user-npub...",
}
Key Features: - Automatically syncs across devices - Latest position always wins - CFI (Canonical Fragment Identifier) for precise positioning - Percentage for progress bars
3. User Settings (Kind 30003)¶
Global application settings. Single replaceable event.
{
kind: 30003,
tags: [
["d", "vibereader-settings"], // Fixed identifier
["app", "vibereader"], // App namespace
],
content: JSON.stringify({
reading: {
fontSize: 18,
fontFamily: "serif",
lineHeight: 1.6,
theme: "dark",
pageMode: "paginated",
},
ai: {
provider: "openai",
model: "qwen3-coder-30b",
temperature: 0.7,
apiEndpoint: "https://webui.plebchat.me/api",
},
ui: {
libraryView: "grid",
sidebarPosition: "right",
},
relays: [
"wss://relay.damus.io",
"wss://relay.primal.net",
],
blossomServers: [
"https://blossom.primal.net",
],
}),
created_at: 1730234567,
pubkey: "user-npub...",
}
Key Features: - Settings follow user across devices - Single source of truth - Includes relay and Blossom server preferences
4. Highlight (Kind 30004)¶
Individual highlight with color. Each highlight is independently addressable and editable.
{
kind: 30004,
tags: [
["d", "hl-{uuid}"], // Unique, stable ID (NOT the CFI)
["book", "{bookId}"], // User's book ID
["blossom", "sha256-abc123..."], // Blossom hash (for cross-user matching)
["book-title", "The Great Book"], // Human-readable
["cfi", "epubcfi(...)"], // For positioning/rendering
["color", "yellow"], // Current color
["private", "false"], // Privacy flag
["t", "highlight"], // Type tag
],
content: JSON.stringify({
text: "The highlighted passage text", // Encrypted if private=true
cfiRange: "epubcfi(/6/4[chap01]!/4/2/2,/1:0,/1:142)",
createdAt: 1730234000,
updatedAt: 1730234567, // Track edits
}),
created_at: 1730234567, // Latest update time
pubkey: "user-npub...",
}
Key Features:
- d tag: Unique UUID (NOT the CFI range - CFI can change if EPUB is re-rendered)
- blossom tag: SHA-256 hash for cross-user book matching
- cfi tag: For positioning the highlight in the EPUB
- private tag: Toggle for encryption (NIP-04/NIP-44)
- Change color by publishing new event with same d tag
- Query all highlights for a book (by blossom hash)
- Filter by color
- No duplicate events (replaceable)
Why UUID for d tag instead of CFI?
- CFI ranges can be complex and long
- CFI might change if EPUB is re-rendered or updated
- UUID provides stable, unique identifier
- CFI is stored in tags for querying and in content for rendering
Editing Example:
// Change highlight color from yellow to green
async function changeHighlightColor(highlightId: string, newColor: string) {
const currentEvent = await nostrService.getHighlight(highlightId);
const updatedEvent = {
...currentEvent,
tags: currentEvent.tags.map(tag =>
tag[0] === "color" ? ["color", newColor] : tag
),
created_at: Math.floor(Date.now() / 1000),
};
await nostrService.publish(updatedEvent); // Replaces old event
}
5. Note (Kind 30005)¶
Individual note attached to text selection. Editable and addressable.
{
kind: 30005,
tags: [
["d", "note-{uuid}"], // Unique, stable ID (NOT the CFI)
["book", "{bookId}"], // User's book ID
["blossom", "sha256-abc123..."], // Blossom hash (for cross-user matching)
["book-title", "The Great Book"], // Human-readable
["cfi", "epubcfi(...)"], // For positioning
["private", "false"], // Privacy flag
["t", "note"], // Type tag
["has-highlight", "true"], // If attached to highlight
],
content: JSON.stringify({
selectedText: "The passage this note refers to", // Encrypted if private=true
noteContent: "My thoughts about this passage...", // Encrypted if private=true
cfiRange: "epubcfi(...)",
createdAt: 1730234000,
updatedAt: 1730234890,
}),
created_at: 1730234890,
pubkey: "user-npub...",
}
Key Features: - Edit note content anytime - Attach to highlights - Query all notes for a book - Full text search via content
Editing Example:
async function editNote(noteId: string, newContent: string) {
const currentEvent = await nostrService.getNote(noteId);
const data = JSON.parse(currentEvent.content);
const updatedEvent = {
kind: 30005,
tags: currentEvent.tags,
content: JSON.stringify({
...data,
noteContent: newContent,
updatedAt: Date.now(),
}),
created_at: Math.floor(Date.now() / 1000),
};
await nostrService.publish(updatedEvent);
}
6. Chat Messages (Kind 30100 or Kind 1)¶
AI chat messages. Can be replaceable (kind 30100) or append-only (kind 1).
Option A: Replaceable Chat Sessions (Kind 30100)
{
kind: 30100,
tags: [
["d", "chat-{bookId}-{sessionId}"],
["book", "{bookId}"],
["role", "user"], // or "assistant"
["context", "{highlightEventId}"], // Reference to highlight/note
],
content: JSON.stringify({
message: "What does this passage mean?",
contextText: "The highlighted passage...",
timestamp: 1730234567,
}),
created_at: 1730234567,
pubkey: "user-npub...",
}
Option B: Append-Only Chat (Kind 1)
{
kind: 1,
tags: [
["t", "vibereader-chat"],
["book", "{bookId}"],
["context-event", "{highlightEventId}"],
["reply", "{previousMessageId}"], // Thread messages
],
content: "What does this passage mean?",
created_at: 1730234567,
pubkey: "user-npub...",
}
Blossom Integration¶
What is Blossom?¶
Blossom is a decentralized file storage protocol (BUD-01) that uses content-addressing (SHA-256) and Nostr authentication. Files are stored on Blossom servers and referenced by their hash.
Upload Flow¶
async function importBook(file: File) {
// 1. Upload EPUB to Blossom
const blossomHash = await blossomService.upload(file);
// 2. Extract metadata
const metadata = await epubService.parseMetadata(file);
// 3. Upload cover image to Blossom
const coverHash = await blossomService.upload(metadata.coverBlob);
// 4. Create book metadata event
const bookId = generateUUID();
const event = {
kind: 30001,
tags: [
["d", `book-${bookId}`],
["title", metadata.title],
["author", metadata.author],
],
content: JSON.stringify({
blossomHash,
blossomUrl: `https://cdn.blossom.com/${blossomHash}`,
coverBlossomHash: coverHash,
fileSize: file.size,
importDate: Date.now(),
}),
};
// 5. Publish to Nostr
await nostrService.publish(event);
// 6. Cache locally
await localCache.store(bookId, file);
return bookId;
}
Download Flow¶
async function openBook(bookId: string) {
// 1. Check local cache first
let epubBlob = await localCache.get(bookId);
if (!epubBlob) {
// 2. Fetch book metadata from Nostr
const bookEvent = await nostrService.getBook(bookId);
const metadata = JSON.parse(bookEvent.content);
// 3. Download EPUB from Blossom
epubBlob = await blossomService.download(metadata.blossomHash);
// 4. Cache locally for offline access
await localCache.store(bookId, epubBlob);
}
// 5. Load reading progress from Nostr
const progress = await nostrService.getProgress(bookId);
// 6. Load annotations
const highlights = await nostrService.getHighlights(bookId);
const notes = await nostrService.getNotes(bookId);
return { epub: epubBlob, progress, highlights, notes };
}
Blossom Server Configuration¶
Users can configure multiple Blossom servers for redundancy:
const blossomServers = [
"https://blossom.primal.net",
"https://cdn.satellite.earth",
// Add more servers
];
Files are uploaded to the primary server, with optional mirroring to backup servers.
Common Workflows¶
1. Change Highlight Color¶
async function changeHighlightColor(highlightId: string, newColor: string) {
// Fetch current event
const currentEvent = await nostrService.getHighlight(highlightId);
// Update color tag
const updatedEvent = {
kind: 30004,
tags: currentEvent.tags.map(tag =>
tag[0] === "color" ? ["color", newColor] : tag
),
content: currentEvent.content,
created_at: Math.floor(Date.now() / 1000),
};
// Publish - automatically replaces old event
await nostrService.publish(updatedEvent);
}
2. Add Note to Highlight¶
async function addNoteToHighlight(highlightId: string, noteText: string) {
const highlight = await nostrService.getHighlight(highlightId);
const highlightData = JSON.parse(highlight.content);
const noteId = generateUUID();
const noteEvent = {
kind: 30005,
tags: [
["d", `note-${noteId}`],
["book", getTag(highlight, "book")],
["cfi", getTag(highlight, "cfi")],
["highlight", highlightId],
["t", "note"],
],
content: JSON.stringify({
selectedText: highlightData.text,
noteContent: noteText,
cfiRange: highlightData.cfiRange,
createdAt: Date.now(),
}),
created_at: Math.floor(Date.now() / 1000),
};
await nostrService.publish(noteEvent);
}
3. Delete Annotation¶
async function deleteHighlight(highlightId: string) {
// Publish event with deletion marker
const deleteEvent = {
kind: 30004,
tags: [
["d", highlightId],
["deleted", "true"],
],
content: "", // Empty content
created_at: Math.floor(Date.now() / 1000),
};
await nostrService.publish(deleteEvent);
}
4. Use Highlight in Chat¶
async function addHighlightToChatContext(highlightId: string) {
const highlight = await nostrService.getHighlight(highlightId);
const highlightData = JSON.parse(highlight.content);
// Reference the highlight event in chat message
const chatMessage = {
kind: 1,
tags: [
["t", "vibereader-chat"],
["book", getTag(highlight, "book")],
["context-event", highlightId], // Reference to highlight
],
content: `Regarding: "${highlightData.text}"\n\nWhat does this mean?`,
created_at: Math.floor(Date.now() / 1000),
};
await nostrService.publish(chatMessage);
}
5. Update Reading Progress¶
async function updateProgress(bookId: string, cfi: string, percentage: number) {
const progressEvent = {
kind: 30002,
tags: [
["d", `progress-${bookId}`],
["book", bookId],
],
content: JSON.stringify({
cfi,
percentage,
timestamp: Date.now(),
}),
created_at: Math.floor(Date.now() / 1000),
};
// Auto-replaces previous progress
await nostrService.publish(progressEvent);
}
Query Patterns¶
Get All Books¶
const filter = {
kinds: [30001],
authors: [userPubkey],
"#t": ["book"],
};
const books = await nostrService.query(filter);
Get All Highlights for a Book¶
const filter = {
kinds: [30004],
authors: [userPubkey],
"#book": [bookId],
};
const highlights = await nostrService.query(filter);
Get Yellow Highlights Only¶
const filter = {
kinds: [30004],
authors: [userPubkey],
"#book": [bookId],
"#color": ["yellow"],
};
const yellowHighlights = await nostrService.query(filter);
Get Reading Progress¶
const filter = {
kinds: [30002],
authors: [userPubkey],
"#d": [`progress-${bookId}`],
};
const progress = await nostrService.query(filter);
Popular Highlights (Kindle-style)¶
Query Strategy¶
To find popular highlights, query all public highlights for a book by Blossom hash:
// Get all public highlights for a book
const allHighlights = await nostrService.query({
kinds: [30004],
"#blossom": [blossomHash],
"#private": ["false"], // Only public highlights
});
// Group by CFI range (fuzzy matching for overlapping ranges)
const popularPassages = aggregateHighlightsByCFI(allHighlights);
// Filter by threshold (e.g., 3+ readers)
const popular = popularPassages.filter(p => p.count >= 3);
// Sort by count
popular.sort((a, b) => b.count - a.count);
CFI Range Matching¶
Since different readers might highlight slightly different ranges of the same passage, use fuzzy matching:
function aggregateHighlightsByCFI(highlights: Highlight[]) {
const clusters: Map<string, HighlightCluster> = new Map();
for (const highlight of highlights) {
const cfi = highlight.tags.find(t => t[0] === "cfi")?.[1];
// Find existing cluster with overlapping CFI
let cluster = findOverlappingCluster(clusters, cfi);
if (!cluster) {
// Create new cluster
cluster = {
cfiRange: cfi,
count: 0,
readers: [],
text: JSON.parse(highlight.content).text,
};
clusters.set(cfi, cluster);
}
cluster.count++;
cluster.readers.push(highlight.pubkey);
}
return Array.from(clusters.values());
}
Display in Reader¶
// Show popular highlights with visual intensity
<span
className={`popular-highlight popular-${getIntensity(count)}`}
title={`${count} readers highlighted this`}
onClick={() => showReadersList(readers)}
>
{text}
</span>
// CSS for intensity
.popular-highlight.popular-low {
border-bottom: 2px solid rgba(255, 200, 0, 0.3);
}
.popular-highlight.popular-medium {
border-bottom: 2px solid rgba(255, 200, 0, 0.6);
}
.popular-highlight.popular-high {
border-bottom: 3px solid rgba(255, 200, 0, 1.0);
}
Privacy Considerations¶
Public Data (Anyone Can Read)¶
- Book titles you're reading (if public)
- Reading progress (if public)
- Public highlights and notes
- Public chat messages
Private Data (Encrypted)¶
- Highlights/notes/chats with
private=true - Content encrypted with NIP-04 or NIP-44
- Only decryptable by user's private key
Privacy Solutions¶
1. Per-Item Privacy Toggle¶
Each annotation has a privacy flag:
// Public highlight
{
tags: [["private", "false"]],
content: JSON.stringify({ text: "visible to all" })
}
// Private highlight (encrypted)
{
tags: [["private", "true"]],
content: await nip04.encrypt(
userPrivkey,
JSON.stringify({ text: "only I can read this" })
)
}
2. NIP-04 Encryption¶
Encrypt event content for private data:
const encryptedContent = await nip04.encrypt(
userPrivkey, // Encrypt to self
JSON.stringify(sensitiveData)
);
2. Private Relays¶
Use personal or paid relays that require authentication:
3. Pseudonymous Keys¶
Create a separate Nostr identity for reading:
Relay Strategy¶
Recommended Relays¶
const defaultRelays = [
"wss://relay.damus.io", // General purpose
"wss://relay.primal.net", // Fast, reliable
"wss://nos.lol", // Popular
"wss://relay.nostr.band", // Good for queries
];
Relay Selection Criteria¶
- ✅ Supports NIP-33 (parameterized replaceable events)
- ✅ Good uptime and reliability
- ✅ Fast query response
- ✅ Accepts your event kinds
- ✅ Optional: Paid/private for privacy
Fallback Strategy¶
- Try multiple relays in parallel
- Cache events locally
- Retry failed publishes
- Allow user to configure relay list
Implementation Checklist¶
Phase 1: Core Infrastructure¶
- Nostr service (event signing, publishing, querying)
- Blossom service (upload, download, verification)
- Local cache (IndexedDB for EPUBs)
- Key management (Nostr identity)
Phase 2: Basic Features¶
- Book import (upload to Blossom, create metadata event)
- Book library (query and display books)
- Reading progress (save and restore position)
- Settings sync (global preferences)
Phase 3: Annotations¶
- Highlights (create, edit color, delete)
- Notes (create, edit, delete)
- Annotation display in reader
- Annotation list/sidebar
Phase 4: Advanced Features¶
- Chat integration (reference highlights/notes)
- Multi-device sync testing
- Offline mode
- Export annotations
Technical Dependencies¶
Nostr Libraries¶
Blossom Libraries¶
Local Storage¶
Resources¶
- Nostr Protocol: https://github.com/nostr-protocol/nostr
- NIP-01 (Basics): https://github.com/nostr-protocol/nips/blob/master/01.md
- NIP-33 (Parameterized Replaceable Events): https://github.com/nostr-protocol/nips/blob/master/33.md
- Blossom (BUD-01): https://github.com/hzrd149/blossom
- nostr-tools: https://github.com/nbd-wtf/nostr-tools
- Relay List: https://nostr.watch/
Future Enhancements¶
Collaborative Features¶
- Share highlights with friends (publish to shared relay)
- Book clubs (group annotations)
- Public book reviews
Advanced Privacy¶
- NIP-44 encryption (better than NIP-04)
- Tor relay support
- Zero-knowledge proofs for private reading stats
Performance Optimizations¶
- Event batching
- Subscription management
- Intelligent relay selection
- CDN for Blossom files
Additional Features¶
- Export to Obsidian/Notion (via Nostr events)
- Reading streaks and statistics
- Book recommendations based on reading history
- Integration with Nostr social features