feat(#709): add configured Discord interaction binding
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
Jarvis
2026-07-13 00:29:37 -05:00
parent 99a2d0fc9d
commit 25ed367655
4 changed files with 144 additions and 3 deletions

View File

@@ -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}`,

View File

@@ -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<DiscordIngressPayload> = {}): 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);

View File

@@ -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'],
),
}),
),
);