Some checks failed
Build & Release / build (push) Failing after 35s
- app_settings table with encrypted field support (AES-256-GCM, key from ENCRYPTION_KEY env) - SettingsService for transparent encrypt/decrypt on get/set - EmailService factory with Resend and SendGrid providers (SMTP stub) — provider config lives in app_settings - Seeds initial admin user and email settings from env vars on first startup if not already present - Migration 0039_app_settings.sql Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
244 lines
9.4 KiB
TypeScript
244 lines
9.4 KiB
TypeScript
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 { 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 { users } from './db/schema/users.js'
|
|
import bcrypt from 'bcryptjs'
|
|
|
|
async function seedInitialUser(app: Awaited<ReturnType<typeof buildApp>>) {
|
|
const email = process.env.INITIAL_USER_EMAIL
|
|
const password = process.env.INITIAL_USER_PASSWORD
|
|
const firstName = process.env.INITIAL_USER_FIRST_NAME
|
|
const lastName = process.env.INITIAL_USER_LAST_NAME
|
|
if (!email || !password || !firstName || !lastName) return
|
|
|
|
const existing = await app.db.select({ id: users.id }).from(users).limit(1)
|
|
if (existing.length > 0) return
|
|
|
|
const passwordHash = await bcrypt.hash(password, 10)
|
|
await app.db.insert(users).values({ email, passwordHash, firstName, lastName, role: 'admin' })
|
|
app.log.info({ email }, 'Initial admin user created')
|
|
}
|
|
|
|
async function seedEmailSettings(app: Awaited<ReturnType<typeof buildApp>>) {
|
|
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')
|
|
}
|
|
|
|
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', 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')
|
|
}
|
|
})
|
|
|
|
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()
|
|
}
|