Files
stack/apps/web/src/lib/api/federation.ts
T
Jason WoltjeandClaude Sonnet 4.5 0495f979a7 feat(#94): implement spoke configuration UI
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]>
2026-02-03 14:51:59 -06:00

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(),
},
];