fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting
This commit is contained in:
@@ -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:^",
|
||||||
@@ -23,6 +24,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",
|
||||||
@@ -33,6 +35,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",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
|
|||||||
89
apps/gateway/src/__tests__/resource-ownership.test.ts
Normal file
89
apps/gateway/src/__tests__/resource-ownership.test.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -10,9 +11,11 @@ import { ProjectsModule } from './projects/projects.module.js';
|
|||||||
import { MissionsModule } from './missions/missions.module.js';
|
import { MissionsModule } from './missions/missions.module.js';
|
||||||
import { TasksModule } from './tasks/tasks.module.js';
|
import { TasksModule } from './tasks/tasks.module.js';
|
||||||
import { CoordModule } from './coord/coord.module.js';
|
import { CoordModule } from './coord/coord.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,
|
||||||
@@ -25,5 +28,11 @@ import { CoordModule } from './coord/coord.module.js';
|
|||||||
CoordModule,
|
CoordModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
11
apps/gateway/src/auth/resource-ownership.ts
Normal file
11
apps/gateway/src/auth/resource-ownership.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
apps/gateway/src/chat/__tests__/chat-security.test.ts
Normal file
80
apps/gateway/src/chat/__tests__/chat-security.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) => {
|
||||||
|
|||||||
31
apps/gateway/src/chat/chat.dto.ts
Normal file
31
apps/gateway/src/chat/chat.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
30
apps/gateway/src/chat/chat.gateway-auth.ts
Normal file
30
apps/gateway/src/chat/chat.gateway-auth.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/gateway/tsconfig.typecheck.json
Normal file
14
apps/gateway/tsconfig.typecheck.json
Normal 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
39
pnpm-lock.yaml
generated
@@ -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)
|
||||||
@@ -74,6 +77,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)
|
||||||
@@ -104,6 +110,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))
|
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))
|
||||||
|
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
|
||||||
@@ -1314,6 +1326,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==}
|
||||||
|
|
||||||
@@ -1650,6 +1665,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:
|
||||||
@@ -3550,6 +3572,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==}
|
||||||
|
|
||||||
@@ -5678,6 +5704,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
|
||||||
@@ -6031,6 +6062,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)
|
||||||
@@ -8248,6 +8285,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:
|
||||||
|
|||||||
Reference in New Issue
Block a user