fix: slack bot authenticates via oauth
This commit is contained in:
@@ -15,12 +15,20 @@ bun run chat:tunnel
|
||||
2. Start Autumn with the same public URL:
|
||||
|
||||
```sh
|
||||
CHAT_URL=https://c.autumn.ngrok.app SLACK_BOT_URL=https://c.autumn.ngrok.app bun d
|
||||
NGROK_URL=https://c.autumn.ngrok.app bun d
|
||||
```
|
||||
|
||||
`bun d` derives `CHAT_URL`, `SLACK_BOT_URL`, and `SLACK_REDIRECT_URI` from
|
||||
`NGROK_URL`, so the Slack OAuth redirect becomes
|
||||
`https://c.autumn.ngrok.app/slack/oauth/callback`. This exact URL must be in
|
||||
the Slack app's OAuth redirect URLs.
|
||||
|
||||
The chat SDK stores its own subscriptions, locks, and queues in Postgres. By
|
||||
default it uses the same `DATABASE_URL` host with the database name changed to
|
||||
`chat`; set `CHAT_STATE_DATABASE_URL` to override this.
|
||||
`chat`; set `CHAT_STATE_DATABASE_URL` to override this. `bun dev:services up`
|
||||
creates the local `chat` database. The `@chat-adapter/state-pg` package creates
|
||||
its state tables automatically on connect, so there is no separate migration
|
||||
command for the chat state database.
|
||||
|
||||
3. Create a Slack app at https://api.slack.com/apps using `slack-manifest.example.json`.
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ const readDocs = async (mcp: ReturnType<typeof createAutumnMcpClient>) => {
|
||||
};
|
||||
|
||||
export const runChatAgent = async ({
|
||||
apiKey,
|
||||
token,
|
||||
env,
|
||||
logger = rootLogger,
|
||||
message,
|
||||
@@ -106,7 +106,7 @@ export const runChatAgent = async ({
|
||||
provider,
|
||||
recentMessages,
|
||||
}: {
|
||||
apiKey: string;
|
||||
token: string;
|
||||
env: AppEnv;
|
||||
logger?: AutumnLogger;
|
||||
message: string;
|
||||
@@ -116,7 +116,11 @@ export const runChatAgent = async ({
|
||||
provider: string;
|
||||
recentMessages?: ChatContextMessage[];
|
||||
}) => {
|
||||
const mcp = createAutumnMcpClient(apiKey, { requireApproval: true });
|
||||
const mcp = createAutumnMcpClient({
|
||||
token,
|
||||
appEnv: env,
|
||||
options: { requireApproval: true },
|
||||
});
|
||||
let previewApproval:
|
||||
| {
|
||||
toolName: string;
|
||||
@@ -138,12 +142,15 @@ export const runChatAgent = async ({
|
||||
});
|
||||
await onAction?.("Loading Autumn tools and guidance");
|
||||
const [tools, docsText] = await Promise.all([
|
||||
getAutumnMcpTools(mcp, {
|
||||
applyApprovalPolicy: true,
|
||||
logger,
|
||||
onToolCall: onAction,
|
||||
onPreview: (approval) => {
|
||||
previewApproval = approval;
|
||||
getAutumnMcpTools({
|
||||
mcp,
|
||||
options: {
|
||||
applyApprovalPolicy: true,
|
||||
logger,
|
||||
onToolCall: onAction,
|
||||
onPreview: (approval) => {
|
||||
previewApproval = approval;
|
||||
},
|
||||
},
|
||||
}),
|
||||
readDocs(mcp),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { isSecretKeyPrefix } from "@autumn/auth";
|
||||
import type { AutumnLogger } from "@autumn/logging";
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
import { MCPClient } from "@mastra/mcp";
|
||||
import { env } from "../lib/env.js";
|
||||
import { logger as rootLogger } from "../lib/logger.js";
|
||||
@@ -26,29 +28,41 @@ type ToolOptions = {
|
||||
};
|
||||
|
||||
const withAuthFetch =
|
||||
(apiKey: string) => (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
({ appEnv, token }: { appEnv: AppEnv; token: string }) =>
|
||||
(input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
headers.set("secret-key", apiKey);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
headers.set("x-autumn-environment", appEnv);
|
||||
if (isSecretKeyPrefix({ token })) {
|
||||
headers.set("secret-key", token);
|
||||
}
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
|
||||
export const createAutumnMcpClient = (
|
||||
apiKey: string,
|
||||
options: { requireApproval?: boolean } = {},
|
||||
) => {
|
||||
const fetchWithAuth = withAuthFetch(apiKey);
|
||||
export const createAutumnMcpClient = ({
|
||||
token,
|
||||
appEnv,
|
||||
options = {},
|
||||
}: {
|
||||
token: string;
|
||||
appEnv: AppEnv;
|
||||
options?: { requireApproval?: boolean };
|
||||
}) => {
|
||||
const fetchWithAuth = withAuthFetch({ appEnv, token });
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"x-autumn-environment": appEnv,
|
||||
};
|
||||
if (isSecretKeyPrefix({ token })) {
|
||||
headers["secret-key"] = token;
|
||||
}
|
||||
|
||||
return new MCPClient({
|
||||
id: `autumn-${apiKey.slice(0, 14)}`,
|
||||
id: `autumn-${token.slice(0, 14)}`,
|
||||
servers: {
|
||||
autumn: {
|
||||
url: new URL("/mcp", env.MCP_SERVER_URL),
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"secret-key": apiKey,
|
||||
},
|
||||
},
|
||||
requestInit: { headers },
|
||||
eventSourceInit: { fetch: fetchWithAuth },
|
||||
fetch: fetchWithAuth,
|
||||
requireToolApproval: options.requireApproval
|
||||
@@ -59,7 +73,13 @@ export const createAutumnMcpClient = (
|
||||
});
|
||||
};
|
||||
|
||||
const formatToolAction = (toolName: string, args: Record<string, unknown>) => {
|
||||
const formatToolAction = ({
|
||||
toolName,
|
||||
args,
|
||||
}: {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}) => {
|
||||
const request =
|
||||
args.request && typeof args.request === "object"
|
||||
? (args.request as Record<string, unknown>)
|
||||
@@ -76,10 +96,13 @@ const formatToolAction = (toolName: string, args: Record<string, unknown>) => {
|
||||
return `${toolLabel(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`;
|
||||
};
|
||||
|
||||
export const getAutumnMcpTools = async (
|
||||
mcp: MCPClient,
|
||||
options: ToolOptions = {},
|
||||
) => {
|
||||
export const getAutumnMcpTools = async ({
|
||||
mcp,
|
||||
options = {},
|
||||
}: {
|
||||
mcp: MCPClient;
|
||||
options?: ToolOptions;
|
||||
}) => {
|
||||
const logger = options.logger ?? rootLogger;
|
||||
const { toolsets, errors } = await mcp.listToolsetsWithErrors();
|
||||
if (Object.keys(errors).length) {
|
||||
@@ -111,7 +134,7 @@ export const getAutumnMcpTools = async (
|
||||
event: "leaf.mcp_tool_called",
|
||||
tool: toolName,
|
||||
});
|
||||
await options.onToolCall?.(formatToolAction(toolName, args));
|
||||
await options.onToolCall?.(formatToolAction({ toolName, args }));
|
||||
const result = await execute(args, ...rest);
|
||||
const writeTool = getWriteToolForPreview(toolName);
|
||||
if (writeTool) {
|
||||
@@ -136,17 +159,19 @@ export const getAutumnMcpTools = async (
|
||||
};
|
||||
|
||||
export const executeAutumnMcpTool = async ({
|
||||
apiKey,
|
||||
env,
|
||||
token,
|
||||
toolName,
|
||||
args,
|
||||
}: {
|
||||
apiKey: string;
|
||||
env: AppEnv;
|
||||
token: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}) => {
|
||||
const mcp = createAutumnMcpClient(apiKey);
|
||||
const mcp = createAutumnMcpClient({ token, appEnv: env });
|
||||
try {
|
||||
const tools = await getAutumnMcpTools(mcp);
|
||||
const tools = await getAutumnMcpTools({ mcp });
|
||||
const tool = tools[toolName.replace(/^autumn_/, "")];
|
||||
if (!tool?.execute) throw new Error(`Unknown Autumn MCP tool: ${toolName}`);
|
||||
return await tool.execute(args);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
|
||||
import { logger as rootLogger } from "../lib/logger.js";
|
||||
import { getInstallationKey } from "../providers/slack/installations.js";
|
||||
import { agentOutputSchema, type BotMessage } from "../types.js";
|
||||
import { runChatAgent, selectChatEnv } from "./agent.js";
|
||||
|
||||
@@ -35,9 +35,13 @@ export const runMessage = async ({
|
||||
provider: installation.provider,
|
||||
},
|
||||
});
|
||||
const token = await getInstallationOAuthAccessToken({
|
||||
installation,
|
||||
env,
|
||||
});
|
||||
return agentOutputSchema.parse(
|
||||
await runChatAgent({
|
||||
apiKey: getInstallationKey(installation, env),
|
||||
token,
|
||||
env,
|
||||
logger,
|
||||
message: text,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
AppEnv,
|
||||
type AppEnv,
|
||||
type ChatProvider,
|
||||
chatApprovals,
|
||||
chatInstallations,
|
||||
} from "@autumn/shared";
|
||||
import { addMinutes, isPast } from "date-fns";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { decrypt } from "../lib/crypto.js";
|
||||
import { db } from "../lib/db.js";
|
||||
import { executeAutumnMcpTool } from "../agent/mcp.js";
|
||||
import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
|
||||
import { db } from "../lib/db.js";
|
||||
|
||||
export const normalizeToolName = (toolName: string) =>
|
||||
toolName.replace(/^autumn_/, "");
|
||||
@@ -120,14 +120,14 @@ export const approveAndRun = async (id: string, providerUserId: string) => {
|
||||
});
|
||||
if (!installation) throw new Error("Chat installation not found");
|
||||
|
||||
const encryptedKey =
|
||||
claimed.env === AppEnv.Live
|
||||
? installation.live_api_key
|
||||
: installation.sandbox_api_key;
|
||||
if (!encryptedKey) throw new Error(`Missing ${claimed.env} API key`);
|
||||
const token = await getInstallationOAuthAccessToken({
|
||||
installation,
|
||||
env: claimed.env,
|
||||
});
|
||||
|
||||
const result = await executeAutumnMcpTool({
|
||||
apiKey: decrypt(encryptedKey),
|
||||
token,
|
||||
env: claimed.env,
|
||||
toolName: claimed.tool_name,
|
||||
args: claimed.tool_args,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { AppEnv, ChatInstallation } from "@autumn/shared";
|
||||
import { decrypt, encrypt } from "../../../lib/crypto.js";
|
||||
import { db } from "../../../lib/db.js";
|
||||
import { env as leafEnv } from "../../../lib/env.js";
|
||||
import {
|
||||
getChatOAuthCredentialByInstallationEnv,
|
||||
updateChatOAuthCredentialTokens,
|
||||
} from "../repos/chatOAuthCredentialsRepo.js";
|
||||
import {
|
||||
parseOAuthScopeString,
|
||||
parseOAuthTokenResponse,
|
||||
} from "../utils/oauthTokenResponse.js";
|
||||
|
||||
const TOKEN_EXPIRY_SKEW_MS = 60_000;
|
||||
|
||||
const getTokenEndpoint = () =>
|
||||
new URL("/api/auth/oauth2/token", leafEnv.BETTER_AUTH_URL).href;
|
||||
|
||||
const getDefaultExpiresAt = () => Date.now() + 60 * 60 * 1000;
|
||||
|
||||
export const getInstallationOAuthAccessToken = async ({
|
||||
installation,
|
||||
env,
|
||||
}: {
|
||||
installation: ChatInstallation;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
const credential = await getChatOAuthCredentialByInstallationEnv({
|
||||
db,
|
||||
chatInstallationId: installation.id,
|
||||
env,
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error(
|
||||
`Missing ${env} Autumn OAuth credentials for Slack install`,
|
||||
);
|
||||
}
|
||||
|
||||
if (credential.access_token_expires_at - TOKEN_EXPIRY_SKEW_MS > Date.now()) {
|
||||
return decrypt(credential.access_token);
|
||||
}
|
||||
|
||||
const refreshToken = decrypt(credential.refresh_token);
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: credential.oauth_client_id,
|
||||
});
|
||||
|
||||
const response = await fetch(getTokenEndpoint(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Could not refresh ${env} Autumn OAuth token for Slack install`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseOAuthTokenResponse({ body: await response.json() });
|
||||
const accessTokenExpiresAt = parsed.expires_in
|
||||
? Date.now() + parsed.expires_in * 1000
|
||||
: getDefaultExpiresAt();
|
||||
const nextRefreshToken = parsed.refresh_token ?? refreshToken;
|
||||
const scopes = parseOAuthScopeString({ scope: parsed.scope });
|
||||
|
||||
await updateChatOAuthCredentialTokens({
|
||||
db,
|
||||
id: credential.id,
|
||||
accessToken: encrypt(parsed.access_token),
|
||||
refreshToken: encrypt(nextRefreshToken),
|
||||
accessTokenExpiresAt,
|
||||
scopes: scopes.length > 0 ? scopes : credential.scopes,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
return parsed.access_token;
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import crypto from "node:crypto";
|
||||
import { prefixOAuthToken } from "@autumn/auth";
|
||||
import {
|
||||
AppEnv,
|
||||
type ChatInstallation,
|
||||
chatOAuthCredentials,
|
||||
oauthAccessToken,
|
||||
oauthClient,
|
||||
oauthConsent,
|
||||
oauthRefreshToken,
|
||||
} from "@autumn/shared";
|
||||
import { ALL_SCOPES } from "@autumn/shared/utils/scopeDefinitions";
|
||||
import { and, eq } 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";
|
||||
|
||||
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 tokenHash = ({ token }: { token: string }) => {
|
||||
const hash = crypto.createHash("sha256").update(token).digest();
|
||||
return hash
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
};
|
||||
|
||||
const generateToken = () => crypto.randomBytes(48).toString("base64url");
|
||||
|
||||
const ensureSlackMcpOAuthClient = async ({ tx }: { tx: ChatTransaction }) => {
|
||||
const now = new Date();
|
||||
|
||||
await tx
|
||||
.insert(oauthClient)
|
||||
.values({
|
||||
id: `oauth_client_${crypto.randomUUID().replace(/-/g, "")}`,
|
||||
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
|
||||
name: "Slack",
|
||||
redirectUris: ["slack://autumn-chat"],
|
||||
scopes: [...ALL_SCOPES],
|
||||
tokenEndpointAuthMethod: "none",
|
||||
grantTypes: ["authorization_code", "refresh_token"],
|
||||
responseTypes: ["code"],
|
||||
public: true,
|
||||
type: "native",
|
||||
metadata: {
|
||||
kind: "mcp_client",
|
||||
mcpClientType: "slack",
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: oauthClient.clientId,
|
||||
set: {
|
||||
name: "Slack",
|
||||
scopes: [...ALL_SCOPES],
|
||||
tokenEndpointAuthMethod: "none",
|
||||
grantTypes: ["authorization_code", "refresh_token"],
|
||||
responseTypes: ["code"],
|
||||
public: true,
|
||||
type: "native",
|
||||
metadata: {
|
||||
kind: "mcp_client",
|
||||
mcpClientType: "slack",
|
||||
},
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const upsertOAuthConsent = async ({
|
||||
tx,
|
||||
env,
|
||||
orgId,
|
||||
userId,
|
||||
}: {
|
||||
tx: ChatTransaction;
|
||||
env: AppEnv;
|
||||
orgId: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
const now = new Date();
|
||||
const [existingConsent] = await tx
|
||||
.select({ id: oauthConsent.id })
|
||||
.from(oauthConsent)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthConsent.clientId, AUTUMN_SLACK_OAUTH_CLIENT_ID),
|
||||
eq(oauthConsent.userId, userId),
|
||||
eq(oauthConsent.referenceId, orgId),
|
||||
eq(oauthConsent.env, env),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingConsent) {
|
||||
await tx
|
||||
.update(oauthConsent)
|
||||
.set({
|
||||
scopes: [...ALL_SCOPES],
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(oauthConsent.id, existingConsent.id));
|
||||
return existingConsent.id;
|
||||
}
|
||||
|
||||
const consentId = `oauth_consent_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
await tx.insert(oauthConsent).values({
|
||||
id: consentId,
|
||||
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
|
||||
userId,
|
||||
referenceId: orgId,
|
||||
scopes: [...ALL_SCOPES],
|
||||
env,
|
||||
redirectUri: "slack://autumn-chat",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return consentId;
|
||||
};
|
||||
|
||||
const createCredentialForEnv = async ({
|
||||
tx,
|
||||
installation,
|
||||
env,
|
||||
userId,
|
||||
}: {
|
||||
tx: ChatTransaction;
|
||||
installation: ChatInstallation;
|
||||
env: AppEnv;
|
||||
userId: string;
|
||||
}) => {
|
||||
const now = Date.now();
|
||||
const nowDate = new Date(now);
|
||||
const rawAccessToken = generateToken();
|
||||
const rawRefreshToken = generateToken();
|
||||
const accessTokenExpiresAt = now + ACCESS_TOKEN_TTL_MS;
|
||||
const refreshTokenExpiresAt = now + REFRESH_TOKEN_TTL_MS;
|
||||
const refreshTokenId = `oauth_refresh_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
const accessTokenId = `oauth_access_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
const consentId = await upsertOAuthConsent({
|
||||
tx,
|
||||
env,
|
||||
orgId: installation.org_id,
|
||||
userId,
|
||||
});
|
||||
|
||||
await tx.insert(oauthRefreshToken).values({
|
||||
id: refreshTokenId,
|
||||
token: tokenHash({ token: rawRefreshToken }),
|
||||
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
|
||||
userId,
|
||||
referenceId: installation.org_id,
|
||||
expiresAt: new Date(refreshTokenExpiresAt),
|
||||
createdAt: nowDate,
|
||||
authTime: nowDate,
|
||||
scopes: [...ALL_SCOPES],
|
||||
});
|
||||
await tx.insert(oauthAccessToken).values({
|
||||
id: accessTokenId,
|
||||
token: tokenHash({ token: rawAccessToken }),
|
||||
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
|
||||
userId,
|
||||
referenceId: installation.org_id,
|
||||
refreshId: refreshTokenId,
|
||||
expiresAt: new Date(accessTokenExpiresAt),
|
||||
createdAt: nowDate,
|
||||
scopes: [...ALL_SCOPES],
|
||||
});
|
||||
await tx.insert(chatOAuthCredentials).values({
|
||||
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_consent_id: consentId,
|
||||
access_token: encrypt(prefixOAuthToken({ token: rawAccessToken })),
|
||||
refresh_token: encrypt(rawRefreshToken),
|
||||
access_token_expires_at: accessTokenExpiresAt,
|
||||
scopes: [...ALL_SCOPES],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
};
|
||||
|
||||
export const replaceInstallationOAuthCredentials = async ({
|
||||
tx,
|
||||
installation,
|
||||
userId,
|
||||
}: {
|
||||
tx: ChatTransaction;
|
||||
installation: ChatInstallation;
|
||||
userId: string;
|
||||
}) => {
|
||||
if (!userId) {
|
||||
throw new Error("Missing user id for Slack MCP OAuth credentials");
|
||||
}
|
||||
|
||||
await ensureSlackMcpOAuthClient({ tx });
|
||||
await createCredentialForEnv({
|
||||
tx,
|
||||
installation,
|
||||
env: AppEnv.Sandbox,
|
||||
userId,
|
||||
});
|
||||
await createCredentialForEnv({
|
||||
tx,
|
||||
installation,
|
||||
env: AppEnv.Live,
|
||||
userId,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { AppEnv, ChatInstallation } from "@autumn/shared";
|
||||
import { encrypt } from "../../../lib/crypto.js";
|
||||
import { db } from "../../../lib/db.js";
|
||||
import { upsertChatOAuthCredential } from "../repos/chatOAuthCredentialsRepo.js";
|
||||
|
||||
export const AUTUMN_SLACK_OAUTH_CLIENT_ID = "autumn_mcp_slack";
|
||||
|
||||
export const upsertInstallationOAuthCredential = async ({
|
||||
installation,
|
||||
env,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt,
|
||||
scopes,
|
||||
oauthClientId = AUTUMN_SLACK_OAUTH_CLIENT_ID,
|
||||
oauthConsentId,
|
||||
}: {
|
||||
installation: ChatInstallation;
|
||||
env: AppEnv;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessTokenExpiresAt: number;
|
||||
scopes: string[];
|
||||
oauthClientId?: string;
|
||||
oauthConsentId?: string | null;
|
||||
}) => {
|
||||
const now = Date.now();
|
||||
|
||||
return upsertChatOAuthCredential({
|
||||
db,
|
||||
credential: {
|
||||
id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`,
|
||||
chat_installation_id: installation.id,
|
||||
org_id: installation.org_id,
|
||||
env,
|
||||
oauth_client_id: oauthClientId,
|
||||
oauth_consent_id: oauthConsentId ?? null,
|
||||
access_token: encrypt(accessToken),
|
||||
refresh_token: encrypt(refreshToken),
|
||||
access_token_expires_at: accessTokenExpiresAt,
|
||||
scopes,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
type ChatOAuthCredential,
|
||||
chatOAuthCredentials,
|
||||
} from "@autumn/shared";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { ChatDb } from "../../../lib/db.js";
|
||||
|
||||
export type ChatOAuthCredentialInsert =
|
||||
typeof chatOAuthCredentials.$inferInsert;
|
||||
|
||||
export const getChatOAuthCredentialByInstallationEnv = async ({
|
||||
db,
|
||||
chatInstallationId,
|
||||
env,
|
||||
}: {
|
||||
db: ChatDb;
|
||||
chatInstallationId: string;
|
||||
env: AppEnv;
|
||||
}) =>
|
||||
db.query.chatOAuthCredentials.findFirst({
|
||||
where: and(
|
||||
eq(chatOAuthCredentials.chat_installation_id, chatInstallationId),
|
||||
eq(chatOAuthCredentials.env, env),
|
||||
),
|
||||
});
|
||||
|
||||
export const upsertChatOAuthCredential = async ({
|
||||
db,
|
||||
credential,
|
||||
}: {
|
||||
db: ChatDb;
|
||||
credential: ChatOAuthCredentialInsert;
|
||||
}) => {
|
||||
const [row] = await db
|
||||
.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,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return row as ChatOAuthCredential;
|
||||
};
|
||||
|
||||
export const updateChatOAuthCredentialTokens = async ({
|
||||
db,
|
||||
id,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt,
|
||||
scopes,
|
||||
updatedAt,
|
||||
}: {
|
||||
db: ChatDb;
|
||||
id: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessTokenExpiresAt: number;
|
||||
scopes: string[];
|
||||
updatedAt: number;
|
||||
}) => {
|
||||
const [row] = await db
|
||||
.update(chatOAuthCredentials)
|
||||
.set({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
access_token_expires_at: accessTokenExpiresAt,
|
||||
scopes,
|
||||
updated_at: updatedAt,
|
||||
})
|
||||
.where(eq(chatOAuthCredentials.id, id))
|
||||
.returning();
|
||||
|
||||
return row as ChatOAuthCredential | undefined;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const oauthTokenPayloadSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
refresh_token: z.string().min(1).optional(),
|
||||
expires_in: z.number().optional(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
const oauthTokenResponseSchema = z.preprocess((value) => {
|
||||
if (value && typeof value === "object" && "response" in value) {
|
||||
return (value as { response?: unknown }).response;
|
||||
}
|
||||
|
||||
return value;
|
||||
}, oauthTokenPayloadSchema);
|
||||
|
||||
export const parseOAuthTokenResponse = ({ body }: { body: unknown }) =>
|
||||
oauthTokenResponseSchema.parse(body);
|
||||
|
||||
export const parseOAuthScopeString = ({ scope }: { scope?: string }) =>
|
||||
scope?.split(/\s+/).filter(Boolean) ?? [];
|
||||
@@ -1,14 +1,14 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { getBearerToken } from "@autumn/auth";
|
||||
import { getBearerToken, isOAuthToken, isSecretKeyPrefix } from "@autumn/auth";
|
||||
import {
|
||||
getProtectedResourceMetadataUrl,
|
||||
getWwwAuthenticateHeader,
|
||||
} from "@autumn/auth/oauth";
|
||||
import {
|
||||
DEFAULT_API_VERSION,
|
||||
MCP_OAUTH_SCOPES,
|
||||
type AutumnMcpAuth,
|
||||
DEFAULT_API_VERSION,
|
||||
environmentSchema,
|
||||
MCP_OAUTH_SCOPES,
|
||||
type MCPServerFlags,
|
||||
type OAuthEnvironment,
|
||||
} from "@autumn/mcp";
|
||||
@@ -72,12 +72,21 @@ const getStaticApiKey = ({
|
||||
flags: MCPOAuthFlags;
|
||||
}): string | undefined => {
|
||||
const secretKey = headers.get("secret-key");
|
||||
if (secretKey) return secretKey;
|
||||
if (secretKey && isSecretKeyPrefix({ token: secretKey })) return secretKey;
|
||||
|
||||
const bearer = getBearerToken({ headers });
|
||||
if (bearer?.startsWith("am_")) return bearer;
|
||||
if (bearer && isSecretKeyPrefix({ token: bearer })) return bearer;
|
||||
|
||||
return flags["oauth-enabled"] ? undefined : flags["secret-key"];
|
||||
const fallbackSecretKey = flags["secret-key"];
|
||||
if (
|
||||
!flags["oauth-enabled"] &&
|
||||
fallbackSecretKey &&
|
||||
isSecretKeyPrefix({ token: fallbackSecretKey })
|
||||
) {
|
||||
return fallbackSecretKey;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const principalFromSecret = ({
|
||||
@@ -134,7 +143,7 @@ export const buildAuthForRequest = async ({
|
||||
}
|
||||
|
||||
const bearer = getBearerToken({ headers });
|
||||
if (bearer) {
|
||||
if (bearer && isOAuthToken({ token: bearer })) {
|
||||
return {
|
||||
apiKey: bearer,
|
||||
authMethod: "oauth",
|
||||
@@ -148,6 +157,14 @@ export const buildAuthForRequest = async ({
|
||||
};
|
||||
}
|
||||
|
||||
if (bearer) {
|
||||
throw new OAuthHttpError(
|
||||
401,
|
||||
"Invalid OAuth token prefix",
|
||||
"invalid_token",
|
||||
);
|
||||
}
|
||||
|
||||
if (flags["oauth-enabled"]) {
|
||||
throw new OAuthHttpError(
|
||||
401,
|
||||
|
||||
@@ -5,29 +5,16 @@ import {
|
||||
type ChatInstallation,
|
||||
type ChatProvider,
|
||||
chatInstallations,
|
||||
Scopes,
|
||||
} 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";
|
||||
import { db } from "../../lib/db.js";
|
||||
import { env } from "../../lib/env.js";
|
||||
|
||||
type ChatTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
const apiKeyScopes = [
|
||||
Scopes.Customers.Read,
|
||||
Scopes.Customers.Write,
|
||||
Scopes.Plans.Read,
|
||||
Scopes.Plans.Write,
|
||||
Scopes.Billing.Read,
|
||||
Scopes.Billing.Write,
|
||||
Scopes.Balances.Write,
|
||||
];
|
||||
|
||||
const apiKeyPrefix = (env: AppEnv) =>
|
||||
env === AppEnv.Live ? "am_sk_live" : "am_sk_test";
|
||||
|
||||
export const getStateSecret = () => env.CHAT_STATE_SECRET;
|
||||
|
||||
export const findInstallation = (provider: ChatProvider, workspaceId: string) =>
|
||||
@@ -50,33 +37,6 @@ export const getInstallationKey = (
|
||||
return decrypt(key);
|
||||
};
|
||||
|
||||
const buildApiKey = ({
|
||||
orgId,
|
||||
userId,
|
||||
env,
|
||||
provider,
|
||||
}: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
env: AppEnv;
|
||||
provider: ChatProvider;
|
||||
}) => {
|
||||
const secret = `${apiKeyPrefix(env)}_${crypto.randomBytes(32).toString("base64url")}`;
|
||||
const key = {
|
||||
id: `key_${crypto.randomUUID().replace(/-/g, "")}`,
|
||||
org_id: orgId,
|
||||
user_id: userId,
|
||||
name: `Chat MCP (${provider})`,
|
||||
prefix: secret.substring(0, 14),
|
||||
created_at: Date.now(),
|
||||
env,
|
||||
hashed_key: crypto.createHash("sha256").update(secret).digest("hex"),
|
||||
meta: { created_via: "chat", provider },
|
||||
scopes: apiKeyScopes,
|
||||
};
|
||||
return { key, secret };
|
||||
};
|
||||
|
||||
const deleteInstallationApiKeys = async (
|
||||
tx: ChatTransaction,
|
||||
installation: ChatInstallation,
|
||||
@@ -111,19 +71,6 @@ export const replaceInstallation = async ({
|
||||
scopes: string[];
|
||||
installedByProviderUserId?: string;
|
||||
}) => {
|
||||
const sandbox = buildApiKey({
|
||||
orgId: state.orgId,
|
||||
userId: state.userId,
|
||||
env: AppEnv.Sandbox,
|
||||
provider,
|
||||
});
|
||||
const live = buildApiKey({
|
||||
orgId: state.orgId,
|
||||
userId: state.userId,
|
||||
env: AppEnv.Live,
|
||||
provider,
|
||||
});
|
||||
|
||||
const sameOrg = and(
|
||||
eq(chatInstallations.org_id, state.orgId),
|
||||
eq(chatInstallations.provider, provider),
|
||||
@@ -134,8 +81,6 @@ export const replaceInstallation = async ({
|
||||
);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(apiKeys).values([sandbox.key, live.key]);
|
||||
|
||||
const existingInstallations = await tx.query.chatInstallations.findMany({
|
||||
where: or(sameOrg, sameWorkspace),
|
||||
});
|
||||
@@ -144,24 +89,29 @@ export const replaceInstallation = async ({
|
||||
}
|
||||
|
||||
await tx.delete(chatInstallations).where(or(sameOrg, sameWorkspace));
|
||||
await tx.insert(chatInstallations).values({
|
||||
id: `chat_inst_${crypto.randomUUID().replace(/-/g, "")}`,
|
||||
org_id: state.orgId,
|
||||
provider,
|
||||
workspace_id: workspaceId,
|
||||
workspace_name: workspaceName,
|
||||
bot_user_id: botUserId,
|
||||
bot_access_token: encrypt(botAccessToken),
|
||||
scopes,
|
||||
default_env: state.env,
|
||||
sandbox_api_key_id: sandbox.key.id,
|
||||
sandbox_api_key: encrypt(sandbox.secret),
|
||||
live_api_key_id: live.key.id,
|
||||
live_api_key: encrypt(live.secret),
|
||||
installed_by_user_id: state.userId,
|
||||
installed_by_provider_user_id: installedByProviderUserId,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
const [installation] = await tx
|
||||
.insert(chatInstallations)
|
||||
.values({
|
||||
id: `chat_inst_${crypto.randomUUID().replace(/-/g, "")}`,
|
||||
org_id: state.orgId,
|
||||
provider,
|
||||
workspace_id: workspaceId,
|
||||
workspace_name: workspaceName,
|
||||
bot_user_id: botUserId,
|
||||
bot_access_token: encrypt(botAccessToken),
|
||||
scopes,
|
||||
default_env: state.env,
|
||||
installed_by_user_id: state.userId,
|
||||
installed_by_provider_user_id: installedByProviderUserId,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
await replaceInstallationOAuthCredentials({
|
||||
tx,
|
||||
installation,
|
||||
userId: state.userId,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
23
packages/auth/src/utils/authTokenUtils.ts
Normal file
23
packages/auth/src/utils/authTokenUtils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const AUTUMN_SECRET_KEY_PREFIX = "am_sk";
|
||||
const AUTUMN_PUBLISHABLE_KEY_PREFIX = "am_pk";
|
||||
const AUTUMN_OAUTH_TOKEN_PREFIX = "am_oauth_";
|
||||
|
||||
export const isSecretKeyPrefix = ({ token }: { token: string }) =>
|
||||
token.startsWith(AUTUMN_SECRET_KEY_PREFIX);
|
||||
|
||||
export const isPublishableKeyPrefix = ({ token }: { token: string }) =>
|
||||
token.startsWith(AUTUMN_PUBLISHABLE_KEY_PREFIX);
|
||||
|
||||
export const isAutumnApiKey = ({ token }: { token: string }) =>
|
||||
isSecretKeyPrefix({ token }) || isPublishableKeyPrefix({ token });
|
||||
|
||||
export const isOAuthToken = ({ token }: { token: string }) =>
|
||||
token.startsWith(AUTUMN_OAUTH_TOKEN_PREFIX);
|
||||
|
||||
export const prefixOAuthToken = ({ token }: { token: string }) =>
|
||||
isOAuthToken({ token }) ? token : `${AUTUMN_OAUTH_TOKEN_PREFIX}${token}`;
|
||||
|
||||
export const stripOAuthTokenPrefix = ({ token }: { token: string }) =>
|
||||
isOAuthToken({ token })
|
||||
? token.slice(AUTUMN_OAUTH_TOKEN_PREFIX.length)
|
||||
: token;
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./authTokenUtils.js";
|
||||
export * from "./getBearerToken.js";
|
||||
|
||||
@@ -13,6 +13,7 @@ const FORMATTED_LOG_EXCLUDE_FIELDS = new Set([
|
||||
"query",
|
||||
"durationMs",
|
||||
"duration_ms",
|
||||
"event",
|
||||
"context",
|
||||
"workflow",
|
||||
"trigger",
|
||||
|
||||
@@ -74,6 +74,10 @@ export const createAutumnClient = (auth: AutumnMcpAuth) => ({
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"x-api-version": auth.xApiVersion ?? DEFAULT_API_VERSION,
|
||||
"x-autumn-environment": auth.env,
|
||||
...(auth.authMethod === "oauth"
|
||||
? { "x-autumn-oauth-resource": auth.resource }
|
||||
: {}),
|
||||
...(auth.failOpen === undefined
|
||||
? {}
|
||||
: { "fail-open": String(auth.failOpen) }),
|
||||
|
||||
@@ -24,6 +24,9 @@ const CHAT_PORT = process.env.CHAT_PORT
|
||||
const LOCAL_CLIENT_URL = `http://localhost:${VITE_PORT}`;
|
||||
const LOCAL_SERVER_URL = `http://localhost:${SERVER_PORT}`;
|
||||
const LOCAL_CHAT_URL = `http://localhost:${CHAT_PORT}`;
|
||||
const publicTunnelUrl = process.env.NGROK_URL?.replace(/\/$/, "");
|
||||
const CHAT_URL = process.env.CHAT_URL ?? publicTunnelUrl ?? LOCAL_CHAT_URL;
|
||||
const SLACK_BOT_URL = process.env.SLACK_BOT_URL ?? publicTunnelUrl ?? CHAT_URL;
|
||||
const skipWorkers = false;
|
||||
const isProductionMode = process.argv.includes("--production");
|
||||
|
||||
@@ -36,6 +39,12 @@ const viteAppEnv = envFile.includes(".env.prod")
|
||||
const useLocalAuthUrls = viteAppEnv === "dev" && !isProductionMode;
|
||||
const localUrl = (value: string | undefined, fallback: string) =>
|
||||
value && !value.includes(".useautumn.com") ? value : fallback;
|
||||
const SLACK_REDIRECT_URI = useLocalAuthUrls
|
||||
? localUrl(
|
||||
process.env.SLACK_REDIRECT_URI,
|
||||
`${SLACK_BOT_URL}/slack/oauth/callback`,
|
||||
)
|
||||
: (process.env.SLACK_REDIRECT_URI ?? `${SLACK_BOT_URL}/slack/oauth/callback`);
|
||||
|
||||
/**
|
||||
* Read environment variable from .env file
|
||||
@@ -266,8 +275,9 @@ async function startDev() {
|
||||
MCP_RESOURCE_URLS:
|
||||
process.env.MCP_RESOURCE_URLS ?? `http://localhost:${CHAT_PORT}/mcp`,
|
||||
AUTUMN_API_URL: process.env.AUTUMN_API_URL ?? LOCAL_SERVER_URL,
|
||||
CHAT_URL: process.env.CHAT_URL ?? LOCAL_CHAT_URL,
|
||||
SLACK_BOT_URL: process.env.SLACK_BOT_URL ?? LOCAL_CHAT_URL,
|
||||
CHAT_URL,
|
||||
SLACK_BOT_URL,
|
||||
SLACK_REDIRECT_URI,
|
||||
DISCORD_BOT_URL: process.env.DISCORD_BOT_URL ?? LOCAL_CHAT_URL,
|
||||
VITE_APP_ENV: viteAppEnv,
|
||||
...(useLocalAuthUrls && {
|
||||
|
||||
@@ -11,6 +11,7 @@ const localConfig = {
|
||||
redisStackPort: 6379,
|
||||
dragonflyPort: 6380,
|
||||
databaseUrl: "postgresql://postgres:postgres@localhost:5432/autumn",
|
||||
chatStateDatabaseUrl: "postgresql://postgres:postgres@localhost:5432/chat",
|
||||
cacheUrl: "redis://localhost:6379",
|
||||
dragonflyUrl: "redis://localhost:6380",
|
||||
};
|
||||
@@ -166,6 +167,32 @@ const doctor = async () => {
|
||||
if (results.some((result) => !result)) process.exit(1);
|
||||
};
|
||||
|
||||
const psql = ({ args, quiet = false }: { args: string[]; quiet?: boolean }) =>
|
||||
dockerCompose({
|
||||
args: ["exec", "-T", "postgres", "psql", "-U", "postgres", ...args],
|
||||
quiet,
|
||||
});
|
||||
|
||||
const ensureChatDatabase = () => {
|
||||
const result = psql({
|
||||
args: [
|
||||
"-d",
|
||||
"postgres",
|
||||
"-tAc",
|
||||
"SELECT 1 FROM pg_database WHERE datname = 'chat'",
|
||||
],
|
||||
quiet: true,
|
||||
});
|
||||
const exists = new TextDecoder().decode(result.stdout).trim() === "1";
|
||||
if (exists) {
|
||||
log("chat database already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
log("creating chat database");
|
||||
psql({ args: ["-d", "postgres", "-c", "CREATE DATABASE chat"] });
|
||||
};
|
||||
|
||||
const up = async () => {
|
||||
log("starting Docker services");
|
||||
dockerCompose({ args: ["up", "-d", "--remove-orphans"] });
|
||||
@@ -176,6 +203,7 @@ const up = async () => {
|
||||
waitForTcp({ port: localConfig.dragonflyPort, label: "Dragonfly" }),
|
||||
]);
|
||||
|
||||
ensureChatDatabase();
|
||||
await doctor();
|
||||
};
|
||||
|
||||
@@ -219,6 +247,7 @@ Commands:
|
||||
|
||||
Local service values:
|
||||
DATABASE_URL=${localConfig.databaseUrl}
|
||||
CHAT_STATE_DATABASE_URL=${localConfig.chatStateDatabaseUrl}
|
||||
CACHE_URL=${localConfig.cacheUrl}
|
||||
CACHE_URL_US_EAST=${localConfig.cacheUrl}
|
||||
CACHE_V2_DRAGONFLY_URL=${localConfig.dragonflyUrl}
|
||||
|
||||
@@ -49,23 +49,23 @@ export const attachPoolErrorHandlers = ({
|
||||
};
|
||||
|
||||
const emitSnapshot = (): void => {
|
||||
const role = getRole();
|
||||
for (const { pool, name, max } of registry.values()) {
|
||||
const totalCount = pool.totalCount;
|
||||
const idleCount = pool.idleCount;
|
||||
const waitingCount = pool.waitingCount;
|
||||
logger.debug("pg_pool_stats", {
|
||||
type: "pg_pool_stats",
|
||||
pool: name,
|
||||
pid: process.pid,
|
||||
role,
|
||||
totalCount,
|
||||
idleCount,
|
||||
waitingCount,
|
||||
max,
|
||||
utilization: max > 0 ? totalCount / max : 0,
|
||||
});
|
||||
}
|
||||
// const role = getRole();
|
||||
// for (const { pool, name, max } of registry.values()) {
|
||||
// const totalCount = pool.totalCount;
|
||||
// const idleCount = pool.idleCount;
|
||||
// const waitingCount = pool.waitingCount;
|
||||
// logger.debug("pg_pool_stats", {
|
||||
// type: "pg_pool_stats",
|
||||
// pool: name,
|
||||
// pid: process.pid,
|
||||
// role,
|
||||
// totalCount,
|
||||
// idleCount,
|
||||
// waitingCount,
|
||||
// max,
|
||||
// utilization: max > 0 ? totalCount / max : 0,
|
||||
// });
|
||||
// }
|
||||
};
|
||||
|
||||
export const startPgPoolMonitor = (intervalMs = 30_000): void => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
AppEnv,
|
||||
AuthType,
|
||||
ErrCode,
|
||||
RecaseError,
|
||||
sortFeatures,
|
||||
} from "@autumn/shared";
|
||||
import type { Context, Next } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { getOAuthAccessTokenRecord } from "@/internal/auth/oauth/oauthAccessTokenApiKey.js";
|
||||
import { oauthConsentRepo } from "@/internal/auth/repos/index.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
|
||||
const getOAuthEnvironment = ({ c }: { c: Context<HonoEnv> }) => {
|
||||
const env = c.req.header("x-autumn-environment") ?? AppEnv.Sandbox;
|
||||
if (env === AppEnv.Live || env === AppEnv.Sandbox) return env;
|
||||
|
||||
throw new RecaseError({
|
||||
message: "Invalid x-autumn-environment",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
|
||||
export const handleOAuthMiddleware = async ({
|
||||
c,
|
||||
token,
|
||||
next,
|
||||
}: {
|
||||
c: Context<HonoEnv>;
|
||||
token: string;
|
||||
next: Next;
|
||||
}) => {
|
||||
const ctx = c.get("ctx");
|
||||
const env = getOAuthEnvironment({ c });
|
||||
const tokenRecord = await getOAuthAccessTokenRecord({
|
||||
db: ctx.db,
|
||||
accessToken: token,
|
||||
resource: c.req.header("x-autumn-oauth-resource") ?? null,
|
||||
requestedScopes: null,
|
||||
});
|
||||
const consent = await oauthConsentRepo.getForClientUserOrg({
|
||||
db: ctx.db,
|
||||
clientId: tokenRecord.clientId,
|
||||
userId: tokenRecord.userId,
|
||||
referenceId: tokenRecord.referenceId,
|
||||
env,
|
||||
});
|
||||
|
||||
if (!consent) {
|
||||
throw new RecaseError({
|
||||
message: "OAuth consent not found for environment",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const data = await OrgService.getWithFeatures({
|
||||
db: ctx.db,
|
||||
orgId: tokenRecord.referenceId,
|
||||
env,
|
||||
});
|
||||
if (!data) {
|
||||
throw new RecaseError({
|
||||
message: "Org not found",
|
||||
code: ErrCode.OrgNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.org = data.org;
|
||||
ctx.features = sortFeatures({ features: data.features }) ?? [];
|
||||
ctx.env = env;
|
||||
ctx.userId = tokenRecord.userId;
|
||||
ctx.authType = AuthType.SecretKey;
|
||||
ctx.scopes = tokenRecord.scopes;
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -1,8 +1,14 @@
|
||||
import { getBearerToken } from "@autumn/auth";
|
||||
import { AuthType, ErrCode, type Feature, RecaseError } from "@autumn/shared";
|
||||
import {
|
||||
getBearerToken,
|
||||
isOAuthToken,
|
||||
isPublishableKeyPrefix,
|
||||
isSecretKeyPrefix,
|
||||
} from "@autumn/auth";
|
||||
import { AuthType, ErrCode, RecaseError, sortFeatures } from "@autumn/shared";
|
||||
import type { Context, Next } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { verifyKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
|
||||
import { handleOAuthMiddleware } from "./authMiddlewares/handleOAuthMiddleware.js";
|
||||
import { betterAuthMiddleware } from "./betterAuthMiddleware.js";
|
||||
import { publicKeyMiddleware } from "./publicKeyMiddleware.js";
|
||||
|
||||
@@ -30,11 +36,11 @@ export const secretKeyMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
return betterAuthMiddleware(c, next);
|
||||
}
|
||||
|
||||
const apiKey = getBearerToken({ headers: c.req.raw.headers });
|
||||
const bearerToken = getBearerToken({ headers: c.req.raw.headers });
|
||||
|
||||
// Step 1 & 2: Check if Authorization header exists
|
||||
// If from dashboard and no Bearer token, use Better Auth session instead
|
||||
if (!apiKey) {
|
||||
if (!bearerToken) {
|
||||
throw new RecaseError({
|
||||
message: "Secret key not found in Authorization header",
|
||||
code: ErrCode.NoSecretKey,
|
||||
@@ -42,27 +48,31 @@ export const secretKeyMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!apiKey.startsWith("am_")) {
|
||||
throw new RecaseError({
|
||||
message: `Invalid secret key: ${maskApiKey(apiKey)}`,
|
||||
code: ErrCode.InvalidSecretKey,
|
||||
statusCode: 401,
|
||||
});
|
||||
if (isOAuthToken({ token: bearerToken })) {
|
||||
return handleOAuthMiddleware({ c, token: bearerToken, next });
|
||||
}
|
||||
|
||||
// Step 3: Handle publishable key verification
|
||||
if (apiKey.startsWith("am_pk")) {
|
||||
return publicKeyMiddleware(c, apiKey, next);
|
||||
if (isPublishableKeyPrefix({ token: bearerToken })) {
|
||||
return publicKeyMiddleware(c, bearerToken, next);
|
||||
}
|
||||
|
||||
if (!isSecretKeyPrefix({ token: bearerToken })) {
|
||||
throw new RecaseError({
|
||||
message: "Invalid authorization token prefix",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Verify the API key
|
||||
const { valid, data } = await verifyKey({
|
||||
db: ctx.db,
|
||||
key: apiKey,
|
||||
key: bearerToken,
|
||||
});
|
||||
|
||||
if (!valid || !data) {
|
||||
const maskedKey = maskApiKey(apiKey);
|
||||
const maskedKey = maskApiKey(bearerToken);
|
||||
throw new RecaseError({
|
||||
message: `Invalid secret key: ${maskedKey}`,
|
||||
code: ErrCode.InvalidSecretKey,
|
||||
@@ -74,13 +84,7 @@ export const secretKeyMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
const { org, features, env, userId } = data;
|
||||
const scopes = (data as { scopes?: string[] | null }).scopes ?? [];
|
||||
|
||||
if (features) {
|
||||
features.sort((a: Feature, b: Feature) => {
|
||||
if (a.archived && !b.archived) return 1;
|
||||
if (!a.archived && b.archived) return -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
sortFeatures({ features });
|
||||
|
||||
ctx.org = org;
|
||||
ctx.features = features;
|
||||
|
||||
@@ -45,6 +45,7 @@ import { handleUpsertAdminRateLimitRedisAllowlistConfig } from "./handleUpsertAd
|
||||
import { handleUpsertAdminRedisV2CacheConfig } from "./handleUpsertAdminRedisV2CacheConfig";
|
||||
import { handleUpsertAdminRequestBlockConfig } from "./handleUpsertAdminRequestBlockConfig";
|
||||
import { handleUpsertAdminStripeSyncConfig } from "./handleUpsertAdminStripeSyncConfig";
|
||||
import { handleUpsertSlackMcpOAuthClient } from "./handleUpsertSlackMcpOAuthClient";
|
||||
import { handleDeleteRollout } from "./rollouts/handleDeleteRollout";
|
||||
import { handleDeleteRolloutOrg } from "./rollouts/handleDeleteRolloutOrg";
|
||||
import { handleGetRollouts } from "./rollouts/handleGetRollouts";
|
||||
@@ -160,6 +161,10 @@ honoAdminRouter.delete("/cache-v2-ramp", ...handleDeleteAdminCacheV2Ramp);
|
||||
honoAdminRouter.get("/org-member", ...handleGetOrgMember);
|
||||
honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount);
|
||||
honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients);
|
||||
honoAdminRouter.post(
|
||||
"/oauth-clients/slack-mcp",
|
||||
...handleUpsertSlackMcpOAuthClient,
|
||||
);
|
||||
honoAdminRouter.post("/invoice-line-items", ...handleGetInvoiceLineItems);
|
||||
honoAdminRouter.post(
|
||||
"/customer-products/:customer_product_id/send-updated-webhook",
|
||||
|
||||
37
server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts
Normal file
37
server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
|
||||
import { registerMcpOAuthClient } from "@/internal/auth/actions/index.js";
|
||||
import { createRoute } from "../../honoMiddlewares/routeHandler";
|
||||
|
||||
const getClientUrl = () =>
|
||||
(process.env.CLIENT_URL || "http://localhost:3000").replace(/\/+$/, "");
|
||||
|
||||
const getSlackMcpRedirectUris = () => {
|
||||
const clientUrl = getClientUrl();
|
||||
return [
|
||||
`${clientUrl}/admin/oauth/slack-mcp/callback`,
|
||||
`${clientUrl}/sandbox/admin/oauth/slack-mcp/callback`,
|
||||
];
|
||||
};
|
||||
|
||||
export const handleUpsertSlackMcpOAuthClient = createRoute({
|
||||
scopes: [Scopes.Superuser],
|
||||
handler: async (c) => {
|
||||
const { db } = c.get("ctx");
|
||||
const result = await registerMcpOAuthClient({
|
||||
db,
|
||||
clientName: "Slack MCP",
|
||||
redirectUris: getSlackMcpRedirectUris(),
|
||||
scope: undefined,
|
||||
});
|
||||
|
||||
if ("error" in result) {
|
||||
throw new RecaseError({
|
||||
message: result.error,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: result.status,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json(result.body, result.status);
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { generateId } from "@/utils/genUtils.js";
|
||||
import { type OAuthClientRecord, oauthClientRepo } from "../repos/index.js";
|
||||
|
||||
const MCP_CLIENT_KIND = "mcp_client";
|
||||
export const SLACK_MCP_OAUTH_CLIENT_ID = "autumn_mcp_slack";
|
||||
const REGISTER_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const DANGEROUS_REDIRECT_SCHEMES = new Set([
|
||||
"javascript:",
|
||||
@@ -107,7 +108,11 @@ const classifyMcpClient = ({
|
||||
return { type: "codex", name: "Codex", clientId: "autumn_mcp_codex" };
|
||||
}
|
||||
if (haystack.includes("slack")) {
|
||||
return { type: "slack", name: "Slack", clientId: "autumn_mcp_slack" };
|
||||
return {
|
||||
type: "slack",
|
||||
name: "Slack",
|
||||
clientId: SLACK_MCP_OAUTH_CLIENT_ID,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { prefixOAuthToken } from "@autumn/auth";
|
||||
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 {
|
||||
getExternalOAuthApiKeyForToken,
|
||||
getOAuthAccessTokenRecord,
|
||||
@@ -48,6 +50,30 @@ const rewriteTokenBody = ({
|
||||
};
|
||||
};
|
||||
|
||||
const rewriteOAuthAccessTokenBody = ({
|
||||
accessToken,
|
||||
body,
|
||||
}: {
|
||||
accessToken: string;
|
||||
body: Record<string, unknown>;
|
||||
}) => {
|
||||
const response = body.response;
|
||||
if (isRecord(response)) {
|
||||
return {
|
||||
...body,
|
||||
response: {
|
||||
...response,
|
||||
access_token: accessToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...body,
|
||||
access_token: accessToken,
|
||||
};
|
||||
};
|
||||
|
||||
const tokenResponseHeaders = (response?: Response) => {
|
||||
const headers = new Headers(response?.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
@@ -116,6 +142,16 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => {
|
||||
resource,
|
||||
requestedScopes,
|
||||
});
|
||||
if (tokenRecord.clientId === SLACK_MCP_OAUTH_CLIENT_ID) {
|
||||
return jsonTokenResponse({
|
||||
body: rewriteOAuthAccessTokenBody({
|
||||
accessToken: prefixOAuthToken({ token: accessToken }),
|
||||
body,
|
||||
}),
|
||||
response,
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
apiKeyResult = await getExternalOAuthApiKeyForToken({
|
||||
db,
|
||||
tokenRecord,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stripOAuthTokenPrefix } from "@autumn/auth";
|
||||
import {
|
||||
AppEnv,
|
||||
checkScopes,
|
||||
@@ -59,12 +60,13 @@ export const getOAuthAccessTokenRecord = async ({
|
||||
resource: string | null;
|
||||
requestedScopes: ScopeString[] | null;
|
||||
}) => {
|
||||
const hashedToken = await hashOAuthToken(accessToken);
|
||||
const tokenValues = [...new Set([hashedToken, accessToken])];
|
||||
const rawAccessToken = stripOAuthTokenPrefix({ token: accessToken });
|
||||
const hashedToken = await hashOAuthToken(rawAccessToken);
|
||||
const tokenValues = [...new Set([hashedToken, rawAccessToken])];
|
||||
const tokenRecord =
|
||||
(await oauthAccessTokenRepo.getValidByTokenValues({ db, tokenValues })) ??
|
||||
(await verifyResourceAccessToken({
|
||||
accessToken,
|
||||
accessToken: rawAccessToken,
|
||||
resource,
|
||||
requestedScopes,
|
||||
}));
|
||||
|
||||
@@ -80,11 +80,13 @@ export const getOAuthConsentForClientUserOrg = async ({
|
||||
clientId,
|
||||
userId,
|
||||
referenceId,
|
||||
env,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
referenceId: string;
|
||||
env?: AppEnv;
|
||||
}) => {
|
||||
const [consent] = await db
|
||||
.select({
|
||||
@@ -99,6 +101,7 @@ export const getOAuthConsentForClientUserOrg = async ({
|
||||
eq(oauthConsent.clientId, clientId),
|
||||
eq(oauthConsent.userId, userId),
|
||||
eq(oauthConsent.referenceId, referenceId),
|
||||
...(env ? [eq(oauthConsent.env, env)] : []),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { actions } from "../models/analyticsModels/actionTable.js";
|
||||
import {
|
||||
chatApprovals,
|
||||
chatInstallations,
|
||||
chatOAuthCredentials,
|
||||
} from "../models/chatModels/chatTable.js";
|
||||
import { chatResults } from "../models/chatResultModels/chatResultTable.js";
|
||||
import { checkoutsRelations } from "../models/checkouts/checkoutRelations.js";
|
||||
@@ -107,6 +108,7 @@ export {
|
||||
autoTopupLimitStates as autoTopupLimits,
|
||||
chatApprovals,
|
||||
chatInstallations,
|
||||
chatOAuthCredentials,
|
||||
chatResults,
|
||||
checkouts,
|
||||
checkoutsRelations,
|
||||
|
||||
@@ -16,10 +16,10 @@ CREATE TABLE "passkey" (
|
||||
ALTER TABLE "passkey" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE "migration_runs" ADD COLUMN "target_limit" numeric;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_customer_products_revenuecat_processor" ON "customer_products" USING btree ("internal_customer_id") WHERE ("customer_products"."processor" ->> 'type') = 'revenuecat';--> statement-breakpoint
|
||||
CREATE INDEX "idx_customers_cursor" ON "customers" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint
|
||||
CREATE INDEX "idx_entities_cursor" ON "entities" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint
|
||||
CREATE INDEX "idx_entitlements_internal_reward_id_c_partial" ON "entitlements" USING btree ("internal_reward_id" COLLATE "C") WHERE "entitlements"."internal_reward_id" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "vercel_resources_installation_name_unique_idx" ON "vercel_resources" USING btree ("org_id","env","installation_id","name") WHERE status <> 'uninstalled';
|
||||
CREATE INDEX CONCURRENTLY "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX CONCURRENTLY "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");--> statement-breakpoint
|
||||
CREATE INDEX CONCURRENTLY "idx_customer_products_revenuecat_processor" ON "customer_products" USING btree ("internal_customer_id") WHERE ("customer_products"."processor" ->> 'type') = 'revenuecat';--> statement-breakpoint
|
||||
CREATE INDEX CONCURRENTLY "idx_customers_cursor" ON "customers" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint
|
||||
CREATE INDEX CONCURRENTLY "idx_entities_cursor" ON "entities" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint
|
||||
CREATE INDEX CONCURRENTLY "idx_entitlements_internal_reward_id_c_partial" ON "entitlements" USING btree ("internal_reward_id" COLLATE "C") WHERE "entitlements"."internal_reward_id" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX CONCURRENTLY "vercel_resources_installation_name_unique_idx" ON "vercel_resources" USING btree ("org_id","env","installation_id","name") WHERE status <> 'uninstalled';
|
||||
@@ -1 +1 @@
|
||||
CREATE INDEX "idx_invoice_line_items_customer_product_ids" ON "invoice_line_items" USING gin ("customer_product_ids");
|
||||
CREATE INDEX CONCURRENTLY "idx_invoice_line_items_customer_product_ids" ON "invoice_line_items" USING gin ("customer_product_ids");
|
||||
18
shared/drizzle/0008_premium_pet_avengers.sql
Normal file
18
shared/drizzle/0008_premium_pet_avengers.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "chat_oauth_credentials" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"chat_installation_id" text NOT NULL,
|
||||
"org_id" text NOT NULL,
|
||||
"env" text NOT NULL,
|
||||
"oauth_client_id" text NOT NULL,
|
||||
"oauth_consent_id" text,
|
||||
"access_token" text NOT NULL,
|
||||
"refresh_token" text NOT NULL,
|
||||
"access_token_expires_at" numeric NOT NULL,
|
||||
"scopes" jsonb NOT NULL,
|
||||
"created_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL,
|
||||
"updated_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL,
|
||||
CONSTRAINT "chat_oauth_credentials_installation_env_key" UNIQUE("chat_installation_id","env")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "chat_oauth_credentials" ADD CONSTRAINT "chat_oauth_credentials_installation_id_fkey" FOREIGN KEY ("chat_installation_id") REFERENCES "public"."chat_installations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_oauth_credentials" ADD CONSTRAINT "chat_oauth_credentials_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "b9eaa778-a288-48ca-8e0f-42b17e0dc873",
|
||||
"id": "40c5361a-8cff-473f-93c1-4dfbc06b00d7",
|
||||
"prevId": "9e1bb4b2-1869-4ca9-ba67-8fbcea263c37",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -840,6 +840,129 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.chat_oauth_credentials": {
|
||||
"name": "chat_oauth_credentials",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"chat_installation_id": {
|
||||
"name": "chat_installation_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"org_id": {
|
||||
"name": "org_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"env": {
|
||||
"name": "env",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oauth_client_id": {
|
||||
"name": "oauth_client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oauth_consent_id": {
|
||||
"name": "oauth_consent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "numeric",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scopes": {
|
||||
"name": "scopes",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "numeric",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "numeric",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"chat_oauth_credentials_installation_id_fkey": {
|
||||
"name": "chat_oauth_credentials_installation_id_fkey",
|
||||
"tableFrom": "chat_oauth_credentials",
|
||||
"tableTo": "chat_installations",
|
||||
"columnsFrom": [
|
||||
"chat_installation_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"chat_oauth_credentials_org_id_fkey": {
|
||||
"name": "chat_oauth_credentials_org_id_fkey",
|
||||
"tableFrom": "chat_oauth_credentials",
|
||||
"tableTo": "organizations",
|
||||
"columnsFrom": [
|
||||
"org_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"chat_oauth_credentials_installation_env_key": {
|
||||
"name": "chat_oauth_credentials_installation_env_key",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"chat_installation_id",
|
||||
"env"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.chat_results": {
|
||||
"name": "chat_results",
|
||||
"schema": "",
|
||||
@@ -4524,13 +4647,6 @@
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"archived": {
|
||||
"name": "archived",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "numeric",
|
||||
@@ -7397,4 +7513,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -61,6 +61,13 @@
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1780679328640,
|
||||
"tag": "0008_premium_pet_avengers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1780665982843,
|
||||
"tag": "0008_slippery_william_stryker",
|
||||
"breakpoints": true
|
||||
|
||||
@@ -81,5 +81,42 @@ export const chatApprovals = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const chatOAuthCredentials = pgTable(
|
||||
"chat_oauth_credentials",
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
chat_installation_id: text("chat_installation_id").notNull(),
|
||||
org_id: text("org_id").notNull(),
|
||||
env: text("env").$type<AppEnv>().notNull(),
|
||||
oauth_client_id: text("oauth_client_id").notNull(),
|
||||
oauth_consent_id: text("oauth_consent_id"),
|
||||
access_token: text("access_token").notNull(),
|
||||
refresh_token: text("refresh_token").notNull(),
|
||||
access_token_expires_at: numeric("access_token_expires_at", {
|
||||
mode: "number",
|
||||
}).notNull(),
|
||||
scopes: jsonb().$type<string[]>().notNull(),
|
||||
created_at: numeric({ mode: "number" }).notNull().default(sqlNow),
|
||||
updated_at: numeric({ mode: "number" }).notNull().default(sqlNow),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.chat_installation_id],
|
||||
foreignColumns: [chatInstallations.id],
|
||||
name: "chat_oauth_credentials_installation_id_fkey",
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [table.org_id],
|
||||
foreignColumns: [organizations.id],
|
||||
name: "chat_oauth_credentials_org_id_fkey",
|
||||
}).onDelete("cascade"),
|
||||
unique("chat_oauth_credentials_installation_env_key").on(
|
||||
table.chat_installation_id,
|
||||
table.env,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export type ChatInstallation = typeof chatInstallations.$inferSelect;
|
||||
export type ChatApproval = typeof chatApprovals.$inferSelect;
|
||||
export type ChatOAuthCredential = typeof chatOAuthCredentials.$inferSelect;
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from "./apiFeatureToDbFeature";
|
||||
export * from "./convertFeatureUtils";
|
||||
export * from "./creditSystemUtils";
|
||||
export * from "./findFeatureUtils";
|
||||
export * from "./sortFeatures";
|
||||
|
||||
export const featureUtils = {
|
||||
isConsumable: isConsumableFeature,
|
||||
|
||||
13
shared/utils/featureUtils/sortFeatures.ts
Normal file
13
shared/utils/featureUtils/sortFeatures.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Feature } from "../../models/featureModels/featureModels.js";
|
||||
|
||||
export const sortFeatures = ({ features }: { features?: Feature[] }) => {
|
||||
if (!features) return features;
|
||||
|
||||
features.sort((a, b) => {
|
||||
if (a.archived && !b.archived) return 1;
|
||||
if (!a.archived && b.archived) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return features;
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Globe,
|
||||
Key,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -62,6 +63,25 @@ export const OAuthClientsView = () => {
|
||||
});
|
||||
|
||||
const clients: OAuthClient[] = data?.clients || [];
|
||||
const upsertSlackMcpMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await axiosInstance.post(
|
||||
"/admin/oauth-clients/slack-mcp",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (client) => {
|
||||
toast.success(
|
||||
`Slack MCP OAuth client ready: ${client.client_id ?? "autumn_mcp_slack"}`,
|
||||
);
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
getBackendErr(error, "Failed to create Slack MCP OAuth client"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteClient = async (client_id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this OAuth client?")) {
|
||||
@@ -157,6 +177,15 @@ export const OAuthClientsView = () => {
|
||||
>
|
||||
Refresh
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<MessageSquare className="w-4 h-4" />}
|
||||
onClick={() => upsertSlackMcpMutation.mutate()}
|
||||
disabled={upsertSlackMcpMutation.isPending}
|
||||
>
|
||||
Add Slack MCP
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user