Add module management system for enabling/disabling features
Stores can enable/disable feature modules from Settings. When disabled, nav links are hidden and API routes return 403. Designed as the foundation for future license-based gating (licensed + enabled flags). Core modules (Accounts, Members, Users, Roles, Settings) are always on. - module_config table with slug, name, description, licensed, enabled - In-memory cache for fast per-request module checks - requireModule middleware wraps route groups in main.ts - Settings page Modules card with toggle switches - Sidebar hides nav links for disabled modules - Default modules seeded: inventory, pos, repairs, rentals, lessons, files, vault, email, reports
This commit is contained in:
@@ -20,7 +20,9 @@ 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 { RbacService } from './services/rbac.service.js'
|
||||
import { ModuleService } from './services/module.service.js'
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
@@ -61,19 +63,48 @@ export async function buildApp() {
|
||||
throw new Error('JWT_SECRET is required in production')
|
||||
}
|
||||
|
||||
// Routes
|
||||
// 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(authRoutes, { prefix: '/v1' })
|
||||
await app.register(accountRoutes, { prefix: '/v1' })
|
||||
await app.register(inventoryRoutes, { prefix: '/v1' })
|
||||
await app.register(productRoutes, { prefix: '/v1' })
|
||||
await app.register(lookupRoutes, { prefix: '/v1' })
|
||||
await app.register(fileRoutes, { prefix: '/v1' })
|
||||
await app.register(rbacRoutes, { prefix: '/v1' })
|
||||
await app.register(repairRoutes, { prefix: '/v1' })
|
||||
await app.register(storageRoutes, { prefix: '/v1' })
|
||||
await app.register(storeRoutes, { prefix: '/v1' })
|
||||
await app.register(vaultRoutes, { prefix: '/v1' })
|
||||
await app.register(moduleRoutes, { 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('vault', vaultRoutes), { prefix: '/v1' })
|
||||
// Register WebDAV custom HTTP methods before routes
|
||||
app.addHttpMethod('PROPFIND', { hasBody: true })
|
||||
app.addHttpMethod('PROPPATCH', { hasBody: true })
|
||||
@@ -84,7 +115,7 @@ export async function buildApp() {
|
||||
app.addHttpMethod('UNLOCK')
|
||||
await app.register(webdavRoutes, { prefix: '/webdav' })
|
||||
|
||||
// Auto-seed system permissions on startup
|
||||
// Auto-seed system permissions and warm module cache on startup
|
||||
app.addHook('onReady', async () => {
|
||||
try {
|
||||
await RbacService.seedPermissions(app.db)
|
||||
@@ -92,6 +123,13 @@ export async function buildApp() {
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'Failed to seed permissions')
|
||||
}
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
|
||||
Reference in New Issue
Block a user