OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / hypernote-server.js

1208 lines · javascript ref:
#!/usr/bin/env bare

/**
 * Hypernote Server
 *
 * P2P note-taking server with:
 * - Corestore + Hyperbee registry
 * - Per-notebook Hyperdrives (DriveManager)
 * - Hyperswarm P2P replication
 * - Identity + auth system
 * - HTTP API
 *
 * Phase 1: Identity and storage
 * Phase 2: Notebook and note CRUD
 * Phase 3: Encryption and sharing
 * Phase 4: Interlinking and advanced operations
 * Phase 5: Web UI
 * Phase 6: P2P replication (per-notebook drives + Hyperswarm)
 */

await import('bare-node-runtime/global')

import http from 'bare-http1'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import subprocess from 'bare-subprocess'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import Hyperdrive from 'hyperdrive'

import { AuthManager } from './lib/auth.js'
import { StorageLocator } from './lib/storage-locator.js'
import { IdentityManager } from './lib/identity/identity-manager.js'
import { DriveManager } from './lib/drive-manager.js'
import { ReplicationManager } from './lib/replication.js'
import { NoteManager } from './lib/note-manager.js'
import { NotebookCrypto } from './lib/notebook-crypto.js'
import { LinkManager } from './lib/link-manager.js'
import { SitePublisher } from './lib/site-publisher.js'
import { MediaStore, parseRange } from './lib/media-store.js'
import { VideoProcessor } from './lib/media/video-processor.js'
import { MultiwriterManager } from './lib/multiwriter/manager.js'
import { Router } from './lib/routes/index.js'
import { registerNotebookRoutes } from './lib/routes/notebooks.js'
import { registerSharingRoutes } from './lib/routes/sharing.js'
import { registerLinkRoutes } from './lib/routes/links.js'
import { registerAuthRoutes } from './lib/routes/auth.js'
import { registerWebRoutes, registerPublicRoutes } from './lib/routes/web.js'
import { registerSyncRoutes } from './lib/routes/sync.js'
import { registerMultiwriterRoutes } from './lib/routes/multiwriter.js'
import { json, notFound } from './lib/response.js'

// Log version on startup for OTA verification
let _appVersion = '0.0.0'
{
  const _appDir = path.dirname(new URL(import.meta.url).pathname)
  try {
    const _pkg = JSON.parse(fs.readFileSync(path.join(_appDir, 'package.json'), 'utf-8'))
    _appVersion = _pkg.version || _appVersion
    console.log(`[Hypernote] v${_appVersion} (${_appDir})`)
  } catch {}
}

const DEBUG = typeof process !== 'undefined' && (process.env?.DEBUG === '1' || process.env?.DEBUG === 'true')

// Catch unhandled rejections to prevent silent crashes
process.on('uncaughtException', (err) => {
  console.error('[FATAL] Uncaught exception:', err.message, err.stack)
})
process.on('unhandledRejection', (reason) => {
  console.error('[WARN] Unhandled rejection:', reason && reason.message ? reason.message : reason)
})

// Background OTA via pgh — content-addressed store, NO Hyperdrive/blobs.
// `pgh app update <name>` resolves the latest release from package.json's
// pgh.upgrade, fetches any new tree closure into the content store, and
// ATOMICALLY materializes it into the install dir (scratch build + swap),
// safe to run while the server is live; the next start picks up the new tree.
// Replaces the former pear-runtime Hyperdrive updater, which kept an unbounded
// append-only blob corestore under ~/.config/hypernote/pear-runtime.
const isPear = typeof Pear !== 'undefined'
if (!isPear && !process.argv.includes('--no-updates')) {
  try {
    const appDir = path.dirname(new URL(import.meta.url).pathname)
    const pkg = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf-8'))
    const upgrade = pkg.pgh && pkg.pgh.upgrade
    if (upgrade) {
      const appName = (pkg.pgh && pkg.pgh.bin) || pkg.name || 'hypernote'
      // Resolve the pgh CLI: prefer ~/.local/bin/pgh, else the store-installed
      // bare runtime + entry. Bail if pgh isn't on this machine.
      let argv = null
      const launcher = path.join(os.homedir(), '.local', 'bin', 'pgh')
      if (fs.existsSync(launcher)) argv = [launcher]
      else {
        const storeRoot = process.env.PGH_STORE_ROOT || path.join(os.homedir(), '.local', 'share', 'store')
        const bareBin = path.join(storeRoot, 'apps', 'pgh', 'runtime', 'bare')
        const entry = path.join(storeRoot, 'apps', 'pgh', 'bin', 'pgh.bare.js')
        if (fs.existsSync(bareBin) && fs.existsSync(entry)) argv = [bareBin, entry]
      }
      if (argv) {
        // Detached + best-effort: never blocks the server. pgh's atomic
        // materialize leaves the live install intact if interrupted.
        const proc = subprocess.spawn(argv[0], [...argv.slice(1), 'app', 'update', appName], {
          stdio: 'ignore',
          detached: true
        })
        proc.on('error', () => {})
        if (proc.unref) proc.unref()
      }
    }
  } catch {}
}

const rawArgs = process.argv.slice(2)
const args = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs

/**
 * Hypernote Server - Main class
 */
