From 119f64e69d109dc27338f51bc53452486825f199 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 23:18:29 +0000 Subject: [PATCH] feat(#707): secure Discord service ingress (#716) --- apps/gateway/src/chat/chat.gateway-auth.ts | 14 ++ apps/gateway/src/chat/chat.gateway.ts | 139 ++++++++++++- .../plugin/discord-ingress.security.spec.ts | 89 +++++++++ .../src/plugin/discord-replay-protector.ts | 40 ++++ apps/gateway/src/plugin/plugin.module.ts | 22 +++ docs/architecture/channel-protocol.md | 4 + docs/guides/admin-guide.md | 25 ++- .../tess-m1-sec-004-discord-ingress.md | 35 ++++ plugins/discord/src/index.ts | 184 +++++++++++++----- 9 files changed, 484 insertions(+), 68 deletions(-) create mode 100644 apps/gateway/src/plugin/discord-ingress.security.spec.ts create mode 100644 apps/gateway/src/plugin/discord-replay-protector.ts create mode 100644 docs/scratchpads/tess-m1-sec-004-discord-ingress.md diff --git a/apps/gateway/src/chat/chat.gateway-auth.ts b/apps/gateway/src/chat/chat.gateway-auth.ts index 16d034c..b1bf564 100644 --- a/apps/gateway/src/chat/chat.gateway-auth.ts +++ b/apps/gateway/src/chat/chat.gateway-auth.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'node:crypto'; import type { IncomingHttpHeaders } from 'node:http'; import { fromNodeHeaders } from 'better-auth/node'; @@ -12,6 +13,19 @@ export interface SessionAuth { }; } +export function validateDiscordServiceToken( + candidate: unknown, + expected: string | undefined, +): boolean { + if (typeof candidate !== 'string' || !expected) return false; + const candidateBuffer = Buffer.from(candidate); + const expectedBuffer = Buffer.from(expected); + return ( + candidateBuffer.length === expectedBuffer.length && + timingSafeEqual(candidateBuffer, expectedBuffer) + ); +} + export async function validateSocketSession( headers: IncomingHttpHeaders, auth: SessionAuth, diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index c552ffe..41fa81c 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -11,6 +11,11 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; +import { + verifyDiscordIngressEnvelope, + type DiscordIngressEnvelope, + type DiscordIngressPayload, +} from '@mosaicstack/discord-plugin'; import type { Auth } from '@mosaicstack/auth'; import type { Brain } from '@mosaicstack/brain'; import type { @@ -34,7 +39,8 @@ import { CommandExecutorService } from '../commands/command-executor.service.js' import { RoutingEngineService } from '../agent/routing/routing-engine.service.js'; import { v4 as uuid } from 'uuid'; import { ChatSocketMessageDto } from './chat.dto.js'; -import { validateSocketSession } from './chat.gateway-auth.js'; +import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js'; +import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js'; /** Per-client state tracking streaming accumulation for persistence. */ interface ClientSession { @@ -58,6 +64,37 @@ interface ClientSession { */ const modelOverrides = new Map(); +function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { + if (typeof value !== 'object' || value === null) return false; + const envelope = value as { payload?: unknown; signature?: unknown }; + if ( + typeof envelope.signature !== 'string' || + typeof envelope.payload !== 'object' || + envelope.payload === null + ) { + return false; + } + const payload = envelope.payload as Record; + return [ + payload['correlationId'], + payload['messageId'], + payload['guildId'], + payload['channelId'], + payload['userId'], + payload['conversationId'], + payload['content'], + ].every((field: unknown): boolean => typeof field === 'string'); +} + +function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto { + if (typeof value !== 'object' || value === null) return false; + const payload = value as { content?: unknown; conversationId?: unknown }; + return ( + typeof payload.content === 'string' && + (payload.conversationId === undefined || typeof payload.conversationId === 'string') + ); +} + @WebSocketGateway({ cors: { origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000', @@ -70,6 +107,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa private readonly logger = new Logger(ChatGateway.name); private readonly clientSessions = new Map(); + private readonly discordReplayProtector = new DiscordReplayProtector(); constructor( @Inject(AgentService) private readonly agentService: AgentService, @@ -85,6 +123,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } async handleConnection(client: Socket): Promise { + const serviceToken = client.handshake.auth['discordServiceToken']; + if (validateDiscordServiceToken(serviceToken, process.env['DISCORD_SERVICE_TOKEN'])) { + client.data.discordService = true; + this.logger.log(`Authenticated Discord service connected: ${client.id}`); + return; + } + const session = await validateSocketSession(client.handshake.headers, this.auth); if (!session) { this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`); @@ -95,8 +140,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.data.user = session.user; client.data.session = session.session; this.logger.log(`Client connected: ${client.id}`); - - // Broadcast command manifest to the newly connected client client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() }); } @@ -131,17 +174,49 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @SubscribeMessage('message') async handleMessage( @ConnectedSocket() client: Socket, - @MessageBody() data: ChatSocketMessageDto, + @MessageBody() rawData: unknown, ): Promise { + let discordIngress: DiscordIngressPayload | null = null; + let data: ChatSocketMessageDto; + if (client.data.discordService) { + if (!isDiscordIngressEnvelope(rawData)) { + this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`); + return; + } + discordIngress = this.resolveDiscordIngress(client, rawData); + if (!discordIngress) return; + data = { conversationId: discordIngress.conversationId, content: discordIngress.content }; + } else { + if (!isChatSocketMessage(rawData)) { + this.logger.warn(`Rejected malformed chat message from ${client.id}`); + return; + } + data = rawData; + } const conversationId = data.conversationId ?? uuid(); - const scope = this.getClientScope(client); + const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; + if (discordIngress && !discordServiceUserId) { + this.logger.warn( + `Rejected Discord ingress without configured service owner from ${client.id}`, + ); + return; + } + const scope = discordIngress + ? { + userId: discordServiceUserId!, + tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId!, + } + : this.getClientScope(client); if (!scope) { client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); return; } const userId = scope.userId; + const correlationId = discordIngress?.correlationId; - this.logger.log(`Message from ${client.id} in conversation ${conversationId}`); + this.logger.log( + `Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`, + ); // Ensure agent session exists for this conversation let sessionRoutingDecision: RoutingDecisionInfo | undefined; @@ -245,6 +320,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa content: data.content, metadata: { timestamp: new Date().toISOString(), + ...(correlationId + ? { + correlationId, + discordMessageId: discordIngress?.messageId, + discordUserId: discordIngress?.userId, + } + : {}), }, }, userId, @@ -308,7 +390,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Send acknowledgment - client.emit('message:ack', { conversationId, messageId: uuid() }); + client.emit('message:ack', { + conversationId, + messageId: uuid(), + ...(correlationId + ? { + correlationId, + discordMessageId: discordIngress?.messageId, + discordUserId: discordIngress?.userId, + } + : {}), + }); // Dispatch to agent try { @@ -534,6 +626,39 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa * Creates it if absent — safe to call concurrently since a duplicate insert * would fail on the PK constraint and be caught here. */ + private resolveDiscordIngress( + client: Socket, + envelope: DiscordIngressEnvelope, + ): DiscordIngressPayload | null { + const payload = verifyDiscordIngressEnvelope( + envelope, + process.env['DISCORD_SERVICE_TOKEN'] ?? '', + { + guildIds: this.readDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), + channelIds: this.readDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), + userIds: this.readDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), + }, + ); + if (!payload) { + this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`); + return null; + } + if (!this.discordReplayProtector.claim(payload.messageId)) { + this.logger.warn( + `Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`, + ); + return null; + } + return payload; + } + + private readDiscordAllowlist(name: string): string[] { + return (process.env[name] ?? '') + .split(',') + .map((id: string): string => id.trim()) + .filter((id: string): boolean => id.length > 0); + } + private async ensureConversation(conversationId: string, userId: string): Promise { try { const existing = await this.brain.conversations.findById(conversationId, userId); diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts new file mode 100644 index 0000000..0530170 --- /dev/null +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { + createDiscordIngressEnvelope, + verifyDiscordIngressEnvelope, + type DiscordIngressPayload, +} from '@mosaicstack/discord-plugin'; +import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js'; +import { DiscordReplayProtector } from './discord-replay-protector.js'; + +const SERVICE_TOKEN = 'test-service-token'; + +function createPayload(overrides: Partial = {}): DiscordIngressPayload { + return { + correlationId: 'correlation-001', + messageId: 'discord-message-001', + guildId: 'guild-001', + channelId: 'channel-001', + userId: 'user-001', + conversationId: 'discord-channel-001', + content: 'hello Tess', + ...overrides, + }; +} + +describe('Discord ingress security', () => { + 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); + expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false); + }); + + it('rejects unauthenticated or tampered service envelopes', () => { + const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN); + + expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload()); + expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull(); + expect( + verifyDiscordIngressEnvelope( + { ...envelope, payload: { ...envelope.payload, content: 'forged command' } }, + SERVICE_TOKEN, + ), + ).toBeNull(); + }); + + it.each([ + ['guild', { guildId: 'unlisted-guild' }], + ['channel', { channelId: 'unlisted-channel' }], + ['user', { userId: 'unlisted-user' }], + ])( + 'rejects an unallowlisted Discord %s', + (_kind: string, overrides: Partial) => { + const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN); + + expect( + verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, { + guildIds: ['guild-001'], + channelIds: ['channel-001'], + userIds: ['user-001'], + }), + ).toBeNull(); + }, + ); + + it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => { + const payload = createPayload({ + correlationId: 'correlation-trace-123', + messageId: 'discord-snowflake-987', + }); + const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN); + + expect( + verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, { + guildIds: ['guild-001'], + channelIds: ['channel-001'], + userIds: ['user-001'], + }), + ).toEqual(payload); + }); + + it('rejects a replayed Discord message ID while retaining bounded replay state', () => { + const replayProtector = new DiscordReplayProtector(60_000, 2); + + expect(replayProtector.claim('discord-message-001')).toBe(true); + expect(replayProtector.claim('discord-message-001')).toBe(false); + expect(replayProtector.claim('discord-message-002')).toBe(true); + expect(replayProtector.claim('discord-message-003')).toBe(true); + expect(replayProtector.size).toBe(2); + }); +}); diff --git a/apps/gateway/src/plugin/discord-replay-protector.ts b/apps/gateway/src/plugin/discord-replay-protector.ts new file mode 100644 index 0000000..626a193 --- /dev/null +++ b/apps/gateway/src/plugin/discord-replay-protector.ts @@ -0,0 +1,40 @@ +/** + * Bounded replay cache for Discord's globally unique native message IDs. + * Durable ingress idempotency is added with Tess's canonical inbox/outbox work. + */ +export class DiscordReplayProtector { + private readonly claimedAt = new Map(); + + constructor( + private readonly ttlMs = 15 * 60 * 1000, + private readonly maxEntries = 10_000, + ) {} + + get size(): number { + return this.claimedAt.size; + } + + /** Claims an ID exactly once within its bounded retention window. */ + claim(messageId: string, now = Date.now()): boolean { + this.prune(now); + if (this.claimedAt.has(messageId)) return false; + + this.claimedAt.set(messageId, now); + this.evictOverflow(); + return true; + } + + private prune(now: number): void { + for (const [messageId, claimedAt] of this.claimedAt) { + if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId); + } + } + + private evictOverflow(): void { + while (this.claimedAt.size > this.maxEntries) { + const oldestMessageId = this.claimedAt.keys().next().value; + if (oldestMessageId === undefined) return; + this.claimedAt.delete(oldestMessageId); + } + } +} diff --git a/apps/gateway/src/plugin/plugin.module.ts b/apps/gateway/src/plugin/plugin.module.ts index 3991c9b..dc059ef 100644 --- a/apps/gateway/src/plugin/plugin.module.ts +++ b/apps/gateway/src/plugin/plugin.module.ts @@ -50,19 +50,41 @@ class TelegramChannelPluginAdapter implements IChannelPlugin { const DEFAULT_GATEWAY_URL = 'http://localhost:14242'; +function requiredDiscordAllowlist(name: string): string[] { + const value = process.env[name] + ?.split(',') + .map((id: string): string => id.trim()) + .filter((id: string): boolean => id.length > 0); + if (!value || value.length === 0) { + throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`); + } + return value; +} + function createPluginRegistry(): IChannelPlugin[] { const plugins: IChannelPlugin[] = []; const discordToken = process.env['DISCORD_BOT_TOKEN']; const discordGuildId = process.env['DISCORD_GUILD_ID']; const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL; + const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN']; + const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; if (discordToken) { + if (!discordServiceToken || !discordServiceUserId) { + throw new Error( + 'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured', + ); + } plugins.push( new DiscordChannelPluginAdapter( new DiscordPlugin({ token: discordToken, guildId: discordGuildId, gatewayUrl: discordGatewayUrl, + serviceToken: discordServiceToken, + allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), + allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), + allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), }), ), ); diff --git a/docs/architecture/channel-protocol.md b/docs/architecture/channel-protocol.md index ad96cbd..315252a 100644 --- a/docs/architecture/channel-protocol.md +++ b/docs/architecture/channel-protocol.md @@ -232,6 +232,10 @@ The following sections document how each supported channel maps its native messa **Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. +### Discord service ingress security + +The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads. + --- ### Telegram diff --git a/docs/guides/admin-guide.md b/docs/guides/admin-guide.md index 4f7c6a1..2430dbd 100644 --- a/docs/guides/admin-guide.md +++ b/docs/guides/admin-guide.md @@ -293,13 +293,24 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe ### Plugins -| Variable | Description | -| ---------------------- | -------------------------------------------------------------------------- | -| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | -| `DISCORD_GUILD_ID` | Discord guild/server ID | -| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | -| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | -| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | +| Variable | Description | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | +| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only | +| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata | +| `DISCORD_GUILD_ID` | Discord guild/server ID | +| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | +| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny | +| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | +| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | + +### Discord ingress security + +When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged. + +Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state. ### Observability diff --git a/docs/scratchpads/tess-m1-sec-004-discord-ingress.md b/docs/scratchpads/tess-m1-sec-004-discord-ingress.md new file mode 100644 index 0000000..92e28d3 --- /dev/null +++ b/docs/scratchpads/tess-m1-sec-004-discord-ingress.md @@ -0,0 +1,35 @@ +# Scratchpad — TESS-M1-SEC-004 Discord ingress + +- **Task / issue:** TESS-M1-SEC-004 / #707 +- **Branch:** `fix/tess-discord-ingress` from `origin/main` at `59e49cfd` +- **Objective:** Authenticate the Discord plugin service at gateway ingress; enforce explicit guild/channel/user allowlists; attach Discord message and generated correlation IDs; reject replayed native message IDs. +- **Scope:** `plugins/discord`, `apps/gateway`, and existing Discord admin/developer protocol docs. +- **Budget:** Task estimate 28K; no explicit hard cap supplied. +- **Assumptions:** The Discord plugin and gateway share an injected high-entropy `DISCORD_SERVICE_TOKEN`; a configured Discord plugin fails closed without it. Allowlist configuration is comma-separated Discord snowflakes. Discord native message ID is the replay key, with bounded in-memory retention pending the M2 durable inbox/idempotency work. + +## Plan + +1. Add failing tests covering ingress service authentication/signing, unlisted guild/channel/user rejection, correlation propagation, and replay rejection. +2. Implement the signed Discord ingress envelope and allowlist validation in the plugin. +3. Authenticate and validate the envelope at the gateway boundary, then enforce bounded replay protection before agent dispatch. +4. Document the service-token and allowlist operations; run focused and baseline gates; obtain independent review. + +## Progress + +- 2026-07-12: Intake complete; PRD TESS-SEC-005, architecture, and threat model reviewed. +- Added service-token Socket.IO authentication, HMAC-signed Discord envelopes, default-deny guild/channel/user allowlists, correlated message metadata, bounded replay rejection, and fail-fast configuration checks. +- Code and security reviews completed. Code review findings on service persistence ownership, package-boundary tests, disconnected ingress observability, and chat payload validation were remediated; final independent code review approved. + +## Risks / blockers + +- Existing `main` has known unrelated Prettier debt; only changed files will be held format-clean. Durable replay persistence is intentionally out of scope for this M1 prerequisite and belongs to TESS-M2 durable inbox/idempotency work. + +## Verification evidence + +- Focused ingress suite: `pnpm --filter @mosaicstack/gateway test -- discord-ingress.security.spec.ts` — 7 passed. +- Gateway suite: `pnpm --filter @mosaicstack/gateway test` — 513 passed, 11 skipped. +- Plugin suite: `pnpm --filter @mosaicstack/discord-plugin test` — no tests, passed by configured `--passWithNoTests`. +- `pnpm typecheck` — passed. +- `pnpm lint` — passed. +- `pnpm format:check` — fails only on the known pre-existing Tess documentation debt listed in the task dispatch; changed files pass targeted Prettier verification. +- Codex security review — no findings; final Codex code review — approved. diff --git a/plugins/discord/src/index.ts b/plugins/discord/src/index.ts index 72fb27f..d25e555 100644 --- a/plugins/discord/src/index.ts +++ b/plugins/discord/src/index.ts @@ -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(); - /** Track in-flight responses to avoid duplicate streaming */ + /** Track in-flight responses to avoid duplicate streaming. */ private pendingResponses = new Map(); - 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 { - // 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 { - // 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 }).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; } }