feat: Add Netlify app connection and secrets sync (#4205)

* fix: save wip

* feat: final impl

* feat: docs

* Update backend/src/services/app-connection/digital-ocean/digital-ocean-connection-service.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* chore: remove empty conflict files

* Update backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update frontend/src/components/secret-syncs/forms/schemas/digital-ocean-app-platform-sync-destination-schema.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update frontend/src/components/secret-syncs/forms/schemas/digital-ocean-app-platform-sync-destination-schema.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/DigitalOceanAppPlatformSyncFields.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update backend/src/services/secret-sync/digital-ocean-app-platform/digital-ocean-app-platform-sync-schemas.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: lint

* feat: Netlify app connection and secrets sync

* feat: docs

* fix: type check

* fix: api client

* fix: lint and types

* fix: typecheck lint

* fix: docs

* fix: lint

* fix: lint

* fix: PR changes

* fix: typecheck

* fix: PR changes

* fix PR changes

* fix: PR Change

* fix: type error

* Small tweaks

* fix: support is_secret

* fix: revert is_secret

* fix: force update existing netlify secret

---------

Co-authored-by: sidwebworks <xodeveloper@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: x032205 <x032205@gmail.com>
This commit is contained in:
Sid
2025-08-01 00:24:40 +05:30
committed by GitHub
parent 3877fe524d
commit 79de7c5f08
90 changed files with 1747 additions and 2 deletions

View File

@@ -2302,6 +2302,9 @@ export const AppConnections = {
DIGITAL_OCEAN_APP_PLATFORM: {
apiToken: "The API token used to authenticate with Digital Ocean App Platform."
},
NETLIFY: {
accessToken: "The Access token used to authenticate with Netlify."
},
OKTA: {
instanceUrl: "The URL used to access your Okta organization.",
apiToken: "The API token used to authenticate with Okta."
@@ -2533,6 +2536,13 @@ export const SecretSyncs = {
workspaceSlug: "The Bitbucket Workspace slug to sync secrets to.",
repositorySlug: "The Bitbucket Repository slug to sync secrets to.",
environmentId: "The Bitbucket Deployment Environment uuid to sync secrets to."
},
NETLIFY: {
accountId: "The ID of the Netlify account to sync secrets to.",
accountName: "The name of the Netlify account to sync secrets to.",
siteName: "The name of the Netlify site to sync secrets to.",
siteId: "The ID of the Netlify site to sync secrets to.",
context: "The Netlify context to sync secrets to."
}
}
};

View File

@@ -75,6 +75,10 @@ import {
import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap";
import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql";
import { MySqlConnectionListItemSchema, SanitizedMySqlConnectionSchema } from "@app/services/app-connection/mysql";
import {
NetlifyConnectionListItemSchema,
SanitizedNetlifyConnectionSchema
} from "@app/services/app-connection/netlify";
import { OktaConnectionListItemSchema, SanitizedOktaConnectionSchema } from "@app/services/app-connection/okta";
import {
PostgresConnectionListItemSchema,
@@ -145,6 +149,7 @@ const SanitizedAppConnectionSchema = z.union([
...SanitizedChecklyConnectionSchema.options,
...SanitizedSupabaseConnectionSchema.options,
...SanitizedDigitalOceanConnectionSchema.options,
...SanitizedNetlifyConnectionSchema.options,
...SanitizedOktaConnectionSchema.options
]);
@@ -184,6 +189,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
ChecklyConnectionListItemSchema,
SupabaseConnectionListItemSchema,
DigitalOceanConnectionListItemSchema,
NetlifyConnectionListItemSchema,
OktaConnectionListItemSchema
]);

View File

@@ -26,6 +26,7 @@ import { registerHumanitecConnectionRouter } from "./humanitec-connection-router
import { registerLdapConnectionRouter } from "./ldap-connection-router";
import { registerMsSqlConnectionRouter } from "./mssql-connection-router";
import { registerMySqlConnectionRouter } from "./mysql-connection-router";
import { registerNetlifyConnectionRouter } from "./netlify-connection-router";
import { registerOktaConnectionRouter } from "./okta-connection-router";
import { registerPostgresConnectionRouter } from "./postgres-connection-router";
import { registerRailwayConnectionRouter } from "./railway-connection-router";
@@ -76,5 +77,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server:
[AppConnection.Checkly]: registerChecklyConnectionRouter,
[AppConnection.Supabase]: registerSupabaseConnectionRouter,
[AppConnection.DigitalOcean]: registerDigitalOceanConnectionRouter,
[AppConnection.Netlify]: registerNetlifyConnectionRouter,
[AppConnection.Okta]: registerOktaConnectionRouter
};

View File

@@ -0,0 +1,87 @@
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 {
CreateNetlifyConnectionSchema,
SanitizedNetlifyConnectionSchema,
UpdateNetlifyConnectionSchema
} from "@app/services/app-connection/netlify";
import { AuthMode } from "@app/services/auth/auth-type";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerNetlifyConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.Netlify,
server,
sanitizedResponseSchema: SanitizedNetlifyConnectionSchema,
createSchema: CreateNetlifyConnectionSchema,
updateSchema: UpdateNetlifyConnectionSchema
});
// The below endpoints are not exposed and for Infisical App use
server.route({
method: "GET",
url: `/:connectionId/accounts`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
response: {
200: z.object({
accounts: z
.object({
name: z.string(),
id: z.string()
})
.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { connectionId } = req.params;
const accounts = await server.services.appConnection.netlify.listAccounts(connectionId, req.permission);
return { accounts };
}
});
server.route({
method: "GET",
url: `/:connectionId/accounts/:accountId/sites`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid(),
accountId: z.string()
}),
response: {
200: z.object({
sites: z
.object({
name: z.string(),
id: z.string()
})
.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { connectionId, accountId } = req.params;
const sites = await server.services.appConnection.netlify.listSites(connectionId, req.permission, accountId);
return { sites };
}
});
};

View File

@@ -21,6 +21,7 @@ import { registerGitLabSyncRouter } from "./gitlab-sync-router";
import { registerHCVaultSyncRouter } from "./hc-vault-sync-router";
import { registerHerokuSyncRouter } from "./heroku-sync-router";
import { registerHumanitecSyncRouter } from "./humanitec-sync-router";
import { registerNetlifySyncRouter } from "./netlify-sync-router";
import { registerRailwaySyncRouter } from "./railway-sync-router";
import { registerRenderSyncRouter } from "./render-sync-router";
import { registerSupabaseSyncRouter } from "./supabase-sync-router";
@@ -61,5 +62,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record<SecretSync, (server: Fastif
[SecretSync.Railway]: registerRailwaySyncRouter,
[SecretSync.Checkly]: registerChecklySyncRouter,
[SecretSync.DigitalOceanAppPlatform]: registerDigitalOceanAppPlatformSyncRouter,
[SecretSync.Netlify]: registerNetlifySyncRouter,
[SecretSync.Bitbucket]: registerBitbucketSyncRouter
};

View File

@@ -0,0 +1,17 @@
import {
CreateNetlifySyncSchema,
NetlifySyncSchema,
UpdateNetlifySyncSchema
} from "@app/services/secret-sync/netlify/netlify-sync-schemas";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
export const registerNetlifySyncRouter = async (server: FastifyZodProvider) =>
registerSyncSecretsEndpoints({
destination: SecretSync.Netlify,
server,
responseSchema: NetlifySyncSchema,
createSchema: CreateNetlifySyncSchema,
updateSchema: UpdateNetlifySyncSchema
});

View File

@@ -44,6 +44,7 @@ import { GitLabSyncListItemSchema, GitLabSyncSchema } from "@app/services/secret
import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault";
import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku";
import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec";
import { NetlifySyncListItemSchema, NetlifySyncSchema } from "@app/services/secret-sync/netlify";
import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas";
import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas";
import { SupabaseSyncListItemSchema, SupabaseSyncSchema } from "@app/services/secret-sync/supabase";
@@ -82,6 +83,7 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [
RailwaySyncSchema,
ChecklySyncSchema,
DigitalOceanAppPlatformSyncSchema,
NetlifySyncSchema,
BitbucketSyncSchema
]);
@@ -114,6 +116,7 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
RailwaySyncListItemSchema,
ChecklySyncListItemSchema,
SupabaseSyncListItemSchema,
NetlifySyncListItemSchema,
BitbucketSyncListItemSchema
]);

