feat(#84): implement instance identity model for federation

Implemented the foundation of federation architecture with instance
identity and connection management:

Database Schema:
- Added Instance model for instance identity with keypair generation
- Added FederationConnection model for workspace-scoped connections
- Added FederationConnectionStatus enum (PENDING, ACTIVE, SUSPENDED, DISCONNECTED)

Service Layer:
- FederationService with instance identity management
- RSA 2048-bit keypair generation for signing
- Public identity endpoint (excludes private key)
- Keypair regeneration capability

API Endpoints:
- GET /api/v1/federation/instance - Returns public instance identity
- POST /api/v1/federation/instance/regenerate-keys - Admin keypair regeneration

Tests:
- 11 tests passing (7 service, 4 controller)
- 100% statement coverage, 100% function coverage
- Follows TDD principles (Red-Green-Refactor)

Configuration:
- Added INSTANCE_NAME and INSTANCE_URL environment variables
- Integrated FederationModule into AppModule

Refs #84

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-03 10:58:50 -06:00
parent 6e63508f97
commit 7989c089ef
10 changed files with 853 additions and 22 deletions

View File

@@ -31,6 +31,7 @@ import { RunnerJobsModule } from "./runner-jobs/runner-jobs.module";
import { JobEventsModule } from "./job-events/job-events.module";
import { JobStepsModule } from "./job-steps/job-steps.module";
import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module";
import { FederationModule } from "./federation/federation.module";
@Module({
imports: [
@@ -84,6 +85,7 @@ import { CoordinatorIntegrationModule } from "./coordinator-integration/coordina
JobEventsModule,
JobStepsModule,
CoordinatorIntegrationModule,
FederationModule,
],
controllers: [AppController],
providers: [

View File

@@ -0,0 +1,114 @@
/**
* Federation Controller Tests
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { FederationController } from "./federation.controller";
import { FederationService } from "./federation.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { PublicInstanceIdentity, InstanceIdentity } from "./types/instance.types";
describe("FederationController", () => {
let controller: FederationController;
let service: FederationService;
const mockPublicIdentity: PublicInstanceIdentity = {
id: "123e4567-e89b-12d3-a456-426614174000",
instanceId: "test-instance-id",
name: "Test Instance",
url: "https://test.example.com",
publicKey: "-----BEGIN PUBLIC KEY-----\nMOCK\n-----END PUBLIC KEY-----",
capabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
protocolVersion: "1.0",
},
metadata: {},
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
};
const mockInstanceIdentity: InstanceIdentity = {
...mockPublicIdentity,
privateKey: "-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----",
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FederationController],
providers: [
{
provide: FederationService,
useValue: {
getPublicIdentity: vi.fn(),
regenerateKeypair: vi.fn(),
},
},
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<FederationController>(FederationController);
service = module.get<FederationService>(FederationService);
});
describe("GET /instance", () => {
it("should return public instance identity", async () => {
// Arrange
vi.spyOn(service, "getPublicIdentity").mockResolvedValue(mockPublicIdentity);
// Act
const result = await controller.getInstance();
// Assert
expect(result).toEqual(mockPublicIdentity);
expect(service.getPublicIdentity).toHaveBeenCalledTimes(1);
});
it("should not expose private key", async () => {
// Arrange
vi.spyOn(service, "getPublicIdentity").mockResolvedValue(mockPublicIdentity);
// Act
const result = await controller.getInstance();
// Assert
expect(result).not.toHaveProperty("privateKey");
});
it("should return consistent identity across multiple calls", async () => {
// Arrange
vi.spyOn(service, "getPublicIdentity").mockResolvedValue(mockPublicIdentity);
// Act
const result1 = await controller.getInstance();
const result2 = await controller.getInstance();
// Assert
expect(result1).toEqual(result2);
expect(result1.instanceId).toEqual(result2.instanceId);
});
});
describe("POST /instance/regenerate-keys", () => {
it("should regenerate keypair and return updated identity", async () => {
// Arrange
const updatedIdentity = {
...mockInstanceIdentity,
publicKey: "NEW_PUBLIC_KEY",
};
vi.spyOn(service, "regenerateKeypair").mockResolvedValue(updatedIdentity);
// Act
const result = await controller.regenerateKeys();
// Assert
expect(result).toEqual(updatedIdentity);
expect(service.regenerateKeypair).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,38 @@
/**
* Federation Controller
*
* API endpoints for instance identity and federation management.
*/
import { Controller, Get, Post, UseGuards, Logger } from "@nestjs/common";
import { FederationService } from "./federation.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { PublicInstanceIdentity, InstanceIdentity } from "./types/instance.types";
@Controller("api/v1/federation")
export class FederationController {
private readonly logger = new Logger(FederationController.name);
constructor(private readonly federationService: FederationService) {}
/**
* Get this instance's public identity
* No authentication required - this is public information for federation
*/
@Get("instance")
async getInstance(): Promise<PublicInstanceIdentity> {
this.logger.debug("GET /api/v1/federation/instance");
return this.federationService.getPublicIdentity();
}
/**
* Regenerate instance keypair
* Requires authentication - this is an admin operation
*/
@Post("instance/regenerate-keys")
@UseGuards(AuthGuard)
async regenerateKeys(): Promise<InstanceIdentity> {
this.logger.log("POST /api/v1/federation/instance/regenerate-keys");
return this.federationService.regenerateKeypair();
}
}

View File

@@ -0,0 +1,19 @@
/**
* Federation Module
*
* Provides instance identity and federation management.
*/
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { FederationController } from "./federation.controller";
import { FederationService } from "./federation.service";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [ConfigModule, PrismaModule],
controllers: [FederationController],
providers: [FederationService],
exports: [FederationService],
})
export class FederationModule {}

View File

@@ -0,0 +1,196 @@
/**
* Federation Service Tests
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { FederationService } from "./federation.service";
import { PrismaService } from "../prisma/prisma.service";
import { ConfigService } from "@nestjs/config";
import { Instance } from "@prisma/client";
describe("FederationService", () => {
let service: FederationService;
let prismaService: PrismaService;
let configService: ConfigService;
const mockInstance: Instance = {
id: "123e4567-e89b-12d3-a456-426614174000",
instanceId: "test-instance-id",
name: "Test Instance",
url: "https://test.example.com",
publicKey: "-----BEGIN PUBLIC KEY-----\nMOCK\n-----END PUBLIC KEY-----",
privateKey: "-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----",
capabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
protocolVersion: "1.0",
},
metadata: {},
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FederationService,
{
provide: PrismaService,
useValue: {
instance: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
},
{
provide: ConfigService,
useValue: {
get: vi.fn((key: string) => {
const config: Record<string, string> = {
INSTANCE_NAME: "Test Instance",
INSTANCE_URL: "https://test.example.com",
};
return config[key];
}),
},
},
],
}).compile();
service = module.get<FederationService>(FederationService);
prismaService = module.get<PrismaService>(PrismaService);
configService = module.get<ConfigService>(ConfigService);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("getInstanceIdentity", () => {
it("should return existing instance identity if found", async () => {
// Arrange
vi.spyOn(prismaService.instance, "findFirst").mockResolvedValue(mockInstance);
// Act
const result = await service.getInstanceIdentity();
// Assert
expect(result).toEqual(mockInstance);
expect(prismaService.instance.findFirst).toHaveBeenCalledTimes(1);
});
it("should create new instance identity if not found", async () => {
// Arrange
vi.spyOn(prismaService.instance, "findFirst").mockResolvedValue(null);
vi.spyOn(prismaService.instance, "create").mockResolvedValue(mockInstance);
vi.spyOn(service, "generateKeypair").mockReturnValue({
publicKey: mockInstance.publicKey,
privateKey: mockInstance.privateKey,
});
// Act
const result = await service.getInstanceIdentity();
// Assert
expect(result).toEqual(mockInstance);
expect(prismaService.instance.findFirst).toHaveBeenCalledTimes(1);
expect(service.generateKeypair).toHaveBeenCalledTimes(1);
expect(prismaService.instance.create).toHaveBeenCalledTimes(1);
});
it("should use config values for instance name and URL", async () => {
// Arrange
vi.spyOn(prismaService.instance, "findFirst").mockResolvedValue(null);
vi.spyOn(prismaService.instance, "create").mockResolvedValue(mockInstance);
vi.spyOn(service, "generateKeypair").mockReturnValue({
publicKey: mockInstance.publicKey,
privateKey: mockInstance.privateKey,
});
// Act
await service.getInstanceIdentity();
// Assert
expect(configService.get).toHaveBeenCalledWith("INSTANCE_NAME");
expect(configService.get).toHaveBeenCalledWith("INSTANCE_URL");
});
});
describe("getPublicIdentity", () => {
it("should return instance identity without private key", async () => {
// Arrange
vi.spyOn(service, "getInstanceIdentity").mockResolvedValue(mockInstance);
// Act
const result = await service.getPublicIdentity();
// Assert
expect(result).toEqual({
id: mockInstance.id,
instanceId: mockInstance.instanceId,
name: mockInstance.name,
url: mockInstance.url,
publicKey: mockInstance.publicKey,
capabilities: mockInstance.capabilities,
metadata: mockInstance.metadata,
createdAt: mockInstance.createdAt,
updatedAt: mockInstance.updatedAt,
});
expect(result).not.toHaveProperty("privateKey");
});
});
describe("generateKeypair", () => {
it("should generate valid RSA key pair", () => {
// Act
const result = service.generateKeypair();
// Assert
expect(result).toHaveProperty("publicKey");
expect(result).toHaveProperty("privateKey");
expect(result.publicKey).toContain("BEGIN PUBLIC KEY");
expect(result.privateKey).toContain("BEGIN PRIVATE KEY");
});
it("should generate different key pairs on each call", () => {
// Act
const result1 = service.generateKeypair();
const result2 = service.generateKeypair();
// Assert
expect(result1.publicKey).not.toEqual(result2.publicKey);
expect(result1.privateKey).not.toEqual(result2.privateKey);
});
});
describe("regenerateKeypair", () => {
it("should generate new keypair and update instance", async () => {
// Arrange
const updatedInstance = { ...mockInstance };
vi.spyOn(service, "getInstanceIdentity").mockResolvedValue(mockInstance);
vi.spyOn(service, "generateKeypair").mockReturnValue({
publicKey: "NEW_PUBLIC_KEY",
privateKey: "NEW_PRIVATE_KEY",
});
vi.spyOn(prismaService.instance, "update").mockResolvedValue(updatedInstance);
// Act
const result = await service.regenerateKeypair();
// Assert
expect(service.generateKeypair).toHaveBeenCalledTimes(1);
expect(prismaService.instance.update).toHaveBeenCalledWith({
where: { id: mockInstance.id },
data: {
publicKey: "NEW_PUBLIC_KEY",
privateKey: "NEW_PRIVATE_KEY",
},
});
expect(result).toEqual(updatedInstance);
});
});
});

View File

@@ -0,0 +1,157 @@
/**
* Federation Service
*
* Manages instance identity and federation connections.
*/
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Instance, Prisma } from "@prisma/client";
import { generateKeyPairSync } from "crypto";
import { randomUUID } from "crypto";
import { PrismaService } from "../prisma/prisma.service";
import {
InstanceIdentity,
PublicInstanceIdentity,
KeyPair,
FederationCapabilities,
} from "./types/instance.types";
@Injectable()
export class FederationService {
private readonly logger = new Logger(FederationService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService
) {}
/**
* Get the instance identity, creating it if it doesn't exist
*/
async getInstanceIdentity(): Promise<InstanceIdentity> {
// Try to find existing instance
let instance = await this.prisma.instance.findFirst();
if (!instance) {
this.logger.log("No instance identity found, creating new one");
instance = await this.createInstanceIdentity();
}
return this.mapToInstanceIdentity(instance);
}
/**
* Get public instance identity (without private key)
*/
async getPublicIdentity(): Promise<PublicInstanceIdentity> {
const instance = await this.getInstanceIdentity();
// Exclude private key from public identity
const { privateKey: _privateKey, ...publicIdentity } = instance;
return publicIdentity;
}
/**
* Generate a new RSA key pair for instance signing
*/
generateKeypair(): KeyPair {
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: {
type: "spki",
format: "pem",
},
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
},
});
return {
publicKey,
privateKey,
};
}
/**
* Regenerate the instance's keypair
*/
async regenerateKeypair(): Promise<InstanceIdentity> {
const instance = await this.getInstanceIdentity();
const { publicKey, privateKey } = this.generateKeypair();
const updatedInstance = await this.prisma.instance.update({
where: { id: instance.id },
data: {
publicKey,
privateKey,
},
});
this.logger.log("Instance keypair regenerated");
return this.mapToInstanceIdentity(updatedInstance);
}
/**
* Create a new instance identity
*/
private async createInstanceIdentity(): Promise<Instance> {
const { publicKey, privateKey } = this.generateKeypair();
const instanceId = this.generateInstanceId();
const name = this.config.get<string>("INSTANCE_NAME") ?? "Mosaic Instance";
const url = this.config.get<string>("INSTANCE_URL") ?? "http://localhost:3000";
const capabilities: FederationCapabilities = {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
supportsAgentSpawn: true,
protocolVersion: "1.0",
};
const instance = await this.prisma.instance.create({
data: {
instanceId,
name,
url,
publicKey,
privateKey,
capabilities: capabilities as Prisma.JsonObject,
metadata: {},
},
});
this.logger.log(`Created instance identity: ${instanceId}`);
return instance;
}
/**
* Generate a unique instance ID
*/
private generateInstanceId(): string {
return `instance-${randomUUID()}`;
}
/**
* Map Prisma Instance to InstanceIdentity type
*/
private mapToInstanceIdentity(instance: Instance): InstanceIdentity {
return {
id: instance.id,
instanceId: instance.instanceId,
name: instance.name,
url: instance.url,
publicKey: instance.publicKey,
privateKey: instance.privateKey,
capabilities: instance.capabilities as FederationCapabilities,
metadata: instance.metadata as Record<string, unknown>,
createdAt: instance.createdAt,
updatedAt: instance.updatedAt,
};
}
}

