diff --git a/apps/api/src/federation/connection.service.spec.ts b/apps/api/src/federation/connection.service.spec.ts index 3a545f5..07e6c75 100644 --- a/apps/api/src/federation/connection.service.spec.ts +++ b/apps/api/src/federation/connection.service.spec.ts @@ -150,6 +150,31 @@ describe("ConnectionService", () => { ); }); + it("should reject connection to instance with incompatible protocol version", async () => { + const incompatibleRemoteIdentity = { + ...mockRemoteIdentity, + capabilities: { + ...mockRemoteIdentity.capabilities, + protocolVersion: "2.0", + }, + }; + + const mockAxiosResponse: AxiosResponse = { + data: incompatibleRemoteIdentity, + status: 200, + statusText: "OK", + headers: {}, + config: {} as never, + }; + + vi.spyOn(prismaService.federationConnection, "count").mockResolvedValue(5); + vi.spyOn(httpService, "get").mockReturnValue(of(mockAxiosResponse)); + + await expect(service.initiateConnection(mockWorkspaceId, mockRemoteUrl)).rejects.toThrow( + "Incompatible protocol version. Expected 1.0, received 2.0" + ); + }); + it("should create a pending connection", async () => { const mockAxiosResponse: AxiosResponse = { data: mockRemoteIdentity, @@ -449,6 +474,42 @@ describe("ConnectionService", () => { signature: "valid-signature", }; + it("should reject request with incompatible protocol version", async () => { + const incompatibleRequest = { + ...mockRequest, + capabilities: { + ...mockRemoteIdentity.capabilities, + protocolVersion: "2.0", + }, + }; + + vi.spyOn(signatureService, "verifyConnectionRequest").mockResolvedValue({ valid: true }); + + await expect( + service.handleIncomingConnectionRequest(mockWorkspaceId, incompatibleRequest) + ).rejects.toThrow("Incompatible protocol version. Expected 1.0, received 2.0"); + }); + + it("should accept request with compatible protocol version", async () => { + const compatibleRequest = { + ...mockRequest, + capabilities: { + ...mockRemoteIdentity.capabilities, + protocolVersion: "1.0", + }, + }; + + vi.spyOn(signatureService, "verifyConnectionRequest").mockResolvedValue({ valid: true }); + vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection); + + const result = await service.handleIncomingConnectionRequest( + mockWorkspaceId, + compatibleRequest + ); + + expect(result.status).toBe(FederationConnectionStatus.PENDING); + }); + it("should validate connection request signature", async () => { const verifySpy = vi.spyOn(signatureService, "verifyConnectionRequest"); vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection); diff --git a/apps/api/src/federation/connection.service.ts b/apps/api/src/federation/connection.service.ts index 983d97d..55487ec 100644 --- a/apps/api/src/federation/connection.service.ts +++ b/apps/api/src/federation/connection.service.ts @@ -21,6 +21,7 @@ import { FederationAuditService } from "./audit.service"; import { firstValueFrom } from "rxjs"; import type { ConnectionRequest, ConnectionDetails } from "./types/connection.types"; import type { PublicInstanceIdentity } from "./types/instance.types"; +import { FEDERATION_PROTOCOL_VERSION } from "./constants"; @Injectable() export class ConnectionService { @@ -55,6 +56,9 @@ export class ConnectionService { // Fetch remote instance identity const remoteIdentity = await this.fetchRemoteIdentity(remoteUrl); + // Validate protocol version compatibility + this.validateProtocolVersion(remoteIdentity.capabilities.protocolVersion); + // Get our instance identity const localIdentity = await this.federationService.getInstanceIdentity(); @@ -316,6 +320,25 @@ export class ConnectionService { throw new UnauthorizedException("Invalid connection request signature"); } + // Validate protocol version compatibility + try { + this.validateProtocolVersion(request.capabilities.protocolVersion); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + this.logger.warn(`Incompatible protocol version from ${request.instanceId}: ${errorMsg}`); + + // Audit log: Connection rejected + this.auditService.logIncomingConnectionRejected({ + workspaceId, + remoteInstanceId: request.instanceId, + remoteUrl: request.instanceUrl, + reason: "Incompatible protocol version", + error: errorMsg, + }); + + throw error; + } + // Create pending connection const connection = await this.prisma.federationConnection.create({ data: { @@ -403,4 +426,22 @@ export class ConnectionService { disconnectedAt: connection.disconnectedAt, }; } + + /** + * Validate protocol version compatibility + * Currently requires exact version match + */ + private validateProtocolVersion(remoteVersion: string | undefined): void { + if (!remoteVersion) { + throw new BadRequestException( + `Protocol version not specified. Expected ${FEDERATION_PROTOCOL_VERSION}` + ); + } + + if (remoteVersion !== FEDERATION_PROTOCOL_VERSION) { + throw new BadRequestException( + `Incompatible protocol version. Expected ${FEDERATION_PROTOCOL_VERSION}, received ${remoteVersion}` + ); + } + } } diff --git a/apps/api/src/federation/constants.ts b/apps/api/src/federation/constants.ts new file mode 100644 index 0000000..e4b1c00 --- /dev/null +++ b/apps/api/src/federation/constants.ts @@ -0,0 +1,13 @@ +/** + * Federation Protocol Constants + * + * Constants for federation protocol versioning and configuration. + */ + +/** + * Current federation protocol version + * Format: MAJOR.MINOR + * - MAJOR version: Breaking changes to protocol + * - MINOR version: Backward-compatible additions + */ +export const FEDERATION_PROTOCOL_VERSION = "1.0";