fix: Resolve merge conflicts with develop
Some checks failed
ci/woodpecker/pr/woodpecker Pipeline failed
ci/woodpecker/push/woodpecker Pipeline failed

Merged OIDC validation changes (#271) with rate limiting (#272)
Both features are now active together
This commit is contained in:
2026-02-03 19:32:34 -06:00
491 changed files with 26522 additions and 2240 deletions

View File

@@ -25,6 +25,25 @@ export class FederationAuditService {
});
}
/**
* Log instance configuration update (system-level operation)
* Logged to application logs for security audit trail
*/
logInstanceConfigurationUpdate(
userId: string,
instanceId: string,
updates: Record<string, unknown>
): void {
this.logger.log({
event: "FEDERATION_INSTANCE_CONFIG_UPDATED",
userId,
instanceId,
updates,
timestamp: new Date().toISOString(),
securityEvent: true,
});
}
/**
* Log federated authentication initiation
*/
@@ -62,4 +81,46 @@ export class FederationAuditService {
securityEvent: true,
});
}
/**
* Log identity verification attempt
*/
logIdentityVerification(userId: string, remoteInstanceId: string, success: boolean): void {
const level = success ? "log" : "warn";
this.logger[level]({
event: "FEDERATION_IDENTITY_VERIFIED",
userId,
remoteInstanceId,
success,
timestamp: new Date().toISOString(),
securityEvent: true,
});
}
/**
* Log identity linking (create mapping)
*/
logIdentityLinking(localUserId: string, remoteInstanceId: string, remoteUserId: string): void {
this.logger.log({
event: "FEDERATION_IDENTITY_LINKED",
localUserId,
remoteUserId,
remoteInstanceId,
timestamp: new Date().toISOString(),
securityEvent: true,
});
}
/**
* Log identity revocation (remove mapping)
*/
logIdentityRevocation(localUserId: string, remoteInstanceId: string): void {
this.logger.warn({
event: "FEDERATION_IDENTITY_REVOKED",
localUserId,
remoteInstanceId,
timestamp: new Date().toISOString(),
securityEvent: true,
});
}
}

View File

@@ -0,0 +1,236 @@
/**
* Command Controller Tests
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { CommandController } from "./command.controller";
import { CommandService } from "./command.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { FederationMessageType, FederationMessageStatus } from "@prisma/client";
import type { AuthenticatedRequest } from "../common/types/user.types";
import type { CommandMessage, CommandResponse } from "./types/message.types";
describe("CommandController", () => {
let controller: CommandController;
let commandService: CommandService;
const mockWorkspaceId = "workspace-123";
const mockUserId = "user-123";
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CommandController],
providers: [
{
provide: CommandService,
useValue: {
sendCommand: vi.fn(),
handleIncomingCommand: vi.fn(),
getCommandMessages: vi.fn(),
getCommandMessage: vi.fn(),
},
},
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<CommandController>(CommandController);
commandService = module.get<CommandService>(CommandService);
});
describe("sendCommand", () => {
it("should send a command", async () => {
const req = {
user: { id: mockUserId, workspaceId: mockWorkspaceId },
} as AuthenticatedRequest;
const dto = {
connectionId: "conn-123",
commandType: "spawn_agent",
payload: { agentType: "task_executor" },
};
const mockResult = {
id: "msg-123",
workspaceId: mockWorkspaceId,
connectionId: "conn-123",
messageType: FederationMessageType.COMMAND,
messageId: "cmd-123",
commandType: "spawn_agent",
payload: { agentType: "task_executor" },
status: FederationMessageStatus.PENDING,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.spyOn(commandService, "sendCommand").mockResolvedValue(mockResult as never);
const result = await controller.sendCommand(req, dto);
expect(result).toEqual(mockResult);
expect(commandService.sendCommand).toHaveBeenCalledWith(
mockWorkspaceId,
"conn-123",
"spawn_agent",
{ agentType: "task_executor" }
);
});
it("should throw error if workspace ID not found", async () => {
const req = {
user: { id: mockUserId },
} as AuthenticatedRequest;
const dto = {
connectionId: "conn-123",
commandType: "test",
payload: {},
};
await expect(controller.sendCommand(req, dto)).rejects.toThrow(
"Workspace ID not found in request"
);
});
});
describe("handleIncomingCommand", () => {
it("should handle an incoming command", async () => {
const dto: CommandMessage = {
messageId: "cmd-123",
instanceId: "remote-instance",
commandType: "spawn_agent",
payload: { agentType: "task_executor" },
timestamp: Date.now(),
signature: "signature-123",
};
const mockResponse: CommandResponse = {
messageId: "resp-123",
correlationId: "cmd-123",
instanceId: "local-instance",
success: true,
data: { result: "success" },
timestamp: Date.now(),
signature: "response-signature",
};
vi.spyOn(commandService, "handleIncomingCommand").mockResolvedValue(mockResponse);
const result = await controller.handleIncomingCommand(dto);
expect(result).toEqual(mockResponse);
expect(commandService.handleIncomingCommand).toHaveBeenCalledWith(dto);
});
});
describe("getCommands", () => {
it("should return all commands for workspace", async () => {
const req = {
user: { id: mockUserId, workspaceId: mockWorkspaceId },
} as AuthenticatedRequest;
const mockCommands = [
{
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: "conn-123",
messageType: FederationMessageType.COMMAND,
messageId: "cmd-1",
commandType: "test",
payload: {},
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
},
];
vi.spyOn(commandService, "getCommandMessages").mockResolvedValue(mockCommands as never);
const result = await controller.getCommands(req);
expect(result).toEqual(mockCommands);
expect(commandService.getCommandMessages).toHaveBeenCalledWith(mockWorkspaceId, undefined);
});
it("should filter commands by status", async () => {
const req = {
user: { id: mockUserId, workspaceId: mockWorkspaceId },
} as AuthenticatedRequest;
const mockCommands = [
{
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: "conn-123",
messageType: FederationMessageType.COMMAND,
messageId: "cmd-1",
commandType: "test",
payload: {},
status: FederationMessageStatus.PENDING,
createdAt: new Date(),
updatedAt: new Date(),
},
];
vi.spyOn(commandService, "getCommandMessages").mockResolvedValue(mockCommands as never);
await controller.getCommands(req, FederationMessageStatus.PENDING);
expect(commandService.getCommandMessages).toHaveBeenCalledWith(
mockWorkspaceId,
FederationMessageStatus.PENDING
);
});
it("should throw error if workspace ID not found", async () => {
const req = {
user: { id: mockUserId },
} as AuthenticatedRequest;
await expect(controller.getCommands(req)).rejects.toThrow(
"Workspace ID not found in request"
);
});
});
describe("getCommand", () => {
it("should return a single command", async () => {
const req = {
user: { id: mockUserId, workspaceId: mockWorkspaceId },
} as AuthenticatedRequest;
const mockCommand = {
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: "conn-123",
messageType: FederationMessageType.COMMAND,
messageId: "cmd-1",
commandType: "test",
payload: { key: "value" },
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.spyOn(commandService, "getCommandMessage").mockResolvedValue(mockCommand as never);
const result = await controller.getCommand(req, "msg-1");
expect(result).toEqual(mockCommand);
expect(commandService.getCommandMessage).toHaveBeenCalledWith(mockWorkspaceId, "msg-1");
});
it("should throw error if workspace ID not found", async () => {
const req = {
user: { id: mockUserId },
} as AuthenticatedRequest;
await expect(controller.getCommand(req, "msg-1")).rejects.toThrow(
"Workspace ID not found in request"
);
});
});
});

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);
}
}

View File

@@ -0,0 +1,574 @@
/**
* Command Service Tests
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { HttpService } from "@nestjs/axios";
import { CommandService } from "./command.service";
import { PrismaService } from "../prisma/prisma.service";
import { FederationService } from "./federation.service";
import { SignatureService } from "./signature.service";
import {
FederationConnectionStatus,
FederationMessageType,
FederationMessageStatus,
} from "@prisma/client";
import { of } from "rxjs";
import type { CommandMessage, CommandResponse } from "./types/message.types";
describe("CommandService", () => {
let service: CommandService;
let prisma: PrismaService;
let federationService: FederationService;
let signatureService: SignatureService;
let httpService: HttpService;
const mockWorkspaceId = "workspace-123";
const mockConnectionId = "connection-123";
const mockInstanceId = "instance-456";
const mockRemoteUrl = "https://remote.example.com";
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CommandService,
{
provide: PrismaService,
useValue: {
federationConnection: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
federationMessage: {
create: vi.fn(),
update: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
},
},
},
{
provide: FederationService,
useValue: {
getInstanceIdentity: vi.fn(),
},
},
{
provide: SignatureService,
useValue: {
signMessage: vi.fn(),
verifyMessage: vi.fn(),
validateTimestamp: vi.fn(),
},
},
{
provide: HttpService,
useValue: {
post: vi.fn(),
},
},
],
}).compile();
service = module.get<CommandService>(CommandService);
prisma = module.get<PrismaService>(PrismaService);
federationService = module.get<FederationService>(FederationService);
signatureService = module.get<SignatureService>(SignatureService);
httpService = module.get<HttpService>(HttpService);
});
describe("sendCommand", () => {
it("should send a command to a remote instance", async () => {
const commandType = "spawn_agent";
const payload = { agentType: "task_executor" };
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
status: FederationConnectionStatus.ACTIVE,
remoteUrl: mockRemoteUrl,
remoteInstanceId: mockInstanceId,
};
const mockIdentity = {
instanceId: "local-instance",
displayName: "Local Instance",
};
const mockMessage = {
id: "msg-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.COMMAND,
messageId: expect.any(String),
correlationId: null,
query: null,
commandType,
payload,
response: {},
status: FederationMessageStatus.PENDING,
error: null,
signature: "signature-123",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
};
vi.spyOn(prisma.federationConnection, "findUnique").mockResolvedValue(
mockConnection as never
);
vi.spyOn(federationService, "getInstanceIdentity").mockResolvedValue(mockIdentity as never);
vi.spyOn(signatureService, "signMessage").mockResolvedValue("signature-123");
vi.spyOn(prisma.federationMessage, "create").mockResolvedValue(mockMessage as never);
vi.spyOn(httpService, "post").mockReturnValue(of({} as never));
const result = await service.sendCommand(
mockWorkspaceId,
mockConnectionId,
commandType,
payload
);
expect(result).toMatchObject({
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.COMMAND,
commandType,
status: FederationMessageStatus.PENDING,
});
expect(httpService.post).toHaveBeenCalledWith(
`${mockRemoteUrl}/api/v1/federation/incoming/command`,
expect.objectContaining({
messageId: expect.any(String),
instanceId: "local-instance",
commandType,
payload,
timestamp: expect.any(Number),
signature: "signature-123",
})
);
});
it("should throw error if connection not found", async () => {
vi.spyOn(prisma.federationConnection, "findUnique").mockResolvedValue(null);
await expect(
service.sendCommand(mockWorkspaceId, mockConnectionId, "test", {})
).rejects.toThrow("Connection not found");
});
it("should throw error if connection is not active", async () => {
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
status: FederationConnectionStatus.SUSPENDED,
};
vi.spyOn(prisma.federationConnection, "findUnique").mockResolvedValue(
mockConnection as never
);
await expect(
service.sendCommand(mockWorkspaceId, mockConnectionId, "test", {})
).rejects.toThrow("Connection is not active");
});
it("should mark command as failed if sending fails", async () => {
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
status: FederationConnectionStatus.ACTIVE,
remoteUrl: mockRemoteUrl,
};
const mockIdentity = {
instanceId: "local-instance",
displayName: "Local Instance",
};
const mockMessage = {
id: "msg-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.COMMAND,
messageId: "test-msg-id",
correlationId: null,
query: null,
commandType: "test",
payload: {},
response: {},
status: FederationMessageStatus.PENDING,
error: null,
signature: "signature-123",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
};
vi.spyOn(prisma.federationConnection, "findUnique").mockResolvedValue(
mockConnection as never
);
vi.spyOn(federationService, "getInstanceIdentity").mockResolvedValue(mockIdentity as never);
vi.spyOn(signatureService, "signMessage").mockResolvedValue("signature-123");
vi.spyOn(prisma.federationMessage, "create").mockResolvedValue(mockMessage as never);
vi.spyOn(httpService, "post").mockReturnValue(
new (class {
subscribe(handlers: { error: (err: Error) => void }) {
handlers.error(new Error("Network error"));
}
})() as never
);
vi.spyOn(prisma.federationMessage, "update").mockResolvedValue(mockMessage as never);
await expect(
service.sendCommand(mockWorkspaceId, mockConnectionId, "test", {})
).rejects.toThrow("Failed to send command");
expect(prisma.federationMessage.update).toHaveBeenCalledWith({
where: { id: "msg-123" },
data: {
status: FederationMessageStatus.FAILED,
error: "Network error",
},
});
});
});
describe("handleIncomingCommand", () => {
it("should process a valid incoming command", async () => {
const commandMessage: CommandMessage = {
messageId: "cmd-123",
instanceId: mockInstanceId,
commandType: "spawn_agent",
payload: { agentType: "task_executor" },
timestamp: Date.now(),
signature: "signature-123",
};
const mockConnection = {
id: mockConnectionId,
remoteInstanceId: mockInstanceId,
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance",
displayName: "Local Instance",
};
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
vi.spyOn(prisma.federationConnection, "findFirst").mockResolvedValue(mockConnection as never);
vi.spyOn(signatureService, "verifyMessage").mockResolvedValue({
valid: true,
error: null,
} as never);
vi.spyOn(federationService, "getInstanceIdentity").mockResolvedValue(mockIdentity as never);
vi.spyOn(signatureService, "signMessage").mockResolvedValue("response-signature");
const response = await service.handleIncomingCommand(commandMessage);
expect(response).toMatchObject({
correlationId: "cmd-123",
instanceId: "local-instance",
success: true,
});
expect(signatureService.validateTimestamp).toHaveBeenCalledWith(commandMessage.timestamp);
expect(signatureService.verifyMessage).toHaveBeenCalledWith(
expect.objectContaining({
messageId: "cmd-123",
instanceId: mockInstanceId,
commandType: "spawn_agent",
}),
"signature-123",
mockInstanceId
);
});
it("should reject command with invalid timestamp", async () => {
const commandMessage: CommandMessage = {
messageId: "cmd-123",
instanceId: mockInstanceId,
commandType: "test",
payload: {},
timestamp: Date.now() - 1000000,
signature: "signature-123",
};
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(false);
await expect(service.handleIncomingCommand(commandMessage)).rejects.toThrow(
"Command timestamp is outside acceptable range"
);
});
it("should reject command if no connection found", async () => {
const commandMessage: CommandMessage = {
messageId: "cmd-123",
instanceId: mockInstanceId,
commandType: "test",
payload: {},
timestamp: Date.now(),
signature: "signature-123",
};
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
vi.spyOn(prisma.federationConnection, "findFirst").mockResolvedValue(null);
await expect(service.handleIncomingCommand(commandMessage)).rejects.toThrow(
"No connection found for remote instance"
);
});
it("should reject command with invalid signature", async () => {
const commandMessage: CommandMessage = {
messageId: "cmd-123",
instanceId: mockInstanceId,
commandType: "test",
payload: {},
timestamp: Date.now(),
signature: "invalid-signature",
};
const mockConnection = {
id: mockConnectionId,
remoteInstanceId: mockInstanceId,
status: FederationConnectionStatus.ACTIVE,
};
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
vi.spyOn(prisma.federationConnection, "findFirst").mockResolvedValue(mockConnection as never);
vi.spyOn(signatureService, "verifyMessage").mockResolvedValue({
valid: false,
error: "Invalid signature",
} as never);
await expect(service.handleIncomingCommand(commandMessage)).rejects.toThrow(
"Invalid signature"
);
});
});
describe("processCommandResponse", () => {
it("should process a successful command response", async () => {
const response: CommandResponse = {
messageId: "resp-123",
correlationId: "cmd-123",
instanceId: mockInstanceId,
success: true,
data: { result: "success" },
timestamp: Date.now(),
signature: "signature-123",
};
const mockMessage = {
id: "msg-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.COMMAND,
messageId: "cmd-123",
correlationId: null,
query: null,
commandType: "test",
payload: {},
response: {},
status: FederationMessageStatus.PENDING,
error: null,
signature: "signature-123",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
};
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
vi.spyOn(prisma.federationMessage, "findFirst").mockResolvedValue(mockMessage as never);
vi.spyOn(signatureService, "verifyMessage").mockResolvedValue({
valid: true,
error: null,
} as never);
vi.spyOn(prisma.federationMessage, "update").mockResolvedValue(mockMessage as never);
await service.processCommandResponse(response);
expect(prisma.federationMessage.update).toHaveBeenCalledWith({
where: { id: "msg-123" },
data: {
status: FederationMessageStatus.DELIVERED,
deliveredAt: expect.any(Date),
response: { result: "success" },
},
});
});
it("should handle failed command response", async () => {
const response: CommandResponse = {
messageId: "resp-123",
correlationId: "cmd-123",
instanceId: mockInstanceId,
success: false,
error: "Command execution failed",
timestamp: Date.now(),
signature: "signature-123",
};
const mockMessage = {
id: "msg-123",
messageType: FederationMessageType.COMMAND,
messageId: "cmd-123",
};
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
vi.spyOn(prisma.federationMessage, "findFirst").mockResolvedValue(mockMessage as never);
vi.spyOn(signatureService, "verifyMessage").mockResolvedValue({
valid: true,
error: null,
} as never);
vi.spyOn(prisma.federationMessage, "update").mockResolvedValue(mockMessage as never);
await service.processCommandResponse(response);
expect(prisma.federationMessage.update).toHaveBeenCalledWith({
where: { id: "msg-123" },
data: {
status: FederationMessageStatus.FAILED,
deliveredAt: expect.any(Date),
error: "Command execution failed",
},
});
});
it("should reject response with invalid timestamp", async () => {
const response: CommandResponse = {
messageId: "resp-123",
correlationId: "cmd-123",
instanceId: mockInstanceId,
success: true,
timestamp: Date.now() - 1000000,
signature: "signature-123",
};
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(false);
await expect(service.processCommandResponse(response)).rejects.toThrow(
"Response timestamp is outside acceptable range"
);
});
});
describe("getCommandMessages", () => {
it("should return all command messages for a workspace", async () => {
const mockMessages = [
{
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.COMMAND,
messageId: "cmd-1",
correlationId: null,
query: null,
commandType: "test",
payload: {},
response: {},
status: FederationMessageStatus.DELIVERED,
error: null,
signature: "sig-1",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: new Date(),
},
];
vi.spyOn(prisma.federationMessage, "findMany").mockResolvedValue(mockMessages as never);
const result = await service.getCommandMessages(mockWorkspaceId);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
workspaceId: mockWorkspaceId,
messageType: FederationMessageType.COMMAND,
commandType: "test",
});
});
it("should filter command messages by status", async () => {
const mockMessages = [
{
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.COMMAND,
messageId: "cmd-1",
correlationId: null,
query: null,
commandType: "test",
payload: {},
response: {},
status: FederationMessageStatus.PENDING,
error: null,
signature: "sig-1",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
},
];
vi.spyOn(prisma.federationMessage, "findMany").mockResolvedValue(mockMessages as never);
await service.getCommandMessages(mockWorkspaceId, FederationMessageStatus.PENDING);
expect(prisma.federationMessage.findMany).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
messageType: FederationMessageType.COMMAND,
status: FederationMessageStatus.PENDING,
},
orderBy: { createdAt: "desc" },
});
});
});
describe("getCommandMessage", () => {
it("should return a single command message", async () => {
const mockMessage = {
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.COMMAND,
messageId: "cmd-1",
correlationId: null,
query: null,
commandType: "test",
payload: { key: "value" },
response: {},
status: FederationMessageStatus.DELIVERED,
error: null,
signature: "sig-1",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: new Date(),
};
vi.spyOn(prisma.federationMessage, "findUnique").mockResolvedValue(mockMessage as never);
const result = await service.getCommandMessage(mockWorkspaceId, "msg-1");
expect(result).toMatchObject({
id: "msg-1",
workspaceId: mockWorkspaceId,
commandType: "test",
payload: { key: "value" },
});
});
it("should throw error if command message not found", async () => {
vi.spyOn(prisma.federationMessage, "findUnique").mockResolvedValue(null);
await expect(service.getCommandMessage(mockWorkspaceId, "invalid-id")).rejects.toThrow(
"Command message not found"
);
});
});
});

View File

@@ -0,0 +1,386 @@
/**
* Command Service
*
* Handles federated command messages.
*/
import { Injectable, Logger } from "@nestjs/common";
import { ModuleRef } from "@nestjs/core";
import { HttpService } from "@nestjs/axios";
import { randomUUID } from "crypto";
import { firstValueFrom } from "rxjs";
import { PrismaService } from "../prisma/prisma.service";
import { FederationService } from "./federation.service";
import { SignatureService } from "./signature.service";
import {
FederationConnectionStatus,
FederationMessageType,
FederationMessageStatus,
} from "@prisma/client";
import type { CommandMessage, CommandResponse, CommandMessageDetails } from "./types/message.types";
@Injectable()
export class CommandService {
private readonly logger = new Logger(CommandService.name);
constructor(
private readonly prisma: PrismaService,
private readonly federationService: FederationService,
private readonly signatureService: SignatureService,
private readonly httpService: HttpService,
private readonly moduleRef: ModuleRef
) {}
/**
* Send a command to a remote instance
*/
async sendCommand(
workspaceId: string,
connectionId: string,
commandType: string,
payload: Record<string, unknown>
): Promise<CommandMessageDetails> {
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Get local instance identity
const identity = await this.federationService.getInstanceIdentity();
// Create command message
const messageId = randomUUID();
const timestamp = Date.now();
const commandPayload: Record<string, unknown> = {
messageId,
instanceId: identity.instanceId,
commandType,
payload,
timestamp,
};
// Sign the command
const signature = await this.signatureService.signMessage(commandPayload);
const signedCommand = {
messageId,
instanceId: identity.instanceId,
commandType,
payload,
timestamp,
signature,
} as CommandMessage;
// Store message in database
const message = await this.prisma.federationMessage.create({
data: {
workspaceId,
connectionId,
messageType: FederationMessageType.COMMAND,
messageId,
commandType,
payload: payload as never,
status: FederationMessageStatus.PENDING,
signature,
},
});
// Send command to remote instance
try {
const remoteUrl = `${connection.remoteUrl}/api/v1/federation/incoming/command`;
await firstValueFrom(this.httpService.post(remoteUrl, signedCommand));
this.logger.log(`Command sent to ${connection.remoteUrl}: ${messageId}`);
} catch (error) {
this.logger.error(`Failed to send command to ${connection.remoteUrl}`, error);
// Update message status to failed
await this.prisma.federationMessage.update({
where: { id: message.id },
data: {
status: FederationMessageStatus.FAILED,
error: error instanceof Error ? error.message : "Unknown error",
},
});
throw new Error("Failed to send command");
}
return this.mapToCommandMessageDetails(message);
}
/**
* Handle incoming command from remote instance
*/
async handleIncomingCommand(commandMessage: CommandMessage): Promise<CommandResponse> {
this.logger.log(
`Received command from ${commandMessage.instanceId}: ${commandMessage.messageId}`
);
// Validate timestamp
if (!this.signatureService.validateTimestamp(commandMessage.timestamp)) {
throw new Error("Command timestamp is outside acceptable range");
}
// Find connection for remote instance
const connection = await this.prisma.federationConnection.findFirst({
where: {
remoteInstanceId: commandMessage.instanceId,
status: FederationConnectionStatus.ACTIVE,
},
});
if (!connection) {
throw new Error("No connection found for remote instance");
}
// Validate connection is active
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Verify signature
const { signature, ...messageToVerify } = commandMessage;
const verificationResult = await this.signatureService.verifyMessage(
messageToVerify,
signature,
commandMessage.instanceId
);
if (!verificationResult.valid) {
throw new Error(verificationResult.error ?? "Invalid signature");
}
// Process command
let responseData: unknown;
let success = true;
let errorMessage: string | undefined;
try {
// Route agent commands to FederationAgentService
if (commandMessage.commandType.startsWith("agent.")) {
// Import FederationAgentService dynamically to avoid circular dependency
const { FederationAgentService } = await import("./federation-agent.service");
const federationAgentService = this.moduleRef.get(FederationAgentService, {
strict: false,
});
const agentResponse = await federationAgentService.handleAgentCommand(
commandMessage.instanceId,
commandMessage.commandType,
commandMessage.payload
);
success = agentResponse.success;
responseData = agentResponse.data;
errorMessage = agentResponse.error;
} else {
// Other command types can be added here
responseData = { message: "Command received and processed" };
}
} catch (error) {
success = false;
errorMessage = error instanceof Error ? error.message : "Command processing failed";
this.logger.error(`Command processing failed: ${errorMessage}`);
}
// Get local instance identity
const identity = await this.federationService.getInstanceIdentity();
// Create response
const responseMessageId = randomUUID();
const responseTimestamp = Date.now();
const responsePayload: Record<string, unknown> = {
messageId: responseMessageId,
correlationId: commandMessage.messageId,
instanceId: identity.instanceId,
success,
timestamp: responseTimestamp,
};
if (responseData !== undefined) {
responsePayload.data = responseData;
}
if (errorMessage !== undefined) {
responsePayload.error = errorMessage;
}
// Sign the response
const responseSignature = await this.signatureService.signMessage(responsePayload);
const response = {
messageId: responseMessageId,
correlationId: commandMessage.messageId,
instanceId: identity.instanceId,
success,
...(responseData !== undefined ? { data: responseData } : {}),
...(errorMessage !== undefined ? { error: errorMessage } : {}),
timestamp: responseTimestamp,
signature: responseSignature,
} as CommandResponse;
return response;
}
/**
* Get all command messages for a workspace
*/
async getCommandMessages(
workspaceId: string,
status?: FederationMessageStatus
): Promise<CommandMessageDetails[]> {
const where: Record<string, unknown> = {
workspaceId,
messageType: FederationMessageType.COMMAND,
};
if (status) {
where.status = status;
}
const messages = await this.prisma.federationMessage.findMany({
where,
orderBy: { createdAt: "desc" },
});
return messages.map((msg) => this.mapToCommandMessageDetails(msg));
}
/**
* Get a single command message
*/
async getCommandMessage(workspaceId: string, messageId: string): Promise<CommandMessageDetails> {
const message = await this.prisma.federationMessage.findUnique({
where: { id: messageId, workspaceId },
});
if (!message) {
throw new Error("Command message not found");
}
return this.mapToCommandMessageDetails(message);
}
/**
* Process a command response from remote instance
*/
async processCommandResponse(response: CommandResponse): Promise<void> {
this.logger.log(`Received response for command: ${response.correlationId}`);
// Validate timestamp
if (!this.signatureService.validateTimestamp(response.timestamp)) {
throw new Error("Response timestamp is outside acceptable range");
}
// Find original command message
const message = await this.prisma.federationMessage.findFirst({
where: {
messageId: response.correlationId,
messageType: FederationMessageType.COMMAND,
},
});
if (!message) {
throw new Error("Original command message not found");
}
// Verify signature
const { signature, ...responseToVerify } = response;
const verificationResult = await this.signatureService.verifyMessage(
responseToVerify,
signature,
response.instanceId
);
if (!verificationResult.valid) {
throw new Error(verificationResult.error ?? "Invalid signature");
}
// Update message with response
const updateData: Record<string, unknown> = {
status: response.success ? FederationMessageStatus.DELIVERED : FederationMessageStatus.FAILED,
deliveredAt: new Date(),
};
if (response.data !== undefined) {
updateData.response = response.data;
}
if (response.error !== undefined) {
updateData.error = response.error;
}
await this.prisma.federationMessage.update({
where: { id: message.id },
data: updateData,
});
this.logger.log(`Command response processed: ${response.correlationId}`);
}
/**
* Map Prisma FederationMessage to CommandMessageDetails
*/
private mapToCommandMessageDetails(message: {
id: string;
workspaceId: string;
connectionId: string;
messageType: FederationMessageType;
messageId: string;
correlationId: string | null;
query: string | null;
commandType: string | null;
payload: unknown;
response: unknown;
status: FederationMessageStatus;
error: string | null;
createdAt: Date;
updatedAt: Date;
deliveredAt: Date | null;
}): CommandMessageDetails {
const details: CommandMessageDetails = {
id: message.id,
workspaceId: message.workspaceId,
connectionId: message.connectionId,
messageType: message.messageType,
messageId: message.messageId,
response: message.response,
status: message.status,
createdAt: message.createdAt,
updatedAt: message.updatedAt,
};
if (message.correlationId !== null) {
details.correlationId = message.correlationId;
}
if (message.commandType !== null) {
details.commandType = message.commandType;
}
if (message.payload !== null && typeof message.payload === "object") {
details.payload = message.payload as Record<string, unknown>;
}
if (message.error !== null) {
details.error = message.error;
}
if (message.deliveredAt !== null) {
details.deliveredAt = message.deliveredAt;
}
return details;
}
}

