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:
h z
2026-05-23 23:49:47 +01:00
parent cb7b3bb5fe
commit ca20df7618
3 changed files with 6 additions and 116 deletions

View File

@@ -1,42 +0,0 @@
// Operator convenience: generate a fresh random key suitable for
// FABRIC_BACKEND_GUILD_SYSTEM_API_KEY (the env that gates
// `x-fabric-system-key` posts to announce channels).
//
// Does NOT write to env / DB — the generated value is printed to stdout;
// operator pastes into compose env + restarts the guild container.
//
// Usage:
// docker exec fabric-backend-guild node dist/cli/gen-system-api-key.js
// docker exec fabric-backend-guild node dist/cli/gen-system-api-key.js --export
//
// Default: prints the raw value only (so KEY=$(... ) works).
// --export: prints `FABRIC_BACKEND_GUILD_SYSTEM_API_KEY=<value>` for pasting.
//
// Rotation: re-run, paste new value into compose, restart guild. The old
// key stops working immediately on container restart (it lives only in
// env, not DB). Any process holding the old value will start getting 401
// on announce POSTs — coordinate (e.g. update dialectic-backend env in
// the same maintenance window).
import { randomBytes } from 'node:crypto';
const args = new Set(process.argv.slice(2));
if (args.has('--help') || args.has('-h')) {
process.stderr.write(
'gen-system-api-key: generate a random key for FABRIC_BACKEND_GUILD_SYSTEM_API_KEY\n' +
' (no flag) print the raw key value\n' +
' --export print FABRIC_BACKEND_GUILD_SYSTEM_API_KEY=<value>\n' +
'\nThe generated value is NOT stored — paste it into your\n' +
'compose env and restart the guild container to activate.\n',
);
process.exit(0);
}
// 32 bytes hex = 64 chars. Same shape as agent keys + admin keys
// throughout the stack.
const key = randomBytes(32).toString('hex');
process.stdout.write(
(args.has('--export') ? `FABRIC_BACKEND_GUILD_SYSTEM_API_KEY=${key}` : key) + '\n',
);

View File

@@ -21,23 +21,6 @@ export class ApiKeyGuard implements CanActivate {
return true;
}
// System-key bypass: when a caller presents x-fabric-system-key matching
// FABRIC_BACKEND_GUILD_SYSTEM_API_KEY, skip the Bearer requirement and
// mark this as a system caller (no userId). Downstream handlers (e.g.
// messaging.controller for announce-type channels) gate per-route on
// req.isSystem instead of req.userId.
//
// This is what makes Dialectic.Backend's lifecycle broadcasts work
// without needing a per-user Fabric session token — the system key
// alone is sufficient for posting to announce channels.
const sysExpected = process.env.FABRIC_BACKEND_GUILD_SYSTEM_API_KEY ?? '';
const sysHeader = req.headers['x-fabric-system-key'];
const sysProvided = Array.isArray(sysHeader) ? sysHeader[0] : sysHeader;
if (sysExpected && sysProvided && sysProvided === sysExpected) {
(req as { isSystem?: boolean }).isSystem = true;
return true;
}
const auth = req.headers['authorization'];
const authValue = Array.isArray(auth) ? auth[0] : auth;
let token = authValue?.startsWith('Bearer ') ? authValue.slice(7) : '';

View File

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