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 <[email protected]>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: x032205 <[email protected]>
This commit is contained in:
Sid
2025-08-01 00:24:40 +05:30
committed by GitHub
co-authored by greptile-apps[bot] sidwebworks x032205
parent 3877fe524d
commit 79de7c5f08
90 changed files with 1747 additions and 2 deletions
@@ -0,0 +1,4 @@
export * from "./netlify-connection-constants";
export * from "./netlify-connection-fns";
export * from "./netlify-connection-schemas";
export * from "./netlify-connection-types";
@@ -0,0 +1,3 @@
export enum NetlifyConnectionMethod {
AccessToken = "access-token"
}
@@ -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;
};
@@ -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();
@@ -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()
});
@@ -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
};
};
@@ -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;
};