View File

@@ -34,6 +34,7 @@ export enum AppConnection {
Checkly = "checkly",
Supabase = "supabase",
DigitalOcean = "digital-ocean",
Netlify = "netlify",
Okta = "okta"
}

View File

@@ -97,6 +97,7 @@ import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnection
import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql";
import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums";
import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns";
import { getNetlifyConnectionListItem, validateNetlifyConnectionCredentials } from "./netlify";
import { getOktaConnectionListItem, OktaConnectionMethod, validateOktaConnectionCredentials } from "./okta";
import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres";
import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway";
@@ -163,6 +164,7 @@ export const listAppConnectionOptions = () => {
getChecklyConnectionListItem(),
getSupabaseConnectionListItem(),
getDigitalOceanConnectionListItem(),
getNetlifyConnectionListItem(),
getOktaConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
};
@@ -251,7 +253,8 @@ export const validateAppConnectionCredentials = async (
[AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.DigitalOcean]: validateDigitalOceanConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator
[AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator
};
return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService);
@@ -381,6 +384,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.Checkly]: platformManagedCredentialsNotSupported,
[AppConnection.Supabase]: platformManagedCredentialsNotSupported,
[AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported,
[AppConnection.Netlify]: platformManagedCredentialsNotSupported,
[AppConnection.Okta]: platformManagedCredentialsNotSupported
};

View File

@@ -36,6 +36,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.Checkly]: "Checkly",
[AppConnection.Supabase]: "Supabase",
[AppConnection.DigitalOcean]: "DigitalOcean App Platform",
[AppConnection.Netlify]: "Netlify",
[AppConnection.Okta]: "Okta"
};
@@ -75,5 +76,6 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
[AppConnection.Checkly]: AppConnectionPlanType.Regular,
[AppConnection.Supabase]: AppConnectionPlanType.Regular,
[AppConnection.DigitalOcean]: AppConnectionPlanType.Regular,
[AppConnection.Netlify]: AppConnectionPlanType.Regular,
[AppConnection.Okta]: AppConnectionPlanType.Regular
};

View File

@@ -81,6 +81,8 @@ import { humanitecConnectionService } from "./humanitec/humanitec-connection-ser
import { ValidateLdapConnectionCredentialsSchema } from "./ldap";
import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql";
import { ValidateMySqlConnectionCredentialsSchema } from "./mysql";
import { ValidateNetlifyConnectionCredentialsSchema } from "./netlify";
import { netlifyConnectionService } from "./netlify/netlify-connection-service";
import { ValidateOktaConnectionCredentialsSchema } from "./okta";
import { oktaConnectionService } from "./okta/okta-connection-service";
import { ValidatePostgresConnectionCredentialsSchema } from "./postgres";
@@ -148,6 +150,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.Checkly]: ValidateChecklyConnectionCredentialsSchema,
[AppConnection.Supabase]: ValidateSupabaseConnectionCredentialsSchema,
[AppConnection.DigitalOcean]: ValidateDigitalOceanConnectionCredentialsSchema,
[AppConnection.Netlify]: ValidateNetlifyConnectionCredentialsSchema,
[AppConnection.Okta]: ValidateOktaConnectionCredentialsSchema
};
@@ -611,6 +614,7 @@ export const appConnectionServiceFactory = ({
checkly: checklyConnectionService(connectAppConnectionById),
supabase: supabaseConnectionService(connectAppConnectionById),
digitalOcean: digitalOceanAppPlatformConnectionService(connectAppConnectionById),
netlify: netlifyConnectionService(connectAppConnectionById),
okta: oktaConnectionService(connectAppConnectionById)
};
};

View File

@@ -149,6 +149,12 @@ import {
} from "./ldap";
import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentialsSchema } from "./mssql";
import { TMySqlConnection, TMySqlConnectionInput, TValidateMySqlConnectionCredentialsSchema } from "./mysql";
import {
TNetlifyConnection,
TNetlifyConnectionConfig,
TNetlifyConnectionInput,
TValidateNetlifyConnectionCredentialsSchema
} from "./netlify";
import {
TOktaConnection,
TOktaConnectionConfig,
@@ -245,6 +251,7 @@ export type TAppConnection = { id: string } & (
| TChecklyConnection
| TSupabaseConnection
| TDigitalOceanConnection
| TNetlifyConnection
| TOktaConnection
);
@@ -288,6 +295,7 @@ export type TAppConnectionInput = { id: string } & (
| TChecklyConnectionInput
| TSupabaseConnectionInput
| TDigitalOceanConnectionInput
| TNetlifyConnectionInput
| TOktaConnectionInput
);
@@ -339,6 +347,7 @@ export type TAppConnectionConfig =
| TChecklyConnectionConfig
| TSupabaseConnectionConfig
| TDigitalOceanConnectionConfig
| TNetlifyConnectionConfig
| TOktaConnectionConfig;
export type TValidateAppConnectionCredentialsSchema =
@@ -377,6 +386,7 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateChecklyConnectionCredentialsSchema
| TValidateSupabaseConnectionCredentialsSchema
| TValidateDigitalOceanCredentialsSchema
| TValidateNetlifyConnectionCredentialsSchema
| TValidateOktaConnectionCredentialsSchema;
export type TListAwsConnectionKmsKeys = {

View File

@@ -0,0 +1,4 @@
export * from "./netlify-connection-constants";
export * from "./netlify-connection-fns";
export * from "./netlify-connection-schemas";
export * from "./netlify-connection-types";

View File

@@ -0,0 +1,3 @@
export enum NetlifyConnectionMethod {
AccessToken = "access-token"
}

View File

@@ -0,0 +1,35 @@
/* eslint-disable no-await-in-loop */
import { AxiosError } from "axios";
import { BadRequestError } from "@app/lib/errors";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { NetlifyConnectionMethod } from "./netlify-connection-constants";
import { NetlifyPublicAPI } from "./netlify-connection-public-client";
import { TNetlifyConnectionConfig } from "./netlify-connection-types";
export const getNetlifyConnectionListItem = () => {
return {
name: "Netlify" as const,
app: AppConnection.Netlify as const,
methods: Object.values(NetlifyConnectionMethod)
};
};
export const validateNetlifyConnectionCredentials = async (config: TNetlifyConnectionConfig) => {
try {
await NetlifyPublicAPI.healthcheck(config);
} 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"
});
}
return config.credentials;
};

View File

