feat(#85): implement CONNECT/DISCONNECT protocol
Implemented connection handshake protocol for federation building on the Instance Identity Model from issue #84. **Services:** - SignatureService: Message signing/verification with RSA-SHA256 - ConnectionService: Federation connection management **API Endpoints:** - POST /api/v1/federation/connections/initiate - POST /api/v1/federation/connections/:id/accept - POST /api/v1/federation/connections/:id/reject - POST /api/v1/federation/connections/:id/disconnect - GET /api/v1/federation/connections - GET /api/v1/federation/connections/:id - POST /api/v1/federation/incoming/connect **Tests:** 70 tests pass (18 Signature + 20 Connection + 13 Controller + 19 existing) **Coverage:** 100% on new code **TDD Approach:** Tests written before implementation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,23 @@
|
||||
* API endpoints for instance identity and federation management.
|
||||
*/
|
||||
|
||||
import { Controller, Get, Post, UseGuards, Logger, Req } from "@nestjs/common";
|
||||
import { Controller, Get, Post, UseGuards, Logger, Req, Body, Param, Query } from "@nestjs/common";
|
||||
import { FederationService } from "./federation.service";
|
||||
import { FederationAuditService } from "./audit.service";
|
||||
import { ConnectionService } from "./connection.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||
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("api/v1/federation")
|
||||
export class FederationController {
|
||||
@@ -18,7 +28,8 @@ export class FederationController {
|
||||
|
||||
constructor(
|
||||
private readonly federationService: FederationService,
|
||||
private readonly auditService: FederationAuditService
|
||||
private readonly auditService: FederationAuditService,
|
||||
private readonly connectionService: ConnectionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -52,4 +63,150 @@ export class FederationController {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a connection to a remote instance
|
||||
* Requires authentication
|
||||
*/
|
||||
@Post("connections/initiate")
|
||||
@UseGuards(AuthGuard)
|
||||
async initiateConnection(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: InitiateConnectionDto
|
||||
): Promise<ConnectionDetails> {
|
||||
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
|
||||
*/
|
||||
@Post("connections/:id/accept")
|
||||
@UseGuards(AuthGuard)
|
||||
async acceptConnection(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("id") connectionId: string,
|
||||
@Body() dto: AcceptConnectionDto
|
||||
): Promise<ConnectionDetails> {
|
||||
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
|
||||
*/
|
||||
@Post("connections/:id/reject")
|
||||
@UseGuards(AuthGuard)
|
||||
async rejectConnection(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("id") connectionId: string,
|
||||
@Body() dto: RejectConnectionDto
|
||||
): Promise<ConnectionDetails> {
|
||||
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
|
||||
*/
|
||||
@Post("connections/:id/disconnect")
|
||||
@UseGuards(AuthGuard)
|
||||
async disconnectConnection(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("id") connectionId: string,
|
||||
@Body() dto: DisconnectConnectionDto
|
||||
): Promise<ConnectionDetails> {
|
||||
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
|
||||
*/
|
||||
@Get("connections")
|
||||
@UseGuards(AuthGuard)
|
||||
async getConnections(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query("status") status?: FederationConnectionStatus
|
||||
): Promise<ConnectionDetails[]> {
|
||||
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
|
||||
*/
|
||||
@Get("connections/:id")
|
||||
@UseGuards(AuthGuard)
|
||||
async getConnection(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("id") connectionId: string
|
||||
): Promise<ConnectionDetails> {
|
||||
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)
|
||||
*/
|
||||
@Post("incoming/connect")
|
||||
async handleIncomingConnection(
|
||||
@Body() dto: IncomingConnectionRequestDto
|
||||
): Promise<{ status: string; connectionId?: string }> {
|
||||
this.logger.log(`Received connection request from ${dto.instanceId}`);
|
||||
|
||||
// For now, create connection in default workspace
|
||||
// TODO: Allow configuration of which workspace handles incoming connections
|
||||
const workspaceId = process.env.DEFAULT_WORKSPACE_ID ?? "default";
|
||||
|
||||
const connection = await this.connectionService.handleIncomingConnectionRequest(
|
||||
workspaceId,
|
||||
dto
|
||||
);
|
||||
|
||||
return {
|
||||
status: "pending",
|
||||
connectionId: connection.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user