feat(cli): move user and guild registration from API to local CLI

This commit is contained in:
nav
2026-05-14 14:43:59 +00:00
parent 7afd220b4a
commit 0b32dc8e3c
7 changed files with 123 additions and 95 deletions

View File

@@ -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);

60
src/cli.ts Normal file
View File

@@ -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 <email> --password <password>');
console.error(' node dist/cli.js node register --node-id <id> --name <name> --endpoint <url>');
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);
});

View File

@@ -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');

View File

@@ -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<GuildNode>,
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,
};
}
}

View File

@@ -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 } });

View File

@@ -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 {}