mcp admin
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
673
scripts/slack/index.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
195
server/src/internal/admin/handleSlackAdminChat.ts
Normal file
195
server/src/internal/admin/handleSlackAdminChat.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
1
shared/drizzle/0009_perpetual_wonder_man.sql
Normal file
1
shared/drizzle/0009_perpetual_wonder_man.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "oauth_consent" ADD COLUMN "metadata" jsonb DEFAULT '{}'::jsonb;
|
||||
7523
shared/drizzle/meta/0009_snapshot.json
Normal file
7523
shared/drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
344
vite/src/views/admin/components/SlackAdminBotTab.tsx
Normal file
344
vite/src/views/admin/components/SlackAdminBotTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user