@@ -0,0 +1,261 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable no-await-in-loop */
/* eslint-disable class-methods-use-this */
import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxiosError } from "axios";
import { createRequestClient } from "@app/lib/config/request";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { NetlifyConnectionMethod } from "./netlify-connection-constants";
import { TNetlifyAccount, TNetlifyConnectionConfig, TNetlifySite, TNetlifyVariable } from "./netlify-connection-types";
export function getNetlifyAuthHeaders(connection: TNetlifyConnectionConfig): Record<string, string> {
switch (connection.method) {
case NetlifyConnectionMethod.AccessToken:
return {
Authorization: `Bearer ${connection.credentials.accessToken}`
};
default:
throw new Error(`Unsupported Netlify connection method`);
}
}
export function getNetlifyRatelimiter(response: AxiosResponse): {
maxAttempts: number;
isRatelimited: boolean;
wait: () => Promise<void>;
} {
const wait = (seconds: number = 60) => {
return new Promise<void>((res) => {
setTimeout(res, seconds * 1000);
});
};
let remaining = parseInt(response.headers["X-RateLimit-Remaining"] as string, 10);
let isRatelimited = response.status === HttpStatusCode.TooManyRequests;
if (isRatelimited) {
if (Math.round(remaining) > 0) {
isRatelimited = true;
remaining += 1; // Jitter to ensure we wait at least 1 second
} else {
remaining = 60;
}
}
return {
isRatelimited,
wait: () => wait(remaining),
maxAttempts: 3
};
}
type NetlifyParams = {
account_id: string;
context_name?: string;
site_id?: string;
};
class NetlifyPublicClient {
private client: AxiosInstance;
constructor() {
this.client = createRequestClient({
baseURL: `${IntegrationUrls.NETLIFY_API_URL}/api/v1`,
headers: {
"Content-Type": "application/json"
}
});
}
async send<T>(connection: TNetlifyConnectionConfig, config: AxiosRequestConfig, retryAttempt = 0): Promise<T> {
const response = await this.client.request<T>({
...config,
timeout: 1000 * 60, // 60 seconds timeout
validateStatus: (status) => (status >= 200 && status < 300) || status === HttpStatusCode.TooManyRequests,
headers: getNetlifyAuthHeaders(connection)
});
const limiter = getNetlifyRatelimiter(response);
if (limiter.isRatelimited && retryAttempt <= limiter.maxAttempts) {
await limiter.wait();
return this.send(connection, config, retryAttempt + 1);
}
return response.data;
}
healthcheck(connection: TNetlifyConnectionConfig) {
switch (connection.method) {
case NetlifyConnectionMethod.AccessToken:
return this.getNetlifyAccounts(connection);
default:
throw new Error(`Unsupported Netlify connection method`);
}
}
async getVariables(
connection: TNetlifyConnectionConfig,
{ account_id, ...params }: NetlifyParams,
limit: number = 50,
page: number = 1
) {
const res = await this.send<TNetlifyVariable[]>(connection, {
method: "GET",
url: `/accounts/${account_id}/env`,
params: {
...params,
limit,
page
}
});
return res;
}
async createVariable(
connection: TNetlifyConnectionConfig,
{ account_id, ...params }: NetlifyParams,
...variables: TNetlifyVariable[]
) {
const res = await this.send<TNetlifyVariable>(connection, {
method: "POST",
url: `/accounts/${account_id}/env`,
data: variables,
params
});
return res;
}
async updateVariableValue(
connection: TNetlifyConnectionConfig,
{ account_id, ...params }: NetlifyParams,
variable: TNetlifyVariable
) {
const res = await this.send<TNetlifyVariable>(connection, {
method: "PATCH",
url: `/accounts/${account_id}/env/${variable.key}`,
data: variable,
params
});
return res;
}
async updateVariable(
connection: TNetlifyConnectionConfig,
{ account_id, ...params }: NetlifyParams,
variable: TNetlifyVariable
) {
const res = await this.send<TNetlifyVariable>(connection, {
method: "PUT",
url: `/accounts/${account_id}/env/${variable.key}`,
data: variable,
params
});
return res;
}
async getVariable(
connection: TNetlifyConnectionConfig,
{ account_id, ...params }: NetlifyParams,
variable: Pick<TNetlifyVariable, "key">
) {
try {
const res = await this.send<TNetlifyVariable>(connection, {
method: "GET",
url: `/accounts/${account_id}/env/${variable.key}`,
params
});
return res;
} catch (error) {
if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) {
return null;
}
throw error;
}
}
async upsertVariable(connection: TNetlifyConnectionConfig, params: NetlifyParams, variable: TNetlifyVariable) {
const res = await this.getVariable(connection, params, variable);
if (!res) {
return this.createVariable(connection, params, variable);
}
if (res.is_secret) {
await this.deleteVariable(connection, params, variable);
return this.createVariable(connection, params, variable);
}
return this.updateVariable(connection, params, variable);
}
async deleteVariable(
connection: TNetlifyConnectionConfig,
{ account_id, ...params }: NetlifyParams,
variable: Pick<TNetlifyVariable, "key">
) {
try {
const res = await this.send<TNetlifyVariable>(connection, {
method: "DELETE",
url: `/accounts/${account_id}/env/${variable.key}`,
params
});
return res;
} catch (error) {
if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) {
return null;
}
throw error;
}
}
async deleteVariableValue(
connection: TNetlifyConnectionConfig,
{ account_id, value_id, ...params }: NetlifyParams & { value_id: string },
variable: Pick<TNetlifyVariable, "key" | "id">
) {
try {
const res = await this.send<TNetlifyVariable>(connection, {
method: "DELETE",
url: `/accounts/${account_id}/${variable.key}/value/${value_id}`,
params
});
return res;
} catch (error) {
if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) {
return null;
}
throw error;
}
}
async getSites(connection: TNetlifyConnectionConfig, accountId: string) {
const res = await this.send<TNetlifySite[]>(connection, {
method: "GET",
url: `/${accountId}/sites`
});
return res;
}
async getNetlifyAccounts(connection: TNetlifyConnectionConfig) {
const res = await this.send<TNetlifyAccount[]>(connection, {
method: "GET",
url: `/accounts`
});
return res;
}
}
export const NetlifyPublicAPI = new NetlifyPublicClient();

View File

@@ -0,0 +1,67 @@
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 { NetlifyConnectionMethod } from "./netlify-connection-constants";
export const NetlifyConnectionMethodSchema = z
.nativeEnum(NetlifyConnectionMethod)
.describe(AppConnections.CREATE(AppConnection.Netlify).method);
export const NetlifyConnectionAccessTokenCredentialsSchema = z.object({
accessToken: z
.string()
.trim()
.min(1, "Access Token required")
.max(255)
.describe(AppConnections.CREDENTIALS.NETLIFY.accessToken)
});
const BaseNetlifyConnectionSchema = BaseAppConnectionSchema.extend({
app: z.literal(AppConnection.Netlify)
});
export const NetlifyConnectionSchema = BaseNetlifyConnectionSchema.extend({
method: NetlifyConnectionMethodSchema,
credentials: NetlifyConnectionAccessTokenCredentialsSchema
});
export const SanitizedNetlifyConnectionSchema = z.discriminatedUnion("method", [
BaseNetlifyConnectionSchema.extend({
method: NetlifyConnectionMethodSchema,
credentials: NetlifyConnectionAccessTokenCredentialsSchema.pick({})
})
]);
export const ValidateNetlifyConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: NetlifyConnectionMethodSchema,
credentials: NetlifyConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Netlify).credentials
)
})
]);
export const CreateNetlifyConnectionSchema = ValidateNetlifyConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Netlify)
);
export const UpdateNetlifyConnectionSchema = z
.object({
credentials: NetlifyConnectionAccessTokenCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.Netlify).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Netlify));
export const NetlifyConnectionListItemSchema = z.object({
name: z.literal("Netlify"),
app: z.literal(AppConnection.Netlify),
methods: z.nativeEnum(NetlifyConnectionMethod).array()
});

View File

@@ -0,0 +1,42 @@
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { NetlifyPublicAPI } from "./netlify-connection-public-client";
import { TNetlifyConnection } from "./netlify-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TNetlifyConnection>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const netlifyConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listAccounts = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Netlify, connectionId, actor);
try {
const accounts = await NetlifyPublicAPI.getNetlifyAccounts(appConnection);
return accounts;
} catch (error) {
logger.error(error, "Failed to list accounts on Netlify");
return [];
}
};
const listSites = async (connectionId: string, actor: OrgServiceActor, accountId: string) => {
const appConnection = await getAppConnection(AppConnection.Netlify, connectionId, actor);
try {
const sites = await NetlifyPublicAPI.getSites(appConnection, accountId);
return sites;
} catch (error) {
logger.error(error, "Failed to list sites on Netlify");
return [];
}
};
return {
listAccounts,
listSites
};
};

View File

@@ -0,0 +1,51 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
CreateNetlifyConnectionSchema,
NetlifyConnectionSchema,
ValidateNetlifyConnectionCredentialsSchema
} from "./netlify-connection-schemas";
export type TNetlifyConnection = z.infer<typeof NetlifyConnectionSchema>;
export type TNetlifyConnectionInput = z.infer<typeof CreateNetlifyConnectionSchema> & {
app: AppConnection.Netlify;
};
export type TValidateNetlifyConnectionCredentialsSchema = typeof ValidateNetlifyConnectionCredentialsSchema;
export type TNetlifyConnectionConfig = DiscriminativePick<TNetlifyConnection, "method" | "app" | "credentials"> & {
orgId: string;
};
export type TNetlifyVariable = {
key: string;
id?: string; // ID of the variable (present in responses)
created_at?: string;
updated_at?: string;
is_secret?: boolean;
scopes?: ("builds" | "functions" | "runtime" | "post_processing")[];
values: TNetlifyVariableValue[];
};
export type TNetlifyVariableValue = {
id?: string;
context?: string; // "all", "dev", "branch-deploy", etc.
value?: string; // Omitted in response if `is_secret` is true
site_id?: string; // Optional: overrides at site-level
created_at?: string;
updated_at?: string;
};
export type TNetlifyAccount = {
id: string;
name: string;
};
export type TNetlifySite = {
id: string;
name: string;
};

