mcp admin

This commit is contained in:
johnyeo
2026-06-06 09:44:29 +01:00
parent 1cda503264
commit 938b82f8cd
28 changed files with 9189 additions and 52 deletions

View File

@@ -43,7 +43,7 @@ export const postApprovalRequest = async ({
const approvalId = await createApproval({
orgId: installation.org_id,
provider: "slack",
provider: installation.provider,
workspaceId: installation.workspace_id,
channelId,
providerUserId,

View File

@@ -25,6 +25,20 @@ import {
export const chatAdapterNames = ["slack"];
const getSlackAdminProvider = () =>
`slack_admin:${env.SLACK_CLIENT_ID}` as const;
const findSlackInstallationForWorkspace = async ({
workspaceId,
}: {
workspaceId: string;
}) => {
return (
(await findInstallation(getSlackAdminProvider(), workspaceId)) ??
(await findInstallation("slack", workspaceId))
);
};
export const bot = new Chat({
userName: env.CHAT_NAME,
adapters: {
@@ -33,7 +47,9 @@ export const bot = new Chat({
clientSecret: env.SLACK_CLIENT_SECRET,
installationProvider: {
getInstallation: async (workspaceId) => {
const installation = await findInstallation("slack", workspaceId);
const installation = await findSlackInstallationForWorkspace({
workspaceId,
});
if (!installation) return null;
return {
botToken: decrypt(installation.bot_access_token),
@@ -74,9 +90,19 @@ const runAndReply = async ({
let logger = rootLogger;
try {
const workspaceId = getSlackWorkspaceId(raw);
const installation = await findSlackInstallationForWorkspace({
workspaceId,
});
if (!installation) {
logger.warn("Slack installation not found", {
event: "leaf.slack_installation_missing",
});
return;
}
const session = createLeafSessionContext({
channelId,
provider: "slack",
provider: installation.provider,
providerUserId,
threadId,
workspaceId,
@@ -91,13 +117,6 @@ const runAndReply = async ({
text_length: text.length,
},
});
const installation = await findInstallation("slack", workspaceId);
if (!installation) {
logger.warn("Slack installation not found", {
event: "leaf.slack_installation_missing",
});
return;
}
if (!text.trim()) {
logger.info("Skipping empty Slack message", {
event: "leaf.slack_message_skipped",

View File

@@ -10,6 +10,7 @@ import {
parseOAuthScopeString,
parseOAuthTokenResponse,
} from "../utils/oauthTokenResponse.js";
import { replaceInstallationOAuthCredentials } from "./replaceInstallationOAuthCredentials.js";
const TOKEN_EXPIRY_SKEW_MS = 60_000;
@@ -25,12 +26,31 @@ export const getInstallationOAuthAccessToken = async ({
installation: ChatInstallation;
env: AppEnv;
}) => {
const credential = await getChatOAuthCredentialByInstallationEnv({
let credential = await getChatOAuthCredentialByInstallationEnv({
db,
chatInstallationId: installation.id,
env,
});
if (
installation.provider.startsWith("slack_admin") &&
(!credential || credential.org_id !== installation.org_id)
) {
await db.transaction(async (tx) => {
await replaceInstallationOAuthCredentials({
tx,
installation,
userId: installation.installed_by_user_id ?? "",
});
});
credential = await getChatOAuthCredentialByInstallationEnv({
db,
chatInstallationId: installation.id,
env,
});
}
if (!credential) {
throw new Error(
`Missing ${env} Autumn OAuth credentials for Slack install`,

View File

@@ -1,6 +1,7 @@
import crypto from "node:crypto";
import { prefixOAuthToken } from "@autumn/auth";
import {
ALL_SCOPES,
AppEnv,
type ChatInstallation,
chatOAuthCredentials,
@@ -9,16 +10,74 @@ import {
oauthConsent,
oauthRefreshToken,
} from "@autumn/shared";
import { ALL_SCOPES } from "@autumn/shared/utils/scopeDefinitions";
import { and, eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { encrypt } from "../../../lib/crypto.js";
import type { db } from "../../../lib/db.js";
import { AUTUMN_SLACK_OAUTH_CLIENT_ID } from "./upsertInstallationOAuthCredential.js";
import {
AUTUMN_ADMIN_OAUTH_CLIENT_ID,
AUTUMN_SLACK_OAUTH_CLIENT_ID,
} from "./upsertInstallationOAuthCredential.js";
type ChatTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
const REFRESH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1000;
const SLACK_ADMIN_CONSENT_KIND = "slack_admin";
type OAuthConsentMetadata =
| {
kind: typeof SLACK_ADMIN_CONSENT_KIND;
chatInstallationId: string;
createdByUserId: string;
}
| Record<string, never>;
const isSlackAdminInstallation = ({
installation,
}: {
installation: ChatInstallation;
}) => installation.provider.startsWith("slack_admin");
const getSlackMcpOAuthClientId = ({
installation,
}: {
installation: ChatInstallation;
}) =>
isSlackAdminInstallation({ installation })
? AUTUMN_ADMIN_OAUTH_CLIENT_ID
: AUTUMN_SLACK_OAUTH_CLIENT_ID;
const getSlackMcpOAuthClientName = ({
installation,
}: {
installation: ChatInstallation;
}) => (isSlackAdminInstallation({ installation }) ? "Slack Admin" : "Slack");
const getOAuthClientMetadata = ({
installation,
}: {
installation: ChatInstallation;
}) => ({
kind: "mcp_client",
mcpClientType: isSlackAdminInstallation({ installation })
? "slack_admin"
: "slack",
});
const getOAuthConsentMetadata = ({
installation,
userId,
}: {
installation: ChatInstallation;
userId: string;
}): OAuthConsentMetadata =>
isSlackAdminInstallation({ installation })
? {
kind: SLACK_ADMIN_CONSENT_KIND,
chatInstallationId: installation.id,
createdByUserId: userId,
}
: {};
const tokenHash = ({ token }: { token: string }) => {
const hash = crypto.createHash("sha256").update(token).digest();
@@ -31,15 +90,24 @@ const tokenHash = ({ token }: { token: string }) => {
const generateToken = () => crypto.randomBytes(48).toString("base64url");
const ensureSlackMcpOAuthClient = async ({ tx }: { tx: ChatTransaction }) => {
const ensureSlackMcpOAuthClient = async ({
tx,
installation,
}: {
tx: ChatTransaction;
installation: ChatInstallation;
}) => {
const now = new Date();
const clientId = getSlackMcpOAuthClientId({ installation });
const name = getSlackMcpOAuthClientName({ installation });
const metadata = getOAuthClientMetadata({ installation });
await tx
.insert(oauthClient)
.values({
id: `oauth_client_${crypto.randomUUID().replace(/-/g, "")}`,
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
name: "Slack",
clientId,
name,
redirectUris: ["slack://autumn-chat"],
scopes: [...ALL_SCOPES],
tokenEndpointAuthMethod: "none",
@@ -47,27 +115,21 @@ const ensureSlackMcpOAuthClient = async ({ tx }: { tx: ChatTransaction }) => {
responseTypes: ["code"],
public: true,
type: "native",
metadata: {
kind: "mcp_client",
mcpClientType: "slack",
},
metadata,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: oauthClient.clientId,
set: {
name: "Slack",
name,
scopes: [...ALL_SCOPES],
tokenEndpointAuthMethod: "none",
grantTypes: ["authorization_code", "refresh_token"],
responseTypes: ["code"],
public: true,
type: "native",
metadata: {
kind: "mcp_client",
mcpClientType: "slack",
},
metadata,
updatedAt: now,
},
});
@@ -78,11 +140,15 @@ const upsertOAuthConsent = async ({
env,
orgId,
userId,
clientId,
metadata,
}: {
tx: ChatTransaction;
env: AppEnv;
orgId: string;
userId: string;
clientId: string;
metadata: OAuthConsentMetadata;
}) => {
const now = new Date();
const [existingConsent] = await tx
@@ -90,10 +156,13 @@ const upsertOAuthConsent = async ({
.from(oauthConsent)
.where(
and(
eq(oauthConsent.clientId, AUTUMN_SLACK_OAUTH_CLIENT_ID),
eq(oauthConsent.clientId, clientId),
eq(oauthConsent.userId, userId),
eq(oauthConsent.referenceId, orgId),
eq(oauthConsent.env, env),
metadata?.kind === SLACK_ADMIN_CONSENT_KIND
? sql`${oauthConsent.metadata}->>'kind' = ${SLACK_ADMIN_CONSENT_KIND}`
: sql`COALESCE(${oauthConsent.metadata}->>'kind', '') != ${SLACK_ADMIN_CONSENT_KIND}`,
),
)
.limit(1);
@@ -103,6 +172,7 @@ const upsertOAuthConsent = async ({
.update(oauthConsent)
.set({
scopes: [...ALL_SCOPES],
metadata,
updatedAt: now,
})
.where(eq(oauthConsent.id, existingConsent.id));
@@ -112,12 +182,13 @@ const upsertOAuthConsent = async ({
const consentId = `oauth_consent_${crypto.randomUUID().replace(/-/g, "")}`;
await tx.insert(oauthConsent).values({
id: consentId,
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
clientId,
userId,
referenceId: orgId,
scopes: [...ALL_SCOPES],
env,
redirectUri: "slack://autumn-chat",
metadata,
createdAt: now,
updatedAt: now,
});
@@ -144,17 +215,21 @@ const createCredentialForEnv = async ({
const refreshTokenExpiresAt = now + REFRESH_TOKEN_TTL_MS;
const refreshTokenId = `oauth_refresh_${crypto.randomUUID().replace(/-/g, "")}`;
const accessTokenId = `oauth_access_${crypto.randomUUID().replace(/-/g, "")}`;
const clientId = getSlackMcpOAuthClientId({ installation });
const metadata = getOAuthConsentMetadata({ installation, userId });
const consentId = await upsertOAuthConsent({
tx,
env,
orgId: installation.org_id,
userId,
clientId,
metadata,
});
await tx.insert(oauthRefreshToken).values({
id: refreshTokenId,
token: tokenHash({ token: rawRefreshToken }),
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
clientId,
userId,
referenceId: installation.org_id,
expiresAt: new Date(refreshTokenExpiresAt),
@@ -165,7 +240,7 @@ const createCredentialForEnv = async ({
await tx.insert(oauthAccessToken).values({
id: accessTokenId,
token: tokenHash({ token: rawAccessToken }),
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
clientId,
userId,
referenceId: installation.org_id,
refreshId: refreshTokenId,
@@ -173,12 +248,12 @@ const createCredentialForEnv = async ({
createdAt: nowDate,
scopes: [...ALL_SCOPES],
});
await tx.insert(chatOAuthCredentials).values({
const credential = {
id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`,
chat_installation_id: installation.id,
org_id: installation.org_id,
env,
oauth_client_id: AUTUMN_SLACK_OAUTH_CLIENT_ID,
oauth_client_id: clientId,
oauth_consent_id: consentId,
access_token: encrypt(prefixOAuthToken({ token: rawAccessToken })),
refresh_token: encrypt(rawRefreshToken),
@@ -186,7 +261,27 @@ const createCredentialForEnv = async ({
scopes: [...ALL_SCOPES],
created_at: now,
updated_at: now,
});
};
await tx
.insert(chatOAuthCredentials)
.values(credential)
.onConflictDoUpdate({
target: [
chatOAuthCredentials.chat_installation_id,
chatOAuthCredentials.env,
],
set: {
org_id: credential.org_id,
oauth_client_id: credential.oauth_client_id,
oauth_consent_id: credential.oauth_consent_id,
access_token: credential.access_token,
refresh_token: credential.refresh_token,
access_token_expires_at: credential.access_token_expires_at,
scopes: credential.scopes,
updated_at: credential.updated_at,
},
});
};
export const replaceInstallationOAuthCredentials = async ({
@@ -202,7 +297,7 @@ export const replaceInstallationOAuthCredentials = async ({
throw new Error("Missing user id for Slack MCP OAuth credentials");
}
await ensureSlackMcpOAuthClient({ tx });
await ensureSlackMcpOAuthClient({ tx, installation });
await createCredentialForEnv({
tx,
installation,

View File

@@ -5,6 +5,7 @@ import { db } from "../../../lib/db.js";
import { upsertChatOAuthCredential } from "../repos/chatOAuthCredentialsRepo.js";
export const AUTUMN_SLACK_OAUTH_CLIENT_ID = "autumn_mcp_slack";
export const AUTUMN_ADMIN_OAUTH_CLIENT_ID = "autumn_admin";
export const upsertInstallationOAuthCredential = async ({
installation,

View File

@@ -3,10 +3,10 @@ import {
AppEnv,
apiKeys,
type ChatInstallation,
type ChatInstallState,
type ChatProvider,
chatInstallations,
} from "@autumn/shared";
import type { ChatInstallState } from "@autumn/shared/utils/chatState";
import { and, eq, or } from "drizzle-orm";
import { replaceInstallationOAuthCredentials } from "../../internal/installations/actions/replaceInstallationOAuthCredentials.js";
import { decrypt, encrypt } from "../../lib/crypto.js";

View File

@@ -1,4 +1,4 @@
import { verifyChatInstallState } from "@autumn/shared/utils/chatState";
import { type ChatProvider, verifyChatInstallState } from "@autumn/shared";
import { Hono } from "hono";
import { z } from "zod";
import { bot } from "../../bot.js";
@@ -11,6 +11,11 @@ const callbackQuery = z.strictObject({
state: z.string(),
});
const isSlackInstallProvider = (provider: string): provider is ChatProvider =>
provider === "slack" ||
provider === "slack_admin" ||
provider.startsWith("slack_admin:");
export const slackRoutes = new Hono();
slackRoutes.get("/oauth/callback", async (c) => {
@@ -21,13 +26,13 @@ slackRoutes.get("/oauth/callback", async (c) => {
});
const parsedState = verifyChatInstallState(state, getStateSecret());
if (!parsedState || parsedState.provider !== "slack")
if (!parsedState || !isSlackInstallProvider(parsedState.provider))
throw new Error("Invalid or expired Slack OAuth state");
const oauth = await exchangeSlackCode(code);
await replaceInstallation({
state: parsedState,
provider: "slack",
provider: parsedState.provider,
workspaceId: oauth.team.id,
workspaceName: oauth.team.name,
botUserId: oauth.bot_user_id,

View File

@@ -29,6 +29,23 @@ services:
volumes:
- autumn-dev-dragonfly:/data
ngrok:
image: ngrok/ngrok:latest
profiles:
- ngrok
environment:
NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN:-}
command:
- http
- host.docker.internal:8080
- --url=${NGROK_DOMAIN}
- --pooling-enabled
- --log=stdout
ports:
- "4040:4040"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
autumn-dev-postgres-18:
autumn-dev-redis-stack:

View File

@@ -80,7 +80,7 @@
"type": "module",
"scripts": {
"dev": "bun scripts/dev.ts",
"dev:services": "bun scripts/devServices/index.ts",
"dev:services": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/devServices/index.ts",
"vite:build": "bun -F @autumn/vite build:bun",
"t": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/testScripts/testDispatcher.ts",
"cm": "cd server && bun cm",
@@ -117,6 +117,7 @@
"tb:prod-legacy": "bun scripts/tinybird/index.ts prod-legacy",
"axiom": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/axiom/cli.ts",
"axiom:prod": "ENV_FILE=.env.prod infisical run --env=prod --recursive -- bun scripts/axiom/cli.ts",
"slack": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/slack/index.ts",
"add-mcp": "bun scripts/mcp/addMcp.ts",
"trigger:deploy": "bunx trigger.dev deploy",
"setupci": "node scripts/setup/setupci.js",

View File

@@ -1,13 +1,23 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createConnection } from "node:net";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import inquirer from "inquirer";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "../..");
const composeFile = join(rootDir, "docker", "dev-services.compose.yml");
const composeProject = "autumn-dev-services";
const zshrcFile = join(homedir(), ".zshrc");
const ngrokConfigFiles = [
join(homedir(), "Library", "Application Support", "ngrok", "ngrok.yml"),
join(homedir(), ".config", "ngrok", "ngrok.yml"),
join(homedir(), ".ngrok2", "ngrok.yml"),
];
const localConfig = {
postgresPort: 5432,
ngrokApiPort: 4040,
redisStackPort: 6379,
dragonflyPort: 6380,
databaseUrl: "postgresql://postgres:postgres@localhost:5432/autumn",
@@ -23,6 +33,103 @@ const log = (message: string) => console.log(`[dev:services] ${message}`);
const composeEnv = { ...process.env };
const readShellConfigEnvVar = ({ key }: { key: string }) => {
if (!existsSync(zshrcFile)) return;
const match = readFileSync(zshrcFile, "utf-8").match(
new RegExp(`^\\s*(?:export\\s+)?${key}=(.+?)\\s*$`, "m"),
);
return match?.[1]?.trim().replace(/^["']|["']$/g, "");
};
const writeShellConfigEnvVar = ({
key,
value,
}: {
key: string;
value: string;
}) => {
const current = existsSync(zshrcFile)
? readFileSync(zshrcFile, "utf-8").split("\n")
: [];
let updated = false;
const lines = current.map((line) => {
if (new RegExp(`^\\s*(?:export\\s+)?${key}=`).test(line)) {
updated = true;
return `export ${key}=${value}`;
}
return line;
});
if (!updated) lines.push(`export ${key}=${value}`);
writeFileSync(zshrcFile, `${lines.join("\n").replace(/\n+$/, "")}\n`);
};
const readNgrokAuthtokenFromConfig = () => {
for (const configFile of ngrokConfigFiles) {
if (!existsSync(configFile)) continue;
const match = readFileSync(configFile, "utf-8").match(
/^\s*authtoken:\s*(.+?)\s*$/m,
);
const token = match?.[1]?.trim().replace(/^["']|["']$/g, "");
if (token) return token;
}
};
const getDomainFromUrl = ({ url }: { url: string }) => {
const normalizedUrl = url.startsWith("http") ? url : `https://${url}`;
return new URL(normalizedUrl).host;
};
const configureNgrokUrl = () => {
const ngrokUrl = composeEnv.NGROK_URL;
if (!ngrokUrl) {
throw new Error(
"NGROK_URL is required for dev services. It should be injected from Infisical dev secrets.",
);
}
composeEnv.NGROK_DOMAIN = getDomainFromUrl({ url: ngrokUrl });
};
const configureNgrokToken = async () => {
if (composeEnv.NGROK_AUTHTOKEN) return;
const shellToken = readShellConfigEnvVar({ key: "NGROK_AUTHTOKEN" });
if (shellToken) {
composeEnv.NGROK_AUTHTOKEN = shellToken;
return;
}
const configuredToken = readNgrokAuthtokenFromConfig();
if (configuredToken) {
composeEnv.NGROK_AUTHTOKEN = configuredToken;
writeShellConfigEnvVar({
key: "NGROK_AUTHTOKEN",
value: configuredToken,
});
log(`saved NGROK_AUTHTOKEN from local ngrok config to ${zshrcFile}`);
return;
}
log(`NGROK_AUTHTOKEN will be saved to ${zshrcFile} after first entry`);
const { token } = await inquirer.prompt<{ token: string }>([
{
type: "password",
name: "token",
message: "NGROK_AUTHTOKEN",
mask: "*",
validate: (value: string) =>
Boolean(value.trim()) || "NGROK_AUTHTOKEN is required",
},
]);
composeEnv.NGROK_AUTHTOKEN = token.trim();
writeShellConfigEnvVar({ key: "NGROK_AUTHTOKEN", value: token.trim() });
log(`saved NGROK_AUTHTOKEN to ${zshrcFile}`);
};
const run = ({
cmd,
args,
@@ -54,6 +161,8 @@ const composeArgs = ({ args }: { args: string[] }) => [
composeProject,
"-f",
composeFile,
"--profile",
"ngrok",
...args,
];
@@ -193,18 +302,80 @@ const ensureChatDatabase = () => {
psql({ args: ["-d", "postgres", "-c", "CREATE DATABASE chat"] });
};
const ensureNgrokRunning = () => {
const result = dockerCompose({
args: ["ps", "--status", "running", "--services", "ngrok"],
quiet: true,
});
const services = new TextDecoder().decode(result.stdout).trim().split("\n");
if (!services.includes("ngrok")) {
const logs = dockerCompose({
args: ["logs", "--tail", "40", "ngrok"],
quiet: true,
allowFailure: true,
});
const stderr = new TextDecoder().decode(logs.stderr).trim();
const stdout = new TextDecoder().decode(logs.stdout).trim();
throw new Error(
[
"ngrok container is not running",
stdout || stderr ? `${stdout}\n${stderr}`.trim() : undefined,
]
.filter(Boolean)
.join("\n"),
);
}
};
const getNgrokUrl = async () => {
for (let attempt = 0; attempt < 60; attempt++) {
try {
const response = await fetch(
`http://127.0.0.1:${localConfig.ngrokApiPort}/api/tunnels`,
);
const data = (await response.json()) as {
tunnels?: Array<{ public_url?: string; proto?: string }>;
};
const tunnel = data.tunnels?.find(
(tunnel) => tunnel.proto === "https" && tunnel.public_url,
);
if (tunnel?.public_url) return tunnel.public_url.replace(/\/$/, "");
} catch {
// ngrok's local API is not ready yet.
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error("ngrok did not expose a public URL on :4040");
};
const up = async () => {
configureNgrokUrl();
await configureNgrokToken();
log("starting Docker services");
dockerCompose({ args: ["up", "-d", "--remove-orphans"] });
dockerCompose({
args: ["rm", "-sf", "ngrok"],
allowFailure: true,
});
dockerCompose({
args: ["up", "-d", "--remove-orphans"],
});
await Promise.all([
waitForTcp({ port: localConfig.postgresPort, label: "Postgres" }),
waitForTcp({ port: localConfig.redisStackPort, label: "Redis Stack" }),
waitForTcp({ port: localConfig.dragonflyPort, label: "Dragonfly" }),
waitForTcp({ port: localConfig.ngrokApiPort, label: "ngrok" }),
]);
ensureChatDatabase();
await doctor();
ensureNgrokRunning();
const ngrokUrl = await getNgrokUrl();
log(`ngrok URL: ${ngrokUrl}`);
log(`export NGROK_URL=${ngrokUrl}`);
};
const down = () => {
@@ -237,7 +408,7 @@ const help = () => {
console.log(`Usage: bun dev:services <command>
Commands:
up Start local Postgres, Redis Stack, and Dragonfly
up Start local Postgres, Redis Stack, Dragonfly, and ngrok
down Stop local services and keep all data
down --volumes Stop services and delete Redis/Dragonfly data
down --postgres Stop services and delete Postgres data
@@ -248,6 +419,7 @@ Commands:
Local service values:
DATABASE_URL=${localConfig.databaseUrl}
CHAT_STATE_DATABASE_URL=${localConfig.chatStateDatabaseUrl}
NGROK_URL=<printed by bun dev:services up>
CACHE_URL=${localConfig.cacheUrl}
CACHE_URL_US_EAST=${localConfig.cacheUrl}
CACHE_V2_DRAGONFLY_URL=${localConfig.dragonflyUrl}

673
scripts/slack/index.ts Normal file
View File

@@ -0,0 +1,673 @@
import "dotenv/config";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import chalk from "chalk";
import inquirer from "inquirer";
const defaultSlackScopes = [
"app_mentions:read",
"assistant:write",
"channels:history",
"channels:read",
"chat:write",
"groups:history",
"groups:read",
"im:history",
"im:read",
"im:write",
"mpim:history",
"mpim:read",
"users:read",
];
const defaultBotEvents = [
"app_mention",
"message.channels",
"message.groups",
"message.im",
"message.mpim",
];
type Args = {
action?: string;
appName?: string;
baseUrl?: string;
dryRun: boolean;
envFile?: string;
help: boolean;
printManifest: boolean;
provider?: SlackInstallProvider;
scopes: string[];
teamId?: string;
};
type SlackInstallProvider = "slack" | "slack_admin";
type SlackManifest = {
display_information: {
name: string;
};
features: {
app_home: {
home_tab_enabled: boolean;
messages_tab_enabled: boolean;
messages_tab_read_only_enabled: boolean;
};
bot_user: {
display_name: string;
always_online: boolean;
};
};
oauth_config: {
redirect_urls: string[];
scopes: {
bot: string[];
};
};
settings: {
event_subscriptions: {
request_url: string;
bot_events: string[];
};
interactivity: {
is_enabled: boolean;
request_url: string;
};
org_deploy_enabled: boolean;
socket_mode_enabled: boolean;
token_rotation_enabled: boolean;
};
};
type SlackManifestCreateResponse = {
ok: boolean;
error?: string;
errors?: unknown[];
app_id?: string;
credentials?: {
client_id?: string;
client_secret?: string;
signing_secret?: string;
verification_token?: string;
};
oauth_authorize_url?: string;
[key: string]: unknown;
};
type SlackApiResponse = {
ok: boolean;
error?: string;
[key: string]: unknown;
};
const usage = () =>
[
"Usage:",
" bun slack [setup-bot] [options]",
"",
"Options:",
" --base-url <url> Public Leaf URL. Defaults to NGROK_URL, SLACK_BOT_URL, or CHAT_URL.",
" --name <name> Slack app name. Defaults to Autumn Chat Local.",
" --env-file <path> Write Slack env vars to this file.",
" --provider <provider> slack or slack_admin. Defaults to prompt for setup-bot.",
" --scopes <csv> Override bot scopes.",
" --team-id <id> Workspace team id for org-scoped Slack CLI auth.",
" --print-manifest Print generated Slack app manifest.",
" --dry-run Print manifest/env without calling Slack.",
" --help Show this help.",
"",
"Example:",
" bun slack",
" bun slack --provider slack_admin",
" bun slack --base-url https://j.dev.useautumn.com --env-file .env.slack-local",
].join("\n");
const readOption = ({
args,
name,
}: {
args: string[];
name: string;
}): string | undefined => {
const inline = args.find((arg) => arg.startsWith(`${name}=`));
if (inline) return inline.slice(name.length + 1);
const index = args.indexOf(name);
if (index === -1) return undefined;
return args[index + 1];
};
const parseArgs = ({ argv }: { argv: string[] }): Args => {
const action = argv[0]?.startsWith("--")
? "setup-bot"
: (argv[0] ?? "setup-bot");
const scopes = readOption({ args: argv, name: "--scopes" });
const providerArg = readOption({ args: argv, name: "--provider" });
const provider =
providerArg === "slack" || providerArg === "slack_admin"
? providerArg
: action === "setup-admin-bot"
? "slack_admin"
: action === "setup-local-bot" || action === "setup-regular-bot"
? "slack"
: undefined;
const defaultAppName =
provider === "slack_admin"
? process.env.SLACK_ADMIN_APP_NAME
: process.env.SLACK_APP_NAME;
return {
action,
appName: readOption({ args: argv, name: "--name" }) ?? defaultAppName,
baseUrl:
readOption({ args: argv, name: "--base-url" }) ??
process.env.NGROK_URL ??
process.env.SLACK_BOT_URL ??
process.env.CHAT_URL,
dryRun: argv.includes("--dry-run"),
envFile: readOption({ args: argv, name: "--env-file" }),
help: argv.includes("--help") || argv.includes("-h"),
printManifest: argv.includes("--print-manifest"),
provider,
scopes: scopes
? scopes.split(",").map((scope) => scope.trim())
: defaultSlackScopes,
teamId: readOption({ args: argv, name: "--team-id" }),
};
};
const trimTrailingSlash = ({ url }: { url: string }) => url.replace(/\/+$/, "");
const defaultAppNameForProvider = ({
provider,
}: {
provider: SlackInstallProvider;
}) =>
provider === "slack_admin" ? "Autumn Chat Admin Local" : "Autumn Chat Local";
const isUrl = ({ value }: { value: string }) => {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
};
const resolveInteractiveArgs = async ({
args,
}: {
args: Args;
}): Promise<Args & { provider: SlackInstallProvider }> => {
const answers = await inquirer.prompt<{
provider?: SlackInstallProvider;
appName?: string;
baseUrl?: string;
envFile?: string;
}>([
...(!args.provider
? [
{
type: "list" as const,
name: "provider" as const,
message: "What kind of Slack bot is this?",
default: "slack",
choices: [
{
name: "Regular org bot",
value: "slack",
},
{
name: "Admin impersonation bot",
value: "slack_admin",
},
],
},
]
: []),
{
type: "input",
name: "appName",
message: "Slack app name",
default: ({ provider }: { provider?: SlackInstallProvider }) =>
args.appName ??
defaultAppNameForProvider({
provider: provider ?? args.provider ?? "slack",
}),
},
...(!args.baseUrl
? [
{
type: "input" as const,
name: "baseUrl" as const,
message: "Public ngrok/Leaf URL",
default:
process.env.NGROK_URL ??
process.env.SLACK_BOT_URL ??
process.env.CHAT_URL,
filter: (value: string) => trimTrailingSlash({ url: value.trim() }),
validate: (value: string) =>
isUrl({ value }) || "Enter a valid http(s) URL",
},
]
: []),
...(!args.envFile
? [
{
type: "input" as const,
name: "envFile" as const,
message: "Env file to write (leave blank to only print)",
},
]
: []),
]);
return {
...args,
provider: answers.provider ?? args.provider ?? "slack",
appName: answers.appName ?? args.appName,
baseUrl: answers.baseUrl ?? args.baseUrl,
envFile: answers.envFile?.trim() || args.envFile,
};
};
const buildSlackManifest = ({
appName,
baseUrl,
scopes,
}: {
appName: string;
baseUrl: string;
scopes: string[];
}): SlackManifest => {
const publicBaseUrl = trimTrailingSlash({ url: baseUrl });
return {
display_information: {
name: appName,
},
features: {
app_home: {
home_tab_enabled: false,
messages_tab_enabled: true,
messages_tab_read_only_enabled: false,
},
bot_user: {
display_name: appName,
always_online: false,
},
},
oauth_config: {
redirect_urls: [`${publicBaseUrl}/slack/oauth/callback`],
scopes: {
bot: scopes,
},
},
settings: {
event_subscriptions: {
request_url: `${publicBaseUrl}/slack/events`,
bot_events: defaultBotEvents,
},
interactivity: {
is_enabled: true,
request_url: `${publicBaseUrl}/slack/interactions`,
},
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: false,
},
};
};
const runSlackCli = ({
args,
quiet = false,
}: {
args: string[];
quiet?: boolean;
}) => {
const result = Bun.spawnSync(["slack", "--skip-update", ...args], {
stdout: "pipe",
stderr: "pipe",
});
const stdout = new TextDecoder().decode(result.stdout).trim();
const stderr = new TextDecoder().decode(result.stderr).trim();
if (!quiet) {
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
}
if (result.exitCode !== 0) {
throw new Error(
[
`slack ${args.join(" ")} failed`,
stdout || undefined,
stderr || undefined,
]
.filter(Boolean)
.join("\n"),
);
}
return stdout;
};
const ensureSlackCli = () => {
try {
runSlackCli({ args: ["version"], quiet: true });
} catch {
console.log(
chalk.yellow(
"Slack CLI is not installed. Install it, then rerun this command:",
),
);
console.log(
"curl -fsSL https://downloads.slack-edge.com/slack-cli/install.sh | bash",
);
throw new Error("Slack CLI is required for Slack setup");
}
};
const maybeShowSlackCliAuthInstructions = () => {
try {
const authList = runSlackCli({ args: ["auth", "list"], quiet: true });
if (authList.includes("No teams are authorized")) {
console.log(chalk.yellow("\nSlack CLI is not authenticated."));
console.log("Run this in another terminal if Slack CLI asks for auth:");
console.log("slack auth login");
}
} catch {
console.log(chalk.yellow("\nCould not read Slack CLI auth state."));
console.log("If Slack CLI prompts for auth, run: slack auth login");
}
};
const parseSlackJson = <T>({
output,
label,
}: {
output: string;
label: string;
}): T => {
try {
return JSON.parse(output) as T;
} catch {
throw new Error(`Could not parse ${label} JSON from Slack CLI:\n${output}`);
}
};
const getSlackApiAuthState = () => {
const output = runSlackCli({
args: ["api", "auth.test"],
quiet: true,
});
return parseSlackJson<SlackApiResponse>({ output, label: "auth.test" });
};
const getTicketFromAuthTokenOutput = ({ output }: { output: string }) => {
const match = output.match(/\/slackauthticket\s+([^\s]+)/);
return match?.[1];
};
const getServiceTokenFromAuthTokenOutput = ({ output }: { output: string }) => {
const match = output.match(/\b(xoxp-[A-Za-z0-9-]+)\b/);
return match?.[1];
};
const ensureSlackApiAuth = async () => {
const initial = getSlackApiAuthState();
if (initial.ok) return undefined;
if (initial.error !== "not_authed") {
throw new Error(`Slack API auth failed: ${initial.error}`);
}
console.log(
chalk.yellow(
"\nSlack CLI is logged in, but API calls need a service token.",
),
);
const ticketOutput = runSlackCli({
args: ["auth", "token", "--no-prompt"],
quiet: true,
});
console.log(ticketOutput);
const ticket = getTicketFromAuthTokenOutput({ output: ticketOutput });
if (!ticket) {
throw new Error("Could not read Slack auth ticket from Slack CLI output");
}
const { challenge } = await inquirer.prompt<{ challenge: string }>([
{
type: "input",
name: "challenge",
message: "Slack challenge code",
validate: (value: string) =>
Boolean(value.trim()) || "Challenge code is required",
},
]);
const tokenOutput = runSlackCli({
args: [
"auth",
"token",
"--ticket",
ticket,
"--challenge",
challenge.trim(),
],
quiet: true,
});
console.log(tokenOutput);
const serviceToken = getServiceTokenFromAuthTokenOutput({
output: tokenOutput,
});
if (!serviceToken) {
throw new Error("Could not read Slack service token from Slack CLI output");
}
const next = parseSlackJson<SlackApiResponse>({
output: runSlackCli({
args: ["api", "auth.test", "--token", serviceToken],
quiet: true,
}),
label: "auth.test",
});
if (!next.ok) {
throw new Error(`Slack API auth still failed: ${next.error}`);
}
return serviceToken;
};
const createSlackApp = async ({
manifest,
serviceToken,
teamId,
}: {
manifest: SlackManifest;
serviceToken?: string;
teamId?: string;
}): Promise<SlackManifestCreateResponse> => {
const output = runSlackCli({
args: [
"api",
"apps.manifest.create",
...(serviceToken ? ["--token", serviceToken] : []),
"--json",
JSON.stringify({
manifest: JSON.stringify(manifest),
...(teamId ? { team_id: teamId } : {}),
}),
],
quiet: true,
});
const json = parseSlackJson<SlackManifestCreateResponse>({
output,
label: "apps.manifest.create",
});
if (!json.ok) throw new Error(`Slack app creation failed: ${json.error}`);
return json;
};
const escapeEnvValue = ({ value }: { value: string }) => {
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value;
return JSON.stringify(value);
};
const upsertEnvFile = ({
filePath,
vars,
}: {
filePath: string;
vars: Record<string, string>;
}) => {
const resolved = resolve(process.cwd(), filePath);
const current = existsSync(resolved) ? readFileSync(resolved, "utf-8") : "";
const lines = current.split("\n");
const seen = new Set<string>();
const updated = lines.map((line) => {
for (const [key, value] of Object.entries(vars)) {
if (line.startsWith(`${key}=`)) {
seen.add(key);
return `${key}=${escapeEnvValue({ value })}`;
}
}
return line;
});
for (const [key, value] of Object.entries(vars)) {
if (!seen.has(key)) {
updated.push(`${key}=${escapeEnvValue({ value })}`);
}
}
writeFileSync(resolved, updated.join("\n").replace(/\n{3,}/g, "\n\n"));
console.log(chalk.green(`Wrote Slack env vars to ${resolved}`));
};
const printEnvExports = ({ vars }: { vars: Record<string, string> }) => {
console.log(chalk.cyan("\nEnv exports:"));
for (const [key, value] of Object.entries(vars)) {
console.log(`export ${key}=${escapeEnvValue({ value })}`);
}
};
const setupSlackBot = async ({ args }: { args: Args }) => {
const resolvedArgs = await resolveInteractiveArgs({ args });
const provider = resolvedArgs.provider;
const readyLabel =
provider === "slack_admin"
? "Slack admin app ready"
: "Slack local app ready";
const nextStep =
provider === "slack_admin"
? "Start Leaf with these env vars, then go to Admin > Slack Bot and click Install."
: "Start Leaf with these env vars, then go to Settings > Integrations and install Slack for the selected org.";
const manifest = buildSlackManifest({
appName: resolvedArgs.appName ?? defaultAppNameForProvider({ provider }),
baseUrl: resolvedArgs.baseUrl,
scopes: resolvedArgs.scopes,
});
if (resolvedArgs.printManifest || resolvedArgs.dryRun) {
console.log(chalk.cyan("Slack app manifest:"));
console.log(JSON.stringify(manifest, null, 2));
}
ensureSlackCli();
maybeShowSlackCliAuthInstructions();
const serviceToken = resolvedArgs.dryRun
? undefined
: await ensureSlackApiAuth();
const slackResponse = resolvedArgs.dryRun
? undefined
: await createSlackApp({
manifest,
serviceToken,
teamId: resolvedArgs.teamId,
});
const credentials = slackResponse?.credentials;
const clientId = credentials?.client_id;
const clientSecret = credentials?.client_secret;
const signingSecret = credentials?.signing_secret;
const redirectUrl = manifest.oauth_config.redirect_urls[0];
if (!resolvedArgs.dryRun && (!clientId || !clientSecret || !signingSecret)) {
console.log(
chalk.yellow("Slack response did not include all credentials."),
);
console.log(JSON.stringify(slackResponse, null, 2));
throw new Error("Could not extract Slack app credentials from response");
}
const envVars = {
SLACK_CLIENT_ID: clientId ?? "<client-id-from-slack>",
SLACK_CLIENT_SECRET: clientSecret ?? "<client-secret-from-slack>",
SLACK_SIGNING_SECRET: signingSecret ?? "<signing-secret-from-slack>",
SLACK_REDIRECT_URI: redirectUrl,
};
console.log(chalk.green(`\n${readyLabel}`));
if (slackResponse?.app_id) console.log(`App ID: ${slackResponse.app_id}`);
printEnvExports({ vars: envVars });
if (resolvedArgs.envFile) {
upsertEnvFile({
filePath: resolvedArgs.envFile,
vars: envVars,
});
}
if (slackResponse?.oauth_authorize_url) {
console.log(
chalk.gray(
"\nSlack returned a raw OAuth URL, but Autumn installs require signed state. Use the Autumn UI install flow instead.",
),
);
}
console.log(chalk.cyan(`\nNext step:\n${nextStep}`));
};
const setupAdminBot = async ({ args }: { args: Args }) =>
setupSlackBot({ args: { ...args, provider: "slack_admin" } });
const setupLocalBot = async ({ args }: { args: Args }) =>
setupSlackBot({ args: { ...args, provider: "slack" } });
const actions = {
"setup-bot": setupSlackBot,
"setup-admin-bot": setupAdminBot,
"setup-local-bot": setupLocalBot,
"setup-regular-bot": setupLocalBot,
} satisfies Record<string, (params: { args: Args }) => Promise<void>>;
type Action = keyof typeof actions;
const isAction = (action: string | undefined): action is Action =>
action !== undefined && Object.hasOwn(actions, action);
const main = async () => {
const args = parseArgs({ argv: process.argv.slice(2) });
if (args.help || !isAction(args.action)) {
console.log(usage());
process.exit(args.help ? 0 : 1);
}
await actions[args.action]({ args });
};
try {
await main();
} catch (error) {
console.error(
chalk.red(error instanceof Error ? error.message : String(error)),
);
process.exit(1);
}

View File

@@ -32,6 +32,12 @@ import { handleGetOrgMember } from "./handleGetOrgMember";
import { handleListAdminOrgs } from "./handleListAdminOrgs";
import { handleListAdminUsers } from "./handleListAdminUsers";
import { handleListOAuthClients } from "./handleListOAuthClients";
import {
handleCreateSlackAdminInstall,
handleDeleteSlackAdminInstall,
handleGetSlackAdminInstall,
handleUpdateSlackAdminTarget,
} from "./handleSlackAdminChat";
import { handleUpsertAdminCustomerBlockConfig } from "./handleUpsertAdminCustomerBlockConfig";
import { handleUpsertAdminFeatureFlagsConfig } from "./handleUpsertAdminFeatureFlagsConfig";
import { handleUpsertAdminFullSubjectGateConfig } from "./handleUpsertAdminFullSubjectGateConfig";
@@ -164,6 +170,16 @@ honoAdminRouter.post(
"/oauth-clients/slack-mcp",
...handleUpsertSlackMcpOAuthClient,
);
honoAdminRouter.get("/chat/slack-admin", ...handleGetSlackAdminInstall);
honoAdminRouter.post(
"/chat/slack-admin/install",
...handleCreateSlackAdminInstall,
);
honoAdminRouter.patch(
"/chat/slack-admin/target",
...handleUpdateSlackAdminTarget,
);
honoAdminRouter.delete("/chat/slack-admin", ...handleDeleteSlackAdminInstall);
honoAdminRouter.post("/invoice-line-items", ...handleGetInvoiceLineItems);
honoAdminRouter.get("/rollouts", ...handleGetRollouts);

View File

@@ -0,0 +1,195 @@
import { randomUUID } from "node:crypto";
import {
AppEnv,
chatInstallations,
chatOAuthCredentials,
createChatInstallState,
ErrCode,
organizations,
RecaseError,
Scopes,
} from "@autumn/shared";
import { addMinutes } from "date-fns";
import { and, eq, or } from "drizzle-orm";
import { z } from "zod/v4";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import {
createSlackInstallUrl,
getChatStateSecret,
getSlackAdminProvider,
} from "../chat/chatUtils.js";
const targetBody = z.strictObject({
org_id: z.string().min(1),
env: z.enum(AppEnv),
});
const findTargetOrg = ({
db,
orgIdOrSlug,
}: {
db: DrizzleCli;
orgIdOrSlug: string;
}) =>
db.query.organizations.findFirst({
where: or(
eq(organizations.id, orgIdOrSlug),
eq(organizations.slug, orgIdOrSlug),
),
});
const getSlackAdminInstallation = async ({ db }: { db: DrizzleCli }) =>
db.query.chatInstallations.findFirst({
where: eq(chatInstallations.provider, getSlackAdminProvider()),
});
const getSlackAdminOAuthCredentials = async ({
db,
installationId,
}: {
db: DrizzleCli;
installationId: string;
}) =>
db.query.chatOAuthCredentials.findMany({
where: eq(chatOAuthCredentials.chat_installation_id, installationId),
});
export const handleCreateSlackAdminInstall = createRoute({
scopes: [Scopes.Superuser],
handler: async (c) => {
const ctx = c.get("ctx");
const state = createChatInstallState({
secret: getChatStateSecret(),
provider: getSlackAdminProvider(),
orgId: ctx.org.id,
userId: ctx.userId ?? "",
env: ctx.env,
expiresAt: addMinutes(Date.now(), 10).getTime(),
nonce: randomUUID(),
});
return c.json({ url: createSlackInstallUrl(state) });
},
});
export const handleGetSlackAdminInstall = createRoute({
scopes: [Scopes.Superuser],
handler: async (c) => {
const { db } = c.get("ctx");
const installation = await getSlackAdminInstallation({ db });
const oauthCredentials = installation
? await getSlackAdminOAuthCredentials({
db,
installationId: installation.id,
})
: [];
return c.json({
installation: installation
? {
id: installation.id,
workspace_id: installation.workspace_id,
workspace_name: installation.workspace_name,
bot_user_id: installation.bot_user_id,
target_org_id: installation.org_id,
target_env: installation.default_env,
updated_at: installation.updated_at,
installed_by_user_id: installation.installed_by_user_id,
oauth_credentials: oauthCredentials.map((credential) => ({
id: credential.id,
env: credential.env,
oauth_client_id: credential.oauth_client_id,
oauth_consent_id: credential.oauth_consent_id,
access_token_expires_at: credential.access_token_expires_at,
updated_at: credential.updated_at,
})),
}
: null,
});
},
});
export const handleUpdateSlackAdminTarget = createRoute({
scopes: [Scopes.Superuser],
body: targetBody,
handler: async (c) => {
const ctx = c.get("ctx");
const { db } = ctx;
const { org_id: orgIdOrSlug, env } = c.req.valid("json");
const installation = await getSlackAdminInstallation({ db });
if (!installation) {
throw new RecaseError({
message: "Slack admin bot is not installed",
code: ErrCode.InvalidRequest,
statusCode: 404,
});
}
const targetOrg = await findTargetOrg({ db, orgIdOrSlug });
if (!targetOrg) {
throw new RecaseError({
message: "Target org not found for ID or slug",
code: ErrCode.OrgNotFound,
statusCode: 404,
});
}
const updated = await db.transaction(async (tx) => {
const now = Date.now();
const [updatedInstallation] = await tx
.update(chatInstallations)
.set({
org_id: targetOrg.id,
default_env: env,
installed_by_user_id: ctx.userId,
updated_at: now,
})
.where(eq(chatInstallations.id, installation.id))
.returning();
await tx
.delete(chatOAuthCredentials)
.where(eq(chatOAuthCredentials.chat_installation_id, installation.id));
return updatedInstallation;
});
return c.json({
installation: {
id: updated.id,
workspace_id: updated.workspace_id,
workspace_name: updated.workspace_name,
target_org_id: updated.org_id,
target_env: updated.default_env,
updated_at: updated.updated_at,
installed_by_user_id: updated.installed_by_user_id,
},
});
},
});
export const handleDeleteSlackAdminInstall = createRoute({
scopes: [Scopes.Superuser],
handler: async (c) => {
const { db } = c.get("ctx");
const installation = await getSlackAdminInstallation({ db });
if (!installation) return c.json({ success: true });
await db.transaction(async (tx) => {
await tx
.delete(chatOAuthCredentials)
.where(eq(chatOAuthCredentials.chat_installation_id, installation.id));
await tx
.delete(chatInstallations)
.where(
and(
eq(chatInstallations.id, installation.id),
eq(chatInstallations.provider, getSlackAdminProvider()),
),
);
});
return c.json({ success: true });
},
});

View File

@@ -5,6 +5,14 @@ import { type OAuthClientRecord, oauthClientRepo } from "../repos/index.js";
const MCP_CLIENT_KIND = "mcp_client";
export const SLACK_MCP_OAUTH_CLIENT_ID = "autumn_mcp_slack";
export const AUTUMN_ADMIN_OAUTH_CLIENT_ID = "autumn_admin";
export const returnsOAuthAccessTokenForClientId = ({
clientId,
}: {
clientId: string;
}) =>
clientId === SLACK_MCP_OAUTH_CLIENT_ID ||
clientId === AUTUMN_ADMIN_OAUTH_CLIENT_ID;
const REGISTER_CACHE_TTL_MS = 5 * 60 * 1000;
const DANGEROUS_REDIRECT_SCHEMES = new Set([
"javascript:",

View File

@@ -3,7 +3,7 @@ import { RecaseError } from "@autumn/shared";
import type { Context } from "hono";
import { db } from "@/db/initDrizzle.js";
import { auth } from "@/utils/auth.js";
import { SLACK_MCP_OAUTH_CLIENT_ID } from "../actions/registerMcpOAuthClient.js";
import { returnsOAuthAccessTokenForClientId } from "../actions/registerMcpOAuthClient.js";
import {
getExternalOAuthApiKeyForToken,
getOAuthAccessTokenRecord,
@@ -142,7 +142,9 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => {
resource,
requestedScopes,
});
if (tokenRecord.clientId === SLACK_MCP_OAUTH_CLIENT_ID) {
if (
returnsOAuthAccessTokenForClientId({ clientId: tokenRecord.clientId })
) {
return jsonTokenResponse({
body: rewriteOAuthAccessTokenBody({
accessToken: prefixOAuthToken({ token: accessToken }),

View File

@@ -1,5 +1,5 @@
import { type AppEnv, oauthConsent } from "@autumn/shared";
import { and, eq } from "drizzle-orm";
import { and, eq, isNull, or } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
export type OAuthConsentApiKeyRecord = {
@@ -12,9 +12,11 @@ export type OAuthConsentApiKeyRecord = {
export const listOAuthConsentsByReferenceId = async ({
db,
referenceId,
env,
}: {
db: DrizzleCli;
referenceId: string;
env?: AppEnv;
}) =>
db
.select({
@@ -27,7 +29,14 @@ export const listOAuthConsentsByReferenceId = async ({
updatedAt: oauthConsent.updatedAt,
})
.from(oauthConsent)
.where(eq(oauthConsent.referenceId, referenceId));
.where(
and(
eq(oauthConsent.referenceId, referenceId),
env
? or(isNull(oauthConsent.env), eq(oauthConsent.env, env))
: undefined,
),
);
export const getOAuthConsentOwner = async ({
db,

View File

@@ -1,6 +1,10 @@
import { randomUUID } from "node:crypto";
import { AppEnv, apiKeys, chatInstallations } from "@autumn/shared";
import { createChatInstallState } from "@autumn/shared/utils/chatState";
import {
AppEnv,
apiKeys,
chatInstallations,
createChatInstallState,
} from "@autumn/shared";
import { addMinutes } from "date-fns";
import { and, eq } from "drizzle-orm";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";

View File

@@ -1,6 +1,13 @@
import { ErrCode, RecaseError } from "@autumn/shared";
export const slackProvider = "slack" as const;
export const slackAdminProviderPrefix = "slack_admin" as const;
export const getSlackAdminProvider = ({
clientId = getRequiredChatEnv("SLACK_CLIENT_ID"),
}: {
clientId?: string;
} = {}) => `${slackAdminProviderPrefix}:${clientId}` as const;
export const defaultSlackScopes = [
"app_mentions:read",

View File

@@ -16,7 +16,7 @@ export const handleGetOrgConsents = createRoute({
scopes: [Scopes.Organisation.Read],
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org } = ctx;
const { db, env, org } = ctx;
if (!org?.id) {
throw new RecaseError({
@@ -28,6 +28,7 @@ export const handleGetOrgConsents = createRoute({
const consents = await oauthConsentRepo.listByReferenceId({
db,
env,
referenceId: org.id,
});

View File

@@ -250,6 +250,9 @@ export const oauthConsent = pgTable("oauth_consent", {
env: text("env").$type<AppEnv>(),
redirectUri: text("redirect_uri"),
oauthApiKeyId: text("oauth_api_key_id"),
metadata: jsonb("metadata")
.$type<Record<string, unknown> | null>()
.default({}),
createdAt: timestamp("created_at", { withTimezone: true }),
updatedAt: timestamp("updated_at", { withTimezone: true }),
}).enableRLS();

View File

@@ -0,0 +1 @@
ALTER TABLE "oauth_consent" ADD COLUMN "metadata" jsonb DEFAULT '{}'::jsonb;

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1780679328640,
"tag": "0008_premium_pet_avengers",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1780687569277,
"tag": "0009_perpetual_wonder_man",
"breakpoints": true
}
]
}
}

View File

@@ -61,6 +61,7 @@ export * from "./models/cusModels/billingControls/purchaseLimitInterval";
export * from "./models/cusModels/cusModels";
// Processor Models
export * from "./models/processorModels/processorModels";
export * from "./utils/chatState";
export { schemas };
// Cus response

View File

@@ -10,7 +10,11 @@ import { sqlNow } from "../../db/utils.js";
import type { AppEnv } from "../genModels/genEnums.js";
import { organizations } from "../orgModels/orgTable.js";
export type ChatProvider = "slack" | "discord";
export type ChatProvider =
| "slack"
| "slack_admin"
| `slack_admin:${string}`
| "discord";
export const chatInstallations = pgTable(
"chat_installations",

View File

@@ -4,7 +4,10 @@ import { z } from "zod";
import { AppEnv } from "../models/genModels/genEnums.js";
const chatInstallStateSchema = z.strictObject({
provider: z.enum(["slack", "discord"]),
provider: z.union([
z.enum(["slack", "slack_admin", "discord"]),
z.string().regex(/^slack_admin:.+$/),
]),
orgId: z.string(),
userId: z.string(),
env: z.nativeEnum(AppEnv),

View File

@@ -3,8 +3,8 @@ import { Globe, Sliders } from "@phosphor-icons/react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { Button } from "@/components/v2/buttons/Button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/v2/buttons/Button";
import { authClient } from "@/lib/auth-client";
import { useEnv } from "@/utils/envUtils";
import { AdminOrgTable } from "@/views/admin/AdminOrgTable";
@@ -13,6 +13,7 @@ import { DefaultView } from "../DefaultView";
import LoadingScreen from "../general/LoadingScreen";
import { CreateUser } from "./components/CreateUser";
import { EdgeConfigTab } from "./components/EdgeConfigTab";
import { SlackAdminBotTab } from "./components/SlackAdminBotTab";
import { useAdmin } from "./hooks/useAdmin";
export const AdminView = () => {
@@ -81,6 +82,7 @@ export const AdminView = () => {
<TabsList>
<TabsTrigger value="orgs">Organizations</TabsTrigger>
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="slack-bot">Slack Bot</TabsTrigger>
<TabsTrigger value="edge-config">Edge Config</TabsTrigger>
</TabsList>
@@ -92,6 +94,10 @@ export const AdminView = () => {
<AdminUserTable />
</TabsContent>
<TabsContent value="slack-bot" className="mt-4">
<SlackAdminBotTab />
</TabsContent>
<TabsContent value="edge-config" className="mt-4">
<EdgeConfigTab />
</TabsContent>

View File

@@ -0,0 +1,344 @@
import { AppEnv } from "@autumn/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, RefreshCw, Save, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/v2/badges/Badge";
import { Button } from "@/components/v2/buttons/Button";
import { Input } from "@/components/v2/inputs/Input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import { useDebounce } from "@/hooks/useDebounce";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
type SlackAdminInstallation = {
id: string;
workspace_id: string;
workspace_name?: string | null;
bot_user_id?: string | null;
target_org_id: string;
target_env: AppEnv;
updated_at?: number | null;
installed_by_user_id?: string | null;
oauth_credentials?: SlackAdminOAuthCredential[];
};
type SlackAdminOAuthCredential = {
id: string;
env: AppEnv;
oauth_client_id: string;
oauth_consent_id?: string | null;
access_token_expires_at: number;
updated_at?: number | null;
};
type OrgSearchResult = {
id: string;
name?: string | null;
slug?: string | null;
createdAt: string;
};
type OrgSearchResponse = {
rows: OrgSearchResult[];
hasNextPage: boolean;
};
const queryKey = ["admin-slack-admin-bot"];
export const SlackAdminBotTab = () => {
const axiosInstance = useAxiosInstance();
const queryClient = useQueryClient();
const [targetOrgIdOrSlug, setTargetOrgIdOrSlug] = useState("");
const [orgSearch, setOrgSearch] = useState("");
const [targetEnv, setTargetEnv] = useState<AppEnv>(AppEnv.Live);
const debouncedOrgSearch = useDebounce({
value: orgSearch.trim(),
delayMs: 250,
});
const { data, isLoading, refetch } = useQuery({
queryKey,
queryFn: async () => {
const { data } = await axiosInstance.get<{
installation: SlackAdminInstallation | null;
}>("/admin/chat/slack-admin");
return data;
},
});
const installation = data?.installation ?? null;
const credentials = installation?.oauth_credentials ?? [];
const { data: orgSearchData, isLoading: isSearchingOrgs } =
useQuery<OrgSearchResponse>({
queryKey: ["admin-slack-bot-org-search", debouncedOrgSearch],
queryFn: async () => {
const params = new URLSearchParams({ search: debouncedOrgSearch });
const { data } = await axiosInstance.get<OrgSearchResponse>(
`/admin/orgs?${params.toString()}`,
);
return data;
},
enabled: Boolean(installation) && debouncedOrgSearch.length > 0,
});
const orgRows = useMemo(
() => orgSearchData?.rows ?? [],
[orgSearchData?.rows],
);
useEffect(() => {
if (!installation) return;
setTargetOrgIdOrSlug(installation.target_org_id);
setTargetEnv(installation.target_env);
}, [installation]);
const installMutation = useMutation({
mutationFn: async () => {
const { data } = await axiosInstance.post<{ url: string }>(
"/admin/chat/slack-admin/install",
);
return data;
},
onSuccess: ({ url }) => {
window.location.assign(url);
},
onError: (error) => {
toast.error(getBackendErr(error, "Failed to create Slack install URL"));
},
});
const updateTargetMutation = useMutation({
mutationFn: async () => {
const { data } = await axiosInstance.patch<{
installation: SlackAdminInstallation;
}>("/admin/chat/slack-admin/target", {
org_id: targetOrgIdOrSlug.trim(),
env: targetEnv,
});
return data;
},
onSuccess: async () => {
toast.success("Slack admin bot target updated");
await queryClient.invalidateQueries({ queryKey });
},
onError: (error) => {
toast.error(getBackendErr(error, "Failed to update Slack admin bot"));
},
});
const revokeMutation = useMutation({
mutationFn: async () => {
await axiosInstance.delete("/admin/chat/slack-admin");
},
onSuccess: async () => {
toast.success("Slack admin bot revoked");
setTargetOrgIdOrSlug("");
setOrgSearch("");
setTargetEnv(AppEnv.Live);
await queryClient.invalidateQueries({ queryKey });
},
onError: (error) => {
toast.error(getBackendErr(error, "Failed to revoke Slack admin bot"));
},
});
const handleRevoke = () => {
if (!confirm("Revoke the Slack admin bot installation?")) return;
revokeMutation.mutate();
};
const handleSelectOrg = ({ org }: { org: OrgSearchResult }) => {
setTargetOrgIdOrSlug(org.id);
setOrgSearch(org.name || org.slug || org.id);
};
return (
<div className="max-w-3xl space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">Slack Bot</h2>
<p className="text-xs text-muted-foreground mt-1">
Install one admin Slack workspace and point it at a target org.
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className="size-3" />
Refresh
</Button>
</div>
<div className="border border-border rounded-lg bg-card p-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{installation?.workspace_name ?? "No workspace installed"}
</p>
<Badge variant={installation ? "muted" : "muted"}>
{installation ? "Installed" : "Not installed"}
</Badge>
</div>
{installation ? (
<p className="text-xs text-muted-foreground mt-1 truncate">
{installation.workspace_id}
{installation.bot_user_id
? ` - Bot ${installation.bot_user_id}`
: ""}
</p>
) : null}
</div>
<Button
variant="primary"
size="sm"
onClick={() => installMutation.mutate()}
isLoading={installMutation.isPending}
>
<ExternalLink className="size-3" />
{installation ? "Reinstall" : "Install"}
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-[1fr_160px_auto] gap-2 items-start">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Target org</span>
<Input
value={targetOrgIdOrSlug}
onChange={(event) => setTargetOrgIdOrSlug(event.target.value)}
placeholder="Org ID or slug"
disabled={!installation}
/>
<Input
value={orgSearch}
onChange={(event) => setOrgSearch(event.target.value)}
placeholder="Search orgs by name, slug, or ID"
disabled={!installation}
/>
{installation && debouncedOrgSearch.length > 0 ? (
<div className="max-h-48 overflow-y-auto rounded-md border border-border bg-background p-1">
{isSearchingOrgs ? (
<div className="px-3 py-2 text-xs text-tertiary-foreground">
Searching organizations...
</div>
) : orgRows.length === 0 ? (
<div className="px-3 py-2 text-xs text-tertiary-foreground">
No organizations found.
</div>
) : (
orgRows.map((org) => {
const isSelected = targetOrgIdOrSlug === org.id;
return (
<button
type="button"
key={org.id}
onClick={() => handleSelectOrg({ org })}
className={`flex w-full flex-col rounded px-2 py-1.5 text-left transition-colors ${
isSelected
? "bg-primary/10 text-foreground"
: "hover:bg-muted/50"
}`}
>
<span className="truncate text-xs font-medium">
{org.name || org.id}
</span>
<span className="truncate font-mono text-[11px] text-tertiary-foreground">
{org.id}
</span>
{org.slug ? (
<span className="truncate text-[11px] text-tertiary-foreground">
{org.slug}
</span>
) : null}
</button>
);
})
)}
</div>
) : null}
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Environment</span>
<Select
value={targetEnv}
onValueChange={(value) => setTargetEnv(value as AppEnv)}
disabled={!installation}
>
<SelectTrigger className="h-input px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={AppEnv.Live}>Live</SelectItem>
<SelectItem value={AppEnv.Sandbox}>Sandbox</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => updateTargetMutation.mutate()}
disabled={!installation || !targetOrgIdOrSlug.trim()}
isLoading={updateTargetMutation.isPending}
>
<Save className="size-3" />
Save
</Button>
</div>
{credentials.length > 0 ? (
<div className="rounded-md border border-border bg-background p-3">
<p className="text-xs font-medium text-muted-foreground">
Internal OAuth credentials
</p>
<div className="mt-2 space-y-1">
{credentials.map((credential) => (
<div
key={credential.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-tertiary-foreground"
>
<span className="font-medium text-foreground">
{credential.env}
</span>
<span>{credential.oauth_client_id}</span>
{credential.oauth_consent_id ? (
<span className="font-mono">
{credential.oauth_consent_id}
</span>
) : null}
</div>
))}
</div>
</div>
) : null}
<div className="flex justify-end border-t border-border pt-4">
<Button
variant="destructive"
size="sm"
onClick={handleRevoke}
disabled={!installation}
isLoading={revokeMutation.isPending}
>
<Trash2 className="size-3" />
Revoke
</Button>
</div>
</div>
</div>
);
};