Add secret sync vercel integration

This commit is contained in:
carlosmonastyrski
2025-03-31 18:10:29 -03:00
parent 5e1a7cfb6e
commit dbabb4f964
55 changed files with 1510 additions and 29 deletions
@@ -5,7 +5,8 @@ export enum AppConnection {
GCP = "gcp",
AzureKeyVault = "azure-key-vault",
AzureAppConfiguration = "azure-app-configuration",
Humanitec = "humanitec"
Humanitec = "humanitec",
Vercel = "vercel"
}
export enum AWSRegion {
@@ -40,6 +40,8 @@ import {
HumanitecConnectionMethod,
validateHumanitecConnectionCredentials
} from "./humanitec";
import { VercelConnectionMethod } from "./vercel";
import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns";
export const listAppConnectionOptions = () => {
return [
@@ -49,7 +51,8 @@ export const listAppConnectionOptions = () => {
getAzureKeyVaultConnectionListItem(),
getAzureAppConfigurationConnectionListItem(),
getDatabricksConnectionListItem(),
getHumanitecConnectionListItem()
getHumanitecConnectionListItem(),
getVercelConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
};
@@ -114,6 +117,8 @@ export const validateAppConnectionCredentials = async (
return validateAzureAppConfigurationConnectionCredentials(appConnection);
case AppConnection.Humanitec:
return validateHumanitecConnectionCredentials(appConnection);
case AppConnection.Vercel:
return validateVercelConnectionCredentials(appConnection);
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled App Connection ${app}`);
@@ -138,6 +143,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
return "Service Principal";
case HumanitecConnectionMethod.API_TOKEN:
return "API Token";
case VercelConnectionMethod.API_TOKEN:
return "API Token";
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled App Connection Method: ${method}`);
@@ -7,5 +7,6 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.AzureKeyVault]: "Azure Key Vault",
[AppConnection.AzureAppConfiguration]: "Azure App Configuration",
[AppConnection.Databricks]: "Databricks",
[AppConnection.Humanitec]: "Humanitec"
[AppConnection.Humanitec]: "Humanitec",
[AppConnection.Vercel]: "Vercel"
};
@@ -37,6 +37,8 @@ import { ValidateGitHubConnectionCredentialsSchema } from "./github";
import { githubConnectionService } from "./github/github-connection-service";
import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec";
import { humanitecConnectionService } from "./humanitec/humanitec-connection-service";
import { ValidateVercelConnectionCredentialsSchema } from "./vercel";
import { vercelConnectionService } from "./vercel/vercel-connection-service";
export type TAppConnectionServiceFactoryDep = {
appConnectionDAL: TAppConnectionDALFactory;
@@ -53,7 +55,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.AzureKeyVault]: ValidateAzureKeyVaultConnectionCredentialsSchema,
[AppConnection.AzureAppConfiguration]: ValidateAzureAppConfigurationConnectionCredentialsSchema,
[AppConnection.Databricks]: ValidateDatabricksConnectionCredentialsSchema,
[AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema
[AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema,
[AppConnection.Vercel]: ValidateVercelConnectionCredentialsSchema
};
export const appConnectionServiceFactory = ({
@@ -375,6 +378,7 @@ export const appConnectionServiceFactory = ({
gcp: gcpConnectionService(connectAppConnectionById),
databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
aws: awsConnectionService(connectAppConnectionById),
humanitec: humanitecConnectionService(connectAppConnectionById)
humanitec: humanitecConnectionService(connectAppConnectionById),
vercel: vercelConnectionService(connectAppConnectionById)
};
};
@@ -38,6 +38,12 @@ import {
THumanitecConnectionInput,
TValidateHumanitecConnectionCredentials
} from "./humanitec";
import {
TValidateVercelConnectionCredentials,
TVercelConnection,
TVercelConnectionConfig,
TVercelConnectionInput
} from "./vercel";
export type TAppConnection = { id: string } & (
| TAwsConnection
@@ -47,6 +53,7 @@ export type TAppConnection = { id: string } & (
| TAzureAppConfigurationConnection
| TDatabricksConnection
| THumanitecConnection
| TVercelConnection
);
export type TAppConnectionInput = { id: string } & (
@@ -57,6 +64,7 @@ export type TAppConnectionInput = { id: string } & (
| TAzureAppConfigurationConnectionInput
| TDatabricksConnectionInput
| THumanitecConnectionInput
| TVercelConnectionInput
);
export type TCreateAppConnectionDTO = Pick<
@@ -75,7 +83,8 @@ export type TAppConnectionConfig =
| TAzureKeyVaultConnectionConfig
| TAzureAppConfigurationConnectionConfig
| TDatabricksConnectionConfig
| THumanitecConnectionConfig;
| THumanitecConnectionConfig
| TVercelConnectionConfig;
export type TValidateAppConnectionCredentials =
| TValidateAwsConnectionCredentials
@@ -84,7 +93,8 @@ export type TValidateAppConnectionCredentials =
| TValidateAzureKeyVaultConnectionCredentials
| TValidateAzureAppConfigurationConnectionCredentials
| TValidateDatabricksConnectionCredentials
| TValidateHumanitecConnectionCredentials;
| TValidateHumanitecConnectionCredentials
| TValidateVercelConnectionCredentials;
export type TListAwsConnectionKmsKeys = {
connectionId: string;
@@ -0,0 +1,4 @@
export * from "./vercel-connection-enums";
export * from "./vercel-connection-fns";
export * from "./vercel-connection-schemas";
export * from "./vercel-connection-types";
@@ -0,0 +1,3 @@
export enum VercelConnectionMethod {
API_TOKEN = "api-token"
}
@@ -0,0 +1,261 @@
/* eslint-disable no-await-in-loop */
import { AxiosError, AxiosResponse } from "axios";
import { request } from "@app/lib/config/request";
import { BadRequestError, InternalServerError } from "@app/lib/errors";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { TVercelBranches } from "@app/services/integration-auth/integration-auth-types";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { VercelConnectionMethod } from "./vercel-connection-enums";
import {
TVercelConnectionConfig,
TVercelConnectionInput,
VercelApp,
VercelEnvironment,
VercelOrgWithApps
} from "./vercel-connection-types";
export const getVercelConnectionListItem = () => {
return {
name: "Vercel" as const,
app: AppConnection.Vercel as const,
methods: Object.values(VercelConnectionMethod) as [VercelConnectionMethod.API_TOKEN]
};
};
export const validateVercelConnectionCredentials = async (config: TVercelConnectionConfig) => {
const { credentials: inputCredentials } = config;
let response: AxiosResponse<VercelApp[]> | null = null;
try {
response = await request.get<VercelApp[]>(`${IntegrationUrls.VERCEL_API_URL}/v9/projects`, {
headers: {
Authorization: `Bearer ${inputCredentials.apiToken}`
}
});
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to validate connection - verify credentials"
});
}
if (!response?.data) {
throw new InternalServerError({
message: "Failed to get organizations: Response was empty"
});
}
return inputCredentials;
};
interface ApiResponse<T> {
pagination?: {
count: number;
next: number;
};
data: T[];
[key: string]: unknown;
}
async function fetchAllPages<T>(
apiUrl: string,
apiToken: string,
initialParams: Record<string, string | number> = {},
dataPath?: string
): Promise<T[]> {
const allItems: T[] = [];
let hasMoreItems = true;
let params: Record<string, string | number> = { ...initialParams, limit: 100 };
while (hasMoreItems) {
try {
const response = await request.get<ApiResponse<T>>(apiUrl, {
params,
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
});
if (!response?.data) {
throw new InternalServerError({
message: `Failed to fetch data from ${apiUrl}: Response was empty or malformed`
});
}
let itemsData: T[];
if (dataPath && dataPath in response.data) {
itemsData = response.data[dataPath] as T[];
} else {
itemsData = response.data.data;
}
if (!Array.isArray(itemsData)) {
throw new InternalServerError({
message: `Failed to fetch data from ${apiUrl}: Expected array but got ${typeof itemsData}`
});
}
allItems.push(...itemsData);
if (response.data.pagination?.next) {
params = { ...params, since: response.data.pagination.next };
} else {
hasMoreItems = false;
}
} catch (error) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to fetch data from ${apiUrl}: ${error.message || "Unknown error"}`
});
}
throw error;
}
}
return allItems;
}
async function fetchOrgProjects(orgId: string, apiToken: string): Promise<VercelApp[]> {
return fetchAllPages<VercelApp>(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects`,
apiToken,
{ teamId: orgId },
"projects"
);
}
async function fetchProjectEnvironments(projectId: string, apiToken: string): Promise<VercelEnvironment[]> {
return fetchAllPages<VercelEnvironment>(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${projectId}/custom-environments`,
apiToken,
{},
"environments"
);
}
async function fetchPreviewBranches(projectId: string, apiToken: string): Promise<string[]> {
const { data } = await request.get<TVercelBranches[]>(
`${IntegrationUrls.VERCEL_API_URL}/v1/integrations/git-branches`,
{
params: {
projectId
},
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
return data.map((b) => b.ref);
}
type VercelTeam = {
id: string;
name: string;
slug: string;
};
type VercelUserResponse = {
user: {
id: string;
name: string;
username: string;
};
};
export const listProjects = async (appConnection: TVercelConnectionInput): Promise<VercelOrgWithApps[]> => {
const { credentials } = appConnection;
const { apiToken } = credentials;
const orgs = await fetchAllPages<VercelTeam>(`${IntegrationUrls.VERCEL_API_URL}/v2/teams`, apiToken, {}, "teams");
const personalAccountResponse = await request.get<VercelUserResponse>(`${IntegrationUrls.VERCEL_API_URL}/v2/user`, {
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
});
if (personalAccountResponse?.data?.user) {
const { user } = personalAccountResponse.data;
orgs.push({
id: user.id,
name: user.name || "Personal Account",
slug: user.username || "personal"
});
}
const orgsWithApps: VercelOrgWithApps[] = [];
const orgPromises = orgs.map(async (org) => {
try {
const projects = await fetchOrgProjects(org.id, apiToken);
const enhancedProjectsPromises = projects.map(async (project) => {
try {
const [environments, previewBranches] = await Promise.all([
fetchProjectEnvironments(project.id, apiToken),
fetchPreviewBranches(project.id, apiToken)
]);
return {
name: project.name,
id: project.id,
envs: environments,
previewBranches
};
} catch (error) {
return {
name: project.name,
id: project.id,
envs: [],
previewBranches: []
};
}
});
const enhancedProjects = await Promise.all(enhancedProjectsPromises);
return {
...org,
apps: enhancedProjects
};
} catch (error) {
return null;
}
});
const results = await Promise.all(orgPromises);
results.forEach((result) => {
if (result !== null) {
orgsWithApps.push(result);
}
});
return orgsWithApps;
};
export const getProjectEnvironmentVariables = (project: VercelApp): Record<string, string> => {
const envVars: Record<string, string> = {};
if (!project.envs) return envVars;
project.envs.forEach((env) => {
if (env.value && env.type !== "gitBranch") {
const { key, value } = env;
envVars[key] = value;
}
});
return envVars;
};
@@ -0,0 +1,56 @@
import z from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { VercelConnectionMethod } from "./vercel-connection-enums";
export const VercelConnectionAccessTokenCredentialsSchema = z.object({
apiToken: z.string().trim().min(1, "API Token required")
});
const BaseVercelConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Vercel) });
export const VercelConnectionSchema = BaseVercelConnectionSchema.extend({
method: z.literal(VercelConnectionMethod.API_TOKEN),
credentials: VercelConnectionAccessTokenCredentialsSchema
});
export const SanitizedVercelConnectionSchema = z.discriminatedUnion("method", [
BaseVercelConnectionSchema.extend({
method: z.literal(VercelConnectionMethod.API_TOKEN),
credentials: VercelConnectionAccessTokenCredentialsSchema.pick({})
})
]);
export const ValidateVercelConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal(VercelConnectionMethod.API_TOKEN).describe(AppConnections?.CREATE(AppConnection.Vercel).method),
credentials: VercelConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Vercel).credentials
)
})
]);
export const CreateVercelConnectionSchema = ValidateVercelConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Vercel)
);
export const UpdateVercelConnectionSchema = z
.object({
credentials: VercelConnectionAccessTokenCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.Vercel).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Vercel));
export const VercelConnectionListItemSchema = z.object({
name: z.literal("Vercel"),
app: z.literal(AppConnection.Vercel),
methods: z.nativeEnum(VercelConnectionMethod).array()
});
@@ -0,0 +1,29 @@
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { listProjects as getVercelProjects } from "./vercel-connection-fns";
import { TVercelConnection } from "./vercel-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TVercelConnection>;
export const vercelConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listProjects = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Vercel, connectionId, actor);
try {
const projects = await getVercelProjects(appConnection);
return projects;
} catch (error) {
logger.error(error, "Failed to establish connection with Vercel");
return [];
}
};
return {
listProjects
};
};
@@ -0,0 +1,73 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
CreateVercelConnectionSchema,
ValidateVercelConnectionCredentialsSchema,
VercelConnectionSchema
} from "./vercel-connection-schemas";
export type TVercelConnection = z.infer<typeof VercelConnectionSchema>;
export type TVercelConnectionInput = z.infer<typeof CreateVercelConnectionSchema> & {
app: AppConnection.Vercel;
};
export type TValidateVercelConnectionCredentials = typeof ValidateVercelConnectionCredentialsSchema;
export type TVercelConnectionConfig = DiscriminativePick<TVercelConnectionInput, "method" | "app" | "credentials"> & {
orgId: string;
};
export type VercelTeam = {
id: string;
name: string;
slug: string;
};
export type VercelEnvironment = {
key: string;
value: string;
type: string;
target?: string[];
gitBranch?: string;
createdAt?: number;
updatedAt?: number;
};
export type VercelAppMeta = {
githubCommitRef?: string;
githubCommitSha?: string;
githubCommitMessage?: string;
githubCommitAuthorName?: string;
};
export type VercelDeployment = {
id: string;
name: string;
url: string;
created: number;
meta?: VercelAppMeta;
target?: "production" | "preview" | "development";
};
export type VercelApp = {
name: string;
id: string;
envs?: VercelEnvironment[];
previewBranches?: string[];
};
export type VercelOrgWithApps = VercelTeam & {
apps: VercelApp[];
};
export type VercelUserResponse = {
user: {
id: string;
name: string;
username: string;
};
};