OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / routes / multiwriter.js

240 lines · javascript ref:
/**
 * Multi-writer Routes
 *
 * API endpoints for instance linking and notebook sharing via Autobase.
 *
 * - GET  /api/multiwriter/status — current multi-writer state
 * - POST /api/multiwriter/link — create link invite
 * - POST /api/multiwriter/join — join with invite
 * - POST /api/notebooks/:notebook/share — toggle sharing on a notebook
 * - GET  /api/notebooks/:notebook/shared-notes — list notes from shared Autobase
 * - POST /api/notebooks/:notebook/shared-notes — create note in shared notebook
 * - POST /api/notebooks/:notebook/shared-notes/:noteId/yjs — apply Yjs update
 * - GET  /api/notebooks/:notebook/shared-notes/:noteId/content — get note content
 */

import { json, error, readJson } from '../response.js'
import { UnauthorizedError, ForbiddenError } from '../errors.js'
import { requireAuth } from './auth-helpers.js'

/**
 * Deny a shared-notes read when the slug also has a regular notebook entry that
 * is private and the caller isn't authorized to read it. Pure shared notebooks
 * (no regular metadata) stay readable — they have no visibility model. Sends a
 * not-found response and returns true when access is denied.
 */
export async function denyPrivateSharedRead (req, res, server, slug) {
  const meta = await server.noteManager.getNotebook(slug)
  if (meta && meta.visibility === 'private') {
    const { authorized } = await server.authManager.authorizeRequest(req, 'read', slug)
    if (!authorized) {
      json(res, { error: 'Notebook not shared', code: 'NOT_FOUND', status: 404 }, 404)
      return true
    }
  }
  return false
}

/**
 * Register all multi-writer routes
 */
