feat(#89): implement COMMAND message type for federation

Implements federated command messages following TDD principles and
mirroring the QueryService pattern for consistency.

## Implementation

### Schema Changes
- Added commandType and payload fields to FederationMessage model
- Supports COMMAND message type (already defined in enum)
- Applied schema changes with prisma db push

### Type Definitions
- CommandMessage: Request structure with commandType and payload
- CommandResponse: Response structure with correlation
- CommandMessageDetails: Full message details for API responses

### CommandService
- sendCommand(): Send command to remote instance with signature
- handleIncomingCommand(): Process incoming commands with verification
- processCommandResponse(): Handle command responses
- getCommandMessages(): List commands for workspace
- getCommandMessage(): Get single command details
- Full signature verification and timestamp validation
- Error handling and status tracking

### CommandController
- POST /api/v1/federation/command - Send command (authenticated)
- POST /api/v1/federation/incoming/command - Handle incoming (public)
- GET /api/v1/federation/commands - List commands (authenticated)
- GET /api/v1/federation/commands/:id - Get command (authenticated)

## Testing
- CommandService: 15 tests, 90.21% coverage
- CommandController: 8 tests, 100% coverage
- All 23 tests passing
- Exceeds 85% coverage requirement
- Total 47 tests passing (includes command tests)

## Security
- RSA signature verification for all incoming commands
- Timestamp validation to prevent replay attacks
- Connection status validation
- Authorization checks on command types

## Quality Checks
- TypeScript compilation: PASSED
- All tests: 47 PASSED
- Code coverage: >85% (90.21% for CommandService, 100% for CommandController)
- Linting: PASSED

Fixes #89

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-03 13:28:49 -06:00
parent 1159ca42a7
commit 9501aa3867
12 changed files with 1777 additions and 2 deletions

View File

@@ -0,0 +1,91 @@
/**
* Command Controller
*
* API endpoints for federated command messages.
*/
import { Controller, Post, Get, Body, Param, Query, UseGuards, Req, Logger } from "@nestjs/common";
import { CommandService } from "./command.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { SendCommandDto, IncomingCommandDto } from "./dto/command.dto";
import type { AuthenticatedRequest } from "../common/types/user.types";
import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
import type { FederationMessageStatus } from "@prisma/client";
@Controller("api/v1/federation")
export class CommandController {
private readonly logger = new Logger(CommandController.name);
constructor(private readonly commandService: CommandService) {}
/**
* Send a command to a remote instance
* Requires authentication
*/
@Post("command")
@UseGuards(AuthGuard)
async sendCommand(
@Req() req: AuthenticatedRequest,
@Body() dto: SendCommandDto
): Promise<CommandMessageDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(
`User ${req.user.id} sending command to connection ${dto.connectionId} in workspace ${req.user.workspaceId}`
);
return this.commandService.sendCommand(
req.user.workspaceId,
dto.connectionId,
dto.commandType,
dto.payload
);
}
/**
* Handle incoming command from remote instance
* Public endpoint - no authentication required (signature-based verification)
*/
@Post("incoming/command")
async handleIncomingCommand(@Body() dto: IncomingCommandDto): Promise<CommandResponse> {
this.logger.log(`Received command from ${dto.instanceId}: ${dto.messageId}`);
return this.commandService.handleIncomingCommand(dto);
}
/**
* Get all command messages for the workspace
* Requires authentication
*/
@Get("commands")
@UseGuards(AuthGuard)
async getCommands(
@Req() req: AuthenticatedRequest,
@Query("status") status?: FederationMessageStatus
): Promise<CommandMessageDetails[]> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
return this.commandService.getCommandMessages(req.user.workspaceId, status);
}
/**
* Get a single command message
* Requires authentication
*/
@Get("commands/:id")
@UseGuards(AuthGuard)
async getCommand(
@Req() req: AuthenticatedRequest,
@Param("id") messageId: string
): Promise<CommandMessageDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
return this.commandService.getCommandMessage(req.user.workspaceId, messageId);
}
}