View File

@@ -0,0 +1,4 @@
export * from "./netlify-sync-constants";
export * from "./netlify-sync-fns";
export * from "./netlify-sync-schemas";
export * from "./netlify-sync-types";

View File

@@ -0,0 +1,19 @@
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 NETLIFY_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "Netlify",
destination: SecretSync.Netlify,
connection: AppConnection.Netlify,
canImportSecrets: true
};
export enum NetlifySyncContext {
All = "all",
DeployPreview = "deploy-preview",
Production = "production",
BranchDeploy = "branch-deploy",
Dev = "dev",
Branch = "branch"
}

View File

@@ -0,0 +1,125 @@
/* eslint-disable no-continue */
import { NetlifyPublicAPI } from "@app/services/app-connection/netlify/netlify-connection-public-client";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { SecretSyncError } from "../secret-sync-errors";
import type { TNetlifySyncWithCredentials } from "./netlify-sync-types";
export const NetlifySyncFns = {
async getSecrets(secretSync: TNetlifySyncWithCredentials): Promise<TSecretMap> {
const config = secretSync.destinationConfig;
const params = {
account_id: config.accountId,
context_name: config.context, // Only used in the case of getVariables
site_id: config.siteId
};
const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params);
const map = {} as TSecretMap;
for (const variable of variables) {
if (variable.is_secret) continue;
const latest = variable.values.sort((a, b) => Number(b.created_at) - Number(a.created_at))[0];
map[variable.key] = {
value: latest?.value ?? ""
};
}
return map;
},
async syncSecrets(secretSync: TNetlifySyncWithCredentials, secretMap: TSecretMap) {
const {
environment,
syncOptions: { disableSecretDeletion, keySchema }
} = secretSync;
const config = secretSync.destinationConfig;
const params = {
account_id: config.accountId,
context_name: config.context,
site_id: config.siteId
};
const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params);
const existing = Object.fromEntries(variables.map((variable) => [variable.key, variable]));
for await (const key of Object.keys(secretMap)) {
try {
const entry = secretMap[key];
await NetlifyPublicAPI.upsertVariable(secretSync.connection, params, {
key,
is_secret: false,
values: [
{
value: entry.value,
context: config.context
}
]
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
if (disableSecretDeletion) return;
for await (const key of Object.keys(existing)) {
try {
// eslint-disable-next-line no-continue, @typescript-eslint/no-unsafe-argument
if (!matchesSchema(key, environment?.slug || "", keySchema)) continue;
if (!secretMap[key]) {
await NetlifyPublicAPI.deleteVariable(secretSync.connection, params, {
key
});
}
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
},
async removeSecrets(secretSync: TNetlifySyncWithCredentials, secretMap: TSecretMap) {
const config = secretSync.destinationConfig;
const params = {
account_id: config.accountId,
context_name: config.context,
site_id: config.siteId
};
const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params);
const existingSecrets = Object.fromEntries(variables.map((variable) => [variable.key, variable]));
for await (const secret of Object.keys(existingSecrets)) {
try {
if (secret in secretMap) {
await NetlifyPublicAPI.deleteVariable(secretSync.connection, params, {
key: secret
});
}
} catch (error) {
throw new SecretSyncError({
error,
secretKey: secret
});
}
}
}
};

View File

@@ -0,0 +1,68 @@
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 { NetlifySyncContext } from "./netlify-sync-constants";
const NetlifySyncDestinationConfigSchema = z.object({
accountId: z
.string()
.min(1, "Account ID is required")
.max(255, "Account ID must be less than 255 characters")
.describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.accountId),
accountName: z
.string()
.min(1, "Account Name is required")
.max(255, "Account Name must be less than 255 characters")
.optional()
.describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.accountName),
siteId: z
.string()
.min(1, "Site ID is required")
.max(255, "Site ID must be less than 255 characters")
.optional()
.describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.siteId),
siteName: z
.string()
.min(1, "Site Name is required")
.max(255, "Site Name must be less than 255 characters")
.optional()
.describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.siteName),
context: z.nativeEnum(NetlifySyncContext).optional().describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.context)
});
const NetlifySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const NetlifySyncSchema = BaseSecretSyncSchema(SecretSync.Netlify, NetlifySyncOptionsConfig).extend({
destination: z.literal(SecretSync.Netlify),
destinationConfig: NetlifySyncDestinationConfigSchema
});
export const CreateNetlifySyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.Netlify,
NetlifySyncOptionsConfig
).extend({
destinationConfig: NetlifySyncDestinationConfigSchema
});
export const UpdateNetlifySyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.Netlify,
NetlifySyncOptionsConfig
).extend({
destinationConfig: NetlifySyncDestinationConfigSchema.optional()
});
export const NetlifySyncListItemSchema = z.object({
name: z.literal("Netlify"),
connection: z.literal(AppConnection.Netlify),
destination: z.literal(SecretSync.Netlify),
canImportSecrets: z.literal(true)
});

View File

@@ -0,0 +1,15 @@
import z from "zod";
import { TNetlifyConnection } from "@app/services/app-connection/netlify";
import { CreateNetlifySyncSchema, NetlifySyncListItemSchema, NetlifySyncSchema } from "./netlify-sync-schemas";
export type TNetlifySyncListItem = z.infer<typeof NetlifySyncListItemSchema>;
export type TNetlifySync = z.infer<typeof NetlifySyncSchema>;
export type TNetlifySyncInput = z.infer<typeof CreateNetlifySyncSchema>;
export type TNetlifySyncWithCredentials = TNetlifySync & {
connection: TNetlifyConnection;
};

View File

@@ -27,6 +27,7 @@ export enum SecretSync {
Railway = "railway",
Checkly = "checkly",
DigitalOceanAppPlatform = "digital-ocean-app-platform",
Netlify = "netlify",
Bitbucket = "bitbucket"
}

View File

