/** * Federation Controller * * API endpoints for instance identity and federation management. * Issue #272: Rate limiting applied to prevent DoS attacks */ import { Controller, Get, Post, UseGuards, Logger, Req, Body, Param, Query } from "@nestjs/common"; import { Throttle } from "@nestjs/throttler"; import { FederationService } from "./federation.service"; import { FederationAuditService } from "./audit.service"; import { ConnectionService } from "./connection.service"; import { getDefaultWorkspaceId } from "./federation.config"; import { AuthGuard } from "../auth/guards/auth.guard"; import { AdminGuard } from "../auth/guards/admin.guard"; import { WorkspaceGuard } from "../common/guards/workspace.guard"; import { SkipCsrf } from "../common/decorators/skip-csrf.decorator"; import type { PublicInstanceIdentity } from "./types/instance.types"; import type { ConnectionDetails } from "./types/connection.types"; import type { AuthenticatedRequest } from "../common/types/user.types"; import { InitiateConnectionDto, AcceptConnectionDto, RejectConnectionDto, DisconnectConnectionDto, IncomingConnectionRequestDto, } from "./dto/connection.dto"; import { FederationConnectionStatus } from "@prisma/client"; @Controller("v1/federation") export class FederationController { private readonly logger = new Logger(FederationController.name); constructor( private readonly federationService: FederationService, private readonly auditService: FederationAuditService, private readonly connectionService: ConnectionService ) {} /** * Get this instance's public identity * No authentication required - this is public information for federation * Rate limit: "long" tier (200 req/hour) - public endpoint * CSRF exempt: GET method (safe) */ @Get("instance") @Throttle({ long: { limit: 200, ttl: 3600000 } }) async getInstance(): Promise { this.logger.debug("GET /api/v1/federation/instance"); return this.federationService.getPublicIdentity(); } /** * Regenerate instance keypair * Requires system administrator privileges * Returns public identity only (private key never exposed in API) * Rate limit: "medium" tier (20 req/min) - sensitive admin operation */ @Post("instance/regenerate-keys") @UseGuards(AuthGuard, AdminGuard) @Throttle({ medium: { limit: 20, ttl: 60000 } }) async regenerateKeys(@Req() req: AuthenticatedRequest): Promise { if (!req.user) { throw new Error("User not authenticated"); } this.logger.warn(`Admin user ${req.user.id} regenerating instance keypair`); const result = await this.federationService.regenerateKeypair(); // Audit log for security compliance this.auditService.logKeypairRegeneration(req.user.id, result.instanceId); return result; } /** * Initiate a connection to a remote instance * Requires authentication and workspace access * Rate limit: "medium" tier (20 req/min) - authenticated endpoint */ @Post("connections/initiate") @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ medium: { limit: 20, ttl: 60000 } }) async initiateConnection( @Req() req: AuthenticatedRequest, @Body() dto: InitiateConnectionDto ): Promise { if (!req.user?.workspaceId) { throw new Error("Workspace ID not found in request"); } this.logger.log( `User ${req.user.id} initiating connection to ${dto.remoteUrl} for workspace ${req.user.workspaceId}` ); return this.connectionService.initiateConnection(req.user.workspaceId, dto.remoteUrl); } /** * Accept a pending connection * Requires authentication and workspace access * Rate limit: "medium" tier (20 req/min) - authenticated endpoint */ @Post("connections/:id/accept") @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ medium: { limit: 20, ttl: 60000 } }) async acceptConnection( @Req() req: AuthenticatedRequest, @Param("id") connectionId: string, @Body() dto: AcceptConnectionDto ): Promise { if (!req.user?.workspaceId) { throw new Error("Workspace ID not found in request"); } this.logger.log( `User ${req.user.id} accepting connection ${connectionId} for workspace ${req.user.workspaceId}` ); return this.connectionService.acceptConnection( req.user.workspaceId, connectionId, dto.metadata ); } /** * Reject a pending connection * Requires authentication and workspace access * Rate limit: "medium" tier (20 req/min) - authenticated endpoint */ @Post("connections/:id/reject") @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ medium: { limit: 20, ttl: 60000 } }) async rejectConnection( @Req() req: AuthenticatedRequest, @Param("id") connectionId: string, @Body() dto: RejectConnectionDto ): Promise { if (!req.user?.workspaceId) { throw new Error("Workspace ID not found in request"); } this.logger.log(`User ${req.user.id} rejecting connection ${connectionId}: ${dto.reason}`); return this.connectionService.rejectConnection(req.user.workspaceId, connectionId, dto.reason); } /** * Disconnect an active connection * Requires authentication and workspace access * Rate limit: "medium" tier (20 req/min) - authenticated endpoint */ @Post("connections/:id/disconnect") @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ medium: { limit: 20, ttl: 60000 } }) async disconnectConnection( @Req() req: AuthenticatedRequest, @Param("id") connectionId: string, @Body() dto: DisconnectConnectionDto ): Promise { if (!req.user?.workspaceId) { throw new Error("Workspace ID not found in request"); } this.logger.log(`User ${req.user.id} disconnecting connection ${connectionId}`); return this.connectionService.disconnect(req.user.workspaceId, connectionId, dto.reason); } /** * Get all connections for the workspace * Requires authentication and workspace access * Rate limit: "long" tier (200 req/hour) - read-only endpoint */ @Get("connections") @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ long: { limit: 200, ttl: 3600000 } }) async getConnections( @Req() req: AuthenticatedRequest, @Query("status") status?: FederationConnectionStatus ): Promise { if (!req.user?.workspaceId) { throw new Error("Workspace ID not found in request"); } return this.connectionService.getConnections(req.user.workspaceId, status); } /** * Get a single connection * Requires authentication and workspace access * Rate limit: "long" tier (200 req/hour) - read-only endpoint */ @Get("connections/:id") @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ long: { limit: 200, ttl: 3600000 } }) async getConnection( @Req() req: AuthenticatedRequest, @Param("id") connectionId: string ): Promise { if (!req.user?.workspaceId) { throw new Error("Workspace ID not found in request"); } return this.connectionService.getConnection(req.user.workspaceId, connectionId); } /** * Handle incoming connection request from remote instance * Public endpoint - no authentication required (signature-based verification) * Rate limit: "short" tier (3 req/sec) - CRITICAL DoS protection (Issue #272) * CSRF exempt: Uses signature-based authentication instead */ @Post("incoming/connect") @SkipCsrf() @Throttle({ short: { limit: 3, ttl: 1000 } }) async handleIncomingConnection( @Body() dto: IncomingConnectionRequestDto ): Promise<{ status: string; connectionId?: string }> { this.logger.log(`Received connection request from ${dto.instanceId}`); // LIMITATION: Incoming connections are created in a default workspace // TODO: Future enhancement - Allow configuration of which workspace handles incoming connections // This could be based on routing rules, instance configuration, or a dedicated federation workspace // Issue #338: Validate DEFAULT_WORKSPACE_ID is a valid UUID (throws if invalid/missing) const workspaceId = getDefaultWorkspaceId(); const connection = await this.connectionService.handleIncomingConnectionRequest( workspaceId, dto ); return { status: "pending", connectionId: connection.id, }; } }