feat(#707): secure Discord service ingress
This commit is contained in:
@@ -1,24 +1,109 @@
|
||||
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
export interface DiscordPluginConfig {
|
||||
token: string;
|
||||
gatewayUrl: string;
|
||||
/** Which guild to bind to (single-guild only for v0.1.0) */
|
||||
/** Shared service credential injected by the approved secret mechanism. */
|
||||
serviceToken: string;
|
||||
/** Which guild to bind to (single-guild only for v0.1.0). */
|
||||
guildId?: string;
|
||||
allowedGuildIds: readonly string[];
|
||||
allowedChannelIds: readonly string[];
|
||||
allowedUserIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface DiscordIngressPayload {
|
||||
correlationId: string;
|
||||
messageId: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DiscordIngressEnvelope {
|
||||
payload: DiscordIngressPayload;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface DiscordIngressAllowlists {
|
||||
guildIds: readonly string[];
|
||||
channelIds: readonly string[];
|
||||
userIds: readonly string[];
|
||||
}
|
||||
|
||||
function signedPayload(payload: DiscordIngressPayload): string {
|
||||
return [
|
||||
payload.correlationId,
|
||||
payload.messageId,
|
||||
payload.guildId,
|
||||
payload.channelId,
|
||||
payload.userId,
|
||||
payload.conversationId,
|
||||
payload.content,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function signPayload(payload: DiscordIngressPayload, serviceToken: string): string {
|
||||
return createHmac('sha256', serviceToken).update(signedPayload(payload)).digest('hex');
|
||||
}
|
||||
|
||||
function isSignatureValid(actual: string, expected: string): boolean {
|
||||
const actualBuffer = Buffer.from(actual, 'hex');
|
||||
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||
return (
|
||||
actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
|
||||
);
|
||||
}
|
||||
|
||||
function includesId(allowedIds: readonly string[], id: string): boolean {
|
||||
return allowedIds.includes(id);
|
||||
}
|
||||
|
||||
/** Creates the signed, auditable envelope accepted by the gateway Discord service boundary. */
|
||||
export function createDiscordIngressEnvelope(
|
||||
payload: DiscordIngressPayload,
|
||||
serviceToken: string,
|
||||
): DiscordIngressEnvelope {
|
||||
return { payload, signature: signPayload(payload, serviceToken) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies service-origin integrity and applies default-deny Discord identity allowlists.
|
||||
* Returns null instead of a partially trusted payload on every failure path.
|
||||
*/
|
||||
export function verifyDiscordIngressEnvelope(
|
||||
envelope: DiscordIngressEnvelope,
|
||||
serviceToken: string,
|
||||
allowlists?: DiscordIngressAllowlists,
|
||||
): DiscordIngressPayload | null {
|
||||
const expectedSignature = signPayload(envelope.payload, serviceToken);
|
||||
if (!isSignatureValid(envelope.signature, expectedSignature)) return null;
|
||||
|
||||
if (
|
||||
allowlists &&
|
||||
(!includesId(allowlists.guildIds, envelope.payload.guildId) ||
|
||||
!includesId(allowlists.channelIds, envelope.payload.channelId) ||
|
||||
!includesId(allowlists.userIds, envelope.payload.userId))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return envelope.payload;
|
||||
}
|
||||
|
||||
export class DiscordPlugin {
|
||||
private client: Client;
|
||||
private socket: Socket | null = null;
|
||||
private config: DiscordPluginConfig;
|
||||
/** Map Discord channel ID → Mosaic conversation ID */
|
||||
/** Map Discord channel ID → Mosaic conversation ID. */
|
||||
private channelConversations = new Map<string, string>();
|
||||
/** Track in-flight responses to avoid duplicate streaming */
|
||||
/** Track in-flight responses to avoid duplicate streaming. */
|
||||
private pendingResponses = new Map<string, string>();
|
||||
|
||||
constructor(config: DiscordPluginConfig) {
|
||||
this.config = config;
|
||||
constructor(private readonly config: DiscordPluginConfig) {
|
||||
this.client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
@@ -30,8 +115,8 @@ export class DiscordPlugin {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Connect to gateway WebSocket
|
||||
this.socket = io(`${this.config.gatewayUrl}/chat`, {
|
||||
auth: { discordServiceToken: this.config.serviceToken },
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
@@ -48,7 +133,6 @@ export class DiscordPlugin {
|
||||
console.error(`[discord] Gateway connection error: ${err.message}`);
|
||||
});
|
||||
|
||||
// Handle streaming text from gateway
|
||||
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
||||
const pending = this.pendingResponses.get(data.conversationId);
|
||||
if (pending !== undefined) {
|
||||
@@ -56,12 +140,11 @@ export class DiscordPlugin {
|
||||
}
|
||||
});
|
||||
|
||||
// When agent finishes, send the accumulated response
|
||||
this.socket.on('agent:end', (data: { conversationId: string }) => {
|
||||
const text = this.pendingResponses.get(data.conversationId);
|
||||
if (text) {
|
||||
this.pendingResponses.delete(data.conversationId);
|
||||
this.sendToDiscord(data.conversationId, text).catch((err) => {
|
||||
this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
|
||||
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
|
||||
});
|
||||
}
|
||||
@@ -71,8 +154,9 @@ export class DiscordPlugin {
|
||||
this.pendingResponses.set(data.conversationId, '');
|
||||
});
|
||||
|
||||
// Set up Discord message handler
|
||||
this.client.on('messageCreate', (message) => this.handleDiscordMessage(message));
|
||||
this.client.on('messageCreate', (message: DiscordMessage) =>
|
||||
this.handleDiscordMessage(message),
|
||||
);
|
||||
|
||||
this.client.on('ready', () => {
|
||||
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
|
||||
@@ -96,7 +180,6 @@ export class DiscordPlugin {
|
||||
const guild = this.client.guilds.cache.get(this.config.guildId);
|
||||
if (!guild) return null;
|
||||
|
||||
// Slugify project name for channel: lowercase, replace spaces/special chars with hyphens
|
||||
const channelName = `mosaic-${project.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
@@ -108,58 +191,58 @@ export class DiscordPlugin {
|
||||
topic: project.description ?? `Mosaic project: ${project.name}`,
|
||||
});
|
||||
|
||||
// Register the channel mapping so messages route correctly
|
||||
this.channelConversations.set(channel.id, `discord-${channel.id}`);
|
||||
|
||||
return { channelId: channel.id };
|
||||
}
|
||||
|
||||
private handleDiscordMessage(message: DiscordMessage): void {
|
||||
// Ignore bot messages
|
||||
if (message.author.bot) return;
|
||||
if (message.author.bot || !this.client.user) return;
|
||||
if (!message.guildId || !this.isAllowedMessage(message)) return;
|
||||
|
||||
// Not ready yet
|
||||
if (!this.client.user) return;
|
||||
|
||||
// Check guild binding
|
||||
if (this.config.guildId && message.guildId !== this.config.guildId) return;
|
||||
|
||||
// Respond to DMs always, or mentions in channels
|
||||
const isDM = !message.guildId;
|
||||
const isMention = message.mentions.has(this.client.user);
|
||||
if (!isMention) return;
|
||||
|
||||
if (!isDM && !isMention) return;
|
||||
|
||||
// Strip bot mention from message content
|
||||
const content = message.content
|
||||
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
|
||||
.trim();
|
||||
|
||||
if (!content) return;
|
||||
|
||||
// Get or create conversation for this Discord channel
|
||||
const channelId = message.channelId;
|
||||
let conversationId = this.channelConversations.get(channelId);
|
||||
if (!conversationId) {
|
||||
conversationId = `discord-${channelId}`;
|
||||
this.channelConversations.set(channelId, conversationId);
|
||||
}
|
||||
|
||||
// Send to gateway
|
||||
if (!this.socket?.connected) {
|
||||
console.error(
|
||||
`[discord] Cannot forward message: not connected to gateway. channel=${channelId}`,
|
||||
`[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.socket.emit('message', {
|
||||
conversationId,
|
||||
content,
|
||||
});
|
||||
|
||||
const channelId = message.channelId;
|
||||
const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`;
|
||||
this.channelConversations.set(channelId, conversationId);
|
||||
|
||||
const envelope = createDiscordIngressEnvelope(
|
||||
{
|
||||
correlationId: randomUUID(),
|
||||
messageId: message.id,
|
||||
guildId: message.guildId,
|
||||
channelId,
|
||||
userId: message.author.id,
|
||||
conversationId,
|
||||
content,
|
||||
},
|
||||
this.config.serviceToken,
|
||||
);
|
||||
this.socket.emit('message', envelope);
|
||||
}
|
||||
|
||||
private isAllowedMessage(message: DiscordMessage): boolean {
|
||||
const guildId = message.guildId;
|
||||
return (
|
||||
guildId !== null &&
|
||||
includesId(this.config.allowedGuildIds, guildId) &&
|
||||
includesId(this.config.allowedChannelIds, message.channelId) &&
|
||||
includesId(this.config.allowedUserIds, message.author.id)
|
||||
);
|
||||
}
|
||||
|
||||
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
|
||||
// Find the Discord channel for this conversation
|
||||
const channelId = Array.from(this.channelConversations.entries()).find(
|
||||
([, convId]) => convId === conversationId,
|
||||
)?.[0];
|
||||
@@ -177,12 +260,10 @@ export class DiscordPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
// Chunk responses for Discord's 2000-char limit
|
||||
const chunks = this.chunkText(text, 1900);
|
||||
for (const chunk of chunks) {
|
||||
for (const chunk of this.chunkText(text, 1900)) {
|
||||
try {
|
||||
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
|
||||
}
|
||||
}
|
||||
@@ -193,21 +274,16 @@ export class DiscordPlugin {
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to break at a newline
|
||||
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
||||
if (breakPoint <= 0) breakPoint = maxLength;
|
||||
|
||||
chunks.push(remaining.slice(0, breakPoint));
|
||||
remaining = remaining.slice(breakPoint).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user