@@ -48,6 +48,7 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault";
import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku";
import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec";
import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns";
import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify";
import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants";
import { RailwaySyncFns } from "./railway/railway-sync-fns";
import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render";
@@ -88,6 +89,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.Railway]: RAILWAY_SYNC_LIST_OPTION,
[SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION,
[SecretSync.DigitalOceanAppPlatform]: DIGITAL_OCEAN_APP_PLATFORM_SYNC_LIST_OPTION,
[SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION,
[SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION
};
@@ -269,6 +271,8 @@ export const SecretSyncFns = {
return SupabaseSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.DigitalOceanAppPlatform:
return DigitalOceanAppPlatformSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.Netlify:
return NetlifySyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.Bitbucket:
return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap);
default:
@@ -381,6 +385,9 @@ export const SecretSyncFns = {
case SecretSync.DigitalOceanAppPlatform:
secretMap = await DigitalOceanAppPlatformSyncFns.getSecrets(secretSync);
break;
case SecretSync.Netlify:
secretMap = await NetlifySyncFns.getSecrets(secretSync);
break;
case SecretSync.Bitbucket:
secretMap = await BitbucketSyncFns.getSecrets(secretSync);
break;
@@ -473,6 +480,8 @@ export const SecretSyncFns = {
return SupabaseSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.DigitalOceanAppPlatform:
return DigitalOceanAppPlatformSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.Netlify:
return NetlifySyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.Bitbucket:
return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap);
default:

View File

@@ -30,6 +30,7 @@ export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[SecretSync.Railway]: "Railway",
[SecretSync.Checkly]: "Checkly",
[SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform",
[SecretSync.Netlify]: "Netlify",
[SecretSync.Bitbucket]: "Bitbucket"
};
@@ -62,6 +63,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.Railway]: AppConnection.Railway,
[SecretSync.Checkly]: AppConnection.Checkly,
[SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean,
[SecretSync.Netlify]: AppConnection.Netlify,
[SecretSync.Bitbucket]: AppConnection.Bitbucket
};
@@ -94,5 +96,6 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
[SecretSync.Railway]: SecretSyncPlanType.Regular,
[SecretSync.Checkly]: SecretSyncPlanType.Regular,
[SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular,
[SecretSync.Netlify]: SecretSyncPlanType.Regular,
[SecretSync.Bitbucket]: SecretSyncPlanType.Regular
};

View File

@@ -117,6 +117,7 @@ import {
THumanitecSyncListItem,
THumanitecSyncWithCredentials
} from "./humanitec";
import { TNetlifySync, TNetlifySyncInput, TNetlifySyncListItem, TNetlifySyncWithCredentials } from "./netlify";
import {
TRailwaySync,
TRailwaySyncInput,
@@ -178,6 +179,7 @@ export type TSecretSync =
| TRailwaySync
| TChecklySync
| TSupabaseSync
| TNetlifySync
| TBitbucketSync;
export type TSecretSyncWithCredentials =
@@ -209,6 +211,7 @@ export type TSecretSyncWithCredentials =
| TChecklySyncWithCredentials
| TSupabaseSyncWithCredentials
| TDigitalOceanAppPlatformSyncWithCredentials
| TNetlifySyncWithCredentials
| TBitbucketSyncWithCredentials;
export type TSecretSyncInput =
@@ -240,6 +243,7 @@ export type TSecretSyncInput =
| TChecklySyncInput
| TSupabaseSyncInput
| TDigitalOceanAppPlatformSyncInput
| TNetlifySyncInput
| TBitbucketSyncInput;
export type TSecretSyncListItem =
@@ -271,6 +275,7 @@ export type TSecretSyncListItem =
| TChecklySyncListItem
| TSupabaseSyncListItem
| TDigitalOceanAppPlatformSyncListItem
| TNetlifySyncListItem
| TBitbucketSyncListItem;
export type TSyncOptionsConfig = {

View File

@@ -0,0 +1,4 @@
---
title: "Available"
openapi: "GET /api/v1/app-connections/netlify/available"
---

View File

@@ -0,0 +1,8 @@
---
title: "Create"
openapi: "POST /api/v1/app-connections/netlify"
---
<Note>
Check out the configuration docs for [Netlify Connections](/integrations/app-connections/netlify) to learn how to obtain the required credentials.
</Note>

View File

@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/app-connections/netlify/{connectionId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Get by ID"
openapi: "GET /api/v1/app-connections/netlify/{connectionId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Get by Name"
openapi: "GET /api/v1/app-connections/netlify/connection-name/{connectionName}"
---

View File

@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v1/app-connections/netlify"
---

View File

@@ -0,0 +1,8 @@
---
title: "Update"
openapi: "PATCH /api/v1/app-connections/netlify/{connectionId}"
---
<Note>
Check out the configuration docs for [Netlify Connections](/integrations/app-connections/netlify) to learn how to obtain the required credentials.
</Note>

View File

@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v1/secret-syncs/netlify"
---

View File

@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/secret-syncs/netlify/{syncId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Get by ID"
openapi: "GET /api/v1/secret-syncs/netlify/{syncId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Get by Name"
openapi: "GET /api/v1/secret-syncs/netlify/sync-name/{syncName}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Import Secrets"
openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/import-secrets"
---

View File

@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v1/secret-syncs/netlify"
---

View File

@@ -0,0 +1,4 @@
---
title: "Remove Secrets"
openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/remove-secrets"
---

View File

@@ -0,0 +1,4 @@
---
title: "Sync Secrets"
openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/sync-secrets"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/secret-syncs/netlify/{syncId}"
---

View File

@@ -497,6 +497,7 @@
"integrations/app-connections/ldap",
"integrations/app-connections/mssql",
"integrations/app-connections/mysql",
"integrations/app-connections/netlify",
"integrations/app-connections/oci",
"integrations/app-connections/okta",
"integrations/app-connections/oracledb",
@@ -540,6 +541,7 @@
"integrations/secret-syncs/hashicorp-vault",
"integrations/secret-syncs/heroku",
"integrations/secret-syncs/humanitec",
"integrations/secret-syncs/netlify",
"integrations/secret-syncs/oci-vault",
"integrations/secret-syncs/railway",
"integrations/secret-syncs/render",
@@ -1557,6 +1559,18 @@
"api-reference/endpoints/app-connections/mysql/update",
"api-reference/endpoints/app-connections/mysql/delete"
]
},
{
"group": "Netlify",
"pages": [
"api-reference/endpoints/app-connections/netlify/list",
"api-reference/endpoints/app-connections/netlify/available",
"api-reference/endpoints/app-connections/netlify/get-by-id",
"api-reference/endpoints/app-connections/netlify/get-by-name",
"api-reference/endpoints/app-connections/netlify/create",
"api-reference/endpoints/app-connections/netlify/update",
"api-reference/endpoints/app-connections/netlify/delete"
]
},
{
"group": "OCI",
@@ -1978,6 +1992,19 @@
"api-reference/endpoints/secret-syncs/humanitec/remove-secrets"
]
},
{
"group": "Netlify",
"pages": [
"api-reference/endpoints/secret-syncs/netlify/list",
"api-reference/endpoints/secret-syncs/netlify/get-by-id",
"api-reference/endpoints/secret-syncs/netlify/get-by-name",
"api-reference/endpoints/secret-syncs/netlify/create",
"api-reference/endpoints/secret-syncs/netlify/update",
"api-reference/endpoints/secret-syncs/netlify/delete",
"api-reference/endpoints/secret-syncs/netlify/sync-secrets",
"api-reference/endpoints/secret-syncs/netlify/remove-secrets"
]
},
{
"group": "OCI",
"pages": [

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

View File

@@ -0,0 +1,102 @@
---
title: "Netlify Connection"
description: "Learn how to configure a Netlify Connection for Infisical."
---
Infisical supports the use of [Personal Access Tokens](https://docs.netlify.com/api/get-started/#get-access-tokens) to connect with Netlify.
<Note>
Netlify requires the token to have **full access** to enable secret management for your sites and services.
</Note>
## Create a Netlify Personal Access Token
<Steps>
<Step title="From your Netlify dashboard, click on your user avatar and go to 'User settings'">
![Netlify User Settings](/images/app-connections/netlify/app-connection-profile.png)
</Step>
<Step title="In the left sidebar, click on 'Applications' and scroll to 'Personal Access Tokens'">
![Applications Tab](/images/app-connections/netlify/app-connection-create-api-key.png)
</Step>
<Step title="Click 'New access token'">
Provide a name for your token and generate it.
![Token Form](/images/app-connections/netlify/app-connection-create-form.png)
</Step>
<Step title="Copy the token securely">
Make sure to copy the token now—you wont be able to access it again.
![Token Generated](/images/app-connections/netlify/app-connection-key-generated.png)
</Step>
</Steps>
## Create a Netlify Connection in Infisical
<Tabs>
<Tab title="Infisical UI">
<Steps>
<Step title="Navigate to App Connections">
In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab.
![App Connections Tab](/images/app-connections/general/add-connection.png)
</Step>
<Step title="Select Netlify Connection">
Click **+ Add Connection** and choose **Netlify Connection** from the list of integrations.
![Select Netlify Connection](/images/app-connections/netlify/app-connection-option.png)
</Step>
<Step title="Fill out the Netlify Connection form">
Complete the form by providing:
- A descriptive name for the connection
- An optional description
- The API Token from the previous step
![Netlify Connection Modal](/images/app-connections/netlify/app-connection-form.png)
</Step>
<Step title="Connection created">
After submitting the form, your **Netlify Connection** will be successfully created and ready to use with your Infisical projects.
![Netlify Connection Created](/images/app-connections/netlify/app-connection-generated.png)
</Step>
</Steps>
</Tab>
<Tab title="API">
To create a Netlify Connection via API, send a request to the [Create Netlify Connection](/api-reference/endpoints/app-connections/netlify/create) endpoint.
### Sample request
```bash Request
curl --request POST \
--url https://app.infisical.com/api/v1/app-connections/netlify \
--header 'Content-Type: application/json' \
--data '{
"name": "my-netlify-connection",
"method": "access-token",
"credentials": {
"accessToken": "[ACCESS TOKEN]"
}
}'
```
### Sample response
```bash Response
{
"appConnection": {
"id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"name": "my-netlify-connection",
"description": null,
"version": 1,
"orgId": "abcdef12-3456-7890-abcd-ef1234567890",
"createdAt": "2025-07-19T10:15:00.000Z",
"updatedAt": "2025-07-19T10:15:00.000Z",
"isPlatformManagedCredentials": false,
"credentialsHash": "d41d8cd98f00b204e9800998ecf8427e",
"app": "netlify",
"method": "access-token",
"credentials": {}
}
}
```
</Tab>
</Tabs>

View File

@@ -0,0 +1,161 @@
---
title: "Netlify Sync"
description: "Learn how to configure a Netlify Sync for Infisical."
---
**Prerequisites:**
- Create a [Netlify Connection](/integrations/app-connections/netlify)
<Tabs>
<Tab title="Infisical UI">
<Steps>
<Step title="Add Sync">
Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button.
![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png)
</Step>
<Step title="Select 'Netlify'">
![Select Netlify](/images/secret-syncs/netlify/select-option.png)
</Step>
<Step title="Configure source">
Configure the **Source** from where secrets should be retrieved, then click **Next**.
![Configure Source](/images/secret-syncs/netlify/sync-source.png)
- **Environment**: The project environment to retrieve secrets from.
- **Secret Path**: The folder path to retrieve secrets from.
<Tip>
If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports).
</Tip>
</Step>
<Step title="Configure destination">
Configure the **Destination** to where secrets should be deployed, then click **Next**.
![Configure Destination](/images/secret-syncs/netlify/sync-destination.png)
- **Netlify Connection**: The Netlify Connection to authenticate with.
- **Account**: The Netlify Account to be used.
- **Site**: The Netlify site where secrets should be synced.
- **Context**: The Netlify deployment context where secrets should be created in.
</Step>
<Step title="Configure Sync Options">
Configure the **Sync Options** to specify how secrets should be synced, then click **Next**.
![Configure Options](/images/secret-syncs/netlify/sync-options.png)
- **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync.
- **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical.
- **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Netlify when keys conflict.
- **Import Secrets (Prioritize Netlify)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Netlify over Infisical when keys conflict.
<Note>
Netlify only supports importing non-secret values.
</Note>
- **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment.
<Note>
We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched.
</Note>
- **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only.
- **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical.
</Step>
<Step title="Configure details">
Configure the **Details** of your Netlify Sync, then click **Next**.
![Configure Details](/images/secret-syncs/netlify/sync-details.png)
- **Name**: The name of your sync. Must be slug-friendly.
- **Description**: An optional description for your sync.
</Step>
<Step title="Review configuration">
Review your Netlify Sync configuration, then click **Create Sync**.
![Review Configuration](/images/secret-syncs/netlify/sync-review.png)
</Step>
<Step title="Sync created">
If enabled, your Netlify Sync will begin syncing your secrets to the destination endpoint.
![Sync Created](/images/secret-syncs/netlify/sync-created.png)
</Step>
</Steps>
</Tab>
<Tab title="API">
To create a **Netlify Sync**, make an API request to the [Create Netlify Sync](/api-reference/endpoints/secret-syncs/netlify/create) API endpoint.
### Sample request
```bash Request
curl --request POST \
--url https://app.infisical.com/api/v1/secret-syncs/netlify \
--header 'Content-Type: application/json' \
--data '{
"name": "my-netlify-sync",
"projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"description": "sync to netlify site",
"connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"environment": "production",
"secretPath": "/site-secrets",
"isEnabled": true,
"syncOptions": {
"initialSyncBehavior": "overwrite-destination",
"autoSyncEnabled": true,
"disableSecretDeletion": false
},
"destinationConfig": {
"siteId": "site-abc123",
"accountId": "account-abc123",
"context": "dev"
}
}'
```
### Sample response
```bash Response
{
"secretSync": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"name": "my-netlify-sync",
"description": "sync to netlify site",
"isEnabled": true,
"version": 1,
"folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"createdAt": "2025-07-19T12:00:00Z",
"updatedAt": "2025-07-19T12:00:00Z",
"syncStatus": "succeeded",
"lastSyncJobId": "job-1234",
"lastSyncMessage": null,
"lastSyncedAt": "2025-07-19T12:00:00Z",
"syncOptions": {
"initialSyncBehavior": "overwrite-destination",
"autoSyncEnabled": true,
"disableSecretDeletion": false
},
"projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"connection": {
"app": "netlify",
"name": "my-netlify-connection",
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a"
},
"environment": {
"slug": "production",
"name": "Production",
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a"
},
"folder": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"path": "/site-secrets"
},
"destination": "netlify",
"destinationConfig": {
"siteId": "site-abc123",
"accountId": "account-abc123",
"context": "dev"
}
}
}
```
</Tab>
</Tabs>

View File

@@ -31,7 +31,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) =>
environment: secretSync.environment ?? undefined,
secretPath: secretSync.folder?.path,
description: secretSync.description ?? ""
},
} as Partial<TSecretSyncForm>,
reValidateMode: "onChange"
});