export function registerMultiwriterRoutes (router, server) {

  // ==================== INSTANCE LINKING ====================

  // Get multi-writer status (admin only)
  router.get('/api/multiwriter/status', async (req, res) => {
    await requireAuth(req, server, 'admin')
    const status = await server.multiwriter.getStatus()
    json(res, status)
  })

  // Create a link invite (admin only)
  router.post('/api/multiwriter/link', async (req, res) => {
    await requireAuth(req, server, 'admin')
    try {
      console.log('[Multiwriter] Creating link invite...')
      const { inviteHex, waitForPeer } = await server.multiwriter.createLinkInvite()
      // Return invite immediately, persist config + start mirror sync when peer joins
      waitForPeer().then(() => {
        console.log('[Multiwriter] Peer connected via invite')
        server._persistMultiwriterConfig()
        server.multiwriter.startMirrorSync()
      }).catch((err) => {
        console.error('[Multiwriter] Invite wait failed:', err.message)
      })
      json(res, { invite: inviteHex })
    } catch (err) {
      console.error('[Multiwriter] Create invite failed:', err.message)
      error(res, 'Failed to create invite: ' + err.message, 500)
    }
  })

  // Join with an invite (admin only)
  router.post('/api/multiwriter/join', async (req, res) => {
    await requireAuth(req, server, 'admin')
    const body = await readJson(req)
    if (!body || !body.invite) {
      error(res, 'Missing invite', 400)
      return
    }
    try {
      console.log('[Multiwriter] Joining with invite...')
      const result = await server.multiwriter.joinWithInvite(body.invite)
      console.log('[Multiwriter] Join complete, bootstrap:', result.bootstrapKey ? result.bootstrapKey.substring(0, 16) + '...' : 'none')
      // Wire shared registry into auth for mirror fallback
      server.authManager.sharedRegistry = server.multiwriter.registry
      await server._persistMultiwriterConfig()
      // Start mirror sync after linking
      server.multiwriter.startMirrorSync()
      json(res, {
        success: true,
        bootstrapKey: result.bootstrapKey,
        localWriterKey: result.localWriterKey
      })
    } catch (err) {
      console.error('[Multiwriter] Join failed:', err.message, err.stack)
      error(res, 'Join failed: ' + err.message, 500)
    }
  })

  // Add a remote writer to the shared registry
  // Called by joining instance via HTTP callback with one-time token, or by admin
  router.post('/api/multiwriter/add-writer', async (req, res) => {
    const body = await readJson(req)
    if (!body || !body.writerKey) {
      error(res, 'Missing writerKey', 400)
      return
    }

    // Inter-instance admission now rides the authenticated blind-pairing
    // handshake (see InstanceLinker — the joiner's stable writer key is admitted
    // during pairing). This HTTP endpoint is ADMIN-ONLY; the old unauthenticated
    // callback-token path (a bearer token that admitted ANY writerKey) is gone.
    try {
      await requireAuth(req, server, 'admin')
      await server.multiwriter.addWriter(body.writerKey)
      json(res, { success: true, writerKey: body.writerKey })
    } catch (err) {
      if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
        error(res, err.message, err instanceof UnauthorizedError ? 401 : 403)
      } else {
        error(res, err.message, 500)
      }
    }
  })

  // Trigger manual mirror sync (admin only)
  router.post('/api/multiwriter/sync', async (req, res) => {
    await requireAuth(req, server, 'admin')
    await server.multiwriter.syncMirror()
    const status = await server.multiwriter.getStatus()
    json(res, { synced: true, ...status })
  })

  // Start/stop mirror sync (admin only)
  router.post('/api/multiwriter/mirror', async (req, res) => {
    await requireAuth(req, server, 'admin')
    const body = await readJson(req)
    if (body && body.enabled) {
      server.multiwriter.startMirrorSync()
      json(res, { mirror: true, message: 'Mirror sync started' })
    } else {
      server.multiwriter.stopMirrorSync()
      json(res, { mirror: false, message: 'Mirror sync stopped' })
    }
  })

  // ==================== NOTEBOOK SHARING ====================

  // Toggle sharing on a notebook (admin only)
  router.post('/api/notebooks/:notebook/share', async (req, res) => {
    await requireAuth(req, server, 'admin', req.params.notebook)
    const body = await readJson(req)
    const slug = req.params.notebook

    if (body.shared) {
      const nb = await server.multiwriter.shareNotebook(slug)
      await server._persistMultiwriterConfig()
      json(res, { shared: true, autobaseKey: nb.bootstrapKey.toString('hex') })
    } else {
      await server.multiwriter.unshareNotebook(slug)
      await server._persistMultiwriterConfig()
      json(res, { shared: false })
    }
  })

  // ==================== SHARED NOTES ====================

  // List notes from shared Autobase (public read; private notebooks gated)
  router.get('/api/notebooks/:notebook/shared-notes', async (req, res) => {
    const slug = req.params.notebook
    if (await denyPrivateSharedRead(req, res, server, slug)) return
    const nb = server.multiwriter.getSharedNotebook(slug)
    if (!nb) {
      json(res, { error: 'Notebook not shared', code: 'NOT_FOUND', status: 404 }, 404)
      return
    }
    const notes = await nb.listNotes()
    json(res, { notes })
  })

  // Create note in shared notebook (write auth)
  router.post('/api/notebooks/:notebook/shared-notes', async (req, res) => {
    const auth = await requireAuth(req, server, 'write', req.params.notebook)
    const slug = req.params.notebook
    const nb = server.multiwriter.getSharedNotebook(slug)
    if (!nb) {
      json(res, { error: 'Notebook not shared', code: 'NOT_FOUND', status: 404 }, 404)
      return
    }

    const body = await readJson(req)
    const note = await nb.createNote({
      title: body.title,
      author: auth.userId || auth.identityId,
      tags: body.tags
    })
    json(res, note, 201)
  })

  // Apply Yjs update to a shared note (write auth)
  router.post('/api/notebooks/:notebook/shared-notes/:noteId/yjs', async (req, res) => {
    const auth = await requireAuth(req, server, 'write', req.params.notebook)
    const slug = req.params.notebook
    const nb = server.multiwriter.getSharedNotebook(slug)
    if (!nb) {
      json(res, { error: 'Notebook not shared', code: 'NOT_FOUND', status: 404 }, 404)
      return
    }

    const body = await readJson(req)
    if (!body || !body.update) {
      error(res, 'Missing update', 400)
      return
    }

    const update = Buffer.from(body.update, 'base64')
    await nb.applyYjsUpdate(req.params.noteId, new Uint8Array(update), auth.userId || auth.identityId)
    json(res, { ok: true })
  })

  // Get shared note content (public read; private notebooks gated)
  router.get('/api/notebooks/:notebook/shared-notes/:noteId/content', async (req, res) => {
    const slug = req.params.notebook
    if (await denyPrivateSharedRead(req, res, server, slug)) return
    const nb = server.multiwriter.getSharedNotebook(slug)
    if (!nb) {
      json(res, { error: 'Notebook not shared', code: 'NOT_FOUND', status: 404 }, 404)
      return
    }

    const doc = await nb.reconstructDoc(req.params.noteId)
    const content = doc.getText('content').toString()
    const note = await nb.getNote(req.params.noteId)
    json(res, { ...note, content })
  })
}

export default registerMultiwriterRoutes