fix: slack bot authenticates via oauth

This commit is contained in:
johnyeo
2026-06-05 18:52:26 +01:00
committed by Charlie Lamb
parent 4e8c26da0a
commit c59153439b
38 changed files with 8645 additions and 190 deletions

View File

@@ -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`.

View File

@@ -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),

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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;
};

View File

@@ -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,
});
};

View File

@@ -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,
},
});
};

View File

@@ -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;
};

View File

@@ -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) ?? [];

View File

@@ -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,

View File

@@ -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,
});
});
};

View 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;

View File

@@ -1 +1,2 @@
export * from "./authTokenUtils.js";
export * from "./getBearerToken.js";

View File

@@ -13,6 +13,7 @@ const FORMATTED_LOG_EXCLUDE_FIELDS = new Set([
"query",
"durationMs",
"duration_ms",
"event",
"context",
"workflow",
"trigger",

View File

@@ -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) }),

View File

@@ -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 && {

View File

@@ -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}

View File

@@ -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 => {

View File

@@ -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();
};

View File

@@ -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;

View File

@@ -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",

View 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);
},
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,
}));

View File

@@ -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);

View File

@@ -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,

View File

@@ -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';

View File

@@ -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");

View 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;

View File

@@ -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": {}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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;

View File

@@ -7,6 +7,7 @@ export * from "./apiFeatureToDbFeature";
export * from "./convertFeatureUtils";
export * from "./creditSystemUtils";
export * from "./findFeatureUtils";
export * from "./sortFeatures";
export const featureUtils = {
isConsumable: isConsumableFeature,

View 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;
};

View File

@@ -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"