From 0b32dc8e3c479d4750479aa10b0260967b3e94cc Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 14 May 2026 14:43:59 +0000 Subject: [PATCH] feat(cli): move user and guild registration from API to local CLI --- package.json | 1 + src/auth/auth.controller.ts | 6 --- src/cli.ts | 60 +++++++++++++++++++++ src/common/center-api-key.guard.ts | 5 -- src/nodes/node-admin.service.ts | 59 +++++++++++++++++++++ src/nodes/nodes.controller.ts | 84 ------------------------------ src/nodes/nodes.module.ts | 3 ++ 7 files changed, 123 insertions(+), 95 deletions(-) create mode 100644 src/cli.ts create mode 100644 src/nodes/node-admin.service.ts diff --git a/package.json b/package.json index 162bac5..bcdf698 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "Fabric Identity Hub (Center service)", "scripts": { "build": "tsc -p tsconfig.build.json", + "cli": "node dist/cli.js", "start": "node dist/main.js", "start:dev": "ts-node src/main.ts", "lint": "eslint 'src/**/*.ts'", diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 85b5e24..0f8adf1 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,6 +1,5 @@ import { Body, Controller, Get, Headers, Post, UnauthorizedException } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { RegisterDto } from './dto.register.dto'; import { LoginDto } from './dto.login.dto'; import { RefreshDto } from './dto.refresh.dto'; import { LogoutDto } from './dto.logout.dto'; @@ -9,11 +8,6 @@ import { LogoutDto } from './dto.logout.dto'; export class AuthController { constructor(private readonly authService: AuthService) {} - @Post('register') - register(@Body() body: RegisterDto) { - return this.authService.register(body); - } - @Post('login') login(@Body() body: LoginDto) { return this.authService.login(body); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..985e1ff --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,60 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { AuthService } from './auth/auth.service'; +import { NodeAdminService } from './nodes/node-admin.service'; + +function getArg(flag: string): string | null { + const idx = process.argv.indexOf(flag); + if (idx === -1) return null; + return process.argv[idx + 1] ?? null; +} + +function printUsageAndExit(): never { + console.error('Usage:'); + console.error(' node dist/cli.js user create --email --password '); + console.error(' node dist/cli.js node register --node-id --name --endpoint '); + process.exit(1); +} + +async function main() { + const [subject, action] = process.argv.slice(2); + if (!subject || !action) printUsageAndExit(); + + const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] }); + try { + if (subject === 'user' && action === 'create') { + const email = getArg('--email'); + const password = getArg('--password'); + if (!email || !password) printUsageAndExit(); + + const auth = app.get(AuthService); + const user = await auth.register({ email, password }); + process.stdout.write(JSON.stringify({ ok: true, user }) + '\n'); + return; + } + + if (subject === 'node' && action === 'register') { + const nodeId = getArg('--node-id'); + const name = getArg('--name'); + const endpoint = getArg('--endpoint'); + if (!nodeId || !name || !endpoint) printUsageAndExit(); + + const nodes = app.get(NodeAdminService); + const result = await nodes.registerNode({ nodeId, name, endpoint }); + process.stdout.write(JSON.stringify({ ok: true, ...result }) + '\n'); + return; + } + + printUsageAndExit(); + } finally { + await app.close(); + } +} + +void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : 'unknown error'; + process.stderr.write(JSON.stringify({ ok: false, error: message }) + '\n'); + process.exit(1); +}); + diff --git a/src/common/center-api-key.guard.ts b/src/common/center-api-key.guard.ts index ca8f2f5..9f756db 100644 --- a/src/common/center-api-key.guard.ts +++ b/src/common/center-api-key.guard.ts @@ -33,11 +33,6 @@ export class CenterApiKeyGuard implements CanActivate { return true; } - // only guild registration is exempt from API key; it is protected by HMAC secret - if (method === 'POST' && (path === '/nodes/register' || path.endsWith('/nodes/register'))) { - return true; - } - const received = req.headers['x-api-key']; const apiKey = Array.isArray(received) ? received[0] : received; if (!apiKey) throw new UnauthorizedException('missing api key'); diff --git a/src/nodes/node-admin.service.ts b/src/nodes/node-admin.service.ts new file mode 100644 index 0000000..6b05a72 --- /dev/null +++ b/src/nodes/node-admin.service.ts @@ -0,0 +1,59 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; +import { randomBytes } from 'crypto'; +import { GuildNode } from '../entities/guild-node.entity'; +import { AuditService } from '../audit/audit.service'; + +@Injectable() +export class NodeAdminService { + constructor( + @InjectRepository(GuildNode) + private readonly nodeRepo: Repository, + private readonly audit: AuditService, + ) {} + + async registerNode(input: { nodeId: string; name: string; endpoint: string }) { + const existedByNodeId = await this.nodeRepo.findOne({ where: { nodeId: input.nodeId } }); + if (existedByNodeId) { + throw new ConflictException('nodeId already exists'); + } + + const existedByEndpoint = await this.nodeRepo.findOne({ where: { endpoint: input.endpoint } }); + if (existedByEndpoint) { + throw new ConflictException('endpoint already exists'); + } + + const node = this.nodeRepo.create({ + nodeId: input.nodeId, + name: input.name, + endpoint: input.endpoint, + status: 'active', + apiKeyHash: null, + }); + + const rawApiKey = `gk_${randomBytes(24).toString('hex')}`; + node.apiKeyHash = await bcrypt.hash(rawApiKey, 10); + const saved = await this.nodeRepo.save(node); + + await this.audit.write({ + action: 'node.register', + targetType: 'node', + targetId: saved.nodeId, + detail: JSON.stringify({ endpoint: saved.endpoint, via: 'cli' }), + }); + + return { + node: { + id: saved.id, + nodeId: saved.nodeId, + name: saved.name, + endpoint: saved.endpoint, + status: saved.status, + }, + apiKey: rawApiKey, + }; + } +} + diff --git a/src/nodes/nodes.controller.ts b/src/nodes/nodes.controller.ts index 7716fca..be8051b 100644 --- a/src/nodes/nodes.controller.ts +++ b/src/nodes/nodes.controller.ts @@ -1,29 +1,20 @@ import { Body, - ConflictException, Controller, DefaultValuePipe, - ForbiddenException, Get, - Headers, - HttpException, NotFoundException, Param, ParseIntPipe, Patch, Post, Query, - Req, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import * as bcrypt from 'bcryptjs'; -import { randomBytes } from 'crypto'; import { GuildNode } from '../entities/guild-node.entity'; import { AuditService } from '../audit/audit.service'; -import { RegisterNodeDto } from './dto.register-node.dto'; import { UpdateNodeStatusDto } from './dto.update-node-status.dto'; -import { FABRIC_PROTOCOL_VERSION, normalizeVersion } from '../common/version'; @Controller('nodes') export class NodesController { @@ -33,81 +24,6 @@ export class NodesController { private readonly audit: AuditService, ) {} - @Post('register') - async register( - @Req() req: { ip?: string; socket?: { remoteAddress?: string } }, - @Body() body: RegisterNodeDto, - @Headers('x-fabric-version') fabricVersion?: string, - ) { - const remoteAddress = (req.ip ?? req.socket?.remoteAddress ?? '').toLowerCase(); - const isLoopback = - remoteAddress === '127.0.0.1' || - remoteAddress === '::1' || - remoteAddress === '::ffff:127.0.0.1'; - if (!isLoopback) { - throw new ForbiddenException('register endpoint only allows localhost caller'); - } - - const requestedVersion = normalizeVersion(fabricVersion); - if (requestedVersion !== FABRIC_PROTOCOL_VERSION) { - throw new HttpException( - { - error: { - code: 'FABRIC_VERSION_NOT_SUPPORTED', - message: `unsupported protocol version: ${requestedVersion}`, - retryable: false, - }, - supportedVersion: FABRIC_PROTOCOL_VERSION, - }, - 400, - ); - } - - const existedByNodeId = await this.nodeRepo.findOne({ - where: { nodeId: body.nodeId }, - }); - if (existedByNodeId) { - throw new ConflictException('nodeId already exists'); - } - - const existedByEndpoint = await this.nodeRepo.findOne({ - where: { endpoint: body.endpoint }, - }); - if (existedByEndpoint) { - throw new ConflictException('endpoint already exists'); - } - - const node = this.nodeRepo.create({ - nodeId: body.nodeId, - name: body.name, - endpoint: body.endpoint, - status: 'active', - apiKeyHash: null, - }); - const rawApiKey = `gk_${randomBytes(24).toString('hex')}`; - node.apiKeyHash = await bcrypt.hash(rawApiKey, 10); - const saved = await this.nodeRepo.save(node); - await this.audit.write({ - action: 'node.register', - targetType: 'node', - targetId: saved.nodeId, - detail: JSON.stringify({ endpoint: saved.endpoint }), - }); - - return { - status: 'accepted', - negotiatedVersion: FABRIC_PROTOCOL_VERSION, - node: { - id: saved.id, - nodeId: saved.nodeId, - name: saved.name, - endpoint: saved.endpoint, - status: saved.status, - }, - apiKey: rawApiKey, - }; - } - @Post(':nodeId/heartbeat') async heartbeat(@Param('nodeId') nodeId: string) { const node = await this.nodeRepo.findOne({ where: { nodeId } }); diff --git a/src/nodes/nodes.module.ts b/src/nodes/nodes.module.ts index 098024a..809e324 100644 --- a/src/nodes/nodes.module.ts +++ b/src/nodes/nodes.module.ts @@ -2,9 +2,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { NodesController } from './nodes.controller'; import { GuildNode } from '../entities/guild-node.entity'; +import { NodeAdminService } from './node-admin.service'; @Module({ imports: [TypeOrmModule.forFeature([GuildNode])], controllers: [NodesController], + providers: [NodeAdminService], + exports: [NodeAdminService], }) export class NodesModule {}