#!/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)
}