@@ -313,6 +340,10 @@ export function SlackIntegrationDetails({
{/* Actions */}
+
>
)}
diff --git a/apps/web/src/lib/bot/webhook-handler.ts b/apps/web/src/lib/bot/webhook-handler.ts
index b1533f0245..f999ac28d3 100644
--- a/apps/web/src/lib/bot/webhook-handler.ts
+++ b/apps/web/src/lib/bot/webhook-handler.ts
@@ -1,6 +1,7 @@
import 'server-only';
import { after } from 'next/server';
import { bot } from '@/lib/bot';
+import { markSlackInstallationRequiresReinstall } from '@/lib/integrations/slack-service';
type Platform = keyof typeof bot.webhooks;
@@ -12,13 +13,75 @@ export function cloneRequestWithBody(request: Request, body: BodyInit): Request
});
}
-export function handleWebhook(platform: string, request: Request): Response | Promise {
+function getSlackTeamIdFromBody(body: string, contentType: string): string | undefined {
+ if (contentType.includes('application/x-www-form-urlencoded')) {
+ const params = new URLSearchParams(body);
+ const teamId = params.get('team_id');
+ if (teamId) return teamId;
+
+ const payload = params.get('payload');
+ if (!payload) return undefined;
+
+ try {
+ const parsed: unknown = JSON.parse(payload);
+ if (typeof parsed !== 'object' || parsed === null) return undefined;
+ if (!('team' in parsed) || typeof parsed.team !== 'object' || parsed.team === null) {
+ return undefined;
+ }
+ const team = parsed.team;
+ if (!('id' in team) || typeof team.id !== 'string') return undefined;
+ return team.id;
+ } catch {
+ return undefined;
+ }
+ }
+
+ try {
+ const parsed: unknown = JSON.parse(body);
+ if (typeof parsed !== 'object' || parsed === null) return undefined;
+ if (!('team_id' in parsed) || typeof parsed.team_id !== 'string') return undefined;
+ return parsed.team_id;
+ } catch {
+ return undefined;
+ }
+}
+
+async function handleSlackWebhookError(error: unknown, teamId: string | undefined): Promise {
+ if (!teamId) return;
+ try {
+ await markSlackInstallationRequiresReinstall(teamId, error);
+ } catch (markError) {
+ console.error('[Webhook] Failed to mark Slack installation as requires_reinstall:', markError);
+ }
+}
+
+export async function handleWebhook(platform: string, request: Request): Promise {
const handler = bot.webhooks[platform as Platform];
if (!handler) {
return new Response('Unknown platform', { status: 404 });
}
- return handler(request, {
- waitUntil: task => after(() => task),
- });
+ const body = await request.text();
+ const clonedRequest = cloneRequestWithBody(request, body);
+ const slackTeamId =
+ platform === 'slack'
+ ? getSlackTeamIdFromBody(body, request.headers.get('content-type') ?? '')
+ : undefined;
+
+ try {
+ return await handler(clonedRequest, {
+ waitUntil: task =>
+ after(async () => {
+ try {
+ await task;
+ } catch (error) {
+ await handleSlackWebhookError(error, slackTeamId);
+ throw error;
+ }
+ }),
+ });
+ } catch (error) {
+ await handleSlackWebhookError(error, slackTeamId);
+ throw error;
+ }
}
diff --git a/apps/web/src/lib/integrations/slack-service.test.ts b/apps/web/src/lib/integrations/slack-service.test.ts
index 0cde7f1759..46849142af 100644
--- a/apps/web/src/lib/integrations/slack-service.test.ts
+++ b/apps/web/src/lib/integrations/slack-service.test.ts
@@ -3,6 +3,9 @@ process.env.TURNSTILE_SECRET_KEY ||= 'test-turnstile-secret';
const mockLimit = jest.fn();
const mockDeleteWhere = jest.fn();
+const mockUpdateSet = jest.fn();
+const mockUpdateWhere = jest.fn();
+const mockUpdateReturning = jest.fn();
const mockAuthRevoke = jest.fn();
jest.mock('@/lib/drizzle', () => ({
@@ -17,6 +20,9 @@ jest.mock('@/lib/drizzle', () => ({
delete: jest.fn(() => ({
where: mockDeleteWhere,
})),
+ update: jest.fn(() => ({
+ set: mockUpdateSet,
+ })),
},
}));
@@ -29,7 +35,13 @@ jest.mock('@slack/web-api', () => ({
}));
import type { Owner } from '@/lib/integrations/core/types';
-import { uninstallApp } from './slack-service';
+import {
+ getSlackMissingScopeErrorInfo,
+ isSlackMissingScopeError,
+ markSlackInstallationRequiresReinstall,
+ uninstallApp,
+ upsertSlackInstallation,
+} from './slack-service';
const owner = { type: 'user', id: 'user-1' } satisfies Owner;
@@ -44,13 +56,30 @@ function buildSlackIntegration(overrides: Record = {}) {
};
}
+function buildSlackMissingScopeError() {
+ return Object.assign(new Error('An API error occurred: missing_scope'), {
+ code: 'slack_webapi_platform_error',
+ data: {
+ ok: false,
+ error: 'missing_scope',
+ needed: 'assistant:write,views:write',
+ provided: 'chat:write',
+ },
+ });
+}
+
describe('slack-service uninstallApp', () => {
beforeEach(() => {
mockLimit.mockReset();
mockDeleteWhere.mockReset();
+ mockUpdateSet.mockReset();
+ mockUpdateWhere.mockReset();
+ mockUpdateReturning.mockReset();
mockAuthRevoke.mockReset();
mockAuthRevoke.mockResolvedValue({ ok: true });
mockDeleteWhere.mockResolvedValue(undefined);
+ mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning });
+ mockUpdateSet.mockReturnValue({ where: mockUpdateWhere });
});
it('deletes Chat SDK Slack state before removing the platform integration row', async () => {
@@ -115,3 +144,132 @@ describe('slack-service uninstallApp', () => {
expect(mockDeleteWhere).toHaveBeenCalledTimes(1);
});
});
+
+describe('slack-service reinstall metadata', () => {
+ beforeEach(() => {
+ mockLimit.mockReset();
+ mockDeleteWhere.mockReset();
+ mockUpdateSet.mockReset();
+ mockUpdateWhere.mockReset();
+ mockUpdateReturning.mockReset();
+ mockAuthRevoke.mockReset();
+ mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning });
+ mockUpdateSet.mockReturnValue({ where: mockUpdateWhere });
+ });
+
+ it('detects Slack missing_scope platform errors', () => {
+ const error = buildSlackMissingScopeError();
+
+ expect(isSlackMissingScopeError(error)).toBe(true);
+ expect(getSlackMissingScopeErrorInfo(error)).toEqual({
+ error: 'missing_scope',
+ missingScopes: ['assistant:write', 'views:write'],
+ providedScopes: ['chat:write'],
+ });
+ expect(
+ getSlackMissingScopeErrorInfo(
+ Object.assign(new Error('An API error occurred: missing_scope'), {
+ code: 'slack_webapi_platform_error',
+ data: { ok: false, error: 'missing_scope' },
+ })
+ )
+ ).toEqual({
+ error: 'missing_scope',
+ missingScopes: [],
+ providedScopes: [],
+ });
+ expect(isSlackMissingScopeError(new Error('different error'))).toBe(false);
+ });
+
+ it('marks an installation as requiring reinstall for missing scopes', async () => {
+ mockLimit.mockResolvedValue([
+ buildSlackIntegration({
+ metadata: {
+ access_token: 'xoxb-token',
+ model_slug: 'anthropic/claude-sonnet-4.5',
+ missing_scopes: ['assistant:write'],
+ },
+ }),
+ ]);
+
+ await expect(
+ markSlackInstallationRequiresReinstall('T123', buildSlackMissingScopeError())
+ ).resolves.toMatchObject({
+ error: 'missing_scope',
+ integrationId: 'integration-1',
+ missingScopes: ['assistant:write', 'views:write'],
+ providedScopes: ['chat:write'],
+ });
+
+ expect(mockUpdateSet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ access_token: 'xoxb-token',
+ model_slug: 'anthropic/claude-sonnet-4.5',
+ requires_reinstall: true,
+ missing_scopes: ['assistant:write', 'views:write'],
+ last_scope_error_code: 'missing_scope',
+ }),
+ })
+ );
+ });
+
+ it('does not mark reinstall for non-missing-scope errors', async () => {
+ await expect(
+ markSlackInstallationRequiresReinstall('T123', new Error('ratelimited'))
+ ).resolves.toBe(null);
+
+ expect(mockLimit).not.toHaveBeenCalled();
+ expect(mockUpdateSet).not.toHaveBeenCalled();
+ });
+
+ it('preserves the selected model and clears reinstall flags during reinstall', async () => {
+ const updatedIntegration = buildSlackIntegration({
+ metadata: {
+ access_token: 'new-token',
+ bot_user_id: 'BNEW',
+ model_slug: 'anthropic/claude-sonnet-4.5',
+ },
+ });
+ mockLimit.mockResolvedValue([
+ buildSlackIntegration({
+ metadata: {
+ access_token: 'old-token',
+ bot_user_id: 'BOLD',
+ model_slug: 'anthropic/claude-sonnet-4.5',
+ requires_reinstall: true,
+ missing_scopes: ['assistant:write'],
+ last_scope_error_at: '2026-04-28T12:00:00.000Z',
+ last_scope_error_code: 'missing_scope',
+ },
+ }),
+ ]);
+ mockUpdateReturning.mockResolvedValue([updatedIntegration]);
+
+ await expect(
+ upsertSlackInstallation({
+ owner,
+ teamId: 'T123',
+ installation: {
+ botToken: 'new-token',
+ botUserId: 'BNEW',
+ teamName: 'Kilo Test',
+ },
+ })
+ ).resolves.toBe(updatedIntegration);
+
+ expect(mockUpdateSet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ platform_installation_id: 'T123',
+ platform_account_id: 'T123',
+ platform_account_login: 'Kilo Test',
+ integration_status: 'active',
+ metadata: {
+ access_token: 'new-token',
+ bot_user_id: 'BNEW',
+ model_slug: 'anthropic/claude-sonnet-4.5',
+ },
+ })
+ );
+ });
+});
diff --git a/apps/web/src/lib/integrations/slack-service.ts b/apps/web/src/lib/integrations/slack-service.ts
index f3f34d7e34..4d4b78019d 100644
--- a/apps/web/src/lib/integrations/slack-service.ts
+++ b/apps/web/src/lib/integrations/slack-service.ts
@@ -33,23 +33,187 @@ export const SLACK_SCOPES = [
'groups:read',
'im:history',
'im:read',
- 'im:write',
'mpim:history',
'mpim:read',
'reactions:read',
'reactions:write',
'team:read',
'users:read',
- 'users:read.email',
];
const SLACK_REDIRECT_URI = `${APP_URL}/api/integrations/slack/callback`;
+const SLACK_PLATFORM_ERROR_CODE = 'slack_webapi_platform_error';
+
+export type SlackIntegrationMetadata = {
+ access_token?: string;
+ bot_user_id?: string;
+ model_slug?: string;
+ incoming_webhook?: { channel: string; channelId: string; url: string };
+ requires_reinstall?: boolean;
+ missing_scopes?: string[];
+ last_scope_error_at?: string;
+ last_scope_error_code?: string;
+};
+
+export type SlackMissingScopeErrorInfo = {
+ error: 'missing_scope';
+ missingScopes: string[];
+ providedScopes: string[];
+ integrationId: string | null;
+};
type SlackUninstallOptions = {
deleteChatSdkInstallation?: (teamId: string) => Promise;
deleteChatSdkIdentityCache?: (teamId: string) => Promise;
};
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+export function readSlackMetadata(metadata: unknown): SlackIntegrationMetadata {
+ if (!isRecord(metadata)) return {};
+
+ const result: SlackIntegrationMetadata = {};
+
+ if (typeof metadata.access_token === 'string') result.access_token = metadata.access_token;
+ if (typeof metadata.bot_user_id === 'string') result.bot_user_id = metadata.bot_user_id;
+ if (typeof metadata.model_slug === 'string') result.model_slug = metadata.model_slug;
+ if (metadata.requires_reinstall === true) result.requires_reinstall = true;
+ if (typeof metadata.last_scope_error_at === 'string') {
+ result.last_scope_error_at = metadata.last_scope_error_at;
+ }
+ if (typeof metadata.last_scope_error_code === 'string') {
+ result.last_scope_error_code = metadata.last_scope_error_code;
+ }
+ if (Array.isArray(metadata.missing_scopes)) {
+ result.missing_scopes = metadata.missing_scopes.filter(scope => typeof scope === 'string');
+ }
+ if (isRecord(metadata.incoming_webhook)) {
+ const webhook = metadata.incoming_webhook;
+ if (
+ typeof webhook.channel === 'string' &&
+ typeof webhook.channelId === 'string' &&
+ typeof webhook.url === 'string'
+ ) {
+ result.incoming_webhook = {
+ channel: webhook.channel,
+ channelId: webhook.channelId,
+ url: webhook.url,
+ };
+ }
+ }
+
+ return result;
+}
+
+export function parseSlackScopes(value: unknown): string[] {
+ if (typeof value === 'string') {
+ return value
+ .split(',')
+ .map(scope => scope.trim())
+ .filter(Boolean);
+ }
+ if (Array.isArray(value)) return value.filter(scope => typeof scope === 'string');
+ return [];
+}
+
+function uniqueScopes(scopes: string[]): string[] {
+ return [...new Set(scopes)];
+}
+
+export function getSlackPlatformError(error: unknown) {
+ if (!isRecord(error)) return null;
+ if (error.code !== SLACK_PLATFORM_ERROR_CODE) return null;
+ if (!isRecord(error.data)) return null;
+ if (typeof error.data.error !== 'string') return null;
+
+ return {
+ error: error.data.error,
+ needed: error.data.needed,
+ provided: error.data.provided,
+ };
+}
+
+export function getSlackMissingScopeErrorInfo(
+ error: unknown
+): Omit | null {
+ const platformError = getSlackPlatformError(error);
+ if (platformError?.error !== 'missing_scope') return null;
+
+ return {
+ error: 'missing_scope',
+ missingScopes: parseSlackScopes(platformError.needed),
+ providedScopes: parseSlackScopes(platformError.provided),
+ };
+}
+
+export function isSlackMissingScopeError(error: unknown): boolean {
+ return getSlackMissingScopeErrorInfo(error) !== null;
+}
+
+function clearSlackReinstallRequiredMetadata(metadata: Record) {
+ const next = { ...metadata };
+ delete next.requires_reinstall;
+ delete next.missing_scopes;
+ delete next.last_scope_error_at;
+ delete next.last_scope_error_code;
+ return next;
+}
+
+export function getSlackReinstallState(integration: PlatformIntegration) {
+ const metadata = readSlackMetadata(integration.metadata);
+ const storedScopes = integration.scopes ?? [];
+ const missingConfiguredScopes = SLACK_SCOPES.filter(scope => !storedScopes.includes(scope));
+ const missingScopes = uniqueScopes([
+ ...(metadata.missing_scopes ?? []),
+ ...missingConfiguredScopes,
+ ]);
+ const requiresReinstall =
+ metadata.requires_reinstall === true || missingConfiguredScopes.length > 0;
+
+ return {
+ requiresReinstall,
+ missingScopes,
+ missingConfiguredScopes,
+ lastScopeErrorAt: metadata.last_scope_error_at ?? null,
+ };
+}
+
+export async function markSlackInstallationRequiresReinstall(
+ teamId: string,
+ error: unknown
+): Promise {
+ const scopeInfo = getSlackMissingScopeErrorInfo(error);
+ if (!scopeInfo) return null;
+
+ const integration = await getInstallationByTeamId(teamId);
+ if (!integration) return { ...scopeInfo, integrationId: null };
+
+ const existingMetadata = isRecord(integration.metadata) ? integration.metadata : {};
+ const metadata = readSlackMetadata(integration.metadata);
+ const missingScopes = uniqueScopes([
+ ...(metadata.missing_scopes ?? []),
+ ...scopeInfo.missingScopes,
+ ]);
+
+ await db
+ .update(platform_integrations)
+ .set({
+ metadata: {
+ ...existingMetadata,
+ requires_reinstall: true,
+ missing_scopes: missingScopes,
+ last_scope_error_at: new Date().toISOString(),
+ last_scope_error_code: scopeInfo.error,
+ },
+ updated_at: new Date().toISOString(),
+ })
+ .where(eq(platform_integrations.id, integration.id));
+
+ return { ...scopeInfo, missingScopes, integrationId: integration.id };
+}
+
function getOwnershipConditions(owner: Owner) {
return owner.type === 'user'
? [
@@ -170,17 +334,27 @@ export async function upsertSlackInstallation({
access_token: installation.botToken,
bot_user_id: installation.botUserId,
model_slug: defaultModel,
- };
+ } satisfies SlackIntegrationMetadata;
if (existing) {
+ const existingMetadata = isRecord(existing.metadata) ? existing.metadata : {};
+ const existingSlackMetadata = readSlackMetadata(existing.metadata);
+ const nextMetadata = clearSlackReinstallRequiredMetadata({
+ ...existingMetadata,
+ access_token: installation.botToken,
+ bot_user_id: installation.botUserId,
+ model_slug: existingSlackMetadata.model_slug ?? defaultModel,
+ }) satisfies SlackIntegrationMetadata;
+
const [updated] = await db
.update(platform_integrations)
.set({
+ platform_installation_id: teamId,
platform_account_id: teamId,
platform_account_login: teamName,
scopes: SLACK_SCOPES,
integration_status: INTEGRATION_STATUS.ACTIVE,
- metadata,
+ metadata: nextMetadata,
updated_at: new Date().toISOString(),
})
.where(eq(platform_integrations.id, existing.id))
@@ -223,7 +397,7 @@ export async function uninstallApp(owner: Owner, options: SlackUninstallOptions
}
// Revoke the token if we have one
- const metadata = integration.metadata as { access_token?: string } | null;
+ const metadata = readSlackMetadata(integration.metadata);
if (metadata?.access_token) {
try {
await revokeSlackToken(metadata.access_token);
@@ -280,7 +454,7 @@ export async function testConnection(owner: Owner): Promise<{ success: boolean;
return { success: false, error: 'No Slack installation found' };
}
- const metadata = integration.metadata as { access_token?: string } | null;
+ const metadata = readSlackMetadata(integration.metadata);
if (!metadata?.access_token) {
return { success: false, error: 'No access token found' };
@@ -314,11 +488,7 @@ export async function sendTestMessage(
return { success: false, error: 'No Slack installation found' };
}
- const metadata = integration.metadata as {
- access_token?: string;
- model_slug?: string;
- incoming_webhook?: { channel: string; channelId: string; url: string };
- } | null;
+ const metadata = readSlackMetadata(integration.metadata);
if (!metadata?.access_token) {
return { success: false, error: 'No access token found' };
@@ -421,7 +591,7 @@ export async function sendMessage(
return { success: false, error: 'No Slack installation found' };
}
- const metadata = integration.metadata as { access_token?: string } | null;
+ const metadata = readSlackMetadata(integration.metadata);
if (!metadata?.access_token) {
return { success: false, error: 'No access token found' };
@@ -473,7 +643,7 @@ export async function updateModel(
}
}
- const existingMetadata = (integration.metadata || {}) as Record;
+ const existingMetadata = isRecord(integration.metadata) ? integration.metadata : {};
await db
.update(platform_integrations)
@@ -499,7 +669,7 @@ export async function getModel(owner: Owner): Promise {
return null;
}
- const metadata = integration.metadata as { model_slug?: string } | null;
+ const metadata = readSlackMetadata(integration.metadata);
return metadata?.model_slug || null;
}
@@ -528,7 +698,7 @@ export type SlackPostMessageResponse = {
export function getAccessTokenFromInstallation(
integration: PlatformIntegration
): string | undefined {
- const metadata = integration.metadata as { access_token?: string } | null;
+ const metadata = readSlackMetadata(integration.metadata);
return metadata?.access_token;
}
diff --git a/apps/web/src/routers/slack-router.ts b/apps/web/src/routers/slack-router.ts
index 079ae08deb..08e43d2bc0 100644
--- a/apps/web/src/routers/slack-router.ts
+++ b/apps/web/src/routers/slack-router.ts
@@ -47,16 +47,21 @@ export const slackRouter = createTRPCRouter({
}
const isInstalled = integration.integration_status === 'active';
- const metadata = integration.metadata as { model_slug?: string } | null;
+ const metadata = slackService.readSlackMetadata(integration.metadata);
+ const reinstallState = slackService.getSlackReinstallState(integration);
return {
installed: isInstalled,
+ needsReinstall: reinstallState.requiresReinstall,
installation: {
teamId: integration.platform_account_id,
teamName: integration.platform_account_login,
scopes: integration.scopes,
installedAt: integration.installed_at,
modelSlug: metadata?.model_slug || null,
+ requiresReinstall: reinstallState.requiresReinstall,
+ missingScopes: reinstallState.missingScopes,
+ lastScopeErrorAt: reinstallState.lastScopeErrorAt,
},
};
}),