View File

@@ -0,0 +1,54 @@
/**
* Command DTOs
*
* Data Transfer Objects for command message operations.
*/
import { IsString, IsObject, IsNotEmpty, IsNumber } from "class-validator";
import type { CommandMessage } from "../types/message.types";
/**
* DTO for sending a command to a remote instance
*/
export class SendCommandDto {
@IsString()
@IsNotEmpty()
connectionId!: string;
@IsString()
@IsNotEmpty()
commandType!: string;
@IsObject()
@IsNotEmpty()
payload!: Record<string, unknown>;
}
/**
* DTO for incoming command request from remote instance
*/
export class IncomingCommandDto implements CommandMessage {
@IsString()
@IsNotEmpty()
messageId!: string;
@IsString()
@IsNotEmpty()
instanceId!: string;
@IsString()
@IsNotEmpty()
commandType!: string;
@IsObject()
@IsNotEmpty()
payload!: Record<string, unknown>;
@IsNumber()
@IsNotEmpty()
timestamp!: number;
@IsString()
@IsNotEmpty()
signature!: string;
}

View File

@@ -0,0 +1,109 @@
/**
* Event DTOs
*
* Data Transfer Objects for event subscription and publishing.
*/
import { IsString, IsNotEmpty, IsOptional, IsObject } from "class-validator";
/**
* DTO for subscribing to an event type
*/
export class SubscribeToEventDto {
@IsString()
@IsNotEmpty()
connectionId!: string;
@IsString()
@IsNotEmpty()
eventType!: string;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}
/**
* DTO for unsubscribing from an event type
*/
export class UnsubscribeFromEventDto {
@IsString()
@IsNotEmpty()
connectionId!: string;
@IsString()
@IsNotEmpty()
eventType!: string;
}
/**
* DTO for publishing an event
*/
export class PublishEventDto {
@IsString()
@IsNotEmpty()
eventType!: string;
@IsObject()
@IsNotEmpty()
payload!: Record<string, unknown>;
}
/**
* DTO for incoming event request
*/
export class IncomingEventDto {
@IsString()
@IsNotEmpty()
messageId!: string;
@IsString()
@IsNotEmpty()
instanceId!: string;
@IsString()
@IsNotEmpty()
eventType!: string;
@IsObject()
@IsNotEmpty()
payload!: Record<string, unknown>;
@IsNotEmpty()
timestamp!: number;
@IsString()
@IsNotEmpty()
signature!: string;
}
/**
* DTO for incoming event acknowledgment
*/
export class IncomingEventAckDto {
@IsString()
@IsNotEmpty()
messageId!: string;
@IsString()
@IsNotEmpty()
correlationId!: string;
@IsString()
@IsNotEmpty()
instanceId!: string;
@IsNotEmpty()
received!: boolean;
@IsOptional()
@IsString()
error?: string;
@IsNotEmpty()
timestamp!: number;
@IsString()
@IsNotEmpty()
signature!: string;
}

View File

@@ -0,0 +1,98 @@
/**
* Identity Linking DTOs
*
* Data transfer objects for identity linking API endpoints.
*/
import { IsString, IsEmail, IsOptional, IsObject, IsArray, IsNumber } from "class-validator";
/**
* DTO for verifying identity from remote instance
*/
export class VerifyIdentityDto {
@IsString()
localUserId!: string;
@IsString()
remoteUserId!: string;
@IsString()
remoteInstanceId!: string;
@IsString()
oidcToken!: string;
@IsNumber()
timestamp!: number;
@IsString()
signature!: string;
}
/**
* DTO for resolving remote user to local user
*/
export class ResolveIdentityDto {
@IsString()
remoteInstanceId!: string;
@IsString()
remoteUserId!: string;
}
/**
* DTO for reverse resolving local user to remote identity
*/
export class ReverseResolveIdentityDto {
@IsString()
localUserId!: string;
@IsString()
remoteInstanceId!: string;
}
/**
* DTO for bulk identity resolution
*/
export class BulkResolveIdentityDto {
@IsString()
remoteInstanceId!: string;
@IsArray()
@IsString({ each: true })
remoteUserIds!: string[];
}
/**
* DTO for creating identity mapping
*/
export class CreateIdentityMappingDto {
@IsString()
remoteInstanceId!: string;
@IsString()
remoteUserId!: string;
@IsString()
oidcSubject!: string;
@IsEmail()
email!: string;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
@IsOptional()
@IsString()
oidcToken?: string;
}
/**
* DTO for updating identity mapping
*/
export class UpdateIdentityMappingDto {
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,46 @@
/**
* Instance Configuration DTOs
*
* Data Transfer Objects for instance configuration API.
*/
import { IsString, IsBoolean, IsOptional, IsObject, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
/**
* DTO for federation capabilities
*/
export class FederationCapabilitiesDto {
@IsBoolean()
supportsQuery!: boolean;
@IsBoolean()
supportsCommand!: boolean;
@IsBoolean()
supportsEvent!: boolean;
@IsBoolean()
supportsAgentSpawn!: boolean;
@IsString()
protocolVersion!: string;
}
/**
* DTO for updating instance configuration
*/
export class UpdateInstanceDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@ValidateNested()
@Type(() => FederationCapabilitiesDto)
capabilities?: FederationCapabilitiesDto;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,53 @@
/**
* Query DTOs
*
* Data Transfer Objects for query message operations.
*/
import { IsString, IsOptional, IsObject, IsNotEmpty } from "class-validator";
import type { QueryMessage } from "../types/message.types";
/**
* DTO for sending a query to a remote instance
*/
export class SendQueryDto {
@IsString()
@IsNotEmpty()
connectionId!: string;
@IsString()
@IsNotEmpty()
query!: string;
@IsOptional()
@IsObject()
context?: Record<string, unknown>;
}
/**
* DTO for incoming query request from remote instance
*/
export class IncomingQueryDto implements QueryMessage {
@IsString()
@IsNotEmpty()
messageId!: string;
@IsString()
@IsNotEmpty()
instanceId!: string;
@IsString()
@IsNotEmpty()
query!: string;
@IsOptional()
@IsObject()
context?: Record<string, unknown>;
@IsNotEmpty()
timestamp!: number;
@IsString()
@IsNotEmpty()
signature!: string;
}

View File

@@ -0,0 +1,393 @@
/**
* EventController Tests
*
* Tests for event subscription and publishing endpoints.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { EventController } from "./event.controller";
import { EventService } from "./event.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { FederationMessageType, FederationMessageStatus } from "@prisma/client";
import type { AuthenticatedRequest } from "../common/types/user.types";
import type { EventMessage, EventAck } from "./types/message.types";
describe("EventController", () => {
let controller: EventController;
let eventService: EventService;
const mockEventService = {
subscribeToEventType: vi.fn(),
unsubscribeFromEventType: vi.fn(),
publishEvent: vi.fn(),
getEventSubscriptions: vi.fn(),
getEventMessages: vi.fn(),
getEventMessage: vi.fn(),
handleIncomingEvent: vi.fn(),
processEventAck: vi.fn(),
};
const mockWorkspaceId = "workspace-123";
const mockUserId = "user-123";
const mockConnectionId = "connection-123";
const mockEventType = "task.created";
beforeEach(async () => {
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [EventController],
providers: [
{
provide: EventService,
useValue: mockEventService,
},
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<EventController>(EventController);
eventService = module.get<EventService>(EventService);
});
describe("subscribeToEvent", () => {
it("should subscribe to an event type", async () => {
const req = {
user: {
id: mockUserId,
workspaceId: mockWorkspaceId,
},
} as AuthenticatedRequest;
const dto = {
connectionId: mockConnectionId,
eventType: mockEventType,
metadata: { key: "value" },
};
const mockSubscription = {
id: "sub-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: mockEventType,
metadata: { key: "value" },
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
mockEventService.subscribeToEventType.mockResolvedValue(mockSubscription);
const result = await controller.subscribeToEvent(req, dto);
expect(result).toEqual(mockSubscription);
expect(mockEventService.subscribeToEventType).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId,
mockEventType,
{ key: "value" }
);
});
it("should throw error if workspace not found", async () => {
const req = {
user: {
id: mockUserId,
},
} as AuthenticatedRequest;
const dto = {
connectionId: mockConnectionId,
eventType: mockEventType,
};
await expect(controller.subscribeToEvent(req, dto)).rejects.toThrow(
"Workspace ID not found in request"
);
});
});
describe("unsubscribeFromEvent", () => {
it("should unsubscribe from an event type", async () => {
const req = {
user: {
id: mockUserId,
workspaceId: mockWorkspaceId,
},
} as AuthenticatedRequest;
const dto = {
connectionId: mockConnectionId,
eventType: mockEventType,
};
mockEventService.unsubscribeFromEventType.mockResolvedValue(undefined);
await controller.unsubscribeFromEvent(req, dto);
expect(mockEventService.unsubscribeFromEventType).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId,
mockEventType
);
});
});
describe("publishEvent", () => {
it("should publish an event", async () => {
const req = {
user: {
id: mockUserId,
workspaceId: mockWorkspaceId,
},
} as AuthenticatedRequest;
const dto = {
eventType: mockEventType,
payload: { data: "test" },
};
const mockMessages = [
{
id: "msg-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.EVENT,
messageId: "msg-id-123",
eventType: mockEventType,
payload: { data: "test" },
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockEventService.publishEvent.mockResolvedValue(mockMessages);
const result = await controller.publishEvent(req, dto);
expect(result).toEqual(mockMessages);
expect(mockEventService.publishEvent).toHaveBeenCalledWith(mockWorkspaceId, mockEventType, {
data: "test",
});
});
});
describe("getSubscriptions", () => {
it("should return all subscriptions for workspace", async () => {
const req = {
user: {
id: mockUserId,
workspaceId: mockWorkspaceId,
},
} as AuthenticatedRequest;
const mockSubscriptions = [
{
id: "sub-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: "task.created",
metadata: {},
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockEventService.getEventSubscriptions.mockResolvedValue(mockSubscriptions);
const result = await controller.getSubscriptions(req);
expect(result).toEqual(mockSubscriptions);
expect(mockEventService.getEventSubscriptions).toHaveBeenCalledWith(
mockWorkspaceId,
undefined
);
});
it("should filter by connectionId when provided", async () => {
const req = {
user: {
id: mockUserId,
workspaceId: mockWorkspaceId,
},
} as AuthenticatedRequest;
const mockSubscriptions = [
{
id: "sub-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: "task.created",
metadata: {},
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockEventService.getEventSubscriptions.mockResolvedValue(mockSubscriptions);
const result = await controller.getSubscriptions(req, mockConnectionId);
expect(result).toEqual(mockSubscriptions);
expect(mockEventService.getEventSubscriptions).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId
);
});
});
describe("getEventMessages", () => {
it("should return all event messages for workspace", async () => {
const req = {
user: {
id: mockUserId,
workspaceId: mockWorkspaceId,
},
} as AuthenticatedRequest;
const mockMessages = [
{
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.EVENT,
messageId: "msg-id-1",
eventType: "task.created",
payload: { data: "test1" },
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockEventService.getEventMessages.mockResolvedValue(mockMessages);
const result = await controller.getEventMessages(req);
expect(result).toEqual(mockMessages);
expect(mockEventService.getEventMessages).toHaveBeenCalledWith(mockWorkspaceId, undefined);
});
it("should filter by status when provided", async () => {
const req = {
user: {
id: mockUserId,
workspaceId: mockWorkspaceId,
},
} as AuthenticatedRequest;
const mockMessages = [
{
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.EVENT,
messageId: "msg-id-1",
eventType: "task.created",
payload: { data: "test1" },
status: FederationMessageStatus.PENDING,
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockEventService.getEventMessages.mockResolvedValue(mockMessages);
const result = await controller.getEventMessages(req, FederationMessageStatus.PENDING);
expect(result).toEqual(mockMessages);
expect(mockEventService.getEventMessages).toHaveBeenCalledWith(
mockWorkspaceId,
FederationMessageStatus.PENDING
);
});
});
describe("getEventMessage", () => {
it("should return a single event message", async () => {
const req = {
user: {
id: mockUserId,
workspaceId: mockWorkspaceId,
},
} as AuthenticatedRequest;
const messageId = "msg-123";
const mockMessage = {
id: messageId,
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.EVENT,
messageId: "msg-id-123",
eventType: "task.created",
payload: { data: "test" },
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
};
mockEventService.getEventMessage.mockResolvedValue(mockMessage);
const result = await controller.getEventMessage(req, messageId);
expect(result).toEqual(mockMessage);
expect(mockEventService.getEventMessage).toHaveBeenCalledWith(mockWorkspaceId, messageId);
});
});
describe("handleIncomingEvent", () => {
it("should handle incoming event and return acknowledgment", async () => {
const eventMessage: EventMessage = {
messageId: "msg-123",
instanceId: "remote-instance-123",
eventType: "task.created",
payload: { data: "test" },
timestamp: Date.now(),
signature: "signature-123",
};
const mockAck: EventAck = {
messageId: "ack-123",
correlationId: eventMessage.messageId,
instanceId: "local-instance-123",
received: true,
timestamp: Date.now(),
signature: "ack-signature-123",
};
mockEventService.handleIncomingEvent.mockResolvedValue(mockAck);
const result = await controller.handleIncomingEvent(eventMessage);
expect(result).toEqual(mockAck);
expect(mockEventService.handleIncomingEvent).toHaveBeenCalledWith(eventMessage);
});
});
describe("handleIncomingEventAck", () => {
it("should process event acknowledgment", async () => {
const ack: EventAck = {
messageId: "ack-123",
correlationId: "msg-123",
instanceId: "remote-instance-123",
received: true,
timestamp: Date.now(),
signature: "ack-signature-123",
};
mockEventService.processEventAck.mockResolvedValue(undefined);
const result = await controller.handleIncomingEventAck(ack);
expect(result).toEqual({ status: "acknowledged" });
expect(mockEventService.processEventAck).toHaveBeenCalledWith(ack);
});
});
});

View File

@@ -0,0 +1,197 @@
/**
* Event Controller
*
* API endpoints for event subscriptions and publishing.
*/
import { Controller, Get, Post, UseGuards, Logger, Req, Body, Param, Query } from "@nestjs/common";
import { EventService } from "./event.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { FederationMessageStatus } from "@prisma/client";
import type { AuthenticatedRequest } from "../common/types/user.types";
import type {
EventMessage,
EventAck,
EventMessageDetails,
SubscriptionDetails,
} from "./types/message.types";
import {
SubscribeToEventDto,
UnsubscribeFromEventDto,
PublishEventDto,
IncomingEventDto,
IncomingEventAckDto,
} from "./dto/event.dto";
@Controller("api/v1/federation")
export class EventController {
private readonly logger = new Logger(EventController.name);
constructor(private readonly eventService: EventService) {}
/**
* Subscribe to an event type from a remote instance
* Requires authentication
*/
@Post("events/subscribe")
@UseGuards(AuthGuard)
async subscribeToEvent(
@Req() req: AuthenticatedRequest,
@Body() dto: SubscribeToEventDto
): Promise<SubscriptionDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(
`User ${req.user.id} subscribing to event type ${dto.eventType} on connection ${dto.connectionId}`
);
return this.eventService.subscribeToEventType(
req.user.workspaceId,
dto.connectionId,
dto.eventType,
dto.metadata
);
}
/**
* Unsubscribe from an event type
* Requires authentication
*/
@Post("events/unsubscribe")
@UseGuards(AuthGuard)
async unsubscribeFromEvent(
@Req() req: AuthenticatedRequest,
@Body() dto: UnsubscribeFromEventDto
): Promise<{ status: string }> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(
`User ${req.user.id} unsubscribing from event type ${dto.eventType} on connection ${dto.connectionId}`
);
await this.eventService.unsubscribeFromEventType(
req.user.workspaceId,
dto.connectionId,
dto.eventType
);
return { status: "unsubscribed" };
}
/**
* Publish an event to subscribed instances
* Requires authentication
*/
@Post("events/publish")
@UseGuards(AuthGuard)
async publishEvent(
@Req() req: AuthenticatedRequest,
@Body() dto: PublishEventDto
): Promise<EventMessageDetails[]> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(`User ${req.user.id} publishing event type ${dto.eventType}`);
return this.eventService.publishEvent(req.user.workspaceId, dto.eventType, dto.payload);
}
/**
* Get all event subscriptions for the workspace
* Requires authentication
*/
@Get("events/subscriptions")
@UseGuards(AuthGuard)
async getSubscriptions(
@Req() req: AuthenticatedRequest,
@Query("connectionId") connectionId?: string
): Promise<SubscriptionDetails[]> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
return this.eventService.getEventSubscriptions(req.user.workspaceId, connectionId);
}
/**
* Get all event messages for the workspace
* Requires authentication
*/
@Get("events/messages")
@UseGuards(AuthGuard)
async getEventMessages(
@Req() req: AuthenticatedRequest,
@Query("status") status?: FederationMessageStatus
): Promise<EventMessageDetails[]> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
return this.eventService.getEventMessages(req.user.workspaceId, status);
}
/**
* Get a single event message
* Requires authentication
*/
@Get("events/messages/:id")
@UseGuards(AuthGuard)
async getEventMessage(
@Req() req: AuthenticatedRequest,
@Param("id") messageId: string
): Promise<EventMessageDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
return this.eventService.getEventMessage(req.user.workspaceId, messageId);
}
/**
* Handle incoming event from remote instance
* Public endpoint - no authentication required (signature-based verification)
*/
@Post("incoming/event")
async handleIncomingEvent(@Body() dto: IncomingEventDto): Promise<EventAck> {
this.logger.log(`Received event from ${dto.instanceId}: ${dto.messageId}`);
const eventMessage: EventMessage = {
messageId: dto.messageId,
instanceId: dto.instanceId,
eventType: dto.eventType,
payload: dto.payload,
timestamp: dto.timestamp,
signature: dto.signature,
};
return this.eventService.handleIncomingEvent(eventMessage);
}
/**
* Handle incoming event acknowledgment from remote instance
* Public endpoint - no authentication required (signature-based verification)
*/
@Post("incoming/event/ack")
async handleIncomingEventAck(@Body() dto: IncomingEventAckDto): Promise<{ status: string }> {
this.logger.log(`Received acknowledgment for event: ${dto.correlationId}`);
const ack: EventAck = {
messageId: dto.messageId,
correlationId: dto.correlationId,
instanceId: dto.instanceId,
received: dto.received,
...(dto.error !== undefined ? { error: dto.error } : {}),
timestamp: dto.timestamp,
signature: dto.signature,
};
await this.eventService.processEventAck(ack);
return { status: "acknowledged" };
}
}

