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:
Ryan Moon
2026-03-30 06:52:27 -05:00
parent 1f9297f533
commit e346e072b8
10 changed files with 294 additions and 13 deletions

View File

@@ -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