class HypernoteServer {
  constructor(options = {}) {
    this.port = options.port || 4040
    this.host = options.host || 'localhost'
    this.pathPrefix = options.pathPrefix || ''
    this.maxMediaSize = options.maxMediaSize || 50 * 1024 * 1024 // 50MB

    // Storage — priority: explicit option > locator breadcrumb > default
    const locatorPath = StorageLocator.read()
    this.storage = options.storage || locatorPath || HypernoteServer.resolveDefaultStorage()

    this.store = new Corestore(this.storage)

    // Registry (Hyperbee for identity, tokens, metadata, drive index)
    this.registryCore = this.store.get({ name: 'registry' })
    this.registry = new Hyperbee(this.registryCore, {
      keyEncoding: 'utf-8',
      valueEncoding: 'json'
    })

    // DriveManager (per-notebook Hyperdrives)
    this.driveManager = new DriveManager(this.store, this.registry)

    // MediaStore (content-addressed blob storage)
    this.mediaStore = new MediaStore(this.store)

    // VideoProcessor (HLS transcoding, poster extraction)
    this.videoProcessor = new VideoProcessor(this.mediaStore)

    // ReplicationManager (Hyperswarm P2P)
    this.replicationManager = new ReplicationManager(this.store, this.driveManager)

    // Auth
    this.authManager = new AuthManager({
      registry: this.registry,
      trustedProxies: options.trustedProxies
    })

    // Identity manager
    this.identityManager = new IdentityManager({
      storagePath: this.storage,
      identityFile: path.join(this.storage, 'server-identity.json')
    })

    // Notebook encryption
    this.notebookCrypto = new NotebookCrypto(this.registry)

    // Note manager (with encryption support, uses DriveManager)
    this.noteManager = new NoteManager(this.driveManager, { crypto: this.notebookCrypto })

    // Link manager (wiki-links, backlinks, graph)
    this.linkManager = new LinkManager(this.registry, this.noteManager)
    this.noteManager.linkManager = this.linkManager

    // Site publisher (hyper:// access for Agregore)
    this.sitePublisher = new SitePublisher(this.store, this.noteManager, this.driveManager)

    // Multi-writer (initialized in ready())
    this.multiwriter = null

    // Router
    this.router = new Router(this)
    this.setupRoutes()
  }

  /**
   * Resolve the default storage path.
   */
  static resolveDefaultStorage () {
    return path.join(os.homedir(), '.config', 'hypernote', 'server-data')
  }

  /**
   * Get LAN-reachable hostname for inter-instance callbacks.
   * Uses mDNS hostname.local since bare-os lacks networkInterfaces().
   */
  static _getLanAddress () {
    try {
      return os.hostname() + '.local'
    } catch {}
    return null
  }

  /**
   * Setup routes
   */
  setupRoutes() {
    // Phase 5: Web UI (serves / and /web/*)
    registerWebRoutes(this.router, this)

    // Phase 7: Auth routes (identity, challenge, profile, username)
    registerAuthRoutes(this.router, this)

    this.router.get('/api/health', async (req, res) => {
      json(res, {
        name: 'hypernote',
        version: _appVersion,
        description: 'P2P note-taking server'
      })
    })

    this.router.get('/api/status', async (req, res) => {
      const identity = this.identityManager.status()
      const syncStatus = this.replicationManager.getStatus()
      json(res, {
        name: 'hypernote',
        version: _appVersion,
        storage: this.storage,
        identity: {
          hasKeypair: identity.hasKeypair,
          hasVault: identity.hasVault,
          hasProof: identity.hasProof,
          devicePublicKey: identity.devicePublicKey
        },
        sync: syncStatus,
        siteKey: this.sitePublisher.key || null
      })
    })

    // Site drive info (hyper:// access URL)
    this.router.get('/api/site', async (req, res) => {
      const key = this.sitePublisher.key
      json(res, {
        siteKey: key,
        hyperUrl: key ? 'hyper://' + key + '/' : null
      })
    })

    // Force republish site drive (admin only)
    this.router.post('/api/site/publish', async (req, res) => {
      const auth = await this.authManager.authenticate(req)
      if (!auth || !auth.permissions || !auth.permissions.admin) {
        json(res, { error: 'Admin permission required' }, 403)
        return
      }
      await this.sitePublisher.publish()
      json(res, { ok: true, siteKey: this.sitePublisher.key })
    })

    this.router.get('/api/whoami', async (req, res, server) => {
      const auth = await server.authManager.authenticate(req, { skipRateLimit: true })
      if (!auth) {
        json(res, { authenticated: false })
        return
      }

      const result = {
        authenticated: true,
        userId: auth.userId,
        method: auth.method,
        username: auth.username || (auth.user && auth.user.username) || null,
        permissions: auth.permissions
      }

      // Include diagnostic identity chain info (admin only)
      if (auth.permissions && auth.permissions.admin) {
        const diag = {}
        try {
          const pubkey = req.headers['x-wirecore-publickey']
          if (pubkey) {
            diag.browserPubkey = pubkey
            const pubkeyEntry = await server.authManager.identityRegistry.db.get(`pubkey:${pubkey}`)
            diag.pubkeyLookup = pubkeyEntry ? pubkeyEntry.value : null
            const identEntry = await server.authManager.identityRegistry.db.get(`identity:${pubkey}`)
            diag.identityRecord = identEntry ? { userId: identEntry.value.userId, method: identEntry.value.method } : null
            if (identEntry && identEntry.value.userId) {
              const userEntry = await server.authManager.identityRegistry.db.get(`user:${identEntry.value.userId}`)
              diag.userRecord = userEntry ? { userId: userEntry.value.userId, username: userEntry.value.username, admin: userEntry.value.globalPermissions?.includes('admin') } : 'NOT FOUND'
            }
          }
        } catch {}
        result._diag = diag
      }

      json(res, result)
    })

    // Phase 2: Notebook + Note + Search routes
    registerNotebookRoutes(this.router, this)

    // Phase 3: Encryption + Sharing routes
    registerSharingRoutes(this.router, this)

    // Phase 4: Links, Backlinks, Graph, Merge, Split
    registerLinkRoutes(this.router, this)

    // Phase 6: Sync control routes
    registerSyncRoutes(this.router, this)

    // Phase 8: Multi-writer routes (linking, sharing)
    registerMultiwriterRoutes(this.router, this)

    // Phase 7: Public URL routes (/:username/:book/:note)
    // MUST be last — pattern /:username matches anything
    registerPublicRoutes(this.router, this)

    // Note: Web UI routes (/ and /web/*) registered at top via registerWebRoutes
  }

