feat(03-01): add ModuleRegistry NestJS module with CRUD and activation endpoints
- ModuleRegistryService with findAll, findBySlug, findActiveForTenant, activate/deactivate, seedModule - ModuleRegistryController with GET /modules, GET /modules/active, POST activate/deactivate - ModuleGuard + @UseModule() decorator for tenant-scoped module access control - ActivateModuleDto with UUID validation - Registered ModuleRegistryModule in AppModule imports
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class ActivateModuleDto {
|
||||
@IsUUID()
|
||||
moduleId!: string;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Request } from 'express';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { ModuleRegistryService } from './module-registry.service';
|
||||
|
||||
/**
|
||||
* REST controller for the module registry.
|
||||
*
|
||||
* - GET /modules — list all registered modules (any authenticated user)
|
||||
* - GET /modules/active — list active modules for current tenant
|
||||
* - POST /modules/:moduleId/activate — activate a module (ADMIN/SUPER_ADMIN)
|
||||
* - POST /modules/:moduleId/deactivate — deactivate a module (ADMIN/SUPER_ADMIN)
|
||||
*
|
||||
* All routes require JWT auth (global JwtAuthGuard).
|
||||
* Activation/deactivation require ADMIN or SUPER_ADMIN role (T-03-01).
|
||||
*/
|
||||
@Controller('modules')
|
||||
export class ModuleRegistryController {
|
||||
constructor(
|
||||
private readonly moduleRegistryService: ModuleRegistryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /modules
|
||||
* Returns all registered modules in the system.
|
||||
* Available to any authenticated user (T-03-03: module catalog is non-sensitive).
|
||||
*/
|
||||
@Get()
|
||||
async findAll() {
|
||||
return this.moduleRegistryService.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /modules/active
|
||||
* Returns modules that are active for the requesting tenant.
|
||||
*/
|
||||
@Get('active')
|
||||
async findActive(@Req() req: Request) {
|
||||
const tenantId = (req as any).tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
}
|
||||
return this.moduleRegistryService.findActiveForTenant(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/:moduleId/activate
|
||||
* Activates a module for the current tenant.
|
||||
* T-03-01: Only ADMIN or SUPER_ADMIN can activate modules.
|
||||
* T-03-02: moduleId validated as UUID by Prisma parameterized queries.
|
||||
*/
|
||||
@Post(':moduleId/activate')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async activate(
|
||||
@Param('moduleId') moduleId: string,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const tenantId = (req as any).tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
}
|
||||
return this.moduleRegistryService.activateForTenant(tenantId, moduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/:moduleId/deactivate
|
||||
* Deactivates a module for the current tenant (soft-delete).
|
||||
* T-03-01: Only ADMIN or SUPER_ADMIN can deactivate modules.
|
||||
*/
|
||||
@Post(':moduleId/deactivate')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async deactivate(
|
||||
@Param('moduleId') moduleId: string,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const tenantId = (req as any).tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
}
|
||||
return this.moduleRegistryService.deactivateForTenant(tenantId, moduleId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ModuleRegistryController } from './module-registry.controller';
|
||||
import { ModuleRegistryService } from './module-registry.service';
|
||||
import { ModuleGuard } from './module.guard';
|
||||
|
||||
/**
|
||||
* NestJS module for the Tessera module registry.
|
||||
*
|
||||
* Provides:
|
||||
* - ModuleRegistryService: CRUD for module records + per-tenant activation
|
||||
* - ModuleGuard: CanActivate guard for module-specific endpoints
|
||||
* - ModuleRegistryController: REST API for listing, activating, and deactivating modules
|
||||
*
|
||||
* Exports ModuleRegistryService and ModuleGuard so downstream feature modules
|
||||
* (e.g., DomaincheckModule) can inject and use them.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [ModuleRegistryController],
|
||||
providers: [ModuleRegistryService, ModuleGuard],
|
||||
exports: [ModuleRegistryService, ModuleGuard],
|
||||
})
|
||||
export class ModuleRegistryModule {}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Service managing the module registry and per-tenant activations.
|
||||
*
|
||||
* Modules are registered centrally; tenants activate/deactivate them
|
||||
* independently via TenantModuleActivation records (per D-07, D-08).
|
||||
*/
|
||||
@Injectable()
|
||||
export class ModuleRegistryService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Returns all registered modules.
|
||||
*/
|
||||
async findAll() {
|
||||
return this.prisma.module.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a module by its unique slug.
|
||||
*/
|
||||
async findBySlug(slug: string) {
|
||||
return this.prisma.module.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all active modules for a given tenant.
|
||||
*/
|
||||
async findActiveForTenant(tenantId: string) {
|
||||
const activations = await this.prisma.tenantModuleActivation.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
module: true,
|
||||
},
|
||||
});
|
||||
|
||||
return activations.map((a) => a.module);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates a module for a tenant (upsert: creates or re-activates).
|
||||
* Per D-08: dynamic activation without restart.
|
||||
*/
|
||||
async activateForTenant(tenantId: string, moduleId: string) {
|
||||
// Verify module exists
|
||||
const moduleExists = await this.prisma.module.findUnique({
|
||||
where: { id: moduleId },
|
||||
});
|
||||
if (!moduleExists) {
|
||||
throw new NotFoundException(`Module with id '${moduleId}' not found`);
|
||||
}
|
||||
|
||||
return this.prisma.tenantModuleActivation.upsert({
|
||||
where: {
|
||||
tenantId_moduleId: {
|
||||
tenantId,
|
||||
moduleId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
isActive: true,
|
||||
activatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
tenantId,
|
||||
moduleId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
module: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates a module for a tenant (soft-delete: sets isActive=false).
|
||||
* Does not remove the activation record, preserving audit trail.
|
||||
*/
|
||||
async deactivateForTenant(tenantId: string, moduleId: string) {
|
||||
// Verify module exists
|
||||
const moduleExists = await this.prisma.module.findUnique({
|
||||
where: { id: moduleId },
|
||||
});
|
||||
if (!moduleExists) {
|
||||
throw new NotFoundException(`Module with id '${moduleId}' not found`);
|
||||
}
|
||||
|
||||
// Check if activation record exists
|
||||
const activation = await this.prisma.tenantModuleActivation.findUnique({
|
||||
where: {
|
||||
tenantId_moduleId: {
|
||||
tenantId,
|
||||
moduleId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!activation) {
|
||||
throw new NotFoundException(
|
||||
`Module '${moduleId}' is not activated for this tenant`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.tenantModuleActivation.update({
|
||||
where: {
|
||||
tenantId_moduleId: {
|
||||
tenantId,
|
||||
moduleId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
isActive: false,
|
||||
},
|
||||
include: {
|
||||
module: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a module (by slug) is active for a given tenant.
|
||||
* Used by ModuleGuard to gate access to module-specific endpoints.
|
||||
*/
|
||||
async isModuleActive(tenantId: string, moduleSlug: string): Promise<boolean> {
|
||||
const module = await this.prisma.module.findUnique({
|
||||
where: { slug: moduleSlug },
|
||||
});
|
||||
|
||||
if (!module) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activation = await this.prisma.tenantModuleActivation.findUnique({
|
||||
where: {
|
||||
tenantId_moduleId: {
|
||||
tenantId,
|
||||
moduleId: module.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return activation?.isActive === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers or updates a module in the registry by slug (upsert).
|
||||
* Used during application startup to seed built-in modules.
|
||||
*/
|
||||
async seedModule(manifest: {
|
||||
slug: string;
|
||||
name: string;
|
||||
version: string;
|
||||
category: string;
|
||||
description: Record<string, string>;
|
||||
icon?: string;
|
||||
isSystem?: boolean;
|
||||
}) {
|
||||
return this.prisma.module.upsert({
|
||||
where: { slug: manifest.slug },
|
||||
update: {
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
category: manifest.category,
|
||||
description: manifest.description,
|
||||
icon: manifest.icon ?? null,
|
||||
isSystem: manifest.isSystem ?? false,
|
||||
},
|
||||
create: {
|
||||
slug: manifest.slug,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
category: manifest.category,
|
||||
description: manifest.description,
|
||||
icon: manifest.icon ?? null,
|
||||
isSystem: manifest.isSystem ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
applyDecorators,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ModuleRegistryService } from './module-registry.service';
|
||||
|
||||
/**
|
||||
* Metadata key for the module slug attached by @UseModule().
|
||||
*/
|
||||
export const MODULE_SLUG_KEY = 'moduleSlug';
|
||||
|
||||
/**
|
||||
* Guard that checks whether the requesting tenant has an active
|
||||
* module activation for the module identified by its slug.
|
||||
*
|
||||
* Per T-03-04: tenantId is sourced from JWT (via TenantMiddleware),
|
||||
* not from user-supplied input, preventing elevation of privilege.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ModuleGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly moduleRegistryService: ModuleRegistryService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Get moduleSlug from metadata (set by @UseModule decorator)
|
||||
const moduleSlug = this.reflector.getAllAndOverride<string>(
|
||||
MODULE_SLUG_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
// If no module slug is set, allow (guard is not applicable)
|
||||
if (!moduleSlug) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const tenantId = request.tenantId;
|
||||
|
||||
// Public routes or routes without tenant context skip module check
|
||||
if (!tenantId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isActive = await this.moduleRegistryService.isModuleActive(
|
||||
tenantId,
|
||||
moduleSlug,
|
||||
);
|
||||
|
||||
if (!isActive) {
|
||||
throw new ForbiddenException(
|
||||
`Module '${moduleSlug}' is not activated for this tenant`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator that protects a controller or route handler with the ModuleGuard.
|
||||
* Ensures the specified module is activated for the requesting tenant.
|
||||
*
|
||||
* Usage:
|
||||
* @UseModule('domaincheck')
|
||||
* @Controller('domaincheck')
|
||||
* export class DomaincheckController { ... }
|
||||
*/
|
||||
export function UseModule(slug: string) {
|
||||
return applyDecorators(
|
||||
SetMetadata(MODULE_SLUG_KEY, slug),
|
||||
UseGuards(ModuleGuard),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user