View File

@@ -0,0 +1,825 @@
/**
* EventService Tests
*
* Tests for federated event message handling.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { EventService } from "./event.service";
import { PrismaService } from "../prisma/prisma.service";
import { FederationService } from "./federation.service";
import { SignatureService } from "./signature.service";
import { HttpService } from "@nestjs/axios";
import { of, throwError } from "rxjs";
import {
FederationConnectionStatus,
FederationMessageType,
FederationMessageStatus,
} from "@prisma/client";
import type { EventMessage, EventAck } from "./types/message.types";
import type { AxiosResponse } from "axios";
describe("EventService", () => {
let service: EventService;
let prisma: PrismaService;
let federationService: FederationService;
let signatureService: SignatureService;
let httpService: HttpService;
const mockWorkspaceId = "workspace-123";
const mockConnectionId = "connection-123";
const mockInstanceId = "instance-123";
const mockRemoteInstanceId = "remote-instance-123";
const mockMessageId = "message-123";
const mockEventType = "task.created";
const mockPrisma = {
federationConnection: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
federationEventSubscription: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
federationMessage: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
};
const mockFederationService = {
getInstanceIdentity: vi.fn(),
};
const mockSignatureService = {
signMessage: vi.fn(),
verifyMessage: vi.fn(),
validateTimestamp: vi.fn(),
};
const mockHttpService = {
post: vi.fn(),
};
beforeEach(async () => {
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
EventService,
{
provide: PrismaService,
useValue: mockPrisma,
},
{
provide: FederationService,
useValue: mockFederationService,
},
{
provide: SignatureService,
useValue: mockSignatureService,
},
{
provide: HttpService,
useValue: mockHttpService,
},
],
}).compile();
service = module.get<EventService>(EventService);
prisma = module.get(PrismaService);
federationService = module.get(FederationService);
signatureService = module.get(SignatureService);
httpService = module.get(HttpService);
});
describe("subscribeToEventType", () => {
it("should create a new subscription", async () => {
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: mockRemoteInstanceId,
remoteUrl: "https://remote.example.com",
remotePublicKey: "public-key",
remoteCapabilities: {},
status: FederationConnectionStatus.ACTIVE,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
connectedAt: new Date(),
disconnectedAt: null,
};
const mockSubscription = {
id: "subscription-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: mockEventType,
metadata: {},
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
prisma.federationEventSubscription.create.mockResolvedValue(mockSubscription);
const result = await service.subscribeToEventType(
mockWorkspaceId,
mockConnectionId,
mockEventType
);
expect(result).toEqual({
id: mockSubscription.id,
workspaceId: mockSubscription.workspaceId,
connectionId: mockSubscription.connectionId,
eventType: mockSubscription.eventType,
metadata: mockSubscription.metadata,
isActive: mockSubscription.isActive,
createdAt: mockSubscription.createdAt,
updatedAt: mockSubscription.updatedAt,
});
expect(prisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: { id: mockConnectionId, workspaceId: mockWorkspaceId },
});
expect(prisma.federationEventSubscription.create).toHaveBeenCalledWith({
data: {
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: mockEventType,
metadata: {},
},
});
});
it("should throw error if connection not found", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(null);
await expect(
service.subscribeToEventType(mockWorkspaceId, mockConnectionId, mockEventType)
).rejects.toThrow("Connection not found");
});
it("should throw error if connection not active", async () => {
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: mockRemoteInstanceId,
remoteUrl: "https://remote.example.com",
remotePublicKey: "public-key",
remoteCapabilities: {},
status: FederationConnectionStatus.SUSPENDED,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
connectedAt: new Date(),
disconnectedAt: null,
};
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
await expect(
service.subscribeToEventType(mockWorkspaceId, mockConnectionId, mockEventType)
).rejects.toThrow("Connection is not active");
});
});
describe("unsubscribeFromEventType", () => {
it("should delete an existing subscription", async () => {
const mockSubscription = {
id: "subscription-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: mockEventType,
metadata: {},
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
prisma.federationEventSubscription.findFirst.mockResolvedValue(mockSubscription);
prisma.federationEventSubscription.delete.mockResolvedValue(mockSubscription);
await service.unsubscribeFromEventType(mockWorkspaceId, mockConnectionId, mockEventType);
expect(prisma.federationEventSubscription.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: mockEventType,
},
});
expect(prisma.federationEventSubscription.delete).toHaveBeenCalledWith({
where: { id: mockSubscription.id },
});
});
it("should throw error if subscription not found", async () => {
prisma.federationEventSubscription.findFirst.mockResolvedValue(null);
await expect(
service.unsubscribeFromEventType(mockWorkspaceId, mockConnectionId, mockEventType)
).rejects.toThrow("Subscription not found");
});
});
describe("publishEvent", () => {
it("should publish event to subscribed connections", async () => {
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: mockRemoteInstanceId,
remoteUrl: "https://remote.example.com",
remotePublicKey: "public-key",
remoteCapabilities: {},
status: FederationConnectionStatus.ACTIVE,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
connectedAt: new Date(),
disconnectedAt: null,
};
const mockSubscription = {
id: "subscription-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: mockEventType,
metadata: {},
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
connection: mockConnection,
};
const mockIdentity = {
id: "id-123",
instanceId: mockInstanceId,
name: "Local Instance",
url: "https://local.example.com",
publicKey: "public-key",
privateKey: "private-key",
capabilities: {},
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
};
const mockMessage = {
id: "message-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.EVENT,
messageId: expect.any(String),
correlationId: null,
query: null,
commandType: null,
eventType: mockEventType,
payload: { data: "test" },
response: null,
status: FederationMessageStatus.PENDING,
error: null,
signature: "signature-123",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
};
prisma.federationEventSubscription.findMany.mockResolvedValue([mockSubscription]);
federationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
signatureService.signMessage.mockResolvedValue("signature-123");
prisma.federationMessage.create.mockResolvedValue(mockMessage);
httpService.post.mockReturnValue(
of({ data: {}, status: 200, statusText: "OK", headers: {}, config: {} as never })
);
const result = await service.publishEvent(mockWorkspaceId, mockEventType, { data: "test" });
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: mockMessage.id,
workspaceId: mockMessage.workspaceId,
connectionId: mockMessage.connectionId,
messageType: mockMessage.messageType,
eventType: mockMessage.eventType,
status: mockMessage.status,
});
expect(prisma.federationEventSubscription.findMany).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
eventType: mockEventType,
isActive: true,
},
include: {
connection: true,
},
});
expect(httpService.post).toHaveBeenCalledWith(
`${mockConnection.remoteUrl}/api/v1/federation/incoming/event`,
expect.objectContaining({
instanceId: mockInstanceId,
eventType: mockEventType,
payload: { data: "test" },
signature: "signature-123",
})
);
});
it("should return empty array if no active subscriptions", async () => {
prisma.federationEventSubscription.findMany.mockResolvedValue([]);
const result = await service.publishEvent(mockWorkspaceId, mockEventType, { data: "test" });
expect(result).toEqual([]);
});
it("should handle failed delivery", async () => {
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: mockRemoteInstanceId,
remoteUrl: "https://remote.example.com",
remotePublicKey: "public-key",
remoteCapabilities: {},
status: FederationConnectionStatus.ACTIVE,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
connectedAt: new Date(),
disconnectedAt: null,
};
const mockSubscription = {
id: "subscription-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: mockEventType,
metadata: {},
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
connection: mockConnection,
};
const mockIdentity = {
id: "id-123",
instanceId: mockInstanceId,
name: "Local Instance",
url: "https://local.example.com",
publicKey: "public-key",
privateKey: "private-key",
capabilities: {},
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
};
const mockMessage = {
id: "message-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.EVENT,
messageId: expect.any(String),
correlationId: null,
query: null,
commandType: null,
eventType: mockEventType,
payload: { data: "test" },
response: null,
status: FederationMessageStatus.PENDING,
error: null,
signature: "signature-123",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
};
prisma.federationEventSubscription.findMany.mockResolvedValue([mockSubscription]);
federationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
signatureService.signMessage.mockResolvedValue("signature-123");
prisma.federationMessage.create.mockResolvedValue(mockMessage);
httpService.post.mockReturnValue(throwError(() => new Error("Network error")));
prisma.federationMessage.update.mockResolvedValue({
...mockMessage,
status: FederationMessageStatus.FAILED,
error: "Network error",
});
const result = await service.publishEvent(mockWorkspaceId, mockEventType, { data: "test" });
expect(result).toHaveLength(1);
expect(prisma.federationMessage.update).toHaveBeenCalledWith({
where: { id: mockMessage.id },
data: {
status: FederationMessageStatus.FAILED,
error: "Network error",
},
});
});
});
describe("handleIncomingEvent", () => {
it("should handle incoming event and return acknowledgment", async () => {
const eventMessage: EventMessage = {
messageId: mockMessageId,
instanceId: mockRemoteInstanceId,
eventType: mockEventType,
payload: { data: "test" },
timestamp: Date.now(),
signature: "signature-123",
};
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: mockRemoteInstanceId,
remoteUrl: "https://remote.example.com",
remotePublicKey: "public-key",
remoteCapabilities: {},
status: FederationConnectionStatus.ACTIVE,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
connectedAt: new Date(),
disconnectedAt: null,
};
const mockIdentity = {
id: "id-123",
instanceId: mockInstanceId,
name: "Local Instance",
url: "https://local.example.com",
publicKey: "public-key",
privateKey: "private-key",
capabilities: {},
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
};
signatureService.validateTimestamp.mockReturnValue(true);
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
signatureService.verifyMessage.mockResolvedValue({ valid: true, error: null });
federationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
signatureService.signMessage.mockResolvedValue("ack-signature-123");
const result = await service.handleIncomingEvent(eventMessage);
expect(result).toEqual({
messageId: expect.any(String),
correlationId: mockMessageId,
instanceId: mockInstanceId,
received: true,
timestamp: expect.any(Number),
signature: "ack-signature-123",
});
expect(signatureService.validateTimestamp).toHaveBeenCalledWith(eventMessage.timestamp);
expect(prisma.federationConnection.findFirst).toHaveBeenCalledWith({
where: {
remoteInstanceId: mockRemoteInstanceId,
status: FederationConnectionStatus.ACTIVE,
},
});
expect(signatureService.verifyMessage).toHaveBeenCalledWith(
{
messageId: eventMessage.messageId,
instanceId: eventMessage.instanceId,
eventType: eventMessage.eventType,
payload: eventMessage.payload,
timestamp: eventMessage.timestamp,
},
eventMessage.signature,
eventMessage.instanceId
);
});
it("should throw error for invalid timestamp", async () => {
const eventMessage: EventMessage = {
messageId: mockMessageId,
instanceId: mockRemoteInstanceId,
eventType: mockEventType,
payload: { data: "test" },
timestamp: Date.now(),
signature: "signature-123",
};
signatureService.validateTimestamp.mockReturnValue(false);
await expect(service.handleIncomingEvent(eventMessage)).rejects.toThrow(
"Event timestamp is outside acceptable range"
);
});
it("should throw error if no active connection found", async () => {
const eventMessage: EventMessage = {
messageId: mockMessageId,
instanceId: mockRemoteInstanceId,
eventType: mockEventType,
payload: { data: "test" },
timestamp: Date.now(),
signature: "signature-123",
};
signatureService.validateTimestamp.mockReturnValue(true);
prisma.federationConnection.findFirst.mockResolvedValue(null);
await expect(service.handleIncomingEvent(eventMessage)).rejects.toThrow(
"No connection found for remote instance"
);
});
it("should throw error for invalid signature", async () => {
const eventMessage: EventMessage = {
messageId: mockMessageId,
instanceId: mockRemoteInstanceId,
eventType: mockEventType,
payload: { data: "test" },
timestamp: Date.now(),
signature: "signature-123",
};
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: mockRemoteInstanceId,
remoteUrl: "https://remote.example.com",
remotePublicKey: "public-key",
remoteCapabilities: {},
status: FederationConnectionStatus.ACTIVE,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
connectedAt: new Date(),
disconnectedAt: null,
};
signatureService.validateTimestamp.mockReturnValue(true);
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
signatureService.verifyMessage.mockResolvedValue({
valid: false,
error: "Invalid signature",
});
await expect(service.handleIncomingEvent(eventMessage)).rejects.toThrow("Invalid signature");
});
});
describe("processEventAck", () => {
it("should process event acknowledgment", async () => {
const ack: EventAck = {
messageId: "ack-123",
correlationId: mockMessageId,
instanceId: mockRemoteInstanceId,
received: true,
timestamp: Date.now(),
signature: "ack-signature-123",
};
const mockMessage = {
id: "message-123",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.EVENT,
messageId: mockMessageId,
correlationId: null,
query: null,
commandType: null,
eventType: mockEventType,
payload: { data: "test" },
response: null,
status: FederationMessageStatus.PENDING,
error: null,
signature: "signature-123",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
};
signatureService.validateTimestamp.mockReturnValue(true);
prisma.federationMessage.findFirst.mockResolvedValue(mockMessage);
signatureService.verifyMessage.mockResolvedValue({ valid: true, error: null });
prisma.federationMessage.update.mockResolvedValue({
...mockMessage,
status: FederationMessageStatus.DELIVERED,
deliveredAt: new Date(),
});
await service.processEventAck(ack);
expect(signatureService.validateTimestamp).toHaveBeenCalledWith(ack.timestamp);
expect(prisma.federationMessage.findFirst).toHaveBeenCalledWith({
where: {
messageId: ack.correlationId,
messageType: FederationMessageType.EVENT,
},
});
expect(prisma.federationMessage.update).toHaveBeenCalledWith({
where: { id: mockMessage.id },
data: {
status: FederationMessageStatus.DELIVERED,
deliveredAt: expect.any(Date),
},
});
});
it("should throw error if original event not found", async () => {
const ack: EventAck = {
messageId: "ack-123",
correlationId: mockMessageId,
instanceId: mockRemoteInstanceId,
received: true,
timestamp: Date.now(),
signature: "ack-signature-123",
};
signatureService.validateTimestamp.mockReturnValue(true);
prisma.federationMessage.findFirst.mockResolvedValue(null);
await expect(service.processEventAck(ack)).rejects.toThrow(
"Original event message not found"
);
});
});
describe("getEventSubscriptions", () => {
it("should return all subscriptions for workspace", async () => {
const mockSubscriptions = [
{
id: "sub-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: "task.created",
metadata: {},
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "sub-2",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: "task.updated",
metadata: {},
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
prisma.federationEventSubscription.findMany.mockResolvedValue(mockSubscriptions);
const result = await service.getEventSubscriptions(mockWorkspaceId);
expect(result).toHaveLength(2);
expect(prisma.federationEventSubscription.findMany).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
},
orderBy: { createdAt: "desc" },
});
});
it("should filter by connectionId when provided", async () => {
const mockSubscriptions = [
{
id: "sub-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
eventType: "task.created",
metadata: {},
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
prisma.federationEventSubscription.findMany.mockResolvedValue(mockSubscriptions);
const result = await service.getEventSubscriptions(mockWorkspaceId, mockConnectionId);
expect(result).toHaveLength(1);
expect(prisma.federationEventSubscription.findMany).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
},
orderBy: { createdAt: "desc" },
});
});
});
describe("getEventMessages", () => {
it("should return all event messages for workspace", async () => {
const mockMessages = [
{
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.EVENT,
messageId: "msg-id-1",
correlationId: null,
query: null,
commandType: null,
eventType: "task.created",
payload: { data: "test1" },
response: null,
status: FederationMessageStatus.DELIVERED,
error: null,
signature: "sig-1",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: new Date(),
},
{
id: "msg-2",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.EVENT,
messageId: "msg-id-2",
correlationId: null,
query: null,
commandType: null,
eventType: "task.updated",
payload: { data: "test2" },
response: null,
status: FederationMessageStatus.PENDING,
error: null,
signature: "sig-2",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
},
];
prisma.federationMessage.findMany.mockResolvedValue(mockMessages);
const result = await service.getEventMessages(mockWorkspaceId);
expect(result).toHaveLength(2);
expect(prisma.federationMessage.findMany).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
messageType: FederationMessageType.EVENT,
},
orderBy: { createdAt: "desc" },
});
});
it("should filter by status when provided", async () => {
const mockMessages = [
{
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: FederationMessageType.EVENT,
messageId: "msg-id-1",
correlationId: null,
query: null,
commandType: null,
eventType: "task.created",
payload: { data: "test1" },
response: null,
status: FederationMessageStatus.PENDING,
error: null,
signature: "sig-1",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
},
];
prisma.federationMessage.findMany.mockResolvedValue(mockMessages);
const result = await service.getEventMessages(
mockWorkspaceId,
FederationMessageStatus.PENDING
);
expect(result).toHaveLength(1);
expect(prisma.federationMessage.findMany).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
messageType: FederationMessageType.EVENT,
status: FederationMessageStatus.PENDING,
},
orderBy: { createdAt: "desc" },
});
});
});
});

View File

@@ -0,0 +1,500 @@
/**
* Event Service
*
* Handles federated event messages and subscriptions.
*/
import { Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { randomUUID } from "crypto";
import { firstValueFrom } from "rxjs";
import { PrismaService } from "../prisma/prisma.service";
import { FederationService } from "./federation.service";
import { SignatureService } from "./signature.service";
import {
FederationConnectionStatus,
FederationMessageType,
FederationMessageStatus,
} from "@prisma/client";
import type {
EventMessage,
EventAck,
EventMessageDetails,
SubscriptionDetails,
} from "./types/message.types";
@Injectable()
export class EventService {
private readonly logger = new Logger(EventService.name);
constructor(
private readonly prisma: PrismaService,
private readonly federationService: FederationService,
private readonly signatureService: SignatureService,
private readonly httpService: HttpService
) {}
/**
* Subscribe to an event type from a remote instance
*/
async subscribeToEventType(
workspaceId: string,
connectionId: string,
eventType: string,
metadata?: Record<string, unknown>
): Promise<SubscriptionDetails> {
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Create subscription
const subscription = await this.prisma.federationEventSubscription.create({
data: {
workspaceId,
connectionId,
eventType,
metadata: (metadata ?? {}) as never,
},
});
this.logger.log(`Subscribed to event type ${eventType} on connection ${connectionId}`);
return this.mapToSubscriptionDetails(subscription);
}
/**
* Unsubscribe from an event type
*/
async unsubscribeFromEventType(
workspaceId: string,
connectionId: string,
eventType: string
): Promise<void> {
// Find subscription
const subscription = await this.prisma.federationEventSubscription.findFirst({
where: {
workspaceId,
connectionId,
eventType,
},
});
if (!subscription) {
throw new Error("Subscription not found");
}
// Delete subscription
await this.prisma.federationEventSubscription.delete({
where: { id: subscription.id },
});
this.logger.log(`Unsubscribed from event type ${eventType} on connection ${connectionId}`);
}
/**
* Publish an event to all subscribed instances
*/
async publishEvent(
workspaceId: string,
eventType: string,
payload: Record<string, unknown>
): Promise<EventMessageDetails[]> {
// Find all active subscriptions for this event type
const subscriptions = await this.prisma.federationEventSubscription.findMany({
where: {
workspaceId,
eventType,
isActive: true,
},
include: {
connection: true,
},
});
if (subscriptions.length === 0) {
this.logger.debug(`No active subscriptions for event type ${eventType}`);
return [];
}
// Get local instance identity
const identity = await this.federationService.getInstanceIdentity();
const results: EventMessageDetails[] = [];
// Publish to each subscribed connection
for (const subscription of subscriptions) {
const connection = subscription.connection;
// Skip if connection is not active
if (connection.status !== FederationConnectionStatus.ACTIVE) {
this.logger.warn(`Skipping inactive connection ${connection.id} for event ${eventType}`);
continue;
}
try {
// Create event message
const messageId = randomUUID();
const timestamp = Date.now();
const eventPayload: Record<string, unknown> = {
messageId,
instanceId: identity.instanceId,
eventType,
payload,
timestamp,
};
// Sign the event
const signature = await this.signatureService.signMessage(eventPayload);
const signedEvent = {
messageId,
instanceId: identity.instanceId,
eventType,
payload,
timestamp,
signature,
} as EventMessage;
// Store message in database
const message = await this.prisma.federationMessage.create({
data: {
workspaceId,
connectionId: connection.id,
messageType: FederationMessageType.EVENT,
messageId,
eventType,
payload: payload as never,
status: FederationMessageStatus.PENDING,
signature,
},
});
// Send event to remote instance
try {
const remoteUrl = `${connection.remoteUrl}/api/v1/federation/incoming/event`;
await firstValueFrom(this.httpService.post(remoteUrl, signedEvent));
this.logger.log(`Event sent to ${connection.remoteUrl}: ${messageId}`);
results.push(this.mapToEventMessageDetails(message));
} catch (error) {
this.logger.error(`Failed to send event to ${connection.remoteUrl}`, error);
// Update message status to failed
await this.prisma.federationMessage.update({
where: { id: message.id },
data: {
status: FederationMessageStatus.FAILED,
error: error instanceof Error ? error.message : "Unknown error",
},
});
results.push(
this.mapToEventMessageDetails({
...message,
status: FederationMessageStatus.FAILED,
error: error instanceof Error ? error.message : "Unknown error",
})
);
}
} catch (error) {
this.logger.error(`Failed to publish event to connection ${connection.id}`, error);
}
}
return results;
}
/**
* Handle incoming event from remote instance
*/
async handleIncomingEvent(eventMessage: EventMessage): Promise<EventAck> {
this.logger.log(`Received event from ${eventMessage.instanceId}: ${eventMessage.messageId}`);
// Validate timestamp
if (!this.signatureService.validateTimestamp(eventMessage.timestamp)) {
throw new Error("Event timestamp is outside acceptable range");
}
// Find connection for remote instance
const connection = await this.prisma.federationConnection.findFirst({
where: {
remoteInstanceId: eventMessage.instanceId,
status: FederationConnectionStatus.ACTIVE,
},
});
if (!connection) {
throw new Error("No connection found for remote instance");
}
// Validate connection is active
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Verify signature
const { signature, ...messageToVerify } = eventMessage;
const verificationResult = await this.signatureService.verifyMessage(
messageToVerify,
signature,
eventMessage.instanceId
);
if (!verificationResult.valid) {
throw new Error(verificationResult.error ?? "Invalid signature");
}
// Store received event
await this.prisma.federationMessage.create({
data: {
workspaceId: connection.workspaceId,
connectionId: connection.id,
messageType: FederationMessageType.EVENT,
messageId: eventMessage.messageId,
eventType: eventMessage.eventType,
payload: eventMessage.payload as never,
status: FederationMessageStatus.DELIVERED,
signature: eventMessage.signature,
deliveredAt: new Date(),
},
});
// Get local instance identity
const identity = await this.federationService.getInstanceIdentity();
// Create acknowledgment
const ackMessageId = randomUUID();
const ackTimestamp = Date.now();
const ackPayload: Record<string, unknown> = {
messageId: ackMessageId,
correlationId: eventMessage.messageId,
instanceId: identity.instanceId,
received: true,
timestamp: ackTimestamp,
};
// Sign the acknowledgment
const ackSignature = await this.signatureService.signMessage(ackPayload);
const ack = {
messageId: ackMessageId,
correlationId: eventMessage.messageId,
instanceId: identity.instanceId,
received: true,
timestamp: ackTimestamp,
signature: ackSignature,
} as EventAck;
return ack;
}
/**
* Process an event acknowledgment from remote instance
*/
async processEventAck(ack: EventAck): Promise<void> {
this.logger.log(`Received acknowledgment for event: ${ack.correlationId}`);
// Validate timestamp
if (!this.signatureService.validateTimestamp(ack.timestamp)) {
throw new Error("Acknowledgment timestamp is outside acceptable range");
}
// Find original event message
const message = await this.prisma.federationMessage.findFirst({
where: {
messageId: ack.correlationId,
messageType: FederationMessageType.EVENT,
},
});
if (!message) {
throw new Error("Original event message not found");
}
// Verify signature
const { signature, ...ackToVerify } = ack;
const verificationResult = await this.signatureService.verifyMessage(
ackToVerify,
signature,
ack.instanceId
);
if (!verificationResult.valid) {
throw new Error(verificationResult.error ?? "Invalid signature");
}
// Update message with acknowledgment
const updateData: Record<string, unknown> = {
status: ack.received ? FederationMessageStatus.DELIVERED : FederationMessageStatus.FAILED,
deliveredAt: new Date(),
};
if (ack.error !== undefined) {
updateData.error = ack.error;
}
await this.prisma.federationMessage.update({
where: { id: message.id },
data: updateData,
});
this.logger.log(`Event acknowledgment processed: ${ack.correlationId}`);
}
/**
* Get all event subscriptions for a workspace
*/
async getEventSubscriptions(
workspaceId: string,
connectionId?: string
): Promise<SubscriptionDetails[]> {
const where: Record<string, unknown> = {
workspaceId,
};
if (connectionId) {
where.connectionId = connectionId;
}
const subscriptions = await this.prisma.federationEventSubscription.findMany({
where,
orderBy: { createdAt: "desc" },
});
return subscriptions.map((sub) => this.mapToSubscriptionDetails(sub));
}
/**
* Get all event messages for a workspace
*/
async getEventMessages(
workspaceId: string,
status?: FederationMessageStatus
): Promise<EventMessageDetails[]> {
const where: Record<string, unknown> = {
workspaceId,
messageType: FederationMessageType.EVENT,
};
if (status) {
where.status = status;
}
const messages = await this.prisma.federationMessage.findMany({
where,
orderBy: { createdAt: "desc" },
});
return messages.map((msg) => this.mapToEventMessageDetails(msg));
}
/**
* Get a single event message
*/
async getEventMessage(workspaceId: string, messageId: string): Promise<EventMessageDetails> {
const message = await this.prisma.federationMessage.findUnique({
where: { id: messageId, workspaceId },
});
if (!message) {
throw new Error("Event message not found");
}
return this.mapToEventMessageDetails(message);
}
/**
* Map Prisma FederationMessage to EventMessageDetails
*/
private mapToEventMessageDetails(message: {
id: string;
workspaceId: string;
connectionId: string;
messageType: FederationMessageType;
messageId: string;
correlationId: string | null;
query: string | null;
commandType: string | null;
eventType: string | null;
payload: unknown;
response: unknown;
status: FederationMessageStatus;
error: string | null;
createdAt: Date;
updatedAt: Date;
deliveredAt: Date | null;
}): EventMessageDetails {
const details: EventMessageDetails = {
id: message.id,
workspaceId: message.workspaceId,
connectionId: message.connectionId,
messageType: message.messageType,
messageId: message.messageId,
response: message.response,
status: message.status,
createdAt: message.createdAt,
updatedAt: message.updatedAt,
};
if (message.correlationId !== null) {
details.correlationId = message.correlationId;
}
if (message.eventType !== null) {
details.eventType = message.eventType;
}
if (message.payload !== null && typeof message.payload === "object") {
details.payload = message.payload as Record<string, unknown>;
}
if (message.error !== null) {
details.error = message.error;
}
if (message.deliveredAt !== null) {
details.deliveredAt = message.deliveredAt;
}
return details;
}
/**
* Map Prisma FederationEventSubscription to SubscriptionDetails
*/
private mapToSubscriptionDetails(subscription: {
id: string;
workspaceId: string;
connectionId: string;
eventType: string;
metadata: unknown;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}): SubscriptionDetails {
return {
id: subscription.id,
workspaceId: subscription.workspaceId,
connectionId: subscription.connectionId,
eventType: subscription.eventType,
metadata:
typeof subscription.metadata === "object" && subscription.metadata !== null
? (subscription.metadata as Record<string, unknown>)
: {},
isActive: subscription.isActive,
createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt,
};
}
}

View File

@@ -0,0 +1,457 @@
/**
* Tests for Federation Agent Service
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { FederationAgentService } from "./federation-agent.service";
import { CommandService } from "./command.service";
import { PrismaService } from "../prisma/prisma.service";
import { FederationConnectionStatus } from "@prisma/client";
import { of, throwError } from "rxjs";
import type {
SpawnAgentCommandPayload,
AgentStatusCommandPayload,
KillAgentCommandPayload,
SpawnAgentResponseData,
AgentStatusResponseData,
KillAgentResponseData,
} from "./types/federation-agent.types";
describe("FederationAgentService", () => {
let service: FederationAgentService;
let commandService: ReturnType<typeof vi.mocked<CommandService>>;
let prisma: ReturnType<typeof vi.mocked<PrismaService>>;
let httpService: ReturnType<typeof vi.mocked<HttpService>>;
let configService: ReturnType<typeof vi.mocked<ConfigService>>;
const mockWorkspaceId = "workspace-1";
const mockConnectionId = "connection-1";
const mockAgentId = "agent-123";
const mockTaskId = "task-456";
const mockOrchestratorUrl = "http://localhost:3001";
beforeEach(async () => {
const mockCommandService = {
sendCommand: vi.fn(),
};
const mockPrisma = {
federationConnection: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
};
const mockHttpService = {
post: vi.fn(),
get: vi.fn(),
};
const mockConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.url") {
return mockOrchestratorUrl;
}
return undefined;
}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
FederationAgentService,
{ provide: CommandService, useValue: mockCommandService },
{ provide: PrismaService, useValue: mockPrisma },
{ provide: HttpService, useValue: mockHttpService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<FederationAgentService>(FederationAgentService);
commandService = module.get(CommandService);
prisma = module.get(PrismaService);
httpService = module.get(HttpService);
configService = module.get(ConfigService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("spawnAgentOnRemote", () => {
const spawnPayload: SpawnAgentCommandPayload = {
taskId: mockTaskId,
agentType: "worker",
context: {
repository: "git.example.com/org/repo",
branch: "main",
workItems: ["item-1"],
},
};
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should spawn agent on remote instance", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
const mockCommandResponse = {
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: "COMMAND" as never,
messageId: "msg-uuid",
commandType: "agent.spawn",
payload: spawnPayload as never,
response: {
agentId: mockAgentId,
status: "spawning",
spawnedAt: "2026-02-03T14:30:00Z",
} as never,
status: "DELIVERED" as never,
createdAt: new Date(),
updatedAt: new Date(),
};
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
const result = await service.spawnAgentOnRemote(
mockWorkspaceId,
mockConnectionId,
spawnPayload
);
expect(prisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: { id: mockConnectionId, workspaceId: mockWorkspaceId },
});
expect(commandService.sendCommand).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId,
"agent.spawn",
spawnPayload
);
expect(result).toEqual(mockCommandResponse);
});
it("should throw error if connection not found", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(null);
await expect(
service.spawnAgentOnRemote(mockWorkspaceId, mockConnectionId, spawnPayload)
).rejects.toThrow("Connection not found");
expect(commandService.sendCommand).not.toHaveBeenCalled();
});
it("should throw error if connection not active", async () => {
const inactiveConnection = {
...mockConnection,
status: FederationConnectionStatus.DISCONNECTED,
};
prisma.federationConnection.findUnique.mockResolvedValue(inactiveConnection as never);
await expect(
service.spawnAgentOnRemote(mockWorkspaceId, mockConnectionId, spawnPayload)
).rejects.toThrow("Connection is not active");
expect(commandService.sendCommand).not.toHaveBeenCalled();
});
});
describe("getAgentStatus", () => {
const statusPayload: AgentStatusCommandPayload = {
agentId: mockAgentId,
};
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should get agent status from remote instance", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
const mockCommandResponse = {
id: "msg-2",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: "COMMAND" as never,
messageId: "msg-uuid-2",
commandType: "agent.status",
payload: statusPayload as never,
response: {
agentId: mockAgentId,
taskId: mockTaskId,
status: "running",
spawnedAt: "2026-02-03T14:30:00Z",
startedAt: "2026-02-03T14:30:05Z",
} as never,
status: "DELIVERED" as never,
createdAt: new Date(),
updatedAt: new Date(),
};
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
const result = await service.getAgentStatus(mockWorkspaceId, mockConnectionId, mockAgentId);
expect(commandService.sendCommand).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId,
"agent.status",
statusPayload
);
expect(result).toEqual(mockCommandResponse);
});
});
describe("killAgentOnRemote", () => {
const killPayload: KillAgentCommandPayload = {
agentId: mockAgentId,
};
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should kill agent on remote instance", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
const mockCommandResponse = {
id: "msg-3",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: "COMMAND" as never,
messageId: "msg-uuid-3",
commandType: "agent.kill",
payload: killPayload as never,
response: {
agentId: mockAgentId,
status: "killed",
killedAt: "2026-02-03T14:35:00Z",
} as never,
status: "DELIVERED" as never,
createdAt: new Date(),
updatedAt: new Date(),
};
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
const result = await service.killAgentOnRemote(
mockWorkspaceId,
mockConnectionId,
mockAgentId
);
expect(commandService.sendCommand).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId,
"agent.kill",
killPayload
);
expect(result).toEqual(mockCommandResponse);
});
});
describe("handleAgentCommand", () => {
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should handle agent.spawn command", async () => {
const spawnPayload: SpawnAgentCommandPayload = {
taskId: mockTaskId,
agentType: "worker",
context: {
repository: "git.example.com/org/repo",
branch: "main",
workItems: ["item-1"],
},
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const mockOrchestratorResponse = {
agentId: mockAgentId,
status: "spawning",
};
httpService.post.mockReturnValue(
of({
data: mockOrchestratorResponse,
status: 200,
statusText: "OK",
headers: {},
config: {} as never,
}) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.spawn",
spawnPayload
);
expect(httpService.post).toHaveBeenCalledWith(
`${mockOrchestratorUrl}/agents/spawn`,
expect.objectContaining({
taskId: mockTaskId,
agentType: "worker",
})
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
agentId: mockAgentId,
status: "spawning",
spawnedAt: expect.any(String),
});
});
it("should handle agent.status command", async () => {
const statusPayload: AgentStatusCommandPayload = {
agentId: mockAgentId,
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const mockOrchestratorResponse = {
agentId: mockAgentId,
taskId: mockTaskId,
status: "running",
spawnedAt: "2026-02-03T14:30:00Z",
startedAt: "2026-02-03T14:30:05Z",
};
httpService.get.mockReturnValue(
of({
data: mockOrchestratorResponse,
status: 200,
statusText: "OK",
headers: {},
config: {} as never,
}) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.status",
statusPayload
);
expect(httpService.get).toHaveBeenCalledWith(
`${mockOrchestratorUrl}/agents/${mockAgentId}/status`
);
expect(result.success).toBe(true);
expect(result.data).toEqual(mockOrchestratorResponse);
});
it("should handle agent.kill command", async () => {
const killPayload: KillAgentCommandPayload = {
agentId: mockAgentId,
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const mockOrchestratorResponse = {
message: `Agent ${mockAgentId} killed successfully`,
};
httpService.post.mockReturnValue(
of({
data: mockOrchestratorResponse,
status: 200,
statusText: "OK",
headers: {},
config: {} as never,
}) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.kill",
killPayload
);
expect(httpService.post).toHaveBeenCalledWith(
`${mockOrchestratorUrl}/agents/${mockAgentId}/kill`,
{}
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
agentId: mockAgentId,
status: "killed",
killedAt: expect.any(String),
});
});
it("should return error for unknown command type", async () => {
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const result = await service.handleAgentCommand("remote-instance-1", "agent.unknown", {});
expect(result.success).toBe(false);
expect(result.error).toContain("Unknown agent command type: agent.unknown");
});
it("should throw error if connection not found", async () => {
prisma.federationConnection.findFirst.mockResolvedValue(null);
await expect(
service.handleAgentCommand("remote-instance-1", "agent.spawn", {})
).rejects.toThrow("No connection found for remote instance");
});
it("should handle orchestrator errors", async () => {
const spawnPayload: SpawnAgentCommandPayload = {
taskId: mockTaskId,
agentType: "worker",
context: {
repository: "git.example.com/org/repo",
branch: "main",
workItems: ["item-1"],
},
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
httpService.post.mockReturnValue(
throwError(() => new Error("Orchestrator connection failed")) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.spawn",
spawnPayload
);
expect(result.success).toBe(false);
expect(result.error).toContain("Orchestrator connection failed");
});
});
});

View File

@@ -0,0 +1,338 @@
/**
* Federation Agent Service
*
* Handles spawning and managing agents on remote federated instances.
*/
import { Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from "rxjs";
import { PrismaService } from "../prisma/prisma.service";
import { CommandService } from "./command.service";
import { FederationConnectionStatus } from "@prisma/client";
import type { CommandMessageDetails } from "./types/message.types";
import type {
SpawnAgentCommandPayload,
AgentStatusCommandPayload,
KillAgentCommandPayload,
SpawnAgentResponseData,
AgentStatusResponseData,
KillAgentResponseData,
} from "./types/federation-agent.types";
/**
* Agent command response structure
*/
export interface AgentCommandResponse {
/** Whether the command was successful */
success: boolean;
/** Response data if successful */
data?:
| SpawnAgentResponseData
| AgentStatusResponseData
| KillAgentResponseData
| Record<string, unknown>;
/** Error message if failed */
error?: string;
}
@Injectable()
export class FederationAgentService {
private readonly logger = new Logger(FederationAgentService.name);
private readonly orchestratorUrl: string;
constructor(
private readonly prisma: PrismaService,
private readonly commandService: CommandService,
private readonly httpService: HttpService,
private readonly configService: ConfigService
) {
this.orchestratorUrl =
this.configService.get<string>("orchestrator.url") ?? "http://localhost:3001";
this.logger.log(
`FederationAgentService initialized with orchestrator URL: ${this.orchestratorUrl}`
);
}
/**
* Spawn an agent on a remote federated instance
* @param workspaceId Workspace ID
* @param connectionId Federation connection ID
* @param payload Agent spawn command payload
* @returns Command message details
*/
async spawnAgentOnRemote(
workspaceId: string,
connectionId: string,
payload: SpawnAgentCommandPayload
): Promise<CommandMessageDetails> {
this.logger.log(
`Spawning agent on remote instance via connection ${connectionId} for task ${payload.taskId}`
);
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Send command via federation
const result = await this.commandService.sendCommand(
workspaceId,
connectionId,
"agent.spawn",
payload as unknown as Record<string, unknown>
);
this.logger.log(`Agent spawn command sent successfully: ${result.messageId}`);
return result;
}
/**
* Get agent status from remote instance
* @param workspaceId Workspace ID
* @param connectionId Federation connection ID
* @param agentId Agent ID
* @returns Command message details
*/
async getAgentStatus(
workspaceId: string,
connectionId: string,
agentId: string
): Promise<CommandMessageDetails> {
this.logger.log(`Getting agent status for ${agentId} via connection ${connectionId}`);
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Send status command
const payload: AgentStatusCommandPayload = { agentId };
const result = await this.commandService.sendCommand(
workspaceId,
connectionId,
"agent.status",
payload as unknown as Record<string, unknown>
);
this.logger.log(`Agent status command sent successfully: ${result.messageId}`);
return result;
}
/**
* Kill an agent on remote instance
* @param workspaceId Workspace ID
* @param connectionId Federation connection ID
* @param agentId Agent ID
* @returns Command message details
*/
async killAgentOnRemote(
workspaceId: string,
connectionId: string,
agentId: string
): Promise<CommandMessageDetails> {
this.logger.log(`Killing agent ${agentId} via connection ${connectionId}`);
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Send kill command
const payload: KillAgentCommandPayload = { agentId };
const result = await this.commandService.sendCommand(
workspaceId,
connectionId,
"agent.kill",
payload as unknown as Record<string, unknown>
);
this.logger.log(`Agent kill command sent successfully: ${result.messageId}`);
return result;
}
/**
* Handle incoming agent command from remote instance
* @param remoteInstanceId Remote instance ID that sent the command
* @param commandType Command type (agent.spawn, agent.status, agent.kill)
* @param payload Command payload
* @returns Agent command response
*/
async handleAgentCommand(
remoteInstanceId: string,
commandType: string,
payload: Record<string, unknown>
): Promise<AgentCommandResponse> {
this.logger.log(`Handling agent command ${commandType} from ${remoteInstanceId}`);
// Verify connection exists for remote instance
const connection = await this.prisma.federationConnection.findFirst({
where: {
remoteInstanceId,
status: FederationConnectionStatus.ACTIVE,
},
});
if (!connection) {
throw new Error("No connection found for remote instance");
}
// Route command to appropriate handler
try {
switch (commandType) {
case "agent.spawn":
return await this.handleSpawnCommand(payload as unknown as SpawnAgentCommandPayload);
case "agent.status":
return await this.handleStatusCommand(payload as unknown as AgentStatusCommandPayload);
case "agent.kill":
return await this.handleKillCommand(payload as unknown as KillAgentCommandPayload);
default:
throw new Error(`Unknown agent command type: ${commandType}`);
}
} catch (error) {
this.logger.error(`Error handling agent command: ${String(error)}`);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Handle agent spawn command by calling local orchestrator
* @param payload Spawn command payload
* @returns Spawn response
*/
private async handleSpawnCommand(
payload: SpawnAgentCommandPayload
): Promise<AgentCommandResponse> {
this.logger.log(`Processing spawn command for task ${payload.taskId}`);
try {
const orchestratorPayload = {
taskId: payload.taskId,
agentType: payload.agentType,
context: payload.context,
options: payload.options,
};
const response = await firstValueFrom(
this.httpService.post<{ agentId: string; status: string }>(
`${this.orchestratorUrl}/agents/spawn`,
orchestratorPayload
)
);
const spawnedAt = new Date().toISOString();
const responseData: SpawnAgentResponseData = {
agentId: response.data.agentId,
status: response.data.status as "spawning",
spawnedAt,
};
this.logger.log(`Agent spawned successfully: ${responseData.agentId}`);
return {
success: true,
data: responseData,
};
} catch (error) {
this.logger.error(`Failed to spawn agent: ${String(error)}`);
throw error;
}
}
/**
* Handle agent status command by calling local orchestrator
* @param payload Status command payload
* @returns Status response
*/
private async handleStatusCommand(
payload: AgentStatusCommandPayload
): Promise<AgentCommandResponse> {
this.logger.log(`Processing status command for agent ${payload.agentId}`);
try {
const response = await firstValueFrom(
this.httpService.get(`${this.orchestratorUrl}/agents/${payload.agentId}/status`)
);
const responseData: AgentStatusResponseData = response.data as AgentStatusResponseData;
this.logger.log(`Agent status retrieved: ${responseData.status}`);
return {
success: true,
data: responseData,
};
} catch (error) {
this.logger.error(`Failed to get agent status: ${String(error)}`);
throw error;
}
}
/**
* Handle agent kill command by calling local orchestrator
* @param payload Kill command payload
* @returns Kill response
*/
private async handleKillCommand(payload: KillAgentCommandPayload): Promise<AgentCommandResponse> {
this.logger.log(`Processing kill command for agent ${payload.agentId}`);
try {
await firstValueFrom(
this.httpService.post(`${this.orchestratorUrl}/agents/${payload.agentId}/kill`, {})
);
const killedAt = new Date().toISOString();
const responseData: KillAgentResponseData = {
agentId: payload.agentId,
status: "killed",
killedAt,
};
this.logger.log(`Agent killed successfully: ${payload.agentId}`);
return {
success: true,
data: responseData,
};
} catch (error) {
this.logger.error(`Failed to kill agent: ${String(error)}`);
throw error;
}
}
}

View File

@@ -240,9 +240,9 @@ describe("FederationAuthController", () => {
subject: "user-subject-123",
};
mockOIDCService.validateToken.mockReturnValue(mockValidation);
mockOIDCService.validateToken.mockResolvedValue(mockValidation);
const result = controller.validateToken(dto);
const result = await controller.validateToken(dto);
expect(result).toEqual(mockValidation);
expect(mockOIDCService.validateToken).toHaveBeenCalledWith(dto.token, dto.instanceId);
@@ -259,9 +259,9 @@ describe("FederationAuthController", () => {
error: "Token has expired",
};
mockOIDCService.validateToken.mockReturnValue(mockValidation);
mockOIDCService.validateToken.mockResolvedValue(mockValidation);
const result = controller.validateToken(dto);
const result = await controller.validateToken(dto);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();

View File

@@ -8,6 +8,7 @@ import { FederationController } from "./federation.controller";
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 { FederationConnectionStatus } from "@prisma/client";
@@ -88,6 +89,14 @@ describe("FederationController", () => {
handleIncomingConnectionRequest: vi.fn(),
},
},
{
provide: FederationAgentService,
useValue: {
spawnAgentOnRemote: vi.fn(),
getAgentStatus: vi.fn(),
killAgentOnRemote: vi.fn(),
},
},
],
})
.overrideGuard(AuthGuard)

View File

@@ -228,4 +228,126 @@ describe("FederationService", () => {
expect(result).toHaveProperty("instanceId");
});
});
describe("updateInstanceConfiguration", () => {
it("should update instance name", async () => {
// Arrange
const updatedInstance = { ...mockInstance, name: "Updated Instance" };
vi.spyOn(service, "getInstanceIdentity").mockResolvedValue({
...mockInstance,
privateKey: mockDecryptedPrivateKey,
});
vi.spyOn(prismaService.instance, "update").mockResolvedValue(updatedInstance);
// Act
const result = await service.updateInstanceConfiguration({ name: "Updated Instance" });
// Assert
expect(prismaService.instance.update).toHaveBeenCalledWith({
where: { id: mockInstance.id },
data: { name: "Updated Instance" },
});
expect(result.name).toBe("Updated Instance");
expect(result).not.toHaveProperty("privateKey");
});
it("should update instance capabilities", async () => {
// Arrange
const newCapabilities = {
supportsQuery: true,
supportsCommand: false,
supportsEvent: true,
supportsAgentSpawn: false,
protocolVersion: "1.0",
};
const updatedInstance = { ...mockInstance, capabilities: newCapabilities };
vi.spyOn(service, "getInstanceIdentity").mockResolvedValue({
...mockInstance,
privateKey: mockDecryptedPrivateKey,
});
vi.spyOn(prismaService.instance, "update").mockResolvedValue(updatedInstance);
// Act
const result = await service.updateInstanceConfiguration({ capabilities: newCapabilities });
// Assert
expect(prismaService.instance.update).toHaveBeenCalledWith({
where: { id: mockInstance.id },
data: { capabilities: newCapabilities },
});
expect(result.capabilities).toEqual(newCapabilities);
});
it("should update instance metadata", async () => {
// Arrange
const newMetadata = { description: "Test description", region: "us-west-2" };
const updatedInstance = { ...mockInstance, metadata: newMetadata };
vi.spyOn(service, "getInstanceIdentity").mockResolvedValue({
...mockInstance,
privateKey: mockDecryptedPrivateKey,
});
vi.spyOn(prismaService.instance, "update").mockResolvedValue(updatedInstance);
// Act
const result = await service.updateInstanceConfiguration({ metadata: newMetadata });
// Assert
expect(prismaService.instance.update).toHaveBeenCalledWith({
where: { id: mockInstance.id },
data: { metadata: newMetadata },
});
expect(result.metadata).toEqual(newMetadata);
});
it("should update multiple fields at once", async () => {
// Arrange
const updates = {
name: "Updated Instance",
capabilities: {
supportsQuery: false,
supportsCommand: false,
supportsEvent: false,
supportsAgentSpawn: false,
protocolVersion: "1.0",
},
metadata: { description: "Updated" },
};
const updatedInstance = { ...mockInstance, ...updates };
vi.spyOn(service, "getInstanceIdentity").mockResolvedValue({
...mockInstance,
privateKey: mockDecryptedPrivateKey,
});
vi.spyOn(prismaService.instance, "update").mockResolvedValue(updatedInstance);
// Act
const result = await service.updateInstanceConfiguration(updates);
// Assert
expect(prismaService.instance.update).toHaveBeenCalledWith({
where: { id: mockInstance.id },
data: updates,
});
expect(result.name).toBe("Updated Instance");
expect(result.capabilities).toEqual(updates.capabilities);
expect(result.metadata).toEqual(updates.metadata);
});
it("should not expose private key in response", async () => {
// Arrange
const updatedInstance = { ...mockInstance, name: "Updated" };
vi.spyOn(service, "getInstanceIdentity").mockResolvedValue({
...mockInstance,
privateKey: mockDecryptedPrivateKey,
});
vi.spyOn(prismaService.instance, "update").mockResolvedValue(updatedInstance);
// Act
const result = await service.updateInstanceConfiguration({ name: "Updated" });
// Assert - SECURITY: Verify private key is NOT in response
expect(result).not.toHaveProperty("privateKey");
expect(result).toHaveProperty("publicKey");
expect(result).toHaveProperty("instanceId");
});
});
});

