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: 'tolerant', resolution: 9, }) } 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) this.index.add(note.id, note.title + '\n' + note.content) } catch (error) { console.error('Failed to decrypt note:', error) } } } /* ----------------------- 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 = '') { 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, } const encryptedNote = { id: note.id, data: this._encrypt(note), } this.notesCache.set(id, note) this.index.add(id, note.title + '\n' + content) 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) { const note = this.notesCache.get(id) if (!note) throw new Error('Note not found') note.content = content note.updatedAt = new Date().toISOString() const encryptedNote = { id: note.id, data: this._encrypt(note), } this.index.update(id, note.title + '\n' + content) 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.content) await this.adapter.update(encryptedNote) return note } search(query) { const ids = this.index.search(query) return ids.map((id) => this.notesCache.get(id)) } }