View File

@@ -0,0 +1,144 @@
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { SingleValue } from "react-select";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl } from "@app/components/v2";
import {
TNetlifyAccount,
TNetlifySite,
useNetlifyConnectionListAccounts,
useNetlifyConnectionListSites
} from "@app/hooks/api/appConnections/netlify";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
import { NetlifySyncContext } from "../schemas/netlify-sync-destination-schema";
export const NetlifySyncFields = () => {
const { control, setValue } = useFormContext<
TSecretSyncForm & { destination: SecretSync.Netlify }
>();
const connectionId = useWatch({ name: "connection.id", control });
const accountId = useWatch({ name: "destinationConfig.accountId", control });
const { data: accounts = [], isPending: isAccountsLoading } = useNetlifyConnectionListAccounts(
connectionId,
{
enabled: Boolean(connectionId)
}
);
const { data: sites = [], isPending: isSitesLoading } = useNetlifyConnectionListSites(
connectionId,
accountId,
{
enabled: Boolean(accountId)
}
);
const contexts = Object.entries(NetlifySyncContext).map(([key, value]) => ({
label: key,
value
}));
return (
<>
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.accountId", "");
setValue("destinationConfig.accountName", "");
}}
/>
<Controller
name="destinationConfig.accountId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Account"
isRequired
tooltipClassName="max-w-md"
>
<FilterableSelect
isLoading={isAccountsLoading && Boolean(connectionId)}
isDisabled={!connectionId}
value={accounts.find((p) => p.id === value) ?? null}
onChange={(option) => {
const v = option as SingleValue<TNetlifyAccount>;
onChange(v?.id ?? null);
setValue("destinationConfig.accountName", v?.name ?? "");
}}
options={accounts}
placeholder="Select an account..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
<Controller
name="destinationConfig.siteId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Site"
isOptional
helperText="If you do not select a site, the secrets will be synced to all sites in the account."
tooltipClassName="max-w-md"
>
<FilterableSelect
isLoading={isSitesLoading && Boolean(accountId)}
isDisabled={!accountId}
value={sites.find((p) => p.id === value) ?? null}
onChange={(option) => {
const v = option as SingleValue<TNetlifySite>;
if (v?.id === value) {
onChange(undefined);
setValue("destinationConfig.siteName", undefined);
} else {
onChange(v?.id);
setValue("destinationConfig.siteName", v?.name);
}
}}
options={sites}
placeholder="Select a site..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
<Controller
name="destinationConfig.context"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Context"
isOptional
helperText="If you do not select a context, the secrets will be synced to all contexts."
tooltipClassName="max-w-md"
>
<FilterableSelect
isDisabled={!accountId}
value={contexts.find((p) => p.value === value) ?? undefined}
onChange={(option) => {
const v = option as SingleValue<{ label: string; value: NetlifySyncContext }>;
if (v) onChange(v.value);
}}
options={contexts}
placeholder="Select a context..."
getOptionLabel={(option) => option.label}
getOptionValue={(option) => option.value}
/>
</FormControl>
)}
/>
</>
);
};

View File