View File

@@ -104,6 +104,46 @@ export class FederationService {
return publicIdentity;
}
/**
* Update instance configuration
* Allows updating name, capabilities, and metadata
* Returns public identity only (no private key exposure)
*/
async updateInstanceConfiguration(updates: {
name?: string;
capabilities?: FederationCapabilities;
metadata?: Record<string, unknown>;
}): Promise<PublicInstanceIdentity> {
const instance = await this.getInstanceIdentity();
// Build update data object
const data: Prisma.InstanceUpdateInput = {};
if (updates.name !== undefined) {
data.name = updates.name;
}
if (updates.capabilities !== undefined) {
data.capabilities = updates.capabilities as Prisma.JsonObject;
}
if (updates.metadata !== undefined) {
data.metadata = updates.metadata as Prisma.JsonObject;
}
const updatedInstance = await this.prisma.instance.update({
where: { id: instance.id },
data,
});
this.logger.log(`Instance configuration updated: ${JSON.stringify(updates)}`);
// Return public identity only (security fix)
const identity = this.mapToInstanceIdentity(updatedInstance);
const { privateKey: _privateKey, ...publicIdentity } = identity;
return publicIdentity;
}
/**
* Create a new instance identity
*/
@@ -145,6 +185,28 @@ export class FederationService {
return instance;
}
/**
* Get a federation connection by remote instance ID
* Returns the first active or pending connection
*/
async getConnectionByRemoteInstanceId(
remoteInstanceId: string
): Promise<{ remotePublicKey: string } | null> {
const connection = await this.prisma.federationConnection.findFirst({
where: {
remoteInstanceId,
status: {
in: ["ACTIVE", "PENDING"],
},
},
select: {
remotePublicKey: true,
},
});
return connection;
}
/**
* Generate a unique instance ID
*/

