import Fastify from 'fastify' import { drizzle } from 'drizzle-orm/postgres-js' import { migrate } from 'drizzle-orm/postgres-js/migrator' import postgres from 'postgres' import rateLimit from '@fastify/rate-limit' import { databasePlugin } from './plugins/database.js' import { redisPlugin } from './plugins/redis.js' import { corsPlugin } from './plugins/cors.js' import { errorHandlerPlugin } from './plugins/error-handler.js' import { authPlugin } from './plugins/auth.js' import { devAuthPlugin } from './plugins/dev-auth.js' import { storagePlugin } from './plugins/storage.js' import { healthRoutes } from './routes/v1/health.js' import { versionRoutes } from './routes/v1/version.js' import { authRoutes } from './routes/v1/auth.js' import { accountRoutes } from './routes/v1/accounts.js' import { inventoryRoutes } from './routes/v1/inventory.js' import { productRoutes } from './routes/v1/products.js' import { lookupRoutes } from './routes/v1/lookups.js' import { fileRoutes } from './routes/v1/files.js' import { rbacRoutes } from './routes/v1/rbac.js' import { repairRoutes } from './routes/v1/repairs.js' import { lessonRoutes } from './routes/v1/lessons.js' import { transactionRoutes } from './routes/v1/transactions.js' import { drawerRoutes } from './routes/v1/drawer.js' import { registerRoutes } from './routes/v1/register.js' import { reportRoutes } from './routes/v1/reports.js' import { discountRoutes } from './routes/v1/discounts.js' import { taxRoutes } from './routes/v1/tax.js' import { storageRoutes } from './routes/v1/storage.js' import { storeRoutes } from './routes/v1/store.js' import { vaultRoutes } from './routes/v1/vault.js' import { webdavRoutes } from './routes/webdav/index.js' import { moduleRoutes } from './routes/v1/modules.js' import { configRoutes } from './routes/v1/config.js' import { RbacService } from './services/rbac.service.js' import { ModuleService } from './services/module.service.js' import { AppConfigService } from './services/config.service.js' import { SettingsService } from './services/settings.service.js' import { eq } from 'drizzle-orm' import { users } from './db/schema/users.js' import { companies } from './db/schema/stores.js' import { roles, userRoles } from './db/schema/rbac.js' import { EmailService } from './services/email.service.js' import bcrypt from 'bcryptjs' async function seedInitialUser(app: Awaited>) { const email = process.env.INITIAL_USER_EMAIL const firstName = process.env.INITIAL_USER_FIRST_NAME const lastName = process.env.INITIAL_USER_LAST_NAME if (!email || !firstName || !lastName) return const existing = await app.db.select({ id: users.id }).from(users).limit(1) if (existing.length > 0) return // Create user with a random password — they'll set their real one via the welcome email const tempPassword = crypto.randomUUID() const passwordHash = await bcrypt.hash(tempPassword, 10) const [user] = await app.db.insert(users).values({ email, passwordHash, firstName, lastName, role: 'admin' }).returning({ id: users.id }) // Assign the Admin RBAC role const [adminRole] = await app.db.select({ id: roles.id }).from(roles).where(eq(roles.name, 'Admin')).limit(1) if (adminRole) { await app.db.insert(userRoles).values({ userId: user.id, roleId: adminRole.id }) } app.log.info({ email }, 'Initial admin user created') // Send welcome email with password setup link try { const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '4h' }) const appUrl = process.env.APP_URL ?? `https://${process.env.HOSTNAME ?? 'localhost'}` const resetLink = `${appUrl}/reset-password?token=${resetToken}` const [store] = await app.db.select({ name: companies.name }).from(companies).limit(1) const storeName = store?.name ?? process.env.BUSINESS_NAME ?? 'LunarFront' await EmailService.send(app.db, { to: email, subject: `Welcome to ${storeName} — Set your password`, html: `

${storeName}

Hi ${firstName},

Your account has been created. Click the button below to set your password and get started:

Set Your Password

This link expires in 4 hours. If it expires, you can request a new one from the login page.


Powered by LunarFront

`, text: `Hi ${firstName}, welcome to ${storeName}! Set your password here: ${resetLink} — This link expires in 4 hours.`, }) app.log.info({ email }, 'Welcome email sent to initial user') } catch (err) { app.log.error({ email, error: (err as Error).message }, 'Failed to send welcome email — user can use forgot password') } } async function seedEmailSettings(app: Awaited>) { const apiKey = process.env.RESEND_API_KEY if (!apiKey) return const existing = await SettingsService.get(app.db, 'email.provider') if (existing) return await SettingsService.set(app.db, 'email.provider', 'resend') await SettingsService.set(app.db, 'email.resend_api_key', apiKey, true) if (process.env.MAIL_FROM) { await SettingsService.set(app.db, 'email.from_address', process.env.MAIL_FROM) } if (process.env.BUSINESS_NAME) { await SettingsService.set(app.db, 'email.business_name', process.env.BUSINESS_NAME) } app.log.info('Email settings seeded from environment') } async function seedCompany(app: Awaited>) { const name = process.env.BUSINESS_NAME if (!name) return const existing = await app.db.select({ id: companies.id }).from(companies).limit(1) if (existing.length > 0) return await app.db.insert(companies).values({ name }) app.log.info({ name }, 'Company seeded from environment') } export async function buildApp() { const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info', ...(process.env.NODE_ENV === 'development' ? { transport: { target: 'pino-pretty' } } : process.env.LOG_FILE ? { transport: { targets: [ { target: 'pino/file', options: { destination: 1 } }, // stdout { target: 'pino/file', options: { destination: process.env.LOG_FILE, mkdir: true } }, ], }, } : {}), }, genReqId: () => crypto.randomUUID(), requestTimeout: 30000, // 30 seconds }) // Plugins await app.register(corsPlugin) await app.register(errorHandlerPlugin) await app.register(databasePlugin) await app.register(redisPlugin) await app.register(rateLimit, { global: false }) await app.register(storagePlugin) // Auth — JWT in production/test, dev bypass only in development without JWT_SECRET if (process.env.JWT_SECRET) { await app.register(authPlugin) } else if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { app.log.warn('JWT_SECRET not set — using dev auth bypass. DO NOT USE IN PRODUCTION.') await app.register(devAuthPlugin) } else { throw new Error('JWT_SECRET is required in production') } // Module gate middleware — returns 403 if module is disabled function requireModule(slug: string) { return async (_request: any, reply: any) => { const enabled = await ModuleService.isEnabled(app.db, slug) if (!enabled) { reply.status(403).send({ error: { message: `Module '${slug}' is not enabled`, statusCode: 403, module: slug } }) } } } // Helper: wrap a route plugin so every route in it gets a module check. // Uses fastify-plugin (fp) pattern to inherit parent decorators while adding the hook. function withModule(slug: string, plugin: any) { const wrapped = async (instance: any, opts: any) => { instance.addHook('onRequest', requireModule(slug)) await plugin(instance, opts) } // Copy the plugin's symbol to maintain encapsulation behavior if (plugin[Symbol.for('skip-override')]) { wrapped[Symbol.for('skip-override')] = true } // Copy plugin name for debugging Object.defineProperty(wrapped, 'name', { value: `${plugin.name || 'anonymous'}[${slug}]` }) return wrapped } // Core routes — always available await app.register(healthRoutes, { prefix: '/v1' }) await app.register(versionRoutes, { prefix: '/v1' }) await app.register(authRoutes, { prefix: '/v1' }) await app.register(accountRoutes, { prefix: '/v1' }) await app.register(rbacRoutes, { prefix: '/v1' }) await app.register(storeRoutes, { prefix: '/v1' }) await app.register(moduleRoutes, { prefix: '/v1' }) await app.register(configRoutes, { prefix: '/v1' }) await app.register(lookupRoutes, { prefix: '/v1' }) // Module-gated routes await app.register(withModule('inventory', inventoryRoutes), { prefix: '/v1' }) await app.register(withModule('inventory', productRoutes), { prefix: '/v1' }) await app.register(withModule('files', fileRoutes), { prefix: '/v1' }) await app.register(withModule('files', storageRoutes), { prefix: '/v1' }) await app.register(withModule('repairs', repairRoutes), { prefix: '/v1' }) await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' }) await app.register(withModule('pos', transactionRoutes), { prefix: '/v1' }) await app.register(withModule('pos', drawerRoutes), { prefix: '/v1' }) await app.register(withModule('pos', registerRoutes), { prefix: '/v1' }) await app.register(withModule('pos', reportRoutes), { prefix: '/v1' }) await app.register(withModule('pos', discountRoutes), { prefix: '/v1' }) await app.register(withModule('pos', taxRoutes), { prefix: '/v1' }) await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' }) // Register WebDAV custom HTTP methods before routes app.addHttpMethod('PROPFIND', { hasBody: true }) app.addHttpMethod('PROPPATCH', { hasBody: true }) app.addHttpMethod('MKCOL', { hasBody: true }) app.addHttpMethod('COPY') app.addHttpMethod('MOVE') app.addHttpMethod('LOCK', { hasBody: true }) app.addHttpMethod('UNLOCK') await app.register(webdavRoutes, { prefix: '/webdav' }) // Auto-seed system permissions and warm module cache on startup app.addHook('onReady', async () => { try { await RbacService.seedPermissions(app.db) await RbacService.seedDefaultRoles(app.db) app.log.info('System permissions and roles seeded') } catch (err) { app.log.error({ err }, 'Failed to seed permissions/roles') } try { await ModuleService.refreshCache(app.db) const enabled = await ModuleService.getEnabledSlugs(app.db) app.log.info({ modules: [...enabled] }, 'Module cache loaded') } catch (err) { app.log.error({ err }, 'Failed to load module cache') } try { await AppConfigService.refreshCache(app.db) const dbLogLevel = await AppConfigService.get(app.db, 'log_level') if (dbLogLevel) { app.log.level = dbLogLevel app.log.info({ level: dbLogLevel }, 'Log level loaded from config') } } catch (err) { app.log.error({ err }, 'Failed to load app config') } try { await seedInitialUser(app) } catch (err) { app.log.error({ err }, 'Failed to seed initial user') } try { await seedEmailSettings(app) } catch (err) { app.log.error({ err }, 'Failed to seed email settings') } try { await seedCompany(app) } catch (err) { app.log.error({ err }, 'Failed to seed company') } }) return app } async function runMigrations() { const connectionString = process.env.DATABASE_URL if (!connectionString) throw new Error('DATABASE_URL is required') const migrationsFolder = process.env.MIGRATIONS_DIR ?? './src/db/migrations' const sql = postgres(connectionString, { max: 1 }) const db = drizzle(sql) await migrate(db, { migrationsFolder }) await sql.end() } async function start() { await runMigrations() const app = await buildApp() const port = parseInt(process.env.PORT ?? '8000', 10) const host = process.env.HOST ?? '0.0.0.0' try { await app.listen({ port, host }) } catch (err) { app.log.error(err) process.exit(1) } } // Only auto-start when not imported by tests if (process.env.NODE_ENV !== 'test') { start() }