From f45c2798abbb4667d71f4da7f29442b467af0834 Mon Sep 17 00:00:00 2001 From: Liam Monaghan Date: Sat, 7 Feb 2026 15:49:02 -0500 Subject: [PATCH 01/11] feat: discord concierge oauth2 integration Replace invite link approach with Discord OAuth2 flow. Users now authorize via Discord to gain channel access, solving the issue of invite links not granting private channel permissions. Implements CSRF token validation, user/guild addition, and per-user channel access via permission overwrites. --- .../onboarding/DiscordConciergeLink.tsx | 3 +- .../src/app/oauth/callback/discord/page.tsx | 115 ++++++ .../createDiscordConciergeChannel.ts | 44 ++- platform/flowglad-next/src/utils/discord.ts | 198 +++++++--- .../src/utils/discord.unit.test.ts | 70 +++- .../src/utils/discordOAuthState.ts | 181 +++++++++ .../src/utils/discordOAuthState.unit.test.ts | 348 ++++++++++++++++++ platform/flowglad-next/src/utils/redis.ts | 5 + 8 files changed, 903 insertions(+), 61 deletions(-) create mode 100644 platform/flowglad-next/src/app/oauth/callback/discord/page.tsx create mode 100644 platform/flowglad-next/src/utils/discordOAuthState.ts create mode 100644 platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts diff --git a/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx b/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx index 2da0c0bff4..38e0a85c7b 100644 --- a/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx +++ b/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx @@ -14,8 +14,7 @@ export function DiscordConciergeLink() { const createChannel = trpc.organizations.createDiscordConciergeChannel.useMutation({ onSuccess: (data) => { - // Open Discord invite in new tab (noopener for security) - window.open(data.inviteUrl, '_blank', 'noopener,noreferrer') + window.open(data.oauthUrl, '_blank', 'noopener,noreferrer') setIsLoading(false) }, onError: (error) => { diff --git a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx new file mode 100644 index 0000000000..4c022938ca --- /dev/null +++ b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx @@ -0,0 +1,115 @@ +import { Result } from 'better-result' +import { redirect } from 'next/navigation' +import { authenticatedTransaction } from '@/db/authenticatedTransaction' +import { + addUserToGuild, + exchangeDiscordOAuthCode, + getDiscordChannelUrl, + getDiscordConfig, + getDiscordUserFromToken, + grantChannelAccess, +} from '@/utils/discord' +import { + decodeDiscordOAuthState, + validateAndConsumeDiscordOAuthCsrfToken, +} from '@/utils/discordOAuthState' + +export default async function DiscordOAuthCallbackPage({ + searchParams, +}: { + searchParams: Promise<{ code?: string; state?: string }> +}) { + const { code, state } = await searchParams + + if (!code || !state) { + redirect('/onboarding') + } + + try { + console.log( + '[Discord OAuth] Starting callback, code length:', + code.length, + 'state length:', + state.length + ) + + const csrfToken = decodeDiscordOAuthState(state) + console.log( + '[Discord OAuth] Decoded CSRF token, prefix:', + csrfToken.substring(0, 4) + ) + + const userId = ( + await authenticatedTransaction(async ({ userId }) => + Result.ok(userId) + ) + ).unwrap() + console.log('[Discord OAuth] Authenticated user:', userId) + + const validation = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken, + expectedUserId: userId, + }) + console.log('[Discord OAuth] CSRF validation result:', validation) + + if (!validation) { + throw new Error('CSRF validation failed') + } + + const config = getDiscordConfig() + console.log('[Discord OAuth] Exchanging code for token...') + const tokenResult = await exchangeDiscordOAuthCode({ + code, + config, + }) + console.log( + '[Discord OAuth] Token exchange successful, token type:', + tokenResult.token_type + ) + + const discordUser = await getDiscordUserFromToken( + tokenResult.access_token + ) + console.log( + '[Discord OAuth] Discord user:', + discordUser.id, + discordUser.username + ) + + console.log( + '[Discord OAuth] Adding user to guild:', + config.guildId + ) + await addUserToGuild({ + guildId: config.guildId, + discordUserId: discordUser.id, + accessToken: tokenResult.access_token, + config, + }) + console.log('[Discord OAuth] User added to guild') + + console.log( + '[Discord OAuth] Granting channel access:', + validation.channelId + ) + await grantChannelAccess({ + channelId: validation.channelId, + discordUserId: discordUser.id, + config, + }) + console.log('[Discord OAuth] Channel access granted') + + const channelUrl = getDiscordChannelUrl( + config.guildId, + validation.channelId + ) + console.log('[Discord OAuth] Redirecting to:', channelUrl) + redirect(channelUrl) + } catch (error) { + if (error instanceof Error && error.message === 'NEXT_REDIRECT') { + throw error + } + console.error('[Discord OAuth] Error:', error) + redirect('/onboarding?error=discord_connection_failed') + } +} diff --git a/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts b/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts index 1b7582425d..f16f2e3bb2 100644 --- a/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts +++ b/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts @@ -6,15 +6,24 @@ import { z } from 'zod' import { adminTransaction } from '@/db/adminTransaction' import { selectOrganizationById } from '@/db/tableMethods/organizationMethods' import { protectedProcedure } from '@/server/trpc' -import { getOrCreateConciergeChannel } from '@/utils/discord' +import { + buildDiscordOAuthUrl, + getDiscordConfig, + getOrCreateConciergeChannel, +} from '@/utils/discord' +import { + createDiscordOAuthCsrfToken, + encodeDiscordOAuthState, +} from '@/utils/discordOAuthState' export const createDiscordConciergeChannelSchema = z.object({}) export const createDiscordConciergeChannel = protectedProcedure .input(createDiscordConciergeChannelSchema) - .output(z.object({ inviteUrl: z.string() })) + .output(z.object({ oauthUrl: z.string() })) .mutation(async ({ ctx }) => { const { organizationId } = ctx + const userId = ctx.user!.id if (!organizationId) { throw new TRPCError({ @@ -32,11 +41,12 @@ export const createDiscordConciergeChannel = protectedProcedure ).unwrap() // Create or get concierge channel (pass existing ID for fast lookup) - const { channelId, inviteUrl } = - await getOrCreateConciergeChannel( - organization.name, - organization.discordConciergeChannelId - ) + const { channelId } = await getOrCreateConciergeChannel( + organization.name, + organization.discordConciergeChannelId + ) + + let finalChannelId = channelId // Persist channel ID using conditional update to prevent race conditions. // If a concurrent request already set the channel ID, the WHERE clause @@ -91,17 +101,23 @@ export const createDiscordConciergeChannel = protectedProcedure persistResult.unwrap() if (raceResolved && winnerChannelId) { - // Return invite for the channel that was persisted first - const winner = await getOrCreateConciergeChannel( - organization.name, - winnerChannelId - ) - return { inviteUrl: winner.inviteUrl } + finalChannelId = winnerChannelId } } - return { inviteUrl } + // Generate OAuth URL so the user can authorize and get channel access + const config = getDiscordConfig() + const csrfToken = await createDiscordOAuthCsrfToken({ + userId, + organizationId, + channelId: finalChannelId, + }) + const state = encodeDiscordOAuthState(csrfToken) + const oauthUrl = buildDiscordOAuthUrl({ state, config }) + + return { oauthUrl } } catch (error) { + console.error('[Discord Mutation] Error:', error) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create Discord channel', diff --git a/platform/flowglad-next/src/utils/discord.ts b/platform/flowglad-next/src/utils/discord.ts index c98c14ea12..51443340d7 100644 --- a/platform/flowglad-next/src/utils/discord.ts +++ b/platform/flowglad-next/src/utils/discord.ts @@ -1,22 +1,24 @@ import { REST } from '@discordjs/rest' import { type APIChannel, - type APIExtendedInvite, type APIMessage, type APIOverwrite, + type APIUser, ChannelType, + OAuth2Routes, OverwriteType, PermissionFlagsBits, - type RESTPostAPIChannelInviteJSONBody, type RESTPostAPIChannelMessageJSONBody, type RESTPostAPIGuildChannelJSONBody, + type RESTPostOAuth2AccessTokenResult, + type RESTPutAPIChannelPermissionJSONBody, + type RESTPutAPIGuildMemberJSONBody, Routes, } from 'discord-api-types/v10' import { panic } from '@/errors' export interface ConciergeChannelResult { channelId: string - inviteUrl: string } export interface DiscordConfig { @@ -24,6 +26,9 @@ export interface DiscordConfig { guildId: string conciergeCategoryPrefix: string flowgladTeamRoleId?: string + oauthClientId?: string + oauthClientSecret?: string + oauthRedirectUri?: string } const DISCORD_CATEGORY_CHANNEL_LIMIT = 50 @@ -53,11 +58,18 @@ export function getDiscordConfig(): DiscordConfig { panic('DISCORD_GUILD_ID environment variable is required') } + const oauthClientId = process.env.DISCORD_OAUTH_CLIENT_ID + const oauthClientSecret = process.env.DISCORD_OAUTH_CLIENT_SECRET + const oauthRedirectUri = process.env.DISCORD_OAUTH_REDIRECT_URI + return { botToken, guildId, conciergeCategoryPrefix, flowgladTeamRoleId, + oauthClientId, + oauthClientSecret, + oauthRedirectUri, } } @@ -267,6 +279,15 @@ async function createPrivateChannel( permission_overwrites: permissionOverwrites, } + console.log('[Discord] createPrivateChannel:', { + name, + categoryId, + botUserId, + guildId, + permissionOverwrites: JSON.stringify(permissionOverwrites), + flowgladTeamRoleId: config.flowgladTeamRoleId, + }) + return (await rest.post(Routes.guildChannels(guildId), { body, })) as APIChannel @@ -318,42 +339,6 @@ async function postWelcomeMessage( })) as APIMessage } -/** - * Get channel invites and find a valid one, or create a new invite. - */ -async function getOrCreateInvite( - rest: REST, - channelId: string -): Promise { - // Fetch existing invites - const invites = (await rest.get( - Routes.channelInvites(channelId) - )) as APIExtendedInvite[] - - // Find a valid invite (unlimited uses, not expired) - const existingInvite = invites.find( - (inv) => - inv.max_uses === 0 && - (!inv.expires_at || new Date(inv.expires_at) > new Date()) - ) - - if (existingInvite) { - return `https://discord.gg/${existingInvite.code}` - } - - // Create new invite: 7 days, unlimited uses - const body: RESTPostAPIChannelInviteJSONBody = { - max_age: 604800, // 7 days in seconds - max_uses: 0, // unlimited - unique: false, - } - - const invite = (await rest.post(Routes.channelInvites(channelId), { - body, - })) as APIExtendedInvite - return `https://discord.gg/${invite.code}` -} - /** * Main function: Get or create a concierge channel for an organization. * If existingChannelId is provided, tries to find that channel first. @@ -396,11 +381,138 @@ export async function getOrCreateConciergeChannel( await postWelcomeMessage(rest, channel.id, orgName, config) } - // Get or create invite - const inviteUrl = await getOrCreateInvite(rest, channel.id) - return { channelId: channel.id, - inviteUrl, } } + +/** + * Build the Discord OAuth2 authorization URL. + * Requires oauthClientId and oauthRedirectUri in config. + */ +export function buildDiscordOAuthUrl(params: { + state: string + config: DiscordConfig +}): string { + const { state, config } = params + if (!config.oauthClientId || !config.oauthRedirectUri) { + panic( + 'DISCORD_OAUTH_CLIENT_ID and DISCORD_OAUTH_REDIRECT_URI are required' + ) + } + const url = new URL(OAuth2Routes.authorizationURL) + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', config.oauthClientId) + url.searchParams.set('scope', 'identify guilds.join') + url.searchParams.set('redirect_uri', config.oauthRedirectUri) + url.searchParams.set('state', state) + url.searchParams.set('prompt', 'consent') + return url.toString() +} + +/** + * Exchange an OAuth2 authorization code for an access token. + * Uses raw fetch (not the bot REST client) because this needs client credentials. + */ +export async function exchangeDiscordOAuthCode(params: { + code: string + config: DiscordConfig +}): Promise { + const { code, config } = params + if ( + !config.oauthClientId || + !config.oauthClientSecret || + !config.oauthRedirectUri + ) { + panic('Discord OAuth credentials are required') + } + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: config.oauthRedirectUri, + client_id: config.oauthClientId, + client_secret: config.oauthClientSecret, + }) + const response = await fetch(OAuth2Routes.tokenURL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + if (!response.ok) { + const errorText = await response.text() + panic( + `Discord OAuth token exchange failed: ${response.status} ${errorText}` + ) + } + return response.json() as Promise +} + +/** + * Get the Discord user associated with an OAuth2 access token. + * Uses raw fetch with Bearer auth (not the bot REST client). + */ +export async function getDiscordUserFromToken( + accessToken: string +): Promise { + const response = await fetch( + 'https://discord.com/api/v10/users/@me', + { + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + if (!response.ok) { + panic(`Failed to fetch Discord user: ${response.status}`) + } + return response.json() as Promise +} + +/** + * Add a user to a guild using their OAuth2 access token. + * Returns silently whether the user was newly added (201) or already a member (204). + */ +export async function addUserToGuild(params: { + guildId: string + discordUserId: string + accessToken: string + config: DiscordConfig +}): Promise { + const { guildId, discordUserId, accessToken, config } = params + const rest = getRestClient(config.botToken) + const body: RESTPutAPIGuildMemberJSONBody = { + access_token: accessToken, + } + await rest.put(Routes.guildMember(guildId, discordUserId), { body }) +} + +/** + * Grant a user ViewChannel + SendMessages on a private channel. + */ +export async function grantChannelAccess(params: { + channelId: string + discordUserId: string + config: DiscordConfig +}): Promise { + const { channelId, discordUserId, config } = params + const rest = getRestClient(config.botToken) + const body: RESTPutAPIChannelPermissionJSONBody = { + allow: ( + PermissionFlagsBits.ViewChannel | + PermissionFlagsBits.SendMessages + ).toString(), + deny: '0', + type: OverwriteType.Member, + } + await rest.put(Routes.channelPermission(channelId, discordUserId), { + body, + }) +} + +/** + * Build the Discord channel URL for direct navigation. + */ +export function getDiscordChannelUrl( + guildId: string, + channelId: string +): string { + return `https://discord.com/channels/${guildId}/${channelId}` +} diff --git a/platform/flowglad-next/src/utils/discord.unit.test.ts b/platform/flowglad-next/src/utils/discord.unit.test.ts index c5d1b20044..a61aee8b04 100644 --- a/platform/flowglad-next/src/utils/discord.unit.test.ts +++ b/platform/flowglad-next/src/utils/discord.unit.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { + buildDiscordOAuthUrl, buildWelcomeMessage, + type DiscordConfig, + getDiscordChannelUrl, getDiscordConfig, parseCohortNumber, sanitizeChannelName, @@ -210,10 +213,9 @@ describe('discord', () => { }) describe('buildWelcomeMessage', () => { - it('includes @here mention and onboarding link', () => { + it('includes onboarding link', () => { const message = buildWelcomeMessage('Acme Corp') - expect(message).toContain('@here') expect(message).toContain('https://app.flowglad.com/onboarding') }) @@ -240,4 +242,68 @@ describe('discord', () => { expect(message).not.toContain('<@&') }) }) + + describe('buildDiscordOAuthUrl', () => { + const baseConfig: DiscordConfig = { + botToken: 'test-bot-token', + guildId: 'test-guild-id', + conciergeCategoryPrefix: 'Concierge Cohort', + oauthClientId: 'test-client-id', + oauthClientSecret: 'test-client-secret', + oauthRedirectUri: + 'http://localhost:3000/oauth/callback/discord', + } + + it('builds a valid Discord OAuth2 authorization URL with required params', () => { + const url = buildDiscordOAuthUrl({ + state: 'test-state', + config: baseConfig, + }) + + const parsed = new URL(url) + expect(parsed.origin).toBe('https://discord.com') + expect(parsed.searchParams.get('response_type')).toBe('code') + expect(parsed.searchParams.get('client_id')).toBe( + 'test-client-id' + ) + expect(parsed.searchParams.get('scope')).toBe( + 'identify guilds.join' + ) + expect(parsed.searchParams.get('redirect_uri')).toBe( + 'http://localhost:3000/oauth/callback/discord' + ) + expect(parsed.searchParams.get('state')).toBe('test-state') + expect(parsed.searchParams.get('prompt')).toBe('consent') + }) + + it('throws when oauthClientId is missing', () => { + const config = { ...baseConfig, oauthClientId: undefined } + + expect(() => + buildDiscordOAuthUrl({ state: 'test', config }) + ).toThrow( + 'DISCORD_OAUTH_CLIENT_ID and DISCORD_OAUTH_REDIRECT_URI are required' + ) + }) + + it('throws when oauthRedirectUri is missing', () => { + const config = { ...baseConfig, oauthRedirectUri: undefined } + + expect(() => + buildDiscordOAuthUrl({ state: 'test', config }) + ).toThrow( + 'DISCORD_OAUTH_CLIENT_ID and DISCORD_OAUTH_REDIRECT_URI are required' + ) + }) + }) + + describe('getDiscordChannelUrl', () => { + it('returns the correct Discord channel URL format', () => { + const url = getDiscordChannelUrl('guild-123', 'channel-456') + + expect(url).toBe( + 'https://discord.com/channels/guild-123/channel-456' + ) + }) + }) }) diff --git a/platform/flowglad-next/src/utils/discordOAuthState.ts b/platform/flowglad-next/src/utils/discordOAuthState.ts new file mode 100644 index 0000000000..cbdf26e725 --- /dev/null +++ b/platform/flowglad-next/src/utils/discordOAuthState.ts @@ -0,0 +1,181 @@ +import { timingSafeEqual } from 'crypto' +import { z } from 'zod' +import { panic } from '@/errors' +import { generateRandomBytes } from './backendCore' +import { logger } from './logger' +import { RedisKeyNamespace, redis } from './redis' + +/** + * CSRF token length in bytes (32 bytes = 256 bits of entropy) + */ +const CSRF_TOKEN_BYTES = 32 + +/** + * TTL for CSRF tokens in seconds (15 minutes) + */ +const CSRF_TTL_SECONDS = 60 * 15 + +/** + * Schema for CSRF token data stored in Redis + */ +const csrfTokenDataSchema = z.object({ + userId: z.string().min(1), + organizationId: z.string().min(1), + channelId: z.string().min(1), + createdAt: z.string().datetime(), +}) + +type CsrfTokenData = z.infer + +/** + * Build Redis key for CSRF token storage + */ +function buildCsrfTokenKey(token: string): string { + return `${RedisKeyNamespace.DiscordOAuthCsrfToken}:${token}` +} + +/** + * Timing-safe string comparison to prevent timing attacks + */ +function safeCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a, 'utf8') + const bufB = Buffer.from(b, 'utf8') + if (bufA.length !== bufB.length) { + return false + } + return timingSafeEqual(bufA, bufB) +} + +/** + * Creates a cryptographically random CSRF token and stores it in Redis + * with the associated user, organization, and channel context. + */ +export async function createDiscordOAuthCsrfToken(params: { + userId: string + organizationId: string + channelId: string +}): Promise { + const { userId, organizationId, channelId } = params + + const csrfToken = generateRandomBytes(CSRF_TOKEN_BYTES) + + const tokenData: CsrfTokenData = { + userId, + organizationId, + channelId, + createdAt: new Date().toISOString(), + } + + const key = buildCsrfTokenKey(csrfToken) + + try { + const redisClient = redis() + await redisClient.set(key, JSON.stringify(tokenData), { + ex: CSRF_TTL_SECONDS, + }) + + logger.info('Discord OAuth CSRF token created', { + userId, + organizationId, + channelId, + tokenPrefix: csrfToken.substring(0, 4), + }) + + return csrfToken + } catch (error) { + logger.error('Failed to store Discord OAuth CSRF token', { + error: error instanceof Error ? error.message : String(error), + userId, + organizationId, + }) + panic('Unable to initiate Discord OAuth flow') + } +} + +/** + * Validates and consumes a CSRF token. This is a single-use operation — + * the token is atomically retrieved and deleted from Redis. + */ +export async function validateAndConsumeDiscordOAuthCsrfToken(params: { + csrfToken: string + expectedUserId: string +}): Promise<{ organizationId: string; channelId: string } | null> { + const { csrfToken, expectedUserId } = params + const key = buildCsrfTokenKey(csrfToken) + + try { + const redisClient = redis() + + const rawData = await redisClient.getdel(key) + + if (!rawData) { + logger.warn( + 'Discord OAuth CSRF token not found or already consumed', + { + tokenPrefix: csrfToken.substring(0, 4), + expectedUserId, + } + ) + return null + } + + const jsonData = + typeof rawData === 'string' ? JSON.parse(rawData) : rawData + const parseResult = csrfTokenDataSchema.safeParse(jsonData) + + if (!parseResult.success) { + logger.warn('Discord OAuth CSRF token data invalid', { + tokenPrefix: csrfToken.substring(0, 4), + error: parseResult.error.message, + }) + return null + } + + const tokenData = parseResult.data + + if (!safeCompare(tokenData.userId, expectedUserId)) { + logger.warn('Discord OAuth CSRF token user mismatch', { + tokenPrefix: csrfToken.substring(0, 4), + expectedUserId, + }) + return null + } + + logger.info('Discord OAuth CSRF token validated', { + userId: expectedUserId, + organizationId: tokenData.organizationId, + channelId: tokenData.channelId, + tokenPrefix: csrfToken.substring(0, 4), + }) + + return { + organizationId: tokenData.organizationId, + channelId: tokenData.channelId, + } + } catch (error) { + logger.error('Error validating Discord OAuth CSRF token', { + error: error instanceof Error ? error.message : String(error), + tokenPrefix: csrfToken.substring(0, 4), + }) + return null + } +} + +/** + * Encodes a CSRF token for use in the OAuth state parameter. + */ +export function encodeDiscordOAuthState(csrfToken: string): string { + return Buffer.from(csrfToken, 'utf8').toString('base64') +} + +/** + * Decodes the OAuth state parameter to extract the CSRF token. + */ +export function decodeDiscordOAuthState(state: string): string { + try { + const decoded = decodeURIComponent(state) + return Buffer.from(decoded, 'base64').toString('utf8') + } catch { + panic('Invalid OAuth state parameter') + } +} diff --git a/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts b/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts new file mode 100644 index 0000000000..ce2835b494 --- /dev/null +++ b/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts @@ -0,0 +1,348 @@ +import { beforeEach, describe, expect, it, mock } from 'bun:test' + +/** + * In-memory Redis mock for testing CSRF token storage. + */ +const mockRedisStore: Map = new Map() + +const mockGenerateRandomBytes = mock( + () => 'mock-csrf-token-32-bytes-long-xx' +) + +mock.module('./redis', () => ({ + redis: () => ({ + get: mock((key: string) => mockRedisStore.get(key) || null), + getdel: mock((key: string) => { + const value = mockRedisStore.get(key) || null + mockRedisStore.delete(key) + return value + }), + set: mock( + (key: string, value: string, _options?: { ex?: number }) => { + mockRedisStore.set(key, value) + return 'OK' + } + ), + del: mock((key: string) => { + const existed = mockRedisStore.has(key) + mockRedisStore.delete(key) + return existed ? 1 : 0 + }), + }), + RedisKeyNamespace: { + DiscordOAuthCsrfToken: 'discordOAuthCsrfToken', + }, +})) + +mock.module('./backendCore', () => ({ + generateRandomBytes: mockGenerateRandomBytes, +})) + +mock.module('./logger', () => ({ + logger: { + info: mock(), + warn: mock(), + error: mock(), + }, +})) + +import { + createDiscordOAuthCsrfToken, + decodeDiscordOAuthState, + encodeDiscordOAuthState, + validateAndConsumeDiscordOAuthCsrfToken, +} from './discordOAuthState' + +describe('discordOAuthState', () => { + beforeEach(() => { + mockRedisStore.clear() + mockGenerateRandomBytes.mockReset() + mockGenerateRandomBytes.mockImplementation( + () => 'mock-csrf-token-32-bytes-long-xx' + ) + }) + + describe('encodeDiscordOAuthState', () => { + it('encodes a CSRF token to base64', () => { + const token = 'test-csrf-token' + const encoded = encodeDiscordOAuthState(token) + + expect(encoded).toBe( + Buffer.from(token, 'utf8').toString('base64') + ) + }) + + it('produces URL-safe output', () => { + const token = 'token-with-special+chars/=test' + const encoded = encodeDiscordOAuthState(token) + + expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/) + }) + }) + + describe('decodeDiscordOAuthState', () => { + it('decodes a base64-encoded state parameter', () => { + const token = 'test-csrf-token' + const encoded = Buffer.from(token, 'utf8').toString('base64') + + const decoded = decodeDiscordOAuthState(encoded) + expect(decoded).toBe(token) + }) + + it('handles URL-encoded state parameters', () => { + const token = 'test-csrf-token' + const encoded = Buffer.from(token, 'utf8').toString('base64') + const urlEncoded = encodeURIComponent(encoded) + + const decoded = decodeDiscordOAuthState(urlEncoded) + expect(decoded).toBe(token) + }) + + it('roundtrips with encodeDiscordOAuthState', () => { + const originalToken = 'my-secret-csrf-token-12345' + const encoded = encodeDiscordOAuthState(originalToken) + const decoded = decodeDiscordOAuthState(encoded) + + expect(decoded).toBe(originalToken) + }) + + it('throws on invalid base64 input', () => { + const invalidState = '%%%invalid%%%' + + expect(() => decodeDiscordOAuthState(invalidState)).toThrow( + 'Invalid OAuth state parameter' + ) + }) + }) + + describe('createDiscordOAuthCsrfToken', () => { + it('creates a token and stores it in Redis with userId, organizationId, and channelId', async () => { + const userId = 'user-123' + const organizationId = 'org-456' + const channelId = 'channel-789' + + const token = await createDiscordOAuthCsrfToken({ + userId, + organizationId, + channelId, + }) + + expect(token).toBe('mock-csrf-token-32-bytes-long-xx') + + const storedData = mockRedisStore.get( + `discordOAuthCsrfToken:${token}` + ) + + const parsedData = JSON.parse(storedData!) + expect(parsedData.userId).toBe(userId) + expect(parsedData.organizationId).toBe(organizationId) + expect(parsedData.channelId).toBe(channelId) + expect(typeof parsedData.createdAt).toBe('string') + }) + + it('stores createdAt timestamp in ISO format', async () => { + const token = await createDiscordOAuthCsrfToken({ + userId: 'user-123', + organizationId: 'org-456', + channelId: 'channel-789', + }) + + const storedData = mockRedisStore.get( + `discordOAuthCsrfToken:${token}` + ) + const parsedData = JSON.parse(storedData!) + + const parsedDate = new Date(parsedData.createdAt) + expect(parsedDate.toISOString()).toBe(parsedData.createdAt) + }) + }) + + describe('validateAndConsumeDiscordOAuthCsrfToken', () => { + const userId = 'user-123' + const organizationId = 'org-456' + const channelId = 'channel-789' + const testToken = 'test-csrf-token' + + beforeEach(() => { + const tokenData = { + userId, + organizationId, + channelId, + createdAt: new Date().toISOString(), + } + mockRedisStore.set( + `discordOAuthCsrfToken:${testToken}`, + JSON.stringify(tokenData) + ) + }) + + it('validates and returns organizationId and channelId for valid token and user', async () => { + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: testToken, + expectedUserId: userId, + }) + + expect(result).toEqual({ organizationId, channelId }) + }) + + it('deletes the token after validation (single-use)', async () => { + expect( + mockRedisStore.has(`discordOAuthCsrfToken:${testToken}`) + ).toBe(true) + + await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: testToken, + expectedUserId: userId, + }) + + expect( + mockRedisStore.has(`discordOAuthCsrfToken:${testToken}`) + ).toBe(false) + }) + + it('returns null for non-existent token', async () => { + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: 'non-existent-token', + expectedUserId: userId, + }) + + expect(result).toBeNull() + }) + + it('returns null and deletes token when user ID does not match', async () => { + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: testToken, + expectedUserId: 'different-user', + }) + + expect(result).toBeNull() + expect( + mockRedisStore.has(`discordOAuthCsrfToken:${testToken}`) + ).toBe(false) + }) + + it('returns null for invalid token data format', async () => { + mockRedisStore.set( + `discordOAuthCsrfToken:invalid-token`, + JSON.stringify({ invalid: 'data' }) + ) + + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: 'invalid-token', + expectedUserId: userId, + }) + + expect(result).toBeNull() + }) + + it('returns null when token data is missing required fields', async () => { + mockRedisStore.set( + `discordOAuthCsrfToken:incomplete-token`, + JSON.stringify({ + userId: 'user-123', + organizationId: 'org-456', + // missing channelId and createdAt + }) + ) + + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: 'incomplete-token', + expectedUserId: 'user-123', + }) + + expect(result).toBeNull() + }) + + it('cannot reuse the same token twice', async () => { + const firstResult = + await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: testToken, + expectedUserId: userId, + }) + expect(firstResult).toEqual({ organizationId, channelId }) + + const secondResult = + await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: testToken, + expectedUserId: userId, + }) + expect(secondResult).toBeNull() + }) + + it('handles Redis returning object instead of string', async () => { + const tokenData = { + userId, + organizationId, + channelId, + createdAt: new Date().toISOString(), + } + + mockRedisStore.set( + `discordOAuthCsrfToken:object-token`, + tokenData as unknown as string + ) + + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: 'object-token', + expectedUserId: userId, + }) + + expect(result).toEqual({ organizationId, channelId }) + }) + }) + + describe('full OAuth flow integration', () => { + beforeEach(() => { + mockGenerateRandomBytes.mockReturnValue( + 'flow-test-token-abc123' + ) + }) + + it('completes full create-encode-decode-validate flow', async () => { + const userId = 'user-flow-test' + const organizationId = 'org-flow-test' + const channelId = 'channel-flow-test' + + const csrfToken = await createDiscordOAuthCsrfToken({ + userId, + organizationId, + channelId, + }) + + const state = encodeDiscordOAuthState(csrfToken) + + const decodedToken = decodeDiscordOAuthState(state) + expect(decodedToken).toBe(csrfToken) + + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: decodedToken, + expectedUserId: userId, + }) + + expect(result).toEqual({ organizationId, channelId }) + }) + + it('prevents CSRF attack with wrong user', async () => { + const legitimateUserId = 'legitimate-user' + const attackerUserId = 'attacker-user' + const organizationId = 'target-org' + const channelId = 'target-channel' + + const csrfToken = await createDiscordOAuthCsrfToken({ + userId: legitimateUserId, + organizationId, + channelId, + }) + + const state = encodeDiscordOAuthState(csrfToken) + const decodedToken = decodeDiscordOAuthState(state) + + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: decodedToken, + expectedUserId: attackerUserId, + }) + + expect(result).toBeNull() + }) + }) +}) diff --git a/platform/flowglad-next/src/utils/redis.ts b/platform/flowglad-next/src/utils/redis.ts index 4297a1d9df..94a0a1ae35 100644 --- a/platform/flowglad-next/src/utils/redis.ts +++ b/platform/flowglad-next/src/utils/redis.ts @@ -166,6 +166,7 @@ export enum RedisKeyNamespace { Telemetry = 'telemetry', BannerDismissals = 'bannerDismissals', StripeOAuthCsrfToken = 'stripeOAuthCsrfToken', + DiscordOAuthCsrfToken = 'discordOAuthCsrfToken', SubscriptionsByCustomer = 'subscriptionsByCustomer', ItemsBySubscription = 'itemsBySubscription', FeaturesBySubscriptionItem = 'featuresBySubscriptionItem', @@ -209,6 +210,10 @@ const evictionPolicy: Record< max: 10000, // up to 10k concurrent OAuth flows ttl: 60 * 15, // 15 minutes - OAuth flow timeout }, + [RedisKeyNamespace.DiscordOAuthCsrfToken]: { + max: 10000, + ttl: 60 * 15, + }, [RedisKeyNamespace.SubscriptionsByCustomer]: { max: 50000, }, From 98b5a1a1aaf96fb63d8a6189b251420d25543a3c Mon Sep 17 00:00:00 2001 From: Liam Monaghan Date: Sat, 7 Feb 2026 16:03:44 -0500 Subject: [PATCH 02/11] updated redirect to use router (prevents popup block) --- .../src/app/(merchant)/onboarding/DiscordConciergeLink.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx b/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx index 38e0a85c7b..96600948eb 100644 --- a/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx +++ b/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx @@ -1,5 +1,6 @@ 'use client' +import { useRouter } from 'next/navigation' import { useState } from 'react' import { trpc } from '@/app/_trpc/client' import { Button } from '@/components/ui/button' @@ -10,11 +11,12 @@ export function DiscordConciergeLink() { const [isLoading, setIsLoading] = useState(false) const [hasError, setHasError] = useState(false) const { organization } = useAuthContext() + const router = useRouter() const createChannel = trpc.organizations.createDiscordConciergeChannel.useMutation({ onSuccess: (data) => { - window.open(data.oauthUrl, '_blank', 'noopener,noreferrer') + router.push(data.oauthUrl) setIsLoading(false) }, onError: (error) => { From e042e57a3c6b2be589a3f07f5aa2a97a42d8d0fa Mon Sep 17 00:00:00 2001 From: Liam Monaghan Date: Sat, 7 Feb 2026 16:05:39 -0500 Subject: [PATCH 03/11] fix: move redirect outside try/catch and remove debug logs in Discord OAuth callback Eliminates fragile NEXT_REDIRECT string check by computing the redirect URL inside try/catch and calling redirect() after. Replaces console.log debug statements with logger.error for the error path. --- .../src/app/oauth/callback/discord/page.tsx | 51 ++++--------------- 1 file changed, 10 insertions(+), 41 deletions(-) diff --git a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx index 4c022938ca..0e35188bdc 100644 --- a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx +++ b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx @@ -13,6 +13,7 @@ import { decodeDiscordOAuthState, validateAndConsumeDiscordOAuthCsrfToken, } from '@/utils/discordOAuthState' +import { logger } from '@/utils/logger' export default async function DiscordOAuthCallbackPage({ searchParams, @@ -25,91 +26,59 @@ export default async function DiscordOAuthCallbackPage({ redirect('/onboarding') } - try { - console.log( - '[Discord OAuth] Starting callback, code length:', - code.length, - 'state length:', - state.length - ) + let redirectUrl: string + try { const csrfToken = decodeDiscordOAuthState(state) - console.log( - '[Discord OAuth] Decoded CSRF token, prefix:', - csrfToken.substring(0, 4) - ) const userId = ( await authenticatedTransaction(async ({ userId }) => Result.ok(userId) ) ).unwrap() - console.log('[Discord OAuth] Authenticated user:', userId) const validation = await validateAndConsumeDiscordOAuthCsrfToken({ csrfToken, expectedUserId: userId, }) - console.log('[Discord OAuth] CSRF validation result:', validation) if (!validation) { throw new Error('CSRF validation failed') } const config = getDiscordConfig() - console.log('[Discord OAuth] Exchanging code for token...') const tokenResult = await exchangeDiscordOAuthCode({ code, config, }) - console.log( - '[Discord OAuth] Token exchange successful, token type:', - tokenResult.token_type - ) const discordUser = await getDiscordUserFromToken( tokenResult.access_token ) - console.log( - '[Discord OAuth] Discord user:', - discordUser.id, - discordUser.username - ) - console.log( - '[Discord OAuth] Adding user to guild:', - config.guildId - ) await addUserToGuild({ guildId: config.guildId, discordUserId: discordUser.id, accessToken: tokenResult.access_token, config, }) - console.log('[Discord OAuth] User added to guild') - console.log( - '[Discord OAuth] Granting channel access:', - validation.channelId - ) await grantChannelAccess({ channelId: validation.channelId, discordUserId: discordUser.id, config, }) - console.log('[Discord OAuth] Channel access granted') - const channelUrl = getDiscordChannelUrl( + redirectUrl = getDiscordChannelUrl( config.guildId, validation.channelId ) - console.log('[Discord OAuth] Redirecting to:', channelUrl) - redirect(channelUrl) } catch (error) { - if (error instanceof Error && error.message === 'NEXT_REDIRECT') { - throw error - } - console.error('[Discord OAuth] Error:', error) - redirect('/onboarding?error=discord_connection_failed') + logger.error('Discord OAuth callback failed', { + error: error instanceof Error ? error.message : String(error), + }) + redirectUrl = '/onboarding?error=discord_connection_failed' } + + redirect(redirectUrl) } From 66ee6bcefe9c2bfaf4314c6a8bd885d5e937a8ed Mon Sep 17 00:00:00 2001 From: Liam Monaghan Date: Sat, 7 Feb 2026 16:07:50 -0500 Subject: [PATCH 04/11] chore: remove all console.log and console.error debug statements from Discord OAuth --- .../src/app/oauth/callback/discord/page.tsx | 7 +------ .../server/mutations/createDiscordConciergeChannel.ts | 1 - platform/flowglad-next/src/utils/discord.ts | 9 --------- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx index 0e35188bdc..2e1a08cc6f 100644 --- a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx +++ b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx @@ -13,8 +13,6 @@ import { decodeDiscordOAuthState, validateAndConsumeDiscordOAuthCsrfToken, } from '@/utils/discordOAuthState' -import { logger } from '@/utils/logger' - export default async function DiscordOAuthCallbackPage({ searchParams, }: { @@ -73,10 +71,7 @@ export default async function DiscordOAuthCallbackPage({ config.guildId, validation.channelId ) - } catch (error) { - logger.error('Discord OAuth callback failed', { - error: error instanceof Error ? error.message : String(error), - }) + } catch { redirectUrl = '/onboarding?error=discord_connection_failed' } diff --git a/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts b/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts index f16f2e3bb2..4279c3ffd0 100644 --- a/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts +++ b/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts @@ -117,7 +117,6 @@ export const createDiscordConciergeChannel = protectedProcedure return { oauthUrl } } catch (error) { - console.error('[Discord Mutation] Error:', error) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create Discord channel', diff --git a/platform/flowglad-next/src/utils/discord.ts b/platform/flowglad-next/src/utils/discord.ts index 51443340d7..8342874ff7 100644 --- a/platform/flowglad-next/src/utils/discord.ts +++ b/platform/flowglad-next/src/utils/discord.ts @@ -279,15 +279,6 @@ async function createPrivateChannel( permission_overwrites: permissionOverwrites, } - console.log('[Discord] createPrivateChannel:', { - name, - categoryId, - botUserId, - guildId, - permissionOverwrites: JSON.stringify(permissionOverwrites), - flowgladTeamRoleId: config.flowgladTeamRoleId, - }) - return (await rest.post(Routes.guildChannels(guildId), { body, })) as APIChannel From d64100cd291434acc3d9ff2df87ce5d5c7dd2c6e Mon Sep 17 00:00:00 2001 From: Liam Monaghan Date: Sat, 7 Feb 2026 16:09:03 -0500 Subject: [PATCH 05/11] fix: restore @here assertion in buildWelcomeMessage test --- platform/flowglad-next/src/utils/discord.unit.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/platform/flowglad-next/src/utils/discord.unit.test.ts b/platform/flowglad-next/src/utils/discord.unit.test.ts index a61aee8b04..fca70cf3ee 100644 --- a/platform/flowglad-next/src/utils/discord.unit.test.ts +++ b/platform/flowglad-next/src/utils/discord.unit.test.ts @@ -213,9 +213,10 @@ describe('discord', () => { }) describe('buildWelcomeMessage', () => { - it('includes onboarding link', () => { + it('includes @here mention and onboarding link', () => { const message = buildWelcomeMessage('Acme Corp') + expect(message).toContain('@here') expect(message).toContain('https://app.flowglad.com/onboarding') }) From bb78e093ad90018381588db16bb6c0d1219a7a08 Mon Sep 17 00:00:00 2001 From: Liam Monaghan Date: Sat, 7 Feb 2026 16:14:08 -0500 Subject: [PATCH 06/11] fix: use optional chaining for ctx.user and correct test descriptions --- .../src/server/mutations/createDiscordConciergeChannel.ts | 4 ++-- .../flowglad-next/src/utils/discordOAuthState.unit.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts b/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts index 4279c3ffd0..48adfa58e7 100644 --- a/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts +++ b/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts @@ -23,9 +23,9 @@ export const createDiscordConciergeChannel = protectedProcedure .output(z.object({ oauthUrl: z.string() })) .mutation(async ({ ctx }) => { const { organizationId } = ctx - const userId = ctx.user!.id + const userId = ctx.user?.id - if (!organizationId) { + if (!organizationId || !userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Organization context required', diff --git a/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts b/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts index ce2835b494..3eec96828e 100644 --- a/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts +++ b/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts @@ -72,7 +72,7 @@ describe('discordOAuthState', () => { ) }) - it('produces URL-safe output', () => { + it('produces valid base64 output', () => { const token = 'token-with-special+chars/=test' const encoded = encodeDiscordOAuthState(token) @@ -106,7 +106,7 @@ describe('discordOAuthState', () => { expect(decoded).toBe(originalToken) }) - it('throws on invalid base64 input', () => { + it('throws on malformed state parameter', () => { const invalidState = '%%%invalid%%%' expect(() => decodeDiscordOAuthState(invalidState)).toThrow( From ec1fb42bd5982b2e77ba0f47191799f11daa207b Mon Sep 17 00:00:00 2001 From: Liam Monaghan Date: Sat, 7 Feb 2026 16:27:21 -0500 Subject: [PATCH 07/11] fix: defer Discord channel creation to OAuth callback to prevent orphaned channels Channel was being created on button click before OAuth, leaving floating channels when users abandoned the flow. Now the channel is only created in the callback after successful authorization. --- .../onboarding/DiscordConciergeLink.tsx | 4 +- .../src/app/oauth/callback/discord/page.tsx | 57 +++++++++++-- .../createDiscordConciergeChannel.ts | 82 +------------------ .../src/utils/discordOAuthState.ts | 10 +-- .../src/utils/discordOAuthState.unit.test.ts | 26 ++---- 5 files changed, 63 insertions(+), 116 deletions(-) diff --git a/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx b/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx index 96600948eb..2a775fc3a0 100644 --- a/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx +++ b/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx @@ -51,9 +51,7 @@ export function DiscordConciergeLink() { onClick={handleClick} disabled={isLoading} > - {isLoading - ? 'Creating channel...' - : 'Join Concierge Channel'} + {isLoading ? 'Connecting...' : 'Join Concierge Channel'} {hasError ? (

diff --git a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx index 2e1a08cc6f..9681a6c41a 100644 --- a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx +++ b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx @@ -1,12 +1,17 @@ +import { organizations } from '@db-core/schema/organizations' import { Result } from 'better-result' +import { and, eq, isNull } from 'drizzle-orm' import { redirect } from 'next/navigation' +import { adminTransaction } from '@/db/adminTransaction' import { authenticatedTransaction } from '@/db/authenticatedTransaction' +import { selectOrganizationById } from '@/db/tableMethods/organizationMethods' import { addUserToGuild, exchangeDiscordOAuthCode, getDiscordChannelUrl, getDiscordConfig, getDiscordUserFromToken, + getOrCreateConciergeChannel, grantChannelAccess, } from '@/utils/discord' import { @@ -61,16 +66,58 @@ export default async function DiscordOAuthCallbackPage({ config, }) + // Fetch org to get existing channel ID (if any) and org name + const organization = ( + await adminTransaction(async ({ transaction }) => { + return selectOrganizationById( + validation.organizationId, + transaction + ) + }) + ).unwrap() + + // Create or reuse existing concierge channel + const { channelId } = await getOrCreateConciergeChannel( + organization.name, + organization.discordConciergeChannelId + ) + + // Persist channel ID if it changed, using compare-and-swap for race safety + if (channelId !== organization.discordConciergeChannelId) { + await adminTransaction(async ({ transaction }) => { + const condition = + organization.discordConciergeChannelId === null + ? and( + eq(organizations.id, validation.organizationId), + isNull(organizations.discordConciergeChannelId) + ) + : and( + eq(organizations.id, validation.organizationId), + eq( + organizations.discordConciergeChannelId, + organization.discordConciergeChannelId + ) + ) + + await transaction + .update(organizations) + .set({ + discordConciergeChannelId: channelId, + updatedAt: Date.now(), + }) + .where(condition) + + return Result.ok(undefined) + }) + } + await grantChannelAccess({ - channelId: validation.channelId, + channelId, discordUserId: discordUser.id, config, }) - redirectUrl = getDiscordChannelUrl( - config.guildId, - validation.channelId - ) + redirectUrl = getDiscordChannelUrl(config.guildId, channelId) } catch { redirectUrl = '/onboarding?error=discord_connection_failed' } diff --git a/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts b/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts index 48adfa58e7..e29ca0fc54 100644 --- a/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts +++ b/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts @@ -1,15 +1,9 @@ -import { organizations } from '@db-core/schema/organizations' import { TRPCError } from '@trpc/server' -import { Result } from 'better-result' -import { and, eq, isNull } from 'drizzle-orm' import { z } from 'zod' -import { adminTransaction } from '@/db/adminTransaction' -import { selectOrganizationById } from '@/db/tableMethods/organizationMethods' import { protectedProcedure } from '@/server/trpc' import { buildDiscordOAuthUrl, getDiscordConfig, - getOrCreateConciergeChannel, } from '@/utils/discord' import { createDiscordOAuthCsrfToken, @@ -33,84 +27,10 @@ export const createDiscordConciergeChannel = protectedProcedure } try { - // Fetch fresh organization data to get latest discordConciergeChannelId - const organization = ( - await adminTransaction(async ({ transaction }) => { - return selectOrganizationById(organizationId, transaction) - }) - ).unwrap() - - // Create or get concierge channel (pass existing ID for fast lookup) - const { channelId } = await getOrCreateConciergeChannel( - organization.name, - organization.discordConciergeChannelId - ) - - let finalChannelId = channelId - - // Persist channel ID using conditional update to prevent race conditions. - // If a concurrent request already set the channel ID, the WHERE clause - // won't match, and we fall back to the winner's channel. - if (channelId !== organization.discordConciergeChannelId) { - const persistResult = await adminTransaction( - async ({ transaction }) => { - const condition = - organization.discordConciergeChannelId === null - ? and( - eq(organizations.id, organizationId), - isNull(organizations.discordConciergeChannelId) - ) - : and( - eq(organizations.id, organizationId), - eq( - organizations.discordConciergeChannelId, - organization.discordConciergeChannelId - ) - ) - - const [updated] = await transaction - .update(organizations) - .set({ - discordConciergeChannelId: channelId, - updatedAt: Date.now(), - }) - .where(condition) - .returning() - - if (!updated) { - // Lost the race — fetch the winner's channel ID - const winnerResult = await selectOrganizationById( - organizationId, - transaction - ) - const winner = winnerResult.unwrap() - return Result.ok({ - raceResolved: true, - winnerChannelId: winner.discordConciergeChannelId, - }) - } - - return Result.ok({ - raceResolved: false, - winnerChannelId: null, - }) - } - ) - - const { raceResolved, winnerChannelId } = - persistResult.unwrap() - - if (raceResolved && winnerChannelId) { - finalChannelId = winnerChannelId - } - } - - // Generate OAuth URL so the user can authorize and get channel access const config = getDiscordConfig() const csrfToken = await createDiscordOAuthCsrfToken({ userId, organizationId, - channelId: finalChannelId, }) const state = encodeDiscordOAuthState(csrfToken) const oauthUrl = buildDiscordOAuthUrl({ state, config }) @@ -119,7 +39,7 @@ export const createDiscordConciergeChannel = protectedProcedure } catch (error) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to create Discord channel', + message: 'Failed to initiate Discord OAuth', cause: error, }) } diff --git a/platform/flowglad-next/src/utils/discordOAuthState.ts b/platform/flowglad-next/src/utils/discordOAuthState.ts index cbdf26e725..476b83f050 100644 --- a/platform/flowglad-next/src/utils/discordOAuthState.ts +++ b/platform/flowglad-next/src/utils/discordOAuthState.ts @@ -21,7 +21,6 @@ const CSRF_TTL_SECONDS = 60 * 15 const csrfTokenDataSchema = z.object({ userId: z.string().min(1), organizationId: z.string().min(1), - channelId: z.string().min(1), createdAt: z.string().datetime(), }) @@ -53,16 +52,14 @@ function safeCompare(a: string, b: string): boolean { export async function createDiscordOAuthCsrfToken(params: { userId: string organizationId: string - channelId: string }): Promise { - const { userId, organizationId, channelId } = params + const { userId, organizationId } = params const csrfToken = generateRandomBytes(CSRF_TOKEN_BYTES) const tokenData: CsrfTokenData = { userId, organizationId, - channelId, createdAt: new Date().toISOString(), } @@ -77,7 +74,6 @@ export async function createDiscordOAuthCsrfToken(params: { logger.info('Discord OAuth CSRF token created', { userId, organizationId, - channelId, tokenPrefix: csrfToken.substring(0, 4), }) @@ -99,7 +95,7 @@ export async function createDiscordOAuthCsrfToken(params: { export async function validateAndConsumeDiscordOAuthCsrfToken(params: { csrfToken: string expectedUserId: string -}): Promise<{ organizationId: string; channelId: string } | null> { +}): Promise<{ organizationId: string } | null> { const { csrfToken, expectedUserId } = params const key = buildCsrfTokenKey(csrfToken) @@ -144,13 +140,11 @@ export async function validateAndConsumeDiscordOAuthCsrfToken(params: { logger.info('Discord OAuth CSRF token validated', { userId: expectedUserId, organizationId: tokenData.organizationId, - channelId: tokenData.channelId, tokenPrefix: csrfToken.substring(0, 4), }) return { organizationId: tokenData.organizationId, - channelId: tokenData.channelId, } } catch (error) { logger.error('Error validating Discord OAuth CSRF token', { diff --git a/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts b/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts index 3eec96828e..bd6451b11b 100644 --- a/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts +++ b/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts @@ -116,15 +116,13 @@ describe('discordOAuthState', () => { }) describe('createDiscordOAuthCsrfToken', () => { - it('creates a token and stores it in Redis with userId, organizationId, and channelId', async () => { + it('creates a token and stores it in Redis with userId and organizationId', async () => { const userId = 'user-123' const organizationId = 'org-456' - const channelId = 'channel-789' const token = await createDiscordOAuthCsrfToken({ userId, organizationId, - channelId, }) expect(token).toBe('mock-csrf-token-32-bytes-long-xx') @@ -136,7 +134,6 @@ describe('discordOAuthState', () => { const parsedData = JSON.parse(storedData!) expect(parsedData.userId).toBe(userId) expect(parsedData.organizationId).toBe(organizationId) - expect(parsedData.channelId).toBe(channelId) expect(typeof parsedData.createdAt).toBe('string') }) @@ -144,7 +141,6 @@ describe('discordOAuthState', () => { const token = await createDiscordOAuthCsrfToken({ userId: 'user-123', organizationId: 'org-456', - channelId: 'channel-789', }) const storedData = mockRedisStore.get( @@ -160,14 +156,12 @@ describe('discordOAuthState', () => { describe('validateAndConsumeDiscordOAuthCsrfToken', () => { const userId = 'user-123' const organizationId = 'org-456' - const channelId = 'channel-789' const testToken = 'test-csrf-token' beforeEach(() => { const tokenData = { userId, organizationId, - channelId, createdAt: new Date().toISOString(), } mockRedisStore.set( @@ -176,13 +170,13 @@ describe('discordOAuthState', () => { ) }) - it('validates and returns organizationId and channelId for valid token and user', async () => { + it('validates and returns organizationId for valid token and user', async () => { const result = await validateAndConsumeDiscordOAuthCsrfToken({ csrfToken: testToken, expectedUserId: userId, }) - expect(result).toEqual({ organizationId, channelId }) + expect(result).toEqual({ organizationId }) }) it('deletes the token after validation (single-use)', async () => { @@ -240,8 +234,7 @@ describe('discordOAuthState', () => { `discordOAuthCsrfToken:incomplete-token`, JSON.stringify({ userId: 'user-123', - organizationId: 'org-456', - // missing channelId and createdAt + // missing organizationId and createdAt }) ) @@ -259,7 +252,7 @@ describe('discordOAuthState', () => { csrfToken: testToken, expectedUserId: userId, }) - expect(firstResult).toEqual({ organizationId, channelId }) + expect(firstResult).toEqual({ organizationId }) const secondResult = await validateAndConsumeDiscordOAuthCsrfToken({ @@ -273,7 +266,6 @@ describe('discordOAuthState', () => { const tokenData = { userId, organizationId, - channelId, createdAt: new Date().toISOString(), } @@ -287,7 +279,7 @@ describe('discordOAuthState', () => { expectedUserId: userId, }) - expect(result).toEqual({ organizationId, channelId }) + expect(result).toEqual({ organizationId }) }) }) @@ -301,12 +293,10 @@ describe('discordOAuthState', () => { it('completes full create-encode-decode-validate flow', async () => { const userId = 'user-flow-test' const organizationId = 'org-flow-test' - const channelId = 'channel-flow-test' const csrfToken = await createDiscordOAuthCsrfToken({ userId, organizationId, - channelId, }) const state = encodeDiscordOAuthState(csrfToken) @@ -319,19 +309,17 @@ describe('discordOAuthState', () => { expectedUserId: userId, }) - expect(result).toEqual({ organizationId, channelId }) + expect(result).toEqual({ organizationId }) }) it('prevents CSRF attack with wrong user', async () => { const legitimateUserId = 'legitimate-user' const attackerUserId = 'attacker-user' const organizationId = 'target-org' - const channelId = 'target-channel' const csrfToken = await createDiscordOAuthCsrfToken({ userId: legitimateUserId, organizationId, - channelId, }) const state = encodeDiscordOAuthState(csrfToken) From 9156aa860379551e7162bb083d318ec70928944f Mon Sep 17 00:00:00 2001 From: Liam Monaghan Date: Sat, 7 Feb 2026 16:45:40 -0500 Subject: [PATCH 08/11] fix: use tableMethods for Discord channel persistence and fix external redirect - Replace raw Drizzle update with updateOrganization tableMethod in OAuth callback - Re-read org after update to handle concurrent race conditions correctly - Use window.location.href instead of router.push for external Discord OAuth URL - Remove unused useRouter import from DiscordConciergeLink --- .../onboarding/DiscordConciergeLink.tsx | 5 +- .../src/app/oauth/callback/discord/page.tsx | 64 ++++++++++--------- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx b/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx index 2a775fc3a0..a567e8ad32 100644 --- a/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx +++ b/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx @@ -1,6 +1,5 @@ 'use client' -import { useRouter } from 'next/navigation' import { useState } from 'react' import { trpc } from '@/app/_trpc/client' import { Button } from '@/components/ui/button' @@ -11,13 +10,11 @@ export function DiscordConciergeLink() { const [isLoading, setIsLoading] = useState(false) const [hasError, setHasError] = useState(false) const { organization } = useAuthContext() - const router = useRouter() const createChannel = trpc.organizations.createDiscordConciergeChannel.useMutation({ onSuccess: (data) => { - router.push(data.oauthUrl) - setIsLoading(false) + window.location.href = data.oauthUrl }, onError: (error) => { console.error('Failed to create Discord channel:', error) diff --git a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx index 9681a6c41a..fbd2003ee6 100644 --- a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx +++ b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx @@ -1,10 +1,11 @@ -import { organizations } from '@db-core/schema/organizations' import { Result } from 'better-result' -import { and, eq, isNull } from 'drizzle-orm' import { redirect } from 'next/navigation' import { adminTransaction } from '@/db/adminTransaction' import { authenticatedTransaction } from '@/db/authenticatedTransaction' -import { selectOrganizationById } from '@/db/tableMethods/organizationMethods' +import { + selectOrganizationById, + updateOrganization, +} from '@/db/tableMethods/organizationMethods' import { addUserToGuild, exchangeDiscordOAuthCode, @@ -82,42 +83,45 @@ export default async function DiscordOAuthCallbackPage({ organization.discordConciergeChannelId ) - // Persist channel ID if it changed, using compare-and-swap for race safety + // Persist channel ID if it changed if (channelId !== organization.discordConciergeChannelId) { + ;( + await adminTransaction(async ({ transaction }) => { + await updateOrganization( + { + id: validation.organizationId, + discordConciergeChannelId: channelId, + }, + transaction + ) + return Result.ok(undefined) + }) + ).unwrap() + } + + // Re-read org to get actual stored channel (handles concurrent race) + const updatedOrg = ( await adminTransaction(async ({ transaction }) => { - const condition = - organization.discordConciergeChannelId === null - ? and( - eq(organizations.id, validation.organizationId), - isNull(organizations.discordConciergeChannelId) - ) - : and( - eq(organizations.id, validation.organizationId), - eq( - organizations.discordConciergeChannelId, - organization.discordConciergeChannelId - ) - ) - - await transaction - .update(organizations) - .set({ - discordConciergeChannelId: channelId, - updatedAt: Date.now(), - }) - .where(condition) - - return Result.ok(undefined) + return selectOrganizationById( + validation.organizationId, + transaction + ) }) - } + ).unwrap() + + const actualChannelId = + updatedOrg.discordConciergeChannelId ?? channelId await grantChannelAccess({ - channelId, + channelId: actualChannelId, discordUserId: discordUser.id, config, }) - redirectUrl = getDiscordChannelUrl(config.guildId, channelId) + redirectUrl = getDiscordChannelUrl( + config.guildId, + actualChannelId + ) } catch { redirectUrl = '/onboarding?error=discord_connection_failed' } From 3cee261629b1d5c0f6291ea5e1caba2c3c8b4ccb Mon Sep 17 00:00:00 2001 From: Liam Monaghan Date: Sat, 7 Feb 2026 16:57:15 -0500 Subject: [PATCH 09/11] fix: add error logging, Zod validation, and cleanup for Discord OAuth - Log errors in Discord OAuth callback catch block for production debugging - Add Zod schema validation on Discord token exchange response - Replace hardcoded Discord API URL with RouteBases.api + Routes.user() - Remove unsafe type cast in OAuth state test using getdel override --- .../src/app/oauth/callback/discord/page.tsx | 6 +++++- platform/flowglad-next/src/utils/discord.ts | 13 +++++++++++-- .../src/utils/discordOAuthState.unit.test.ts | 16 ++++++++++++---- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx index fbd2003ee6..5d8117d052 100644 --- a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx +++ b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx @@ -19,6 +19,7 @@ import { decodeDiscordOAuthState, validateAndConsumeDiscordOAuthCsrfToken, } from '@/utils/discordOAuthState' +import { logger } from '@/utils/logger' export default async function DiscordOAuthCallbackPage({ searchParams, }: { @@ -122,7 +123,10 @@ export default async function DiscordOAuthCallbackPage({ config.guildId, actualChannelId ) - } catch { + } catch (error) { + logger.error('Discord OAuth callback failed', { + error: error instanceof Error ? error.message : String(error), + }) redirectUrl = '/onboarding?error=discord_connection_failed' } diff --git a/platform/flowglad-next/src/utils/discord.ts b/platform/flowglad-next/src/utils/discord.ts index 8342874ff7..cbd2ad64d2 100644 --- a/platform/flowglad-next/src/utils/discord.ts +++ b/platform/flowglad-next/src/utils/discord.ts @@ -13,8 +13,10 @@ import { type RESTPostOAuth2AccessTokenResult, type RESTPutAPIChannelPermissionJSONBody, type RESTPutAPIGuildMemberJSONBody, + RouteBases, Routes, } from 'discord-api-types/v10' +import { z } from 'zod' import { panic } from '@/errors' export interface ConciergeChannelResult { @@ -435,7 +437,14 @@ export async function exchangeDiscordOAuthCode(params: { `Discord OAuth token exchange failed: ${response.status} ${errorText}` ) } - return response.json() as Promise + const json = await response.json() + const tokenSchema = z.object({ + access_token: z.string(), + token_type: z.string(), + expires_in: z.number(), + scope: z.string(), + }) + return tokenSchema.parse(json) as RESTPostOAuth2AccessTokenResult } /** @@ -446,7 +455,7 @@ export async function getDiscordUserFromToken( accessToken: string ): Promise { const response = await fetch( - 'https://discord.com/api/v10/users/@me', + `${RouteBases.api}${Routes.user('@me')}`, { headers: { Authorization: `Bearer ${accessToken}` }, } diff --git a/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts b/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts index bd6451b11b..341549bc41 100644 --- a/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts +++ b/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, mock } from 'bun:test' * In-memory Redis mock for testing CSRF token storage. */ const mockRedisStore: Map = new Map() +let getdelOverride: ((key: string) => unknown) | null = null const mockGenerateRandomBytes = mock( () => 'mock-csrf-token-32-bytes-long-xx' @@ -13,6 +14,9 @@ mock.module('./redis', () => ({ redis: () => ({ get: mock((key: string) => mockRedisStore.get(key) || null), getdel: mock((key: string) => { + if (getdelOverride) { + return getdelOverride(key) + } const value = mockRedisStore.get(key) || null mockRedisStore.delete(key) return value @@ -56,6 +60,7 @@ import { describe('discordOAuthState', () => { beforeEach(() => { mockRedisStore.clear() + getdelOverride = null mockGenerateRandomBytes.mockReset() mockGenerateRandomBytes.mockImplementation( () => 'mock-csrf-token-32-bytes-long-xx' @@ -269,10 +274,13 @@ describe('discordOAuthState', () => { createdAt: new Date().toISOString(), } - mockRedisStore.set( - `discordOAuthCsrfToken:object-token`, - tokenData as unknown as string - ) + // Simulate Redis clients that return parsed objects + getdelOverride = (key: string) => { + if (key === `discordOAuthCsrfToken:object-token`) { + return tokenData + } + return null + } const result = await validateAndConsumeDiscordOAuthCsrfToken({ csrfToken: 'object-token', From f100471aacb7c23fdb5d8cf374bddef97519a17f Mon Sep 17 00:00:00 2001 From: Liam Monaghan Date: Sat, 7 Feb 2026 17:08:32 -0500 Subject: [PATCH 10/11] feat: mention joining user in welcome message instead of @here Thread discordUserId through getOrCreateConciergeChannel to buildWelcomeMessage so the welcome message @mentions the specific user who joined. This creates an unread notification badge on the channel, making it visible in their sidebar even if the category is collapsed. Falls back to @here when no user ID is available. --- .../src/app/oauth/callback/discord/page.tsx | 3 ++- platform/flowglad-next/src/utils/discord.ts | 24 ++++++++++++++----- .../src/utils/discord.unit.test.ts | 16 +++++++++++-- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx index 5d8117d052..9b65e916d4 100644 --- a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx +++ b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx @@ -81,7 +81,8 @@ export default async function DiscordOAuthCallbackPage({ // Create or reuse existing concierge channel const { channelId } = await getOrCreateConciergeChannel( organization.name, - organization.discordConciergeChannelId + organization.discordConciergeChannelId, + discordUser.id ) // Persist channel ID if it changed diff --git a/platform/flowglad-next/src/utils/discord.ts b/platform/flowglad-next/src/utils/discord.ts index cbd2ad64d2..d525c42414 100644 --- a/platform/flowglad-next/src/utils/discord.ts +++ b/platform/flowglad-next/src/utils/discord.ts @@ -291,13 +291,16 @@ async function createPrivateChannel( */ export function buildWelcomeMessage( orgName: string, - flowgladTeamRoleId?: string + flowgladTeamRoleId?: string, + discordUserId?: string ): string { const teamMention = flowgladTeamRoleId ? `<@&${flowgladTeamRoleId}>` : 'the Flowglad team' - return `@here Welcome to your private concierge channel with ${teamMention}! Ask us any questions about onboarding, we're here to help 🙌 + const greeting = discordUserId ? `<@${discordUserId}>` : '@here' + + return `${greeting} Welcome to your private concierge channel with ${teamMention}! Ask us any questions about onboarding, we're here to help 🙌 **To finish setup, head to :** @@ -316,11 +319,13 @@ async function postWelcomeMessage( rest: REST, channelId: string, orgName: string, - config: DiscordConfig + config: DiscordConfig, + discordUserId?: string ): Promise { const content = buildWelcomeMessage( orgName, - config.flowgladTeamRoleId + config.flowgladTeamRoleId, + discordUserId ) const body: RESTPostAPIChannelMessageJSONBody = { @@ -344,7 +349,8 @@ async function postWelcomeMessage( */ export async function getOrCreateConciergeChannel( orgName: string, - existingChannelId?: string | null + existingChannelId?: string | null, + discordUserId?: string ): Promise { const config = getDiscordConfig() const rest = getRestClient(config.botToken) @@ -371,7 +377,13 @@ export async function getOrCreateConciergeChannel( // Post welcome message for newly created channels if (isNewChannel) { - await postWelcomeMessage(rest, channel.id, orgName, config) + await postWelcomeMessage( + rest, + channel.id, + orgName, + config, + discordUserId + ) } return { diff --git a/platform/flowglad-next/src/utils/discord.unit.test.ts b/platform/flowglad-next/src/utils/discord.unit.test.ts index fca70cf3ee..0a1c6e69e3 100644 --- a/platform/flowglad-next/src/utils/discord.unit.test.ts +++ b/platform/flowglad-next/src/utils/discord.unit.test.ts @@ -213,11 +213,23 @@ describe('discord', () => { }) describe('buildWelcomeMessage', () => { - it('includes @here mention and onboarding link', () => { + it('mentions the user by Discord ID and includes onboarding link', () => { + const message = buildWelcomeMessage( + 'Acme Corp', + undefined, + '987654321' + ) + + expect(message).toContain('<@987654321>') + expect(message).not.toContain('@here') + expect(message).toContain('https://app.flowglad.com/onboarding') + }) + + it('falls back to @here when no Discord user ID is provided', () => { const message = buildWelcomeMessage('Acme Corp') expect(message).toContain('@here') - expect(message).toContain('https://app.flowglad.com/onboarding') + expect(message).not.toContain('<@') }) it('includes all four onboarding steps', () => { From 6bb54c02790027b83599e6c89f0827c6b8051412 Mon Sep 17 00:00:00 2001 From: angihe93 <76918646+angihe93@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:24:30 -0500 Subject: [PATCH 11/11] fix merge --- platform/flowglad-next/src/utils/discord.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/platform/flowglad-next/src/utils/discord.ts b/platform/flowglad-next/src/utils/discord.ts index bd61f8c4b1..ca463a5d61 100644 --- a/platform/flowglad-next/src/utils/discord.ts +++ b/platform/flowglad-next/src/utils/discord.ts @@ -29,6 +29,9 @@ export interface DiscordConfig { conciergeCategoryPrefix: string flowgladTeamRoleId?: string internalBotRoleId?: string + oauthClientId?: string + oauthClientSecret?: string + oauthRedirectUri?: string } const DISCORD_CATEGORY_CHANNEL_LIMIT = 50 @@ -69,6 +72,9 @@ export function getDiscordConfig(): DiscordConfig { conciergeCategoryPrefix, flowgladTeamRoleId, internalBotRoleId, + oauthClientId, + oauthClientSecret, + oauthRedirectUri, } }