import { Index } from 'flexsearch' import crypto from 'crypto' import sodium from 'libsodium-wrappers' export default class NotesAPI { constructor(adapter, encryptionKey = null) { if (!adapter) { throw new Error('NotesAPI requires a storage adapter') } this.adapter = adapter this.notesCache = new Map() this.encryptionKey = encryptionKey || process.env.NOTES_ENCRYPTION_KEY this._sodiumReady = false this.index = new Index({ tokenize: 'forward', }) } async _initSodium() { if (!this._sodiumReady) { await sodium.ready this._sodiumReady = true } } _encrypt(note) { if (!this.encryptionKey) { throw new Error('Encryption key not set') } const key = Buffer.from(this.encryptionKey, 'hex') if (key.length !== 32) { throw new Error( 'Encryption key must be 64 hex characters (32 bytes)', ) } const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) const message = JSON.stringify(note) const ciphertext = sodium.crypto_secretbox_easy( Buffer.from(message), nonce, key, ) const combined = Buffer.concat([nonce, ciphertext]) return combined.toString('base64') } _decrypt(encryptedData) { if (!this.encryptionKey) { throw new Error('Encryption key not set') } const key = Buffer.from(this.encryptionKey, 'hex') if (key.length !== 32) { throw new Error( 'Encryption key must be 64 hex characters (32 bytes)', ) } let combined try { combined = Buffer.from(encryptedData, 'base64') } catch (e) { throw new Error('Invalid encrypted data: not valid base64') } if ( combined.length < sodium.crypto_secretbox_NONCEBYTES + sodium.crypto_secretbox_MACBYTES ) { throw new Error('Invalid encrypted data: too short') } const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES) const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES) let decrypted try { decrypted = sodium.crypto_secretbox_open_easy( ciphertext, nonce, key, ) } catch (e) { throw new Error('Decryption failed: wrong key or corrupted data') } if (!decrypted) { throw new Error('Decryption failed: no data returned') } const decryptedStr = Buffer.from(decrypted).toString('utf8') try { return JSON.parse(decryptedStr) } catch (e) { throw new Error( `Decryption succeeded but invalid JSON: ${decryptedStr}`, ) } } async init() { await this._initSodium() await this.adapter.init() const encryptedNotes = await this.adapter.getAll() for (const encryptedNote of encryptedNotes) { try { const note = this._decrypt(encryptedNote.data || encryptedNote) this.notesCache.set(note.id, note) const searchText = note.plainText || this._extractPlainText(note.content) this.index.add(note.id, note.title + '\n' + searchText) } catch (error) { console.error('Failed to decrypt note:', error) } } } _extractPlainText(content) { if (!content) return '' if (typeof content === 'string') return content const extractText = (node) => { if (typeof node === 'string') return node if (!node || !node.content) return '' return node.content.map(extractText).join(' ') } return extractText(content) } /* ----------------------- Public API ------------------------*/ getCategories() { const categories = new Set() for (const note of this.notesCache.values()) { if (note.category) { categories.add(note.category) } } return Array.from(categories).sort() } getCategoryNotes(categoryName) { return Array.from(this.notesCache.values()) .filter((n) => n.category === categoryName) .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)) } getNote(id) { return this.notesCache.get(id) ?? null } async createNote(metadata = {}, content = '', plainText = '') { const id = crypto.randomUUID() const now = new Date().toISOString() const note = { id, title: metadata.title || 'Untitled', category: metadata.category || null, createdAt: now, updatedAt: now, content, plainText, } const encryptedNote = { id: note.id, data: this._encrypt(note), } this.notesCache.set(id, note) this.index.add(id, note.title + '\n' + plainText) await this.adapter.create(encryptedNote) return note } async deleteNote(id) { await this.adapter.delete(id) this.notesCache.delete(id) this.index.remove(id) } async updateNote(id, content, plainText = '') { const note = this.notesCache.get(id) if (!note) throw new Error('Note not found') note.content = content note.plainText = plainText note.updatedAt = new Date().toISOString() const encryptedNote = { id: note.id, data: this._encrypt(note), } this.index.update(id, note.title + '\n' + plainText) await this.adapter.update(encryptedNote) return note } async updateNoteMetadata(id, updates = {}) { const note = this.notesCache.get(id) if (!note) throw new Error('Note not found') const allowedFields = ['title', 'category'] for (const key of Object.keys(updates)) { if (!allowedFields.includes(key)) { throw new Error(`Invalid metadata field: ${key}`) } } if (updates.title !== undefined) { note.title = updates.title } if (updates.category !== undefined) { note.category = updates.category } note.updatedAt = new Date().toISOString() const encryptedNote = { id: note.id, data: this._encrypt(note), } this.index.update( id, note.title + '\n' + (note.plainText || this._extractPlainText(note.content)), ) await this.adapter.update(encryptedNote) return note } search(query) { const ids = this.index.search(query, { limit: 50, suggest: true, }) return ids.map((id) => this.notesCache.get(id)) } }