Merge pull request #3338 from Infisical/feat/vercelSecretSyncIntegration

Add secret sync vercel integration
This commit is contained in:
carlosmonastyrski
2025-04-11 07:52:02 -03:00
committed by GitHub
89 changed files with 1965 additions and 21 deletions
+10
View File
@@ -1705,6 +1705,9 @@ export const AppConnections = {
sslEnabled: "Whether or not to use SSL when connecting to the database.",
sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates.",
sslCertificate: "The SSL certificate to use for connection."
},
VERCEL: {
apiToken: "The API token used to authenticate with Vercel."
}
}
};
@@ -1820,6 +1823,13 @@ export const SecretSyncs = {
org: "The ID of the Humanitec org to sync secrets to.",
env: "The ID of the Humanitec environment to sync secrets to.",
scope: "The Humanitec scope that secrets should be synced to."
},
VERCEL: {
app: "The ID of the Vercel app to sync secrets to.",
appName: "The name of the Vercel app to sync secrets to.",
env: "The ID of the Vercel environment to sync secrets to.",
branch: "The branch to sync preview secrets to.",
teamId: "The ID of the Vercel team to sync secrets to."
}
}
};
@@ -27,6 +27,7 @@ import {
PostgresConnectionListItemSchema,
SanitizedPostgresConnectionSchema
} from "@app/services/app-connection/postgres";
import { SanitizedVercelConnectionSchema, VercelConnectionListItemSchema } from "@app/services/app-connection/vercel";
import { AuthMode } from "@app/services/auth/auth-type";
// can't use discriminated due to multiple schemas for certain apps
@@ -38,6 +39,7 @@ const SanitizedAppConnectionSchema = z.union([
...SanitizedAzureAppConfigurationConnectionSchema.options,
...SanitizedDatabricksConnectionSchema.options,
...SanitizedHumanitecConnectionSchema.options,
...SanitizedVercelConnectionSchema.options,
...SanitizedPostgresConnectionSchema.options,
...SanitizedMsSqlConnectionSchema.options
]);
@@ -50,6 +52,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
AzureAppConfigurationConnectionListItemSchema,
DatabricksConnectionListItemSchema,
HumanitecConnectionListItemSchema,
VercelConnectionListItemSchema,
PostgresConnectionListItemSchema,
MsSqlConnectionListItemSchema
]);
@@ -9,6 +9,7 @@ import { registerGitHubConnectionRouter } from "./github-connection-router";
import { registerHumanitecConnectionRouter } from "./humanitec-connection-router";
import { registerMsSqlConnectionRouter } from "./mssql-connection-router";
import { registerPostgresConnectionRouter } from "./postgres-connection-router";
import { registerVercelConnectionRouter } from "./vercel-connection-router";
export * from "./app-connection-router";
@@ -21,6 +22,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server:
[AppConnection.AzureAppConfiguration]: registerAzureAppConfigurationConnectionRouter,
[AppConnection.Databricks]: registerDatabricksConnectionRouter,
[AppConnection.Humanitec]: registerHumanitecConnectionRouter,
[AppConnection.Vercel]: registerVercelConnectionRouter,
[AppConnection.Postgres]: registerPostgresConnectionRouter,
[AppConnection.MsSql]: registerMsSqlConnectionRouter
};
@@ -0,0 +1,77 @@
import z from "zod";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
CreateVercelConnectionSchema,
SanitizedVercelConnectionSchema,
UpdateVercelConnectionSchema,
VercelOrgWithApps
} from "@app/services/app-connection/vercel";
import { AuthMode } from "@app/services/auth/auth-type";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerVercelConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.Vercel,
server,
sanitizedResponseSchema: SanitizedVercelConnectionSchema,
createSchema: CreateVercelConnectionSchema,
updateSchema: UpdateVercelConnectionSchema
});
// The below endpoints are not exposed and for Infisical App use
server.route({
method: "GET",
url: `/:connectionId/projects`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
response: {
200: z
.object({
id: z.string(),
name: z.string(),
slug: z.string(),
apps: z
.object({
id: z.string(),
name: z.string(),
envs: z
.object({
id: z.string(),
slug: z.string(),
type: z.string(),
target: z.array(z.string()).optional(),
description: z.string().optional(),
createdAt: z.number().optional(),
updatedAt: z.number().optional()
})
.array()
.optional(),
previewBranches: z.array(z.string()).optional()
})
.array()
})
.array()
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { connectionId } = req.params;
const projects: VercelOrgWithApps[] = await server.services.appConnection.vercel.listProjects(
connectionId,
req.permission
);
return projects;
}
});
};
@@ -8,6 +8,7 @@ import { registerDatabricksSyncRouter } from "./databricks-sync-router";
import { registerGcpSyncRouter } from "./gcp-sync-router";
import { registerGitHubSyncRouter } from "./github-sync-router";
import { registerHumanitecSyncRouter } from "./humanitec-sync-router";
import { registerVercelSyncRouter } from "./vercel-sync-router";
export * from "./secret-sync-router";
@@ -19,5 +20,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record<SecretSync, (server: Fastif
[SecretSync.AzureKeyVault]: registerAzureKeyVaultSyncRouter,
[SecretSync.AzureAppConfiguration]: registerAzureAppConfigurationSyncRouter,
[SecretSync.Databricks]: registerDatabricksSyncRouter,
[SecretSync.Humanitec]: registerHumanitecSyncRouter
[SecretSync.Humanitec]: registerHumanitecSyncRouter,
[SecretSync.Vercel]: registerVercelSyncRouter
};
@@ -22,6 +22,7 @@ import { DatabricksSyncListItemSchema, DatabricksSyncSchema } from "@app/service
import { GcpSyncListItemSchema, GcpSyncSchema } from "@app/services/secret-sync/gcp";
import { GitHubSyncListItemSchema, GitHubSyncSchema } from "@app/services/secret-sync/github";
import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec";
import { VercelSyncListItemSchema, VercelSyncSchema } from "@app/services/secret-sync/vercel";
const SecretSyncSchema = z.discriminatedUnion("destination", [
AwsParameterStoreSyncSchema,
@@ -31,7 +32,8 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [
AzureKeyVaultSyncSchema,
AzureAppConfigurationSyncSchema,
DatabricksSyncSchema,
HumanitecSyncSchema
HumanitecSyncSchema,
VercelSyncSchema
]);
const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
@@ -42,7 +44,8 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
AzureKeyVaultSyncListItemSchema,
AzureAppConfigurationSyncListItemSchema,
DatabricksSyncListItemSchema,
HumanitecSyncListItemSchema
HumanitecSyncListItemSchema,
VercelSyncListItemSchema
]);
export const registerSecretSyncRouter = async (server: FastifyZodProvider) => {
@@ -0,0 +1,13 @@
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { CreateVercelSyncSchema, UpdateVercelSyncSchema, VercelSyncSchema } from "@app/services/secret-sync/vercel";
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
export const registerVercelSyncRouter = async (server: FastifyZodProvider) =>
registerSyncSecretsEndpoints({
destination: SecretSync.Vercel,
server,
responseSchema: VercelSyncSchema,
createSchema: CreateVercelSyncSchema,
updateSchema: UpdateVercelSyncSchema
});
@@ -6,6 +6,7 @@ export enum AppConnection {
AzureKeyVault = "azure-key-vault",
AzureAppConfiguration = "azure-app-configuration",
Humanitec = "humanitec",
Vercel = "vercel",
Postgres = "postgres",
MsSql = "mssql"
}
@@ -41,6 +41,8 @@ import {
} from "./humanitec";
import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql";
import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres";
import { VercelConnectionMethod } from "./vercel";
import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns";
export const listAppConnectionOptions = () => {
return [
@@ -51,6 +53,7 @@ export const listAppConnectionOptions = () => {
getAzureAppConfigurationConnectionListItem(),
getDatabricksConnectionListItem(),
getHumanitecConnectionListItem(),
getVercelConnectionListItem(),
getPostgresConnectionListItem(),
getMsSqlConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
@@ -108,7 +111,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TAppConnect
validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator
[AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Vercel]: validateVercelConnectionCredentials as TAppConnectionCredentialsValidator
};
export const validateAppConnectionCredentials = async (
@@ -132,6 +136,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case DatabricksConnectionMethod.ServicePrincipal:
return "Service Principal";
case HumanitecConnectionMethod.ApiToken:
case VercelConnectionMethod.ApiToken:
return "API Token";
case PostgresConnectionMethod.UsernameAndPassword:
case MsSqlConnectionMethod.UsernameAndPassword:
@@ -175,5 +180,6 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported,
[AppConnection.Humanitec]: platformManagedCredentialsNotSupported,
[AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform,
[AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform
[AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform,
[AppConnection.Vercel]: platformManagedCredentialsNotSupported
};
@@ -8,6 +8,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.AzureAppConfiguration]: "Azure App Configuration",
[AppConnection.Databricks]: "Databricks",
[AppConnection.Humanitec]: "Humanitec",
[AppConnection.Vercel]: "Vercel",
[AppConnection.Postgres]: "PostgreSQL",
[AppConnection.MsSql]: "Microsoft SQL Server"
};
@@ -41,6 +41,8 @@ import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec";
import { humanitecConnectionService } from "./humanitec/humanitec-connection-service";
import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql";
import { ValidatePostgresConnectionCredentialsSchema } from "./postgres";
import { ValidateVercelConnectionCredentialsSchema } from "./vercel";
import { vercelConnectionService } from "./vercel/vercel-connection-service";
export type TAppConnectionServiceFactoryDep = {
appConnectionDAL: TAppConnectionDALFactory;
@@ -58,6 +60,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.AzureAppConfiguration]: ValidateAzureAppConfigurationConnectionCredentialsSchema,
[AppConnection.Databricks]: ValidateDatabricksConnectionCredentialsSchema,
[AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema,
[AppConnection.Vercel]: ValidateVercelConnectionCredentialsSchema,
[AppConnection.Postgres]: ValidatePostgresConnectionCredentialsSchema,
[AppConnection.MsSql]: ValidateMsSqlConnectionCredentialsSchema
};
@@ -430,6 +433,7 @@ export const appConnectionServiceFactory = ({
gcp: gcpConnectionService(connectAppConnectionById),
databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
aws: awsConnectionService(connectAppConnectionById),
humanitec: humanitecConnectionService(connectAppConnectionById)
humanitec: humanitecConnectionService(connectAppConnectionById),
vercel: vercelConnectionService(connectAppConnectionById)
};
};
@@ -51,6 +51,12 @@ import {
TPostgresConnectionInput,
TValidatePostgresConnectionCredentialsSchema
} from "./postgres";
import {
TValidateVercelConnectionCredentialsSchema,
TVercelConnection,
TVercelConnectionConfig,
TVercelConnectionInput
} from "./vercel";
export type TAppConnection = { id: string } & (
| TAwsConnection
@@ -60,6 +66,7 @@ export type TAppConnection = { id: string } & (
| TAzureAppConfigurationConnection
| TDatabricksConnection
| THumanitecConnection
| TVercelConnection
| TPostgresConnection
| TMsSqlConnection
);
@@ -76,6 +83,7 @@ export type TAppConnectionInput = { id: string } & (
| TAzureAppConfigurationConnectionInput
| TDatabricksConnectionInput
| THumanitecConnectionInput
| TVercelConnectionInput
| TPostgresConnectionInput
| TMsSqlConnectionInput
);
@@ -99,6 +107,7 @@ export type TAppConnectionConfig =
| TAzureAppConfigurationConnectionConfig
| TDatabricksConnectionConfig
| THumanitecConnectionConfig
| TVercelConnectionConfig
| TSqlConnectionConfig;
export type TValidateAppConnectionCredentialsSchema =
@@ -110,7 +119,8 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateDatabricksConnectionCredentialsSchema
| TValidateHumanitecConnectionCredentialsSchema
| TValidatePostgresConnectionCredentialsSchema
| TValidateMsSqlConnectionCredentialsSchema;
| TValidateMsSqlConnectionCredentialsSchema
| TValidateVercelConnectionCredentialsSchema;
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 {
ApiToken = "api-token"
}
@@ -0,0 +1,273 @@
/* 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 {
TVercelConnection,
TVercelConnectionConfig,
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.ApiToken]
};
};
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,
teamId: string,
apiToken: string
): Promise<VercelEnvironment[]> {
try {
return await fetchAllPages<VercelEnvironment>(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${projectId}/custom-environments?teamId=${teamId}`,
apiToken,
{},
"environments"
);
} catch (error) {
return [];
}
}
async function fetchPreviewBranches(projectId: string, apiToken: string): Promise<string[]> {
try {
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.filter((b) => b.ref !== "main").map((b) => b.ref);
} catch (error) {
return [];
}
}
type VercelTeam = {
id: string;
name: string;
slug: string;
};
type VercelUserResponse = {
user: {
id: string;
name: string;
username: string;
};
};
export const listProjects = async (appConnection: TVercelConnection): 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.name, org.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.slug && env.type !== "gitBranch") {
const { id, slug } = env;
envVars[id] = slug;
}
});
return envVars;
};
@@ -0,0 +1,58 @@
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").describe(AppConnections.CREDENTIALS.VERCEL.apiToken)
});
const BaseVercelConnectionSchema = BaseAppConnectionSchema.extend({
app: z.literal(AppConnection.Vercel)
});
export const VercelConnectionSchema = BaseVercelConnectionSchema.extend({
method: z.literal(VercelConnectionMethod.ApiToken),
credentials: VercelConnectionAccessTokenCredentialsSchema
});
export const SanitizedVercelConnectionSchema = z.discriminatedUnion("method", [
BaseVercelConnectionSchema.extend({
method: z.literal(VercelConnectionMethod.ApiToken),
credentials: VercelConnectionAccessTokenCredentialsSchema.pick({})
})
]);
export const ValidateVercelConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal(VercelConnectionMethod.ApiToken).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 TValidateVercelConnectionCredentialsSchema = typeof ValidateVercelConnectionCredentialsSchema;
export type TVercelConnectionConfig = DiscriminativePick<TVercelConnectionInput, "method" | "app" | "credentials"> & {
orgId: string;
};
export type VercelTeam = {
id: string;
name: string;
slug: string;
};
export type VercelEnvironment = {
id: string;
slug: 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;
};
};
@@ -6,7 +6,8 @@ export enum SecretSync {
AzureKeyVault = "azure-key-vault",
AzureAppConfiguration = "azure-app-configuration",
Databricks = "databricks",
Humanitec = "humanitec"
Humanitec = "humanitec",
Vercel = "vercel"
}
export enum SecretSyncInitialSyncBehavior {
@@ -26,6 +26,7 @@ import { GCP_SYNC_LIST_OPTION } from "./gcp";
import { GcpSyncFns } from "./gcp/gcp-sync-fns";
import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec";
import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns";
import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel";
const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION,
@@ -35,7 +36,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.AzureKeyVault]: AZURE_KEY_VAULT_SYNC_LIST_OPTION,
[SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION,
[SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION,
[SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION
[SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION,
[SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION
};
export const listSecretSyncOptions = () => {
@@ -121,6 +123,8 @@ export const SecretSyncFns = {
}).syncSecrets(secretSync, secretMap);
case SecretSync.Humanitec:
return HumanitecSyncFns.syncSecrets(secretSync, secretMap);
case SecretSync.Vercel:
return VercelSyncFns.syncSecrets(secretSync, secretMap);
default:
throw new Error(
`Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -165,6 +169,9 @@ export const SecretSyncFns = {
case SecretSync.Humanitec:
secretMap = await HumanitecSyncFns.getSecrets(secretSync);
break;
case SecretSync.Vercel:
secretMap = await VercelSyncFns.getSecrets(secretSync);
break;
default:
throw new Error(
`Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -207,6 +214,8 @@ export const SecretSyncFns = {
}).removeSecrets(secretSync, secretMap);
case SecretSync.Humanitec:
return HumanitecSyncFns.removeSecrets(secretSync, secretMap);
case SecretSync.Vercel:
return VercelSyncFns.removeSecrets(secretSync, secretMap);
default:
throw new Error(
`Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -9,7 +9,8 @@ export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[SecretSync.AzureKeyVault]: "Azure Key Vault",
[SecretSync.AzureAppConfiguration]: "Azure App Configuration",
[SecretSync.Databricks]: "Databricks",
[SecretSync.Humanitec]: "Humanitec"
[SecretSync.Humanitec]: "Humanitec",
[SecretSync.Vercel]: "Vercel"
};
export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
@@ -20,5 +21,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault,
[SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration,
[SecretSync.Databricks]: AppConnection.Databricks,
[SecretSync.Humanitec]: AppConnection.Humanitec
[SecretSync.Humanitec]: AppConnection.Humanitec,
[SecretSync.Vercel]: AppConnection.Vercel
};
@@ -49,6 +49,7 @@ import {
THumanitecSyncListItem,
THumanitecSyncWithCredentials
} from "./humanitec";
import { TVercelSync, TVercelSyncInput, TVercelSyncListItem, TVercelSyncWithCredentials } from "./vercel";
export type TSecretSync =
| TAwsParameterStoreSync
@@ -58,7 +59,8 @@ export type TSecretSync =
| TAzureKeyVaultSync
| TAzureAppConfigurationSync
| TDatabricksSync
| THumanitecSync;
| THumanitecSync
| TVercelSync;
export type TSecretSyncWithCredentials =
| TAwsParameterStoreSyncWithCredentials
@@ -68,7 +70,8 @@ export type TSecretSyncWithCredentials =
| TAzureKeyVaultSyncWithCredentials
| TAzureAppConfigurationSyncWithCredentials
| TDatabricksSyncWithCredentials
| THumanitecSyncWithCredentials;
| THumanitecSyncWithCredentials
| TVercelSyncWithCredentials;
export type TSecretSyncInput =
| TAwsParameterStoreSyncInput
@@ -78,7 +81,8 @@ export type TSecretSyncInput =
| TAzureKeyVaultSyncInput
| TAzureAppConfigurationSyncInput
| TDatabricksSyncInput
| THumanitecSyncInput;
| THumanitecSyncInput
| TVercelSyncInput;
export type TSecretSyncListItem =
| TAwsParameterStoreSyncListItem
@@ -88,7 +92,8 @@ export type TSecretSyncListItem =
| TAzureKeyVaultSyncListItem
| TAzureAppConfigurationSyncListItem
| TDatabricksSyncListItem
| THumanitecSyncListItem;
| THumanitecSyncListItem
| TVercelSyncListItem;
export type TSyncOptionsConfig = {
canImportSecrets: boolean;
@@ -0,0 +1,5 @@
export * from "./vercel-sync-constants";
export * from "./vercel-sync-enums";
export * from "./vercel-sync-fns";
export * from "./vercel-sync-schemas";
export * from "./vercel-sync-types";
@@ -0,0 +1,10 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
export const VERCEL_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "Vercel",
destination: SecretSync.Vercel,
connection: AppConnection.Vercel,
canImportSecrets: true
};
@@ -0,0 +1,12 @@
export enum VercelSyncScope {
Application = "application",
Environment = "environment"
}
export const VercelEnvironmentType = {
Development: "development",
Preview: "preview",
Production: "production"
} as const;
export type VercelEnvironment = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType];
@@ -0,0 +1,313 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { request } from "@app/lib/config/request";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { VercelEnvironmentType } from "./vercel-sync-enums";
import { DefaultVercelEnvType, TVercelSyncWithCredentials, VercelApiSecret } from "./vercel-sync-types";
function isVercelDefaultEnvType(value: string): value is DefaultVercelEnvType {
return Object.values(VercelEnvironmentType).map(String).includes(value);
}
const MAX_RETRIES = 5;
const sleep = async () =>
new Promise((resolve) => {
setTimeout(resolve, 60000);
});
const getVercelSecretsWithRetries = async (
secretSync: TVercelSyncWithCredentials,
attempt = 0
): Promise<VercelApiSecret[]> => {
const {
destinationConfig,
connection: {
credentials: { apiToken }
}
} = secretSync;
const params: { [key: string]: string } = {
decrypt: "true",
...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {})
};
try {
const { data } = await request.get<{ envs: VercelApiSecret[] }>(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`,
{
params,
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
return data.envs;
} catch (error) {
if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) {
await sleep();
return await getVercelSecretsWithRetries(secretSync, attempt + 1);
}
throw error;
}
};
const getDecryptedVercelSecret = async (
secretSync: TVercelSyncWithCredentials,
secret: VercelApiSecret,
attempt = 0
): Promise<VercelApiSecret> => {
const {
destinationConfig,
connection: {
credentials: { apiToken }
}
} = secretSync;
const params: { [key: string]: string } = {
decrypt: "true",
...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {})
};
try {
const { data: decryptedSecret } = await request.get(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${secret.id}?teamId=${destinationConfig.teamId}`,
{
params,
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
return decryptedSecret as VercelApiSecret;
} catch (error) {
if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) {
await sleep();
return await getDecryptedVercelSecret(secretSync, secret, attempt + 1);
}
throw error;
}
};
const getVercelSecrets = async (secretSync: TVercelSyncWithCredentials): Promise<VercelApiSecret[]> => {
const { destinationConfig } = secretSync;
const secrets = await getVercelSecretsWithRetries(secretSync);
const filteredSecrets = secrets.filter((secret) => {
if (!isVercelDefaultEnvType(destinationConfig.env)) {
if (secret.customEnvironmentIds?.includes(destinationConfig.env)) {
return true;
}
return false;
}
if (secret.target.includes(destinationConfig.env)) {
// If it's preview environment with a branch specified
if (
destinationConfig.env === VercelEnvironmentType.Preview &&
destinationConfig.branch &&
secret.gitBranch &&
secret.gitBranch !== destinationConfig.branch
) {
return false;
}
return true;
}
return false;
});
// For secrets of type "encrypted", we need to get their decrypted value
const secretsWithValues = await Promise.all(
filteredSecrets.map(async (secret) => {
if (secret.type === "encrypted") {
const decryptedSecret = await getDecryptedVercelSecret(secretSync, secret);
return decryptedSecret;
}
return secret;
})
);
return secretsWithValues;
};
const deleteSecret = async (
secretSync: TVercelSyncWithCredentials,
vercelSecret: VercelApiSecret,
attempt = 0
): Promise<void> => {
const {
destinationConfig,
connection: {
credentials: { apiToken }
}
} = secretSync;
try {
await request.delete(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
} catch (error) {
if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) {
await sleep();
return await deleteSecret(secretSync, vercelSecret, attempt + 1);
}
throw new SecretSyncError({
error,
secretKey: vercelSecret.key
});
}
};
const createSecret = async (
secretSync: TVercelSyncWithCredentials,
secretMap: TSecretMap,
key: string,
attempt = 0
): Promise<void> => {
try {
const {
destinationConfig,
connection: {
credentials: { apiToken }
}
} = secretSync;
await request.post(
`${IntegrationUrls.VERCEL_API_URL}/v10/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`,
{
key,
value: secretMap[key].value,
type: "encrypted",
target: isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [],
customEnvironmentIds: !isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [],
...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch
? { gitBranch: destinationConfig.branch }
: {})
},
{
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
} catch (error) {
if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) {
await sleep();
return await createSecret(secretSync, secretMap, key, attempt + 1);
}
throw new SecretSyncError({
error,
secretKey: key
});
}
};
const updateSecret = async (
secretSync: TVercelSyncWithCredentials,
secretMap: TSecretMap,
vercelSecret: VercelApiSecret,
attempt = 0
): Promise<void> => {
try {
const {
destinationConfig,
connection: {
credentials: { apiToken }
}
} = secretSync;
let target = [...vercelSecret.target];
if (isVercelDefaultEnvType(destinationConfig.env) && !vercelSecret.target.includes(destinationConfig.env)) {
target = [...target, destinationConfig.env];
}
let customEnvironmentIds = [...(vercelSecret.customEnvironmentIds || [])];
if (
!isVercelDefaultEnvType(destinationConfig.env) &&
!vercelSecret.customEnvironmentIds?.includes(destinationConfig.env)
) {
customEnvironmentIds = [...customEnvironmentIds, destinationConfig.env];
}
await request.patch(
`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`,
{
...(vercelSecret.type !== "sensitive" && { key: vercelSecret.key }),
value: secretMap[vercelSecret.key].value,
type: vercelSecret.type,
target,
customEnvironmentIds,
...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch
? { gitBranch: destinationConfig.branch }
: {})
},
{
headers: {
Authorization: `Bearer ${apiToken}`,
"Accept-Encoding": "application/json"
}
}
);
} catch (error) {
if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) {
await sleep();
return await updateSecret(secretSync, secretMap, vercelSecret, attempt + 1);
}
throw new SecretSyncError({
error,
secretKey: vercelSecret.key
});
}
};
export const VercelSyncFns = {
syncSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => {
const vercelSecrets = await getVercelSecrets(secretSync);
const vercelSecretsMap = new Map(vercelSecrets.map((s) => [s.key, s]));
// Create or update secrets
for await (const key of Object.keys(secretMap)) {
const existingSecret = vercelSecretsMap.get(key);
if (!existingSecret) {
await createSecret(secretSync, secretMap, key);
} else if (existingSecret.value !== secretMap[key].value) {
await updateSecret(secretSync, secretMap, existingSecret);
}
}
// Delete secrets if disableSecretDeletion is not set
if (secretSync.syncOptions.disableSecretDeletion) return;
for await (const vercelSecret of vercelSecrets) {
if (!secretMap[vercelSecret.key]) {
await deleteSecret(secretSync, vercelSecret);
}
}
},
getSecrets: async (secretSync: TVercelSyncWithCredentials): Promise<TSecretMap> => {
const vercelSecrets = await getVercelSecrets(secretSync);
return Object.fromEntries(vercelSecrets.map((s) => [s.key, { value: s.value ?? "" }]));
},
removeSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => {
const vercelSecrets = await getVercelSecrets(secretSync);
for await (const vercelSecret of vercelSecrets) {
if (vercelSecret.key in secretMap) {
await deleteSecret(secretSync, vercelSecret);
}
}
}
};
@@ -0,0 +1,49 @@
import { z } from "zod";
import { SecretSyncs } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
BaseSecretSyncSchema,
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
import { VercelEnvironmentType } from "./vercel-sync-enums";
const VercelSyncDestinationConfigSchema = z.object({
app: z.string().min(1, "App ID is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.app),
appName: z.string().min(1, "App Name is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.appName),
env: z.nativeEnum(VercelEnvironmentType).or(z.string()).describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.env),
branch: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.branch),
teamId: z.string().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.teamId)
});
const VercelSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const VercelSyncSchema = BaseSecretSyncSchema(SecretSync.Vercel, VercelSyncOptionsConfig).extend({
destination: z.literal(SecretSync.Vercel),
destinationConfig: VercelSyncDestinationConfigSchema
});
export const CreateVercelSyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.Vercel,
VercelSyncOptionsConfig
).extend({
destinationConfig: VercelSyncDestinationConfigSchema
});
export const UpdateVercelSyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.Vercel,
VercelSyncOptionsConfig
).extend({
destinationConfig: VercelSyncDestinationConfigSchema.optional()
});
export const VercelSyncListItemSchema = z.object({
name: z.literal("Vercel"),
connection: z.literal(AppConnection.Vercel),
destination: z.literal(SecretSync.Vercel),
canImportSecrets: z.literal(true)
});
@@ -0,0 +1,40 @@
import z from "zod";
import { TVercelConnection } from "@app/services/app-connection/vercel";
import { VercelEnvironmentType } from "./vercel-sync-enums";
import { CreateVercelSyncSchema, VercelSyncListItemSchema, VercelSyncSchema } from "./vercel-sync-schemas";
export type TVercelSyncListItem = z.infer<typeof VercelSyncListItemSchema>;
export type TVercelSync = z.infer<typeof VercelSyncSchema>;
export type TVercelSyncInput = z.infer<typeof CreateVercelSyncSchema>;
export type TVercelSyncWithCredentials = TVercelSync & {
connection: TVercelConnection;
};
export type VercelSecret = {
description: string;
is_secret: boolean;
key: string;
source: "app" | "env";
value: string;
};
export interface VercelApiSecret {
id: string;
key: string;
value: string;
type: string;
target: string[];
customEnvironmentIds?: string[];
gitBranch?: string;
createdAt?: number;
updatedAt?: number;
configurationId?: string;
system?: boolean;
}
export type DefaultVercelEnvType = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType];