Implements the final piece of M7-Federation - the spoke configuration UI that allows administrators to configure their local instance's federation capabilities and settings. Backend Changes: - Add UpdateInstanceDto with validation for name, capabilities, and metadata - Implement FederationService.updateInstanceConfiguration() method - Add PATCH /api/v1/federation/instance endpoint to FederationController - Add audit logging for configuration updates - Add tests for updateInstanceConfiguration (5 new tests, all passing) Frontend Changes: - Create SpokeConfigurationForm component with PDA-friendly design - Create /federation/settings page with configuration management - Add regenerate keypair functionality with confirmation dialog - Extend federation API client with updateInstanceConfiguration and regenerateInstanceKeys - Add comprehensive tests (10 tests, all passing) Design Decisions: - Admin-only access via AdminGuard - Never expose private key in API responses (security) - PDA-friendly language throughout (no demanding terms) - Clear visual hierarchy with read-only and editable fields - Truncated public key with copy button for usability - Confirmation dialog for destructive key regeneration All tests passing: - Backend: 13/13 federation service tests passing - Frontend: 10/10 SpokeConfigurationForm tests passing - TypeScript compilation: passing - Linting: passing - PDA-friendliness: verified This completes M7-Federation. All federation features are now implemented. Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
273 lines
6.6 KiB
TypeScript
273 lines
6.6 KiB
TypeScript
/**
|
|
* Federation API Client
|
|
* Handles federation connection management API requests
|
|
*/
|
|
|
|
import { apiGet, apiPost, apiPatch } from "./client";
|
|
|
|
/**
|
|
* Federation connection status
|
|
*/
|
|
export enum FederationConnectionStatus {
|
|
PENDING = "PENDING",
|
|
ACTIVE = "ACTIVE",
|
|
DISCONNECTED = "DISCONNECTED",
|
|
REJECTED = "REJECTED",
|
|
}
|
|
|
|
/**
|
|
* Federation capabilities
|
|
*/
|
|
export interface FederationCapabilities {
|
|
supportsQuery: boolean;
|
|
supportsCommand: boolean;
|
|
supportsEvent: boolean;
|
|
supportsAgentSpawn: boolean;
|
|
protocolVersion: string;
|
|
}
|
|
|
|
/**
|
|
* Connection details
|
|
*/
|
|
export interface ConnectionDetails {
|
|
id: string;
|
|
workspaceId: string;
|
|
remoteInstanceId: string;
|
|
remoteUrl: string;
|
|
remotePublicKey: string;
|
|
remoteCapabilities: FederationCapabilities;
|
|
status: FederationConnectionStatus;
|
|
metadata: Record<string, unknown>;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
connectedAt: string | null;
|
|
disconnectedAt: string | null;
|
|
}
|
|
|
|
/**
|
|
* Public instance identity
|
|
*/
|
|
export interface PublicInstanceIdentity {
|
|
id: string;
|
|
instanceId: string;
|
|
name: string;
|
|
url: string;
|
|
publicKey: string;
|
|
capabilities: FederationCapabilities;
|
|
metadata: Record<string, unknown>;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/**
|
|
* Initiate connection request
|
|
*/
|
|
export interface InitiateConnectionRequest {
|
|
remoteUrl: string;
|
|
}
|
|
|
|
/**
|
|
* Accept connection request
|
|
*/
|
|
export interface AcceptConnectionRequest {
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Reject connection request
|
|
*/
|
|
export interface RejectConnectionRequest {
|
|
reason: string;
|
|
}
|
|
|
|
/**
|
|
* Disconnect connection request
|
|
*/
|
|
export interface DisconnectConnectionRequest {
|
|
reason?: string;
|
|
}
|
|
|
|
/**
|
|
* Fetch all connections
|
|
*/
|
|
export async function fetchConnections(
|
|
status?: FederationConnectionStatus
|
|
): Promise<ConnectionDetails[]> {
|
|
const params = new URLSearchParams();
|
|
|
|
if (status) {
|
|
params.append("status", status);
|
|
}
|
|
|
|
const queryString = params.toString();
|
|
const endpoint = queryString
|
|
? `/api/v1/federation/connections?${queryString}`
|
|
: "/api/v1/federation/connections";
|
|
|
|
return apiGet<ConnectionDetails[]>(endpoint);
|
|
}
|
|
|
|
/**
|
|
* Fetch a single connection
|
|
*/
|
|
export async function fetchConnection(connectionId: string): Promise<ConnectionDetails> {
|
|
return apiGet<ConnectionDetails>(`/api/v1/federation/connections/${connectionId}`);
|
|
}
|
|
|
|
/**
|
|
* Initiate a new connection
|
|
*/
|
|
export async function initiateConnection(
|
|
request: InitiateConnectionRequest
|
|
): Promise<ConnectionDetails> {
|
|
return apiPost<ConnectionDetails>("/api/v1/federation/connections/initiate", request);
|
|
}
|
|
|
|
/**
|
|
* Accept a pending connection
|
|
*/
|
|
export async function acceptConnection(
|
|
connectionId: string,
|
|
request?: AcceptConnectionRequest
|
|
): Promise<ConnectionDetails> {
|
|
return apiPost<ConnectionDetails>(
|
|
`/api/v1/federation/connections/${connectionId}/accept`,
|
|
request ?? {}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reject a pending connection
|
|
*/
|
|
export async function rejectConnection(
|
|
connectionId: string,
|
|
request: RejectConnectionRequest
|
|
): Promise<ConnectionDetails> {
|
|
return apiPost<ConnectionDetails>(
|
|
`/api/v1/federation/connections/${connectionId}/reject`,
|
|
request
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Disconnect an active connection
|
|
*/
|
|
export async function disconnectConnection(
|
|
connectionId: string,
|
|
request?: DisconnectConnectionRequest
|
|
): Promise<ConnectionDetails> {
|
|
return apiPost<ConnectionDetails>(
|
|
`/api/v1/federation/connections/${connectionId}/disconnect`,
|
|
request ?? {}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get this instance's public identity
|
|
*/
|
|
export async function fetchInstanceIdentity(): Promise<PublicInstanceIdentity> {
|
|
return apiGet<PublicInstanceIdentity>("/api/v1/federation/instance");
|
|
}
|
|
|
|
/**
|
|
* Update instance configuration request
|
|
*/
|
|
export interface UpdateInstanceRequest {
|
|
name?: string;
|
|
capabilities?: FederationCapabilities;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Update this instance's configuration
|
|
* Admin-only operation
|
|
*/
|
|
export async function updateInstanceConfiguration(
|
|
updates: UpdateInstanceRequest
|
|
): Promise<PublicInstanceIdentity> {
|
|
return apiPatch<PublicInstanceIdentity>("/api/v1/federation/instance", updates);
|
|
}
|
|
|
|
/**
|
|
* Regenerate instance keypair
|
|
* Admin-only operation
|
|
*/
|
|
export async function regenerateInstanceKeys(): Promise<PublicInstanceIdentity> {
|
|
return apiPost<PublicInstanceIdentity>("/api/v1/federation/instance/regenerate-keys", {});
|
|
}
|
|
|
|
/**
|
|
* Mock connections for development
|
|
*/
|
|
export const mockConnections: ConnectionDetails[] = [
|
|
{
|
|
id: "conn-1",
|
|
workspaceId: "workspace-1",
|
|
remoteInstanceId: "instance-work-001",
|
|
remoteUrl: "https://mosaic.work.example.com",
|
|
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
|
|
remoteCapabilities: {
|
|
supportsQuery: true,
|
|
supportsCommand: true,
|
|
supportsEvent: true,
|
|
supportsAgentSpawn: true,
|
|
protocolVersion: "1.0",
|
|
},
|
|
status: FederationConnectionStatus.ACTIVE,
|
|
metadata: {
|
|
name: "Work Instance",
|
|
description: "Corporate Mosaic instance",
|
|
},
|
|
createdAt: new Date("2026-02-01").toISOString(),
|
|
updatedAt: new Date("2026-02-01").toISOString(),
|
|
connectedAt: new Date("2026-02-01").toISOString(),
|
|
disconnectedAt: null,
|
|
},
|
|
{
|
|
id: "conn-2",
|
|
workspaceId: "workspace-1",
|
|
remoteInstanceId: "instance-partner-001",
|
|
remoteUrl: "https://mosaic.partner.example.com",
|
|
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
|
|
remoteCapabilities: {
|
|
supportsQuery: true,
|
|
supportsCommand: false,
|
|
supportsEvent: true,
|
|
supportsAgentSpawn: false,
|
|
protocolVersion: "1.0",
|
|
},
|
|
status: FederationConnectionStatus.PENDING,
|
|
metadata: {
|
|
name: "Partner Instance",
|
|
description: "Shared project collaboration",
|
|
},
|
|
createdAt: new Date("2026-02-02").toISOString(),
|
|
updatedAt: new Date("2026-02-02").toISOString(),
|
|
connectedAt: null,
|
|
disconnectedAt: null,
|
|
},
|
|
{
|
|
id: "conn-3",
|
|
workspaceId: "workspace-1",
|
|
remoteInstanceId: "instance-old-001",
|
|
remoteUrl: "https://mosaic.old.example.com",
|
|
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
|
|
remoteCapabilities: {
|
|
supportsQuery: true,
|
|
supportsCommand: true,
|
|
supportsEvent: false,
|
|
supportsAgentSpawn: false,
|
|
protocolVersion: "1.0",
|
|
},
|
|
status: FederationConnectionStatus.DISCONNECTED,
|
|
metadata: {
|
|
name: "Previous Instance",
|
|
description: "No longer in use",
|
|
},
|
|
createdAt: new Date("2026-01-15").toISOString(),
|
|
updatedAt: new Date("2026-01-30").toISOString(),
|
|
connectedAt: new Date("2026-01-15").toISOString(),
|
|
disconnectedAt: new Date("2026-01-30").toISOString(),
|
|
},
|
|
];
|