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