feat(#90): implement EVENT subscriptions for federation

Implement event pub/sub messaging for federation to enable real-time
event streaming between federated instances.

Features:
- Event subscription management (subscribe/unsubscribe)
- Event publishing to subscribed instances
- Event acknowledgment protocol
- Server-side event filtering based on subscriptions
- Full signature verification and connection validation

Implementation:
- FederationEventSubscription model for storing subscriptions
- EventService with complete event lifecycle management
- EventController with authenticated and public endpoints
- EventMessage, EventAck, and SubscriptionDetails types
- Comprehensive DTOs for all event operations

API Endpoints:
- POST /api/v1/federation/events/subscribe
- POST /api/v1/federation/events/unsubscribe
- POST /api/v1/federation/events/publish
- GET /api/v1/federation/events/subscriptions
- GET /api/v1/federation/events/messages
- POST /api/v1/federation/incoming/event (public)
- POST /api/v1/federation/incoming/event/ack (public)

Testing:
- 18 unit tests for EventService (89.09% coverage)
- 11 unit tests for EventController (83.87% coverage)
- All 29 tests passing
- Follows TDD red-green-refactor cycle

Technical Notes:
- Reuses existing FederationMessage model with eventType field
- Follows patterns from QueryService and CommandService
- Uses existing signature and connection infrastructure
- Supports hierarchical event type naming (e.g., "task.created")

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-03 13:45:00 -06:00
parent 9501aa3867
commit ca4f5ec011
10 changed files with 2395 additions and 5 deletions

View File

@@ -0,0 +1,40 @@
-- Add eventType column to federation_messages table
ALTER TABLE "federation_messages" ADD COLUMN "event_type" TEXT;
-- Add index for eventType
CREATE INDEX "federation_messages_event_type_idx" ON "federation_messages"("event_type");
-- CreateTable
CREATE TABLE "federation_event_subscriptions" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"connection_id" UUID NOT NULL,
"event_type" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "federation_event_subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_workspace_id_idx" ON "federation_event_subscriptions"("workspace_id");
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_connection_id_idx" ON "federation_event_subscriptions"("connection_id");
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_event_type_idx" ON "federation_event_subscriptions"("event_type");
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_workspace_id_is_active_idx" ON "federation_event_subscriptions"("workspace_id", "is_active");
-- CreateIndex
CREATE UNIQUE INDEX "federation_event_subscriptions_workspace_id_connection_id_even_key" ON "federation_event_subscriptions"("workspace_id", "connection_id", "event_type");
-- AddForeignKey
ALTER TABLE "federation_event_subscriptions" ADD CONSTRAINT "federation_event_subscriptions_connection_id_fkey" FOREIGN KEY ("connection_id") REFERENCES "federation_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "federation_event_subscriptions" ADD CONSTRAINT "federation_event_subscriptions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -268,9 +268,10 @@ model Workspace {
personalities Personality[]
llmSettings WorkspaceLlmSettings?
qualityGates QualityGate[]
runnerJobs RunnerJob[]
federationConnections FederationConnection[]
federationMessages FederationMessage[]
runnerJobs RunnerJob[]
federationConnections FederationConnection[]
federationMessages FederationMessage[]
federationEventSubscriptions FederationEventSubscription[]
@@index([ownerId])
@@map("workspaces")
@@ -1287,8 +1288,9 @@ model FederationConnection {
disconnectedAt DateTime? @map("disconnected_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
messages FederationMessage[]
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
messages FederationMessage[]
eventSubscriptions FederationEventSubscription[]
@@unique([workspaceId, remoteInstanceId])
@@index([workspaceId])
@@ -1330,6 +1332,7 @@ model FederationMessage {
// Message content
query String? @db.Text
commandType String? @map("command_type") @db.Text
eventType String? @map("event_type") @db.Text // For EVENT messages
payload Json? @default("{}")
response Json? @default("{}")
@@ -1353,5 +1356,30 @@ model FederationMessage {
@@index([connectionId])
@@index([messageId])
@@index([correlationId])
@@index([eventType])
@@map("federation_messages")
}
model FederationEventSubscription {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
connectionId String @map("connection_id") @db.Uuid
// Event subscription details
eventType String @map("event_type")
metadata Json @default("{}")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
connection FederationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, connectionId, eventType])
@@index([workspaceId])
@@index([connectionId])
@@index([eventType])
@@index([workspaceId, isActive])
@@map("federation_event_subscriptions")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import { FederationAuthController } from "./federation-auth.controller";
import { IdentityLinkingController } from "./identity-linking.controller";
import { QueryController } from "./query.controller";
import { CommandController } from "./command.controller";
import { EventController } from "./event.controller";
import { FederationService } from "./federation.service";
import { CryptoService } from "./crypto.service";
import { FederationAuditService } from "./audit.service";
@@ -22,6 +23,7 @@ import { IdentityLinkingService } from "./identity-linking.service";
import { IdentityResolutionService } from "./identity-resolution.service";
import { QueryService } from "./query.service";
import { CommandService } from "./command.service";
import { EventService } from "./event.service";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
@@ -39,6 +41,7 @@ import { PrismaModule } from "../prisma/prisma.module";
IdentityLinkingController,
QueryController,
CommandController,
EventController,
],
providers: [
FederationService,
@@ -51,6 +54,7 @@ import { PrismaModule } from "../prisma/prisma.module";
IdentityResolutionService,
QueryService,
CommandService,
EventService,
],
exports: [
FederationService,
@@ -62,6 +66,7 @@ import { PrismaModule } from "../prisma/prisma.module";
IdentityResolutionService,
QueryService,
CommandService,
EventService,
],
})
export class FederationModule {}

View File

@@ -151,3 +151,97 @@ export interface CommandMessageDetails {
/** 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;
}