// eslint-disable-next-line global-require const express = require('express'); // eslint-disable-next-line global-require const axios = require('axios'); // eslint-disable-next-line global-require const nacl = require('tweetnacl'); // eslint-disable-next-line global-require const auth = require('../middleware/auth'); // eslint-disable-next-line global-require const adminAuth = require('../middleware/adminAuth'); // eslint-disable-next-line global-require const Integration = require('../models/Integration'); // eslint-disable-next-line global-require const DiscordIntegration = require('../models/DiscordIntegration'); // eslint-disable-next-line global-require const Pod = require('../models/Pod'); // eslint-disable-next-line global-require const User = require('../models/User'); // eslint-disable-next-line global-require const DiscordService = require('../services/discordService'); // eslint-disable-next-line global-require const { runDiscordCommandForIntegrations, combinedReplyIsPublic } = require('../services/discordMultiCommandService'); // Static import keeps the destructive route visible to CodeQL's rate-limit // query while sharing the connector write bucket with its sibling routes. import { listIntegrationsRateLimit, writeIntegrationsRateLimit } from '../middleware/integrationRateLimit'; import { platformIpRateLimit } from '../middleware/platformRateLimit'; import { invalidDiscordIdError, isDiscordSnowflake, isSupplied } from '../utils/discordBinding'; import { assertCanBindSurface, SurfaceBindRefusedError } from '../services/chatRelay/bindSurface'; import { mintDiscordInstallProof, mintDiscordInstallState, readDiscordInstallProof, readDiscordInstallState, } from '../utils/discordInstallProof'; interface AuthReq { user?: { id: string; role?: string }; params?: Record; query?: Record; body?: Record; headers?: Record; header?: (name: string) => string | undefined; } interface Res { status: (n: number) => Res; json: (d: unknown) => void; redirect: (url: string) => void; writeHead: (status: number, headers: Record) => void; end: (data?: string) => void; } const router: ReturnType = express.Router(); const { DISCORD_PUBLIC_KEY } = process.env; function verifySignature(req: { headers: Record; body: Buffer }): boolean { const signature = req.headers['x-signature-ed25519']; const timestamp = req.headers['x-signature-timestamp']; const body = req.body.toString('utf8'); if (!signature || !timestamp || !DISCORD_PUBLIC_KEY) return false; try { const signatureBytes = Buffer.from(signature, 'hex'); const publicKeyBytes = Buffer.from(DISCORD_PUBLIC_KEY, 'hex'); const message = Buffer.from(timestamp + body, 'utf8'); return nacl.sign.detached.verify(message, signatureBytes, publicKeyBytes); } catch (error) { console.error('Error verifying signature:', error); return false; } } // The bot permissions the install asks for (the value the legacy room link // uses): view channels, send messages, manage webhooks. const DISCORD_BOT_PERMISSIONS = '536873984'; // One redirect for the authorize request and the code exchange: Discord refuses // an exchange whose redirect_uri differs from the one the user authorized. const discordRedirectUri = (): string => ( `${String(process.env.BACKEND_URL || 'http://localhost:5000').replace(/\/+$/, '')}/api/discord/callback` ); async function canManagePod(podId: string, userId: string): Promise { const [user, pod] = await Promise.all([User.findById(userId), Pod.findById(podId)]) as [{ role?: string } | null, { createdBy?: { toString: () => string } } | null]; if (!user || !pod) return false; if ((user as { role?: string }).role === 'admin') return true; return pod.createdBy?.toString() === userId; } async function canManageIntegration(integration: { createdBy?: { toString: () => string }; podId?: unknown } | null, userId: string): Promise { const user = await User.findById(userId) as { role?: string } | null; if (!user || !integration) return false; if (user.role === 'admin') return true; if (integration.createdBy?.toString() === userId) return true; const pod = await Pod.findById(integration.podId) as { createdBy?: { toString: () => string } } | null; return pod?.createdBy?.toString() === userId; } async function handleInstallationEvent(interaction: { id?: string; guild_id?: string; user?: { id?: string } }): Promise<{ success: boolean; message?: string; error?: string }> { try { const { id: installationId, guild_id: serverId, user } = interaction; console.log('Handling Discord installation event:', installationId, serverId, user?.id); const existingIntegration = await Integration.findOne({ installationId }); if (existingIntegration) return { success: true, message: 'Installation already exists' }; console.log('Installation event received - pod binding will be handled by frontend flow'); return { success: true, message: 'Installation event received' }; } catch (error) { console.error('Error handling installation event:', error); return { success: false, error: (error as Error).message }; } } // Rate-limited ahead of auth, the shape `installables.ts:140` uses for its // catalog route. Mounted first, the limiter refuses an over-budget caller before // `auth`'s lookups as well as the handler's two, which is what CodeQL's // `js/missing-rate-limiting` asks for. router.get('/channels/:guildId', listIntegrationsRateLimit, auth, async (req: AuthReq, res: Res) => { try { const { guildId } = req.params || {}; // Shape BEFORE authority: an id in a shape Discord would not accept is // refused rather than looked up, so no unvalidated value reaches the URL // below. The lookup is not the guard โ€” it is what a well-formed id still has // to pass. if (!isDiscordSnowflake(guildId)) return res.status(400).json(invalidDiscordIdError('guildId')); // Authority: the caller must belong to a pod bound to this guild. The request // carries the guild and the server carries the token, so the binding is what // scopes the answer to the caller's own pod. // `_id` only: the route needs the caller's pod ids, not the hydrated // documents. const callerPods = await Pod.find({ $or: [{ members: req.user?.id }, { createdBy: req.user?.id }] }) .select('_id') .lean(); const podIds = (callerPods || []).map((pod: { _id: unknown }) => pod._id); const binding = podIds.length ? await Integration.findOne({ type: 'discord', 'config.serverId': guildId, podId: { $in: podIds } }) : null; // A first install has no binding yet. The proof the OAuth callback minted // stands in for one: same guild, and a pod the caller belongs to (F89). const proof = binding ? null : readDiscordInstallProof(req.query?.install); const provenInstall = !!proof && proof.guildId === guildId && proof.userId === String(req.user?.id) && podIds.some((podId: unknown) => String(podId) === proof.podId); // One answer for a guild that is bound elsewhere and one that does not // exist: the route must not become an oracle for either. if (!binding && !provenInstall) return res.status(404).json({ message: 'Guild not found' }); const response = await axios.get(`https://discord.com/api/guilds/${guildId}/channels`, { headers: { Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}` } }); const textChannels = (response.data as Array<{ type: number; id: string; name: string; topic?: string }>) .filter((channel) => channel.type === 0) .map((channel) => ({ id: channel.id, name: channel.name, topic: channel.topic })); res.json(textChannels); } catch (error) { const e = error as { response?: { data?: unknown }; message?: string }; console.error('Error fetching Discord channels:', e.response?.data || e.message); res.status(500).json({ message: 'Failed to fetch channels' }); } }); router.post('/interactions', async (req: AuthReq & { body: Buffer }, res: Res) => { if (!verifySignature(req as { headers: Record; body: Buffer })) { return res.status(401).json({ error: 'Invalid signature' }); } let interaction: Record; try { interaction = JSON.parse((req.body as unknown as Buffer).toString('utf8')); } catch (error) { return res.status(400).json({ error: 'Invalid JSON' }); } const { type, data, id: interactionId, token: interactionToken, guild_id: guildId } = interaction as { type: number; data?: { type?: number; name?: string }; id?: string; token?: string; guild_id?: string }; if (type === 1) { const pongBody = JSON.stringify({ type: 1 }); res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(pongBody) }); res.end(pongBody); return; } if (type === 2 && (data as { type?: string })?.type === 'INSTALLATION_CREATE') { const result = await handleInstallationEvent(interaction as { id?: string; guild_id?: string; user?: { id?: string } }); if (result.success) return res.json({ type: 4, data: { content: 'Discord bot installed successfully!' } }); return res.status(500).json({ type: 4, data: { content: 'Failed to process installation' } }); } if (type === 2 && data?.type === 1) { const channelId = (interaction as { channel_id?: string }).channel_id; try { let integrations: Array<{ _id: unknown; podId?: { name?: string }; config?: { channelName?: string } }> = []; if (channelId) { integrations = await Integration.find({ 'config.serverId': guildId, 'config.channelId': channelId, type: 'discord', isActive: true }).populate('podId', 'name type'); } if (!integrations.length && !channelId) { const fallback = await Integration.findOne({ 'config.serverId': guildId, type: 'discord', isActive: true }).populate('podId', 'name type'); if (fallback) integrations = [fallback]; } if (!integrations.length) return res.json({ type: 4, data: { content: 'โŒ Discord integration not found for this channel.', flags: 64 } }); if (integrations.length === 1) { const discordService = new DiscordService(integrations[0]._id); await discordService.initialize(); const result = await discordService.handleInteraction(interaction); if (result) { result.interactionToken = interactionToken; result.interactionId = interactionId; return res.json(result); } return res.json({ type: 4, data: { content: 'โŒ Command not recognized.', flags: 64 } }); } const commandResults = await runDiscordCommandForIntegrations({ commandName: data.name, integrations, guildId, channelId }) as Array<{ integration: typeof integrations[0]; result: { content: string; success: boolean; refused?: boolean } }>; const header = `๐Ÿ”— ${integrations.length} pods linked to this Discord channel`; const blocks = commandResults.map(({ integration, result }) => { const podName = (integration.podId as { name?: string })?.name || integration.config?.channelName || `Pod ${integration.podId || integration._id}`; return `**${podName}**\n${result.content}`; }); const response: Record = { type: 4, data: { content: [header, ...blocks].join('\n\n'), flags: combinedReplyIsPublic(commandResults) ? 0 : 64 } }; response.interactionToken = interactionToken; response.interactionId = interactionId; return res.json(response); } catch (error) { console.error('Error handling slash command:', error); return res.json({ type: 4, data: { content: 'โŒ An error occurred while processing the command.', flags: 64 } }); } } return res.json({ type: 4, data: { content: 'This interaction type is not supported.', flags: 64 } }); }); router.get('/install-link/:podId', listIntegrationsRateLimit, auth, async (req: AuthReq, res: Res) => { try { const { podId } = req.params || {}; // The instance's own application only. A caller-chosen client id would send // the bot add to an app whose callback is not ours. const clientId = process.env.DISCORD_CLIENT_ID; // The link starts a Discord bind, so it asks the rule the bind itself runs // (F143, services/chatRelay/bindSurface): a guild channel is a shared // surface, which takes the pod's creator or an instance admin. const pod = podId ? await Pod.findById(podId) : null; if (!pod) return res.status(403).json({ error: 'Access denied' }); try { await assertCanBindSurface({ userId: req.user?.id, pod, type: 'discord', config: {}, }); } catch (error) { if (error instanceof SurfaceBindRefusedError) return res.status(403).json({ error: 'Access denied' }); throw error; } if (!clientId) return res.status(400).json({ error: 'Discord client ID is required' }); // A code grant with our callback as the redirect. Without `response_type` // and `redirect_uri`, Discord adds the bot and stops on its own page, so the // callback never runs: no install proof, no channel picker, no binding (walked // on prod 2026-11-04). const installUrl = new URL('https://discord.com/api/oauth2/authorize'); installUrl.searchParams.set('client_id', String(clientId)); installUrl.searchParams.set('scope', 'bot applications.commands'); installUrl.searchParams.set('permissions', DISCORD_BOT_PERMISSIONS); installUrl.searchParams.set('response_type', 'code'); installUrl.searchParams.set('redirect_uri', discordRedirectUri()); installUrl.searchParams.set('state', mintDiscordInstallState({ podId: String(podId), userId: String(req.user?.id) })); res.json({ installUrl: installUrl.toString(), podId, status: 'ready' }); } catch { console.error('Error generating installation link'); res.status(500).json({ error: 'Failed to generate installation link' }); } }); router.get('/binding/:podId', auth, async (req: AuthReq, res: Res) => { try { const { podId } = req.params || {}; const canManage = await canManagePod(podId || '', req.user?.id || ''); if (!canManage) return res.status(403).json({ error: 'Access denied' }); const integration = await Integration.findOne({ podId, type: 'discord', isActive: true }); if (!integration) return res.status(404).json({ error: 'No Discord integration found for this pod' }); const discordIntegration = await DiscordIntegration.findOne({ integrationId: integration._id }); res.json({ integration, discordIntegration }); } catch (error) { console.error('Error getting Discord binding:', error); res.status(500).json({ error: 'Failed to get Discord binding' }); } }); router.delete('/uninstall/:installationId', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: Res) => { try { const { installationId } = req.params || {}; // `installationId` is no longer Discord-owned: Installable app projections // use it as their parent back-pointer too. Keep this destructive legacy // route provider-scoped so a Discord installation id can never delete a // connector row from another provider. const integration = await Integration.findOne({ installationId, type: 'discord' }) as { _id: unknown; createdBy?: { toString: () => string }; podId?: unknown } | null; if (!integration) return res.status(404).json({ error: 'Integration not found' }); const canManage = await canManageIntegration(integration, req.user?.id || ''); if (!canManage) return res.status(403).json({ error: 'Access denied' }); await DiscordIntegration.findOneAndDelete({ integrationId: integration._id }); await Integration.findByIdAndDelete(integration._id); res.json({ message: 'Discord integration removed successfully' }); } catch (error) { console.error('Error removing Discord integration:', error); res.status(500).json({ error: 'Failed to remove Discord integration' }); } }); router.post('/register-commands/:integrationId', auth, async (req: AuthReq, res: Res) => { try { const { integrationId } = req.params || {}; const integration = await Integration.findById(integrationId) as { type?: string; config?: { serverId?: string }; createdBy?: { toString: () => string }; podId?: unknown } | null; if (!integration) return res.status(404).json({ error: 'Integration not found' }); if (integration.type !== 'discord') return res.status(400).json({ error: 'Integration is not a Discord integration' }); const canManage = await canManageIntegration(integration, req.user?.id || ''); if (!canManage) return res.status(403).json({ error: 'Access denied' }); const guildId = integration.config?.serverId; if (!guildId) return res.status(400).json({ error: 'Server ID not found in integration config' }); const discordService = new DiscordService(integrationId); await discordService.initialize(); const success = await discordService.registerSlashCommands(guildId); if (success) res.json({ message: 'Slash commands registered successfully', commands: ['/commonly-summary', '/discord-status', '/discord-enable', '/discord-disable'] }); else res.status(500).json({ error: 'Failed to register slash commands' }); } catch (error) { console.error('Error registering slash commands:', error); res.status(500).json({ error: 'Failed to register slash commands' }); } }); router.get('/health', async (_req: AuthReq, res: Res) => { try { const { DISCORD_CLIENT_ID: clientId, DISCORD_BOT_TOKEN: botToken } = process.env; if (!clientId || !botToken) return res.json({ timestamp: new Date().toISOString(), status: 'no_credentials', message: 'Discord credentials not configured', summary: { total: 0, registered: 0, failed: 0 } }); const url = `https://discord.com/api/v10/applications/${clientId}/commands`; const response = await axios.get(url, { headers: { Authorization: `Bot ${botToken}` } }); if (response.status === 200) { const registeredCommands = (response.data as Array<{ name: string }>).map((cmd) => cmd.name); const expectedCommands = ['commonly-summary', 'discord-status', 'discord-enable', 'discord-disable']; const missingCommands = expectedCommands.filter((cmd) => !registeredCommands.includes(cmd)); return res.json({ timestamp: new Date().toISOString(), status: missingCommands.length === 0 ? 'healthy' : 'degraded', globalCommands: { registered: registeredCommands, missing: missingCommands, total: expectedCommands.length }, summary: { total: expectedCommands.length, registered: registeredCommands.length, failed: missingCommands.length } }); } throw new Error(`Discord API returned status ${response.status}`); } catch (error) { console.error('Error in Discord health check:', error); res.status(500).json({ timestamp: new Date().toISOString(), status: 'error', error: (error as Error).message }); } }); router.post('/register-all', auth, adminAuth, async (_req: AuthReq, res: Res) => { try { // eslint-disable-next-line global-require const DiscordServiceClass = require('../services/discordService'); const result = await DiscordServiceClass.registerCommandsForAllIntegrations() as { success: boolean }; res.json({ success: result.success, message: result.success ? 'All commands registered successfully' : 'Some commands failed to register', details: result }); } catch (error) { console.error('Error in bulk command registration:', error); res.status(500).json({ success: false, error: (error as Error).message }); } }); // TASK-108 (triage doc ยง6/ยง5): a third-party redirect target โ€” Discord sends a // browser here, so there is no token to key on and the IP tier is the only // tier it can have. 600/60s is DERIVED, not ruled (the ยง6 table gives one // budget, to the X callback, for this class), and it is deliberately generous: // a human clicking an install link must never meet this limiter, and the // machine-driven Discord surface is POST /interactions, which is a different // route with its own signature check. const discordCallbackLimit = platformIpRateLimit({ windowMs: 60_000, limit: 600, label: '600 Discord OAuth callback hits per 60s per IP', }); router.get('/callback', discordCallbackLimit, async (req: AuthReq, res: Res) => { const frontend = String(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/+$/, ''); try { const { code, state, guild_id: guildId } = req.query || {}; if (!code) return res.redirect(`${frontend}/discord/error?error=No authorization code received`); // A signed state from install-link, never a bare pod id (see // utils/discordInstallProof.ts). Refused before the code is exchanged. const installState = readDiscordInstallState(state); if (!installState) return res.redirect(`${frontend}/discord/error?error=Invalid state parameter`); const { podId, userId: installerId } = installState; // Unauthenticated route, so `guild_id` here is request-tainted input on a URL // built with the instance bot token. Refused before the code exchange: a // malformed id must not reach Discord at all, and the same value is forwarded // to the page, which posts it back to the integration write. if (isSupplied(guildId) && !isDiscordSnowflake(guildId)) { return res.redirect(`${frontend}/discord/error?error=Invalid guild id`); } const tokenResponse = await axios.post('https://discord.com/api/oauth2/token', { client_id: process.env.DISCORD_CLIENT_ID, client_secret: process.env.DISCORD_CLIENT_SECRET, grant_type: 'authorization_code', code, redirect_uri: discordRedirectUri() }, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); // The token response names the guild the bot was added to. `guild_id` in the // query is only Discord's hint and anyone can edit it, so a disagreement is a // refusal, and only the token response's guild earns an install proof (F89). const tokenGuildId = (tokenResponse?.data as { guild?: { id?: unknown } } | undefined)?.guild?.id; const provenGuildId = isDiscordSnowflake(tokenGuildId) ? String(tokenGuildId) : null; if (provenGuildId && isSupplied(guildId) && guildId !== provenGuildId) { return res.redirect(`${frontend}/discord/error?error=OAuth%20authorization%20failed`); } const installedGuildId = provenGuildId || guildId; let serverName = 'Unknown Server'; if (isDiscordSnowflake(installedGuildId)) { try { const guildResponse = await axios.get(`https://discord.com/api/guilds/${installedGuildId}`, { headers: { Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}` } }); serverName = (guildResponse.data as { name?: string }).name || serverName; } catch { // Discord may echo request credentials in its error body. A failed // post-exchange lookup is a failed callback, not a completed install. console.error('Discord OAuth guild lookup failed'); return res.redirect(`${frontend}/discord/error?error=OAuth%20authorization%20failed`); } } const successUrl = new URL(`${frontend}/discord/success`); successUrl.searchParams.append('pod_id', podId); successUrl.searchParams.append('guild_id', installedGuildId || ''); successUrl.searchParams.append('server_name', serverName); if (provenGuildId) successUrl.searchParams.append('install', mintDiscordInstallProof({ podId, guildId: provenGuildId, userId: installerId })); res.redirect(successUrl.toString()); } catch { console.error('Discord OAuth callback error'); res.redirect(`${frontend}/discord/error?error=OAuth%20authorization%20failed`); } }); module.exports = router; export {};