fix: Resolve merge conflicts with develop
Merged OIDC validation changes (#271) with rate limiting (#272) Both features are now active together
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
236
apps/api/src/federation/command.controller.spec.ts
Normal file
236
apps/api/src/federation/command.controller.spec.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
91
apps/api/src/federation/command.controller.ts
Normal file
91
apps/api/src/federation/command.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
574
apps/api/src/federation/command.service.spec.ts
Normal file
574
apps/api/src/federation/command.service.spec.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
386
apps/api/src/federation/command.service.ts
Normal file
386
apps/api/src/federation/command.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
54
apps/api/src/federation/dto/command.dto.ts
Normal file
54
apps/api/src/federation/dto/command.dto.ts
Normal 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;
|
||||
}
|
||||
109
apps/api/src/federation/dto/event.dto.ts
Normal file
109
apps/api/src/federation/dto/event.dto.ts
Normal 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;
|
||||
}
|
||||
98
apps/api/src/federation/dto/identity-linking.dto.ts
Normal file
98
apps/api/src/federation/dto/identity-linking.dto.ts
Normal 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>;
|
||||
}
|
||||
46
apps/api/src/federation/dto/instance.dto.ts
Normal file
46
apps/api/src/federation/dto/instance.dto.ts
Normal 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>;
|
||||
}
|
||||
53
apps/api/src/federation/dto/query.dto.ts
Normal file
53
apps/api/src/federation/dto/query.dto.ts
Normal 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;
|
||||
}
|
||||
393
apps/api/src/federation/event.controller.spec.ts
Normal file
393
apps/api/src/federation/event.controller.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
197
apps/api/src/federation/event.controller.ts
Normal file
197
apps/api/src/federation/event.controller.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
825
apps/api/src/federation/event.service.spec.ts
Normal file
825
apps/api/src/federation/event.service.spec.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
500
apps/api/src/federation/event.service.ts
Normal file
500
apps/api/src/federation/event.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
457
apps/api/src/federation/federation-agent.service.spec.ts
Normal file
457
apps/api/src/federation/federation-agent.service.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
338
apps/api/src/federation/federation-agent.service.ts
Normal file
338
apps/api/src/federation/federation-agent.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
319
apps/api/src/federation/identity-linking.controller.spec.ts
Normal file
319
apps/api/src/federation/identity-linking.controller.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
151
apps/api/src/federation/identity-linking.controller.ts
Normal file
151
apps/api/src/federation/identity-linking.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
404
apps/api/src/federation/identity-linking.service.spec.ts
Normal file
404
apps/api/src/federation/identity-linking.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
323
apps/api/src/federation/identity-linking.service.ts
Normal file
323
apps/api/src/federation/identity-linking.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
151
apps/api/src/federation/identity-resolution.service.spec.ts
Normal file
151
apps/api/src/federation/identity-resolution.service.spec.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
137
apps/api/src/federation/identity-resolution.service.ts
Normal file
137
apps/api/src/federation/identity-resolution.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
238
apps/api/src/federation/query.controller.spec.ts
Normal file
238
apps/api/src/federation/query.controller.spec.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
91
apps/api/src/federation/query.controller.ts
Normal file
91
apps/api/src/federation/query.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
493
apps/api/src/federation/query.service.spec.ts
Normal file
493
apps/api/src/federation/query.service.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
360
apps/api/src/federation/query.service.ts
Normal file
360
apps/api/src/federation/query.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
149
apps/api/src/federation/types/federation-agent.types.ts
Normal file
149
apps/api/src/federation/types/federation-agent.types.ts
Normal 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;
|
||||
}
|
||||
141
apps/api/src/federation/types/identity-linking.types.ts
Normal file
141
apps/api/src/federation/types/identity-linking.types.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
247
apps/api/src/federation/types/message.types.ts
Normal file
247
apps/api/src/federation/types/message.types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user