  /**
   * Initialize storage and identity
   */
  async ready() {
    // Ensure storage directory exists
    await fs.promises.mkdir(this.storage, { recursive: true })

    // Ready corestore + hyperbee
    await this.registry.ready()

    // Ready media store
    await this.mediaStore.ready()

    // Wire isReferencedAnywhere to scan all notebooks
    this.mediaStore.isReferencedAnywhere = async (hash) => {
      return this._isMediaReferenced(hash)
    }

    // Write storage locator breadcrumb
    await StorageLocator.write(this.storage)

    // Run migration from single-drive to per-notebook drives
    await this._migrateToPerNotebookDrives()

    // Run migration from Hyperdrive media to content-addressed MediaStore
    await this._migrateToContentAddressedMedia()

    // Ensure device keypair exists
    const isNew = await this.identityManager.ensureKeypair()
    if (isNew) {
      console.log('[Hypernote] Generated new device keypair')
    }

    // Create default admin token on first start
    const adminResult = await this.authManager.createDefaultAdmin()
    if (adminResult) {
      console.log('[Hypernote] Default admin token:', adminResult.token)
      console.log('[Hypernote] Save this token! It won\'t be shown again.')
    }

    // Ensure default drive structure
    await this._ensureDriveStructure()

    // Initialize multi-writer manager
    const mwConfig = await this.registry.get('multiwriter-config')
    const registryBootstrapKey = mwConfig && mwConfig.value.registryBootstrapKey
      ? Buffer.from(mwConfig.value.registryBootstrapKey, 'hex')
      : null
    const instanceIdentity = this.identityManager.status()
    const instanceHost = this.host === '0.0.0.0'
      ? HypernoteServer._getLanAddress() || 'localhost'
      : this.host
    this.multiwriter = new MultiwriterManager(this.store, {
      registryBootstrapKey,
      swarm: null, // wired after replication starts
      instanceKey: instanceIdentity.devicePublicKey || null,
      instanceUrl: `http://${instanceHost}:${this.port}${this.pathPrefix}`
    })
    this.multiwriter.setManagers({
      driveManager: this.driveManager,
      replicationManager: this.replicationManager,
      identityRegistry: this.authManager.identityRegistry
    })
    await this.multiwriter.ready()
    // Wire shared registry into auth for mirror fallback
    this.authManager.sharedRegistry = this.multiwriter.registry
    this.noteManager.multiwriter = this.multiwriter

    // Wire media-gc events from all existing shared notebooks
    this._wireNotebookGcEvents()

    const mwStatus = await this.multiwriter.getStatus()
    const mwKeyHex = mwStatus.registryKey ? (Buffer.isBuffer(mwStatus.registryKey) ? mwStatus.registryKey.toString('hex') : mwStatus.registryKey) : null
    const localWriterKey = this.multiwriter.registry ? this.multiwriter.registry.localWriterKey : null
    const lwkHex = localWriterKey ? (Buffer.isBuffer(localWriterKey) ? localWriterKey.toString('hex') : localWriterKey) : null
    if (DEBUG) console.log(`[Multiwriter] Registry key: ${mwKeyHex ? mwKeyHex.substring(0, 16) + '...' : 'none'}`)
    if (DEBUG) console.log(`[Multiwriter] Local writer key: ${lwkHex || 'none'}`)
    console.log(`[Multiwriter] Shared notebooks: ${mwStatus.sharedNotebooks}`)

    // Start P2P replication in background (don't block HTTP start)
    this.replicationManager.start().then(async () => {
      await this.replicationManager.announceAll()

      // Wire swarm into multiwriter for discovery
      this.multiwriter._swarm = this.replicationManager.swarm

      // Announce shared registry on swarm for peer discovery
      if (this.multiwriter.registry && this.multiwriter.registry.discoveryKey) {
        this.replicationManager.swarm.join(
          this.multiwriter.registry.discoveryKey,
          { server: true, client: true }
        )
        console.log('[Hypernote] Shared registry announced on swarm')
      }

      // Publish site drive and announce on swarm
      await this.sitePublisher.publish()
      const siteDiscoveryKey = this.sitePublisher.discoveryKey
      if (siteDiscoveryKey) {
        this.replicationManager.swarm.join(siteDiscoveryKey, { server: true, client: true })
        console.log('[Hypernote] Site published: hyper://' + this.sitePublisher.key + '/')
      }

      console.log('[Hypernote] P2P replication started')

      // Deduplicate users sharing same pubkey identities
      await this._deduplicateIdentities()

      // One-time identity repair if needed
      await this._repairIdentity()

      // Process any pending writer keys (from failed callbacks or manual adds)
      await this._processPendingWriters()

      // Start mirror sync if linked to another instance
      if (mwStatus.linked) {
        this.multiwriter.startMirrorSync()
        console.log('[Hypernote] Mirror sync started')
      }
    }).catch(err => {
      console.error('[Hypernote] Replication start failed:', err.message)
    })

    console.log(`[Hypernote] Storage: ${this.storage}`)
  }

