feat(federation): admin controller + mosaic CLI federation commands (FED-M2-08)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

Implements the two halves of FED-M2-08:

Gateway (apps/gateway/src/federation/):
- federation-admin.dto.ts: CreatePeerKeypairDto, StorePeerCertDto,
  GenerateEnrollmentTokenDto, RevokeGrantBodyDto
- federation.controller.ts: FederationController under
  /api/admin/federation with AdminGuard on all routes.
  Grant CRUD (create, list, get, revoke) delegating to GrantsService.
  Token generation delegating to EnrollmentService + returning enrollmentUrl.
  Peer listing via direct DB query.
  Peer keypair generation via webcrypto + @peculiar/x509 CSR generation.
  Peer cert storage with X509Certificate serial/notAfter extraction.
- federation.module.ts: register FederationController

CLI (packages/mosaic/src/commands/federation.ts):
- mosaic federation (alias: fed) command group
- grant create/list/show/revoke/token subcommands
- peer list/add subcommands (add runs full enrollment flow)
- Admin token resolved from -t flag or meta.json adminToken
- packages/mosaic/src/cli.ts: register registerFederationCommand

Tests (apps/gateway/src/federation/__tests__/federation.controller.spec.ts):
- listGrants, createGrant, generateToken, listPeers coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jarvis
2026-04-21 23:32:14 -05:00
parent 0bfaa56e9e
commit 8eff67a2bf
6 changed files with 934 additions and 1 deletions

View File

