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:
Jason Woltje
2026-02-03 11:41:07 -06:00
parent b336d9c1f7
commit fc3919012f
13 changed files with 2063 additions and 19 deletions

View File

@@ -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,
};
}
}