feat(#709): add configured Discord interaction binding
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -13,6 +13,8 @@ import { Server, Socket } from 'socket.io';
|
|||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
import {
|
import {
|
||||||
verifyDiscordIngressEnvelope,
|
verifyDiscordIngressEnvelope,
|
||||||
|
parseDiscordInteractionBindings,
|
||||||
|
resolveDiscordInteractionBinding,
|
||||||
type DiscordIngressEnvelope,
|
type DiscordIngressEnvelope,
|
||||||
type DiscordIngressPayload,
|
type DiscordIngressPayload,
|
||||||
} from '@mosaicstack/discord-plugin';
|
} 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}`);
|
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
||||||
return null;
|
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)) {
|
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
createDiscordIngressEnvelope,
|
createDiscordIngressEnvelope,
|
||||||
verifyDiscordIngressEnvelope,
|
verifyDiscordIngressEnvelope,
|
||||||
type DiscordIngressPayload,
|
type DiscordIngressPayload,
|
||||||
|
resolveDiscordInteractionBinding,
|
||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
||||||
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
||||||
@@ -23,6 +24,25 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Discord ingress security', () => {
|
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', () => {
|
it('accepts only the configured Discord service identity', () => {
|
||||||
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
||||||
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type OnModuleDestroy,
|
type OnModuleDestroy,
|
||||||
type OnModuleInit,
|
type OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DiscordPlugin } from '@mosaicstack/discord-plugin';
|
import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin';
|
||||||
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
||||||
import { PluginService } from './plugin.service.js';
|
import { PluginService } from './plugin.service.js';
|
||||||
import type { IChannelPlugin } from './plugin.interface.js';
|
import type { IChannelPlugin } from './plugin.interface.js';
|
||||||
@@ -85,6 +85,9 @@ function createPluginRegistry(): IChannelPlugin[] {
|
|||||||
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
||||||
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
||||||
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
||||||
|
interactionBindings: parseDiscordInteractionBindings(
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'],
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,75 @@ export interface DiscordPluginConfig {
|
|||||||
allowedGuildIds: readonly string[];
|
allowedGuildIds: readonly string[];
|
||||||
allowedChannelIds: readonly string[];
|
allowedChannelIds: readonly string[];
|
||||||
allowedUserIds: 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<Record<string, DiscordInteractionRole>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationRoles: Readonly<
|
||||||
|
Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]>
|
||||||
|
> = {
|
||||||
|
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<DiscordInteractionBinding>;
|
||||||
|
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 {
|
export interface DiscordIngressPayload {
|
||||||
@@ -22,6 +91,15 @@ export interface DiscordIngressPayload {
|
|||||||
userId: string;
|
userId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
threadId?: string;
|
||||||
|
attachments?: readonly DiscordAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordAttachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
contentType: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscordIngressEnvelope {
|
export interface DiscordIngressEnvelope {
|
||||||
@@ -44,6 +122,8 @@ function signedPayload(payload: DiscordIngressPayload): string {
|
|||||||
payload.userId,
|
payload.userId,
|
||||||
payload.conversationId,
|
payload.conversationId,
|
||||||
payload.content,
|
payload.content,
|
||||||
|
payload.threadId ?? '',
|
||||||
|
JSON.stringify(payload.attachments ?? []),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +294,18 @@ export class DiscordPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channelId = message.channelId;
|
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);
|
this.channelConversations.set(channelId, conversationId);
|
||||||
|
|
||||||
const envelope = createDiscordIngressEnvelope(
|
const envelope = createDiscordIngressEnvelope(
|
||||||
@@ -222,10 +313,17 @@ export class DiscordPlugin {
|
|||||||
correlationId: randomUUID(),
|
correlationId: randomUUID(),
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
guildId: message.guildId,
|
guildId: message.guildId,
|
||||||
channelId,
|
channelId: bindingChannelId,
|
||||||
userId: message.author.id,
|
userId: message.author.id,
|
||||||
conversationId,
|
conversationId,
|
||||||
content,
|
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,
|
this.config.serviceToken,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user