feat(federation): admin controller + CLI federation commands (FED-M2-08) #498
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Unit tests for FederationController (FED-M2-08).
|
||||
*
|
||||
* Coverage:
|
||||
* - listGrants: delegates to GrantsService with query params
|
||||
* - createGrant: delegates to GrantsService, validates body
|
||||
* - generateToken: returns enrollmentUrl containing the token
|
||||
* - listPeers: returns DB rows
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { FederationController } from '../federation.controller.js';
|
||||
import type { GrantsService } from '../grants.service.js';
|
||||
import type { EnrollmentService } from '../enrollment.service.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GRANT_ID = 'g1111111-1111-1111-1111-111111111111';
|
||||
const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
|
||||
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
|
||||
|
||||
const MOCK_GRANT = {
|
||||
id: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: { resources: ['tasks'], operations: ['list'] },
|
||||
status: 'pending' as const,
|
||||
expiresAt: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
};
|
||||
|
||||
const MOCK_PEER = {
|
||||
id: PEER_ID,
|
||||
commonName: 'test-peer',
|
||||
displayName: 'Test Peer',
|
||||
certPem: '',
|
||||
certSerial: 'pending',
|
||||
certNotAfter: new Date(0),
|
||||
clientKeyPem: null,
|
||||
state: 'pending' as const,
|
||||
endpointUrl: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB mock builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeDbMock(rows: unknown[] = []) {
|
||||
const orderBy = vi.fn().mockResolvedValue(rows);
|
||||
const where = vi.fn().mockReturnValue({ orderBy });
|
||||
const from = vi.fn().mockReturnValue({ where, orderBy });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
|
||||
return {
|
||||
select,
|
||||
from,
|
||||
where,
|
||||
orderBy,
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationController', () => {
|
||||
let db: Db;
|
||||
let grantsService: GrantsService;
|
||||
let enrollmentService: EnrollmentService;
|
||||
let controller: FederationController;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDbMock([MOCK_PEER]);
|
||||
|
||||
grantsService = {
|
||||
createGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
|
||||
getGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
|
||||
listGrants: vi.fn().mockResolvedValue([MOCK_GRANT]),
|
||||
revokeGrant: vi.fn().mockResolvedValue({ ...MOCK_GRANT, status: 'revoked' }),
|
||||
activateGrant: vi.fn(),
|
||||
expireGrant: vi.fn(),
|
||||
} as unknown as GrantsService;
|
||||
|
||||
enrollmentService = {
|
||||
createToken: vi.fn().mockResolvedValue({
|
||||
token: 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12',
|
||||
expiresAt: '2026-01-01T00:15:00.000Z',
|
||||
}),
|
||||
redeem: vi.fn(),
|
||||
} as unknown as EnrollmentService;
|
||||
|
||||
controller = new FederationController(db, grantsService, enrollmentService);
|
||||
});
|
||||
|
||||
// ─── Grant management ──────────────────────────────────────────────────
|
||||
|
||||
describe('listGrants', () => {
|
||||
it('delegates to GrantsService with provided query params', async () => {
|
||||
const query = { peerId: PEER_ID, status: 'pending' as const };
|
||||
const result = await controller.listGrants(query);
|
||||
|
||||
expect(grantsService.listGrants).toHaveBeenCalledWith(query);
|
||||
expect(result).toEqual([MOCK_GRANT]);
|
||||
});
|
||||
|
||||
it('delegates to GrantsService with empty filters', async () => {
|
||||
const result = await controller.listGrants({});
|
||||
|
||||
expect(grantsService.listGrants).toHaveBeenCalledWith({});
|
||||
expect(result).toEqual([MOCK_GRANT]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGrant', () => {
|
||||
it('delegates to GrantsService and returns created grant', async () => {
|
||||
const body = {
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: { resources: ['tasks'], operations: ['list'] },
|
||||
};
|
||||
|
||||
const result = await controller.createGrant(body);
|
||||
|
||||
expect(grantsService.createGrant).toHaveBeenCalledWith(body);
|
||||
expect(result).toEqual(MOCK_GRANT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGrant', () => {
|
||||
it('delegates to GrantsService with provided ID', async () => {
|
||||
const result = await controller.getGrant(GRANT_ID);
|
||||
|
||||
expect(grantsService.getGrant).toHaveBeenCalledWith(GRANT_ID);
|
||||
expect(result).toEqual(MOCK_GRANT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeGrant', () => {
|
||||
it('delegates to GrantsService with id and reason', async () => {
|
||||
const result = await controller.revokeGrant(GRANT_ID, { reason: 'test reason' });
|
||||
|
||||
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, 'test reason');
|
||||
expect(result).toMatchObject({ status: 'revoked' });
|
||||
});
|
||||
|
||||
it('delegates without reason when omitted', async () => {
|
||||
await controller.revokeGrant(GRANT_ID, {});
|
||||
|
||||
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateToken', () => {
|
||||
it('returns enrollmentUrl containing the token', async () => {
|
||||
const token = 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12';
|
||||
vi.mocked(enrollmentService.createToken).mockResolvedValueOnce({
|
||||
token,
|
||||
expiresAt: '2026-01-01T00:15:00.000Z',
|
||||
});
|
||||
|
||||
const result = await controller.generateToken(GRANT_ID, { ttlSeconds: 900 });
|
||||
|
||||
expect(result.token).toBe(token);
|
||||
expect(result.enrollmentUrl).toContain(token);
|
||||
expect(result.enrollmentUrl).toContain('/api/federation/enrollment/');
|
||||
});
|
||||
|
||||
it('creates token via EnrollmentService with correct grantId and peerId', async () => {
|
||||
await controller.generateToken(GRANT_ID, { ttlSeconds: 300 });
|
||||
|
||||
expect(enrollmentService.createToken).toHaveBeenCalledWith({
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
ttlSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundException when grant does not exist', async () => {
|
||||
vi.mocked(grantsService.getGrant).mockRejectedValueOnce(
|
||||
new NotFoundException(`Grant ${GRANT_ID} not found`),
|
||||
);
|
||||
|
||||
await expect(controller.generateToken(GRANT_ID, { ttlSeconds: 900 })).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Peer management ───────────────────────────────────────────────────
|
||||
|
||||
describe('listPeers', () => {
|
||||
it('returns DB rows ordered by commonName', async () => {
|
||||
const result = await controller.listPeers();
|
||||
|
||||
expect(db.select).toHaveBeenCalled();
|
||||
// The DB mock resolves with [MOCK_PEER]
|
||||
expect(result).toEqual([MOCK_PEER]);
|
||||
});
|
||||
});
|
||||
});
|
||||
39
apps/gateway/src/federation/federation-admin.dto.ts
Normal file
39
apps/gateway/src/federation/federation-admin.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* DTOs for the federation admin controller (FED-M2-08).
|
||||
*/
|
||||
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Max, Min } from 'class-validator';
|
||||
|
||||
export class CreatePeerKeypairDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
commonName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
displayName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
endpointUrl?: string;
|
||||
}
|
||||
|
||||
export class StorePeerCertDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
certPem!: string;
|
||||
}
|
||||
|
||||
export class GenerateEnrollmentTokenDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(60)
|
||||
@Max(900)
|
||||
ttlSeconds: number = 900;
|
||||
}
|
||||
|
||||
export class RevokeGrantBodyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
266
apps/gateway/src/federation/federation.controller.ts
Normal file
266
apps/gateway/src/federation/federation.controller.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* FederationController — admin REST API for federation management (FED-M2-08).
|
||||
*
|
||||
* Routes (all under /api/admin/federation, all require AdminGuard):
|
||||
*
|
||||
* Grant management:
|
||||
* POST /api/admin/federation/grants
|
||||
* GET /api/admin/federation/grants
|
||||
* GET /api/admin/federation/grants/:id
|
||||
* PATCH /api/admin/federation/grants/:id/revoke
|
||||
* POST /api/admin/federation/grants/:id/tokens
|
||||
*
|
||||
* Peer management:
|
||||
* GET /api/admin/federation/peers
|
||||
* POST /api/admin/federation/peers/keypair
|
||||
* PATCH /api/admin/federation/peers/:id/cert
|
||||
*
|
||||
* NOTE: The enrollment REDEMPTION endpoint (POST /api/federation/enrollment/:token)
|
||||
* is handled by EnrollmentController — not duplicated here.
|
||||
*/
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
|
||||
import { type Db, eq, federationPeers } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { sealClientKey } from './peer-key.util.js';
|
||||
import { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
||||
import {
|
||||
CreatePeerKeypairDto,
|
||||
GenerateEnrollmentTokenDto,
|
||||
RevokeGrantBodyDto,
|
||||
StorePeerCertDto,
|
||||
} from './federation-admin.dto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer to a Base64 string (for PEM encoding).
|
||||
*/
|
||||
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
for (const b of bytes) {
|
||||
binary += String.fromCharCode(b);
|
||||
}
|
||||
return Buffer.from(binary, 'binary').toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Base64 string in PEM armour.
|
||||
*/
|
||||
function toPem(label: string, b64: string): string {
|
||||
const lines = b64.match(/.{1,64}/g) ?? [];
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Controller('api/admin/federation')
|
||||
@UseGuards(AdminGuard)
|
||||
export class FederationController {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(GrantsService) private readonly grantsService: GrantsService,
|
||||
@Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService,
|
||||
) {}
|
||||
|
||||
// ─── Grant management ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/admin/federation/grants
|
||||
* Create a new grant in pending state.
|
||||
*/
|
||||
@Post('grants')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createGrant(@Body() body: CreateGrantDto) {
|
||||
return this.grantsService.createGrant(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/federation/grants
|
||||
* List grants with optional filters.
|
||||
*/
|
||||
@Get('grants')
|
||||
async listGrants(@Query() query: ListGrantsDto) {
|
||||
return this.grantsService.listGrants(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/federation/grants/:id
|
||||
* Get a single grant by ID.
|
||||
*/
|
||||
@Get('grants/:id')
|
||||
async getGrant(@Param('id') id: string) {
|
||||
return this.grantsService.getGrant(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/federation/grants/:id/revoke
|
||||
* Revoke an active grant.
|
||||
*/
|
||||
@Patch('grants/:id/revoke')
|
||||
async revokeGrant(@Param('id') id: string, @Body() body: RevokeGrantBodyDto) {
|
||||
return this.grantsService.revokeGrant(id, body.reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/federation/grants/:id/tokens
|
||||
* Generate a single-use enrollment token for a pending grant.
|
||||
* Returns the token plus an enrollmentUrl the operator shares out-of-band.
|
||||
*/
|
||||
@Post('grants/:id/tokens')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async generateToken(@Param('id') id: string, @Body() body: GenerateEnrollmentTokenDto) {
|
||||
const grant = await this.grantsService.getGrant(id);
|
||||
|
||||
const result = await this.enrollmentService.createToken({
|
||||
grantId: id,
|
||||
peerId: grant.peerId,
|
||||
ttlSeconds: body.ttlSeconds ?? 900,
|
||||
});
|
||||
|
||||
const baseUrl = process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242';
|
||||
const enrollmentUrl = `${baseUrl}/api/federation/enrollment/${result.token}`;
|
||||
|
||||
return {
|
||||
token: result.token,
|
||||
expiresAt: result.expiresAt,
|
||||
enrollmentUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Peer management ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/admin/federation/peers
|
||||
* List all federation peer rows.
|
||||
*/
|
||||
@Get('peers')
|
||||
async listPeers() {
|
||||
return this.db.select().from(federationPeers).orderBy(federationPeers.commonName);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/federation/peers/keypair
|
||||
* Generate a new peer entry with EC P-256 key pair and a PKCS#10 CSR.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Generate EC P-256 key pair via webcrypto
|
||||
* 2. Generate a self-signed CSR via @peculiar/x509
|
||||
* 3. Export private key as PEM
|
||||
* 4. sealClientKey(privatePem) → sealed blob
|
||||
* 5. Insert pending peer row
|
||||
* 6. Return { peerId, csrPem }
|
||||
*/
|
||||
@Post('peers/keypair')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createPeerKeypair(@Body() body: CreatePeerKeypairDto) {
|
||||
// 1. Generate EC P-256 key pair via Web Crypto
|
||||
const keyPair = await webcrypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true, // extractable
|
||||
['sign', 'verify'],
|
||||
);
|
||||
|
||||
// 2. Generate PKCS#10 CSR
|
||||
const csr = await Pkcs10CertificateRequestGenerator.create({
|
||||
name: `CN=${body.commonName}`,
|
||||
keys: keyPair,
|
||||
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
||||
});
|
||||
|
||||
const csrPem = csr.toString('pem');
|
||||
|
||||
// 3. Export private key as PKCS#8 PEM
|
||||
const pkcs8Der = await webcrypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
const privatePem = toPem('PRIVATE KEY', arrayBufferToBase64(pkcs8Der));
|
||||
|
||||
// 4. Seal the private key
|
||||
const sealed = sealClientKey(privatePem);
|
||||
|
||||
// 5. Insert pending peer row
|
||||
const [peer] = await this.db
|
||||
.insert(federationPeers)
|
||||
.values({
|
||||
commonName: body.commonName,
|
||||
displayName: body.displayName,
|
||||
certPem: '',
|
||||
certSerial: 'pending',
|
||||
certNotAfter: new Date(0),
|
||||
clientKeyPem: sealed,
|
||||
state: 'pending',
|
||||
endpointUrl: body.endpointUrl,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
peerId: peer!.id,
|
||||
csrPem,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/federation/peers/:id/cert
|
||||
* Store a signed certificate after enrollment completes.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Parse the cert to extract serial and notAfter
|
||||
* 2. Update the peer row with cert data + state='active'
|
||||
* 3. Return the updated peer row
|
||||
*/
|
||||
@Patch('peers/:id/cert')
|
||||
async storePeerCert(@Param('id') id: string, @Body() body: StorePeerCertDto) {
|
||||
// Ensure peer exists
|
||||
const [existing] = await this.db
|
||||
.select({ id: federationPeers.id })
|
||||
.from(federationPeers)
|
||||
.where(eq(federationPeers.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Peer ${id} not found`);
|
||||
}
|
||||
|
||||
// 1. Parse cert
|
||||
const x509 = new X509Certificate(body.certPem);
|
||||
const certSerial = x509.serialNumber;
|
||||
const certNotAfter = new Date(x509.validTo);
|
||||
|
||||
// 2. Update peer
|
||||
const [updated] = await this.db
|
||||
.update(federationPeers)
|
||||
.set({
|
||||
certPem: body.certPem,
|
||||
certSerial,
|
||||
certNotAfter,
|
||||
state: 'active',
|
||||
})
|
||||
.where(eq(federationPeers.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { CaService } from './ca.service.js';
|
||||
import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { FederationController } from './federation.controller.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [EnrollmentController],
|
||||
controllers: [EnrollmentController, FederationController],
|
||||
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
|
||||
exports: [CaService, EnrollmentService, GrantsService],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
410
packages/mosaic/src/commands/federation.ts
Normal file
410
packages/mosaic/src/commands/federation.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user