From 4f9ec9f99bdbeba166eb8b7178dfe5bb82176411 Mon Sep 17 00:00:00 2001
From: Thibault Le Ouay Ducasse
Date: Mon, 28 Sep 2026 15:47:53 +0200
Subject: [PATCH] slack: new scopes, bot events, reconnect detection
Co-Authored-By: Claude Opus 5.5
---
.../settings/integrations/page.tsx | 1 +
.../settings/integrations/slack-card.tsx | 20 +++++++++++
apps/server/slack-manifest.json | 16 +++++++--
apps/server/src/routes/slack/handler.test.ts | 33 +++++++++++++++++++
apps/server/src/routes/slack/handler.ts | 15 +++++++--
apps/server/src/routes/slack/home.ts | 20 +++++++++--
apps/server/src/routes/slack/oauth.test.ts | 22 +++++++++++++
apps/server/src/routes/slack/oauth.ts | 21 +++---------
.../src/routes/slack/workspace-resolver.ts | 7 ++++
packages/api/src/router/integration.ts | 10 +++++-
.../__tests__/slack-scopes.test.ts | 26 +++++++++++++++
packages/services/src/integration/index.ts | 1 +
.../services/src/integration/slack-scopes.ts | 31 +++++++++++++++++
13 files changed, 199 insertions(+), 24 deletions(-)
create mode 100644 packages/services/src/integration/__tests__/slack-scopes.test.ts
create mode 100644 packages/services/src/integration/slack-scopes.ts
diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx
index adb43aa4..9d010330 100644
--- a/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx
+++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx
@@ -59,6 +59,7 @@ export default function Page() {
data: slackIntegration.data as {
teamName?: string;
},
+ missingScopes: slackIntegration.missingScopes,
}
: null
}
diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx
index d276f461..d7065ab3 100644
--- a/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx
+++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx
@@ -17,6 +17,7 @@ import {
FormCardTitle,
FormCardUpgrade,
} from "@/components/forms/form-card";
+import { useFeature } from "@/hooks/use-feature";
import { useTRPC } from "@/lib/trpc/client";
const SERVER_URL =
@@ -30,6 +31,7 @@ interface SlackIntegrationCardProps {
id: number;
externalId: string;
data: { teamName?: string };
+ missingScopes: string[];
} | null;
}
@@ -41,6 +43,9 @@ export function SlackIntegrationCard({
const trpc = useTRPC();
const queryClient = useQueryClient();
const isConnected = !!integration;
+ const incidents = useFeature("incident-management");
+ const needsReconnect =
+ incidents && (integration?.missingScopes.length ?? 0) > 0;
const deleteIntegration = useMutation(
trpc.integrationRouter.deleteIntegration.mutationOptions({
@@ -97,6 +102,21 @@ export function SlackIntegrationCard({
{integration.data?.teamName ?? "Slack workspace"}
. Only members with a linked Slack account can use it.
+ {needsReconnect ? (
+
+
+ Reconnect Slack to enable incident channels.
+
+
+
+ ) : null}
{linkedAccountsQuery.isPending ? (
Loading linked accounts…
diff --git a/apps/server/slack-manifest.json b/apps/server/slack-manifest.json
index c12e8d22..895a2505 100644
--- a/apps/server/slack-manifest.json
+++ b/apps/server/slack-manifest.json
@@ -40,8 +40,8 @@
{
"command": "/openstatus",
"url": "https://api.openstatus.dev/slack/commands",
- "description": "Manage this channel's status page subscriptions",
- "usage_hint": "subscribe | unsubscribe | subscriptions | help",
+ "description": "Incidents and status page subscriptions from Slack",
+ "usage_hint": "incident declare | incident note | incident list | subscribe | help",
"should_escape": false
}
]
@@ -56,12 +56,17 @@
"assistant:write",
"channels:history",
"channels:join",
+ "channels:manage",
+ "channels:write.invites",
"chat:write",
"commands",
"groups:history",
"groups:read",
"groups:write",
"im:history",
+ "pins:write",
+ "reactions:read",
+ "reactions:write",
"users:read",
"users:read.email"
]
@@ -77,9 +82,14 @@
"app_context_changed",
"app_home_opened",
"app_mention",
+ "app_uninstalled",
+ "channel_archive",
+ "channel_deleted",
"message.channels",
"message.groups",
- "message.im"
+ "message.im",
+ "reaction_added",
+ "tokens_revoked"
]
},
"interactivity": {
diff --git a/apps/server/src/routes/slack/handler.test.ts b/apps/server/src/routes/slack/handler.test.ts
index d325579f..17c4bcee 100644
--- a/apps/server/src/routes/slack/handler.test.ts
+++ b/apps/server/src/routes/slack/handler.test.ts
@@ -1932,3 +1932,36 @@ describe("hardening", () => {
}
});
});
+
+describe("reconnect banner", () => {
+ const app = createTestApp();
+
+ beforeEach(resetSlackTestState);
+
+ test("the home tab asks an old install to reconnect", async () => {
+ slackTestState.resolveWorkspace = () =>
+ Promise.resolve({
+ workspace: {
+ id: 1,
+ name: "Test Workspace",
+ slug: "test",
+ plan: "team",
+ limits: { "slack-agent": true },
+ },
+ botToken: "xoxb-test",
+ botUserId: "UBOT",
+ scopes: "chat:write,users:read,users:read.email",
+ });
+ await signAndPost(app, {
+ type: "event_callback",
+ team_id: "T_KNOWN",
+ event_id: `evt_reconnect_${Date.now()}`,
+ event: { type: "app_home_opened", tab: "home", user: "U1" },
+ });
+ const publish = await waitForCall("views.publish");
+ const view = publish?.args.view as {
+ blocks: { type: string; text?: { text: string } }[];
+ };
+ expect(view.blocks[0].text?.text).toContain("Reconnect openstatus");
+ });
+});
diff --git a/apps/server/src/routes/slack/handler.ts b/apps/server/src/routes/slack/handler.ts
index ac8fc4d2..f2cafd77 100644
--- a/apps/server/src/routes/slack/handler.ts
+++ b/apps/server/src/routes/slack/handler.ts
@@ -1,5 +1,9 @@
import { getLogger } from "@logtape/logtape";
-import { uninstallSlackTeam } from "@openstatus/services/integration";
+import { isFeatureEnabled } from "@openstatus/services";
+import {
+ missingSlackScopes,
+ uninstallSlackTeam,
+} from "@openstatus/services/integration";
import { WebClient } from "@slack/web-api";
import type { Context } from "hono";
import { z } from "zod";
@@ -367,7 +371,14 @@ async function processEvent(body: SlackEvent, config: SlackConfig) {
slackUserId: userId,
});
if (actor) {
- await publishHomeView(slack, userId);
+ const needsReconnect =
+ isFeatureEnabled(resolved.workspace, "incident-management") &&
+ missingSlackScopes(resolved.scopes).length > 0;
+ await publishHomeView(slack, userId, {
+ reconnectUrl: needsReconnect
+ ? `${config.dashboardUrl}/settings/integrations`
+ : undefined,
+ });
} else {
const url = await linkAccountUrl(config, {
workspaceId: resolved.workspace.id,
diff --git a/apps/server/src/routes/slack/home.ts b/apps/server/src/routes/slack/home.ts
index 824df333..3fc7008e 100644
--- a/apps/server/src/routes/slack/home.ts
+++ b/apps/server/src/routes/slack/home.ts
@@ -5,8 +5,23 @@ import { buildLinkAccountBlocks } from "./blocks";
export const DOCS_URL = "https://www.openstatus.dev/docs";
-export function buildHomeBlocks(): KnownBlock[] {
+export function buildHomeBlocks(
+ opts: { reconnectUrl?: string } = {},
+): KnownBlock[] {
+ const reconnect: KnownBlock[] = opts.reconnectUrl
+ ? [
+ {
+ type: "section",
+ text: {
+ type: "mrkdwn",
+ text: `:warning: *Reconnect openstatus to enable incident channels.* This install is missing permissions openstatus needs to open a channel per incident. <${opts.reconnectUrl}|Reconnect from the dashboard>.`,
+ },
+ },
+ { type: "divider" },
+ ]
+ : [];
return [
+ ...reconnect,
{
type: "header",
text: { type: "plain_text", text: "openstatus", emoji: true },
@@ -48,10 +63,11 @@ export function buildHomeBlocks(): KnownBlock[] {
export async function publishHomeView(
slack: WebClient,
userId: string,
+ opts: { reconnectUrl?: string } = {},
): Promise {
await slack.views.publish({
user_id: userId,
- view: { type: "home", blocks: buildHomeBlocks() },
+ view: { type: "home", blocks: buildHomeBlocks(opts) },
});
}
diff --git a/apps/server/src/routes/slack/oauth.test.ts b/apps/server/src/routes/slack/oauth.test.ts
index 27e8a331..b62703df 100644
--- a/apps/server/src/routes/slack/oauth.test.ts
+++ b/apps/server/src/routes/slack/oauth.test.ts
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
+import { SLACK_BOT_SCOPES } from "@openstatus/services/integration";
import { expect } from "@std/expect";
import { describe, test } from "@std/testing/bdd";
import { Hono } from "hono";
@@ -9,6 +10,7 @@ import {
withSlackConfig,
} from "@/libs/test/slack-config";
+import manifest from "../../../slack-manifest.json" with { type: "json" };
import type { SlackEnv } from "./config";
import { handleSlackInstall, handleSlackOAuthCallback } from "./oauth";
@@ -40,6 +42,26 @@ function makeInstallToken(workspaceId: number): string {
return signToken({ workspaceId, userId: 1, ts: Date.now() });
}
+describe("slack manifest", () => {
+ test("requests exactly the bot scopes the code asks for", () => {
+ expect([...manifest.oauth_config.scopes.bot].sort()).toEqual(
+ [...SLACK_BOT_SCOPES].sort(),
+ );
+ });
+
+ test("subscribes to the events the handler acts on", () => {
+ for (const event of [
+ "app_uninstalled",
+ "tokens_revoked",
+ "reaction_added",
+ "channel_deleted",
+ "channel_archive",
+ ]) {
+ expect(manifest.settings.event_subscriptions.bot_events).toContain(event);
+ }
+ });
+});
+
describe("handleSlackInstall", () => {
const app = createTestApp();
diff --git a/apps/server/src/routes/slack/oauth.ts b/apps/server/src/routes/slack/oauth.ts
index a877d0e6..d413b832 100644
--- a/apps/server/src/routes/slack/oauth.ts
+++ b/apps/server/src/routes/slack/oauth.ts
@@ -6,7 +6,10 @@ import {
selectWorkspaceSchema,
workspace as workspaceTable,
} from "@openstatus/db/src/schema";
-import { installSlackAgent } from "@openstatus/services/integration";
+import {
+ installSlackAgent,
+ SLACK_BOT_SCOPES,
+} from "@openstatus/services/integration";
import type { Context } from "hono";
import { z } from "zod";
@@ -17,21 +20,7 @@ const logger = getLogger(["api-server", "slack", "oauth"]);
const SLACK_OAUTH_URL = "https://slack.com/oauth/v2/authorize";
const SLACK_TOKEN_URL = "https://slack.com/api/oauth.v2.access";
-const BOT_SCOPES = [
- "app_mentions:read",
- "assistant:write",
- "channels:history",
- "channels:join",
- "chat:write",
- "commands",
- "groups:history",
- "groups:read",
- "groups:write",
- "im:history",
- // users.info + profile.email: attributes Slack actions to workspace members.
- "users:read",
- "users:read.email",
-].join(",");
+const BOT_SCOPES = SLACK_BOT_SCOPES.join(",");
const oauthStateSchema = z.object({
workspaceId: z.number().int(),
diff --git a/apps/server/src/routes/slack/workspace-resolver.ts b/apps/server/src/routes/slack/workspace-resolver.ts
index ec01c8b9..a9006e02 100644
--- a/apps/server/src/routes/slack/workspace-resolver.ts
+++ b/apps/server/src/routes/slack/workspace-resolver.ts
@@ -5,11 +5,16 @@ import {
workspace,
} from "@openstatus/db/src/schema";
import type { Workspace } from "@openstatus/db/src/schema/workspaces/validation";
+import { z } from "zod";
+
+const integrationDataSchema = z.object({ scopes: z.string().optional() });
export interface SlackWorkspace {
workspace: Workspace;
botToken: string;
botUserId: string;
+ /** Comma list Slack granted at install; missing scopes mean "reconnect". */
+ scopes?: string;
}
interface IntegrationCredential {
@@ -24,6 +29,7 @@ export async function resolveWorkspace(
.select({
workspaceId: integration.workspaceId,
credential: integration.credential,
+ data: integration.data,
})
.from(integration)
.where(
@@ -58,5 +64,6 @@ export async function resolveWorkspace(
workspace: parsed.data,
botToken: credential.botToken,
botUserId: credential.botUserId ?? "",
+ scopes: integrationDataSchema.safeParse(row.data).data?.scopes ?? "",
};
}
diff --git a/packages/api/src/router/integration.ts b/packages/api/src/router/integration.ts
index 745e5c39..11475bd1 100644
--- a/packages/api/src/router/integration.ts
+++ b/packages/api/src/router/integration.ts
@@ -4,6 +4,7 @@ import crypto from "crypto";
import {
deleteIntegration,
listIntegrations,
+ missingSlackScopes,
} from "@openstatus/services/integration";
import { z } from "zod";
@@ -31,7 +32,14 @@ function signInstallToken(args: {
export const integrationRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
try {
- return await listIntegrations({ ctx: toServiceCtx(ctx) });
+ const integrations = await listIntegrations({ ctx: toServiceCtx(ctx) });
+ return integrations.map((i) => ({
+ ...i,
+ missingScopes:
+ i.name === "slack-agent" && typeof i.data.scopes === "string"
+ ? missingSlackScopes(i.data.scopes)
+ : [],
+ }));
} catch (err) {
toTRPCError(err);
}
diff --git a/packages/services/src/integration/__tests__/slack-scopes.test.ts b/packages/services/src/integration/__tests__/slack-scopes.test.ts
new file mode 100644
index 00000000..1ac6279d
--- /dev/null
+++ b/packages/services/src/integration/__tests__/slack-scopes.test.ts
@@ -0,0 +1,26 @@
+import { expect } from "@std/expect";
+import { describe, test } from "@std/testing/bdd";
+
+import { SLACK_BOT_SCOPES, missingSlackScopes } from "../slack-scopes";
+
+describe("missingSlackScopes", () => {
+ test("nothing missing when every scope was granted", () => {
+ expect(missingSlackScopes(SLACK_BOT_SCOPES.join(","))).toEqual([]);
+ });
+
+ test("an install from before the incident scopes needs a reconnect", () => {
+ const old =
+ "app_mentions:read,assistant:write,channels:history,channels:join,chat:write,commands,groups:history,groups:read,groups:write,im:history,users:read,users:read.email";
+ expect(missingSlackScopes(old)).toEqual([
+ "channels:manage",
+ "channels:write.invites",
+ "pins:write",
+ "reactions:read",
+ "reactions:write",
+ ]);
+ });
+
+ test("no scopes recorded means everything is missing", () => {
+ expect(missingSlackScopes(undefined)).toHaveLength(SLACK_BOT_SCOPES.length);
+ });
+});
diff --git a/packages/services/src/integration/index.ts b/packages/services/src/integration/index.ts
index 893bf790..b1593aef 100644
--- a/packages/services/src/integration/index.ts
+++ b/packages/services/src/integration/index.ts
@@ -1,5 +1,6 @@
export { deleteIntegration } from "./delete";
export { installSlackAgent } from "./install-slack-agent";
+export { missingSlackScopes, SLACK_BOT_SCOPES } from "./slack-scopes";
export {
uninstallSlackAgent,
uninstallSlackTeam,
diff --git a/packages/services/src/integration/slack-scopes.ts b/packages/services/src/integration/slack-scopes.ts
new file mode 100644
index 00000000..c5ae1657
--- /dev/null
+++ b/packages/services/src/integration/slack-scopes.ts
@@ -0,0 +1,31 @@
+/** Bot scopes the Slack app requests; the manifest lists the same set. */
+export const SLACK_BOT_SCOPES = [
+ "app_mentions:read",
+ "assistant:write",
+ "channels:history",
+ "channels:join",
+ "channels:manage",
+ "channels:write.invites",
+ "chat:write",
+ "commands",
+ "groups:history",
+ "groups:read",
+ "groups:write",
+ "im:history",
+ "pins:write",
+ "reactions:read",
+ "reactions:write",
+ "users:read",
+ "users:read.email",
+] as const;
+
+/** Scopes the install lacks, from the comma list Slack returned at OAuth. */
+export function missingSlackScopes(granted: string | undefined): string[] {
+ const have = new Set(
+ (granted ?? "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean),
+ );
+ return SLACK_BOT_SCOPES.filter((scope) => !have.has(scope));
+}
--
2.51.2