@@ -18,6 +18,7 @@ import { registerUninstallCommand } from './commands/uninstall.js';
// prdy is registered via launch.ts
import { registerLaunchCommands } from './commands/launch.js';
import { registerAuthCommand } from './commands/auth.js';
import { registerFederationCommand } from './commands/federation.js';
import { registerGatewayCommand } from './commands/gateway.js';
import {
backgroundUpdateCheck,
@@ -336,6 +337,10 @@ registerAuthCommand(program);
registerGatewayCommand(program);
// ─── federation ───────────────────────────────────────────────────────
registerFederationCommand(program);
// ─── agent ─────────────────────────────────────────────────────────────
registerAgentCommand(program);

View File

@@ -0,0 +1,410 @@
/**
* `mosaic federation` command group — federation grant + peer management (FED-M2-08).
*
* All HTTP calls go to the local gateway admin API using an admin token
* resolved from CLI options or meta.json.
*
* Subcommands:
* grant create --peer-id <uuid> --user-id <uuid> --scope <json> [--expires-at <iso>]
* grant list [--peer-id <uuid>] [--user-id <uuid>] [--status pending|active|revoked|expired]
* grant show <id>
* grant revoke <id> [--reason <text>]
* grant token <id> [--ttl 900]
*
* peer list
* peer add <enrollment-url>
*/
import type { Command } from 'commander';
import { readMeta } from './gateway/daemon.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface FedParentOpts {
host: string;
port: string;
token?: string;
json?: boolean;
}
interface ResolvedOpts {
baseUrl: string;
token?: string;
json: boolean;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function resolveOpts(raw: FedParentOpts): ResolvedOpts {
const meta = readMeta();
const host = raw.host ?? meta?.host ?? 'localhost';
const port = parseInt(raw.port, 10) || meta?.port || 14242;
const token = raw.token ?? meta?.adminToken;
return {
baseUrl: `http://${host}:${port.toString()}`,
token,
json: raw.json ?? false,
};
}
function requireToken(opts: ResolvedOpts): string {
if (!opts.token) {
console.error(
'Error: admin token required. Use -t/--token <token> or ensure meta.json has adminToken.',
);
process.exit(1);
}
return opts.token;
}
async function apiRequest<T>(
opts: ResolvedOpts,
method: string,
path: string,
body?: unknown,
): Promise<T> {
const token = requireToken(opts);
const url = `${opts.baseUrl}${path}`;
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) {
let message = text;
try {
const parsed = JSON.parse(text) as { message?: string };
message = parsed.message ?? text;
} catch {
// use raw text
}
throw new Error(`HTTP ${res.status.toString()}: ${message}`);
}
if (!text) return undefined as unknown as T;
return JSON.parse(text) as T;
}
function printJson(data: unknown, useJson: boolean): void {
if (useJson) {
console.log(JSON.stringify(data, null, 2));
}
}
function printTable(rows: Record<string, unknown>[]): void {
if (rows.length === 0) {
console.log('(none)');
return;
}
for (const row of rows) {
for (const [key, val] of Object.entries(row)) {
console.log(` ${key}: ${String(val ?? '')}`);
}
console.log('');
}
}
// ---------------------------------------------------------------------------
// Command registration
// ---------------------------------------------------------------------------
export function registerFederationCommand(program: Command): void {
const fed = program
.command('federation')
.alias('fed')
.description('Manage federation grants and peers')
.option('-h, --host <host>', 'Gateway host', 'localhost')
.option('-p, --port <port>', 'Gateway port', '14242')
.option('-t, --token <token>', 'Admin token')
.option('--json', 'Machine-readable JSON output')
.action(() => fed.outputHelp());
// ─── grant subcommands ─────────────────────────────────────────────────
const grant = fed
.command('grant')
.description('Manage federation grants')
.action(() => grant.outputHelp());
grant
.command('create')
.description('Create a new federation grant')
.requiredOption('--peer-id <uuid>', 'Peer UUID')
.requiredOption('--user-id <uuid>', 'Subject user UUID')
.requiredOption('--scope <json>', 'Grant scope as JSON string')
.option('--expires-at <iso>', 'Optional expiry (ISO 8601)')
.action(
async (cmdOpts: { peerId: string; userId: string; scope: string; expiresAt?: string }) => {
const opts = resolveOpts(fed.opts() as FedParentOpts);
try {
let scope: Record<string, unknown>;
try {
scope = JSON.parse(cmdOpts.scope) as Record<string, unknown>;
} catch {
console.error('Error: --scope must be valid JSON');
process.exit(1);
}
const body: Record<string, unknown> = {
peerId: cmdOpts.peerId,
subjectUserId: cmdOpts.userId,
scope,
};
if (cmdOpts.expiresAt) body['expiresAt'] = cmdOpts.expiresAt;
const result = await apiRequest<Record<string, unknown>>(
opts,
'POST',
'/api/admin/federation/grants',
body,
);
if (opts.json) {
printJson(result, true);
} else {
console.log(`Grant created: ${String(result['id'])}`);
console.log(` Peer: ${String(result['peerId'])}`);
console.log(` User: ${String(result['subjectUserId'])}`);
console.log(` Status: ${String(result['status'])}`);
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
},
);
grant
.command('list')
.description('List federation grants')
.option('--peer-id <uuid>', 'Filter by peer UUID')
.option('--user-id <uuid>', 'Filter by subject user UUID')
.option('--status <status>', 'Filter by status (pending|active|revoked|expired)')
.action(async (cmdOpts: { peerId?: string; userId?: string; status?: string }) => {
const opts = resolveOpts(fed.opts() as FedParentOpts);
try {
const params = new URLSearchParams();
if (cmdOpts.peerId) params.set('peerId', cmdOpts.peerId);
if (cmdOpts.userId) params.set('subjectUserId', cmdOpts.userId);
if (cmdOpts.status) params.set('status', cmdOpts.status);
const qs = params.toString() ? `?${params.toString()}` : '';
const result = await apiRequest<Record<string, unknown>[]>(
opts,
'GET',
`/api/admin/federation/grants${qs}`,
);
if (opts.json) {
printJson(result, true);
} else {
console.log(`Grants (${result.length.toString()}):\n`);
printTable(result);
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
grant
.command('show <id>')
.description('Get a single grant by ID')
.action(async (id: string) => {
const opts = resolveOpts(fed.opts() as FedParentOpts);
try {
const result = await apiRequest<Record<string, unknown>>(
opts,
'GET',
`/api/admin/federation/grants/${id}`,
);
if (opts.json) {
printJson(result, true);
} else {
for (const [key, val] of Object.entries(result)) {
console.log(` ${key}: ${String(val ?? '')}`);
}
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
grant
.command('revoke <id>')
.description('Revoke an active grant')
.option('--reason <text>', 'Revocation reason')
.action(async (id: string, cmdOpts: { reason?: string }) => {
const opts = resolveOpts(fed.opts() as FedParentOpts);
try {
const body: Record<string, unknown> = {};
if (cmdOpts.reason) body['reason'] = cmdOpts.reason;
const result = await apiRequest<Record<string, unknown>>(
opts,
'PATCH',
`/api/admin/federation/grants/${id}/revoke`,
body,
);
if (opts.json) {
printJson(result, true);
} else {
console.log(`Grant ${id} revoked.`);
if (result['revokedReason']) console.log(` Reason: ${String(result['revokedReason'])}`);
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
grant
.command('token <id>')
.description('Generate a single-use enrollment token for a grant')
.option('--ttl <seconds>', 'Token lifetime in seconds (60-900)', '900')
.action(async (id: string, cmdOpts: { ttl: string }) => {
const opts = resolveOpts(fed.opts() as FedParentOpts);
try {
const ttlSeconds = parseInt(cmdOpts.ttl, 10) || 900;
const result = await apiRequest<{
token: string;
expiresAt: string;
enrollmentUrl: string;
}>(opts, 'POST', `/api/admin/federation/grants/${id}/tokens`, { ttlSeconds });
if (opts.json) {
printJson(result, true);
} else {
console.log('Enrollment token generated:');
console.log(` Token: ${result.token}`);
console.log(` Expires at: ${result.expiresAt}`);
console.log(` Enrollment URL: ${result.enrollmentUrl}`);
console.log('');
console.log('Share the enrollment URL with the remote peer operator.');
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── peer subcommands ──────────────────────────────────────────────────
const peer = fed
.command('peer')
.description('Manage federation peers')
.action(() => peer.outputHelp());
peer
.command('list')
.description('List all federation peers')
.action(async () => {
const opts = resolveOpts(fed.opts() as FedParentOpts);
try {
const result = await apiRequest<Record<string, unknown>[]>(
opts,
'GET',
'/api/admin/federation/peers',
);
if (opts.json) {
printJson(result, true);
} else {
console.log(`Peers (${result.length.toString()}):\n`);
printTable(result);
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
peer
.command('add <enrollment-url>')
.description('Enroll as a peer using a remote enrollment URL')
.action(async (enrollmentUrl: string) => {
const opts = resolveOpts(fed.opts() as FedParentOpts);
try {
// 1. Validate enrollment URL
let parsedUrl: URL;
try {
parsedUrl = new URL(enrollmentUrl);
} catch {
console.error(`Error: invalid enrollment URL: ${enrollmentUrl}`);
process.exit(1);
}
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
console.error('Error: enrollment URL must use http or https');
process.exit(1);
}
const hostname = parsedUrl.hostname;
const commonName = hostname.replace(/\./g, '-');
console.log(`Enrolling as peer with remote: ${enrollmentUrl}`);
console.log(` Common name: ${commonName}`);
// 2. Generate key pair and CSR via local gateway
console.log('Generating key pair and CSR...');
const keypairResult = await apiRequest<{ peerId: string; csrPem: string }>(
opts,
'POST',
'/api/admin/federation/peers/keypair',
{ commonName, displayName: hostname },
);
const { peerId, csrPem } = keypairResult;
console.log(` Peer ID: ${peerId}`);
// 3. Submit CSR to remote enrollment endpoint
console.log('Submitting CSR to remote enrollment endpoint...');
const remoteRes = await fetch(enrollmentUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ csrPem }),
});
if (!remoteRes.ok) {
const errText = await remoteRes.text();
throw new Error(`Remote enrollment failed (${remoteRes.status.toString()}): ${errText}`);
}
const remoteResult = (await remoteRes.json()) as { certPem: string; certChainPem: string };
if (!remoteResult.certPem) {
throw new Error('Remote enrollment response missing certPem');
}
// 4. Store the signed certificate in the local gateway
console.log('Storing signed certificate...');
await apiRequest<Record<string, unknown>>(
opts,
'PATCH',
`/api/admin/federation/peers/${peerId}/cert`,
{ certPem: remoteResult.certPem },
);
console.log(`\nPeer enrolled successfully.`);
console.log(` ID: ${peerId}`);
console.log(` State: active`);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
}