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:
2026-06-19 12:36:33 +02:00
parent 8c24c1e267
commit fa15d3527a
6 changed files with 393 additions and 0 deletions
@@ -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),
);
}