  /**
   * Deduplicate users: when two users share the same pubkey identity,
   * merge the duplicate into the one with a username (or older one).
   * This fixes state corrupted by mirror sync importing users whose
   * pubkey already belongs to a local user.
   */
  async _deduplicateIdentities () {
    try {
      const db = this.authManager.identityRegistry.db
      // Build map of pubkey → [userId, ...] from user.identities
      const pubkeyToUsers = new Map()
      const usersById = new Map()

      for await (const entry of db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
        const u = entry.value
        if (!u || !u.userId || u.mergedInto) continue
        usersById.set(u.userId, u)
        if (u.identities) {
          for (const identityId of u.identities) {
            if (!pubkeyToUsers.has(identityId)) pubkeyToUsers.set(identityId, [])
            pubkeyToUsers.get(identityId).push(u.userId)
          }
        }
      }

      // Find duplicates
      for (const [pubkey, userIds] of pubkeyToUsers) {
        if (userIds.length <= 1) continue

        // Pick primary: prefer user with username, then with admin, then oldest
        const users = userIds.map(id => usersById.get(id)).filter(Boolean)
        users.sort((a, b) => {
          if (a.username && !b.username) return -1
          if (!a.username && b.username) return 1
          if (a.globalPermissions?.includes('admin') && !b.globalPermissions?.includes('admin')) return -1
          if (!a.globalPermissions?.includes('admin') && b.globalPermissions?.includes('admin')) return 1
          return (a.createdAt || 0) - (b.createdAt || 0)
        })

        const primary = users[0]
        const duplicates = users.slice(1)

        for (const dup of duplicates) {
          if (DEBUG) console.log(`[Dedup] Merging duplicate user ${dup.userId.substring(0, 16)} into ${primary.userId.substring(0, 16)} (${primary.username || 'no name'}) for pubkey ${pubkey.substring(0, 20)}...`)

          // Merge permissions (union)
          primary.globalPermissions = [...new Set([...primary.globalPermissions, ...dup.globalPermissions])]
          for (const nb of (dup.notebooks || [])) {
            const existing = primary.notebooks?.find(n => n.name === nb.name)
            if (existing) {
              existing.permissions = [...new Set([...existing.permissions, ...nb.permissions])]
            } else {
              primary.notebooks.push(nb)
            }
          }

          // Mark duplicate as merged
          dup.mergedInto = primary.userId
          dup.mergedAt = Date.now()
          dup.identities = []
          dup.globalPermissions = []
          dup.notebooks = []
          await db.put(`user:${dup.userId}`, dup)
        }

        // Fix identity record to point to primary
        const identEntry = await db.get(`identity:${pubkey}`)
        if (identEntry && identEntry.value) {
          identEntry.value.userId = primary.userId
          identEntry.value.updatedAt = Date.now()
          await db.put(`identity:${pubkey}`, identEntry.value)
        }

        // Ensure primary has this identity in its list
        if (!primary.identities.includes(pubkey)) {
          primary.identities.push(pubkey)
        }

        primary.updatedAt = Date.now()
        await db.put(`user:${primary.userId}`, primary)
        if (DEBUG) console.log(`[Dedup] Fixed: identity ${pubkey.substring(0, 20)}... → user ${primary.userId.substring(0, 16)} (${primary.username})`)
      }
    } catch (err) {
      console.error('[Dedup] Failed:', err.message)
    }
  }

  /**
   * One-time identity repair: fix pubkey mappings overwritten by mirror import.
   * Reads {storage}/repair-identity.json, fixes the pubkey->userId mapping, deletes file.
   */
  async _repairIdentity () {
    const repairPath = path.join(this.storage, 'repair-identity.json')
    try {
      const data = fs.readFileSync(repairPath, 'utf-8')
      const repair = JSON.parse(data)
      if (repair.pubkey && repair.correctUserId) {
        // Fix identity to point to correct userId
        const identEntry = await this.authManager.identityRegistry.db.get(`identity:${repair.pubkey}`)
        if (identEntry && identEntry.value) {
          identEntry.value.userId = repair.correctUserId
          await this.authManager.identityRegistry.db.put(`identity:${repair.pubkey}`, identEntry.value)
          if (DEBUG) console.log(`[Repair] Fixed identity ${repair.pubkey.substring(0, 16)}... → user ${repair.correctUserId.substring(0, 16)}`)
        }
        // Fix pubkey reverse lookup
        await this.authManager.identityRegistry.db.put(`pubkey:${repair.pubkey}`, { identityId: repair.pubkey })
        // Delete the bad imported user
        if (repair.deleteUserId) {
          await this.authManager.identityRegistry.db.del(`user:${repair.deleteUserId}`)
          console.log(`[Repair] Deleted imported user ${repair.deleteUserId.substring(0, 16)}`)
        }
      }
      fs.unlinkSync(repairPath)
      console.log('[Repair] Identity repair complete')
    } catch {}
  }

