OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / routes / sharing.js

231 lines · javascript ref:
/**
 * 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