@@ -23,6 +23,7 @@ import { GitLabSyncFields } from "./GitLabSyncFields";
import { HCVaultSyncFields } from "./HCVaultSyncFields";
import { HerokuSyncFields } from "./HerokuSyncFields";
import { HumanitecSyncFields } from "./HumanitecSyncFields";
import { NetlifySyncFields } from "./NetlifySyncFields";
import { OCIVaultSyncFields } from "./OCIVaultSyncFields";
import { RailwaySyncFields } from "./RailwaySyncFields";
import { RenderSyncFields } from "./RenderSyncFields";
@@ -95,6 +96,8 @@ export const SecretSyncDestinationFields = () => {
return <SupabaseSyncFields />;
case SecretSync.DigitalOceanAppPlatform:
return <DigitalOceanAppPlatformSyncFields />;
case SecretSync.Netlify:
return <NetlifySyncFields />;
case SecretSync.Bitbucket:
return <BitbucketSyncFields />;
default:

View File

@@ -67,6 +67,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
case SecretSync.Checkly:
case SecretSync.Supabase:
case SecretSync.DigitalOceanAppPlatform:
case SecretSync.Netlify:
case SecretSync.Bitbucket:
AdditionalSyncOptionsFieldsComponent = null;
break;

View File

@@ -0,0 +1,22 @@
import { useFormContext } from "react-hook-form";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const NetlifySyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Netlify }>();
const accountName = watch("destinationConfig.accountName");
const accountId = watch("destinationConfig.accountId");
const siteName = watch("destinationConfig.siteName");
const siteId = watch("destinationConfig.siteId");
const context = watch("destinationConfig.context");
return (
<>
<GenericFieldLabel label="Account">{accountName || accountId}</GenericFieldLabel>
<GenericFieldLabel label="Site">{siteName || siteId || "None"}</GenericFieldLabel>
<GenericFieldLabel label="Context">{context || "None"}</GenericFieldLabel>
</>
);
};

View File

@@ -32,6 +32,7 @@ import { GitLabSyncReviewFields } from "./GitLabSyncReviewFields";
import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields";
import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields";
import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields";
import { NetlifySyncReviewFields } from "./NetlifySyncReviewFields";
import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields";
import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields";
import { RailwaySyncReviewFields } from "./RailwaySyncReviewFields";
@@ -150,6 +151,9 @@ export const SecretSyncReviewFields = () => {
case SecretSync.DigitalOceanAppPlatform:
DestinationFieldsComponent = <DigitalOceanAppPlatformSyncReviewFields />;
break;
case SecretSync.Netlify:
DestinationFieldsComponent = <NetlifySyncReviewFields />;
break;
case SecretSync.Bitbucket:
DestinationFieldsComponent = <BitbucketSyncReviewFields />;
break;

View File

@@ -0,0 +1,26 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export enum NetlifySyncContext {
// All = "all", - Netlify doesn't allow "all" as the context when using is_secret: true
DeployPreview = "deploy-preview",
Production = "production",
BranchDeploy = "branch-deploy",
Dev = "dev",
Branch = "branch"
}
export const NetlifySyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.Netlify),
destinationConfig: z.object({
accountId: z.string(),
accountName: z.string(),
siteId: z.string().optional(),
siteName: z.string().optional(),
context: z.nativeEnum(NetlifySyncContext).optional()
})
})
);

View File

@@ -20,6 +20,7 @@ import { GitlabSyncDestinationSchema } from "./gitlab-sync-destination-schema";
import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema";
import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema";
import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema";
import { NetlifySyncDestinationSchema } from "./netlify-sync-destination-schema";
import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema";
import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema";
import { RenderSyncDestinationSchema } from "./render-sync-destination-schema";
@@ -59,6 +60,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
RailwaySyncDestinationSchema,
ChecklySyncDestinationSchema,
DigitalOceanAppPlatformSyncDestinationSchema,
NetlifySyncDestinationSchema,
BitbucketSyncDestinationSchema
]);

View File

@@ -45,6 +45,7 @@ import { BitbucketConnectionMethod } from "@app/hooks/api/appConnections/types/b
import { ChecklyConnectionMethod } from "@app/hooks/api/appConnections/types/checkly-connection";
import { DigitalOceanConnectionMethod } from "@app/hooks/api/appConnections/types/digital-ocean";
import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection";
import { NetlifyConnectionMethod } from "@app/hooks/api/appConnections/types/netlify-connection";
import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection";
import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection";
import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection";
@@ -105,6 +106,10 @@ export const APP_CONNECTION_MAP: Record<
name: "Digital Ocean",
image: "Digital Ocean.png"
},
[AppConnection.Netlify]: {
name: "Netlify",
image: "Netlify.png"
},
[AppConnection.Okta]: { name: "Okta", image: "Okta.png" }
};
@@ -152,6 +157,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
case AzureDevOpsConnectionMethod.AccessToken:
case WindmillConnectionMethod.AccessToken:
case FlyioConnectionMethod.AccessToken:
case NetlifyConnectionMethod.AccessToken:
return { name: "Access Token", icon: faKey };
case Auth0ConnectionMethod.ClientCredentials:
return { name: "Client Credentials", icon: faServer };

View File

@@ -106,6 +106,10 @@ export const SECRET_SYNC_MAP: Record<SecretSync, { name: string; image: string }
name: "Digital Ocean App Platform",
image: "Digital Ocean.png"
},
[SecretSync.Netlify]: {
name: "Netlify",
image: "Netlify.png"
},
[SecretSync.Bitbucket]: {
name: "Bitbucket",
image: "Bitbucket.png"
@@ -141,6 +145,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.Railway]: AppConnection.Railway,
[SecretSync.Checkly]: AppConnection.Checkly,
[SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean,
[SecretSync.Netlify]: AppConnection.Netlify,
[SecretSync.Bitbucket]: AppConnection.Bitbucket
};

View File

@@ -34,5 +34,6 @@ export enum AppConnection {
Checkly = "checkly",
Supabase = "supabase",
DigitalOcean = "digital-ocean",
Netlify = "netlify",
Okta = "okta"
}

View File

@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./types";

View File

@@ -0,0 +1,65 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "@app/hooks/api/appConnections";
import { TNetlifyAccount, TNetlifySite } from "./types";
const netlifyConnectionKeys = {
all: [...appConnectionKeys.all, "netlify"] as const,
listAccounts: (connectionId: string) =>
[...netlifyConnectionKeys.all, "accounts", connectionId] as const,
listSites: (connectionId: string, accountId: string) =>
[...netlifyConnectionKeys.all, "sites", connectionId, accountId] as const
};
export const useNetlifyConnectionListAccounts = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TNetlifyAccount[],
unknown,
TNetlifyAccount[],
ReturnType<typeof netlifyConnectionKeys.listAccounts>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: netlifyConnectionKeys.listAccounts(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<{ accounts: TNetlifyAccount[] }>(
`/api/v1/app-connections/netlify/${connectionId}/accounts`
);
return data.accounts;
},
...options
});
};
export const useNetlifyConnectionListSites = (
connectionId: string,
accountId: string,
options?: Omit<
UseQueryOptions<
TNetlifySite[],
unknown,
TNetlifySite[],
ReturnType<typeof netlifyConnectionKeys.listSites>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: netlifyConnectionKeys.listSites(connectionId, accountId),
queryFn: async () => {
const { data } = await apiRequest.get<{ sites: TNetlifySite[] }>(
`/api/v1/app-connections/netlify/${connectionId}/accounts/${accountId}/sites`
);
return data.sites;
},
...options
});
};

View File

@@ -0,0 +1,9 @@
export type TNetlifyAccount = {
id: string;
name: string;
};
export type TNetlifySite = {
id: string;
name: string;
};

View File

