Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ jobs:
LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }}
LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }}
LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }}
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }}
GH_APP_ID: ${{ secrets.GH_APP_ID }}
GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }}
GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }}
Expand Down Expand Up @@ -229,6 +232,9 @@ jobs:
--env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \
--env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \
--env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \
--env SLACK_CLIENT_ID=$SLACK_CLIENT_ID \
--env SLACK_CLIENT_SECRET=$SLACK_CLIENT_SECRET \
--env SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET \
--env GH_APP_ID="$GH_APP_ID" \
--env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \
--env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ jobs:
LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }}
LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }}
LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }}
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }}
GH_APP_ID: ${{ secrets.GH_APP_ID }}
GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }}
GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }}
Expand Down Expand Up @@ -133,6 +136,9 @@ jobs:
--env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \
--env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \
--env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \
--env SLACK_CLIENT_ID=$SLACK_CLIENT_ID \
--env SLACK_CLIENT_SECRET=$SLACK_CLIENT_SECRET \
--env SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET \
--env GH_APP_ID="$GH_APP_ID" \
--env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \
--env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \
Expand Down
4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724",
"@linear/sdk": "^68.1.0",
"@modelcontextprotocol/sdk": "^1.25.3",
"@octokit/app": "^16.1.2",
"@octokit/rest": "^22.0.1",
"@octokit/webhooks": "^14.2.0",
"@sentry/nextjs": "^10.36.0",
"@slack/types": "^2.19.0",
"@slack/web-api": "^7.13.0",
"@superset/auth": "workspace:*",
"@superset/db": "workspace:*",
"@superset/mcp": "workspace:*",
"@superset/shared": "workspace:*",
"@superset/trpc": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.8",
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/app/api/agent/[transport]/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { auth } from "@superset/auth/server";
import { registerTools } from "@superset/mcp";
import type { McpContext } from "@superset/mcp/auth";
import { createMcpHandler, withMcpAuth } from "mcp-handler";
import { env } from "@/env";
import type { McpContext } from "@/lib/mcp/auth";
import { registerTools } from "@/lib/mcp/tools";

async function verifyToken(req: Request, bearerToken?: string) {
// 1. Try session auth
Expand Down
116 changes: 116 additions & 0 deletions apps/api/src/app/api/integrations/slack/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { WebClient } from "@slack/web-api";
import { db } from "@superset/db/client";
import type { SlackConfig } from "@superset/db/schema";
import { integrationConnections, members } from "@superset/db/schema";
import { and, eq } from "drizzle-orm";

import { env } from "@/env";
import { verifySignedState } from "@/lib/oauth-state";

export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");

if (error) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=oauth_denied`,
);
}

if (!code || !state) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=missing_params`,
);
}
Comment on lines +2 to +26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n apps/api/src/app/api/integrations/slack/callback/route.ts

Repository: superset-sh/superset

Length of output: 4751


Add Zod parsing for OAuth callback query params.
Guidelines call for Zod validation at API boundaries; validate code and state as non-empty before use.

🔧 Suggested fix
 import { integrationConnections, members } from "@superset/db/schema";
 import { and, eq } from "drizzle-orm";
+import { z } from "zod";
@@
 interface SlackOAuthResponse {
@@
 }
 
