feat(guild): system-key bypass + announce-only system path + gen CLI

Three coupled changes that let Dialectic.Backend (and future system
broadcasters) post to announce channels without needing a Fabric user
bearer.

1. ApiKeyGuard: when x-fabric-system-key matches
   FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env, skip the Bearer requirement
   and set req.isSystem=true. Pre-Bearer system bypass; no per-user
   session token needed. Empty env -> bypass disabled (closed by default).

2. messaging.controller POST /channels/:id/messages: when req.isSystem,
   skip assertParticipant + fetch channel directly. Enforce xType=announce
   (system key only writes to announce channels - never to regular chats).
   Persist with sentinel author 00000000-0000-0000-0000-000000000000.
   Emit message.created + realtime.emitMessageCreated with xType=announce
   so the Phase 1 busy-discard logic kicks in for recipients.

3. New cli: src/cli/gen-system-api-key.ts. Generates a random 32-byte
   hex key (same shape as agent + admin keys) and prints it. Does NOT
   store - operator pastes into compose env and restarts guild. Pattern
   mirrors the existing print-commands-sync-key.ts.

Removes the need for a FABRIC_BOT_BEARER_TOKEN concept entirely - the
system key alone is sufficient. announce-channel posts by regular
authenticated users (who happen to know channel id but no system key)
are now 403 announce_system_only.
This commit is contained in:
h z
2026-05-23 17:49:53 +01:00
parent 80ee9082f3
commit 985b06a886
3 changed files with 113 additions and 16 deletions

View File

@@ -156,14 +156,59 @@ export class MessagingController {
async create(
@Param('id') channelId: string,
@Body() body: CreateMessageDto,
@Req() req: { userId?: string },
@Req() req: { userId?: string; isSystem?: boolean },
@Headers('idempotency-key') idempotencyKey?: string,
@Headers('x-fabric-system-key') systemKey?: string,
) {
const scope = `POST:/channels/${channelId}/messages`;
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
if (existed) return existed;
// System caller (ApiKeyGuard set isSystem from x-fabric-system-key):
// skip the per-user participant check; resolve channel directly. Only
// allowed for xType=announce — system POSTs to non-announce channels
// are still rejected (broadcast key shouldn't write to regular chats).
if (req.isSystem) {
const sysChannel = await this.channelRepo.findOne({ where: { id: channelId } });
if (!sysChannel) {
throw new ForbiddenException('channel not found');
}
if (sysChannel.closed) {
throw new ConflictException({ error: 'channel_closed', message: 'channel is closed' });
}
if (sysChannel.xType !== 'announce') {
throw new ForbiddenException({
error: 'system_key_announce_only',
message: 'system api key only valid for announce-type channels',
});
}
// Persist with the all-zeros sentinel user id (no real user has it;
// intentionally not FK-constrained against users since announce
// messages don't belong to any one Center user).
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
const sysMessage = await this.persistMessage(channelId, {
authorUserId: SYSTEM_USER_ID,
content: body.content ?? '',
clientMessageId: body.clientMessageId,
replyToMessageId: body.replyToMessageId,
mentions: body.mentions,
attachments: body.attachments,
});
const sysView = this.toView(sysMessage) as Record<string, unknown>;
await this.saveIdempotentResponse(scope, idempotencyKey, sysView);
await this.events.emit({
eventType: 'message.created',
channelId,
actorId: SYSTEM_USER_ID,
data: sysView,
});
await this.realtime.emitMessageCreated(channelId, sysView, {
xType: 'announce',
authorUserId: SYSTEM_USER_ID,
wakeUserIds: new Set<string>(),
});
return sysView;
}
// Guild C-1: caller must be a participant of the channel, and the
// author is always the authenticated user — body.authorUserId is
// ignored so a caller can never post as someone else.
@@ -176,21 +221,14 @@ export class MessagingController {
const xType = channel.xType ?? 'general';
const isRotating = xType === 'discuss' || xType === 'work';
// announce channels: posts only allowed when the caller presents a
// valid system key (matches FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env).
// The ApiKeyGuard has already validated user identity; this is an
// additional system-only gate on top. Non-system posts are silently
// discarded (return 403 + log) so misbehaving clients don't pollute
// the broadcast.
// announce channels: user-bearer POSTs (i.e. not isSystem above) are
// never allowed. The system-key bypass above is the ONLY path that
// writes to announce channels.
if (xType === 'announce') {
const expected = process.env.FABRIC_BACKEND_GUILD_SYSTEM_API_KEY ?? '';
if (!expected || systemKey !== expected) {
// log + reject; treat empty env as "no system caller is ever valid"
throw new ForbiddenException({
error: 'announce_system_only',
message: 'announce-type channels accept system-signed posts only',
});
}
throw new ForbiddenException({
error: 'announce_system_only',
message: 'announce-type channels accept system-signed posts only (use x-fabric-system-key)',
});
}
const authorUserId = userId;