diff --git a/src/cli/gen-system-api-key.ts b/src/cli/gen-system-api-key.ts deleted file mode 100644 index c256b52..0000000 --- a/src/cli/gen-system-api-key.ts +++ /dev/null @@ -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=` 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 5d9979b..eb27a83 100644 --- a/src/common/api-key.guard.ts +++ b/src/common/api-key.guard.ts @@ -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) : ''; diff --git a/src/messaging/messaging.controller.ts b/src/messaging/messaging.controller.ts index 2e908c8..5a4a57e 100644 --- a/src/messaging/messaging.controller.ts +++ b/src/messaging/messaging.controller.ts @@ -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; - 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. + // + // 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