Pairs with Dialectic.Backend@5cf4302 which removes the backend-driven
broadcast that was the only consumer of serviceEndpoint. With agent-
driven recruitment broadcasts via fabric-send-message, the
service-to-service URL distinction goes away (agents use the regular
guild endpoint).
Removed:
- GuildNode.serviceEndpoint column (TypeORM will drop on next sync)
- GET /api/auth/me/guilds + /api/nodes response fields
- NodeAdminService.setServiceEndpoint()
- cli 'node set-service-endpoint' subcommand
Kept:
- GuildNode.purpose (used by fabric-guild-list for intent-based
channel discovery — still wanted)
81 lines
2.5 KiB
TypeScript
81 lines
2.5 KiB
TypeScript
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import bcrypt from 'bcryptjs';
|
|
import { randomBytes } from 'crypto';
|
|
import { GuildNode } from '../entities/guild-node.entity.js';
|
|
import { AuditService } from '../audit/audit.service.js';
|
|
|
|
@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,
|
|
};
|
|
}
|
|
|
|
// Admin-only via cli (never HTTP): set the free-form purpose string on
|
|
// a guild node. Pass an empty string to clear it (null).
|
|
async setPurpose(nodeId: string, purpose: string) {
|
|
const node = await this.nodeRepo.findOne({ where: { nodeId } });
|
|
if (!node) throw new NotFoundException(`node ${nodeId} not found`);
|
|
const trimmed = String(purpose ?? '').trim();
|
|
node.purpose = trimmed === '' ? null : trimmed;
|
|
const saved = await this.nodeRepo.save(node);
|
|
await this.audit.write({
|
|
action: 'node.set_purpose',
|
|
targetType: 'node',
|
|
targetId: saved.nodeId,
|
|
detail: JSON.stringify({ purpose: saved.purpose, via: 'cli' }),
|
|
});
|
|
return {
|
|
nodeId: saved.nodeId,
|
|
name: saved.name,
|
|
purpose: saved.purpose,
|
|
};
|
|
}
|
|
}
|
|
|