@mrbelloc/encrypted-store
v3.0.0
Published
Encrypted document storage with change detection using PouchDB and AES-256-GCM
Maintainers
Readme
Encrypted Store
Client-side encrypted document storage with change detection using PouchDB and AES-256-GCM encryption.
Simple API for offline-first apps - PUT, GET, DELETE documents with automatic sync to CouchDB.
Features
- 🔐 AES-256-GCM encryption with WebCrypto API
- 📦 Simple document API:
put,get,delete,getAll - 🔄 Real-time change detection (
onChange,onDelete) - ⚠️ Conflict detection and resolution
- 🌐 Sync to CouchDB (or any PouchDB-compatible server)
- 📊 Sync progress events
- 🔌 Offline-first with automatic retry
- 📱 Works in browser and Node.js
- 🎯 TypeScript with full type safety
Installation
For Browser (Vite/Webpack)
npm install @mrbelloc/encrypted-store pouchdb-browser@^8.0.1 eventsNote: Currently requires PouchDB v8. PouchDB v9 has compatibility issues with TypeScript types and some bundlers.
Required for Vite: Install the events package to fix "Class extends value [object Object]" errors.
For Node.js
npm install @mrbelloc/encrypted-store pouchdbQuick Start
Browser (Vite/React/Vue/Svelte)
import PouchDBModule from 'pouchdb-browser';
// Workaround for ESM/CommonJS compatibility in some bundlers
const PouchDB = PouchDBModule.default || PouchDBModule;
import { EncryptedStore } from '@mrbelloc/encrypted-store';
// Create database and encrypted store (uses IndexedDB in browser)
const db = new PouchDB('myapp');
const store = new EncryptedStore(db, 'my-password', {
onChange: (docs) => {
console.log('Documents changed:', docs);
},
onDelete: (docs) => {
console.log('Documents deleted:', docs);
},
onConflict: (conflicts) => {
console.log('Conflicts detected:', conflicts);
},
onSync: (info) => {
console.log('Sync progress:', info);
},
onError: (errors) => {
console.error('Decryption errors:', errors);
}
});
// Load existing data and start change detection
await store.loadAll();
// Create/update documents
await store.put('expenses', {
_id: 'lunch',
amount: 15.50,
date: '2024-01-15'
});
// Get a document
const expense = await store.get('expenses', 'lunch');
console.log(expense); // { _id: 'lunch', _table: 'expenses', amount: 15.50, date: '2024-01-15' }
// Get all documents (optionally filtered by table)
const allExpenses = await store.getAll('expenses');
const allDocs = await store.getAll();
// Delete a document
await store.delete('expenses', 'lunch');
// Sync to CouchDB
await store.connectRemote({
url: 'http://localhost:5984/myapp',
live: true,
retry: true
});Node.js
import PouchDB from 'pouchdb';
import { EncryptedStore } from '@mrbelloc/encrypted-store';
// Create database and encrypted store (uses LevelDB in Node)
const db = new PouchDB('myapp');
const store = new EncryptedStore(db, 'my-password', {
onChange: (docs) => console.log('Changed:', docs),
onDelete: (docs) => console.log('Deleted:', docs),
});
await store.loadAll();API Reference
new EncryptedStore(db, password, listener?, options?)
Creates an encrypted store.
db: PouchDB database instancepassword: Encryption password (string)listener: Optional object with callbacksoptions: Optional configuration object
Options:
interface EncryptedStoreOptions {
passphraseMode?: "derive" | "raw"; // default: "derive"
}passphraseMode: "derive"(default): Uses PBKDF2 with 100k iterations for user passphrases. Recommended for production use. Provides strong protection against brute-force and dictionary attacks. First unlock will take ~50-100ms.passphraseMode: "raw": Uses SHA-256 only. For pre-derived keys or advanced users who handle key derivation themselves. Allows full control over KDF algorithm, iterations, and progress UI.
Listener Callbacks
interface StoreListener {
onChange: (docs: Doc[]) => void;
onDelete: (docs: Doc[]) => void;
onConflict?: (conflicts: ConflictInfo[]) => void;
onSync?: (info: SyncInfo) => void;
onError?: (errors: DecryptionErrorEvent[]) => void;
}onChange(docs): Called when documents are added or updatedonDelete(docs): Called when documents are deletedonConflict(conflicts): Called when conflicts are detectedonSync(info): Called during sync operationsonError(errors): Called when documents fail to decrypt
await store.loadAll()
Loads all existing documents and starts change detection. Call this once after creating the store.
await store.put(table, doc)
Creates or updates a document.
table: Document type (e.g., "expenses", "tasks")doc: Document object with optional_idfield (generated if missing)
Returns the document with _table field added.
await store.get(table, id)
Gets a document by table and ID. Returns null if not found.
await store.delete(table, id)
Deletes a document by table and ID.
await store.deleteAllLocal()
Deletes all documents locally only. Automatically disconnects sync first to prevent deletions from propagating to remote. Use this when you want to clear local data only.
// Clear all local data without affecting remote
await store.deleteAllLocal();await store.deleteAllAndSync()
Deletes all documents locally AND propagates deletions to remote. Waits for sync to complete before returning. Throws an error if sync is not connected.
// Connect to remote first
await store.connectRemote({ url: 'http://localhost:5984/mydb' });
// Delete everything locally and remotely
await store.deleteAllAndSync();Note: Call connectRemote() first, or use deleteAllLocal() instead.
await store.getAll(table?)
Gets all documents, optionally filtered by table.
const allExpenses = await store.getAll('expenses');
const allDocs = await store.getAll();await store.connectRemote(options)
Connects to a remote CouchDB server for sync.
interface RemoteOptions {
url: string; // CouchDB URL
live?: boolean; // Continuous sync (default: true)
retry?: boolean; // Auto-retry on failure (default: true)
}store.disconnectRemote()
Disconnects from remote sync.
await store.syncNow()
Triggers an immediate one-time sync with the remote. Useful for controlling sync timing, especially with rate-limited services like IBM Cloudant's free tier.
// Connect with continuous sync disabled
await store.connectRemote({
url: 'http://localhost:5984/mydb',
live: false,
retry: false
});
// Manually trigger sync when needed
await store.syncNow();
// Example: Batch multiple changes then sync
await store.put('expenses', { _id: '1', amount: 10 });
await store.put('expenses', { _id: '2', amount: 20 });
await store.put('expenses', { _id: '3', amount: 30 });
await store.syncNow(); // Sync all changes at onceThrows an error if connectRemote() hasn't been called first.
store.reconnect()
Re-subscribes to changes. Useful after disconnect/reconnect scenarios or if the change feed needs to be restarted.
// Restart the change detection feed
store.reconnect();await store.getConflictInfo(table, id)
Check if a document has conflicts without triggering the callback. Returns ConflictInfo if conflicts exist, or null if none.
const conflictInfo = await store.getConflictInfo('expenses', 'lunch');
if (conflictInfo) {
console.log('Conflict detected!');
console.log('Winner:', conflictInfo.winner);
console.log('Losers:', conflictInfo.losers);
// Handle the conflict
}await store.resolveConflict(table, id, winningDoc)
Manually resolve a conflict by choosing the winning document.
// Option 1: Use in onConflict callback
store.listener.onConflict = async (conflicts) => {
for (const conflict of conflicts) {
// Pick the document with the latest timestamp
const latest = [conflict.winner, ...conflict.losers]
.sort((a, b) => b.timestamp - a.timestamp)[0];
await store.resolveConflict(conflict.table, conflict.id, latest);
}
};
// Option 2: Check manually and resolve
const conflict = await store.getConflictInfo('expenses', 'lunch');
if (conflict) {
await store.resolveConflict('expenses', 'lunch', conflict.winner);
}Conflict Detection
When the same document is edited offline on multiple devices, PouchDB detects conflicts automatically:
interface ConflictInfo {
docId: string; // Full document ID (e.g., "expenses_lunch")
table: string; // Document table (e.g., "expenses")
id: string; // Document ID (e.g., "lunch")
currentRev: string; // Current revision ID
conflictRevs: string[];// Conflicting revision IDs
winner: Doc; // The winning document (current version)
losers: Doc[]; // Conflicting versions
}The onConflict callback gives you both the winner and all conflicting versions, so you can:
- Show a UI for manual resolution
- Auto-resolve based on timestamps
- Merge changes programmatically
- Log conflicts for review
Sync Events
Monitor sync progress with the onSync callback:
interface SyncInfo {
direction: 'push' | 'pull' | 'both';
change: {
docs_read?: number;
docs_written?: number;
doc_write_failures?: number;
errors?: any[];
};
}Example:
const store = new EncryptedStore(db, password, {
onChange: (docs) => console.log('Changed:', docs.length),
onDelete: (docs) => console.log('Deleted:', docs.length),
onSync: (info) => {
if (info.direction === 'push') {
console.log(`Pushed ${info.change.docs_written} docs to server`);
} else {
console.log(`Pulled ${info.change.docs_read} docs from server`);
}
}
});Deployment Options
Free Tier Options
IBM Cloudant - Free tier: 1GB storage, 20 req/sec
// Option 1: Continuous sync (may hit rate limits on initial sync) await store.connectRemote({ url: 'https://username:[email protected]/mydb' }); // Option 2: Manual sync control (recommended for rate-limited services) await store.connectRemote({ url: 'https://username:[email protected]/mydb', live: false, retry: false }); // Trigger sync manually when needed await store.syncNow();Oracle Cloud Free Tier - Run your own CouchDB
# On Oracle VM docker run -d -p 5984:5984 -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password couchdbSelf-hosted - CouchDB on any VPS ($5/month)
await store.connectRemote({ url: 'http://admin:[email protected]:5984/mydb' });
Backup Strategy
Example using Oracle Free Tier + S3:
# Daily backup script
#!/bin/bash
TODAY=$(date +%Y-%m-%d)
curl -X GET http://admin:password@localhost:5984/mydb/_all_docs?include_docs=true > backup-$TODAY.json
aws s3 cp backup-$TODAY.json s3://my-backups/couchdb/Example: React Integration
import { useState, useEffect } from 'react';
import PouchDBModule from 'pouchdb-browser';
const PouchDB = PouchDBModule.default || PouchDBModule;
import { EncryptedStore } from '@mrbelloc/encrypted-store';
function useEncryptedStore(dbName: string, password: string) {
const [expenses, setExpenses] = useState<Map<string, any>>(new Map());
const [store, setStore] = useState<EncryptedStore | null>(null);
useEffect(() => {
const db = new PouchDB(dbName);
const encryptedStore = new EncryptedStore(db, password, {
onChange: (docs) => {
setExpenses((prev) => {
const next = new Map(prev);
docs.forEach((doc) => {
if (doc._table === 'expenses') {
next.set(doc._id, doc);
}
});
return next;
});
},
onDelete: (docs) => {
setExpenses((prev) => {
const next = new Map(prev);
docs.forEach((doc) => {
if (doc._table === 'expenses') {
next.delete(doc._id);
}
});
return next;
});
},
onConflict: (conflicts) => {
// Auto-resolve: pick latest by timestamp
conflicts.forEach(async (conflict) => {
const latest = [conflict.winner, ...conflict.losers]
.sort((a, b) => b.timestamp - a.timestamp)[0];
await encryptedStore.resolveConflict(conflict.table, conflict.id, latest);
});
}
});
encryptedStore.loadAll();
setStore(encryptedStore);
return () => {
encryptedStore.disconnectRemote();
};
}, [dbName, password]);
return { expenses: Array.from(expenses.values()), store };
}
function App() {
const { expenses, store } = useEncryptedStore('myapp', 'my-password');
const addExpense = async () => {
await store?.put('expenses', {
_id: crypto.randomUUID(),
amount: 25,
description: 'Coffee',
timestamp: Date.now()
});
};
return (
<div>
<button onClick={addExpense}>Add Expense</button>
<ul>
{expenses.map((exp) => (
<li key={exp._id}>{exp.description}: ${exp.amount}</li>
))}
</ul>
</div>
);
}TypeScript Types
interface Doc {
_id: string;
_table: string;
[key: string]: any;
}
interface ConflictInfo {
docId: string;
table: string;
id: string;
currentRev: string;
conflictRevs: string[];
winner: Doc;
losers: Doc[];
}
interface SyncInfo {
direction: 'push' | 'pull' | 'both';
change: {
docs_read?: number;
docs_written?: number;
doc_write_failures?: number;
errors?: any[];
};
}
interface DecryptionErrorEvent {
docId: string;
error: Error;
rawDoc: any;
}
interface RemoteOptions {
url: string;
live?: boolean;
retry?: boolean;
}
interface EncryptedStoreOptions {
passphraseMode?: "derive" | "raw";
}How It Works
- Encryption: Documents are encrypted with AES-256-GCM before storage
- Storage: Encrypted data stored in PouchDB (IndexedDB in browser, LevelDB in Node)
- Change Detection: PouchDB's changes feed notifies of all changes
- Conflict Detection: PouchDB's MVCC detects conflicts automatically
- Sync: Bi-directional sync with CouchDB using PouchDB replication
- Events: Callbacks notify your app of changes, conflicts, and sync progress
Browser vs Node.js
Browser (Vite/Webpack)
- Use
pouchdb-browser@^8.0.1- includes IndexedDB adapter - Smaller bundle size
- Works with Vite, Webpack, etc.
- Vite users:
- Add
define: { global: 'globalThis' }to vite.config.ts (PouchDB v8 requirement) - Install
eventspackage:npm install events(fixes "Class extends" errors)
- Add
- Import: Use
const PouchDB = PouchDBModule.default || PouchDBModulefor better compatibility
Node.js
- Use
pouchdb- includes LevelDB adapter - For CLI tools, servers, etc.
Security Notes
- Encryption happens client-side before any data leaves the device
- Remote servers only see encrypted blobs
- Password is never transmitted or stored
- Use a strong password (consider using a key derivation function like PBKDF2)
License
MIT
Why PouchDB?
- Mature: 10+ years of production use
- Reliable: Battle-tested conflict resolution
- Compatible: Works with any CouchDB server
- Offline-first: Built for unreliable networks
- Simple: Easy to understand replication model
- Free: No vendor lock-in, self-hostable
