diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 7b4f0b3..566096a 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -13,6 +13,8 @@ import { Server, Socket } from 'socket.io'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import { verifyDiscordIngressEnvelope, + parseDiscordInteractionBindings, + resolveDiscordInteractionBinding, type DiscordIngressEnvelope, type DiscordIngressPayload, } from '@mosaicstack/discord-plugin'; @@ -654,6 +656,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`); return null; } + try { + const binding = resolveDiscordInteractionBinding( + parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), + payload.guildId, + payload.channelId, + payload.userId, + 'send', + ); + if (!binding) { + this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); + return null; + } + } catch { + this.logger.warn( + `Rejected Discord ingress without valid binding configuration from ${client.id}`, + ); + return null; + } if (!this.discordReplayProtector.claim(payload.messageId)) { this.logger.warn( `Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`, diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts index 0530170..bebc106 100644 --- a/apps/gateway/src/plugin/discord-ingress.security.spec.ts +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -3,6 +3,7 @@ import { createDiscordIngressEnvelope, verifyDiscordIngressEnvelope, type DiscordIngressPayload, + resolveDiscordInteractionBinding, } from '@mosaicstack/discord-plugin'; import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js'; import { DiscordReplayProtector } from './discord-replay-protector.js'; @@ -23,6 +24,25 @@ function createPayload(overrides: Partial = {}): DiscordI } describe('Discord ingress security', () => { + it('binds a differently named configured interaction instance without code changes', () => { + const binding = resolveDiscordInteractionBinding( + [ + { + instanceId: 'Nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { 'user-001': 'operator' }, + }, + ], + 'guild-001', + 'channel-001', + 'user-001', + 'send', + ); + + expect(binding?.instanceId).toBe('Nova'); + }); + it('accepts only the configured Discord service identity', () => { expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true); expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false); diff --git a/apps/gateway/src/plugin/plugin.module.ts b/apps/gateway/src/plugin/plugin.module.ts index dc059ef..fdf13cf 100644 --- a/apps/gateway/src/plugin/plugin.module.ts +++ b/apps/gateway/src/plugin/plugin.module.ts @@ -6,7 +6,7 @@ import { type OnModuleDestroy, type OnModuleInit, } from '@nestjs/common'; -import { DiscordPlugin } from '@mosaicstack/discord-plugin'; +import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin'; import { TelegramPlugin } from '@mosaicstack/telegram-plugin'; import { PluginService } from './plugin.service.js'; import type { IChannelPlugin } from './plugin.interface.js'; @@ -85,6 +85,9 @@ function createPluginRegistry(): IChannelPlugin[] { allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), + interactionBindings: parseDiscordInteractionBindings( + process.env['DISCORD_INTERACTION_BINDINGS'], + ), }), ), ); diff --git a/plugins/discord/src/index.ts b/plugins/discord/src/index.ts index d25e555..c752c03 100644 --- a/plugins/discord/src/index.ts +++ b/plugins/discord/src/index.ts @@ -12,6 +12,75 @@ export interface DiscordPluginConfig { allowedGuildIds: readonly string[]; allowedChannelIds: readonly string[]; allowedUserIds: readonly string[]; + /** Provisioned interaction bindings; instance identity is configuration, never code. */ + interactionBindings?: readonly DiscordInteractionBinding[]; +} + +export type DiscordInteractionOperation = 'bind' | 'attach' | 'send' | 'approve'; +export type DiscordInteractionRole = 'viewer' | 'operator' | 'admin'; + +export interface DiscordInteractionBinding { + instanceId: string; + guildId: string; + channelId: string; + /** Pairing roster keyed by Discord user ID. */ + pairedUsers: Readonly>; +} + +const operationRoles: Readonly< + Record +> = { + bind: ['admin'], + attach: ['operator', 'admin'], + send: ['operator', 'admin'], + approve: ['admin'], +}; + +/** Resolves a configuration-owned binding and applies pairing/RBAC before ingress. */ +export function resolveDiscordInteractionBinding( + bindings: readonly DiscordInteractionBinding[], + guildId: string, + channelId: string, + userId: string, + operation: DiscordInteractionOperation, +): DiscordInteractionBinding | null { + const binding = bindings.find( + (candidate) => candidate.guildId === guildId && candidate.channelId === channelId, + ); + if (!binding) return null; + const role = binding.pairedUsers[userId]; + return role && operationRoles[operation].includes(role) ? binding : null; +} + +/** Parses provisioned binding roster JSON and rejects malformed or empty data. */ +export function parseDiscordInteractionBindings( + value: string | undefined, +): DiscordInteractionBinding[] { + if (!value) throw new Error('DISCORD_INTERACTION_BINDINGS is required when Discord is enabled'); + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error('DISCORD_INTERACTION_BINDINGS must be a non-empty JSON array'); + } + return parsed.map((binding: unknown): DiscordInteractionBinding => { + if (typeof binding !== 'object' || binding === null) + throw new Error('Invalid Discord interaction binding'); + const candidate = binding as Partial; + if ( + !candidate.instanceId || + !candidate.guildId || + !candidate.channelId || + !candidate.pairedUsers || + typeof candidate.pairedUsers !== 'object' + ) { + throw new Error('Invalid Discord interaction binding'); + } + return { + instanceId: candidate.instanceId, + guildId: candidate.guildId, + channelId: candidate.channelId, + pairedUsers: candidate.pairedUsers, + }; + }); } export interface DiscordIngressPayload { @@ -22,6 +91,15 @@ export interface DiscordIngressPayload { userId: string; conversationId: string; content: string; + threadId?: string; + attachments?: readonly DiscordAttachment[]; +} + +export interface DiscordAttachment { + id: string; + name: string; + url: string; + contentType: string | null; } export interface DiscordIngressEnvelope { @@ -44,6 +122,8 @@ function signedPayload(payload: DiscordIngressPayload): string { payload.userId, payload.conversationId, payload.content, + payload.threadId ?? '', + JSON.stringify(payload.attachments ?? []), ].join('\n'); } @@ -214,7 +294,18 @@ export class DiscordPlugin { } const channelId = message.channelId; - const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`; + const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null; + const bindingChannelId = parentChannelId ?? channelId; + const binding = resolveDiscordInteractionBinding( + this.config.interactionBindings ?? [], + message.guildId, + bindingChannelId, + message.author.id, + 'send', + ); + if (!binding) return; + const conversationId = + this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`; this.channelConversations.set(channelId, conversationId); const envelope = createDiscordIngressEnvelope( @@ -222,10 +313,17 @@ export class DiscordPlugin { correlationId: randomUUID(), messageId: message.id, guildId: message.guildId, - channelId, + channelId: bindingChannelId, userId: message.author.id, conversationId, content, + threadId: parentChannelId ? channelId : undefined, + attachments: Array.from(message.attachments.values()).map((attachment) => ({ + id: attachment.id, + name: attachment.name, + url: attachment.url, + contentType: attachment.contentType, + })), }, this.config.serviceToken, );