/**
* Sharing + Encryption Routes
*
* Registers Phase 3 HTTP endpoints for notebook sharing and encryption.
*/
import { json, readJson } from '../response.js'
import { ValidationError, NotFoundError } from '../errors.js'
import { requireAuth } from './auth-helpers.js'
const SHARE_PREFIX = 'share:'
/**
* Register sharing and encryption routes
*/
export function registerSharingRoutes (router, server) {
const nm = server.noteManager
const crypto = server.notebookCrypto
// ==================== ENCRYPTION ====================
// Enable encryption on a notebook
router.post('/api/notebooks/:notebook/encrypt', async (req, res) => {
await requireAuth(req, server, 'admin', req.params.notebook)
const body = await readJson(req)
if (!body.password) {
throw new ValidationError('Password is required', 'password')
}
if (!crypto) {
throw new Error('Encryption not configured on server')
}
const slug = req.params.notebook
const meta = await nm.getNotebook(slug)
if (!meta) throw new NotFoundError(`Notebook '${slug}' not found`, 'notebook')
if (meta.encrypted) {
json(res, { error: 'Notebook is already encrypted' }, 400)
return
}
// Enable encryption — generates notebook key
const notebookKey = await crypto.enableEncryption(slug, body.password)
// Cache the key for immediate use
nm.cacheKey(slug, notebookKey)
// Update notebook metadata
await nm.updateNotebook(slug, { encrypted: true })
// Re-encrypt all existing notes
const notes = await nm.listNotes(slug) // Returns plaintext (key now cached)
const drive = await server.driveManager.getDrive(slug)
for (const note of notes) {
const encrypted = nm._encryptNote(note, slug)
await drive.put(
`/notes/${note.id}.json`,
Buffer.from(JSON.stringify(encrypted, null, 2))
)
}
json(res, { encrypted: true, slug, notesEncrypted: notes.length })
})
// Disable encryption on a notebook
router.post('/api/notebooks/:notebook/decrypt', async (req, res) => {
await requireAuth(req, server, 'admin', req.params.notebook)
const body = await readJson(req)
if (!body.password) {
throw new ValidationError('Password is required', 'password')
}
if (!crypto) {
throw new Error('Encryption not configured on server')
}
const slug = req.params.notebook
const meta = await nm.getNotebook(slug)
if (!meta) throw new NotFoundError(`Notebook '${slug}' not found`, 'notebook')
if (!meta.encrypted) {
json(res, { error: 'Notebook is not encrypted' }, 400)
return
}
// Ensure key is cached for decrypting notes
if (!nm.isUnlocked(slug)) {
await nm.unlockNotebook(slug, body.password)
}
// Decrypt all notes and re-write as plaintext
const notes = await nm.listNotes(slug)
const drive = await server.driveManager.getDrive(slug)
for (const note of notes) {
// note is already decrypted by listNotes, write back without encryption
delete note.encrypted
await drive.put(
`/notes/${note.id}.json`,
Buffer.from(JSON.stringify(note, null, 2))
)
}
// Remove encryption key from registry
await crypto.disableEncryption(slug, body.password)
// Update metadata
await nm.updateNotebook(slug, { encrypted: false })
// Clear cached key
nm.lockNotebook(slug)
json(res, { encrypted: false, slug, notesDecrypted: notes.length })
})
// Unlock encrypted notebook for session
router.post('/api/notebooks/:notebook/unlock', async (req, res) => {
await requireAuth(req, server, 'read', req.params.notebook)
const body = await readJson(req)
if (!body.password) {
throw new ValidationError('Password is required', 'password')
}
if (!crypto) {
throw new Error('Encryption not configured on server')
}
const slug = req.params.notebook
await nm.unlockNotebook(slug, body.password)
json(res, { unlocked: true, slug })
})
// ==================== SHARING ====================
// Create share token for a notebook
router.post('/api/notebooks/:notebook/share', async (req, res) => {
const auth = await requireAuth(req, server, 'admin', req.params.notebook)
const body = await readJson(req)
const slug = req.params.notebook
const meta = await nm.getNotebook(slug)
if (!meta) throw new NotFoundError(`Notebook '${slug}' not found`, 'notebook')
const permissions = body.permissions || ['read']
const validPerms = ['read', 'write']
for (const p of permissions) {
if (!validPerms.includes(p)) {
throw new ValidationError(`Invalid permission: ${p}`, 'permissions')
}
}
const label = body.label || ''
const username = auth.username || auth.userId || 'admin'
// Create token scoped to this notebook
const tokenResult = await server.authManager.createToken({
username: `share:${username}`,
description: `Share: ${slug} (${label || permissions.join(', ')})`,
permissions,
notebooks: [slug],
expiresAt: body.expiresAt || null
})
// Store share record in registry
const shareKey = `${SHARE_PREFIX}${slug}:${tokenResult.tokenHash}`
await server.registry.put(shareKey, {
notebook: slug,
tokenHash: tokenResult.tokenHash,
permissions,
sharedBy: username,
sharedAt: Date.now(),
label
})
json(res, {
token: tokenResult.token,
tokenHash: tokenResult.tokenHash,
notebook: slug,
permissions,
label
}, 201)
})
// List shares for a notebook
router.get('/api/notebooks/:notebook/shares', async (req, res) => {
await requireAuth(req, server, 'admin', req.params.notebook)
const slug = req.params.notebook
const meta = await nm.getNotebook(slug)
if (!meta) throw new NotFoundError(`Notebook '${slug}' not found`, 'notebook')
const prefix = `${SHARE_PREFIX}${slug}:`
const shares = []
for await (const entry of server.registry.createReadStream({
gt: prefix,
lt: prefix + '\xff'
})) {
shares.push(entry.value)
}
json(res, shares)
})
// Revoke a share
router.delete('/api/notebooks/:notebook/shares/:tokenHash', async (req, res) => {
await requireAuth(req, server, 'admin', req.params.notebook)
const slug = req.params.notebook
const tokenHash = req.params.tokenHash
const shareKey = `${SHARE_PREFIX}${slug}:${tokenHash}`
const entry = await server.registry.get(shareKey)
if (!entry) throw new NotFoundError('Share not found', 'share')
// Revoke the bearer token
await server.authManager.revokeToken(tokenHash)
// Remove share record
await server.registry.del(shareKey)
json(res, { revoked: true, tokenHash, notebook: slug })
})
}
export default registerSharingRoutes