  /**
   * Process pending writer keys from file (for failed callbacks or manual adds).
   * Reads {storage}/pending-writers.json, adds each key, deletes file.
   */
  async _processPendingWriters () {
    const pendingPath = path.join(this.storage, 'pending-writers.json')
    try {
      const data = fs.readFileSync(pendingPath, 'utf-8')
      const keys = JSON.parse(data)
      if (!Array.isArray(keys) || keys.length === 0) return
      for (const key of keys) {
        try {
          await this.multiwriter.registry.addWriter(key)
          console.log(`[Hypernote] Added pending writer: ${key.substring(0, 16)}...`)
        } catch (err) {
          console.error(`[Hypernote] Failed to add pending writer ${key.substring(0, 16)}...:`, err.message)
        }
      }
      fs.unlinkSync(pendingPath)
    } catch {}
  }

  /**
   * Check if a media hash is referenced in any notebook's note content.
   */
  async _isMediaReferenced (hash) {
    const { extractMediaHashes } = await import('./lib/multiwriter/notebook-apply.js')
    const notebooks = await this.noteManager.listNotebooks()
    for (const nb of notebooks) {
      try {
        const notes = await this.noteManager.listNotes(nb.slug)
        for (const note of notes) {
          const hashes = extractMediaHashes(note.content || '')
          if (hashes.has(hash)) return true
        }
      } catch {}
    }
    return false
  }

  /**
   * Wire media-gc event listeners from all current shared notebooks.
   * Also patches MultiwriterManager.shareNotebook to auto-wire future notebooks.
   */
  _wireNotebookGcEvents () {
    if (!this.multiwriter || !this.multiwriter._sharedNotebooks) return

    // Wire existing notebooks
    for (const [slug, snb] of this.multiwriter._sharedNotebooks) {
      this._wireGcForNotebook(slug, snb)
    }

    // Patch shareNotebook to wire GC for newly created notebooks
    const origShareNotebook = this.multiwriter.shareNotebook.bind(this.multiwriter)
    this.multiwriter.shareNotebook = async (slug) => {
      const snb = await origShareNotebook(slug)
      this._wireGcForNotebook(slug, snb)
      return snb
    }
  }

  /**
   * Attach a media-gc listener to a SharedNotebook's Autobase.
   * When a note is deleted or media is removed, schedule orphan cleanup.
   */
  _wireGcForNotebook (slug, snb) {
    if (!snb || !snb._base || !snb._base.on) return
    if (snb._gcWired) return // prevent duplicate listeners
    snb._gcWired = true

    snb._base.on('media-gc', (events) => {
      for (const event of events) {
        if (event.hash && this.mediaStore) {
          this.mediaStore.scheduleOrphan(event.hash)
        }
      }
    })
  }

  /**
   * Migrate from Phase 1-5 single-drive layout to per-notebook drives.
   */
  async _migrateToPerNotebookDrives () {
    const marker = await this.registry.get('migration:v2-drives')
    if (marker) return

    const oldDrive = new Hyperdrive(this.store, { name: 'user-drive' })
    await oldDrive.ready()

    let slugs = []
    try {
      for await (const name of oldDrive.readdir('/notebooks')) {
        slugs.push(name)
      }
    } catch {
      // Fresh install — no old data to migrate
    }

    if (slugs.length > 0) {
      console.log(`[Hypernote] Migrating ${slugs.length} notebooks to per-notebook drives...`)

      for (const slug of slugs) {
        const drive = await this.driveManager.createDrive(slug)

        // Copy metadata.json
        const meta = await oldDrive.get(`/notebooks/${slug}/metadata.json`)
        if (meta) await drive.put('/metadata.json', meta)

        // Copy notes
        try {
          for await (const f of oldDrive.readdir(`/notebooks/${slug}/notes`)) {
            const buf = await oldDrive.get(`/notebooks/${slug}/notes/${f}`)
            if (buf) await drive.put(`/notes/${f}`, buf)
          }
        } catch {}
      }

      // Migrate profile to registry
      const profileBuf = await oldDrive.get('/profile.json')
      if (profileBuf) {
        await this.registry.put('profile', JSON.parse(profileBuf.toString()))
      }

      console.log(`[Hypernote] Migration complete: ${slugs.length} notebooks migrated`)
    }

    await this.registry.put('migration:v2-drives', { migratedAt: Date.now(), count: slugs.length })
  }

  /**
   * Recursively copy a directory tree using bare-fs.
   */
  async _copyDir (src, dest) {
    await fs.promises.mkdir(dest, { recursive: true })
    const entries = await fs.promises.readdir(src, { withFileTypes: true })
    for (const entry of entries) {
      const srcPath = path.join(src, entry.name)
      const destPath = path.join(dest, entry.name)
      if (entry.isDirectory()) {
        await this._copyDir(srcPath, destPath)
      } else {
        await fs.promises.copyFile(srcPath, destPath)
      }
    }
  }