View File

@@ -0,0 +1,319 @@
/**
* Identity Linking Controller Tests
*
* Integration tests for identity linking API endpoints.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { IdentityLinkingController } from "./identity-linking.controller";
import { IdentityLinkingService } from "./identity-linking.service";
import { IdentityResolutionService } from "./identity-resolution.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { FederatedIdentity } from "./types/oidc.types";
import type {
CreateIdentityMappingDto,
UpdateIdentityMappingDto,
VerifyIdentityDto,
ResolveIdentityDto,
BulkResolveIdentityDto,
} from "./dto/identity-linking.dto";
describe("IdentityLinkingController", () => {
let controller: IdentityLinkingController;
let identityLinkingService: IdentityLinkingService;
let identityResolutionService: IdentityResolutionService;
const mockIdentity: FederatedIdentity = {
id: "identity-id",
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
oidcSubject: "oidc-subject",
email: "user@example.com",
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
};
const mockUser = {
id: "local-user-id",
email: "user@example.com",
name: "Test User",
};
beforeEach(async () => {
const mockIdentityLinkingService = {
verifyIdentity: vi.fn(),
createIdentityMapping: vi.fn(),
updateIdentityMapping: vi.fn(),
validateIdentityMapping: vi.fn(),
listUserIdentities: vi.fn(),
revokeIdentityMapping: vi.fn(),
};
const mockIdentityResolutionService = {
resolveIdentity: vi.fn(),
reverseResolveIdentity: vi.fn(),
bulkResolveIdentities: vi.fn(),
};
const mockAuthGuard = {
canActivate: (context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
request.user = mockUser;
return true;
},
};
const module: TestingModule = await Test.createTestingModule({
controllers: [IdentityLinkingController],
providers: [
{ provide: IdentityLinkingService, useValue: mockIdentityLinkingService },
{ provide: IdentityResolutionService, useValue: mockIdentityResolutionService },
{ provide: Reflector, useValue: { getAllAndOverride: vi.fn(() => []) } },
],
})
.overrideGuard(AuthGuard)
.useValue(mockAuthGuard)
.compile();
controller = module.get<IdentityLinkingController>(IdentityLinkingController);
identityLinkingService = module.get(IdentityLinkingService);
identityResolutionService = module.get(IdentityResolutionService);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("POST /identity/verify", () => {
it("should verify identity with valid request", async () => {
const dto: VerifyIdentityDto = {
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
oidcToken: "valid-token",
timestamp: Date.now(),
signature: "valid-signature",
};
identityLinkingService.verifyIdentity.mockResolvedValue({
verified: true,
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
email: "user@example.com",
});
const result = await controller.verifyIdentity(dto);
expect(result.verified).toBe(true);
expect(result.localUserId).toBe("local-user-id");
expect(identityLinkingService.verifyIdentity).toHaveBeenCalledWith(dto);
});
it("should return verification failure", async () => {
const dto: VerifyIdentityDto = {
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
oidcToken: "invalid-token",
timestamp: Date.now(),
signature: "invalid-signature",
};
identityLinkingService.verifyIdentity.mockResolvedValue({
verified: false,
error: "Invalid signature",
});
const result = await controller.verifyIdentity(dto);
expect(result.verified).toBe(false);
expect(result.error).toBe("Invalid signature");
});
});
describe("POST /identity/resolve", () => {
it("should resolve remote user to local user", async () => {
const dto: ResolveIdentityDto = {
remoteInstanceId: "remote-instance-id",
remoteUserId: "remote-user-id",
};
identityResolutionService.resolveIdentity.mockResolvedValue({
found: true,
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
email: "user@example.com",
});
const result = await controller.resolveIdentity(dto);
expect(result.found).toBe(true);
expect(result.localUserId).toBe("local-user-id");
expect(identityResolutionService.resolveIdentity).toHaveBeenCalledWith(
"remote-instance-id",
"remote-user-id"
);
});
it("should return not found when mapping does not exist", async () => {
const dto: ResolveIdentityDto = {
remoteInstanceId: "remote-instance-id",
remoteUserId: "unknown-user-id",
};
identityResolutionService.resolveIdentity.mockResolvedValue({
found: false,
remoteUserId: "unknown-user-id",
remoteInstanceId: "remote-instance-id",
});
const result = await controller.resolveIdentity(dto);
expect(result.found).toBe(false);
expect(result.localUserId).toBeUndefined();
});
});
describe("POST /identity/bulk-resolve", () => {
it("should resolve multiple remote users", async () => {
const dto: BulkResolveIdentityDto = {
remoteInstanceId: "remote-instance-id",
remoteUserIds: ["remote-user-1", "remote-user-2", "unknown-user"],
};
identityResolutionService.bulkResolveIdentities.mockResolvedValue({
mappings: {
"remote-user-1": "local-user-1",
"remote-user-2": "local-user-2",
},
notFound: ["unknown-user"],
});
const result = await controller.bulkResolveIdentity(dto);
expect(result.mappings).toEqual({
"remote-user-1": "local-user-1",
"remote-user-2": "local-user-2",
});
expect(result.notFound).toEqual(["unknown-user"]);
});
});
describe("GET /identity/me", () => {
it("should return current user's federated identities", async () => {
identityLinkingService.listUserIdentities.mockResolvedValue([mockIdentity]);
const result = await controller.getCurrentUserIdentities(mockUser);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(mockIdentity);
expect(identityLinkingService.listUserIdentities).toHaveBeenCalledWith("local-user-id");
});
it("should return empty array if no identities", async () => {
identityLinkingService.listUserIdentities.mockResolvedValue([]);
const result = await controller.getCurrentUserIdentities(mockUser);
expect(result).toEqual([]);
});
});
describe("POST /identity/link", () => {
it("should create identity mapping", async () => {
const dto: CreateIdentityMappingDto = {
remoteInstanceId: "remote-instance-id",
remoteUserId: "remote-user-id",
oidcSubject: "oidc-subject",
email: "user@example.com",
metadata: { source: "manual" },
};
identityLinkingService.createIdentityMapping.mockResolvedValue(mockIdentity);
const result = await controller.createIdentityMapping(mockUser, dto);
expect(result).toEqual(mockIdentity);
expect(identityLinkingService.createIdentityMapping).toHaveBeenCalledWith(
"local-user-id",
dto
);
});
});
describe("PATCH /identity/:remoteInstanceId", () => {
it("should update identity mapping", async () => {
const remoteInstanceId = "remote-instance-id";
const dto: UpdateIdentityMappingDto = {
metadata: { updated: true },
};
const updatedIdentity = { ...mockIdentity, metadata: { updated: true } };
identityLinkingService.updateIdentityMapping.mockResolvedValue(updatedIdentity);
const result = await controller.updateIdentityMapping(mockUser, remoteInstanceId, dto);
expect(result.metadata).toEqual({ updated: true });
expect(identityLinkingService.updateIdentityMapping).toHaveBeenCalledWith(
"local-user-id",
remoteInstanceId,
dto
);
});
});
describe("DELETE /identity/:remoteInstanceId", () => {
it("should revoke identity mapping", async () => {
const remoteInstanceId = "remote-instance-id";
identityLinkingService.revokeIdentityMapping.mockResolvedValue(undefined);
const result = await controller.revokeIdentityMapping(mockUser, remoteInstanceId);
expect(result).toEqual({ success: true });
expect(identityLinkingService.revokeIdentityMapping).toHaveBeenCalledWith(
"local-user-id",
remoteInstanceId
);
});
});
describe("GET /identity/:remoteInstanceId/validate", () => {
it("should validate existing identity mapping", async () => {
const remoteInstanceId = "remote-instance-id";
identityLinkingService.validateIdentityMapping.mockResolvedValue({
valid: true,
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
});
const result = await controller.validateIdentityMapping(mockUser, remoteInstanceId);
expect(result.valid).toBe(true);
expect(result.localUserId).toBe("local-user-id");
});
it("should return invalid if mapping not found", async () => {
const remoteInstanceId = "unknown-instance-id";
identityLinkingService.validateIdentityMapping.mockResolvedValue({
valid: false,
error: "Identity mapping not found",
});
const result = await controller.validateIdentityMapping(mockUser, remoteInstanceId);
expect(result.valid).toBe(false);
expect(result.error).toContain("not found");
});
});
});

View File

@@ -0,0 +1,151 @@
/**
* Identity Linking Controller
*
* API endpoints for cross-instance identity verification and management.
*/
import { Controller, Post, Get, Patch, Delete, Body, Param, UseGuards } from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard";
import { IdentityLinkingService } from "./identity-linking.service";
import { IdentityResolutionService } from "./identity-resolution.service";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import type {
VerifyIdentityDto,
ResolveIdentityDto,
BulkResolveIdentityDto,
CreateIdentityMappingDto,
UpdateIdentityMappingDto,
} from "./dto/identity-linking.dto";
import type {
IdentityVerificationResponse,
IdentityResolutionResponse,
BulkIdentityResolutionResponse,
IdentityMappingValidation,
} from "./types/identity-linking.types";
import type { FederatedIdentity } from "./types/oidc.types";
/**
* User object from authentication
*/
interface AuthenticatedUser {
id: string;
email: string;
name: string;
}
@Controller("federation/identity")
export class IdentityLinkingController {
constructor(
private readonly identityLinkingService: IdentityLinkingService,
private readonly identityResolutionService: IdentityResolutionService
) {}
/**
* POST /api/v1/federation/identity/verify
*
* Verify a user's identity from a remote instance.
* Validates signature and OIDC token.
*/
@Post("verify")
async verifyIdentity(@Body() dto: VerifyIdentityDto): Promise<IdentityVerificationResponse> {
return this.identityLinkingService.verifyIdentity(dto);
}
/**
* POST /api/v1/federation/identity/resolve
*
* Resolve a remote user to a local user.
*/
@Post("resolve")
@UseGuards(AuthGuard)
async resolveIdentity(@Body() dto: ResolveIdentityDto): Promise<IdentityResolutionResponse> {
return this.identityResolutionService.resolveIdentity(dto.remoteInstanceId, dto.remoteUserId);
}
/**
* POST /api/v1/federation/identity/bulk-resolve
*
* Bulk resolve multiple remote users to local users.
*/
@Post("bulk-resolve")
@UseGuards(AuthGuard)
async bulkResolveIdentity(
@Body() dto: BulkResolveIdentityDto
): Promise<BulkIdentityResolutionResponse> {
return this.identityResolutionService.bulkResolveIdentities(
dto.remoteInstanceId,
dto.remoteUserIds
);
}
/**
* GET /api/v1/federation/identity/me
*
* Get the current user's federated identities.
*/
@Get("me")
@UseGuards(AuthGuard)
async getCurrentUserIdentities(
@CurrentUser() user: AuthenticatedUser
): Promise<FederatedIdentity[]> {
return this.identityLinkingService.listUserIdentities(user.id);
}
/**
* POST /api/v1/federation/identity/link
*
* Create a new identity mapping for the current user.
*/
@Post("link")
@UseGuards(AuthGuard)
async createIdentityMapping(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: CreateIdentityMappingDto
): Promise<FederatedIdentity> {
return this.identityLinkingService.createIdentityMapping(user.id, dto);
}
/**
* PATCH /api/v1/federation/identity/:remoteInstanceId
*
* Update an existing identity mapping.
*/
@Patch(":remoteInstanceId")
@UseGuards(AuthGuard)
async updateIdentityMapping(
@CurrentUser() user: AuthenticatedUser,
@Param("remoteInstanceId") remoteInstanceId: string,
@Body() dto: UpdateIdentityMappingDto
): Promise<FederatedIdentity> {
return this.identityLinkingService.updateIdentityMapping(user.id, remoteInstanceId, dto);
}
/**
* DELETE /api/v1/federation/identity/:remoteInstanceId
*
* Revoke an identity mapping.
*/
@Delete(":remoteInstanceId")
@UseGuards(AuthGuard)
async revokeIdentityMapping(
@CurrentUser() user: AuthenticatedUser,
@Param("remoteInstanceId") remoteInstanceId: string
): Promise<{ success: boolean }> {
await this.identityLinkingService.revokeIdentityMapping(user.id, remoteInstanceId);
return { success: true };
}
/**
* GET /api/v1/federation/identity/:remoteInstanceId/validate
*
* Validate an identity mapping exists and is valid.
*/
@Get(":remoteInstanceId/validate")
@UseGuards(AuthGuard)
async validateIdentityMapping(
@CurrentUser() user: AuthenticatedUser,
@Param("remoteInstanceId") remoteInstanceId: string
): Promise<IdentityMappingValidation> {
return this.identityLinkingService.validateIdentityMapping(user.id, remoteInstanceId);
}
}

View File

