refactor(guild): drop system-key bypass + announce-only-system limit
Pairs with Dialectic.Backend@5cf4302 which removes the backend-driven
broadcaster that was the only consumer of the x-fabric-system-key
header path. Backend cleanup is complete on the consumer side; this
removes the producer-side surface.
Removed:
- ApiKeyGuard: x-fabric-system-key bypass branch (sysExpected /
sysProvided / req.isSystem flag) — only Bearer flow remains.
- messaging.controller.create(): the entire 'if (req.isSystem)'
branch including the SYSTEM_USER_ID='00000000-...-0000' sentinel
persistence path.
- messaging.controller.create(): the 'if (xType === announce) throw
announce_system_only' gate. Announce channels are now ordinary
channels — any participant can POST. Use case: agents post one-off
recruitment broadcasts via fabric-send-message (e.g. dialectic
'come participate in topic X' messages).
- cli/gen-system-api-key.ts: deleted (was the generator for the env
that's no longer read).
Kept:
- channel.purpose field + PATCH /api/channels/:id (member auth for
setting purpose — agents use this to label channels for
fabric-channel-list discoverability).
- cli/print-commands-sync-key.ts (separate key, separate lifecycle).
- GuildRole.isSystem flag (unrelated — system-role permission gate).
This commit is contained in:
@@ -156,62 +156,21 @@ export class MessagingController {
|
||||
async create(
|
||||
@Param('id') channelId: string,
|
||||
@Body() body: CreateMessageDto,
|
||||
@Req() req: { userId?: string; isSystem?: boolean },
|
||||
@Req() req: { userId?: string },
|
||||
@Headers('idempotency-key') idempotencyKey?: 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.
|
||||
//
|
||||
// announce channels: any participant can POST. Use case is one-off
|
||||
// recruitment / broadcast messages posted by the agent that just
|
||||
// created the originating topic (e.g. dialectic invites). No
|
||||
// server-side privileged path — author is always a real user.
|
||||
const userId = String(req.userId ?? '');
|
||||
if (!userId) throw new ForbiddenException('missing user');
|
||||
const channel = await this.assertParticipant(channelId, userId);
|
||||
@@ -220,16 +179,6 @@ export class MessagingController {
|
||||
}
|
||||
const xType = channel.xType ?? 'general';
|
||||
const isRotating = xType === 'discuss' || xType === 'work';
|
||||
|
||||
// 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') {
|
||||
throw new ForbiddenException({
|
||||
error: 'announce_system_only',
|
||||
message: 'announce-type channels accept system-signed posts only (use x-fabric-system-key)',
|
||||
});
|
||||
}
|
||||
const authorUserId = userId;
|
||||
|
||||
// ---- translate <@user.name:NAME> -> <@userId> (outside backticks) via
|
||||
|
||||
Reference in New Issue
Block a user