@@ -156,6 +156,10 @@ export type TDigitalOceanConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.DigitalOcean;
};
export type TNetlifyConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Netlify;
};
export type TOktaConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Okta;
};
@@ -193,6 +197,7 @@ export type TAppConnectionOption =
| TRailwayConnectionOption
| TChecklyConnectionOption
| TDigitalOceanConnectionOption
| TNetlifyConnectionOption
| TOktaConnectionOption;
export type TAppConnectionOptionMap = {
@@ -231,5 +236,6 @@ export type TAppConnectionOptionMap = {
[AppConnection.Checkly]: TChecklyConnectionOption;
[AppConnection.Supabase]: TSupabaseConnectionOption;
[AppConnection.DigitalOcean]: TDigitalOceanConnectionOption;
[AppConnection.Netlify]: TNetlifyConnectionOption;
[AppConnection.Okta]: TOktaConnectionOption;
};

View File

@@ -24,6 +24,7 @@ import { THumanitecConnection } from "./humanitec-connection";
import { TLdapConnection } from "./ldap-connection";
import { TMsSqlConnection } from "./mssql-connection";
import { TMySqlConnection } from "./mysql-connection";
import { TNetlifyConnection } from "./netlify-connection";
import { TOCIConnection } from "./oci-connection";
import { TOktaConnection } from "./okta-connection";
import { TOracleDBConnection } from "./oracledb-connection";
@@ -109,6 +110,7 @@ export type TAppConnection =
| TChecklyConnection
| TSupabaseConnection
| TDigitalOceanConnection
| TNetlifyConnection
| TOktaConnection;
export type TAvailableAppConnection = Pick<TAppConnection, "name" | "id">;
@@ -181,5 +183,6 @@ export type TAppConnectionMap = {
[AppConnection.Checkly]: TChecklyConnection;
[AppConnection.Supabase]: TSupabaseConnection;
[AppConnection.DigitalOcean]: TDigitalOceanConnection;
[AppConnection.Netlify]: TNetlifyConnection;
[AppConnection.Okta]: TOktaConnection;
};

View File

@@ -0,0 +1,14 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum NetlifyConnectionMethod {
AccessToken = "access-token"
}
export type TNetlifyConnection = TRootAppConnection & {
app: AppConnection.Netlify;
method: NetlifyConnectionMethod.AccessToken;
credentials: {
accessToken: string;
};
};

View File

@@ -27,6 +27,7 @@ export enum SecretSync {
Railway = "railway",
Checkly = "checkly",
DigitalOceanAppPlatform = "digital-ocean-app-platform",
Netlify = "netlify",
Bitbucket = "bitbucket"
}

View File

@@ -21,6 +21,7 @@ import { TGitLabSync } from "./gitlab-sync";
import { THCVaultSync } from "./hc-vault-sync";
import { THerokuSync } from "./heroku-sync";
import { THumanitecSync } from "./humanitec-sync";
import { TNetlifySync } from "./netlify-sync";
import { TOCIVaultSync } from "./oci-vault-sync";
import { TRailwaySync } from "./railway-sync";
import { TRenderSync } from "./render-sync";
@@ -67,6 +68,7 @@ export type TSecretSync =
| TChecklySync
| TSupabaseSync
| TDigitalOceanAppPlatformSync
| TNetlifySync
| TBitbucketSync;
export type TListSecretSyncs = { secretSyncs: TSecretSync[] };

View File

@@ -0,0 +1,20 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export type TNetlifySync = TRootSecretSync & {
destination: SecretSync.Netlify;
destinationConfig: {
accountId: string;
accountName: string;
siteId?: string;
siteName?: string;
context?: string;
};
connection: {
app: AppConnection.Netlify;
name: string;
id: string;
};
};

View File

@@ -33,6 +33,7 @@ import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
import { LdapConnectionForm } from "./LdapConnectionForm";
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
import { MySqlConnectionForm } from "./MySqlConnectionForm";
import { NetlifyConnectionForm } from "./NetlifyConnectionForm";
import { OCIConnectionForm } from "./OCIConnectionForm";
import { OktaConnectionForm } from "./OktaConnectionForm";
import { OracleDBConnectionForm } from "./OracleDBConnectionForm";
@@ -153,6 +154,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <SupabaseConnectionForm onSubmit={onSubmit} />;
case AppConnection.DigitalOcean:
return <DigitalOceanConnectionForm onSubmit={onSubmit} />;
case AppConnection.Netlify:
return <NetlifyConnectionForm onSubmit={onSubmit} />;
case AppConnection.Okta:
return <OktaConnectionForm onSubmit={onSubmit} />;
default:

View File

@@ -0,0 +1,136 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
NetlifyConnectionMethod,
TNetlifyConnection
} from "@app/hooks/api/appConnections/types/netlify-connection";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TNetlifyConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.Netlify)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(NetlifyConnectionMethod.AccessToken),
credentials: z.object({
accessToken: z.string().trim().min(1, "Netlify Access Token required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const NetlifyConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Netlify,
method: NetlifyConnectionMethod.AccessToken
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The auth method you would like to connect with ${
APP_CONNECTION_MAP[AppConnection.Netlify].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(NetlifyConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.accessToken"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Access Token Value"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Netlify"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};

View File

@@ -0,0 +1,14 @@
import { TNetlifySync } from "@app/hooks/api/secretSyncs/types/netlify-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TNetlifySync;
};
export const NetlifySyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};

View File

@@ -20,6 +20,7 @@ import { GitLabSyncDestinationCol } from "./GitLabSyncDestinationCol";
import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol";
import { HerokuSyncDestinationCol } from "./HerokuSyncDestinationCol";
import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol";
import { NetlifySyncDestinationCol } from "./NetlifySyncDestinationCol";
import { OCIVaultSyncDestinationCol } from "./OCIVaultSyncDestinationCol";
import { RailwaySyncDestinationCol } from "./RailwaySyncDestinationCol";
import { RenderSyncDestinationCol } from "./RenderSyncDestinationCol";
@@ -92,6 +93,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <SupabaseSyncDestinationCol secretSync={secretSync} />;
case SecretSync.DigitalOceanAppPlatform:
return <DigitalOceanAppPlatformSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Netlify:
return <NetlifySyncDestinationCol secretSync={secretSync} />;
case SecretSync.Bitbucket:
return <BitbucketSyncDestinationCol secretSync={secretSync} />;
default:

View File

@@ -178,6 +178,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
primaryText = destinationConfig.appName;
secondaryText = "Digital Ocean App";
break;
case SecretSync.Netlify:
primaryText = destinationConfig.siteName || destinationConfig.siteId || "All Sites";
secondaryText = destinationConfig.accountName || destinationConfig.accountId;
break;
case SecretSync.Bitbucket:
primaryText = destinationConfig.workspaceSlug;
secondaryText = destinationConfig.repositorySlug;

View File

@@ -0,0 +1,22 @@
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TNetlifySync } from "@app/hooks/api/secretSyncs/types/netlify-sync";
type Props = {
secretSync: TNetlifySync;
};
export const NetlifySyncDestinationSection = ({ secretSync }: Props) => {
const { destinationConfig } = secretSync;
return (
<>
<GenericFieldLabel label="Account">
{destinationConfig.accountName || destinationConfig.accountId}
</GenericFieldLabel>
<GenericFieldLabel label="Site">
{destinationConfig.siteName || destinationConfig.siteId || "None"}
</GenericFieldLabel>
<GenericFieldLabel label="Context">{destinationConfig.context || "None"}</GenericFieldLabel>
</>
);
};

View File

@@ -31,6 +31,7 @@ import { GitLabSyncDestinationSection } from "./GitLabSyncDestinationSection";
import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection";
import { HerokuSyncDestinationSection } from "./HerokuSyncDestinationSection";
import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection";
import { NetlifySyncDestinationSection } from "./NetlifySyncDestinationSection";
import { OCIVaultSyncDestinationSection } from "./OCIVaultSyncDestinationSection";
import { RailwaySyncDestinationSection } from "./RailwaySyncDestinationSection";
import { RenderSyncDestinationSection } from "./RenderSyncDestinationSection";
@@ -141,6 +142,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
<DigitalOceanAppPlatformSyncDestinationSection secretSync={secretSync} />
);
break;
case SecretSync.Netlify:
DestinationComponents = <NetlifySyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.Bitbucket:
DestinationComponents = <BitbucketSyncDestinationSection secretSync={secretSync} />;
break;

View File

@@ -69,6 +69,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
case SecretSync.Supabase:
case SecretSync.Checkly:
case SecretSync.DigitalOceanAppPlatform:
case SecretSync.Netlify:
case SecretSync.Bitbucket:
AdditionalSyncOptionsComponent = null;
break;