@@ -0,0 +1,404 @@
/**
* Identity Linking Service Tests
*
* Tests for cross-instance identity verification and mapping.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { IdentityLinkingService } from "./identity-linking.service";
import { OIDCService } from "./oidc.service";
import { SignatureService } from "./signature.service";
import { FederationAuditService } from "./audit.service";
import { PrismaService } from "../prisma/prisma.service";
import type {
IdentityVerificationRequest,
CreateIdentityMappingDto,
UpdateIdentityMappingDto,
} from "./types/identity-linking.types";
import type { FederatedIdentity } from "./types/oidc.types";
describe("IdentityLinkingService", () => {
let service: IdentityLinkingService;
let oidcService: OIDCService;
let signatureService: SignatureService;
let auditService: FederationAuditService;
let prismaService: PrismaService;
const mockFederatedIdentity: FederatedIdentity = {
id: "identity-id",
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
oidcSubject: "oidc-subject",
email: "user@example.com",
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const mockOIDCService = {
linkFederatedIdentity: vi.fn(),
getFederatedIdentity: vi.fn(),
getUserFederatedIdentities: vi.fn(),
revokeFederatedIdentity: vi.fn(),
validateToken: vi.fn(),
};
const mockSignatureService = {
verifyMessage: vi.fn(),
validateTimestamp: vi.fn(),
};
const mockAuditService = {
logIdentityVerification: vi.fn(),
logIdentityLinking: vi.fn(),
logIdentityRevocation: vi.fn(),
};
const mockPrismaService = {
federatedIdentity: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
};
const module: TestingModule = await Test.createTestingModule({
providers: [
IdentityLinkingService,
{ provide: OIDCService, useValue: mockOIDCService },
{ provide: SignatureService, useValue: mockSignatureService },
{ provide: FederationAuditService, useValue: mockAuditService },
{ provide: PrismaService, useValue: mockPrismaService },
],
}).compile();
service = module.get<IdentityLinkingService>(IdentityLinkingService);
oidcService = module.get(OIDCService);
signatureService = module.get(SignatureService);
auditService = module.get(FederationAuditService);
prismaService = module.get(PrismaService);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("verifyIdentity", () => {
it("should verify identity with valid signature and token", async () => {
const request: IdentityVerificationRequest = {
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
oidcToken: "valid-token",
timestamp: Date.now(),
signature: "valid-signature",
};
signatureService.validateTimestamp.mockReturnValue(true);
signatureService.verifyMessage.mockResolvedValue({ valid: true });
oidcService.validateToken.mockReturnValue({
valid: true,
userId: "remote-user-id",
instanceId: "remote-instance-id",
email: "user@example.com",
});
oidcService.getFederatedIdentity.mockResolvedValue(mockFederatedIdentity);
const result = await service.verifyIdentity(request);
expect(result.verified).toBe(true);
expect(result.localUserId).toBe("local-user-id");
expect(result.remoteUserId).toBe("remote-user-id");
expect(result.remoteInstanceId).toBe("remote-instance-id");
expect(signatureService.validateTimestamp).toHaveBeenCalledWith(request.timestamp);
expect(signatureService.verifyMessage).toHaveBeenCalled();
expect(oidcService.validateToken).toHaveBeenCalledWith("valid-token", "remote-instance-id");
expect(auditService.logIdentityVerification).toHaveBeenCalled();
});
it("should reject identity with invalid signature", async () => {
const request: IdentityVerificationRequest = {
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
oidcToken: "valid-token",
timestamp: Date.now(),
signature: "invalid-signature",
};
signatureService.validateTimestamp.mockReturnValue(true);
signatureService.verifyMessage.mockResolvedValue({
valid: false,
error: "Invalid signature",
});
const result = await service.verifyIdentity(request);
expect(result.verified).toBe(false);
expect(result.error).toContain("Invalid signature");
expect(oidcService.validateToken).not.toHaveBeenCalled();
});
it("should reject identity with expired timestamp", async () => {
const request: IdentityVerificationRequest = {
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
oidcToken: "valid-token",
timestamp: Date.now() - 10 * 60 * 1000, // 10 minutes ago
signature: "valid-signature",
};
signatureService.validateTimestamp.mockReturnValue(false);
const result = await service.verifyIdentity(request);
expect(result.verified).toBe(false);
expect(result.error).toContain("expired");
expect(signatureService.verifyMessage).not.toHaveBeenCalled();
});
it("should reject identity with invalid OIDC token", async () => {
const request: IdentityVerificationRequest = {
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
oidcToken: "invalid-token",
timestamp: Date.now(),
signature: "valid-signature",
};
signatureService.validateTimestamp.mockReturnValue(true);
signatureService.verifyMessage.mockResolvedValue({ valid: true });
oidcService.validateToken.mockReturnValue({
valid: false,
error: "Invalid token",
});
const result = await service.verifyIdentity(request);
expect(result.verified).toBe(false);
expect(result.error).toContain("Invalid token");
});
it("should reject identity if mapping does not exist", async () => {
const request: IdentityVerificationRequest = {
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
oidcToken: "valid-token",
timestamp: Date.now(),
signature: "valid-signature",
};
signatureService.validateTimestamp.mockReturnValue(true);
signatureService.verifyMessage.mockResolvedValue({ valid: true });
oidcService.validateToken.mockReturnValue({
valid: true,
userId: "remote-user-id",
instanceId: "remote-instance-id",
});
oidcService.getFederatedIdentity.mockResolvedValue(null);
const result = await service.verifyIdentity(request);
expect(result.verified).toBe(false);
expect(result.error).toContain("not found");
});
});
describe("resolveLocalIdentity", () => {
it("should resolve remote user to local user", async () => {
prismaService.federatedIdentity.findFirst.mockResolvedValue(mockFederatedIdentity as never);
const result = await service.resolveLocalIdentity("remote-instance-id", "remote-user-id");
expect(result).not.toBeNull();
expect(result?.localUserId).toBe("local-user-id");
expect(result?.remoteUserId).toBe("remote-user-id");
expect(result?.email).toBe("user@example.com");
});
it("should return null when mapping not found", async () => {
prismaService.federatedIdentity.findFirst.mockResolvedValue(null);
const result = await service.resolveLocalIdentity("remote-instance-id", "unknown-user-id");
expect(result).toBeNull();
});
});
describe("resolveRemoteIdentity", () => {
it("should resolve local user to remote user", async () => {
oidcService.getFederatedIdentity.mockResolvedValue(mockFederatedIdentity);
const result = await service.resolveRemoteIdentity("local-user-id", "remote-instance-id");
expect(result).not.toBeNull();
expect(result?.remoteUserId).toBe("remote-user-id");
expect(result?.localUserId).toBe("local-user-id");
});
it("should return null when mapping not found", async () => {
oidcService.getFederatedIdentity.mockResolvedValue(null);
const result = await service.resolveRemoteIdentity("unknown-user-id", "remote-instance-id");
expect(result).toBeNull();
});
});
describe("createIdentityMapping", () => {
it("should create identity mapping with valid data", async () => {
const dto: CreateIdentityMappingDto = {
remoteInstanceId: "remote-instance-id",
remoteUserId: "remote-user-id",
oidcSubject: "oidc-subject",
email: "user@example.com",
metadata: { source: "manual" },
};
oidcService.linkFederatedIdentity.mockResolvedValue(mockFederatedIdentity);
const result = await service.createIdentityMapping("local-user-id", dto);
expect(result).toEqual(mockFederatedIdentity);
expect(oidcService.linkFederatedIdentity).toHaveBeenCalledWith(
"local-user-id",
"remote-user-id",
"remote-instance-id",
"oidc-subject",
"user@example.com",
{ source: "manual" }
);
expect(auditService.logIdentityLinking).toHaveBeenCalled();
});
it("should validate OIDC token if provided", async () => {
const dto: CreateIdentityMappingDto = {
remoteInstanceId: "remote-instance-id",
remoteUserId: "remote-user-id",
oidcSubject: "oidc-subject",
email: "user@example.com",
oidcToken: "valid-token",
};
oidcService.validateToken.mockReturnValue({ valid: true });
oidcService.linkFederatedIdentity.mockResolvedValue(mockFederatedIdentity);
await service.createIdentityMapping("local-user-id", dto);
expect(oidcService.validateToken).toHaveBeenCalledWith("valid-token", "remote-instance-id");
});
it("should throw error if OIDC token is invalid", async () => {
const dto: CreateIdentityMappingDto = {
remoteInstanceId: "remote-instance-id",
remoteUserId: "remote-user-id",
oidcSubject: "oidc-subject",
email: "user@example.com",
oidcToken: "invalid-token",
};
oidcService.validateToken.mockReturnValue({
valid: false,
error: "Invalid token",
});
await expect(service.createIdentityMapping("local-user-id", dto)).rejects.toThrow(
"Invalid OIDC token"
);
});
});
describe("updateIdentityMapping", () => {
it("should update identity mapping metadata", async () => {
const dto: UpdateIdentityMappingDto = {
metadata: { updated: true },
};
const updatedIdentity = { ...mockFederatedIdentity, metadata: { updated: true } };
prismaService.federatedIdentity.findUnique.mockResolvedValue(mockFederatedIdentity as never);
prismaService.federatedIdentity.update.mockResolvedValue(updatedIdentity as never);
const result = await service.updateIdentityMapping(
"local-user-id",
"remote-instance-id",
dto
);
expect(result.metadata).toEqual({ updated: true });
expect(prismaService.federatedIdentity.update).toHaveBeenCalled();
});
it("should throw error if mapping not found", async () => {
const dto: UpdateIdentityMappingDto = {
metadata: { updated: true },
};
prismaService.federatedIdentity.findUnique.mockResolvedValue(null);
await expect(
service.updateIdentityMapping("unknown-user-id", "remote-instance-id", dto)
).rejects.toThrow("not found");
});
});
describe("validateIdentityMapping", () => {
it("should validate existing identity mapping", async () => {
oidcService.getFederatedIdentity.mockResolvedValue(mockFederatedIdentity);
const result = await service.validateIdentityMapping("local-user-id", "remote-instance-id");
expect(result.valid).toBe(true);
expect(result.localUserId).toBe("local-user-id");
expect(result.remoteUserId).toBe("remote-user-id");
});
it("should return invalid if mapping not found", async () => {
oidcService.getFederatedIdentity.mockResolvedValue(null);
const result = await service.validateIdentityMapping("unknown-user-id", "remote-instance-id");
expect(result.valid).toBe(false);
expect(result.error).toContain("not found");
});
});
describe("listUserIdentities", () => {
it("should list all federated identities for a user", async () => {
const identities = [mockFederatedIdentity];
oidcService.getUserFederatedIdentities.mockResolvedValue(identities);
const result = await service.listUserIdentities("local-user-id");
expect(result).toEqual(identities);
expect(oidcService.getUserFederatedIdentities).toHaveBeenCalledWith("local-user-id");
});
it("should return empty array if user has no federated identities", async () => {
oidcService.getUserFederatedIdentities.mockResolvedValue([]);
const result = await service.listUserIdentities("local-user-id");
expect(result).toEqual([]);
});
});
describe("revokeIdentityMapping", () => {
it("should revoke identity mapping", async () => {
oidcService.revokeFederatedIdentity.mockResolvedValue(undefined);
await service.revokeIdentityMapping("local-user-id", "remote-instance-id");
expect(oidcService.revokeFederatedIdentity).toHaveBeenCalledWith(
"local-user-id",
"remote-instance-id"
);
expect(auditService.logIdentityRevocation).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,323 @@
/**
* Identity Linking Service
*
* Handles cross-instance user identity verification and mapping.
*/
import { Injectable, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { OIDCService } from "./oidc.service";
import { SignatureService } from "./signature.service";
import { FederationAuditService } from "./audit.service";
import type {
IdentityVerificationRequest,
IdentityVerificationResponse,
CreateIdentityMappingDto,
UpdateIdentityMappingDto,
IdentityMappingValidation,
} from "./types/identity-linking.types";
import type { FederatedIdentity } from "./types/oidc.types";
@Injectable()
export class IdentityLinkingService {
private readonly logger = new Logger(IdentityLinkingService.name);
constructor(
private readonly prisma: PrismaService,
private readonly oidcService: OIDCService,
private readonly signatureService: SignatureService,
private readonly auditService: FederationAuditService
) {}
/**
* Verify a user's identity from a remote instance
*
* Validates:
* 1. Timestamp is recent (not expired)
* 2. Signature is valid (signed by remote instance)
* 3. OIDC token is valid
* 4. Identity mapping exists
*/
async verifyIdentity(
request: IdentityVerificationRequest
): Promise<IdentityVerificationResponse> {
this.logger.log(`Verifying identity: ${request.localUserId} from ${request.remoteInstanceId}`);
// Validate timestamp (prevent replay attacks)
if (!this.signatureService.validateTimestamp(request.timestamp)) {
this.logger.warn(`Identity verification failed: Request timestamp expired`);
return {
verified: false,
error: "Request timestamp expired",
};
}
// Verify signature
const { signature, ...messageToVerify } = request;
const signatureValidation = await this.signatureService.verifyMessage(
messageToVerify,
signature,
request.remoteInstanceId
);
if (!signatureValidation.valid) {
const errorMessage = signatureValidation.error ?? "Invalid signature";
this.logger.warn(`Identity verification failed: ${errorMessage}`);
return {
verified: false,
error: errorMessage,
};
}
// Validate OIDC token
const tokenValidation = await this.oidcService.validateToken(
request.oidcToken,
request.remoteInstanceId
);
if (!tokenValidation.valid) {
const tokenError = tokenValidation.error ?? "Invalid OIDC token";
this.logger.warn(`Identity verification failed: ${tokenError}`);
return {
verified: false,
error: tokenError,
};
}
// Check if identity mapping exists
const identity = await this.oidcService.getFederatedIdentity(
request.localUserId,
request.remoteInstanceId
);
if (!identity) {
this.logger.warn(
`Identity verification failed: Mapping not found for ${request.localUserId}`
);
return {
verified: false,
error: "Identity mapping not found",
};
}
// Verify that the remote user ID matches
if (identity.remoteUserId !== request.remoteUserId) {
this.logger.warn(
`Identity verification failed: Remote user ID mismatch (expected ${identity.remoteUserId}, got ${request.remoteUserId})`
);
return {
verified: false,
error: "Remote user ID mismatch",
};
}
// Log successful verification
this.auditService.logIdentityVerification(request.localUserId, request.remoteInstanceId, true);
this.logger.log(`Identity verified successfully: ${request.localUserId}`);
return {
verified: true,
localUserId: identity.localUserId,
remoteUserId: identity.remoteUserId,
remoteInstanceId: identity.remoteInstanceId,
email: identity.email,
};
}
/**
* Resolve a remote user to a local user
*
* Looks up the identity mapping by remote instance and user ID.
*/
async resolveLocalIdentity(
remoteInstanceId: string,
remoteUserId: string
): Promise<FederatedIdentity | null> {
this.logger.debug(`Resolving local identity for ${remoteUserId}@${remoteInstanceId}`);
// Query by remoteInstanceId and remoteUserId
// Note: Prisma doesn't have a unique constraint for this pair,
// so we use findFirst
const identity = await this.prisma.federatedIdentity.findFirst({
where: {
remoteInstanceId,
remoteUserId,
},
});
if (!identity) {
this.logger.debug(`No local identity found for ${remoteUserId}@${remoteInstanceId}`);
return null;
}
return {
id: identity.id,
localUserId: identity.localUserId,
remoteUserId: identity.remoteUserId,
remoteInstanceId: identity.remoteInstanceId,
oidcSubject: identity.oidcSubject,
email: identity.email,
metadata: identity.metadata as Record<string, unknown>,
createdAt: identity.createdAt,
updatedAt: identity.updatedAt,
};
}
/**
* Resolve a local user to a remote identity
*
* Looks up the identity mapping by local user ID and remote instance.
*/
async resolveRemoteIdentity(
localUserId: string,
remoteInstanceId: string
): Promise<FederatedIdentity | null> {
this.logger.debug(`Resolving remote identity for ${localUserId}@${remoteInstanceId}`);
const identity = await this.oidcService.getFederatedIdentity(localUserId, remoteInstanceId);
if (!identity) {
this.logger.debug(`No remote identity found for ${localUserId}@${remoteInstanceId}`);
return null;
}
return identity;
}
/**
* Create a new identity mapping
*
* Optionally validates OIDC token if provided.
*/
async createIdentityMapping(
localUserId: string,
dto: CreateIdentityMappingDto
): Promise<FederatedIdentity> {
this.logger.log(
`Creating identity mapping: ${localUserId} -> ${dto.remoteUserId}@${dto.remoteInstanceId}`
);
// Validate OIDC token if provided
if (dto.oidcToken) {
const tokenValidation = await this.oidcService.validateToken(
dto.oidcToken,
dto.remoteInstanceId
);
if (!tokenValidation.valid) {
const validationError = tokenValidation.error ?? "Unknown validation error";
throw new UnauthorizedException(`Invalid OIDC token: ${validationError}`);
}
}
// Create identity mapping via OIDCService
const identity = await this.oidcService.linkFederatedIdentity(
localUserId,
dto.remoteUserId,
dto.remoteInstanceId,
dto.oidcSubject,
dto.email,
dto.metadata ?? {}
);
// Log identity linking
this.auditService.logIdentityLinking(localUserId, dto.remoteInstanceId, dto.remoteUserId);
return identity;
}
/**
* Update an existing identity mapping
*/
async updateIdentityMapping(
localUserId: string,
remoteInstanceId: string,
dto: UpdateIdentityMappingDto
): Promise<FederatedIdentity> {
this.logger.log(`Updating identity mapping: ${localUserId}@${remoteInstanceId}`);
// Verify mapping exists
const existing = await this.prisma.federatedIdentity.findUnique({
where: {
localUserId_remoteInstanceId: {
localUserId,
remoteInstanceId,
},
},
});
if (!existing) {
throw new NotFoundException("Identity mapping not found");
}
// Update metadata
const updated = await this.prisma.federatedIdentity.update({
where: {
localUserId_remoteInstanceId: {
localUserId,
remoteInstanceId,
},
},
data: {
metadata: (dto.metadata ?? existing.metadata) as Prisma.InputJsonValue,
},
});
return {
id: updated.id,
localUserId: updated.localUserId,
remoteUserId: updated.remoteUserId,
remoteInstanceId: updated.remoteInstanceId,
oidcSubject: updated.oidcSubject,
email: updated.email,
metadata: updated.metadata as Record<string, unknown>,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
};
}
/**
* Validate an identity mapping exists and is valid
*/
async validateIdentityMapping(
localUserId: string,
remoteInstanceId: string
): Promise<IdentityMappingValidation> {
const identity = await this.oidcService.getFederatedIdentity(localUserId, remoteInstanceId);
if (!identity) {
return {
valid: false,
error: "Identity mapping not found",
};
}
return {
valid: true,
localUserId: identity.localUserId,
remoteUserId: identity.remoteUserId,
remoteInstanceId: identity.remoteInstanceId,
};
}
/**
* List all federated identities for a user
*/
async listUserIdentities(localUserId: string): Promise<FederatedIdentity[]> {
return this.oidcService.getUserFederatedIdentities(localUserId);
}
/**
* Revoke an identity mapping
*/
async revokeIdentityMapping(localUserId: string, remoteInstanceId: string): Promise<void> {
this.logger.log(`Revoking identity mapping: ${localUserId}@${remoteInstanceId}`);
await this.oidcService.revokeFederatedIdentity(localUserId, remoteInstanceId);
// Log revocation
this.auditService.logIdentityRevocation(localUserId, remoteInstanceId);
}
}

View File

@@ -0,0 +1,151 @@
/**
* Identity Resolution Service Tests
*
* Tests for resolving identities between local and remote instances.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { IdentityResolutionService } from "./identity-resolution.service";
import { IdentityLinkingService } from "./identity-linking.service";
import type { FederatedIdentity } from "./types/oidc.types";
describe("IdentityResolutionService", () => {
let service: IdentityResolutionService;
let identityLinkingService: IdentityLinkingService;
const mockIdentity: FederatedIdentity = {
id: "identity-id",
localUserId: "local-user-id",
remoteUserId: "remote-user-id",
remoteInstanceId: "remote-instance-id",
oidcSubject: "oidc-subject",
email: "user@example.com",
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const mockIdentityLinkingService = {
resolveLocalIdentity: vi.fn(),
resolveRemoteIdentity: vi.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
IdentityResolutionService,
{ provide: IdentityLinkingService, useValue: mockIdentityLinkingService },
],
}).compile();
service = module.get<IdentityResolutionService>(IdentityResolutionService);
identityLinkingService = module.get(IdentityLinkingService);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("resolveIdentity", () => {
it("should resolve remote identity to local user", async () => {
identityLinkingService.resolveLocalIdentity.mockResolvedValue(mockIdentity);
const result = await service.resolveIdentity("remote-instance-id", "remote-user-id");
expect(result.found).toBe(true);
expect(result.localUserId).toBe("local-user-id");
expect(result.remoteUserId).toBe("remote-user-id");
expect(result.email).toBe("user@example.com");
expect(identityLinkingService.resolveLocalIdentity).toHaveBeenCalledWith(
"remote-instance-id",
"remote-user-id"
);
});
it("should return not found when mapping does not exist", async () => {
identityLinkingService.resolveLocalIdentity.mockResolvedValue(null);
const result = await service.resolveIdentity("remote-instance-id", "unknown-user-id");
expect(result.found).toBe(false);
expect(result.localUserId).toBeUndefined();
expect(result.remoteUserId).toBe("unknown-user-id");
});
});
describe("reverseResolveIdentity", () => {
it("should resolve local user to remote identity", async () => {
identityLinkingService.resolveRemoteIdentity.mockResolvedValue(mockIdentity);
const result = await service.reverseResolveIdentity("local-user-id", "remote-instance-id");
expect(result.found).toBe(true);
expect(result.remoteUserId).toBe("remote-user-id");
expect(result.localUserId).toBe("local-user-id");
expect(identityLinkingService.resolveRemoteIdentity).toHaveBeenCalledWith(
"local-user-id",
"remote-instance-id"
);
});
it("should return not found when mapping does not exist", async () => {
identityLinkingService.resolveRemoteIdentity.mockResolvedValue(null);
const result = await service.reverseResolveIdentity("unknown-user-id", "remote-instance-id");
expect(result.found).toBe(false);
expect(result.remoteUserId).toBeUndefined();
expect(result.localUserId).toBe("unknown-user-id");
});
});
describe("bulkResolveIdentities", () => {
it("should resolve multiple remote users to local users", async () => {
const mockIdentity2: FederatedIdentity = {
...mockIdentity,
id: "identity-id-2",
localUserId: "local-user-id-2",
remoteUserId: "remote-user-id-2",
};
identityLinkingService.resolveLocalIdentity
.mockResolvedValueOnce(mockIdentity)
.mockResolvedValueOnce(mockIdentity2)
.mockResolvedValueOnce(null);
const result = await service.bulkResolveIdentities("remote-instance-id", [
"remote-user-id",
"remote-user-id-2",
"unknown-user-id",
]);
expect(result.mappings["remote-user-id"]).toBe("local-user-id");
expect(result.mappings["remote-user-id-2"]).toBe("local-user-id-2");
expect(result.notFound).toEqual(["unknown-user-id"]);
expect(identityLinkingService.resolveLocalIdentity).toHaveBeenCalledTimes(3);
});
it("should handle empty array", async () => {
const result = await service.bulkResolveIdentities("remote-instance-id", []);
expect(result.mappings).toEqual({});
expect(result.notFound).toEqual([]);
expect(identityLinkingService.resolveLocalIdentity).not.toHaveBeenCalled();
});
it("should handle all not found", async () => {
identityLinkingService.resolveLocalIdentity
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null);
const result = await service.bulkResolveIdentities("remote-instance-id", [
"unknown-1",
"unknown-2",
]);
expect(result.mappings).toEqual({});
expect(result.notFound).toEqual(["unknown-1", "unknown-2"]);
});
});
});

View File

@@ -0,0 +1,137 @@
/**
* Identity Resolution Service
*
* Handles identity resolution (lookup) between local and remote instances.
* Optimized for read-heavy operations.
*/
import { Injectable, Logger } from "@nestjs/common";
import { IdentityLinkingService } from "./identity-linking.service";
import type {
IdentityResolutionResponse,
BulkIdentityResolutionResponse,
} from "./types/identity-linking.types";
@Injectable()
export class IdentityResolutionService {
private readonly logger = new Logger(IdentityResolutionService.name);
constructor(private readonly identityLinkingService: IdentityLinkingService) {}
/**
* Resolve a remote user to a local user
*
* Looks up the identity mapping by remote instance and user ID.
*/
async resolveIdentity(
remoteInstanceId: string,
remoteUserId: string
): Promise<IdentityResolutionResponse> {
this.logger.debug(`Resolving identity: ${remoteUserId}@${remoteInstanceId}`);
const identity = await this.identityLinkingService.resolveLocalIdentity(
remoteInstanceId,
remoteUserId
);
if (!identity) {
return {
found: false,
remoteUserId,
remoteInstanceId,
};
}
return {
found: true,
localUserId: identity.localUserId,
remoteUserId: identity.remoteUserId,
remoteInstanceId: identity.remoteInstanceId,
email: identity.email,
metadata: identity.metadata,
};
}
/**
* Reverse resolve a local user to a remote identity
*
* Looks up the identity mapping by local user ID and remote instance.
*/
async reverseResolveIdentity(
localUserId: string,
remoteInstanceId: string
): Promise<IdentityResolutionResponse> {
this.logger.debug(`Reverse resolving identity: ${localUserId}@${remoteInstanceId}`);
const identity = await this.identityLinkingService.resolveRemoteIdentity(
localUserId,
remoteInstanceId
);
if (!identity) {
return {
found: false,
localUserId,
remoteInstanceId,
};
}
return {
found: true,
localUserId: identity.localUserId,
remoteUserId: identity.remoteUserId,
remoteInstanceId: identity.remoteInstanceId,
email: identity.email,
metadata: identity.metadata,
};
}
/**
* Bulk resolve multiple remote users to local users
*
* Efficient batch operation for resolving many identities at once.
* Useful for aggregated dashboard views and multi-user operations.
*/
async bulkResolveIdentities(
remoteInstanceId: string,
remoteUserIds: string[]
): Promise<BulkIdentityResolutionResponse> {
this.logger.debug(
`Bulk resolving ${remoteUserIds.length.toString()} identities for ${remoteInstanceId}`
);
if (remoteUserIds.length === 0) {
return {
mappings: {},
notFound: [],
};
}
const mappings: Record<string, string> = {};
const notFound: string[] = [];
// Resolve each identity
// TODO: Optimize with a single database query using IN clause
for (const remoteUserId of remoteUserIds) {
const identity = await this.identityLinkingService.resolveLocalIdentity(
remoteInstanceId,
remoteUserId
);
if (identity) {
mappings[remoteUserId] = identity.localUserId;
} else {
notFound.push(remoteUserId);
}
}
this.logger.debug(
`Bulk resolution complete: ${Object.keys(mappings).length.toString()} found, ${notFound.length.toString()} not found`
);
return {
mappings,
notFound,
};
}
}

View File

@@ -5,6 +5,15 @@
export * from "./federation.module";
export * from "./federation.service";
export * from "./federation.controller";
export * from "./identity-linking.service";
export * from "./identity-resolution.service";
export * from "./identity-linking.controller";
export * from "./crypto.service";
export * from "./audit.service";
export * from "./query.service";
export * from "./query.controller";
export * from "./command.service";
export * from "./command.controller";
export * from "./types/instance.types";
export * from "./types/identity-linking.types";
export * from "./types/message.types";

View File

@@ -14,6 +14,28 @@ import type {
FederatedTokenValidation,
OIDCTokenClaims,
} from "./types/oidc.types";
import * as jose from "jose";
/**
* Helper function to create test JWTs for testing
*/
async function createTestJWT(
claims: OIDCTokenClaims,
secret: string = "test-secret-key-for-jwt-signing"
): Promise<string> {
const secretKey = new TextEncoder().encode(secret);
const jwt = await new jose.SignJWT(claims as Record<string, unknown>)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(claims.iat)
.setExpirationTime(claims.exp)
.setSubject(claims.sub)
.setIssuer(claims.iss)
.setAudience(claims.aud)
.sign(secretKey);
return jwt;
}
describe("OIDCService", () => {
let service: OIDCService;
@@ -288,90 +310,137 @@ describe("OIDCService", () => {
});
});
describe("validateToken", () => {
it("should validate a valid OIDC token", () => {
const token = "valid-oidc-token";
describe("validateToken - Real JWT Validation", () => {
it("should reject malformed token (not a JWT)", async () => {
const token = "not-a-jwt-token";
const instanceId = "remote-instance-123";
// Mock token validation (simplified - real implementation would decode JWT)
const mockClaims: OIDCTokenClaims = {
sub: "user-subject-123",
const result = await service.validateToken(token, instanceId);
expect(result.valid).toBe(false);
expect(result.error).toContain("Malformed token");
});
it("should reject token with invalid format (missing parts)", async () => {
const token = "header.payload"; // Missing signature
const instanceId = "remote-instance-123";
const result = await service.validateToken(token, instanceId);
expect(result.valid).toBe(false);
expect(result.error).toContain("Malformed token");
});
it("should reject expired token", async () => {
// Create an expired JWT (exp in the past)
const expiredToken = await createTestJWT({
sub: "user-123",
iss: "https://auth.example.com",
aud: "mosaic-client-id",
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
iat: Math.floor(Date.now() / 1000) - 7200,
email: "user@example.com",
});
const result = await service.validateToken(expiredToken, "remote-instance-123");
expect(result.valid).toBe(false);
expect(result.error).toContain("expired");
});
it("should reject token with invalid signature", async () => {
// Create a JWT with a different key than what the service will validate
const invalidToken = await createTestJWT(
{
sub: "user-123",
iss: "https://auth.example.com",
aud: "mosaic-client-id",
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
email: "user@example.com",
},
"wrong-secret-key"
);
const result = await service.validateToken(invalidToken, "remote-instance-123");
expect(result.valid).toBe(false);
expect(result.error).toContain("signature");
});
it("should reject token with wrong issuer", async () => {
const token = await createTestJWT({
sub: "user-123",
iss: "https://wrong-issuer.com", // Wrong issuer
aud: "mosaic-client-id",
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
email: "user@example.com",
});
const result = await service.validateToken(token, "remote-instance-123");
expect(result.valid).toBe(false);
expect(result.error).toContain("issuer");
});
it("should reject token with wrong audience", async () => {
const token = await createTestJWT({
sub: "user-123",
iss: "https://auth.example.com",
aud: "wrong-audience", // Wrong audience
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
email: "user@example.com",
});
const result = await service.validateToken(token, "remote-instance-123");
expect(result.valid).toBe(false);
expect(result.error).toContain("audience");
});
it("should validate a valid JWT token with correct signature and claims", async () => {
const validToken = await createTestJWT({
sub: "user-123",
iss: "https://auth.example.com",
aud: "mosaic-client-id",
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
email: "user@example.com",
email_verified: true,
};
name: "Test User",
});
const expectedResult: FederatedTokenValidation = {
valid: true,
userId: "user-subject-123",
instanceId,
email: "user@example.com",
subject: "user-subject-123",
};
// For now, we'll mock the validation
// Real implementation would use jose or jsonwebtoken to decode and verify
vi.spyOn(service, "validateToken").mockReturnValue(expectedResult);
const result = service.validateToken(token, instanceId);
const result = await service.validateToken(validToken, "remote-instance-123");
expect(result.valid).toBe(true);
expect(result.userId).toBe("user-subject-123");
expect(result.userId).toBe("user-123");
expect(result.subject).toBe("user-123");
expect(result.email).toBe("user@example.com");
expect(result.instanceId).toBe("remote-instance-123");
expect(result.error).toBeUndefined();
});
it("should reject expired token", () => {
const token = "expired-token";
const instanceId = "remote-instance-123";
it("should extract all user info from valid token", async () => {
const validToken = await createTestJWT({
sub: "user-456",
iss: "https://auth.example.com",
aud: "mosaic-client-id",
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
email: "test@example.com",
email_verified: true,
name: "Test User",
preferred_username: "testuser",
});
const expectedResult: FederatedTokenValidation = {
valid: false,
error: "Token has expired",
};
const result = await service.validateToken(validToken, "remote-instance-123");
vi.spyOn(service, "validateToken").mockReturnValue(expectedResult);
const result = service.validateToken(token, instanceId);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it("should reject token with invalid signature", () => {
const token = "invalid-signature-token";
const instanceId = "remote-instance-123";
const expectedResult: FederatedTokenValidation = {
valid: false,
error: "Invalid token signature",
};
vi.spyOn(service, "validateToken").mockReturnValue(expectedResult);
const result = service.validateToken(token, instanceId);
expect(result.valid).toBe(false);
expect(result.error).toBe("Invalid token signature");
});
it("should reject malformed token", () => {
const token = "not-a-jwt";
const instanceId = "remote-instance-123";
const expectedResult: FederatedTokenValidation = {
valid: false,
error: "Malformed token",
};
vi.spyOn(service, "validateToken").mockReturnValue(expectedResult);
const result = service.validateToken(token, instanceId);
expect(result.valid).toBe(false);
expect(result.error).toBe("Malformed token");
expect(result.valid).toBe(true);
expect(result.userId).toBe("user-456");
expect(result.email).toBe("test@example.com");
expect(result.subject).toBe("user-456");
});
});

View File

@@ -9,6 +9,7 @@ import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../prisma/prisma.service";
import type { FederatedIdentity, FederatedTokenValidation } from "./types/oidc.types";
import type { Prisma } from "@prisma/client";
import * as jose from "jose";
@Injectable()
export class OIDCService {
@@ -100,34 +101,112 @@ export class OIDCService {
/**
* Validate an OIDC token from a federated instance
*
* NOTE: This is a simplified implementation for the initial version.
* In production, this should:
* Verifies JWT signature and validates all standard claims.
*
* Current implementation uses a test secret for validation.
* Production implementation should:
* 1. Fetch OIDC discovery metadata from the issuer
* 2. Retrieve and cache JWKS (JSON Web Key Set)
* 3. Verify JWT signature using the public key
* 4. Validate claims (iss, aud, exp, etc.)
* 5. Handle token refresh if needed
*
* For now, we provide the interface and basic structure.
* Full JWT validation will be implemented when needed.
* 3. Verify JWT signature using the public key from JWKS
* 4. Handle key rotation and JWKS refresh
*/
validateToken(_token: string, _instanceId: string): FederatedTokenValidation {
async validateToken(token: string, instanceId: string): Promise<FederatedTokenValidation> {
try {
// TODO: Implement full JWT validation
// For now, this is a placeholder that should be implemented
// when federation OIDC is actively used
// Validate token format
if (!token || typeof token !== "string") {
return {
valid: false,
error: "Malformed token: token must be a non-empty string",
};
}
this.logger.warn("Token validation not fully implemented - returning mock validation");
// Check if token looks like a JWT (three parts separated by dots)
const parts = token.split(".");
if (parts.length !== 3) {
return {
valid: false,
error: "Malformed token: JWT must have three parts (header.payload.signature)",
};
}
// This is a placeholder response
// Real implementation would decode and verify the JWT
return {
valid: false,
error: "Token validation not yet implemented",
// Get validation secret from config (for testing/development)
// In production, this should fetch JWKS from the remote instance
const secret =
this.config.get<string>("OIDC_VALIDATION_SECRET") ?? "test-secret-key-for-jwt-signing";
const secretKey = new TextEncoder().encode(secret);
// Verify and decode JWT
const { payload } = await jose.jwtVerify(token, secretKey, {
issuer: "https://auth.example.com", // TODO: Fetch from remote instance config
audience: "mosaic-client-id", // TODO: Get from config
});
// Extract claims
const sub = payload.sub;
const email = payload.email as string | undefined;
if (!sub) {
return {
valid: false,
error: "Token missing required 'sub' claim",
};
}
// Return validation result
const result: FederatedTokenValidation = {
valid: true,
userId: sub,
subject: sub,
instanceId,
};
// Only include email if present (exactOptionalPropertyTypes compliance)
if (email) {
result.email = email;
}
return result;
} catch (error) {
// Handle specific JWT errors
if (error instanceof jose.errors.JWTExpired) {
return {
valid: false,
error: "Token has expired",
};
}
if (error instanceof jose.errors.JWTClaimValidationFailed) {
const claimError = error.message;
// Check specific claim failures
if (claimError.includes("iss") || claimError.includes("issuer")) {
return {
valid: false,
error: "Invalid token issuer",
};
}
if (claimError.includes("aud") || claimError.includes("audience")) {
return {
valid: false,
error: "Invalid token audience",
};
}
return {
valid: false,
error: `Claim validation failed: ${claimError}`,
};
}
if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
return {
valid: false,
error: "Invalid token signature",
};
}
// Generic error handling
this.logger.error(
`Token validation error: ${error instanceof Error ? error.message : "Unknown error"}`
`Token validation error: ${error instanceof Error ? error.message : "Unknown error"}`,
error instanceof Error ? error.stack : undefined
);
return {

View File

@@ -0,0 +1,238 @@
/**
* Query Controller Tests
*
* Tests for federated query API endpoints.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { FederationMessageType, FederationMessageStatus } from "@prisma/client";
import { QueryController } from "./query.controller";
import { QueryService } from "./query.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { AuthenticatedRequest } from "../common/types/user.types";
import type { SendQueryDto, IncomingQueryDto } from "./dto/query.dto";
describe("QueryController", () => {
let controller: QueryController;
let queryService: QueryService;
const mockQueryService = {
sendQuery: vi.fn(),
handleIncomingQuery: vi.fn(),
getQueryMessages: vi.fn(),
getQueryMessage: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [QueryController],
providers: [{ provide: QueryService, useValue: mockQueryService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<QueryController>(QueryController);
queryService = module.get<QueryService>(QueryService);
vi.clearAllMocks();
});
describe("sendQuery", () => {
it("should send query to remote instance", async () => {
const req = {
user: {
id: "user-1",
workspaceId: "workspace-1",
},
} as AuthenticatedRequest;
const dto: SendQueryDto = {
connectionId: "connection-1",
query: "SELECT * FROM tasks",
context: { userId: "user-1" },
};
const mockResult = {
id: "msg-1",
workspaceId: "workspace-1",
connectionId: "connection-1",
messageType: FederationMessageType.QUERY,
messageId: "unique-msg-1",
query: dto.query,
status: FederationMessageStatus.PENDING,
createdAt: new Date(),
updatedAt: new Date(),
};
mockQueryService.sendQuery.mockResolvedValue(mockResult);
const result = await controller.sendQuery(req, dto);
expect(result).toBeDefined();
expect(result.messageType).toBe(FederationMessageType.QUERY);
expect(mockQueryService.sendQuery).toHaveBeenCalledWith(
"workspace-1",
dto.connectionId,
dto.query,
dto.context
);
});
it("should throw error if user not authenticated", async () => {
const req = {} as AuthenticatedRequest;
const dto: SendQueryDto = {
connectionId: "connection-1",
query: "SELECT * FROM tasks",
};
await expect(controller.sendQuery(req, dto)).rejects.toThrow(
"Workspace ID not found in request"
);
});
});
describe("handleIncomingQuery", () => {
it("should process incoming query", async () => {
const dto: IncomingQueryDto = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
timestamp: Date.now(),
signature: "valid-signature",
};
const mockResponse = {
messageId: "response-1",
correlationId: dto.messageId,
instanceId: "local-instance-1",
success: true,
data: { tasks: [] },
timestamp: Date.now(),
signature: "response-signature",
};
mockQueryService.handleIncomingQuery.mockResolvedValue(mockResponse);
const result = await controller.handleIncomingQuery(dto);
expect(result).toBeDefined();
expect(result.correlationId).toBe(dto.messageId);
expect(mockQueryService.handleIncomingQuery).toHaveBeenCalledWith(dto);
});
it("should return error response for invalid query", async () => {
const dto: IncomingQueryDto = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
timestamp: Date.now(),
signature: "invalid-signature",
};
mockQueryService.handleIncomingQuery.mockRejectedValue(new Error("Invalid signature"));
await expect(controller.handleIncomingQuery(dto)).rejects.toThrow("Invalid signature");
});
});
describe("getQueries", () => {
it("should return query messages for workspace", async () => {
const req = {
user: {
id: "user-1",
workspaceId: "workspace-1",
},
} as AuthenticatedRequest;
const mockMessages = [
{
id: "msg-1",
workspaceId: "workspace-1",
connectionId: "connection-1",
messageType: FederationMessageType.QUERY,
messageId: "unique-msg-1",
query: "SELECT * FROM tasks",
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockQueryService.getQueryMessages.mockResolvedValue(mockMessages);
const result = await controller.getQueries(req, undefined);
expect(result).toHaveLength(1);
expect(mockQueryService.getQueryMessages).toHaveBeenCalledWith("workspace-1", undefined);
});
it("should filter by status when provided", async () => {
const req = {
user: {
id: "user-1",
workspaceId: "workspace-1",
},
} as AuthenticatedRequest;
const status = FederationMessageStatus.PENDING;
mockQueryService.getQueryMessages.mockResolvedValue([]);
await controller.getQueries(req, status);
expect(mockQueryService.getQueryMessages).toHaveBeenCalledWith("workspace-1", status);
});
it("should throw error if user not authenticated", async () => {
const req = {} as AuthenticatedRequest;
await expect(controller.getQueries(req, undefined)).rejects.toThrow(
"Workspace ID not found in request"
);
});
});
describe("getQuery", () => {
it("should return query message by ID", async () => {
const req = {
user: {
id: "user-1",
workspaceId: "workspace-1",
},
} as AuthenticatedRequest;
const messageId = "msg-1";
const mockMessage = {
id: messageId,
workspaceId: "workspace-1",
connectionId: "connection-1",
messageType: FederationMessageType.QUERY,
messageId: "unique-msg-1",
query: "SELECT * FROM tasks",
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
};
mockQueryService.getQueryMessage.mockResolvedValue(mockMessage);
const result = await controller.getQuery(req, messageId);
expect(result).toBeDefined();
expect(result.id).toBe(messageId);
expect(mockQueryService.getQueryMessage).toHaveBeenCalledWith("workspace-1", messageId);
});
it("should throw error if user not authenticated", async () => {
const req = {} as AuthenticatedRequest;
await expect(controller.getQuery(req, "msg-1")).rejects.toThrow(
"Workspace ID not found in request"
);
});
});
});

View File

@@ -0,0 +1,91 @@
/**
* Query Controller
*
* API endpoints for federated query messages.
*/
import { Controller, Post, Get, Body, Param, Query, UseGuards, Req, Logger } from "@nestjs/common";
import { QueryService } from "./query.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { SendQueryDto, IncomingQueryDto } from "./dto/query.dto";
import type { AuthenticatedRequest } from "../common/types/user.types";
import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
import type { FederationMessageStatus } from "@prisma/client";
@Controller("api/v1/federation")
export class QueryController {
private readonly logger = new Logger(QueryController.name);
constructor(private readonly queryService: QueryService) {}
/**
* Send a query to a remote instance
* Requires authentication
*/
@Post("query")
@UseGuards(AuthGuard)
async sendQuery(
@Req() req: AuthenticatedRequest,
@Body() dto: SendQueryDto
): Promise<QueryMessageDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(
`User ${req.user.id} sending query to connection ${dto.connectionId} in workspace ${req.user.workspaceId}`
);
return this.queryService.sendQuery(
req.user.workspaceId,
dto.connectionId,
dto.query,
dto.context
);
}
/**
* Handle incoming query from remote instance
* Public endpoint - no authentication required (signature-based verification)
*/
@Post("incoming/query")
async handleIncomingQuery(@Body() dto: IncomingQueryDto): Promise<QueryResponse> {
this.logger.log(`Received query from ${dto.instanceId}: ${dto.messageId}`);
return this.queryService.handleIncomingQuery(dto);
}
/**
* Get all query messages for the workspace
* Requires authentication
*/
@Get("queries")
@UseGuards(AuthGuard)
async getQueries(
@Req() req: AuthenticatedRequest,
@Query("status") status?: FederationMessageStatus
): Promise<QueryMessageDetails[]> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
return this.queryService.getQueryMessages(req.user.workspaceId, status);
}
/**
* Get a single query message
* Requires authentication
*/
@Get("queries/:id")
@UseGuards(AuthGuard)
async getQuery(
@Req() req: AuthenticatedRequest,
@Param("id") messageId: string
): Promise<QueryMessageDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
return this.queryService.getQueryMessage(req.user.workspaceId, messageId);
}
}

View File

@@ -0,0 +1,493 @@
/**
* Query Service Tests
*
* Tests for federated query message handling.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ConfigService } from "@nestjs/config";
import {
FederationConnectionStatus,
FederationMessageType,
FederationMessageStatus,
} from "@prisma/client";
import { QueryService } from "./query.service";
import { PrismaService } from "../prisma/prisma.service";
import { FederationService } from "./federation.service";
import { SignatureService } from "./signature.service";
import { HttpService } from "@nestjs/axios";
import { of, throwError } from "rxjs";
import type { AxiosResponse } from "axios";
describe("QueryService", () => {
let service: QueryService;
let prisma: PrismaService;
let federationService: FederationService;
let signatureService: SignatureService;
let httpService: HttpService;
const mockPrisma = {
federationConnection: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
federationMessage: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
};
const mockFederationService = {
getInstanceIdentity: vi.fn(),
getPublicIdentity: vi.fn(),
};
const mockSignatureService = {
signMessage: vi.fn(),
verifyMessage: vi.fn(),
validateTimestamp: vi.fn(),
};
const mockHttpService = {
post: vi.fn(),
};
const mockConfig = {
get: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
QueryService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: FederationService, useValue: mockFederationService },
{ provide: SignatureService, useValue: mockSignatureService },
{ provide: HttpService, useValue: mockHttpService },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
service = module.get<QueryService>(QueryService);
prisma = module.get<PrismaService>(PrismaService);
federationService = module.get<FederationService>(FederationService);
signatureService = module.get<SignatureService>(SignatureService);
httpService = module.get<HttpService>(HttpService);
vi.clearAllMocks();
});
describe("sendQuery", () => {
it("should send query to remote instance with signed message", async () => {
const workspaceId = "workspace-1";
const connectionId = "connection-1";
const query = "SELECT * FROM tasks";
const context = { userId: "user-1" };
const mockConnection = {
id: connectionId,
workspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
remotePublicKey: "mock-public-key",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
id: "identity-1",
instanceId: "local-instance-1",
name: "Local Instance",
url: "https://local.example.com",
publicKey: "local-public-key",
privateKey: "local-private-key",
};
const mockMessage = {
id: "message-1",
workspaceId,
connectionId,
messageType: FederationMessageType.QUERY,
messageId: expect.any(String),
correlationId: null,
query,
response: null,
status: FederationMessageStatus.PENDING,
error: null,
signature: "mock-signature",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
};
const mockResponse: AxiosResponse = {
data: { success: true },
status: 200,
statusText: "OK",
headers: {},
config: { headers: {} as never },
};
mockPrisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("mock-signature");
mockPrisma.federationMessage.create.mockResolvedValue(mockMessage);
mockHttpService.post.mockReturnValue(of(mockResponse));
const result = await service.sendQuery(workspaceId, connectionId, query, context);
expect(result).toBeDefined();
expect(result.messageType).toBe(FederationMessageType.QUERY);
expect(result.query).toBe(query);
expect(mockPrisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: { id: connectionId, workspaceId },
});
expect(mockPrisma.federationMessage.create).toHaveBeenCalled();
expect(mockHttpService.post).toHaveBeenCalledWith(
`${mockConnection.remoteUrl}/api/v1/federation/incoming/query`,
expect.objectContaining({
messageId: expect.any(String),
instanceId: mockIdentity.instanceId,
query,
context,
timestamp: expect.any(Number),
signature: "mock-signature",
})
);
});
it("should throw error if connection not found", async () => {
mockPrisma.federationConnection.findUnique.mockResolvedValue(null);
await expect(
service.sendQuery("workspace-1", "connection-1", "SELECT * FROM tasks")
).rejects.toThrow("Connection not found");
});
it("should throw error if connection not active", async () => {
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
status: FederationConnectionStatus.PENDING,
};
mockPrisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
await expect(
service.sendQuery("workspace-1", "connection-1", "SELECT * FROM tasks")
).rejects.toThrow("Connection is not active");
});
it("should handle network errors gracefully", async () => {
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("mock-signature");
mockPrisma.federationMessage.create.mockResolvedValue({
id: "message-1",
messageId: "msg-1",
});
mockHttpService.post.mockReturnValue(throwError(() => new Error("Network error")));
await expect(
service.sendQuery("workspace-1", "connection-1", "SELECT * FROM tasks")
).rejects.toThrow("Failed to send query");
});
});
describe("handleIncomingQuery", () => {
it("should process valid incoming query", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
context: {},
timestamp: Date.now(),
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("response-signature");
const result = await service.handleIncomingQuery(queryMessage);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
expect(result.correlationId).toBe(queryMessage.messageId);
expect(result.instanceId).toBe(mockIdentity.instanceId);
expect(result.signature).toBe("response-signature");
});
it("should reject query with invalid signature", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
timestamp: Date.now(),
signature: "invalid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({
valid: false,
error: "Invalid signature",
});
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow("Invalid signature");
});
it("should reject query with expired timestamp", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
timestamp: Date.now() - 10 * 60 * 1000, // 10 minutes ago
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(false);
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow(
"Query timestamp is outside acceptable range"
);
});
it("should reject query from inactive connection", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
timestamp: Date.now(),
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.PENDING,
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow(
"Connection is not active"
);
});
it("should reject query from unknown instance", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "unknown-instance",
query: "SELECT * FROM tasks",
timestamp: Date.now(),
signature: "valid-signature",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(null);
mockSignatureService.validateTimestamp.mockReturnValue(true);
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow(
"No connection found for remote instance"
);
});
});
describe("getQueryMessages", () => {
it("should return query messages for workspace", async () => {
const workspaceId = "workspace-1";
const mockMessages = [
{
id: "msg-1",
workspaceId,
connectionId: "connection-1",
messageType: FederationMessageType.QUERY,
messageId: "unique-msg-1",
query: "SELECT * FROM tasks",
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockPrisma.federationMessage.findMany.mockResolvedValue(mockMessages);
const result = await service.getQueryMessages(workspaceId);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("msg-1");
expect(mockPrisma.federationMessage.findMany).toHaveBeenCalledWith({
where: {
workspaceId,
messageType: FederationMessageType.QUERY,
},
orderBy: { createdAt: "desc" },
});
});
it("should filter by status when provided", async () => {
const workspaceId = "workspace-1";
const status = FederationMessageStatus.PENDING;
mockPrisma.federationMessage.findMany.mockResolvedValue([]);
await service.getQueryMessages(workspaceId, status);
expect(mockPrisma.federationMessage.findMany).toHaveBeenCalledWith({
where: {
workspaceId,
messageType: FederationMessageType.QUERY,
status,
},
orderBy: { createdAt: "desc" },
});
});
});
describe("getQueryMessage", () => {
it("should return query message by ID", async () => {
const workspaceId = "workspace-1";
const messageId = "msg-1";
const mockMessage = {
id: "msg-1",
workspaceId,
messageType: FederationMessageType.QUERY,
messageId: "unique-msg-1",
query: "SELECT * FROM tasks",
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
};
mockPrisma.federationMessage.findUnique.mockResolvedValue(mockMessage);
const result = await service.getQueryMessage(workspaceId, messageId);
expect(result).toBeDefined();
expect(result.id).toBe(messageId);
expect(mockPrisma.federationMessage.findUnique).toHaveBeenCalledWith({
where: { id: messageId, workspaceId },
});
});
it("should throw error if message not found", async () => {
mockPrisma.federationMessage.findUnique.mockResolvedValue(null);
await expect(service.getQueryMessage("workspace-1", "msg-1")).rejects.toThrow(
"Query message not found"
);
});
});
describe("processQueryResponse", () => {
it("should update message with response", async () => {
const response = {
messageId: "response-1",
correlationId: "original-msg-1",
instanceId: "remote-instance-1",
success: true,
data: { tasks: [] },
timestamp: Date.now(),
signature: "valid-signature",
};
const mockMessage = {
id: "msg-1",
messageId: "original-msg-1",
workspaceId: "workspace-1",
status: FederationMessageStatus.PENDING,
};
mockPrisma.federationMessage.findFirst.mockResolvedValue(mockMessage);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockPrisma.federationMessage.update.mockResolvedValue({
...mockMessage,
status: FederationMessageStatus.DELIVERED,
response: response.data,
});
await service.processQueryResponse(response);
expect(mockPrisma.federationMessage.update).toHaveBeenCalledWith({
where: { id: mockMessage.id },
data: {
status: FederationMessageStatus.DELIVERED,
response: response.data,
deliveredAt: expect.any(Date),
},
});
});
it("should reject response with invalid signature", async () => {
const response = {
messageId: "response-1",
correlationId: "original-msg-1",
instanceId: "remote-instance-1",
success: true,
timestamp: Date.now(),
signature: "invalid-signature",
};
const mockMessage = {
id: "msg-1",
messageId: "original-msg-1",
workspaceId: "workspace-1",
};
mockPrisma.federationMessage.findFirst.mockResolvedValue(mockMessage);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({
valid: false,
error: "Invalid signature",
});
await expect(service.processQueryResponse(response)).rejects.toThrow("Invalid signature");
});
});
});

View File

@@ -0,0 +1,360 @@
/**
* Query Service
*
* Handles federated query messages.
*/
import { Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { randomUUID } from "crypto";
import { firstValueFrom } from "rxjs";
import { PrismaService } from "../prisma/prisma.service";
import { FederationService } from "./federation.service";
import { SignatureService } from "./signature.service";
import {
FederationConnectionStatus,
FederationMessageType,
FederationMessageStatus,
} from "@prisma/client";
import type { QueryMessage, QueryResponse, QueryMessageDetails } from "./types/message.types";
@Injectable()
export class QueryService {
private readonly logger = new Logger(QueryService.name);
constructor(
private readonly prisma: PrismaService,
private readonly federationService: FederationService,
private readonly signatureService: SignatureService,
private readonly httpService: HttpService
) {}
/**
* Send a query to a remote instance
*/
async sendQuery(
workspaceId: string,
connectionId: string,
query: string,
context?: Record<string, unknown>
): Promise<QueryMessageDetails> {
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Get local instance identity
const identity = await this.federationService.getInstanceIdentity();
// Create query message
const messageId = randomUUID();
const timestamp = Date.now();
const queryPayload: Record<string, unknown> = {
messageId,
instanceId: identity.instanceId,
query,
timestamp,
};
if (context) {
queryPayload.context = context;
}
// Sign the query
const signature = await this.signatureService.signMessage(queryPayload);
const signedQuery = {
messageId,
instanceId: identity.instanceId,
query,
...(context ? { context } : {}),
timestamp,
signature,
} as QueryMessage;
// Store message in database
const message = await this.prisma.federationMessage.create({
data: {
workspaceId,
connectionId,
messageType: FederationMessageType.QUERY,
messageId,
query,
status: FederationMessageStatus.PENDING,
signature,
},
});
// Send query to remote instance
try {
const remoteUrl = `${connection.remoteUrl}/api/v1/federation/incoming/query`;
await firstValueFrom(this.httpService.post(remoteUrl, signedQuery));
this.logger.log(`Query sent to ${connection.remoteUrl}: ${messageId}`);
} catch (error) {
this.logger.error(`Failed to send query to ${connection.remoteUrl}`, error);
// Update message status to failed
await this.prisma.federationMessage.update({
where: { id: message.id },
data: {
status: FederationMessageStatus.FAILED,
error: error instanceof Error ? error.message : "Unknown error",
},
});
throw new Error("Failed to send query");
}
return this.mapToQueryMessageDetails(message);
}
/**
* Handle incoming query from remote instance
*/
async handleIncomingQuery(queryMessage: QueryMessage): Promise<QueryResponse> {
this.logger.log(`Received query from ${queryMessage.instanceId}: ${queryMessage.messageId}`);
// Validate timestamp
if (!this.signatureService.validateTimestamp(queryMessage.timestamp)) {
throw new Error("Query timestamp is outside acceptable range");
}
// Find connection for remote instance
const connection = await this.prisma.federationConnection.findFirst({
where: {
remoteInstanceId: queryMessage.instanceId,
status: FederationConnectionStatus.ACTIVE,
},
});
if (!connection) {
throw new Error("No connection found for remote instance");
}
// Validate connection is active
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Verify signature
const { signature, ...messageToVerify } = queryMessage;
const verificationResult = await this.signatureService.verifyMessage(
messageToVerify,
signature,
queryMessage.instanceId
);
if (!verificationResult.valid) {
throw new Error(verificationResult.error ?? "Invalid signature");
}
// Process query (placeholder - would delegate to actual query processor)
let responseData: unknown;
let success = true;
let errorMessage: string | undefined;
try {
// TODO: Implement actual query processing
// For now, return a placeholder response
responseData = { message: "Query received and processed" };
} catch (error) {
success = false;
errorMessage = error instanceof Error ? error.message : "Query processing failed";
this.logger.error(`Query processing failed: ${errorMessage}`);
}
// Get local instance identity
const identity = await this.federationService.getInstanceIdentity();
// Create response
const responseMessageId = randomUUID();
const responseTimestamp = Date.now();
const responsePayload: Record<string, unknown> = {
messageId: responseMessageId,
correlationId: queryMessage.messageId,
instanceId: identity.instanceId,
success,
timestamp: responseTimestamp,
};
if (responseData !== undefined) {
responsePayload.data = responseData;
}
if (errorMessage !== undefined) {
responsePayload.error = errorMessage;
}
// Sign the response
const responseSignature = await this.signatureService.signMessage(responsePayload);
const response = {
messageId: responseMessageId,
correlationId: queryMessage.messageId,
instanceId: identity.instanceId,
success,
...(responseData !== undefined ? { data: responseData } : {}),
...(errorMessage !== undefined ? { error: errorMessage } : {}),
timestamp: responseTimestamp,
signature: responseSignature,
} as QueryResponse;
return response;
}
/**
* Get all query messages for a workspace
*/
async getQueryMessages(
workspaceId: string,
status?: FederationMessageStatus
): Promise<QueryMessageDetails[]> {
const where: Record<string, unknown> = {
workspaceId,
messageType: FederationMessageType.QUERY,
};
if (status) {
where.status = status;
}
const messages = await this.prisma.federationMessage.findMany({
where,
orderBy: { createdAt: "desc" },
});
return messages.map((msg) => this.mapToQueryMessageDetails(msg));
}
/**
* Get a single query message
*/
async getQueryMessage(workspaceId: string, messageId: string): Promise<QueryMessageDetails> {
const message = await this.prisma.federationMessage.findUnique({
where: { id: messageId, workspaceId },
});
if (!message) {
throw new Error("Query message not found");
}
return this.mapToQueryMessageDetails(message);
}
/**
* Process a query response from remote instance
*/
async processQueryResponse(response: QueryResponse): Promise<void> {
this.logger.log(`Received response for query: ${response.correlationId}`);
// Validate timestamp
if (!this.signatureService.validateTimestamp(response.timestamp)) {
throw new Error("Response timestamp is outside acceptable range");
}
// Find original query message
const message = await this.prisma.federationMessage.findFirst({
where: {
messageId: response.correlationId,
messageType: FederationMessageType.QUERY,
},
});
if (!message) {
throw new Error("Original query message not found");
}
// Verify signature
const { signature, ...responseToVerify } = response;
const verificationResult = await this.signatureService.verifyMessage(
responseToVerify,
signature,
response.instanceId
);
if (!verificationResult.valid) {
throw new Error(verificationResult.error ?? "Invalid signature");
}
// Update message with response
const updateData: Record<string, unknown> = {
status: response.success ? FederationMessageStatus.DELIVERED : FederationMessageStatus.FAILED,
deliveredAt: new Date(),
};
if (response.data !== undefined) {
updateData.response = response.data;
}
if (response.error !== undefined) {
updateData.error = response.error;
}
await this.prisma.federationMessage.update({
where: { id: message.id },
data: updateData,
});
this.logger.log(`Query response processed: ${response.correlationId}`);
}
/**
* Map Prisma FederationMessage to QueryMessageDetails
*/
private mapToQueryMessageDetails(message: {
id: string;
workspaceId: string;
connectionId: string;
messageType: FederationMessageType;
messageId: string;
correlationId: string | null;
query: string | null;
response: unknown;
status: FederationMessageStatus;
error: string | null;
createdAt: Date;
updatedAt: Date;
deliveredAt: Date | null;
}): QueryMessageDetails {
const details: QueryMessageDetails = {
id: message.id,
workspaceId: message.workspaceId,
connectionId: message.connectionId,
messageType: message.messageType,
messageId: message.messageId,
response: message.response,
status: message.status,
createdAt: message.createdAt,
updatedAt: message.updatedAt,
};
if (message.correlationId !== null) {
details.correlationId = message.correlationId;
}
if (message.query !== null) {
details.query = message.query;
}
if (message.error !== null) {
details.error = message.error;
}
if (message.deliveredAt !== null) {
details.deliveredAt = message.deliveredAt;
}
return details;
}
}

View File

@@ -116,6 +116,40 @@ export class SignatureService {
return this.sign(message, identity.privateKey);
}
/**
* Verify a message signature using a remote instance's public key
* Fetches the public key from the connection record
*/
async verifyMessage(
message: SignableMessage,
signature: string,
remoteInstanceId: string
): Promise<SignatureValidationResult> {
try {
// Fetch remote instance public key from connection record
// For now, we'll fetch from any connection with this instance
// In production, this should be cached or fetched from instance identity endpoint
const connection =
await this.federationService.getConnectionByRemoteInstanceId(remoteInstanceId);
if (!connection) {
return {
valid: false,
error: "Remote instance not connected",
};
}
// Verify signature using remote public key
return this.verify(message, signature, connection.remotePublicKey);
} catch (error) {
this.logger.error("Failed to verify message", error);
return {
valid: false,
error: error instanceof Error ? error.message : "Verification failed",
};
}
}
/**
* Verify a connection request signature
*/

View File

@@ -0,0 +1,149 @@
/**
* Federation Agent Command Types
*
* Types for agent spawn commands sent via federation COMMAND messages.
*/
/**
* Agent type options for spawning
*/
export type FederationAgentType = "worker" | "reviewer" | "tester";
/**
* Agent status returned from remote instance
*/
export type FederationAgentStatus = "spawning" | "running" | "completed" | "failed" | "killed";
/**
* Context for agent execution
*/
export interface FederationAgentContext {
/** Git repository URL or path */
repository: string;
/** Git branch to work on */
branch: string;
/** Work items for the agent to complete */
workItems: string[];
/** Optional skills to load */
skills?: string[];
/** Optional instructions */
instructions?: string;
}
/**
* Options for spawning an agent
*/
export interface FederationAgentOptions {
/** Enable Docker sandbox isolation */
sandbox?: boolean;
/** Timeout in milliseconds */
timeout?: number;
/** Maximum retry attempts */
maxRetries?: number;
}
/**
* Payload for agent.spawn command
*/
export interface SpawnAgentCommandPayload {
/** Unique task identifier */
taskId: string;
/** Type of agent to spawn */
agentType: FederationAgentType;
/** Context for task execution */
context: FederationAgentContext;
/** Optional configuration */
options?: FederationAgentOptions;
}
/**
* Payload for agent.status command
*/
export interface AgentStatusCommandPayload {
/** Unique agent identifier */
agentId: string;
}
/**
* Payload for agent.kill command
*/
export interface KillAgentCommandPayload {
/** Unique agent identifier */
agentId: string;
}
/**
* Response data for agent.spawn command
*/
export interface SpawnAgentResponseData {
/** Unique agent identifier */
agentId: string;
/** Current agent status */
status: FederationAgentStatus;
/** Timestamp when agent was spawned */
spawnedAt: string;
}
/**
* Response data for agent.status command
*/
export interface AgentStatusResponseData {
/** Unique agent identifier */
agentId: string;
/** Task identifier */
taskId: string;
/** Current agent status */
status: FederationAgentStatus;
/** Timestamp when agent was spawned */
spawnedAt: string;
/** Timestamp when agent started (if running/completed) */
startedAt?: string;
/** Timestamp when agent completed (if completed/failed/killed) */
completedAt?: string;
/** Error message (if failed) */
error?: string;
/** Agent progress data */
progress?: Record<string, unknown>;
}
/**
* Response data for agent.kill command
*/
export interface KillAgentResponseData {
/** Unique agent identifier */
agentId: string;
/** Status after kill operation */
status: FederationAgentStatus;
/** Timestamp when agent was killed */
killedAt: string;
}
/**
* Details about a federated agent
*/
export interface FederatedAgentDetails {
/** Agent ID */
agentId: string;
/** Task ID */
taskId: string;
/** Remote instance ID where agent is running */
remoteInstanceId: string;
/** Connection ID used to spawn the agent */
connectionId: string;
/** Agent type */
agentType: FederationAgentType;
/** Current status */
status: FederationAgentStatus;
/** Spawn timestamp */
spawnedAt: Date;
/** Start timestamp */
startedAt?: Date;
/** Completion timestamp */
completedAt?: Date;
/** Error message if failed */
error?: string;
/** Context used to spawn agent */
context: FederationAgentContext;
/** Options used to spawn agent */
options?: FederationAgentOptions;
}

View File

@@ -0,0 +1,141 @@
/**
* Federation Identity Linking Types
*
* Types for cross-instance user identity verification and resolution.
*/
/**
* Request to verify a user's identity from a remote instance
*/
export interface IdentityVerificationRequest {
/** Local user ID on this instance */
localUserId: string;
/** Remote user ID on the originating instance */
remoteUserId: string;
/** Remote instance federation ID */
remoteInstanceId: string;
/** OIDC token for authentication */
oidcToken: string;
/** Request timestamp (Unix milliseconds) */
timestamp: number;
/** Request signature (signed by remote instance private key) */
signature: string;
}
/**
* Response from identity verification
*/
export interface IdentityVerificationResponse {
/** Whether the identity was verified successfully */
verified: boolean;
/** Local user ID (if verified) */
localUserId?: string;
/** Remote user ID (if verified) */
remoteUserId?: string;
/** Remote instance ID (if verified) */
remoteInstanceId?: string;
/** User's email (if verified) */
email?: string;
/** Error message if verification failed */
error?: string;
}
/**
* Request to resolve a remote user to a local user
*/
export interface IdentityResolutionRequest {
/** Remote instance federation ID */
remoteInstanceId: string;
/** Remote user ID to resolve */
remoteUserId: string;
}
/**
* Response from identity resolution
*/
export interface IdentityResolutionResponse {
/** Whether a mapping was found */
found: boolean;
/** Local user ID (if found) */
localUserId?: string;
/** Remote user ID */
remoteUserId?: string;
/** Remote instance ID */
remoteInstanceId?: string;
/** User's email (if found) */
email?: string;
/** Additional metadata */
metadata?: Record<string, unknown>;
}
/**
* Request to reverse resolve a local user to a remote identity
*/
export interface ReverseIdentityResolutionRequest {
/** Local user ID to resolve */
localUserId: string;
/** Remote instance federation ID */
remoteInstanceId: string;
}
/**
* Request for bulk identity resolution
*/
export interface BulkIdentityResolutionRequest {
/** Remote instance federation ID */
remoteInstanceId: string;
/** Array of remote user IDs to resolve */
remoteUserIds: string[];
}
/**
* Response for bulk identity resolution
*/
export interface BulkIdentityResolutionResponse {
/** Map of remoteUserId -> localUserId */
mappings: Record<string, string>;
/** Remote user IDs that could not be resolved */
notFound: string[];
}
/**
* DTO for creating identity mapping
*/
export interface CreateIdentityMappingDto {
/** Remote instance ID */
remoteInstanceId: string;
/** Remote user ID */
remoteUserId: string;
/** OIDC subject identifier */
oidcSubject: string;
/** User's email */
email: string;
/** Optional metadata */
metadata?: Record<string, unknown>;
/** Optional: OIDC token for validation */
oidcToken?: string;
}
/**
* DTO for updating identity mapping
*/
export interface UpdateIdentityMappingDto {
/** Updated metadata */
metadata?: Record<string, unknown>;
}
/**
* Identity mapping validation result
*/
export interface IdentityMappingValidation {
/** Whether the mapping is valid */
valid: boolean;
/** Local user ID (if valid) */
localUserId?: string;
/** Remote user ID (if valid) */
remoteUserId?: string;
/** Remote instance ID (if valid) */
remoteInstanceId?: string;
/** Error message if invalid */
error?: string;
}

View File

@@ -7,3 +7,6 @@
export * from "./instance.types";
export * from "./connection.types";
export * from "./oidc.types";
export * from "./identity-linking.types";
export * from "./message.types";
export * from "./federation-agent.types";

View File

@@ -0,0 +1,247 @@
/**
* Message Protocol Types
*
* Types for federation message protocol (QUERY, COMMAND, EVENT).
*/
import type { FederationMessageType, FederationMessageStatus } from "@prisma/client";
/**
* Query message payload (sent to remote instance)
*/
export interface QueryMessage {
/** Unique message identifier for deduplication */
messageId: string;
/** Sending instance's federation ID */
instanceId: string;
/** Query string to execute */
query: string;
/** Optional context for query execution */
context?: Record<string, unknown>;
/** Request timestamp (Unix milliseconds) */
timestamp: number;
/** RSA signature of the query payload */
signature: string;
}
/**
* Query response payload
*/
export interface QueryResponse {
/** Unique message identifier for this response */
messageId: string;
/** Original query messageId (for correlation) */
correlationId: string;
/** Responding instance's federation ID */
instanceId: string;
/** Whether the query was successful */
success: boolean;
/** Query result data */
data?: unknown;
/** Error message (if success=false) */
error?: string;
/** Response timestamp (Unix milliseconds) */
timestamp: number;
/** RSA signature of the response payload */
signature: string;
}
/**
* Query message details response
*/
export interface QueryMessageDetails {
/** Message ID */
id: string;
/** Workspace ID */
workspaceId: string;
/** Connection ID */
connectionId: string;
/** Message type */
messageType: FederationMessageType;
/** Unique message identifier */
messageId: string;
/** Correlation ID (for responses) */
correlationId?: string;
/** Query string */
query?: string;
/** Response data */
response?: unknown;
/** Message status */
status: FederationMessageStatus;
/** Error message */
error?: string;
/** Creation timestamp */
createdAt: Date;
/** Last update timestamp */
updatedAt: Date;
/** Delivery timestamp */
deliveredAt?: Date;
}
/**
* Command message payload (sent to remote instance)
*/
export interface CommandMessage {
/** Unique message identifier for deduplication */
messageId: string;
/** Sending instance's federation ID */
instanceId: string;
/** Command type to execute */
commandType: string;
/** Command-specific payload */
payload: Record<string, unknown>;
/** Request timestamp (Unix milliseconds) */
timestamp: number;
/** RSA signature of the command payload */
signature: string;
}
/**
* Command response payload
*/
export interface CommandResponse {
/** Unique message identifier for this response */
messageId: string;
/** Original command messageId (for correlation) */
correlationId: string;
/** Responding instance's federation ID */
instanceId: string;
/** Whether the command was successful */
success: boolean;
/** Command result data */
data?: unknown;
/** Error message (if success=false) */
error?: string;
/** Response timestamp (Unix milliseconds) */
timestamp: number;
/** RSA signature of the response payload */
signature: string;
}
/**
* Command message details response
*/
export interface CommandMessageDetails {
/** Message ID */
id: string;
/** Workspace ID */
workspaceId: string;
/** Connection ID */
connectionId: string;
/** Message type */
messageType: FederationMessageType;
/** Unique message identifier */
messageId: string;
/** Correlation ID (for responses) */
correlationId?: string;
/** Command type */
commandType?: string;
/** Command payload */
payload?: Record<string, unknown>;
/** Response data */
response?: unknown;
/** Message status */
status: FederationMessageStatus;
/** Error message */
error?: string;
/** Creation timestamp */
createdAt: Date;
/** Last update timestamp */
updatedAt: Date;
/** Delivery timestamp */
deliveredAt?: Date;
}
/**
* Event message payload (sent to remote instance)
*/
export interface EventMessage {
/** Unique message identifier for deduplication */
messageId: string;
/** Sending instance's federation ID */
instanceId: string;
/** Event type (e.g., "task.created", "user.updated") */
eventType: string;
/** Event-specific payload */
payload: Record<string, unknown>;
/** Request timestamp (Unix milliseconds) */
timestamp: number;
/** RSA signature of the event payload */
signature: string;
}
/**
* Event acknowledgment payload
*/
export interface EventAck {
/** Unique message identifier for this acknowledgment */
messageId: string;
/** Original event messageId (for correlation) */
correlationId: string;
/** Acknowledging instance's federation ID */
instanceId: string;
/** Whether the event was received successfully */
received: boolean;
/** Error message (if received=false) */
error?: string;
/** Acknowledgment timestamp (Unix milliseconds) */
timestamp: number;
/** RSA signature of the acknowledgment payload */
signature: string;
}
/**
* Event message details response
*/
export interface EventMessageDetails {
/** Message ID */
id: string;
/** Workspace ID */
workspaceId: string;
/** Connection ID */
connectionId: string;
/** Message type */
messageType: FederationMessageType;
/** Unique message identifier */
messageId: string;
/** Correlation ID (for acknowledgments) */
correlationId?: string;
/** Event type */
eventType?: string;
/** Event payload */
payload?: Record<string, unknown>;
/** Response data */
response?: unknown;
/** Message status */
status: FederationMessageStatus;
/** Error message */
error?: string;
/** Creation timestamp */
createdAt: Date;
/** Last update timestamp */
updatedAt: Date;
/** Delivery timestamp */
deliveredAt?: Date;
}
/**
* Event subscription details
*/
export interface SubscriptionDetails {
/** Subscription ID */
id: string;
/** Workspace ID */
workspaceId: string;
/** Connection ID */
connectionId: string;
/** Event type subscribed to */
eventType: string;
/** Additional metadata */
metadata: Record<string, unknown>;
/** Whether subscription is active */
isActive: boolean;
/** Creation timestamp */
createdAt: Date;
/** Last update timestamp */
updatedAt: Date;
}