fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting

This commit is contained in:
2026-03-13 08:25:57 -05:00
parent 01e9891243
commit 55b5a31c3c
22 changed files with 696 additions and 74 deletions

View File

@@ -8,10 +8,11 @@
"build": "tsc", "build": "tsc",
"dev": "tsx watch src/main.ts", "dev": "tsx watch src/main.ts",
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },
"dependencies": { "dependencies": {
"@fastify/helmet": "^13.0.2",
"@mariozechner/pi-ai": "~0.57.1", "@mariozechner/pi-ai": "~0.57.1",
"@mariozechner/pi-coding-agent": "~0.57.1", "@mariozechner/pi-coding-agent": "~0.57.1",
"@mosaic/auth": "workspace:^", "@mosaic/auth": "workspace:^",
@@ -26,6 +27,7 @@
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/platform-fastify": "^11.0.0", "@nestjs/platform-fastify": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0", "@nestjs/platform-socket.io": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.0.0", "@nestjs/websockets": "^11.0.0",
"@opentelemetry/auto-instrumentations-node": "^0.71.0", "@opentelemetry/auto-instrumentations-node": "^0.71.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
@@ -36,6 +38,8 @@
"@opentelemetry/semantic-conventions": "^1.40.0", "@opentelemetry/semantic-conventions": "^1.40.0",
"@sinclair/typebox": "^0.34.48", "@sinclair/typebox": "^0.34.48",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",

View File

@@ -0,0 +1,89 @@
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ConversationsController } from '../conversations/conversations.controller.js';
import { MissionsController } from '../missions/missions.controller.js';
import { ProjectsController } from '../projects/projects.controller.js';
import { TasksController } from '../tasks/tasks.controller.js';
function createBrain() {
return {
conversations: {
findAll: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
findMessages: vi.fn(),
addMessage: vi.fn(),
},
projects: {
findAll: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
},
missions: {
findAll: vi.fn(),
findById: vi.fn(),
findByProject: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
},
tasks: {
findAll: vi.fn(),
findById: vi.fn(),
findByProject: vi.fn(),
findByMission: vi.fn(),
findByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
},
};
}
describe('Resource ownership checks', () => {
it('forbids access to another user conversation', async () => {
const brain = createBrain();
brain.conversations.findById.mockResolvedValue({ id: 'conv-1', userId: 'user-2' });
const controller = new ConversationsController(brain as never);
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('forbids access to another user project', async () => {
const brain = createBrain();
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
const controller = new ProjectsController(brain as never);
await expect(controller.findOne('project-1', { id: 'user-1' })).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('forbids access to a mission owned by another project owner', async () => {
const brain = createBrain();
brain.missions.findById.mockResolvedValue({ id: 'mission-1', projectId: 'project-1' });
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
const controller = new MissionsController(brain as never);
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('forbids access to a task owned by another project owner', async () => {
const brain = createBrain();
brain.tasks.findById.mockResolvedValue({ id: 'task-1', projectId: 'project-1' });
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
const controller = new TasksController(brain as never);
await expect(controller.findOne('task-1', { id: 'user-1' })).rejects.toBeInstanceOf(
ForbiddenException,
);
});
});

View File

@@ -89,7 +89,7 @@ export class ProviderService implements OnModuleInit {
const modelsEnv = process.env['OLLAMA_MODELS'] ?? 'llama3.2,codellama,mistral'; const modelsEnv = process.env['OLLAMA_MODELS'] ?? 'llama3.2,codellama,mistral';
const modelIds = modelsEnv const modelIds = modelsEnv
.split(',') .split(',')
.map((m) => m.trim()) .map((modelId: string) => modelId.trim())
.filter(Boolean); .filter(Boolean);
this.registerCustomProvider({ this.registerCustomProvider({

View File

@@ -145,8 +145,11 @@ export class RoutingService {
private classifyTier(model: ModelInfo): CostTier { private classifyTier(model: ModelInfo): CostTier {
const cost = model.cost.input; const cost = model.cost.input;
if (cost <= COST_TIER_THRESHOLDS.cheap.maxInput) return 'cheap'; const cheapThreshold = COST_TIER_THRESHOLDS['cheap'];
if (cost <= COST_TIER_THRESHOLDS.standard.maxInput) return 'standard'; const standardThreshold = COST_TIER_THRESHOLDS['standard'];
if (cost <= cheapThreshold.maxInput) return 'cheap';
if (cost <= standardThreshold.maxInput) return 'standard';
return 'premium'; return 'premium';
} }

View File

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { HealthController } from './health/health.controller.js'; import { HealthController } from './health/health.controller.js';
import { DatabaseModule } from './database/database.module.js'; import { DatabaseModule } from './database/database.module.js';
import { AuthModule } from './auth/auth.module.js'; import { AuthModule } from './auth/auth.module.js';
@@ -14,9 +15,11 @@ import { MemoryModule } from './memory/memory.module.js';
import { LogModule } from './log/log.module.js'; import { LogModule } from './log/log.module.js';
import { SkillsModule } from './skills/skills.module.js'; import { SkillsModule } from './skills/skills.module.js';
import { PluginModule } from './plugin/plugin.module.js'; import { PluginModule } from './plugin/plugin.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@Module({ @Module({
imports: [ imports: [
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 60 }]),
DatabaseModule, DatabaseModule,
AuthModule, AuthModule,
BrainModule, BrainModule,
@@ -33,5 +36,11 @@ import { PluginModule } from './plugin/plugin.module.js';
PluginModule, PluginModule,
], ],
controllers: [HealthController], controllers: [HealthController],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -0,0 +1,11 @@
import { ForbiddenException } from '@nestjs/common';
export function assertOwner(
ownerId: string | null | undefined,
userId: string,
resourceName: string,
): void {
if (!ownerId || ownerId !== userId) {
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
}
}

View File

@@ -0,0 +1,80 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { validateSync } from 'class-validator';
import { describe, expect, it, vi } from 'vitest';
import { SendMessageDto } from '../../conversations/conversations.dto.js';
import { ChatRequestDto } from '../chat.dto.js';
import { validateSocketSession } from '../chat.gateway-auth.js';
describe('Chat controller source hardening', () => {
it('applies AuthGuard and reads the current user', () => {
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
expect(source).toContain('@UseGuards(AuthGuard)');
expect(source).toContain('@CurrentUser() user: { id: string }');
});
});
describe('WebSocket session authentication', () => {
it('returns null when the handshake does not resolve to a session', async () => {
const result = await validateSocketSession(
{},
{
api: {
getSession: vi.fn().mockResolvedValue(null),
},
},
);
expect(result).toBeNull();
});
it('returns the resolved session when Better Auth accepts the headers', async () => {
const session = { user: { id: 'user-1' }, session: { id: 'session-1' } };
const result = await validateSocketSession(
{ cookie: 'session=abc' },
{
api: {
getSession: vi.fn().mockResolvedValue(session),
},
},
);
expect(result).toBe(session);
});
});
describe('Chat DTO validation', () => {
it('rejects unsupported message roles and system messages', () => {
const dto = Object.assign(new SendMessageDto(), {
content: 'hello',
role: 'system',
});
const errors = validateSync(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('rejects oversized conversation message content above 32000 characters', () => {
const dto = Object.assign(new SendMessageDto(), {
content: 'x'.repeat(32_001),
role: 'user',
});
const errors = validateSync(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('rejects oversized chat content above 10000 characters', () => {
const dto = Object.assign(new ChatRequestDto(), {
content: 'x'.repeat(10_001),
});
const errors = validateSync(dto);
expect(errors.length).toBeGreaterThan(0);
});
});

View File

@@ -1,12 +1,20 @@
import { Controller, Post, Body, Logger, HttpException, HttpStatus, Inject } from '@nestjs/common'; import {
Controller,
Post,
Body,
Logger,
HttpException,
HttpStatus,
Inject,
UseGuards,
} from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { Throttle } from '@nestjs/throttler';
import { AgentService } from '../agent/agent.service.js'; import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { ChatRequestDto } from './chat.dto.js';
interface ChatRequest {
conversationId?: string;
content: string;
}
interface ChatResponse { interface ChatResponse {
conversationId: string; conversationId: string;
@@ -14,13 +22,18 @@ interface ChatResponse {
} }
@Controller('api/chat') @Controller('api/chat')
@UseGuards(AuthGuard)
export class ChatController { export class ChatController {
private readonly logger = new Logger(ChatController.name); private readonly logger = new Logger(ChatController.name);
constructor(@Inject(AgentService) private readonly agentService: AgentService) {} constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Post() @Post()
async chat(@Body() body: ChatRequest): Promise<ChatResponse> { @Throttle({ default: { limit: 10, ttl: 60_000 } })
async chat(
@Body() body: ChatRequestDto,
@CurrentUser() user: { id: string },
): Promise<ChatResponse> {
const conversationId = body.conversationId ?? uuid(); const conversationId = body.conversationId ?? uuid();
try { try {
@@ -36,6 +49,8 @@ export class ChatController {
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE); throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
} }
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
let responseText = ''; let responseText = '';
const done = new Promise<void>((resolve, reject) => { const done = new Promise<void>((resolve, reject) => {

View File

@@ -0,0 +1,31 @@
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ChatRequestDto {
@IsOptional()
@IsUUID()
conversationId?: string;
@IsString()
@MaxLength(10_000)
content!: string;
}
export class ChatSocketMessageDto {
@IsOptional()
@IsUUID()
conversationId?: string;
@IsString()
@MaxLength(10_000)
content!: string;
@IsOptional()
@IsString()
@MaxLength(255)
provider?: string;
@IsOptional()
@IsString()
@MaxLength(255)
modelId?: string;
}

View File

@@ -0,0 +1,30 @@
import type { IncomingHttpHeaders } from 'node:http';
import { fromNodeHeaders } from 'better-auth/node';
export interface SocketSessionResult {
session: unknown;
user: { id: string };
}
export interface SessionAuth {
api: {
getSession(context: { headers: Headers }): Promise<SocketSessionResult | null>;
};
}
export async function validateSocketSession(
headers: IncomingHttpHeaders,
auth: SessionAuth,
): Promise<SocketSessionResult | null> {
const sessionHeaders = fromNodeHeaders(headers);
const result = await auth.api.getSession({ headers: sessionHeaders });
if (!result) {
return null;
}
return {
session: result.session,
user: { id: result.user.id },
};
}

View File

@@ -11,18 +11,17 @@ import {
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import type { Auth } from '@mosaic/auth';
import { AgentService } from '../agent/agent.service.js'; import { AgentService } from '../agent/agent.service.js';
import { AUTH } from '../auth/auth.tokens.js';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { ChatSocketMessageDto } from './chat.dto.js';
interface ChatMessage { import { validateSocketSession } from './chat.gateway-auth.js';
conversationId?: string;
content: string;
provider?: string;
modelId?: string;
}
@WebSocketGateway({ @WebSocketGateway({
cors: { origin: '*' }, cors: {
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
},
namespace: '/chat', namespace: '/chat',
}) })
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@@ -35,13 +34,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
{ conversationId: string; cleanup: () => void } { conversationId: string; cleanup: () => void }
>(); >();
constructor(@Inject(AgentService) private readonly agentService: AgentService) {} constructor(
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(AUTH) private readonly auth: Auth,
) {}
afterInit(): void { afterInit(): void {
this.logger.log('Chat WebSocket gateway initialized'); this.logger.log('Chat WebSocket gateway initialized');
} }
handleConnection(client: Socket): void { async handleConnection(client: Socket): Promise<void> {
const session = await validateSocketSession(client.handshake.headers, this.auth);
if (!session) {
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
client.disconnect();
return;
}
client.data.user = session.user;
client.data.session = session.session;
this.logger.log(`Client connected: ${client.id}`); this.logger.log(`Client connected: ${client.id}`);
} }
@@ -58,7 +69,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@SubscribeMessage('message') @SubscribeMessage('message')
async handleMessage( async handleMessage(
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: ChatMessage, @MessageBody() data: ChatSocketMessageDto,
): Promise<void> { ): Promise<void> {
const conversationId = data.conversationId ?? uuid(); const conversationId = data.conversationId ?? uuid();

View File

@@ -16,7 +16,8 @@ import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';
import type { import { assertOwner } from '../auth/resource-ownership.js';
import {
CreateConversationDto, CreateConversationDto,
UpdateConversationDto, UpdateConversationDto,
SendMessageDto, SendMessageDto,
@@ -33,10 +34,8 @@ export class ConversationsController {
} }
@Get(':id') @Get(':id')
async findOne(@Param('id') id: string) { async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
const conversation = await this.brain.conversations.findById(id); return this.getOwnedConversation(id, user.id);
if (!conversation) throw new NotFoundException('Conversation not found');
return conversation;
} }
@Post() @Post()
@@ -49,7 +48,12 @@ export class ConversationsController {
} }
@Patch(':id') @Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateConversationDto) { async update(
@Param('id') id: string,
@Body() dto: UpdateConversationDto,
@CurrentUser() user: { id: string },
) {
await this.getOwnedConversation(id, user.id);
const conversation = await this.brain.conversations.update(id, dto); const conversation = await this.brain.conversations.update(id, dto);
if (!conversation) throw new NotFoundException('Conversation not found'); if (!conversation) throw new NotFoundException('Conversation not found');
return conversation; return conversation;
@@ -57,22 +61,25 @@ export class ConversationsController {
@Delete(':id') @Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) { async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
await this.getOwnedConversation(id, user.id);
const deleted = await this.brain.conversations.remove(id); const deleted = await this.brain.conversations.remove(id);
if (!deleted) throw new NotFoundException('Conversation not found'); if (!deleted) throw new NotFoundException('Conversation not found');
} }
@Get(':id/messages') @Get(':id/messages')
async listMessages(@Param('id') id: string) { async listMessages(@Param('id') id: string, @CurrentUser() user: { id: string }) {
const conversation = await this.brain.conversations.findById(id); await this.getOwnedConversation(id, user.id);
if (!conversation) throw new NotFoundException('Conversation not found');
return this.brain.conversations.findMessages(id); return this.brain.conversations.findMessages(id);
} }
@Post(':id/messages') @Post(':id/messages')
async addMessage(@Param('id') id: string, @Body() dto: SendMessageDto) { async addMessage(
const conversation = await this.brain.conversations.findById(id); @Param('id') id: string,
if (!conversation) throw new NotFoundException('Conversation not found'); @Body() dto: SendMessageDto,
@CurrentUser() user: { id: string },
) {
await this.getOwnedConversation(id, user.id);
return this.brain.conversations.addMessage({ return this.brain.conversations.addMessage({
conversationId: id, conversationId: id,
role: dto.role, role: dto.role,
@@ -80,4 +87,11 @@ export class ConversationsController {
metadata: dto.metadata, metadata: dto.metadata,
}); });
} }
private async getOwnedConversation(id: string, userId: string) {
const conversation = await this.brain.conversations.findById(id);
if (!conversation) throw new NotFoundException('Conversation not found');
assertOwner(conversation.userId, userId, 'Conversation');
return conversation;
}
} }

View File

@@ -1,15 +1,36 @@
export interface CreateConversationDto { import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class CreateConversationDto {
@IsOptional()
@IsString()
@MaxLength(255)
title?: string; title?: string;
@IsOptional()
@IsUUID()
projectId?: string; projectId?: string;
} }
export interface UpdateConversationDto { export class UpdateConversationDto {
@IsOptional()
@IsString()
@MaxLength(255)
title?: string; title?: string;
@IsOptional()
@IsUUID()
projectId?: string | null; projectId?: string | null;
} }
export interface SendMessageDto { export class SendMessageDto {
role: 'user' | 'assistant' | 'system'; @IsIn(['user', 'assistant', 'system'])
content: string; role!: 'user' | 'assistant' | 'system';
@IsString()
@MaxLength(10_000)
content!: string;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }

View File

@@ -1,19 +1,36 @@
import './tracing.js'; import './tracing.js';
import 'reflect-metadata'; import 'reflect-metadata';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common'; import { Logger, ValidationPipe } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import helmet from '@fastify/helmet';
import { AppModule } from './app.module.js'; import { AppModule } from './app.module.js';
import { mountAuthHandler } from './auth/auth.controller.js'; import { mountAuthHandler } from './auth/auth.controller.js';
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
if (!process.env['BETTER_AUTH_SECRET']) {
throw new Error('BETTER_AUTH_SECRET is required');
}
const logger = new Logger('Bootstrap'); const logger = new Logger('Bootstrap');
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter()); const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ bodyLimit: 1_048_576 }),
);
await app.register(helmet as never, { contentSecurityPolicy: false });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
mountAuthHandler(app); mountAuthHandler(app);
const port = process.env['GATEWAY_PORT'] ?? 4000; const port = Number(process.env['GATEWAY_PORT'] ?? 4000);
await app.listen(port as number, '0.0.0.0'); await app.listen(port, '0.0.0.0');
logger.log(`Gateway listening on port ${port}`); logger.log(`Gateway listening on port ${port}`);
} }

View File

@@ -2,6 +2,7 @@ import {
Body, Body,
Controller, Controller,
Delete, Delete,
ForbiddenException,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
@@ -15,7 +16,9 @@ import {
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import type { CreateMissionDto, UpdateMissionDto } from './missions.dto.js'; import { CurrentUser } from '../auth/current-user.decorator.js';
import { assertOwner } from '../auth/resource-ownership.js';
import { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
@Controller('api/missions') @Controller('api/missions')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@@ -28,10 +31,8 @@ export class MissionsController {
} }
@Get(':id') @Get(':id')
async findOne(@Param('id') id: string) { async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
const mission = await this.brain.missions.findById(id); return this.getOwnedMission(id, user.id);
if (!mission) throw new NotFoundException('Mission not found');
return mission;
} }
@Post() @Post()
@@ -45,7 +46,15 @@ export class MissionsController {
} }
@Patch(':id') @Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateMissionDto) { async update(
@Param('id') id: string,
@Body() dto: UpdateMissionDto,
@CurrentUser() user: { id: string },
) {
await this.getOwnedMission(id, user.id);
if (dto.projectId) {
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
}
const mission = await this.brain.missions.update(id, dto); const mission = await this.brain.missions.update(id, dto);
if (!mission) throw new NotFoundException('Mission not found'); if (!mission) throw new NotFoundException('Mission not found');
return mission; return mission;
@@ -53,8 +62,34 @@ export class MissionsController {
@Delete(':id') @Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) { async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
await this.getOwnedMission(id, user.id);
const deleted = await this.brain.missions.remove(id); const deleted = await this.brain.missions.remove(id);
if (!deleted) throw new NotFoundException('Mission not found'); if (!deleted) throw new NotFoundException('Mission not found');
} }
private async getOwnedMission(id: string, userId: string) {
const mission = await this.brain.missions.findById(id);
if (!mission) throw new NotFoundException('Mission not found');
await this.getOwnedProject(mission.projectId, userId, 'Mission');
return mission;
}
private async getOwnedProject(
projectId: string | null | undefined,
userId: string,
resourceName: string,
) {
if (!projectId) {
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
}
const project = await this.brain.projects.findById(projectId);
if (!project) {
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
}
assertOwner(project.ownerId, userId, resourceName);
return project;
}
} }

View File

@@ -1,14 +1,46 @@
export interface CreateMissionDto { import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
name: string;
const missionStatuses = ['planning', 'active', 'paused', 'completed', 'failed'] as const;
export class CreateMissionDto {
@IsString()
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string; description?: string;
@IsOptional()
@IsUUID()
projectId?: string; projectId?: string;
@IsOptional()
@IsIn(missionStatuses)
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed'; status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
} }
export interface UpdateMissionDto { export class UpdateMissionDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string; name?: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string | null; description?: string | null;
@IsOptional()
@IsUUID()
projectId?: string | null; projectId?: string | null;
@IsOptional()
@IsIn(missionStatuses)
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed'; status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
@IsOptional()
@IsObject()
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
} }

View File

@@ -16,7 +16,8 @@ import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';
import type { CreateProjectDto, UpdateProjectDto } from './projects.dto.js'; import { assertOwner } from '../auth/resource-ownership.js';
import { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
@Controller('api/projects') @Controller('api/projects')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@@ -29,10 +30,8 @@ export class ProjectsController {
} }
@Get(':id') @Get(':id')
async findOne(@Param('id') id: string) { async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
const project = await this.brain.projects.findById(id); return this.getOwnedProject(id, user.id);
if (!project) throw new NotFoundException('Project not found');
return project;
} }
@Post() @Post()
@@ -46,7 +45,12 @@ export class ProjectsController {
} }
@Patch(':id') @Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateProjectDto) { async update(
@Param('id') id: string,
@Body() dto: UpdateProjectDto,
@CurrentUser() user: { id: string },
) {
await this.getOwnedProject(id, user.id);
const project = await this.brain.projects.update(id, dto); const project = await this.brain.projects.update(id, dto);
if (!project) throw new NotFoundException('Project not found'); if (!project) throw new NotFoundException('Project not found');
return project; return project;
@@ -54,8 +58,16 @@ export class ProjectsController {
@Delete(':id') @Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) { async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
await this.getOwnedProject(id, user.id);
const deleted = await this.brain.projects.remove(id); const deleted = await this.brain.projects.remove(id);
if (!deleted) throw new NotFoundException('Project not found'); if (!deleted) throw new NotFoundException('Project not found');
} }
private async getOwnedProject(id: string, userId: string) {
const project = await this.brain.projects.findById(id);
if (!project) throw new NotFoundException('Project not found');
assertOwner(project.ownerId, userId, 'Project');
return project;
}
} }

View File

@@ -1,12 +1,38 @@
export interface CreateProjectDto { import { IsIn, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
name: string;
const projectStatuses = ['active', 'paused', 'completed', 'archived'] as const;
export class CreateProjectDto {
@IsString()
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string; description?: string;
@IsOptional()
@IsIn(projectStatuses)
status?: 'active' | 'paused' | 'completed' | 'archived'; status?: 'active' | 'paused' | 'completed' | 'archived';
} }
export interface UpdateProjectDto { export class UpdateProjectDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string; name?: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string | null; description?: string | null;
@IsOptional()
@IsIn(projectStatuses)
status?: 'active' | 'paused' | 'completed' | 'archived'; status?: 'active' | 'paused' | 'completed' | 'archived';
@IsOptional()
@IsObject()
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
} }

View File

@@ -2,6 +2,7 @@ import {
Body, Body,
Controller, Controller,
Delete, Delete,
ForbiddenException,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
@@ -16,7 +17,9 @@ import {
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import type { CreateTaskDto, UpdateTaskDto } from './tasks.dto.js'; import { CurrentUser } from '../auth/current-user.decorator.js';
import { assertOwner } from '../auth/resource-ownership.js';
import { CreateTaskDto, UpdateTaskDto } from './tasks.dto.js';
@Controller('api/tasks') @Controller('api/tasks')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@@ -39,10 +42,8 @@ export class TasksController {
} }
@Get(':id') @Get(':id')
async findOne(@Param('id') id: string) { async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
const task = await this.brain.tasks.findById(id); return this.getOwnedTask(id, user.id);
if (!task) throw new NotFoundException('Task not found');
return task;
} }
@Post() @Post()
@@ -61,7 +62,18 @@ export class TasksController {
} }
@Patch(':id') @Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateTaskDto) { async update(
@Param('id') id: string,
@Body() dto: UpdateTaskDto,
@CurrentUser() user: { id: string },
) {
await this.getOwnedTask(id, user.id);
if (dto.projectId) {
await this.getOwnedProject(dto.projectId, user.id, 'Task');
}
if (dto.missionId) {
await this.getOwnedMission(dto.missionId, user.id, 'Task');
}
const task = await this.brain.tasks.update(id, { const task = await this.brain.tasks.update(id, {
...dto, ...dto,
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined, dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
@@ -72,8 +84,46 @@ export class TasksController {
@Delete(':id') @Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) { async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
await this.getOwnedTask(id, user.id);
const deleted = await this.brain.tasks.remove(id); const deleted = await this.brain.tasks.remove(id);
if (!deleted) throw new NotFoundException('Task not found'); if (!deleted) throw new NotFoundException('Task not found');
} }
private async getOwnedTask(id: string, userId: string) {
const task = await this.brain.tasks.findById(id);
if (!task) throw new NotFoundException('Task not found');
if (task.projectId) {
await this.getOwnedProject(task.projectId, userId, 'Task');
return task;
}
if (task.missionId) {
await this.getOwnedMission(task.missionId, userId, 'Task');
return task;
}
throw new ForbiddenException('Task does not belong to the current user');
}
private async getOwnedMission(missionId: string, userId: string, resourceName: string) {
const mission = await this.brain.missions.findById(missionId);
if (!mission?.projectId) {
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
}
await this.getOwnedProject(mission.projectId, userId, resourceName);
return mission;
}
private async getOwnedProject(projectId: string, userId: string, resourceName: string) {
const project = await this.brain.projects.findById(projectId);
if (!project) {
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
}
assertOwner(project.ownerId, userId, resourceName);
return project;
}
} }

View File

@@ -1,24 +1,103 @@
export interface CreateTaskDto { import {
title: string; ArrayMaxSize,
IsArray,
IsIn,
IsISO8601,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
const taskStatuses = ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'] as const;
const taskPriorities = ['critical', 'high', 'medium', 'low'] as const;
export class CreateTaskDto {
@IsString()
@MaxLength(255)
title!: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string; description?: string;
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@IsOptional()
@IsIn(taskPriorities)
priority?: 'critical' | 'high' | 'medium' | 'low'; priority?: 'critical' | 'high' | 'medium' | 'low';
@IsOptional()
@IsUUID()
projectId?: string; projectId?: string;
@IsOptional()
@IsUUID()
missionId?: string; missionId?: string;
@IsOptional()
@IsString()
@MaxLength(255)
assignee?: string; assignee?: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
tags?: string[]; tags?: string[];
@IsOptional()
@IsISO8601()
dueDate?: string; dueDate?: string;
} }
export interface UpdateTaskDto { export class UpdateTaskDto {
@IsOptional()
@IsString()
@MaxLength(255)
title?: string; title?: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string | null; description?: string | null;
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@IsOptional()
@IsIn(taskPriorities)
priority?: 'critical' | 'high' | 'medium' | 'low'; priority?: 'critical' | 'high' | 'medium' | 'low';
@IsOptional()
@IsUUID()
projectId?: string | null; projectId?: string | null;
@IsOptional()
@IsUUID()
missionId?: string | null; missionId?: string | null;
@IsOptional()
@IsString()
@MaxLength(255)
assignee?: string | null; assignee?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
tags?: string[] | null; tags?: string[] | null;
@IsOptional()
@IsISO8601()
dueDate?: string | null; dueDate?: string | null;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
} }

View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "../..",
"baseUrl": ".",
"paths": {
"@mosaic/auth": ["../../packages/auth/src/index.ts"],
"@mosaic/brain": ["../../packages/brain/src/index.ts"],
"@mosaic/coord": ["../../packages/coord/src/index.ts"],
"@mosaic/db": ["../../packages/db/src/index.ts"],
"@mosaic/types": ["../../packages/types/src/index.ts"]
}
}
}

39
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
apps/gateway: apps/gateway:
dependencies: dependencies:
'@fastify/helmet':
specifier: ^13.0.2
version: 13.0.2
'@mariozechner/pi-ai': '@mariozechner/pi-ai':
specifier: ~0.57.1 specifier: ~0.57.1
version: 0.57.1(ws@8.19.0)(zod@4.3.6) version: 0.57.1(ws@8.19.0)(zod@4.3.6)
@@ -83,6 +86,9 @@ importers:
'@nestjs/platform-socket.io': '@nestjs/platform-socket.io':
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(rxjs@7.8.2) version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(rxjs@7.8.2)
'@nestjs/throttler':
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)
'@nestjs/websockets': '@nestjs/websockets':
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -113,6 +119,12 @@ importers:
better-auth: better-auth:
specifier: ^1.5.5 specifier: ^1.5.5
version: 1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)) version: 1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(lightningcss@1.31.1))
class-transformer:
specifier: ^0.5.1
version: 0.5.1
class-validator:
specifier: ^0.15.1
version: 0.15.1
fastify: fastify:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.8.2 version: 5.8.2
@@ -1371,6 +1383,9 @@ packages:
'@fastify/forwarded@3.0.1': '@fastify/forwarded@3.0.1':
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
'@fastify/helmet@13.0.2':
resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==}
'@fastify/merge-json-schemas@0.2.1': '@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
@@ -1720,6 +1735,13 @@ packages:
'@nestjs/websockets': ^11.0.0 '@nestjs/websockets': ^11.0.0
rxjs: ^7.1.0 rxjs: ^7.1.0
'@nestjs/throttler@6.5.0':
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
peerDependencies:
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
reflect-metadata: ^0.1.13 || ^0.2.0
'@nestjs/websockets@11.1.16': '@nestjs/websockets@11.1.16':
resolution: {integrity: sha512-kfLhCFsq6139JVFCQpbFB6LOEjZzdpE7JzXsZtRbVjqmsgTKVSIh8gKRgzpcq27rbLNqHhhZavboOltOfSxZow==} resolution: {integrity: sha512-kfLhCFsq6139JVFCQpbFB6LOEjZzdpE7JzXsZtRbVjqmsgTKVSIh8gKRgzpcq27rbLNqHhhZavboOltOfSxZow==}
peerDependencies: peerDependencies:
@@ -3739,6 +3761,10 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
helmet@8.1.0:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
highlight.js@10.7.3: highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
@@ -5992,6 +6018,11 @@ snapshots:
'@fastify/forwarded@3.0.1': {} '@fastify/forwarded@3.0.1': {}
'@fastify/helmet@13.0.2':
dependencies:
fastify-plugin: 5.1.0
helmet: 8.1.0
'@fastify/merge-json-schemas@0.2.1': '@fastify/merge-json-schemas@0.2.1':
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
@@ -6362,6 +6393,12 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@nestjs/websockets@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': '@nestjs/websockets@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies: dependencies:
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -8675,6 +8712,8 @@ snapshots:
has-flag@4.0.0: {} has-flag@4.0.0: {}
helmet@8.1.0: {}
highlight.js@10.7.3: {} highlight.js@10.7.3: {}
hosted-git-info@9.0.2: hosted-git-info@9.0.2: