/**
* HyperBookingServer — main server class.
*
* Follows hypernote pattern:
* - Corestore + Hyperbee for data
* - AuthManager for identity
* - Router for HTTP dispatch
* - Static file serving from web/
*/
import http from 'bare-http1'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import crypto from 'bare-crypto'
import { AuthManager } from './auth.js'
import { Router } from './router.js'
import { json, notFound, escapeHtml } from './response.js'
import { registerProviderRoutes } from './routes/providers.js'
import { registerPackageRoutes } from './routes/packages.js'
import { registerAvailabilityRoutes } from './routes/availability.js'
import { registerBookingRoutes } from './routes/bookings.js'
import { registerAuthRoutes } from './routes/auth.js'
import { registerWebRoutes, registerPublicRoutes } from './routes/web.js'
import { registerDashboardRoutes } from './routes/dashboard.js'
import { registerGcalRoutes } from './routes/gcal.js'
import { GoogleCalendarClient } from './gcal.js'
export class HyperBookingServer {
constructor (options = {}) {
this.port = options.port || 4050
this.host = options.host || 'localhost'
this.pathPrefix = options.pathPrefix || ''
this.network = options.network || false
this.appDir = path.dirname(new URL(import.meta.url).pathname)
this.appDir = path.resolve(this.appDir, '..')
// Storage
this.storage = options.storage || path.join(os.homedir(), '.config', 'hyper-booking', 'data')
this.store = new Corestore(this.storage)
// Registry (Hyperbee for all structured data)
this.registryCore = this.store.get({ name: 'registry' })
this.db = new Hyperbee(this.registryCore, {
keyEncoding: 'utf-8',
valueEncoding: 'json'
})
// Auth
this.authManager = new AuthManager({
registry: this.db,
trustedProxies: options.trustedProxies
})
// Google Calendar integration (optional — requires GCAL_CLIENT_ID env var)
const gcalClientId = options.gcalClientId || (typeof process !== 'undefined' && process.env && process.env.GCAL_CLIENT_ID)
const gcalClientSecret = options.gcalClientSecret || (typeof process !== 'undefined' && process.env && process.env.GCAL_CLIENT_SECRET)
// Always create gcal client — routes check if credentials exist
this.gcal = new GoogleCalendarClient({
clientId: gcalClientId || '',
clientSecret: gcalClientSecret || ''
})
this.gcalConfigured = !!gcalClientId
// Fixed OAuth redirect URI (Google blocks .local and private IPs)
const gcalRedirectUri = options.gcalRedirectUri || (typeof process !== 'undefined' && process.env && process.env.GCAL_REDIRECT_URI)
this.gcalRedirectUri = gcalRedirectUri || null
// Router
this.router = new Router(this)
// HTTP server
this.httpServer = null
}
async ready () {
await this.db.ready()
console.log('[hyper-booking] Storage ready:', this.storage)
// First-run bootstrap
const initialized = await this.db.get('meta:initialized')
if (!initialized) {
await this.db.put('meta:initialized', {
at: Date.now(),
version: '0.1.0'
})
console.log('[hyper-booking] First run — admin account will be created on first sign-in')
}
}
async start () {
this.setupRoutes()
this.httpServer = http.createServer(async (req, res) => {
// CORS
const origin = req.headers.origin || ''
const allowed = !origin || this.isAllowedOrigin(origin)
if (allowed && origin) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Auth-Token, X-Wirecore-PublicKey, X-Wirecore-Signature, X-Wirecore-Challenge')
res.setHeader('Access-Control-Max-Age', '86400')
}
if (req.method === 'OPTIONS') {
res.statusCode = 204
res.end()
return
}
try {
const matched = await this.router.dispatch(req, res)
if (!matched) notFound(res)
} catch (err) {
console.error('[Server] Error:', err)
if (!res.writableEnded) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'Internal server error' }))
}
}
})
this.httpServer.setTimeout(120000)
return new Promise((resolve) => {
this.httpServer.listen(this.port, this.host, resolve)
})
}
setupRoutes () {
registerWebRoutes(this.router, this)
registerAuthRoutes(this.router, this)
registerProviderRoutes(this.router, this)
registerPackageRoutes(this.router, this)
registerAvailabilityRoutes(this.router, this)
registerBookingRoutes(this.router, this)
registerDashboardRoutes(this.router, this)
registerGcalRoutes(this.router, this)
// Public slug routes MUST be last (catch-all /:slug)
registerPublicRoutes(this.router, this)
}
isAllowedOrigin (origin) {
if (!origin) return true
// Allow localhost, LAN IPs, and any same-host origin
if (origin.startsWith('http://localhost')) return true
if (origin.startsWith('http://127.0.0.1')) return true
if (origin.startsWith('https://localhost')) return true
if (origin.match(/^https?:\/\/192\.168\./)) return true
if (origin.match(/^https?:\/\/10\./)) return true
if (origin.match(/^https?:\/\/172\.(1[6-9]|2\d|3[01])\./)) return true
if (origin === 'https://7rnx.net') return true
return false
}
// ---- Helpers for route handlers ----
generateId (prefix) {
return prefix + '_' + crypto.randomBytes(12).toString('hex')
}
async close () {
if (this.httpServer) this.httpServer.close()
if (this.store) await this.store.close()
}
}