View File

@@ -0,0 +1,8 @@
/**
* Federation Module Exports
*/
export * from "./federation.module";
export * from "./federation.service";
export * from "./federation.controller";
export * from "./types/instance.types";

View File

@@ -0,0 +1,113 @@
/**
* Instance Identity Types
*
* Types for federation instance identity model.
*/
import type { FederationConnectionStatus } from "@prisma/client";
/**
* Capabilities that an instance can support
*/
export interface FederationCapabilities {
/** Supports QUERY message type */
supportsQuery?: boolean;
/** Supports COMMAND message type */
supportsCommand?: boolean;
/** Supports EVENT message type */
supportsEvent?: boolean;
/** Supports agent spawning */
supportsAgentSpawn?: boolean;
/** Supported protocol version */
protocolVersion?: string;
}
/**
* Instance identity information
*/
export interface InstanceIdentity {
/** Internal UUID */
id: string;
/** Federation identifier (unique across all instances) */
instanceId: string;
/** Display name for this instance */
name: string;
/** Base URL for this instance */
url: string;
/** RSA public key for signature verification */
publicKey: string;
/** Encrypted RSA private key for signing (not exposed in public identity) */
privateKey?: string;
/** Capabilities this instance supports */
capabilities: FederationCapabilities;
/** Additional metadata */
metadata: Record<string, unknown>;
/** Creation timestamp */
createdAt: Date;
/** Last update timestamp */
updatedAt: Date;
}
/**
* Public instance identity (excludes private key)
*/
export interface PublicInstanceIdentity {
/** Internal UUID */
id: string;
/** Federation identifier */
instanceId: string;
/** Display name */
name: string;
/** Base URL */
url: string;
/** RSA public key */
publicKey: string;
/** Capabilities */
capabilities: FederationCapabilities;
/** Additional metadata */
metadata: Record<string, unknown>;
/** Creation timestamp */
createdAt: Date;
/** Last update timestamp */
updatedAt: Date;
}
/**
* Federation connection information
*/
export interface FederationConnection {
/** Internal UUID */
id: string;
/** Workspace that owns this connection */
workspaceId: string;
/** Remote instance federation ID */
remoteInstanceId: string;
/** Remote instance base 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;
/** Timestamp when connection became active */
connectedAt: Date | null;
/** Timestamp when connection was disconnected */
disconnectedAt: Date | null;
}
/**
* Key pair for instance signing
*/
export interface KeyPair {
/** RSA public key (PEM format) */
publicKey: string;
/** RSA private key (PEM format) */
privateKey: string;
}