Implements the final piece of M7-Federation - the spoke configuration UI that allows administrators to configure their local instance's federation capabilities and settings. Backend Changes: - Add UpdateInstanceDto with validation for name, capabilities, and metadata - Implement FederationService.updateInstanceConfiguration() method - Add PATCH /api/v1/federation/instance endpoint to FederationController - Add audit logging for configuration updates - Add tests for updateInstanceConfiguration (5 new tests, all passing) Frontend Changes: - Create SpokeConfigurationForm component with PDA-friendly design - Create /federation/settings page with configuration management - Add regenerate keypair functionality with confirmation dialog - Extend federation API client with updateInstanceConfiguration and regenerateInstanceKeys - Add comprehensive tests (10 tests, all passing) Design Decisions: - Admin-only access via AdminGuard - Never expose private key in API responses (security) - PDA-friendly language throughout (no demanding terms) - Clear visual hierarchy with read-only and editable fields - Truncated public key with copy button for usability - Confirmation dialog for destructive key regeneration All tests passing: - Backend: 13/13 federation service tests passing - Frontend: 10/10 SpokeConfigurationForm tests passing - TypeScript compilation: passing - Linting: passing - PDA-friendliness: verified This completes M7-Federation. All federation features are now implemented. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
/**
|
|
* Federation Controller
|
|
*
|
|
* API endpoints for instance identity and federation management.
|
|
*/
|
|
|
|
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Patch,
|
|
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 { FederationAgentService } from "./federation-agent.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 { CommandMessageDetails } from "./types/message.types";
|
|
import type { AuthenticatedRequest } from "../common/types/user.types";
|
|
import {
|
|
InitiateConnectionDto,
|
|
AcceptConnectionDto,
|
|
RejectConnectionDto,
|
|
DisconnectConnectionDto,
|
|
IncomingConnectionRequestDto,
|
|
} from "./dto/connection.dto";
|
|
import { UpdateInstanceDto } from "./dto/instance.dto";
|
|
import type { SpawnAgentCommandPayload } from "./types/federation-agent.types";
|
|
import { FederationConnectionStatus } from "@prisma/client";
|
|
|
|
@Controller("api/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,
|
|
private readonly federationAgentService: FederationAgentService
|
|
) {}
|
|
|
|
/**
|
|
* Get this instance's public identity
|
|
* No authentication required - this is public information for federation
|
|
*/
|
|
@Get("instance")
|
|
async getInstance(): Promise<PublicInstanceIdentity> {
|
|
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)
|
|
*/
|
|
@Post("instance/regenerate-keys")
|
|
@UseGuards(AuthGuard, AdminGuard)
|
|
async regenerateKeys(@Req() req: AuthenticatedRequest): Promise<PublicInstanceIdentity> {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Update instance configuration
|
|
* Requires system administrator privileges
|
|
* Allows updating name, capabilities, and metadata
|
|
* Returns public identity only (private key never exposed in API)
|
|
*/
|
|
@Patch("instance")
|
|
@UseGuards(AuthGuard, AdminGuard)
|
|
async updateInstanceConfiguration(
|
|
@Req() req: AuthenticatedRequest,
|
|
@Body() dto: UpdateInstanceDto
|
|
): Promise<PublicInstanceIdentity> {
|
|
if (!req.user) {
|
|
throw new Error("User not authenticated");
|
|
}
|
|
|
|
this.logger.log(`Admin user ${req.user.id} updating instance configuration`);
|
|
|
|
const result = await this.federationService.updateInstanceConfiguration(dto);
|
|
|
|
// Audit log for security compliance
|
|
const auditData: Record<string, unknown> = {};
|
|
if (dto.name !== undefined) auditData.name = dto.name;
|
|
if (dto.capabilities !== undefined) auditData.capabilities = dto.capabilities;
|
|
if (dto.metadata !== undefined) auditData.metadata = dto.metadata;
|
|
this.auditService.logInstanceConfigurationUpdate(req.user.id, result.instanceId, auditData);
|
|
|
|
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}`);
|
|
|
|
// 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
|
|
// For now, uses DEFAULT_WORKSPACE_ID environment variable or falls back to "default"
|
|
const workspaceId = process.env.DEFAULT_WORKSPACE_ID ?? "default";
|
|
|
|
const connection = await this.connectionService.handleIncomingConnectionRequest(
|
|
workspaceId,
|
|
dto
|
|
);
|
|
|
|
return {
|
|
status: "pending",
|
|
connectionId: connection.id,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Spawn an agent on a remote federated instance
|
|
* Requires authentication
|
|
*/
|
|
@Post("agents/spawn")
|
|
@UseGuards(AuthGuard)
|
|
async spawnAgentOnRemote(
|
|
@Req() req: AuthenticatedRequest,
|
|
@Body() body: { connectionId: string; payload: SpawnAgentCommandPayload }
|
|
): Promise<CommandMessageDetails> {
|
|
if (!req.user?.workspaceId) {
|
|
throw new Error("Workspace ID not found in request");
|
|
}
|
|
|
|
this.logger.log(
|
|
`User ${req.user.id} spawning agent on remote instance via connection ${body.connectionId}`
|
|
);
|
|
|
|
return this.federationAgentService.spawnAgentOnRemote(
|
|
req.user.workspaceId,
|
|
body.connectionId,
|
|
body.payload
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get agent status from remote instance
|
|
* Requires authentication
|
|
*/
|
|
@Get("agents/:agentId/status")
|
|
@UseGuards(AuthGuard)
|
|
async getAgentStatus(
|
|
@Req() req: AuthenticatedRequest,
|
|
@Param("agentId") agentId: string,
|
|
@Query("connectionId") connectionId: string
|
|
): Promise<CommandMessageDetails> {
|
|
if (!req.user?.workspaceId) {
|
|
throw new Error("Workspace ID not found in request");
|
|
}
|
|
|
|
if (!connectionId) {
|
|
throw new Error("connectionId query parameter is required");
|
|
}
|
|
|
|
this.logger.log(
|
|
`User ${req.user.id} getting agent ${agentId} status via connection ${connectionId}`
|
|
);
|
|
|
|
return this.federationAgentService.getAgentStatus(req.user.workspaceId, connectionId, agentId);
|
|
}
|
|
|
|
/**
|
|
* Kill an agent on remote instance
|
|
* Requires authentication
|
|
*/
|
|
@Post("agents/:agentId/kill")
|
|
@UseGuards(AuthGuard)
|
|
async killAgentOnRemote(
|
|
@Req() req: AuthenticatedRequest,
|
|
@Param("agentId") agentId: string,
|
|
@Body() body: { connectionId: string }
|
|
): Promise<CommandMessageDetails> {
|
|
if (!req.user?.workspaceId) {
|
|
throw new Error("Workspace ID not found in request");
|
|
}
|
|
|
|
this.logger.log(
|
|
`User ${req.user.id} killing agent ${agentId} via connection ${body.connectionId}`
|
|
);
|
|
|
|
return this.federationAgentService.killAgentOnRemote(
|
|
req.user.workspaceId,
|
|
body.connectionId,
|
|
agentId
|
|
);
|
|
}
|
|
}
|