  /**
   * Migrate Hyperdrive /media/ files to content-addressed MediaStore.
   * Walks all local drives, hashes each file, stores in MediaStore,
   * and creates legacy filename→hash mappings for URL backward compat.
   */
  async _migrateToContentAddressedMedia () {
    const marker = await this.registry.get('migration:v3-media-store')
    if (marker) return

    // Backup storage before migration
    const backupPath = this.storage + '.pre-v3-backup'
    try {
      await fs.promises.access(backupPath)
      // Backup already exists (previous failed attempt?)
      console.log('[Migration] Backup already exists at', backupPath)
    } catch {
      console.log('[Migration] Backing up storage before v3 media migration...')
      try {
        await this._copyDir(this.storage, backupPath)
        console.log('[Migration] Backup saved to', backupPath)
      } catch (err) {
        console.error('[Migration] Backup failed:', err.message)
        console.error('[Migration] Aborting migration — please back up manually')
        return
      }
    }

    let migrated = 0
    let deduped = 0
    let failed = 0

    const mimeTypes = {
      png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
      gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
      mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
      mp3: 'audio/mpeg', ogg: 'audio/ogg'
    }

    // Walk all local Hyperdrive /media/ files
    const drives = await this.driveManager.listDrives()
    for (const entry of drives) {
      if (!entry.isLocal) continue
      const drive = await this.driveManager.getDrive(entry.slug)
      if (!drive) continue

      let files
      try {
        const iter = drive.readdir('/media/')
        files = []
        for await (const f of iter) files.push(f)
      } catch {
        continue // no /media/ dir
      }

      for (const filename of files) {
        try {
          const buf = await drive.get('/media/' + filename)
          if (!buf || buf.length === 0) continue

          const ext = filename.split('.').pop().toLowerCase()
          const mimeType = mimeTypes[ext] || 'application/octet-stream'

          const hash = this.mediaStore._hash(buf)
          const existed = await this.mediaStore.has(hash)
          await this.mediaStore.put(buf, mimeType)

          if (existed) {
            deduped++
          } else {
            migrated++
          }

          // Store legacy filename → hash mapping for URL backward compat
          await this.mediaStore._index.put('legacy:' + filename, {
            hash,
            slug: entry.slug,
            migratedAt: Date.now()
          })
        } catch (err) {
          console.error('[Migration] Failed to migrate', entry.slug + '/' + filename + ':', err.message)
          failed++
        }
      }
    }

    await this.registry.put('migration:v3-media-store', {
      migratedAt: Date.now(),
      migrated,
      deduped,
      failed
    })

    console.log('[Migration] Media migration complete: ' + migrated + ' migrated, ' + deduped + ' deduped, ' + failed + ' failed')
  }

  /**
   * Ensure default notebook and profile exist.
   */
  async _ensureDriveStructure() {
    // Check if profile exists in registry
    const profileEntry = await this.registry.get('profile')
    if (!profileEntry) {
      await this.registry.put('profile', {
        name: 'My Notes',
        createdAt: Date.now()
      })
    }

    // Ensure "general" notebook drive exists
    const generalDrive = await this.driveManager.getDrive('general')
    if (!generalDrive) {
      await this.noteManager.createNotebook('general', {
        name: 'General',
        description: 'Default notebook'
      })
    } else {
      // Check if it has metadata
      const meta = await generalDrive.entry('/metadata.json')
      if (!meta) {
        await this.noteManager.createNotebook('general', {
          name: 'General',
          description: 'Default notebook'
        })
      }
    }
  }

  /**
   * Start HTTP server
   */
  async start() {
    await this.ready()

    // Build CORS origin whitelist from server config
    const allowedOrigins = new Set([
      `http://localhost:${this.port}`,
      `http://127.0.0.1:${this.port}`,
      `http://[::1]:${this.port}`
    ])
    // In network mode, also allow LAN hostname
    if (this.host === '0.0.0.0') {
      const lanAddr = HypernoteServer._getLanAddress()
      if (lanAddr) allowedOrigins.add(`http://${lanAddr}:${this.port}`)
    }

    const server = http.createServer(async (req, res) => {
      // CORS — whitelist-based origin checking
      const origin = req.headers.origin || ''
      if (origin && (allowedOrigins.has(origin) || origin.startsWith('hyper://'))) {
        res.setHeader('Access-Control-Allow-Origin', origin)
        res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
        res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Wirecore-PublicKey, X-Wirecore-Signature, X-Wirecore-Challenge')
        res.setHeader('Access-Control-Allow-Credentials', 'true')
      }

      // Handle CORS preflight
      if (req.method === 'OPTIONS') {
        res.statusCode = 204
        res.end()
        return
      }

      try {
        const matched = await this.router.dispatch(req, res)
        if (!matched) {
          notFound(res, 'Route not found')
        }
      } catch (err) {
        if (!res.writableEnded) {
          const status = err.statusCode || 500
          if (status >= 500) console.error('[Hypernote] Request error:', err.message)
          res.statusCode = status
          res.setHeader('Content-Type', 'application/json')
          res.end(JSON.stringify({ error: status >= 500 ? 'Internal server error' : err.message }))
        }
      }
    })

    // Request timeout — prevent stalled connections from accumulating
    server.setTimeout(120000) // 2 minutes
    server.on('timeout', (socket) => {
      socket.destroy()
    })

    const host = this.host || 'localhost'
    server.listen(this.port, host, () => {
      console.log(`[Hypernote] Server listening on http://${host}:${this.port}`)
      if (host === '0.0.0.0') {
        console.log(`[Hypernote] Network mode: accessible from LAN`)
      }
    })

    return server
  }