+const callbackParamsSchema = z.object({
+	code: z.string().min(1),
+	state: z.string().min(1),
+});
+
 export async function GET(request: Request) {
 	const url = new URL(request.url);
-	const code = url.searchParams.get("code");
-	const state = url.searchParams.get("state");
 	const error = url.searchParams.get("error");
+	const parsedParams = callbackParamsSchema.safeParse({
+		code: url.searchParams.get("code"),
+		state: url.searchParams.get("state"),
+	});
+	if (!parsedParams.success) {
+		return Response.redirect(
+			`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=missing_params`,
+		);
+	}
+	const { code, state } = parsedParams.data;

As per coding guidelines: Validate at boundaries using Zod schemas for tRPC inputs and API route bodies.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { db } from "@superset/db/client";
import type { SlackConfig } from "@superset/db/schema";
import { integrationConnections, members } from "@superset/db/schema";
import { and, eq } from "drizzle-orm";
import { env } from "@/env";
import { verifySignedState } from "@/lib/oauth-state";
interface SlackOAuthResponse {
ok: boolean;
error?: string;
access_token: string;
token_type: string;
scope: string;
bot_user_id: string;
app_id: string;
team: {
id: string;
name: string;
};
authed_user: {
id: string;
};
}
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
if (error) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=oauth_denied`,
);
}
if (!code || !state) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=missing_params`,
);
}
import { db } from "@superset/db/client";
import type { SlackConfig } from "@superset/db/schema";
import { integrationConnections, members } from "@superset/db/schema";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { env } from "@/env";
import { verifySignedState } from "@/lib/oauth-state";
interface SlackOAuthResponse {
ok: boolean;
error?: string;
access_token: string;
token_type: string;
scope: string;
bot_user_id: string;
app_id: string;
team: {
id: string;
name: string;
};
authed_user: {
id: string;
};
}
const callbackParamsSchema = z.object({
code: z.string().min(1),
state: z.string().min(1),
});
export async function GET(request: Request) {
const url = new URL(request.url);
const error = url.searchParams.get("error");
const parsedParams = callbackParamsSchema.safeParse({
code: url.searchParams.get("code"),
state: url.searchParams.get("state"),
});
if (!parsedParams.success) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=missing_params`,
);
}
const { code, state } = parsedParams.data;
if (error) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=oauth_denied`,
);
}
🤖 Prompt for AI Agents
In `@apps/api/src/app/api/integrations/slack/callback/route.ts` around lines 1 -
42, Validate the OAuth callback query params at the API boundary by adding a Zod
schema and parsing the incoming request in the GET handler: import z from zod,
define a schema like z.object({ code: z.string().nonempty(), state:
z.string().nonempty() }) and parse the URL searchParams into that schema before
using code or state; on parse failure redirect to
`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=missing_params` (same
redirect used currently) and only proceed to call verifySignedState or further
logic after successful validation so code and state are guaranteed non-empty.


const stateData = verifySignedState(state);
if (!stateData) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=invalid_state`,
);
}

const { organizationId, userId } = stateData;

// Re-verify membership at callback time (state was signed earlier)
const membership = await db.query.members.findFirst({
where: and(
eq(members.organizationId, organizationId),
eq(members.userId, userId),
),
});

if (!membership) {
console.error("[slack/callback] Membership verification failed:", {
organizationId,
userId,
});
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=unauthorized`,
);
}

const redirectUri = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`;
const client = new WebClient();

try {
const tokenData = await client.oauth.v2.access({
client_id: env.SLACK_CLIENT_ID,
client_secret: env.SLACK_CLIENT_SECRET,
redirect_uri: redirectUri,
code,
});

if (!tokenData.ok || !tokenData.access_token || !tokenData.team) {
console.error("[slack/callback] Slack API error:", tokenData.error);
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=slack_api_error`,
);
}

const config: SlackConfig = {
provider: "slack",
};

await db
.insert(integrationConnections)
.values({
organizationId,
connectedByUserId: userId,
provider: "slack",
accessToken: tokenData.access_token,
externalOrgId: tokenData.team.id,
externalOrgName: tokenData.team.name,
config,
})
.onConflictDoUpdate({
target: [
integrationConnections.organizationId,
integrationConnections.provider,
],
set: {
accessToken: tokenData.access_token,
externalOrgId: tokenData.team.id,
externalOrgName: tokenData.team.name,
connectedByUserId: userId,
config,
updatedAt: new Date(),
},
});

console.log("[slack/callback] Connected workspace:", {
organizationId,
teamId: tokenData.team.id,
teamName: tokenData.team.name,
});

