From 985b06a886da92bf6fcef80bf7a2dc3a6298cfd5 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 17:49:53 +0100 Subject: [PATCH] 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. --- src/cli/gen-system-api-key.ts | 42 ++++++++++++++++ src/common/api-key.guard.ts | 17 +++++++ src/messaging/messaging.controller.ts | 70 +++++++++++++++++++++------ 3 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 src/cli/gen-system-api-key.ts diff --git a/src/cli/gen-system-api-key.ts b/src/cli/gen-system-api-key.ts new file mode 100644 index 0000000..c256b52 --- /dev/null +++ b/src/cli/gen-system-api-key.ts @@ -0,0 +1,42 @@ +// 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=` 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=\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', +); diff --git a/src/common/api-key.guard.ts b/src/common/api-key.guard.ts index eb27a83..5d9979b 100644 --- a/src/common/api-key.guard.ts +++ b/src/common/api-key.guard.ts @@ -21,6 +21,23 @@ 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) : ''; diff --git a/src/messaging/messaging.controller.ts b/src/messaging/messaging.controller.ts index fd2590a..2e908c8 100644 --- a/src/messaging/messaging.controller.ts +++ b/src/messaging/messaging.controller.ts @@ -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; + 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(), + }); + 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;