  /**
   * Persist multi-writer config to registry for restore on restart
   */
  async _persistMultiwriterConfig () {
    const status = await this.multiwriter.getStatus()
    const keyHex = status.registryKey
      ? (Buffer.isBuffer(status.registryKey) ? status.registryKey.toString('hex') : status.registryKey)
      : null
    await this.registry.put('multiwriter-config', {
      registryBootstrapKey: keyHex,
      linkedAt: Date.now()
    })
  }

  /**
   * Graceful shutdown.
   */
  async close () {
    if (this.multiwriter) await this.multiwriter.close()
    await this.replicationManager.stop()
    await this.store.close()
  }
}

// ==================== MAIN ====================

const command = args[0]

if (command === 'serve' || !command) {
  const port = parseInt(args[1]) || 4040

  // Parse flags
  let storage = null
  let network = false
  let pathPrefix = ''
  for (let i = 0; i < args.length; i++) {
    if (args[i].startsWith('--storage=')) {
      storage = args[i].split('=')[1]
    } else if (args[i] === '--storage' && args[i + 1]) {
      storage = args[i + 1]
    } else if (args[i] === '--network') {
      network = true
    } else if (args[i].startsWith('--prefix=')) {
      pathPrefix = args[i].split('=')[1]
    } else if (args[i] === '--prefix' && args[i + 1]) {
      pathPrefix = args[i + 1]
    }
  }

  const host = network ? '0.0.0.0' : 'localhost'
  globalThis._hnServer = new HypernoteServer({ port, host, storage, pathPrefix })
  globalThis._hnHttpServer = await globalThis._hnServer.start()
} else if (command === 'promote') {
  // CLI admin promotion — for bootstrapping first admin
  const target = args[1] // publicKey or username
  if (!target) {
    console.log('Usage: bare hypernote-server.js promote <publicKey|username> [--storage=path]')
    process.exit(1)
  }

  let storage = null
  for (let i = 0; i < args.length; i++) {
    if (args[i].startsWith('--storage=')) storage = args[i].split('=')[1]
    else if (args[i] === '--storage' && args[i + 1]) storage = args[i + 1]
  }

  const locatorPath = StorageLocator.read()
  storage = storage || locatorPath || HypernoteServer.resolveDefaultStorage()

  const store = new Corestore(storage)
  const authCore = store.get({ name: 'registry' })
  const authDb = new Hyperbee(authCore, { keyEncoding: 'utf-8', valueEncoding: 'json' })
  await authDb.ready()

  const { IdentityRegistry } = await import('./lib/auth/identity-registry.js')
  const registry = new IdentityRegistry(authDb)

  // Find user by public key or username
  let user = null
  if (target.length === 44) {
    // Looks like a public key
    const identity = await registry.getIdentityByPublicKey(target)
    if (identity) {
      user = await registry.getUserByIdentity(identity.identityId)
    }
  }
  if (!user) {
    user = await registry.getUserByUsername(target)
  }
  if (!user) {
    // Try scanning all users for a match
    for await (const entry of authDb.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
      const u = entry.value
      if (u.userId === target || u.username === target) {
        user = u
        break
      }
    }
  }

  if (!user) {
    console.error(`User not found: ${target}`)
    console.log('Available users:')
    for await (const entry of authDb.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
      const u = entry.value
      console.log(`  ${u.userId}  @${u.username || '(no username)'}  [${(u.globalPermissions || []).join(',')}]`)
    }
    await store.close()
    process.exit(1)
  }

  await registry.upgradePermissions(user.userId, '*', ['read', 'write', 'admin'])
  console.log(`Promoted ${user.username || user.userId} to admin`)
  console.log(`  userId: ${user.userId}`)
  console.log(`  username: ${user.username || '(none)'}`)
  console.log(`  permissions: read, write, admin`)
  await store.close()
} else if (command === 'new-token') {
  // Generate a fresh admin token
  let storage = null
  for (let i = 0; i < args.length; i++) {
    if (args[i].startsWith('--storage=')) storage = args[i].split('=')[1]
    else if (args[i] === '--storage' && args[i + 1]) storage = args[i + 1]
  }

  const locatorPath = StorageLocator.read()
  storage = storage || locatorPath || HypernoteServer.resolveDefaultStorage()

  const store = new Corestore(storage)
  const authCore = store.get({ name: 'registry' })
  const authDb = new Hyperbee(authCore, { keyEncoding: 'utf-8', valueEncoding: 'json' })
  await authDb.ready()

  const { AuthManager } = await import('./lib/auth.js')
  const authManager = new AuthManager({ registry: authDb })

  const result = await authManager.createToken({
    username: 'admin',
    description: 'CLI-generated admin token',
    permissions: ['read', 'write', 'admin'],
    notebooks: ['*']
  })
  console.log(result.token)
  await store.close()
} else if (command === 'migrate') {
  const subCommand = args[1] // 'verify' or 'cleanup'

  let storage = null
  for (let i = 0; i < args.length; i++) {
    if (args[i].startsWith('--storage=')) storage = args[i].split('=')[1]
    else if (args[i] === '--storage' && args[i + 1]) storage = args[i + 1]
  }

  const locatorPath = StorageLocator.read()
  storage = storage || locatorPath || HypernoteServer.resolveDefaultStorage()

  const store = new Corestore(storage)
  const registryCore = store.get({ name: 'registry' })
  const registry = new Hyperbee(registryCore, { keyEncoding: 'utf-8', valueEncoding: 'json' })
  await registry.ready()

  const { extractMediaHashes } = await import('./lib/multiwriter/notebook-apply.js')

  const mediaStore = new MediaStore(store)
  await mediaStore.ready()

  const driveManager = new DriveManager(store, registry)
  const noteManager = new NoteManager(driveManager)

  if (subCommand === 'verify') {
    // Check migration marker
    const marker = await registry.get('migration:v3-media-store')
    if (!marker) {
      console.log('No v3 media migration found. Run the server first to trigger migration.')
      await store.close()
      process.exit(1)
    }
    console.log('Migration marker:', JSON.stringify(marker.value, null, 2))

    // Walk all notes, extract media hashes, verify each exists
    let totalHashes = 0
    let found = 0
    let missing = 0
    const missingList = []

    const notebooks = await noteManager.listNotebooks()
    for (const nb of notebooks) {
      const notes = await noteManager.listNotes(nb.slug)
      for (const note of notes) {
        const hashes = extractMediaHashes(note.content || '')
        for (const hash of hashes) {
          totalHashes++
          const exists = await mediaStore.has(hash)
          if (exists) {
            found++
          } else {
            // Check legacy mapping
            const legacy = await mediaStore._index.get('legacy:' + hash)
            if (legacy && legacy.value && legacy.value.hash) {
              const legacyExists = await mediaStore.has(legacy.value.hash)
              if (legacyExists) { found++; continue }
            }
            missing++
            missingList.push({ notebook: nb.slug, note: note.slug || note.id, hash })
          }
        }
      }
    }

    // Also verify MediaStore blobs are readable
    const allBlobs = await mediaStore.listAll()
    let readable = 0
    let unreadable = 0
    for (const blob of allBlobs) {
      try {
        const buf = await mediaStore.get(blob.hash)
        if (buf && buf.length > 0) readable++
        else unreadable++
      } catch {
        unreadable++
      }
    }

    console.log('\n=== Migration Verification ===')
    console.log('Media references in notes: ' + totalHashes)
    console.log('  Found in MediaStore: ' + found)
    console.log('  Missing: ' + missing)
    console.log('Blobs in MediaStore: ' + allBlobs.length)
    console.log('  Readable: ' + readable)
    console.log('  Unreadable: ' + unreadable)

    if (missing > 0) {
      console.log('\nMissing media:')
      for (const m of missingList) {
        console.log('  ' + m.notebook + '/' + m.note + ' → ' + m.hash)
      }
      console.log('\nVerification FAILED. Do NOT clean up yet.')
    } else {
      // Set verified marker
      await registry.put('migration:v3-verified', { verifiedAt: Date.now(), totalHashes, blobs: allBlobs.length })
      console.log('\nVerification PASSED. Safe to run: bare hypernote-server.js migrate cleanup')
    }

    await store.close()

  } else if (subCommand === 'cleanup') {
    // Check verified marker
    const verified = await registry.get('migration:v3-verified')
    if (!verified) {
      console.log('Migration not verified. Run "migrate verify" first.')
      await store.close()
      process.exit(1)
    }

    // Delete backup directory
    const backupPath = storage + '.pre-v3-backup'
    try {
      await fs.promises.access(backupPath)
      console.log('Removing backup at ' + backupPath + '...')
      await fs.promises.rm(backupPath, { recursive: true, force: true })
      console.log('Backup removed.')
    } catch {
      console.log('No backup found at ' + backupPath)
    }

    // Clear old Hyperdrive /media/ files
    let cleared = 0
    const drives = await driveManager.listDrives()
    for (const entry of drives) {
      if (!entry.isLocal) continue
      const drive = await driveManager.getDrive(entry.slug)
      if (!drive) continue
      try {
        const files = []
        for await (const f of drive.readdir('/media/')) files.push(f)
        for (const f of files) {
          try {
            await drive.clear('/media/' + f)
            cleared++
          } catch {}
        }
      } catch {}
    }

    console.log('Cleared ' + cleared + ' old Hyperdrive media files.')
    console.log('Cleanup complete.')

    await store.close()

  } else {
    console.log('Usage:')
    console.log('  bare hypernote-server.js migrate verify [--storage=path]')
    console.log('  bare hypernote-server.js migrate cleanup [--storage=path]')
    process.exit(1)
  }
} else {
  console.log('Usage:')
  console.log('  bare hypernote-server.js [serve] [port] [--storage=path] [--network]')
  console.log('  bare hypernote-server.js promote <publicKey|username> [--storage=path]')
  console.log('  bare hypernote-server.js new-token [--storage=path]')
  console.log('  bare hypernote-server.js migrate verify [--storage=path]')
  console.log('  bare hypernote-server.js migrate cleanup [--storage=path]')
  process.exit(1)
}