return Response.redirect(`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack`);
} catch (error) {
console.error("[slack/callback] Token exchange failed:", error);
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/slack?error=token_exchange_failed`,
);
}
}
73 changes: 73 additions & 0 deletions apps/api/src/app/api/integrations/slack/connect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { auth } from "@superset/auth/server";
import { db } from "@superset/db/client";
import { members } from "@superset/db/schema";
import { and, eq } from "drizzle-orm";

import { env } from "@/env";
import { createSignedState } from "@/lib/oauth-state";

const SLACK_SCOPES = [
"app_mentions:read",
"chat:write",
"reactions:write",
"channels:history",
"groups:history",
"im:history",
"im:read",
"im:write",
"mpim:history",
"users:read",
"assistant:write",
"links:read",
"links:write",
].join(",");

export async function GET(request: Request) {
const url = new URL(request.url);
const organizationId = url.searchParams.get("organizationId");
if (!organizationId) {
return Response.json(
{ error: "Missing organizationId parameter" },
{ status: 400 },
);
}

const session = await auth.api.getSession({
headers: request.headers,
});

if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const userId = session.user.id;

const membership = await db.query.members.findFirst({
where: and(
eq(members.organizationId, organizationId),
eq(members.userId, userId),
),
});

if (!membership) {
return Response.json(
{ error: "User is not a member of this organization" },
{ status: 403 },
);
}

const state = createSignedState({
organizationId,
userId,
});

const redirectUri = `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/callback`;

const slackAuthUrl = new URL("https://slack.com/oauth/v2/authorize");
slackAuthUrl.searchParams.set("client_id", env.SLACK_CLIENT_ID);
slackAuthUrl.searchParams.set("redirect_uri", redirectUri);
slackAuthUrl.searchParams.set("scope", SLACK_SCOPES);
slackAuthUrl.searchParams.set("state", state);

return Response.redirect(slackAuthUrl.toString());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { processAssistantMessage } from "./process-assistant-message";
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { GenericMessageEvent } from "@slack/types";
import { db } from "@superset/db/client";
import { integrationConnections } from "@superset/db/schema";
import { and, eq } from "drizzle-orm";
import { runSlackAgent } from "../utils/run-agent";
import { formatActionsAsText } from "../utils/slack-blocks";
import { createSlackClient } from "../utils/slack-client";

interface ProcessAssistantMessageParams {
event: GenericMessageEvent;
teamId: string;
eventId: string;
}

export async function processAssistantMessage({
event,
teamId,
eventId,
}: ProcessAssistantMessageParams): Promise<void> {
console.log("[slack/process-assistant-message] Processing message:", {
eventId,
teamId,
channel: event.channel,
user: event.user,
});

const connection = await db.query.integrationConnections.findFirst({
where: and(
eq(integrationConnections.provider, "slack"),
eq(integrationConnections.externalOrgId, teamId),
),
});

if (!connection) {
console.error(
"[slack/process-assistant-message] No connection found for team:",
teamId,
);
return;
}

const slack = createSlackClient(connection.accessToken);

const threadTs = event.thread_ts ?? event.ts;

try {
await slack.assistant.threads.setStatus({
channel_id: event.channel,
thread_ts: threadTs,
status: "Thinking...",
});
} catch (err) {
console.warn(
"[slack/process-assistant-message] Failed to set status:",
err,
);
}

try {
const result = await runSlackAgent({
prompt: event.text ?? "",
channelId: event.channel,
threadTs,
organizationId: connection.organizationId,
slackToken: connection.accessToken,
slackTeamId: teamId,
});

// Format actions as text with URLs (enables Slack unfurling)
const hasActions = result.actions.length > 0;
const responseText = hasActions
? formatActionsAsText(result.actions)
: result.text;

await slack.chat.postMessage({
channel: event.channel,
thread_ts: threadTs,
text: responseText,
});
} catch (err) {
console.error("[slack/process-assistant-message] Agent error:", err);

await slack.chat.postMessage({
channel: event.channel,
thread_ts: threadTs,
text: `Sorry, something went wrong: ${err instanceof Error ? err.message : "Unknown error"}`,
});
} finally {
try {
await slack.assistant.threads.setStatus({
channel_id: event.channel,
thread_ts: threadTs,
status: "",
});
} catch {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { processEntityDetails } from "./process-entity-details";
Loading