chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
||||
import { sql, type Db } from '@mosaicstack/db';
|
||||
import { createQueue } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||
|
||||
@Controller('api/admin/health')
|
||||
@UseGuards(AdminGuard)
|
||||
export class AdminHealthController {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async check(): Promise<HealthStatusDto> {
|
||||
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
||||
|
||||
const sessions = this.agentService.listAllSessionsForSystem();
|
||||
const providers = this.providerService.listProviders();
|
||||
|
||||
const allOk = database.status === 'ok' && cache.status === 'ok';
|
||||
|
||||
return {
|
||||
status: allOk ? 'ok' : 'degraded',
|
||||
database,
|
||||
cache,
|
||||
agentPool: { activeSessions: sessions.length },
|
||||
providers: providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
available: p.available,
|
||||
modelCount: p.models.length,
|
||||
})),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async checkDatabase(): Promise<ServiceStatusDto> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await this.db.execute(sql`SELECT 1`);
|
||||
return { status: 'ok', latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'error',
|
||||
latencyMs: Date.now() - start,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkCache(): Promise<ServiceStatusDto> {
|
||||
// On Local tier there is no Redis. The cache is intentionally absent, which
|
||||
// is a healthy state for this tier — report 'ok' rather than opening a new
|
||||
// ioredis connection on every admin health check (which would spam
|
||||
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
|
||||
// signals "no cache backend to measure" for this tier.
|
||||
if (this.mosaicConfig?.queue?.type === 'local') {
|
||||
return { status: 'ok', latencyMs: 0 };
|
||||
}
|
||||
const start = Date.now();
|
||||
const handle = createQueue();
|
||||
try {
|
||||
await handle.redis.ping();
|
||||
return { status: 'ok', latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'error',
|
||||
latencyMs: Date.now() - start,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
await handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import { QueueService } from '../queue/queue.service.js';
|
||||
import type { JobDto, JobListDto, JobStatus, QueueListDto } from '../queue/queue-admin.dto.js';
|
||||
|
||||
@Controller('api/admin/jobs')
|
||||
@UseGuards(AdminGuard)
|
||||
export class AdminJobsController {
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(QueueService)
|
||||
private readonly queueService: QueueService | null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /api/admin/jobs
|
||||
* List jobs across all queues. Optional ?status=active|completed|failed|waiting|delayed
|
||||
*/
|
||||
@Get()
|
||||
async listJobs(@Query('status') status?: string): Promise<JobListDto> {
|
||||
if (!this.queueService) {
|
||||
return { jobs: [], total: 0 };
|
||||
}
|
||||
|
||||
const validStatuses: JobStatus[] = ['active', 'completed', 'failed', 'waiting', 'delayed'];
|
||||
const normalised = status as JobStatus | undefined;
|
||||
|
||||
if (normalised && !validStatuses.includes(normalised)) {
|
||||
return { jobs: [], total: 0 };
|
||||
}
|
||||
|
||||
const jobs: JobDto[] = await this.queueService.listJobs(normalised);
|
||||
return { jobs, total: jobs.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/jobs/:id/retry
|
||||
* Retry a specific failed job. The id is "<queue>__<bullmq-job-id>".
|
||||
*/
|
||||
@Post(':id/retry')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async retryJob(@Param('id') id: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.queueService) {
|
||||
throw new NotFoundException('Queue service is not available');
|
||||
}
|
||||
|
||||
const result = await this.queueService.retryJob(id);
|
||||
if (!result.ok) {
|
||||
throw new NotFoundException(result.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/jobs/queues
|
||||
* Return status for all managed queues.
|
||||
*/
|
||||
@Get('queues')
|
||||
async listQueues(): Promise<QueueListDto> {
|
||||
if (!this.queueService) {
|
||||
return { queues: [] };
|
||||
}
|
||||
|
||||
const health = await this.queueService.getHealthStatus();
|
||||
const queues = Object.entries(health.queues).map(([name, stats]) => ({
|
||||
name,
|
||||
waiting: stats.waiting,
|
||||
active: stats.active,
|
||||
completed: stats.completed,
|
||||
failed: stats.failed,
|
||||
delayed: 0,
|
||||
paused: stats.paused,
|
||||
}));
|
||||
|
||||
return { queues };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/jobs/queues/:name/pause
|
||||
* Pause the named queue.
|
||||
*/
|
||||
@Post('queues/:name/pause')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async pauseQueue(@Param('name') name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.queueService) {
|
||||
throw new NotFoundException('Queue service is not available');
|
||||
}
|
||||
|
||||
const result = await this.queueService.pauseQueue(name);
|
||||
if (!result.ok) {
|
||||
throw new NotFoundException(result.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/jobs/queues/:name/resume
|
||||
* Resume the named queue.
|
||||
*/
|
||||
@Post('queues/:name/resume')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resumeQueue(@Param('name') name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.queueService) {
|
||||
throw new NotFoundException('Queue service is not available');
|
||||
}
|
||||
|
||||
const result = await this.queueService.resumeQueue(name);
|
||||
if (!result.ok) {
|
||||
throw new NotFoundException(result.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { eq, type Db, adminTokens } from '@mosaicstack/db';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import type {
|
||||
CreateTokenDto,
|
||||
TokenCreatedDto,
|
||||
TokenDto,
|
||||
TokenListDto,
|
||||
} from './admin-tokens.dto.js';
|
||||
|
||||
function hashToken(plaintext: string): string {
|
||||
return createHash('sha256').update(plaintext).digest('hex');
|
||||
}
|
||||
|
||||
function toTokenDto(row: typeof adminTokens.$inferSelect): TokenDto {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
scope: row.scope,
|
||||
expiresAt: row.expiresAt?.toISOString() ?? null,
|
||||
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('api/admin/tokens')
|
||||
@UseGuards(AdminGuard)
|
||||
export class AdminTokensController {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Body() dto: CreateTokenDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
): Promise<TokenCreatedDto> {
|
||||
const plaintext = randomBytes(32).toString('hex');
|
||||
const tokenHash = hashToken(plaintext);
|
||||
const id = uuid();
|
||||
|
||||
const expiresAt = dto.expiresInDays
|
||||
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
const [row] = await this.db
|
||||
.insert(adminTokens)
|
||||
.values({
|
||||
id,
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
label: dto.label ?? 'CLI token',
|
||||
scope: dto.scope ?? 'admin',
|
||||
expiresAt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { ...toTokenDto(row!), plaintext };
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: { id: string }): Promise<TokenListDto> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(adminTokens)
|
||||
.where(eq(adminTokens.userId, user.id))
|
||||
.orderBy(adminTokens.createdAt);
|
||||
|
||||
return { tokens: rows.map(toTokenDto), total: rows.length };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async revoke(@Param('id') id: string, @CurrentUser() _user: { id: string }): Promise<void> {
|
||||
await this.db.delete(adminTokens).where(eq(adminTokens.id, id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { IsString, IsOptional, IsInt, Min } from 'class-validator';
|
||||
|
||||
export class CreateTokenDto {
|
||||
@IsString()
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scope?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
expiresInDays?: number;
|
||||
}
|
||||
|
||||
export interface TokenDto {
|
||||
id: string;
|
||||
label: string;
|
||||
scope: string;
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TokenCreatedDto extends TokenDto {
|
||||
plaintext: string;
|
||||
}
|
||||
|
||||
export interface TokenListDto {
|
||||
tokens: TokenDto[];
|
||||
total: number;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { eq, type Db, users as usersTable } from '@mosaicstack/db';
|
||||
import type { Auth } from '@mosaicstack/auth';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import type {
|
||||
BanUserDto,
|
||||
CreateUserDto,
|
||||
UpdateUserRoleDto,
|
||||
UserDto,
|
||||
UserListDto,
|
||||
} from './admin.dto.js';
|
||||
|
||||
type UserRow = typeof usersTable.$inferSelect;
|
||||
|
||||
function toUserDto(u: UserRow): UserDto {
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
banned: u.banned ?? false,
|
||||
banReason: u.banReason ?? null,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
updatedAt: u.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function requireUpdated(
|
||||
db: Db,
|
||||
id: string,
|
||||
update: Partial<Omit<UserRow, 'id' | 'createdAt'>>,
|
||||
): Promise<UserDto> {
|
||||
const [updated] = await db
|
||||
.update(usersTable)
|
||||
.set({ ...update, updatedAt: new Date() })
|
||||
.where(eq(usersTable.id, id))
|
||||
.returning();
|
||||
if (!updated) throw new InternalServerErrorException('Update returned no rows');
|
||||
return toUserDto(updated);
|
||||
}
|
||||
|
||||
@Controller('api/admin/users')
|
||||
@UseGuards(AdminGuard)
|
||||
export class AdminController {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(AUTH) private readonly auth: Auth,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async listUsers(): Promise<UserListDto> {
|
||||
const rows = await this.db.select().from(usersTable).orderBy(usersTable.createdAt);
|
||||
const userList: UserDto[] = rows.map(toUserDto);
|
||||
return { users: userList, total: userList.length };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getUser(@Param('id') id: string): Promise<UserDto> {
|
||||
const [user] = await this.db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return toUserDto(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createUser(@Body() body: CreateUserDto): Promise<UserDto> {
|
||||
// Use auth API to create user so password is properly hashed
|
||||
const authApi = this.auth.api as unknown as {
|
||||
createUser: (opts: {
|
||||
body: { name: string; email: string; password: string; role?: string };
|
||||
}) => Promise<{
|
||||
user: { id: string; name: string; email: string; createdAt: unknown; updatedAt: unknown };
|
||||
}>;
|
||||
};
|
||||
|
||||
const result = await authApi.createUser({
|
||||
body: {
|
||||
name: body.name,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
role: body.role ?? 'member',
|
||||
},
|
||||
});
|
||||
|
||||
// Re-fetch from DB to get full row with our schema
|
||||
const [user] = await this.db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, result.user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!user) throw new InternalServerErrorException('User created but not found in DB');
|
||||
return toUserDto(user);
|
||||
}
|
||||
|
||||
@Patch(':id/role')
|
||||
async setRole(@Param('id') id: string, @Body() body: UpdateUserRoleDto): Promise<UserDto> {
|
||||
await this.ensureExists(id);
|
||||
return requireUpdated(this.db, id, { role: body.role });
|
||||
}
|
||||
|
||||
@Post(':id/ban')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async banUser(@Param('id') id: string, @Body() body: BanUserDto): Promise<UserDto> {
|
||||
await this.ensureExists(id);
|
||||
return requireUpdated(this.db, id, { banned: true, banReason: body.reason ?? null });
|
||||
}
|
||||
|
||||
@Post(':id/unban')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async unbanUser(@Param('id') id: string): Promise<UserDto> {
|
||||
await this.ensureExists(id);
|
||||
return requireUpdated(this.db, id, { banned: false, banReason: null, banExpires: null });
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteUser(@Param('id') id: string): Promise<void> {
|
||||
await this.ensureExists(id);
|
||||
await this.db.delete(usersTable).where(eq(usersTable.id, id));
|
||||
}
|
||||
|
||||
private async ensureExists(id: string): Promise<void> {
|
||||
const [existing] = await this.db
|
||||
.select({ id: usersTable.id })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new NotFoundException('User not found');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface UserDto {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
banned: boolean;
|
||||
banReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserListDto {
|
||||
users: UserDto[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateUserDto {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRoleDto {
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface BanUserDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface HealthStatusDto {
|
||||
status: 'ok' | 'degraded' | 'error';
|
||||
database: ServiceStatusDto;
|
||||
cache: ServiceStatusDto;
|
||||
agentPool: AgentPoolStatusDto;
|
||||
providers: ProviderStatusDto[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceStatusDto {
|
||||
status: 'ok' | 'error';
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AgentPoolStatusDto {
|
||||
activeSessions: number;
|
||||
}
|
||||
|
||||
export interface ProviderStatusDto {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
modelCount: number;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
import type { Auth } from '@mosaicstack/auth';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { eq, adminTokens, users as usersTable } from '@mosaicstack/db';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
interface UserWithRole {
|
||||
id: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
type AuthenticatedRequest = FastifyRequest & { user: unknown; session: unknown };
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
constructor(
|
||||
@Inject(AUTH) private readonly auth: Auth,
|
||||
@Inject(DB) private readonly db: Db,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
|
||||
// Try bearer token auth first
|
||||
const authHeader = request.raw.headers['authorization'];
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
return this.validateBearerToken(request, authHeader.slice(7));
|
||||
}
|
||||
|
||||
// Fall back to BetterAuth session
|
||||
return this.validateSession(request);
|
||||
}
|
||||
|
||||
private async validateBearerToken(request: FastifyRequest, plaintext: string): Promise<boolean> {
|
||||
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
|
||||
|
||||
const [row] = await this.db
|
||||
.select({
|
||||
tokenId: adminTokens.id,
|
||||
userId: adminTokens.userId,
|
||||
scope: adminTokens.scope,
|
||||
expiresAt: adminTokens.expiresAt,
|
||||
userName: usersTable.name,
|
||||
userEmail: usersTable.email,
|
||||
userRole: usersTable.role,
|
||||
})
|
||||
.from(adminTokens)
|
||||
.innerJoin(usersTable, eq(adminTokens.userId, usersTable.id))
|
||||
.where(eq(adminTokens.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
throw new UnauthorizedException('Invalid API token');
|
||||
}
|
||||
|
||||
if (row.expiresAt && row.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('API token expired');
|
||||
}
|
||||
|
||||
if (row.userRole !== 'admin') {
|
||||
throw new ForbiddenException('Admin access required');
|
||||
}
|
||||
|
||||
// Update last-used timestamp (fire-and-forget)
|
||||
this.db
|
||||
.update(adminTokens)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(adminTokens.id, row.tokenId))
|
||||
.then(() => {})
|
||||
.catch(() => {});
|
||||
|
||||
const req = request as AuthenticatedRequest;
|
||||
req.user = { id: row.userId, name: row.userName, email: row.userEmail, role: row.userRole };
|
||||
req.session = { id: `token:${row.tokenId}`, userId: row.userId };
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async validateSession(request: FastifyRequest): Promise<boolean> {
|
||||
const headers = fromNodeHeaders(request.raw.headers);
|
||||
const result = await this.auth.api.getSession({ headers });
|
||||
|
||||
if (!result) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
const user = result.user as UserWithRole;
|
||||
|
||||
let userRole = user.role;
|
||||
if (!userRole) {
|
||||
const [dbUser] = await this.db
|
||||
.select({ role: usersTable.role })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, user.id))
|
||||
.limit(1);
|
||||
userRole = dbUser?.role ?? 'member';
|
||||
(user as UserWithRole).role = userRole;
|
||||
}
|
||||
|
||||
if (userRole !== 'admin') {
|
||||
throw new ForbiddenException('Admin access required');
|
||||
}
|
||||
|
||||
const req = request as AuthenticatedRequest;
|
||||
req.user = result.user;
|
||||
req.session = result.session;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller.js';
|
||||
import { AdminHealthController } from './admin-health.controller.js';
|
||||
import { AdminJobsController } from './admin-jobs.controller.js';
|
||||
import { AdminTokensController } from './admin-tokens.controller.js';
|
||||
import { BootstrapController } from './bootstrap.controller.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
AdminController,
|
||||
AdminHealthController,
|
||||
AdminJobsController,
|
||||
AdminTokensController,
|
||||
BootstrapController,
|
||||
],
|
||||
providers: [AdminGuard],
|
||||
})
|
||||
export class AdminModule {}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Inject,
|
||||
InternalServerErrorException,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
|
||||
import type { Auth } from '@mosaicstack/auth';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { BootstrapSetupDto } from './bootstrap.dto.js';
|
||||
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
||||
|
||||
@Controller('api/bootstrap')
|
||||
export class BootstrapController {
|
||||
constructor(
|
||||
@Inject(AUTH) private readonly auth: Auth,
|
||||
@Inject(DB) private readonly db: Db,
|
||||
) {}
|
||||
|
||||
@Get('status')
|
||||
async status(): Promise<BootstrapStatusDto> {
|
||||
const [result] = await this.db.select({ total: count() }).from(usersTable);
|
||||
return { needsSetup: (result?.total ?? 0) === 0 };
|
||||
}
|
||||
|
||||
@Post('setup')
|
||||
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
|
||||
// Only allow setup when zero users exist
|
||||
const [result] = await this.db.select({ total: count() }).from(usersTable);
|
||||
if ((result?.total ?? 0) > 0) {
|
||||
throw new ForbiddenException('Setup already completed — users exist');
|
||||
}
|
||||
|
||||
// Create admin user via BetterAuth API
|
||||
const authApi = this.auth.api as unknown as {
|
||||
createUser: (opts: {
|
||||
body: { name: string; email: string; password: string; role?: string };
|
||||
}) => Promise<{
|
||||
user: { id: string; name: string; email: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
const created = await authApi.createUser({
|
||||
body: {
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
password: dto.password,
|
||||
role: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify user was created
|
||||
const [user] = await this.db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, created.user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!user) throw new InternalServerErrorException('User created but not found');
|
||||
|
||||
// Ensure role is admin (createUser may not set it via BetterAuth)
|
||||
if (user.role !== 'admin') {
|
||||
await this.db.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
|
||||
}
|
||||
|
||||
// Generate admin API token
|
||||
const plaintext = randomBytes(32).toString('hex');
|
||||
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
|
||||
const tokenId = uuid();
|
||||
|
||||
const [token] = await this.db
|
||||
.insert(adminTokens)
|
||||
.values({
|
||||
id: tokenId,
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
label: 'Initial setup token',
|
||||
scope: 'admin',
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: 'admin',
|
||||
},
|
||||
token: {
|
||||
id: token!.id,
|
||||
plaintext,
|
||||
label: token!.label,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IsString, IsEmail, MinLength } from 'class-validator';
|
||||
|
||||
export class BootstrapSetupDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export interface BootstrapStatusDto {
|
||||
needsSetup: boolean;
|
||||
}
|
||||
|
||||
export interface BootstrapResultDto {
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
token: {
|
||||
id: string;
|
||||
plaintext: string;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* E2E integration test — POST /api/bootstrap/setup
|
||||
*
|
||||
* Regression guard for the `import type { BootstrapSetupDto }` class-erasure
|
||||
* bug (IUV-M01, issue #436).
|
||||
*
|
||||
* When `BootstrapSetupDto` is imported with `import type`, TypeScript erases
|
||||
* the class at compile time. NestJS then sees `Object` as the `@Body()`
|
||||
* metatype, and ValidationPipe with `whitelist:true + forbidNonWhitelisted:true`
|
||||
* treats every property as non-whitelisted, returning:
|
||||
*
|
||||
* 400 { message: ["property email should not exist", "property password should not exist"] }
|
||||
*
|
||||
* The fix is a plain value import (`import { BootstrapSetupDto }`), which
|
||||
* preserves the class reference so Nest can read the class-validator decorators.
|
||||
*
|
||||
* This test MUST fail if `import type` is re-introduced on `BootstrapSetupDto`.
|
||||
* A controller unit test that constructs ValidationPipe manually won't catch
|
||||
* this — only the real DI binding path exercises the metatype lookup.
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { BootstrapController } from './bootstrap.controller.js';
|
||||
import type { BootstrapResultDto } from './bootstrap.dto.js';
|
||||
|
||||
// ─── Minimal mock dependencies ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* We use explicit `@Inject(AUTH)` / `@Inject(DB)` in the controller so we
|
||||
* can provide mock values by token without spinning up the real DB or Auth.
|
||||
*/
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
const MOCK_USER_ID = 'mock-user-id-001';
|
||||
|
||||
const mockAuth = {
|
||||
api: {
|
||||
createUser: () =>
|
||||
Promise.resolve({
|
||||
user: {
|
||||
id: MOCK_USER_ID,
|
||||
name: 'Admin',
|
||||
email: '[email protected]',
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
// Override db.select() so the second query (verify user exists) returns a user.
|
||||
// The bootstrap controller calls select().from() twice:
|
||||
// 1. count() to check zero users → returns [{total: 0}]
|
||||
// 2. select().where().limit() → returns [the created user]
|
||||
let selectCallCount = 0;
|
||||
const mockDbWithUser = {
|
||||
select: () => {
|
||||
selectCallCount++;
|
||||
return {
|
||||
from: () => {
|
||||
if (selectCallCount === 1) {
|
||||
// First call: count — zero users
|
||||
return Promise.resolve([{ total: 0 }]);
|
||||
}
|
||||
// Subsequent calls: return a mock user row
|
||||
return {
|
||||
where: () => ({
|
||||
limit: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: MOCK_USER_ID,
|
||||
name: 'Admin',
|
||||
email: '[email protected]',
|
||||
role: 'admin',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
update: () => ({
|
||||
set: () => ({
|
||||
where: () => Promise.resolve([]),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({
|
||||
returning: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'token-id-001',
|
||||
label: 'Initial setup token',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── Test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
selectCallCount = 0;
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [BootstrapController],
|
||||
providers: [
|
||||
{ provide: AUTH, useValue: mockAuth },
|
||||
{ provide: DB, useValue: mockDbWithUser },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
|
||||
// Mirror main.ts configuration exactly — this is what reproduced the 400.
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.init();
|
||||
// Fastify requires waiting for the adapter to be ready
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns 201 (not 400) when a valid {name, email, password} body is sent', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({ name: 'Admin', email: '[email protected]', password: 'password123' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
// Before the fix (import type), Nest ValidationPipe returned 400 with
|
||||
// "property email should not exist" / "property password should not exist"
|
||||
// because the DTO class was erased and every field looked non-whitelisted.
|
||||
expect(res.status).not.toBe(400);
|
||||
expect(res.status).toBe(201);
|
||||
const body = res.body as BootstrapResultDto;
|
||||
expect(body.user).toBeDefined();
|
||||
expect(body.user.email).toBe('[email protected]');
|
||||
expect(body.token).toBeDefined();
|
||||
expect(body.token.plaintext).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 400 when extra forbidden properties are sent', async () => {
|
||||
// This proves ValidationPipe IS active and working (forbidNonWhitelisted).
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({
|
||||
name: 'Admin',
|
||||
email: '[email protected]',
|
||||
password: 'password123',
|
||||
extraField: 'should-be-rejected',
|
||||
})
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when email is invalid', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({ name: 'Admin', email: 'not-an-email', password: 'password123' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when password is too short', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({ name: 'Admin', email: '[email protected]', password: 'short' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user