feat(#85): implement CONNECT/DISCONNECT protocol
Implemented connection handshake protocol for federation building on the Instance Identity Model from issue #84. **Services:** - SignatureService: Message signing/verification with RSA-SHA256 - ConnectionService: Federation connection management **API Endpoints:** - POST /api/v1/federation/connections/initiate - POST /api/v1/federation/connections/:id/accept - POST /api/v1/federation/connections/:id/reject - POST /api/v1/federation/connections/:id/disconnect - GET /api/v1/federation/connections - GET /api/v1/federation/connections/:id - POST /api/v1/federation/incoming/connect **Tests:** 70 tests pass (18 Signature + 20 Connection + 13 Controller + 19 existing) **Coverage:** 100% on new code **TDD Approach:** Tests written before implementation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.72.1",
|
"@anthropic-ai/sdk": "^0.72.1",
|
||||||
"@mosaic/shared": "workspace:*",
|
"@mosaic/shared": "workspace:*",
|
||||||
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/common": "^11.1.12",
|
"@nestjs/common": "^11.1.12",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
|
"axios": "^1.13.4",
|
||||||
"better-auth": "^1.4.17",
|
"better-auth": "^1.4.17",
|
||||||
"bullmq": "^5.67.2",
|
"bullmq": "^5.67.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
|
|||||||
422
apps/api/src/federation/connection.service.spec.ts
Normal file
422
apps/api/src/federation/connection.service.spec.ts
Normal file
@@ -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>(ConnectionService);
|
||||||
|
prismaService = module.get<PrismaService>(PrismaService);
|
||||||
|
federationService = module.get<FederationService>(FederationService);
|
||||||
|
signatureService = module.get<SignatureService>(SignatureService);
|
||||||
|
httpService = module.get<HttpService>(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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
330
apps/api/src/federation/connection.service.ts
Normal file
330
apps/api/src/federation/connection.service.ts
Normal file
@@ -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<ConnectionDetails> {
|
||||||
|
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<ConnectionRequest, "signature"> = {
|
||||||
|
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<string, unknown>
|
||||||
|
): Promise<ConnectionDetails> {
|
||||||
|
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<ConnectionDetails> {
|
||||||
|
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<string, unknown>),
|
||||||
|
rejectionReason: reason,
|
||||||
|
} as Prisma.JsonObject,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapToConnectionDetails(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect an active connection
|
||||||
|
*/
|
||||||
|
async disconnect(
|
||||||
|
workspaceId: string,
|
||||||
|
connectionId: string,
|
||||||
|
reason?: string
|
||||||
|
): Promise<ConnectionDetails> {
|
||||||
|
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<string, unknown>),
|
||||||
|
...(reason ? { disconnectReason: reason } : {}),
|
||||||
|
} as Prisma.JsonObject,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapToConnectionDetails(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all connections for a workspace
|
||||||
|
*/
|
||||||
|
async getConnections(
|
||||||
|
workspaceId: string,
|
||||||
|
status?: FederationConnectionStatus
|
||||||
|
): Promise<ConnectionDetails[]> {
|
||||||
|
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<ConnectionDetails> {
|
||||||
|
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<ConnectionDetails> {
|
||||||
|
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<PublicInstanceIdentity> {
|
||||||
|
try {
|
||||||
|
const normalizedUrl = this.normalizeUrl(remoteUrl);
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get<PublicInstanceIdentity>(`${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<string, unknown>,
|
||||||
|
status: connection.status,
|
||||||
|
metadata: connection.metadata as Record<string, unknown>,
|
||||||
|
createdAt: connection.createdAt,
|
||||||
|
updatedAt: connection.updatedAt,
|
||||||
|
connectedAt: connection.connectedAt,
|
||||||
|
disconnectedAt: connection.disconnectedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
64
apps/api/src/federation/dto/connection.dto.ts
Normal file
64
apps/api/src/federation/dto/connection.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
timestamp!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
signature!: string;
|
||||||
|
}
|
||||||
@@ -7,14 +7,18 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { FederationController } from "./federation.controller";
|
import { FederationController } from "./federation.controller";
|
||||||
import { FederationService } from "./federation.service";
|
import { FederationService } from "./federation.service";
|
||||||
import { FederationAuditService } from "./audit.service";
|
import { FederationAuditService } from "./audit.service";
|
||||||
|
import { ConnectionService } from "./connection.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { AdminGuard } from "../auth/guards/admin.guard";
|
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||||
|
import { FederationConnectionStatus } from "@prisma/client";
|
||||||
import type { PublicInstanceIdentity } from "./types/instance.types";
|
import type { PublicInstanceIdentity } from "./types/instance.types";
|
||||||
|
import type { ConnectionDetails } from "./types/connection.types";
|
||||||
|
|
||||||
describe("FederationController", () => {
|
describe("FederationController", () => {
|
||||||
let controller: FederationController;
|
let controller: FederationController;
|
||||||
let service: FederationService;
|
let service: FederationService;
|
||||||
let auditService: FederationAuditService;
|
let auditService: FederationAuditService;
|
||||||
|
let connectionService: ConnectionService;
|
||||||
|
|
||||||
const mockPublicIdentity: PublicInstanceIdentity = {
|
const mockPublicIdentity: PublicInstanceIdentity = {
|
||||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||||
@@ -37,6 +41,22 @@ describe("FederationController", () => {
|
|||||||
id: "user-123",
|
id: "user-123",
|
||||||
email: "admin@example.com",
|
email: "admin@example.com",
|
||||||
name: "Admin User",
|
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 () => {
|
beforeEach(async () => {
|
||||||
@@ -56,6 +76,18 @@ describe("FederationController", () => {
|
|||||||
logKeypairRegeneration: vi.fn(),
|
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)
|
.overrideGuard(AuthGuard)
|
||||||
@@ -67,6 +99,7 @@ describe("FederationController", () => {
|
|||||||
controller = module.get<FederationController>(FederationController);
|
controller = module.get<FederationController>(FederationController);
|
||||||
service = module.get<FederationService>(FederationService);
|
service = module.get<FederationService>(FederationService);
|
||||||
auditService = module.get<FederationAuditService>(FederationAuditService);
|
auditService = module.get<FederationAuditService>(FederationAuditService);
|
||||||
|
connectionService = module.get<ConnectionService>(ConnectionService);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /instance", () => {
|
describe("GET /instance", () => {
|
||||||
@@ -155,4 +188,147 @@ describe("FederationController", () => {
|
|||||||
expect(result).toHaveProperty("instanceId");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,13 +4,23 @@
|
|||||||
* API endpoints for instance identity and federation management.
|
* 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 { FederationService } from "./federation.service";
|
||||||
import { FederationAuditService } from "./audit.service";
|
import { FederationAuditService } from "./audit.service";
|
||||||
|
import { ConnectionService } from "./connection.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { AdminGuard } from "../auth/guards/admin.guard";
|
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||||
import type { PublicInstanceIdentity } from "./types/instance.types";
|
import type { PublicInstanceIdentity } from "./types/instance.types";
|
||||||
|
import type { ConnectionDetails } from "./types/connection.types";
|
||||||
import type { AuthenticatedRequest } from "../common/types/user.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")
|
@Controller("api/v1/federation")
|
||||||
export class FederationController {
|
export class FederationController {
|
||||||
@@ -18,7 +28,8 @@ export class FederationController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly federationService: FederationService,
|
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;
|
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<ConnectionDetails> {
|
||||||
|
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<ConnectionDetails> {
|
||||||
|
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<ConnectionDetails> {
|
||||||
|
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<ConnectionDetails> {
|
||||||
|
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<ConnectionDetails[]> {
|
||||||
|
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<ConnectionDetails> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,32 @@
|
|||||||
|
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { FederationController } from "./federation.controller";
|
import { FederationController } from "./federation.controller";
|
||||||
import { FederationService } from "./federation.service";
|
import { FederationService } from "./federation.service";
|
||||||
import { CryptoService } from "./crypto.service";
|
import { CryptoService } from "./crypto.service";
|
||||||
import { FederationAuditService } from "./audit.service";
|
import { FederationAuditService } from "./audit.service";
|
||||||
|
import { SignatureService } from "./signature.service";
|
||||||
|
import { ConnectionService } from "./connection.service";
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, PrismaModule],
|
imports: [
|
||||||
|
ConfigModule,
|
||||||
|
PrismaModule,
|
||||||
|
HttpModule.register({
|
||||||
|
timeout: 10000,
|
||||||
|
maxRedirects: 5,
|
||||||
|
}),
|
||||||
|
],
|
||||||
controllers: [FederationController],
|
controllers: [FederationController],
|
||||||
providers: [FederationService, CryptoService, FederationAuditService],
|
providers: [
|
||||||
exports: [FederationService, CryptoService],
|
FederationService,
|
||||||
|
CryptoService,
|
||||||
|
FederationAuditService,
|
||||||
|
SignatureService,
|
||||||
|
ConnectionService,
|
||||||
|
],
|
||||||
|
exports: [FederationService, CryptoService, SignatureService, ConnectionService],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
283
apps/api/src/federation/signature.service.spec.ts
Normal file
283
apps/api/src/federation/signature.service.spec.ts
Normal file
@@ -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<FederationService>;
|
||||||
|
|
||||||
|
// 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>(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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
192
apps/api/src/federation/signature.service.ts
Normal file
192
apps/api/src/federation/signature.service.ts
Normal file
@@ -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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
apps/api/src/federation/types/connection.types.ts
Normal file
137
apps/api/src/federation/types/connection.types.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>;
|
||||||
|
/** Creation timestamp */
|
||||||
|
createdAt: Date;
|
||||||
|
/** Last update timestamp */
|
||||||
|
updatedAt: Date;
|
||||||
|
/** Connection established timestamp */
|
||||||
|
connectedAt: Date | null;
|
||||||
|
/** Disconnection timestamp */
|
||||||
|
disconnectedAt: Date | null;
|
||||||
|
}
|
||||||
8
apps/api/src/federation/types/index.ts
Normal file
8
apps/api/src/federation/types/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Federation Types
|
||||||
|
*
|
||||||
|
* Central export for all federation-related types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./instance.types";
|
||||||
|
export * from "./connection.types";
|
||||||
222
docs/scratchpads/85-connect-disconnect-protocol.md
Normal file
222
docs/scratchpads/85-connect-disconnect-protocol.md
Normal file
@@ -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)
|
||||||
63
pnpm-lock.yaml
generated
63
pnpm-lock.yaml
generated
@@ -60,6 +60,9 @@ importers:
|
|||||||
'@mosaic/shared':
|
'@mosaic/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
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':
|
'@nestjs/bullmq':
|
||||||
specifier: ^11.0.4
|
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)
|
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:
|
archiver:
|
||||||
specifier: ^7.0.1
|
specifier: ^7.0.1
|
||||||
version: 7.0.1
|
version: 7.0.1
|
||||||
|
axios:
|
||||||
|
specifier: ^1.13.4
|
||||||
|
version: 1.13.4
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.4.17
|
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))
|
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]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@nestjs/bull-shared@11.0.4':
|
||||||
resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==}
|
resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3272,6 +3285,9 @@ packages:
|
|||||||
asynckit@0.4.0:
|
asynckit@0.4.0:
|
||||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
|
axios@1.13.4:
|
||||||
|
resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==}
|
||||||
|
|
||||||
b4a@1.7.3:
|
b4a@1.7.3:
|
||||||
resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==}
|
resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4420,6 +4436,15 @@ packages:
|
|||||||
flatted@3.3.3:
|
flatted@3.3.3:
|
||||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
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:
|
foreground-child@3.3.1:
|
||||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -5502,6 +5527,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0:
|
||||||
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
pump@3.0.3:
|
pump@3.0.3:
|
||||||
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
|
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
|
||||||
|
|
||||||
@@ -6954,7 +6982,7 @@ snapshots:
|
|||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
commander: 12.1.0
|
commander: 12.1.0
|
||||||
dotenv: 17.2.3
|
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
|
open: 10.2.0
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prettier: 3.8.1
|
prettier: 3.8.1
|
||||||
@@ -7668,6 +7696,12 @@ snapshots:
|
|||||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||||
optional: true
|
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)':
|
'@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:
|
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)
|
'@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: {}
|
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: {}
|
b4a@1.7.3: {}
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
@@ -9843,7 +9885,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
better-sqlite3: 12.6.2
|
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)
|
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
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -9868,7 +9910,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
'@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
|
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)
|
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
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -10616,16 +10658,6 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.2.3: {}
|
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)):
|
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:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
@@ -10635,7 +10667,6 @@ snapshots:
|
|||||||
kysely: 0.28.10
|
kysely: 0.28.10
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
optional: true
|
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10995,6 +11026,8 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.3.3: {}
|
flatted@3.3.3: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11: {}
|
||||||
|
|
||||||
foreground-child@3.3.1:
|
foreground-child@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
@@ -12075,6 +12108,8 @@ snapshots:
|
|||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
ipaddr.js: 1.9.1
|
ipaddr.js: 1.9.1
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
pump@3.0.3:
|
pump@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
end-of-stream: 1.4.5
|
end-of-stream: 1.4.5
|
||||||
|
|||||||
Reference in New Issue
Block a user