/**
* 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