diff --git a/apps/api/package.json b/apps/api/package.json index 5e4c388..5627fab 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.72.1", "@mosaic/shared": "workspace:*", + "@nestjs/axios": "^4.0.1", "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.1.12", "@nestjs/config": "^4.0.2", @@ -47,6 +48,7 @@ "@types/multer": "^2.0.0", "adm-zip": "^0.5.16", "archiver": "^7.0.1", + "axios": "^1.13.4", "better-auth": "^1.4.17", "bullmq": "^5.67.2", "class-transformer": "^0.5.1", diff --git a/apps/api/src/federation/connection.service.spec.ts b/apps/api/src/federation/connection.service.spec.ts new file mode 100644 index 0000000..1fd4930 --- /dev/null +++ b/apps/api/src/federation/connection.service.spec.ts @@ -0,0 +1,422 @@ +/** + * Connection Service Tests + * + * Tests for federation connection management. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { HttpService } from "@nestjs/axios"; +import { ConnectionService } from "./connection.service"; +import { FederationService } from "./federation.service"; +import { SignatureService } from "./signature.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { FederationConnectionStatus } from "@prisma/client"; +import { FederationConnection } from "@prisma/client"; +import { of, throwError } from "rxjs"; +import type { AxiosResponse } from "axios"; + +describe("ConnectionService", () => { + let service: ConnectionService; + let prismaService: PrismaService; + let federationService: FederationService; + let signatureService: SignatureService; + let httpService: HttpService; + + const mockWorkspaceId = "workspace-123"; + const mockRemoteUrl = "https://remote.example.com"; + const mockInstanceIdentity = { + id: "local-id", + instanceId: "local-instance-123", + name: "Local Instance", + url: "https://local.example.com", + publicKey: "-----BEGIN PUBLIC KEY-----\nLOCAL\n-----END PUBLIC KEY-----", + privateKey: "-----BEGIN PRIVATE KEY-----\nLOCAL\n-----END PRIVATE KEY-----", + capabilities: { + supportsQuery: true, + supportsCommand: true, + protocolVersion: "1.0", + }, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockRemoteIdentity = { + id: "remote-id", + instanceId: "remote-instance-456", + name: "Remote Instance", + url: mockRemoteUrl, + publicKey: "-----BEGIN PUBLIC KEY-----\nREMOTE\n-----END PUBLIC KEY-----", + capabilities: { + supportsQuery: true, + protocolVersion: "1.0", + }, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockConnection: FederationConnection = { + id: "conn-123", + workspaceId: mockWorkspaceId, + remoteInstanceId: mockRemoteIdentity.instanceId, + remoteUrl: mockRemoteUrl, + remotePublicKey: mockRemoteIdentity.publicKey, + remoteCapabilities: mockRemoteIdentity.capabilities, + status: FederationConnectionStatus.PENDING, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + connectedAt: null, + disconnectedAt: null, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ConnectionService, + { + provide: PrismaService, + useValue: { + federationConnection: { + create: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + }, + }, + { + provide: FederationService, + useValue: { + getInstanceIdentity: vi.fn().mockResolvedValue(mockInstanceIdentity), + getPublicIdentity: vi.fn().mockResolvedValue(mockInstanceIdentity), + }, + }, + { + provide: SignatureService, + useValue: { + signMessage: vi.fn().mockResolvedValue("mock-signature"), + verifyConnectionRequest: vi.fn().mockReturnValue({ valid: true }), + }, + }, + { + provide: HttpService, + useValue: { + get: vi.fn(), + post: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ConnectionService); + prismaService = module.get(PrismaService); + federationService = module.get(FederationService); + signatureService = module.get(SignatureService); + httpService = module.get(HttpService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("initiateConnection", () => { + it("should create a pending connection", async () => { + const mockAxiosResponse: AxiosResponse = { + data: mockRemoteIdentity, + status: 200, + statusText: "OK", + headers: {}, + config: {} as never, + }; + + vi.spyOn(httpService, "get").mockReturnValue(of(mockAxiosResponse)); + vi.spyOn(httpService, "post").mockReturnValue( + of({ data: { accepted: true } } as AxiosResponse) + ); + vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection); + + const result = await service.initiateConnection(mockWorkspaceId, mockRemoteUrl); + + expect(result).toBeDefined(); + expect(result.status).toBe(FederationConnectionStatus.PENDING); + expect(result.remoteUrl).toBe(mockRemoteUrl); + expect(prismaService.federationConnection.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + workspaceId: mockWorkspaceId, + remoteUrl: mockRemoteUrl, + status: FederationConnectionStatus.PENDING, + }), + }) + ); + }); + + it("should fetch remote instance identity", async () => { + const mockAxiosResponse: AxiosResponse = { + data: mockRemoteIdentity, + status: 200, + statusText: "OK", + headers: {}, + config: {} as never, + }; + + vi.spyOn(httpService, "get").mockReturnValue(of(mockAxiosResponse)); + vi.spyOn(httpService, "post").mockReturnValue( + of({ data: { accepted: true } } as AxiosResponse) + ); + vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection); + + await service.initiateConnection(mockWorkspaceId, mockRemoteUrl); + + expect(httpService.get).toHaveBeenCalledWith(`${mockRemoteUrl}/api/v1/federation/instance`); + }); + + it("should throw error if remote instance not reachable", async () => { + vi.spyOn(httpService, "get").mockReturnValue(throwError(() => new Error("Network error"))); + + await expect(service.initiateConnection(mockWorkspaceId, mockRemoteUrl)).rejects.toThrow(); + }); + + it("should send signed connection request", async () => { + const mockAxiosResponse: AxiosResponse = { + data: mockRemoteIdentity, + status: 200, + statusText: "OK", + headers: {}, + config: {} as never, + }; + + const postSpy = vi + .spyOn(httpService, "post") + .mockReturnValue(of({ data: { accepted: true } } as AxiosResponse)); + vi.spyOn(httpService, "get").mockReturnValue(of(mockAxiosResponse)); + vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection); + + await service.initiateConnection(mockWorkspaceId, mockRemoteUrl); + + expect(postSpy).toHaveBeenCalledWith( + `${mockRemoteUrl}/api/v1/federation/incoming/connect`, + expect.objectContaining({ + instanceId: mockInstanceIdentity.instanceId, + instanceUrl: mockInstanceIdentity.url, + publicKey: mockInstanceIdentity.publicKey, + signature: "mock-signature", + }) + ); + }); + }); + + describe("acceptConnection", () => { + it("should update connection status to ACTIVE", async () => { + vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(mockConnection); + vi.spyOn(prismaService.federationConnection, "update").mockReturnValue({ + ...mockConnection, + status: FederationConnectionStatus.ACTIVE, + connectedAt: new Date(), + }); + + const result = await service.acceptConnection(mockWorkspaceId, mockConnection.id); + + expect(result.status).toBe(FederationConnectionStatus.ACTIVE); + expect(result.connectedAt).toBeDefined(); + expect(prismaService.federationConnection.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: mockConnection.id, + }), + data: expect.objectContaining({ + status: FederationConnectionStatus.ACTIVE, + connectedAt: expect.any(Date), + }), + }) + ); + }); + + it("should throw error if connection not found", async () => { + vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(null); + + await expect(service.acceptConnection(mockWorkspaceId, "non-existent-id")).rejects.toThrow( + "Connection not found" + ); + }); + + it("should enforce workspace isolation", async () => { + vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(null); + + await expect( + service.acceptConnection("different-workspace", mockConnection.id) + ).rejects.toThrow("Connection not found"); + }); + }); + + describe("rejectConnection", () => { + it("should update connection status to DISCONNECTED", async () => { + vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(mockConnection); + vi.spyOn(prismaService.federationConnection, "update").mockReturnValue({ + ...mockConnection, + status: FederationConnectionStatus.DISCONNECTED, + metadata: { rejectionReason: "Not approved" }, + }); + + const result = await service.rejectConnection( + mockWorkspaceId, + mockConnection.id, + "Not approved" + ); + + expect(result.status).toBe(FederationConnectionStatus.DISCONNECTED); + expect(result.metadata).toHaveProperty("rejectionReason", "Not approved"); + }); + + it("should throw error if connection not found", async () => { + vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(null); + + await expect( + service.rejectConnection(mockWorkspaceId, "non-existent-id", "Reason") + ).rejects.toThrow("Connection not found"); + }); + }); + + describe("disconnect", () => { + const activeConnection: FederationConnection = { + ...mockConnection, + status: FederationConnectionStatus.ACTIVE, + connectedAt: new Date(), + }; + + it("should disconnect active connection", async () => { + vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(activeConnection); + vi.spyOn(prismaService.federationConnection, "update").mockReturnValue({ + ...activeConnection, + status: FederationConnectionStatus.DISCONNECTED, + disconnectedAt: new Date(), + }); + + const result = await service.disconnect( + mockWorkspaceId, + mockConnection.id, + "Manual disconnect" + ); + + expect(result.status).toBe(FederationConnectionStatus.DISCONNECTED); + expect(result.disconnectedAt).toBeDefined(); + }); + + it("should store disconnection reason in metadata", async () => { + vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(activeConnection); + vi.spyOn(prismaService.federationConnection, "update").mockReturnValue({ + ...activeConnection, + status: FederationConnectionStatus.DISCONNECTED, + disconnectedAt: new Date(), + metadata: { disconnectReason: "Test reason" }, + }); + + const result = await service.disconnect(mockWorkspaceId, mockConnection.id, "Test reason"); + + expect(result.metadata).toHaveProperty("disconnectReason", "Test reason"); + }); + }); + + describe("getConnections", () => { + it("should list all connections for workspace", async () => { + const connections = [mockConnection]; + vi.spyOn(prismaService.federationConnection, "findMany").mockResolvedValue(connections); + + const result = await service.getConnections(mockWorkspaceId); + + expect(result).toEqual(connections); + expect(prismaService.federationConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { workspaceId: mockWorkspaceId }, + }) + ); + }); + + it("should filter by status if provided", async () => { + const connections = [mockConnection]; + vi.spyOn(prismaService.federationConnection, "findMany").mockResolvedValue(connections); + + await service.getConnections(mockWorkspaceId, FederationConnectionStatus.ACTIVE); + + expect(prismaService.federationConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + workspaceId: mockWorkspaceId, + status: FederationConnectionStatus.ACTIVE, + }, + }) + ); + }); + }); + + describe("getConnection", () => { + it("should return connection details", async () => { + vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(mockConnection); + + const result = await service.getConnection(mockWorkspaceId, mockConnection.id); + + expect(result).toEqual(mockConnection); + }); + + it("should throw error if connection not found", async () => { + vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(null); + + await expect(service.getConnection(mockWorkspaceId, "non-existent-id")).rejects.toThrow( + "Connection not found" + ); + }); + + it("should enforce workspace isolation", async () => { + vi.spyOn(prismaService.federationConnection, "findFirst").mockResolvedValue(null); + + await expect(service.getConnection("different-workspace", mockConnection.id)).rejects.toThrow( + "Connection not found" + ); + }); + }); + + describe("handleIncomingConnectionRequest", () => { + const mockRequest = { + instanceId: mockRemoteIdentity.instanceId, + instanceUrl: mockRemoteIdentity.url, + publicKey: mockRemoteIdentity.publicKey, + capabilities: mockRemoteIdentity.capabilities, + timestamp: Date.now(), + signature: "valid-signature", + }; + + it("should validate connection request signature", async () => { + const verifySpy = vi.spyOn(signatureService, "verifyConnectionRequest"); + vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection); + + await service.handleIncomingConnectionRequest(mockWorkspaceId, mockRequest); + + expect(verifySpy).toHaveBeenCalledWith(mockRequest); + }); + + it("should create pending connection for valid request", async () => { + vi.spyOn(signatureService, "verifyConnectionRequest").mockReturnValue({ valid: true }); + vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection); + + const result = await service.handleIncomingConnectionRequest(mockWorkspaceId, mockRequest); + + expect(result.status).toBe(FederationConnectionStatus.PENDING); + expect(prismaService.federationConnection.create).toHaveBeenCalled(); + }); + + it("should reject request with invalid signature", async () => { + vi.spyOn(signatureService, "verifyConnectionRequest").mockReturnValue({ + valid: false, + error: "Invalid signature", + }); + + await expect( + service.handleIncomingConnectionRequest(mockWorkspaceId, mockRequest) + ).rejects.toThrow("Invalid connection request signature"); + }); + }); +}); diff --git a/apps/api/src/federation/connection.service.ts b/apps/api/src/federation/connection.service.ts new file mode 100644 index 0000000..2fcb373 --- /dev/null +++ b/apps/api/src/federation/connection.service.ts @@ -0,0 +1,330 @@ +/** + * Connection Service + * + * Manages federation connections between instances. + */ + +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { FederationConnectionStatus, Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import { FederationService } from "./federation.service"; +import { SignatureService } from "./signature.service"; +import { firstValueFrom } from "rxjs"; +import type { ConnectionRequest, ConnectionDetails } from "./types/connection.types"; +import type { PublicInstanceIdentity } from "./types/instance.types"; + +@Injectable() +export class ConnectionService { + private readonly logger = new Logger(ConnectionService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly federationService: FederationService, + private readonly signatureService: SignatureService, + private readonly httpService: HttpService + ) {} + + /** + * Initiate a connection to a remote instance + */ + async initiateConnection(workspaceId: string, remoteUrl: string): Promise { + this.logger.log(`Initiating connection to ${remoteUrl} for workspace ${workspaceId}`); + + // Fetch remote instance identity + const remoteIdentity = await this.fetchRemoteIdentity(remoteUrl); + + // Get our instance identity + const localIdentity = await this.federationService.getInstanceIdentity(); + + // Create connection record with PENDING status + const connection = await this.prisma.federationConnection.create({ + data: { + workspaceId, + remoteInstanceId: remoteIdentity.instanceId, + remoteUrl: this.normalizeUrl(remoteUrl), + remotePublicKey: remoteIdentity.publicKey, + remoteCapabilities: remoteIdentity.capabilities as Prisma.JsonObject, + status: FederationConnectionStatus.PENDING, + metadata: {}, + }, + }); + + // Create signed connection request + const request: Omit = { + instanceId: localIdentity.instanceId, + instanceUrl: localIdentity.url, + publicKey: localIdentity.publicKey, + capabilities: localIdentity.capabilities, + timestamp: Date.now(), + }; + + const signature = await this.signatureService.signMessage(request); + const signedRequest: ConnectionRequest = { ...request, signature }; + + // Send connection request to remote instance (fire-and-forget for now) + try { + await firstValueFrom( + this.httpService.post(`${remoteUrl}/api/v1/federation/incoming/connect`, signedRequest) + ); + this.logger.log(`Connection request sent to ${remoteUrl}`); + } catch (error) { + this.logger.error(`Failed to send connection request to ${remoteUrl}`, error); + // Connection is still created in PENDING state, can be retried + } + + return this.mapToConnectionDetails(connection); + } + + /** + * Accept a pending connection + */ + async acceptConnection( + workspaceId: string, + connectionId: string, + metadata?: Record + ): Promise { + this.logger.log(`Accepting connection ${connectionId} for workspace ${workspaceId}`); + + // Verify connection exists and belongs to workspace + const connection = await this.prisma.federationConnection.findFirst({ + where: { + id: connectionId, + workspaceId, + }, + }); + + if (!connection) { + throw new NotFoundException("Connection not found"); + } + + // Update status to ACTIVE + const updated = await this.prisma.federationConnection.update({ + where: { + id: connectionId, + }, + data: { + status: FederationConnectionStatus.ACTIVE, + connectedAt: new Date(), + metadata: (metadata ?? connection.metadata) as Prisma.JsonObject, + }, + }); + + this.logger.log(`Connection ${connectionId} activated`); + + return this.mapToConnectionDetails(updated); + } + + /** + * Reject a pending connection + */ + async rejectConnection( + workspaceId: string, + connectionId: string, + reason: string + ): Promise { + this.logger.log(`Rejecting connection ${connectionId}: ${reason}`); + + // Verify connection exists and belongs to workspace + const connection = await this.prisma.federationConnection.findFirst({ + where: { + id: connectionId, + workspaceId, + }, + }); + + if (!connection) { + throw new NotFoundException("Connection not found"); + } + + // Update status to DISCONNECTED with rejection reason + const updated = await this.prisma.federationConnection.update({ + where: { + id: connectionId, + }, + data: { + status: FederationConnectionStatus.DISCONNECTED, + metadata: { + ...(connection.metadata as Record), + rejectionReason: reason, + } as Prisma.JsonObject, + }, + }); + + return this.mapToConnectionDetails(updated); + } + + /** + * Disconnect an active connection + */ + async disconnect( + workspaceId: string, + connectionId: string, + reason?: string + ): Promise { + this.logger.log(`Disconnecting connection ${connectionId}`); + + // Verify connection exists and belongs to workspace + const connection = await this.prisma.federationConnection.findFirst({ + where: { + id: connectionId, + workspaceId, + }, + }); + + if (!connection) { + throw new NotFoundException("Connection not found"); + } + + // Update status to DISCONNECTED + const updated = await this.prisma.federationConnection.update({ + where: { + id: connectionId, + }, + data: { + status: FederationConnectionStatus.DISCONNECTED, + disconnectedAt: new Date(), + metadata: { + ...(connection.metadata as Record), + ...(reason ? { disconnectReason: reason } : {}), + } as Prisma.JsonObject, + }, + }); + + return this.mapToConnectionDetails(updated); + } + + /** + * Get all connections for a workspace + */ + async getConnections( + workspaceId: string, + status?: FederationConnectionStatus + ): Promise { + const connections = await this.prisma.federationConnection.findMany({ + where: { + workspaceId, + ...(status ? { status } : {}), + }, + orderBy: { + createdAt: "desc", + }, + }); + + return connections.map((conn) => this.mapToConnectionDetails(conn)); + } + + /** + * Get a single connection + */ + async getConnection(workspaceId: string, connectionId: string): Promise { + const connection = await this.prisma.federationConnection.findFirst({ + where: { + id: connectionId, + workspaceId, + }, + }); + + if (!connection) { + throw new NotFoundException("Connection not found"); + } + + return this.mapToConnectionDetails(connection); + } + + /** + * Handle incoming connection request from remote instance + */ + async handleIncomingConnectionRequest( + workspaceId: string, + request: ConnectionRequest + ): Promise { + this.logger.log(`Received connection request from ${request.instanceId}`); + + // Verify signature + const validation = this.signatureService.verifyConnectionRequest(request); + + if (!validation.valid) { + const errorMsg = validation.error ?? "Unknown error"; + this.logger.warn(`Invalid connection request from ${request.instanceId}: ${errorMsg}`); + throw new Error("Invalid connection request signature"); + } + + // Create pending connection + const connection = await this.prisma.federationConnection.create({ + data: { + workspaceId, + remoteInstanceId: request.instanceId, + remoteUrl: this.normalizeUrl(request.instanceUrl), + remotePublicKey: request.publicKey, + remoteCapabilities: request.capabilities as Prisma.JsonObject, + status: FederationConnectionStatus.PENDING, + metadata: { + requestTimestamp: request.timestamp, + } as Prisma.JsonObject, + }, + }); + + this.logger.log(`Created pending connection ${connection.id} from ${request.instanceId}`); + + return this.mapToConnectionDetails(connection); + } + + /** + * Fetch remote instance identity via HTTP + */ + private async fetchRemoteIdentity(remoteUrl: string): Promise { + try { + const normalizedUrl = this.normalizeUrl(remoteUrl); + const response = await firstValueFrom( + this.httpService.get(`${normalizedUrl}/api/v1/federation/instance`) + ); + + return response.data; + } catch (error: unknown) { + this.logger.error(`Failed to fetch remote identity from ${remoteUrl}`, error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Could not connect to remote instance: ${remoteUrl}: ${errorMessage}`); + } + } + + /** + * Normalize URL (remove trailing slash) + */ + private normalizeUrl(url: string): string { + return url.replace(/\/$/, ""); + } + + /** + * Map Prisma FederationConnection to ConnectionDetails type + */ + private mapToConnectionDetails(connection: { + id: string; + workspaceId: string; + remoteInstanceId: string; + remoteUrl: string; + remotePublicKey: string; + remoteCapabilities: unknown; + status: FederationConnectionStatus; + metadata: unknown; + createdAt: Date; + updatedAt: Date; + connectedAt: Date | null; + disconnectedAt: Date | null; + }): ConnectionDetails { + return { + id: connection.id, + workspaceId: connection.workspaceId, + remoteInstanceId: connection.remoteInstanceId, + remoteUrl: connection.remoteUrl, + remotePublicKey: connection.remotePublicKey, + remoteCapabilities: connection.remoteCapabilities as Record, + status: connection.status, + metadata: connection.metadata as Record, + createdAt: connection.createdAt, + updatedAt: connection.updatedAt, + connectedAt: connection.connectedAt, + disconnectedAt: connection.disconnectedAt, + }; + } +} diff --git a/apps/api/src/federation/dto/connection.dto.ts b/apps/api/src/federation/dto/connection.dto.ts new file mode 100644 index 0000000..0c5df3e --- /dev/null +++ b/apps/api/src/federation/dto/connection.dto.ts @@ -0,0 +1,64 @@ +/** + * Connection DTOs + * + * Data Transfer Objects for federation connection API. + */ + +import { IsString, IsUrl, IsOptional, IsObject } from "class-validator"; + +/** + * DTO for initiating a connection + */ +export class InitiateConnectionDto { + @IsUrl() + remoteUrl!: string; +} + +/** + * DTO for accepting a connection + */ +export class AcceptConnectionDto { + @IsOptional() + @IsObject() + metadata?: Record; +} + +/** + * DTO for rejecting a connection + */ +export class RejectConnectionDto { + @IsString() + reason!: string; +} + +/** + * DTO for disconnecting a connection + */ +export class DisconnectConnectionDto { + @IsOptional() + @IsString() + reason?: string; +} + +/** + * DTO for incoming connection request (from remote instance) + */ +export class IncomingConnectionRequestDto { + @IsString() + instanceId!: string; + + @IsUrl() + instanceUrl!: string; + + @IsString() + publicKey!: string; + + @IsObject() + capabilities!: Record; + + @IsString() + timestamp!: number; + + @IsString() + signature!: string; +} diff --git a/apps/api/src/federation/federation.controller.spec.ts b/apps/api/src/federation/federation.controller.spec.ts index 716b49e..cff56ca 100644 --- a/apps/api/src/federation/federation.controller.spec.ts +++ b/apps/api/src/federation/federation.controller.spec.ts @@ -7,14 +7,18 @@ import { Test, TestingModule } from "@nestjs/testing"; import { FederationController } from "./federation.controller"; import { FederationService } from "./federation.service"; import { FederationAuditService } from "./audit.service"; +import { ConnectionService } from "./connection.service"; import { AuthGuard } from "../auth/guards/auth.guard"; import { AdminGuard } from "../auth/guards/admin.guard"; +import { FederationConnectionStatus } from "@prisma/client"; import type { PublicInstanceIdentity } from "./types/instance.types"; +import type { ConnectionDetails } from "./types/connection.types"; describe("FederationController", () => { let controller: FederationController; let service: FederationService; let auditService: FederationAuditService; + let connectionService: ConnectionService; const mockPublicIdentity: PublicInstanceIdentity = { id: "123e4567-e89b-12d3-a456-426614174000", @@ -37,6 +41,22 @@ describe("FederationController", () => { id: "user-123", email: "admin@example.com", name: "Admin User", + workspaceId: "workspace-123", + }; + + const mockConnection: ConnectionDetails = { + id: "conn-123", + workspaceId: "workspace-123", + remoteInstanceId: "remote-instance-456", + remoteUrl: "https://remote.example.com", + remotePublicKey: "-----BEGIN PUBLIC KEY-----\nREMOTE\n-----END PUBLIC KEY-----", + remoteCapabilities: { supportsQuery: true }, + status: FederationConnectionStatus.PENDING, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + connectedAt: null, + disconnectedAt: null, }; beforeEach(async () => { @@ -56,6 +76,18 @@ describe("FederationController", () => { logKeypairRegeneration: vi.fn(), }, }, + { + provide: ConnectionService, + useValue: { + initiateConnection: vi.fn(), + acceptConnection: vi.fn(), + rejectConnection: vi.fn(), + disconnect: vi.fn(), + getConnections: vi.fn(), + getConnection: vi.fn(), + handleIncomingConnectionRequest: vi.fn(), + }, + }, ], }) .overrideGuard(AuthGuard) @@ -67,6 +99,7 @@ describe("FederationController", () => { controller = module.get(FederationController); service = module.get(FederationService); auditService = module.get(FederationAuditService); + connectionService = module.get(ConnectionService); }); describe("GET /instance", () => { @@ -155,4 +188,147 @@ describe("FederationController", () => { expect(result).toHaveProperty("instanceId"); }); }); + + describe("POST /connections/initiate", () => { + it("should initiate connection to remote instance", async () => { + const dto = { remoteUrl: "https://remote.example.com" }; + vi.spyOn(connectionService, "initiateConnection").mockResolvedValue(mockConnection); + + const mockRequest = { user: mockUser } as never; + const result = await controller.initiateConnection(mockRequest, dto); + + expect(result).toEqual(mockConnection); + expect(connectionService.initiateConnection).toHaveBeenCalledWith( + mockUser.workspaceId, + dto.remoteUrl + ); + }); + }); + + describe("POST /connections/:id/accept", () => { + it("should accept pending connection", async () => { + const activeConnection = { ...mockConnection, status: FederationConnectionStatus.ACTIVE }; + vi.spyOn(connectionService, "acceptConnection").mockResolvedValue(activeConnection); + + const mockRequest = { user: mockUser } as never; + const result = await controller.acceptConnection(mockRequest, "conn-123", {}); + + expect(result.status).toBe(FederationConnectionStatus.ACTIVE); + expect(connectionService.acceptConnection).toHaveBeenCalledWith( + mockUser.workspaceId, + "conn-123", + undefined + ); + }); + }); + + describe("POST /connections/:id/reject", () => { + it("should reject pending connection", async () => { + const rejectedConnection = { + ...mockConnection, + status: FederationConnectionStatus.DISCONNECTED, + }; + vi.spyOn(connectionService, "rejectConnection").mockResolvedValue(rejectedConnection); + + const mockRequest = { user: mockUser } as never; + const result = await controller.rejectConnection(mockRequest, "conn-123", { + reason: "Not approved", + }); + + expect(result.status).toBe(FederationConnectionStatus.DISCONNECTED); + expect(connectionService.rejectConnection).toHaveBeenCalledWith( + mockUser.workspaceId, + "conn-123", + "Not approved" + ); + }); + }); + + describe("POST /connections/:id/disconnect", () => { + it("should disconnect active connection", async () => { + const disconnectedConnection = { + ...mockConnection, + status: FederationConnectionStatus.DISCONNECTED, + }; + vi.spyOn(connectionService, "disconnect").mockResolvedValue(disconnectedConnection); + + const mockRequest = { user: mockUser } as never; + const result = await controller.disconnectConnection(mockRequest, "conn-123", { + reason: "Manual disconnect", + }); + + expect(result.status).toBe(FederationConnectionStatus.DISCONNECTED); + expect(connectionService.disconnect).toHaveBeenCalledWith( + mockUser.workspaceId, + "conn-123", + "Manual disconnect" + ); + }); + }); + + describe("GET /connections", () => { + it("should list all connections for workspace", async () => { + vi.spyOn(connectionService, "getConnections").mockResolvedValue([mockConnection]); + + const mockRequest = { user: mockUser } as never; + const result = await controller.getConnections(mockRequest); + + expect(result).toEqual([mockConnection]); + expect(connectionService.getConnections).toHaveBeenCalledWith( + mockUser.workspaceId, + undefined + ); + }); + + it("should filter connections by status", async () => { + vi.spyOn(connectionService, "getConnections").mockResolvedValue([mockConnection]); + + const mockRequest = { user: mockUser } as never; + await controller.getConnections(mockRequest, FederationConnectionStatus.ACTIVE); + + expect(connectionService.getConnections).toHaveBeenCalledWith( + mockUser.workspaceId, + FederationConnectionStatus.ACTIVE + ); + }); + }); + + describe("GET /connections/:id", () => { + it("should return connection details", async () => { + vi.spyOn(connectionService, "getConnection").mockResolvedValue(mockConnection); + + const mockRequest = { user: mockUser } as never; + const result = await controller.getConnection(mockRequest, "conn-123"); + + expect(result).toEqual(mockConnection); + expect(connectionService.getConnection).toHaveBeenCalledWith( + mockUser.workspaceId, + "conn-123" + ); + }); + }); + + describe("POST /incoming/connect", () => { + it("should handle incoming connection request", async () => { + const dto = { + instanceId: "remote-instance-456", + instanceUrl: "https://remote.example.com", + publicKey: "PUBLIC_KEY", + capabilities: { supportsQuery: true }, + timestamp: Date.now(), + signature: "valid-signature", + }; + vi.spyOn(connectionService, "handleIncomingConnectionRequest").mockResolvedValue( + mockConnection + ); + + const result = await controller.handleIncomingConnection(dto); + + expect(result).toEqual({ + status: "pending", + connectionId: mockConnection.id, + }); + expect(connectionService.handleIncomingConnectionRequest).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/api/src/federation/federation.controller.ts b/apps/api/src/federation/federation.controller.ts index e390b45..4422998 100644 --- a/apps/api/src/federation/federation.controller.ts +++ b/apps/api/src/federation/federation.controller.ts @@ -4,13 +4,23 @@ * API endpoints for instance identity and federation management. */ -import { Controller, Get, Post, UseGuards, Logger, Req } from "@nestjs/common"; +import { Controller, Get, Post, UseGuards, Logger, Req, Body, Param, Query } from "@nestjs/common"; import { FederationService } from "./federation.service"; import { FederationAuditService } from "./audit.service"; +import { ConnectionService } from "./connection.service"; import { AuthGuard } from "../auth/guards/auth.guard"; import { AdminGuard } from "../auth/guards/admin.guard"; import type { PublicInstanceIdentity } from "./types/instance.types"; +import type { ConnectionDetails } from "./types/connection.types"; import type { AuthenticatedRequest } from "../common/types/user.types"; +import { + InitiateConnectionDto, + AcceptConnectionDto, + RejectConnectionDto, + DisconnectConnectionDto, + IncomingConnectionRequestDto, +} from "./dto/connection.dto"; +import { FederationConnectionStatus } from "@prisma/client"; @Controller("api/v1/federation") export class FederationController { @@ -18,7 +28,8 @@ export class FederationController { constructor( private readonly federationService: FederationService, - private readonly auditService: FederationAuditService + private readonly auditService: FederationAuditService, + private readonly connectionService: ConnectionService ) {} /** @@ -52,4 +63,150 @@ export class FederationController { return result; } + + /** + * Initiate a connection to a remote instance + * Requires authentication + */ + @Post("connections/initiate") + @UseGuards(AuthGuard) + async initiateConnection( + @Req() req: AuthenticatedRequest, + @Body() dto: InitiateConnectionDto + ): Promise { + if (!req.user?.workspaceId) { + throw new Error("Workspace ID not found in request"); + } + + this.logger.log( + `User ${req.user.id} initiating connection to ${dto.remoteUrl} for workspace ${req.user.workspaceId}` + ); + + return this.connectionService.initiateConnection(req.user.workspaceId, dto.remoteUrl); + } + + /** + * Accept a pending connection + * Requires authentication + */ + @Post("connections/:id/accept") + @UseGuards(AuthGuard) + async acceptConnection( + @Req() req: AuthenticatedRequest, + @Param("id") connectionId: string, + @Body() dto: AcceptConnectionDto + ): Promise { + if (!req.user?.workspaceId) { + throw new Error("Workspace ID not found in request"); + } + + this.logger.log( + `User ${req.user.id} accepting connection ${connectionId} for workspace ${req.user.workspaceId}` + ); + + return this.connectionService.acceptConnection( + req.user.workspaceId, + connectionId, + dto.metadata + ); + } + + /** + * Reject a pending connection + * Requires authentication + */ + @Post("connections/:id/reject") + @UseGuards(AuthGuard) + async rejectConnection( + @Req() req: AuthenticatedRequest, + @Param("id") connectionId: string, + @Body() dto: RejectConnectionDto + ): Promise { + if (!req.user?.workspaceId) { + throw new Error("Workspace ID not found in request"); + } + + this.logger.log(`User ${req.user.id} rejecting connection ${connectionId}: ${dto.reason}`); + + return this.connectionService.rejectConnection(req.user.workspaceId, connectionId, dto.reason); + } + + /** + * Disconnect an active connection + * Requires authentication + */ + @Post("connections/:id/disconnect") + @UseGuards(AuthGuard) + async disconnectConnection( + @Req() req: AuthenticatedRequest, + @Param("id") connectionId: string, + @Body() dto: DisconnectConnectionDto + ): Promise { + if (!req.user?.workspaceId) { + throw new Error("Workspace ID not found in request"); + } + + this.logger.log(`User ${req.user.id} disconnecting connection ${connectionId}`); + + return this.connectionService.disconnect(req.user.workspaceId, connectionId, dto.reason); + } + + /** + * Get all connections for the workspace + * Requires authentication + */ + @Get("connections") + @UseGuards(AuthGuard) + async getConnections( + @Req() req: AuthenticatedRequest, + @Query("status") status?: FederationConnectionStatus + ): Promise { + if (!req.user?.workspaceId) { + throw new Error("Workspace ID not found in request"); + } + + return this.connectionService.getConnections(req.user.workspaceId, status); + } + + /** + * Get a single connection + * Requires authentication + */ + @Get("connections/:id") + @UseGuards(AuthGuard) + async getConnection( + @Req() req: AuthenticatedRequest, + @Param("id") connectionId: string + ): Promise { + if (!req.user?.workspaceId) { + throw new Error("Workspace ID not found in request"); + } + + return this.connectionService.getConnection(req.user.workspaceId, connectionId); + } + + /** + * Handle incoming connection request from remote instance + * Public endpoint - no authentication required (signature-based verification) + */ + @Post("incoming/connect") + async handleIncomingConnection( + @Body() dto: IncomingConnectionRequestDto + ): Promise<{ status: string; connectionId?: string }> { + this.logger.log(`Received connection request from ${dto.instanceId}`); + + // For now, create connection in default workspace + // TODO: Allow configuration of which workspace handles incoming connections + const workspaceId = process.env.DEFAULT_WORKSPACE_ID ?? "default"; + + const connection = await this.connectionService.handleIncomingConnectionRequest( + workspaceId, + dto + ); + + return { + status: "pending", + connectionId: connection.id, + }; + } } diff --git a/apps/api/src/federation/federation.module.ts b/apps/api/src/federation/federation.module.ts index 73dec64..d765941 100644 --- a/apps/api/src/federation/federation.module.ts +++ b/apps/api/src/federation/federation.module.ts @@ -6,16 +6,32 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; +import { HttpModule } from "@nestjs/axios"; import { FederationController } from "./federation.controller"; import { FederationService } from "./federation.service"; import { CryptoService } from "./crypto.service"; import { FederationAuditService } from "./audit.service"; +import { SignatureService } from "./signature.service"; +import { ConnectionService } from "./connection.service"; import { PrismaModule } from "../prisma/prisma.module"; @Module({ - imports: [ConfigModule, PrismaModule], + imports: [ + ConfigModule, + PrismaModule, + HttpModule.register({ + timeout: 10000, + maxRedirects: 5, + }), + ], controllers: [FederationController], - providers: [FederationService, CryptoService, FederationAuditService], - exports: [FederationService, CryptoService], + providers: [ + FederationService, + CryptoService, + FederationAuditService, + SignatureService, + ConnectionService, + ], + exports: [FederationService, CryptoService, SignatureService, ConnectionService], }) export class FederationModule {} diff --git a/apps/api/src/federation/signature.service.spec.ts b/apps/api/src/federation/signature.service.spec.ts new file mode 100644 index 0000000..800c243 --- /dev/null +++ b/apps/api/src/federation/signature.service.spec.ts @@ -0,0 +1,283 @@ +/** + * Signature Service Tests + * + * Tests for message signing and verification. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { SignatureService } from "./signature.service"; +import { FederationService } from "./federation.service"; +import { generateKeyPairSync } from "crypto"; + +describe("SignatureService", () => { + let service: SignatureService; + let mockFederationService: Partial; + + // Test keypair + const { publicKey, privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); + + beforeEach(async () => { + mockFederationService = { + getInstanceIdentity: vi.fn().mockResolvedValue({ + id: "test-id", + instanceId: "instance-123", + name: "Test Instance", + url: "https://test.example.com", + publicKey, + privateKey, + capabilities: {}, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SignatureService, + { + provide: FederationService, + useValue: mockFederationService, + }, + ], + }).compile(); + + service = module.get(SignatureService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("sign", () => { + it("should create a valid signature for a message", () => { + const message = { + instanceId: "instance-123", + timestamp: Date.now(), + data: "test data", + }; + + const signature = service.sign(message, privateKey); + + expect(signature).toBeDefined(); + expect(typeof signature).toBe("string"); + expect(signature.length).toBeGreaterThan(0); + }); + + it("should create different signatures for different messages", () => { + const message1 = { data: "message 1", timestamp: 1 }; + const message2 = { data: "message 2", timestamp: 2 }; + + const signature1 = service.sign(message1, privateKey); + const signature2 = service.sign(message2, privateKey); + + expect(signature1).not.toBe(signature2); + }); + + it("should create consistent signatures for the same message", () => { + const message = { data: "test", timestamp: 12345 }; + + const signature1 = service.sign(message, privateKey); + const signature2 = service.sign(message, privateKey); + + // RSA signatures are deterministic for the same input + expect(signature1).toBe(signature2); + }); + }); + + describe("verify", () => { + it("should verify a valid signature", () => { + const message = { + instanceId: "instance-123", + timestamp: Date.now(), + data: "test data", + }; + + const signature = service.sign(message, privateKey); + const result = service.verify(message, signature, publicKey); + + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should reject an invalid signature", () => { + const message = { + instanceId: "instance-123", + timestamp: Date.now(), + data: "test data", + }; + + const invalidSignature = "invalid-signature-data"; + const result = service.verify(message, invalidSignature, publicKey); + + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("should reject a tampered message", () => { + const originalMessage = { + instanceId: "instance-123", + timestamp: Date.now(), + data: "original data", + }; + + const signature = service.sign(originalMessage, privateKey); + + const tamperedMessage = { + ...originalMessage, + data: "tampered data", + }; + + const result = service.verify(tamperedMessage, signature, publicKey); + + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("should reject a signature from wrong key", () => { + const message = { data: "test" }; + + // Generate a different keypair + const { publicKey: wrongPublicKey, privateKey: wrongPrivateKey } = generateKeyPairSync( + "rsa", + { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + } + ); + + const signature = service.sign(message, wrongPrivateKey); + const result = service.verify(message, signature, publicKey); + + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe("validateTimestamp", () => { + it("should accept recent timestamps", () => { + const recentTimestamp = Date.now(); + const result = service.validateTimestamp(recentTimestamp); + + expect(result).toBe(true); + }); + + it("should accept timestamps within 5 minutes", () => { + const fourMinutesAgo = Date.now() - 4 * 60 * 1000; + const result = service.validateTimestamp(fourMinutesAgo); + + expect(result).toBe(true); + }); + + it("should reject timestamps older than 5 minutes", () => { + const sixMinutesAgo = Date.now() - 6 * 60 * 1000; + const result = service.validateTimestamp(sixMinutesAgo); + + expect(result).toBe(false); + }); + + it("should reject future timestamps beyond tolerance", () => { + const farFuture = Date.now() + 10 * 60 * 1000; + const result = service.validateTimestamp(farFuture); + + expect(result).toBe(false); + }); + + it("should accept slightly future timestamps (clock skew tolerance)", () => { + const slightlyFuture = Date.now() + 30 * 1000; // 30 seconds + const result = service.validateTimestamp(slightlyFuture); + + expect(result).toBe(true); + }); + }); + + describe("signMessage", () => { + it("should sign a message with instance private key", async () => { + const message = { + instanceId: "instance-123", + timestamp: Date.now(), + data: "test", + }; + + const signature = await service.signMessage(message); + + expect(signature).toBeDefined(); + expect(typeof signature).toBe("string"); + expect(signature.length).toBeGreaterThan(0); + }); + + it("should create verifiable signatures with instance keys", async () => { + const message = { + instanceId: "instance-123", + timestamp: Date.now(), + }; + + const signature = await service.signMessage(message); + const result = service.verify(message, signature, publicKey); + + expect(result.valid).toBe(true); + }); + }); + + describe("verifyConnectionRequest", () => { + it("should verify a valid connection request", () => { + const timestamp = Date.now(); + const request = { + instanceId: "instance-123", + instanceUrl: "https://test.example.com", + publicKey, + capabilities: { supportsQuery: true }, + timestamp, + }; + + const signature = service.sign(request, privateKey); + const signedRequest = { ...request, signature }; + + const result = service.verifyConnectionRequest(signedRequest); + + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should reject request with invalid signature", () => { + const request = { + instanceId: "instance-123", + instanceUrl: "https://test.example.com", + publicKey, + capabilities: {}, + timestamp: Date.now(), + signature: "invalid-signature", + }; + + const result = service.verifyConnectionRequest(request); + + expect(result.valid).toBe(false); + expect(result.error).toContain("signature"); + }); + + it("should reject request with expired timestamp", () => { + const expiredTimestamp = Date.now() - 10 * 60 * 1000; // 10 minutes ago + const request = { + instanceId: "instance-123", + instanceUrl: "https://test.example.com", + publicKey, + capabilities: {}, + timestamp: expiredTimestamp, + }; + + const signature = service.sign(request, privateKey); + const signedRequest = { ...request, signature }; + + const result = service.verifyConnectionRequest(signedRequest); + + expect(result.valid).toBe(false); + expect(result.error).toContain("timestamp"); + }); + }); +}); diff --git a/apps/api/src/federation/signature.service.ts b/apps/api/src/federation/signature.service.ts new file mode 100644 index 0000000..a3511bd --- /dev/null +++ b/apps/api/src/federation/signature.service.ts @@ -0,0 +1,192 @@ +/** + * Signature Service + * + * Handles message signing and verification for federation protocol. + */ + +import { Injectable, Logger } from "@nestjs/common"; +import { createSign, createVerify } from "crypto"; +import { FederationService } from "./federation.service"; +import type { + SignableMessage, + SignatureValidationResult, + ConnectionRequest, +} from "./types/connection.types"; + +@Injectable() +export class SignatureService { + private readonly logger = new Logger(SignatureService.name); + private readonly TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes + private readonly CLOCK_SKEW_TOLERANCE_MS = 60 * 1000; // 1 minute for future timestamps + + constructor(private readonly federationService: FederationService) {} + + /** + * Sign a message with a private key + * Returns base64-encoded RSA-SHA256 signature + */ + sign(message: SignableMessage, privateKey: string): string { + try { + // Create canonical JSON representation (sorted keys) + const canonical = this.canonicalizeMessage(message); + + // Create signature + const sign = createSign("RSA-SHA256"); + sign.update(canonical); + sign.end(); + + const signature = sign.sign(privateKey, "base64"); + + return signature; + } catch (error) { + this.logger.error("Failed to sign message", error); + throw new Error("Failed to sign message"); + } + } + + /** + * Verify a message signature with a public key + */ + verify( + message: SignableMessage, + signature: string, + publicKey: string + ): SignatureValidationResult { + try { + // Create canonical JSON representation (sorted keys) + const canonical = this.canonicalizeMessage(message); + + // Verify signature + const verify = createVerify("RSA-SHA256"); + verify.update(canonical); + verify.end(); + + const valid = verify.verify(publicKey, signature, "base64"); + + if (!valid) { + return { + valid: false, + error: "Invalid signature", + }; + } + + return { valid: true }; + } catch (error) { + this.logger.error("Signature verification failed", error); + return { + valid: false, + error: error instanceof Error ? error.message : "Verification failed", + }; + } + } + + /** + * Validate timestamp is within acceptable range + * Rejects timestamps older than 5 minutes or more than 1 minute in the future + */ + validateTimestamp(timestamp: number): boolean { + const now = Date.now(); + const age = now - timestamp; + + // Reject if too old + if (age > this.TIMESTAMP_TOLERANCE_MS) { + this.logger.warn(`Timestamp too old: ${age.toString()}ms`); + return false; + } + + // Reject if too far in the future (allow some clock skew) + if (age < -this.CLOCK_SKEW_TOLERANCE_MS) { + this.logger.warn(`Timestamp too far in future: ${(-age).toString()}ms`); + return false; + } + + return true; + } + + /** + * Sign a message using this instance's private key + */ + async signMessage(message: SignableMessage): Promise { + const identity = await this.federationService.getInstanceIdentity(); + + if (!identity.privateKey) { + throw new Error("Instance private key not available"); + } + + return this.sign(message, identity.privateKey); + } + + /** + * Verify a connection request signature + */ + verifyConnectionRequest(request: ConnectionRequest): SignatureValidationResult { + // Extract signature and create message for verification + const { signature, ...message } = request; + + // Validate timestamp + if (!this.validateTimestamp(request.timestamp)) { + return { + valid: false, + error: "Request timestamp is outside acceptable range", + }; + } + + // Verify signature using the public key from the request + const result = this.verify(message, signature, request.publicKey); + + if (!result.valid) { + const errorMsg = result.error ?? "Unknown error"; + this.logger.warn(`Connection request signature verification failed: ${errorMsg}`); + } + + return result; + } + + /** + * Create canonical JSON representation of a message for signing + * Sorts keys recursively to ensure consistent signatures + */ + private canonicalizeMessage(message: SignableMessage): string { + return JSON.stringify(this.sortObjectKeys(message)); + } + + /** + * Recursively sort object keys for canonical representation + * @param obj - The object to sort + * @returns A new object with sorted keys + */ + private sortObjectKeys(obj: SignableMessage): SignableMessage { + // Handle null + if (obj === null) { + return obj; + } + + // Handle arrays - map recursively + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (Array.isArray(obj)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument + return obj.map((item: any) => + typeof item === "object" && item !== null ? this.sortObjectKeys(item) : item + ) as SignableMessage; + } + + // Handle non-objects (primitives) + if (typeof obj !== "object") { + return obj; + } + + // Handle objects - sort keys alphabetically + const sorted: SignableMessage = {}; + const keys = Object.keys(obj).sort(); + + for (const key of keys) { + const value = obj[key]; + sorted[key] = + typeof value === "object" && value !== null + ? this.sortObjectKeys(value as SignableMessage) + : value; + } + + return sorted; + } +} diff --git a/apps/api/src/federation/types/connection.types.ts b/apps/api/src/federation/types/connection.types.ts new file mode 100644 index 0000000..a907056 --- /dev/null +++ b/apps/api/src/federation/types/connection.types.ts @@ -0,0 +1,137 @@ +/** + * Connection Protocol Types + * + * Types for federation connection handshake protocol. + */ + +import type { FederationCapabilities } from "./instance.types"; +import type { FederationConnectionStatus } from "@prisma/client"; + +/** + * Connection request payload (sent to remote instance) + */ +export interface ConnectionRequest { + /** Requesting instance's federation ID */ + instanceId: string; + /** Requesting instance's base URL */ + instanceUrl: string; + /** Requesting instance's public key */ + publicKey: string; + /** Requesting instance's capabilities */ + capabilities: FederationCapabilities; + /** Request timestamp (Unix milliseconds) */ + timestamp: number; + /** RSA signature of the request payload */ + signature: string; +} + +/** + * Connection response payload + */ +export interface ConnectionResponse { + /** Whether the connection was accepted */ + accepted: boolean; + /** Responding instance's federation ID */ + instanceId: string; + /** Responding instance's public key */ + publicKey: string; + /** Responding instance's capabilities */ + capabilities: FederationCapabilities; + /** Rejection reason (if accepted=false) */ + reason?: string; + /** Response timestamp (Unix milliseconds) */ + timestamp: number; + /** RSA signature of the response payload */ + signature: string; +} + +/** + * Disconnect request payload + */ +export interface DisconnectRequest { + /** Disconnecting instance's federation ID */ + instanceId: string; + /** Reason for disconnection */ + reason?: string; + /** Request timestamp (Unix milliseconds) */ + timestamp: number; + /** RSA signature of the request payload */ + signature: string; +} + +/** + * Signable message (any object that can be signed) + */ +export type SignableMessage = Record; + +/** + * Signature validation result + */ +export interface SignatureValidationResult { + /** Whether the signature is valid */ + valid: boolean; + /** Error message if validation failed */ + error?: string; +} + +/** + * Connection initiation request DTO + */ +export interface InitiateConnectionDto { + /** URL of the remote instance to connect to */ + remoteUrl: string; +} + +/** + * Connection acceptance DTO + */ +export interface AcceptConnectionDto { + /** Optional metadata to store with the connection */ + metadata?: Record; +} + +/** + * Connection rejection DTO + */ +export interface RejectConnectionDto { + /** Reason for rejection */ + reason: string; +} + +/** + * Connection disconnection DTO + */ +export interface DisconnectConnectionDto { + /** Reason for disconnection */ + reason?: string; +} + +/** + * Connection details response + */ +export interface ConnectionDetails { + /** Connection ID */ + id: string; + /** Workspace ID */ + workspaceId: string; + /** Remote instance federation ID */ + remoteInstanceId: string; + /** Remote instance URL */ + remoteUrl: string; + /** Remote instance public key */ + remotePublicKey: string; + /** Remote instance capabilities */ + remoteCapabilities: FederationCapabilities; + /** Connection status */ + status: FederationConnectionStatus; + /** Additional metadata */ + metadata: Record; + /** Creation timestamp */ + createdAt: Date; + /** Last update timestamp */ + updatedAt: Date; + /** Connection established timestamp */ + connectedAt: Date | null; + /** Disconnection timestamp */ + disconnectedAt: Date | null; +} diff --git a/apps/api/src/federation/types/index.ts b/apps/api/src/federation/types/index.ts new file mode 100644 index 0000000..3ea1066 --- /dev/null +++ b/apps/api/src/federation/types/index.ts @@ -0,0 +1,8 @@ +/** + * Federation Types + * + * Central export for all federation-related types. + */ + +export * from "./instance.types"; +export * from "./connection.types"; diff --git a/docs/scratchpads/85-connect-disconnect-protocol.md b/docs/scratchpads/85-connect-disconnect-protocol.md new file mode 100644 index 0000000..70e217e --- /dev/null +++ b/docs/scratchpads/85-connect-disconnect-protocol.md @@ -0,0 +1,222 @@ +# Issue #85: [FED-002] CONNECT/DISCONNECT Protocol + +## Objective + +Implement the connection handshake protocol for federation, building on the Instance Identity Model from issue #84. This includes: + +- Connection request/accept/reject handshake +- Message signing and verification using instance keypairs +- Connection state management (PENDING → ACTIVE, DISCONNECTED) +- API endpoints for initiating and managing connections +- Proper error handling and validation + +## Context + +Issue #84 provides the foundation: +- `Instance` model with keypair for signing +- `FederationConnection` model with status enum (PENDING, ACTIVE, SUSPENDED, DISCONNECTED) +- `FederationService` with identity management +- `CryptoService` for encryption/decryption +- Database schema is already in place + +## Approach + +### 1. Create Types for Connection Protocol + +Define TypeScript interfaces in `/apps/api/src/federation/types/connection.types.ts`: + +```typescript +// Connection request payload +interface ConnectionRequest { + instanceId: string; + instanceUrl: string; + publicKey: string; + capabilities: FederationCapabilities; + timestamp: number; + signature: string; // Sign entire payload with private key +} + +// Connection response +interface ConnectionResponse { + accepted: boolean; + instanceId: string; + publicKey: string; + capabilities: FederationCapabilities; + reason?: string; // If rejected + timestamp: number; + signature: string; +} + +// Disconnect request +interface DisconnectRequest { + instanceId: string; + reason?: string; + timestamp: number; + signature: string; +} +``` + +### 2. Add Signature Service + +Create `/apps/api/src/federation/signature.service.ts` for message signing: + +- `sign(message: object, privateKey: string): string` - Sign a message +- `verify(message: object, signature: string, publicKey: string): boolean` - Verify signature +- `signConnectionRequest(...)` - Sign connection request +- `verifyConnectionRequest(...)` - Verify connection request + +### 3. Create Connection Service + +Create `/apps/api/src/federation/connection.service.ts`: + +- `initiateConnection(workspaceId, remoteUrl)` - Start connection handshake +- `acceptConnection(workspaceId, connectionId)` - Accept pending connection +- `rejectConnection(workspaceId, connectionId, reason)` - Reject connection +- `disconnect(workspaceId, connectionId, reason)` - Disconnect active connection +- `getConnections(workspaceId, status?)` - List connections +- `getConnection(workspaceId, connectionId)` - Get single connection + +### 4. Add API Endpoints + +Extend `FederationController` with: + +- `POST /api/v1/federation/connections/initiate` - Initiate connection to remote instance +- `POST /api/v1/federation/connections/:id/accept` - Accept incoming connection +- `POST /api/v1/federation/connections/:id/reject` - Reject incoming connection +- `POST /api/v1/federation/connections/:id/disconnect` - Disconnect active connection +- `GET /api/v1/federation/connections` - List workspace connections +- `GET /api/v1/federation/connections/:id` - Get connection details +- `POST /api/v1/federation/incoming/connect` - Public endpoint for receiving connection requests + +### 5. Connection Handshake Flow + +**Initiator (Instance A) → Target (Instance B)** + +1. Instance A calls `POST /api/v1/federation/connections/initiate` with `remoteUrl` +2. Service creates connection record with status=PENDING +3. Service fetches remote instance identity from `GET {remoteUrl}/api/v1/federation/instance` +4. Service creates signed ConnectionRequest +5. Service sends request to `POST {remoteUrl}/api/v1/federation/incoming/connect` +6. Instance B receives request, validates signature +7. Instance B creates connection record with status=PENDING +8. Instance B can accept (status=ACTIVE) or reject (status=DISCONNECTED) +9. Instance B sends signed ConnectionResponse back to Instance A +10. Instance A updates connection status based on response + +### 6. Security Considerations + +- All connection requests must be signed with instance private key +- All responses must be verified using remote instance public key +- Timestamps must be within 5 minutes to prevent replay attacks +- Connection requests must come from authenticated workspace members +- Public key must match the one fetched from remote instance identity endpoint + +### 7. Testing Strategy + +**Unit Tests** (TDD approach): +- SignatureService.sign() creates valid signatures +- SignatureService.verify() validates signatures correctly +- SignatureService.verify() rejects invalid signatures +- ConnectionService.initiateConnection() creates PENDING connection +- ConnectionService.acceptConnection() updates to ACTIVE +- ConnectionService.rejectConnection() marks as DISCONNECTED +- ConnectionService.disconnect() updates active connection to DISCONNECTED +- Timestamp validation rejects old requests (>5 min) + +**Integration Tests**: +- POST /connections/initiate creates connection and calls remote +- POST /incoming/connect validates signature and creates connection +- POST /connections/:id/accept updates status correctly +- POST /connections/:id/reject marks connection as rejected +- POST /connections/:id/disconnect disconnects active connection +- GET /connections returns workspace connections +- Workspace isolation (can't access other workspace connections) + +## Progress + +- [x] Create scratchpad +- [ ] Create connection.types.ts with protocol types +- [ ] Write tests for SignatureService +- [ ] Implement SignatureService (sign, verify) +- [ ] Write tests for ConnectionService +- [ ] Implement ConnectionService +- [ ] Write tests for connection API endpoints +- [ ] Implement connection API endpoints +- [ ] Update FederationModule with new providers +- [ ] Verify all tests pass +- [ ] Verify type checking passes +- [ ] Verify test coverage ≥85% +- [ ] Commit changes + +## Testing Plan + +### Unit Tests + +1. **SignatureService**: + - Should create RSA SHA-256 signatures + - Should verify valid signatures + - Should reject invalid signatures + - Should reject tampered messages + - Should reject expired timestamps + +2. **ConnectionService**: + - Should initiate connection with PENDING status + - Should fetch remote instance identity before connecting + - Should create signed connection request + - Should accept connection and update to ACTIVE + - Should reject connection with reason + - Should disconnect active connection + - Should list connections for workspace + - Should enforce workspace isolation + +### Integration Tests + +1. **POST /api/v1/federation/connections/initiate**: + - Should require authentication + - Should create connection record + - Should fetch remote instance identity + - Should return connection details + +2. **POST /api/v1/federation/incoming/connect**: + - Should validate connection request signature + - Should reject requests with invalid signatures + - Should reject requests with old timestamps + - Should create pending connection + +3. **POST /api/v1/federation/connections/:id/accept**: + - Should require authentication + - Should update connection to ACTIVE + - Should set connectedAt timestamp + - Should enforce workspace ownership + +4. **POST /api/v1/federation/connections/:id/reject**: + - Should require authentication + - Should update connection to DISCONNECTED + - Should store rejection reason + +5. **POST /api/v1/federation/connections/:id/disconnect**: + - Should require authentication + - Should disconnect active connection + - Should set disconnectedAt timestamp + +6. **GET /api/v1/federation/connections**: + - Should list workspace connections + - Should filter by status if provided + - Should enforce workspace isolation + +## Design Decisions + +1. **RSA Signatures**: Use RSA SHA-256 for signing (matches existing keypair format) +2. **Timestamp Validation**: 5-minute window to prevent replay attacks +3. **Workspace Scoping**: All connections belong to a workspace for RLS +4. **Stateless Protocol**: Each request is independently signed and verified +5. **Public Connection Endpoint**: `/incoming/connect` is public (no auth) but requires valid signature +6. **State Transitions**: PENDING → ACTIVE, PENDING → DISCONNECTED, ACTIVE → DISCONNECTED + +## Notes + +- Connection requests are workspace-scoped (authenticated users only) +- Incoming connection endpoint is public but cryptographically verified +- Need to handle network errors gracefully when calling remote instances +- Should validate remote instance URL format before attempting connection +- Consider rate limiting for incoming connection requests (future enhancement) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55edc26..3b15249 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@mosaic/shared': specifier: workspace:* version: link:../../packages/shared + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2) '@nestjs/bullmq': specifier: ^11.0.4 version: 11.0.4(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(bullmq@5.67.2) @@ -123,6 +126,9 @@ importers: archiver: specifier: ^7.0.1 version: 7.0.1 + axios: + specifier: ^1.13.4 + version: 1.13.4 better-auth: specifier: ^1.4.17 version: 1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -1499,6 +1505,13 @@ packages: cpu: [x64] os: [win32] + '@nestjs/axios@4.0.1': + resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + axios: ^1.3.1 + rxjs: ^7.0.0 + '@nestjs/bull-shared@11.0.4': resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} peerDependencies: @@ -3272,6 +3285,9 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + axios@1.13.4: + resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} + b4a@1.7.3: resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} peerDependencies: @@ -4420,6 +4436,15 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -5502,6 +5527,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -6954,7 +6982,7 @@ snapshots: chalk: 5.6.2 commander: 12.1.0 dotenv: 17.2.3 - drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) + drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) open: 10.2.0 pg: 8.17.2 prettier: 3.8.1 @@ -7668,6 +7696,12 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true + '@nestjs/axios@4.0.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + axios: 1.13.4 + rxjs: 7.8.2 + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)': dependencies: '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -9810,6 +9844,14 @@ snapshots: asynckit@0.4.0: {} + axios@1.13.4: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + b4a@1.7.3: {} balanced-match@1.0.2: {} @@ -9843,7 +9885,7 @@ snapshots: optionalDependencies: '@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) better-sqlite3: 12.6.2 - drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) + drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pg: 8.17.2 prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) @@ -9868,7 +9910,7 @@ snapshots: optionalDependencies: '@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) better-sqlite3: 12.6.2 - drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) + drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pg: 8.17.2 prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) @@ -10616,16 +10658,6 @@ snapshots: dotenv@17.2.3: {} - drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)): - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) - '@types/pg': 8.16.0 - better-sqlite3: 12.6.2 - kysely: 0.28.10 - pg: 8.17.2 - prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) - drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)): optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -10635,7 +10667,6 @@ snapshots: kysely: 0.28.10 pg: 8.17.2 prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) - optional: true dunder-proto@1.0.1: dependencies: @@ -10995,6 +11026,8 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -12075,6 +12108,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + pump@3.0.3: dependencies: end-of-stream: 1.4.5