Files
2026-07-13 13:32:57 +08:00

1886 lines
61 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import pLimit from "p-limit";
import { Vercel } from "@vercel/sdk";
import type {
ResponseBodyEnvs,
FilterProjectEnvsResponseBody,
} from "@vercel/sdk/models/filterprojectenvsop";
import type { GetV9ProjectsIdOrNameCustomEnvironmentsEnvironments } from "@vercel/sdk/models/getv9projectsidornamecustomenvironmentsop";
import type { ResponseBodyProjects } from "@vercel/sdk/models/getprojectsop";
import type { Organization, OrganizationIntegration, SecretReference } from "@trigger.dev/database";
import { z } from "zod";
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import { $transaction, prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import type {
SyncEnvVarsMapping,
TriggerEnvironmentType,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import {
shouldSyncEnvVar,
envTypeToVercelTarget,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server";
import {
callVercelWithRecovery,
wrapVercelCallWithRecovery,
VercelSchemas,
} from "./vercelSdkRecovery.server";
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
function normalizeTarget(target: string[] | string | undefined): string[] {
if (Array.isArray(target)) return target.filter(Boolean);
if (typeof target === "string") return [target];
return [];
}
function extractVercelEnvs(response: FilterProjectEnvsResponseBody): ResponseBodyEnvs[] {
if ("envs" in response && Array.isArray(response.envs)) {
return response.envs;
}
return [];
}
function isVercelSecretType(type: string): boolean {
return type === "secret" || type === "sensitive";
}
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
export type VercelApiError = {
message: string;
authInvalid: boolean;
};
const VercelErrorSchema = z.union([
z.object({ status: z.number() }),
z.object({ response: z.object({ status: z.number() }) }),
z.object({ statusCode: z.number() }),
]);
function extractVercelErrorStatus(error: unknown): number | null {
if (error && typeof error === "object" && "status" in error) {
const parsed = VercelErrorSchema.safeParse(error);
if (parsed.success && "status" in parsed.data) {
return parsed.data.status;
}
}
if (error && typeof error === "object" && "response" in error) {
const parsed = VercelErrorSchema.safeParse(error);
if (parsed.success && "response" in parsed.data) {
return parsed.data.response.status;
}
}
if (error && typeof error === "object" && "statusCode" in error) {
const parsed = VercelErrorSchema.safeParse(error);
if (parsed.success && "statusCode" in parsed.data) {
return parsed.data.statusCode;
}
}
if (typeof error === "string") {
if (error.includes("401")) return 401;
if (error.includes("403")) return 403;
}
return null;
}
function isVercelAuthError(error: unknown): boolean {
const status = extractVercelErrorStatus(error);
return status === 401 || status === 403;
}
function toVercelApiError(error: unknown): VercelApiError {
if (isVercelApiErrorShape(error)) return error;
return {
message: error instanceof Error ? error.message : "Unknown error",
authInvalid: isVercelAuthError(error),
};
}
function isVercelApiErrorShape(error: unknown): error is VercelApiError {
return (
error !== null &&
typeof error === "object" &&
"message" in error &&
"authInvalid" in error &&
typeof (error as VercelApiError).message === "string" &&
typeof (error as VercelApiError).authInvalid === "boolean"
);
}
// ---------------------------------------------------------------------------
// Schemas & token types
// ---------------------------------------------------------------------------
export const VercelSecretSchema = z.object({
accessToken: z.string(),
tokenType: z.string().optional(),
teamId: z.string().nullable().optional(),
userId: z.string().optional(),
installationId: z.string().optional(),
raw: z.record(z.any()).optional(),
});
export type VercelSecret = z.infer<typeof VercelSecretSchema>;
export type TokenResponse = {
accessToken: string;
tokenType: string;
teamId?: string;
userId?: string;
raw: Record<string, unknown>;
};
// ---------------------------------------------------------------------------
// Domain types narrowed from Vercel SDK response types.
//
// Using Pick and indexed-access types ties these definitions to the SDK so
// that any upstream type change surfaces as a compile error here rather than
// silently breaking at runtime.
// ---------------------------------------------------------------------------
/** Narrowed env-var type from the SDK's FilterProjectEnvs response. */
export type VercelEnvironmentVariable = {
id: string; // narrowed from ResponseBodyEnvs["id"] (string | undefined)
key: ResponseBodyEnvs["key"];
type: ResponseBodyEnvs["type"];
isSecret: boolean;
target: string[];
isShared?: boolean;
customEnvironmentIds: string[];
};
/** Narrowed custom-environment type only the fields we consume. */
export type VercelCustomEnvironment = Pick<
GetV9ProjectsIdOrNameCustomEnvironmentsEnvironments,
"id" | "slug" | "description" | "branchMatcher"
>;
/** Narrowed env-var-with-value type from the SDK's FilterProjectEnvs response. */
export type VercelEnvironmentVariableValue = {
key: ResponseBodyEnvs["key"];
value: string; // narrowed from ResponseBodyEnvs["value"] only present after null-check
target: string[];
type: ResponseBodyEnvs["type"];
isSecret: boolean;
};
/** Minimal shape of a shared (team-level) env var record from `GET /v1/env`. */
const RawSharedEnvVarSchema = z
.object({
id: z.string().optional(),
key: z.string().optional(),
type: z.string().optional(),
target: z.union([z.array(z.string()), z.string()]).optional(),
value: z.string().optional(),
applyToAllCustomEnvironments: z.boolean().optional(),
})
.passthrough();
type RawSharedEnvVar = z.infer<typeof RawSharedEnvVarSchema>;
/** Page shape of `GET /v1/env` (shared env vars), validated at the boundary. */
const SharedEnvPageSchema = z.object({
data: z.array(RawSharedEnvVarSchema).default([]),
pagination: z.object({ next: z.number().nullish() }).nullish(),
});
/** Narrowed Vercel project type only id and name. */
export type VercelProject = Pick<ResponseBodyProjects, "id" | "name">;
// ---------------------------------------------------------------------------
// Mapper functions narrow wide SDK responses into our domain types.
// ---------------------------------------------------------------------------
function toVercelEnvironmentVariable(env: ResponseBodyEnvs): VercelEnvironmentVariable {
return {
id: env.id ?? "",
key: env.key,
type: env.type,
isSecret: isVercelSecretType(env.type),
target: normalizeTarget(env.target),
customEnvironmentIds: env.customEnvironmentIds ?? [],
};
}
function toVercelCustomEnvironment({
id,
slug,
description,
branchMatcher,
}: GetV9ProjectsIdOrNameCustomEnvironmentsEnvironments): VercelCustomEnvironment {
return { id, slug, description, branchMatcher };
}
function toVercelEnvironmentVariableValue(
env: ResponseBodyEnvs
): VercelEnvironmentVariableValue | null {
if (!env.value) return null;
return {
key: env.key,
value: env.value,
target: normalizeTarget(env.target),
type: env.type,
isSecret: isVercelSecretType(env.type),
};
}
// ---------------------------------------------------------------------------
// Repository
// ---------------------------------------------------------------------------
export class VercelIntegrationRepository {
static exchangeCodeForToken(code: string): ResultAsync<TokenResponse, VercelApiError> {
const clientId = env.VERCEL_INTEGRATION_CLIENT_ID;
const clientSecret = env.VERCEL_INTEGRATION_CLIENT_SECRET;
const redirectUri = `${env.APP_ORIGIN}/vercel/callback`;
if (!clientId || !clientSecret) {
logger.error("Vercel integration not configured");
return errAsync({ message: "Vercel integration not configured", authInvalid: false });
}
return ResultAsync.fromPromise(
fetch("https://api.vercel.com/v2/oauth/access_token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: redirectUri,
}),
}).then(async (response) => {
if (!response.ok) {
const errorText = await response.text();
logger.error("Failed to exchange Vercel OAuth code", {
status: response.status,
error: errorText,
});
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
return response.json() as Promise<{
access_token: string;
token_type: string;
team_id?: string;
user_id?: string;
}>;
}),
(error) => {
logger.error("Error exchanging Vercel OAuth code", { error });
return toVercelApiError(error);
}
).map(
(data): TokenResponse => ({
accessToken: data.access_token,
tokenType: data.token_type,
teamId: data.team_id,
userId: data.user_id,
raw: data as Record<string, unknown>,
})
);
}
static getVercelClient(
integration: OrganizationIntegration & { tokenReference: SecretReference }
): ResultAsync<Vercel, VercelApiError> {
return this.getVercelClientAndToken(integration).map(({ client }) => client);
}
/**
* Resolve both the Vercel SDK client and the raw bearer token. The raw token
* is needed to paginate shared env vars via `fetch`, since the SDK's
* `listSharedEnvVariable` exposes no `until` cursor param.
*/
static getVercelClientAndToken(
integration: OrganizationIntegration & { tokenReference: SecretReference }
): ResultAsync<{ client: Vercel; accessToken: string }, VercelApiError> {
return ResultAsync.fromPromise(
(async () => {
const secretStore = getSecretStore(integration.tokenReference.provider);
const secret = await secretStore.getSecret(
VercelSecretSchema,
integration.tokenReference.key
);
if (!secret) {
throw new Error("Failed to get Vercel access token");
}
return {
client: new Vercel({ bearerToken: secret.accessToken }),
accessToken: secret.accessToken,
};
})(),
(error) => toVercelApiError(error)
);
}
static getTeamSlug(client: Vercel, teamId: string | null): ResultAsync<string, VercelApiError> {
if (teamId) {
return wrapVercelCallWithRecovery(
client.teams.getTeam({ teamId }),
VercelSchemas.getTeam,
"Failed to fetch Vercel team",
{ teamId },
toVercelApiError
).map((response) => response.slug);
}
return wrapVercelCallWithRecovery(
client.user.getAuthUser(),
VercelSchemas.getAuthUser,
"Failed to fetch Vercel user",
{},
toVercelApiError
).map((response) => response?.user.username ?? "unknown");
}
static validateVercelToken(
integration: OrganizationIntegration & { tokenReference: SecretReference }
): ResultAsync<{ isValid: boolean }, VercelApiError> {
return this.getVercelClient(integration)
.andThen((client) =>
callVercelWithRecovery(client.user.getAuthUser(), VercelSchemas.getAuthUser, {
context: "validateVercelToken",
}).mapErr(toVercelApiError)
)
.map(() => ({ isValid: true }))
.orElse((error) => (error.authInvalid ? okAsync({ isValid: false }) : errAsync(error)));
}
static async getTeamIdFromIntegration(
integration: OrganizationIntegration & { tokenReference: SecretReference }
): Promise<string | null> {
const secretStore = getSecretStore(integration.tokenReference.provider);
const secret = await secretStore.getSecret(VercelSecretSchema, integration.tokenReference.key);
if (!secret) {
return null;
}
return secret.teamId ?? null;
}
static getVercelIntegrationConfiguration(
accessToken: string,
configurationId: string,
teamId?: string | null
): ResultAsync<
{
id: string;
teamId: string | null;
projects: string[];
},
VercelApiError
> {
return ResultAsync.fromPromise(
fetch(
`https://api.vercel.com/v1/integrations/configuration/${configurationId}${teamId ? `?teamId=${teamId}` : ""}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
}
).then(async (response) => {
if (!response.ok) {
const errorText = await response.text();
logger.error("Failed to fetch Vercel integration configuration", {
status: response.status,
error: errorText,
configurationId,
teamId,
});
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
return response.json() as Promise<{
id: string;
teamId?: string | null;
projects?: string[];
[key: string]: any;
}>;
}),
(error) => {
logger.error("Error fetching Vercel integration configuration", {
configurationId,
teamId,
error,
});
return toVercelApiError(error);
}
).map((data) => ({
id: data.id,
teamId: data.teamId ?? null,
projects: data.projects || [],
}));
}
static getVercelCustomEnvironments(
client: Vercel,
projectId: string,
teamId?: string | null
): ResultAsync<VercelCustomEnvironment[], VercelApiError> {
return wrapVercelCallWithRecovery(
client.environment.getV9ProjectsIdOrNameCustomEnvironments({
idOrName: projectId,
...(teamId && { teamId }),
}),
VercelSchemas.getCustomEnvironments,
"Failed to fetch Vercel custom environments",
{ projectId, teamId },
toVercelApiError
).map((response) => (response.environments || []).map(toVercelCustomEnvironment));
}
static getVercelEnvironmentVariables(
client: Vercel,
projectId: string,
teamId?: string | null
): ResultAsync<VercelEnvironmentVariable[], VercelApiError> {
return wrapVercelCallWithRecovery(
client.projects.filterProjectEnvs({
idOrName: projectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
"Failed to fetch Vercel environment variables",
{ projectId, teamId },
toVercelApiError
).map((response) => {
// Warn if response is paginated (more data exists that we're not fetching)
if (
"pagination" in response &&
response.pagination &&
"next" in response.pagination &&
response.pagination.next !== null
) {
logger.warn(
"Vercel filterProjectEnvs returned paginated response - some env vars may be missing",
{ projectId, count: response.pagination.count }
);
}
return extractVercelEnvs(response).map(toVercelEnvironmentVariable);
});
}
static getVercelEnvironmentVariableValues(
client: Vercel,
projectId: string,
teamId?: string | null,
target?: string,
/** If provided, only include keys that pass this filter */
shouldIncludeKey?: (key: string) => boolean
): ResultAsync<VercelEnvironmentVariableValue[], VercelApiError> {
return wrapVercelCallWithRecovery(
client.projects.filterProjectEnvs({
idOrName: projectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
"Failed to fetch Vercel environment variable values",
{ projectId, teamId, target },
toVercelApiError
).andThen((response) => {
// Apply all filters BEFORE decryption to avoid unnecessary API calls
const filteredEnvs = extractVercelEnvs(response).filter((env) => {
if (target && !normalizeTarget(env.target).includes(target)) return false;
if (shouldIncludeKey && !shouldIncludeKey(env.key)) return false;
if (isVercelSecretType(env.type)) return false;
return true;
});
// Fetch decrypted values for encrypted vars, use list values for others
const concurrencyLimit = pLimit(5);
return ResultAsync.fromPromise(
Promise.all(
filteredEnvs.map((env) =>
concurrencyLimit(() => this.#resolveEnvVarValue(client, projectId, teamId, env))
)
),
(error) => toVercelApiError(error)
).map((results) => results.filter((v): v is VercelEnvironmentVariableValue => v !== null));
});
}
static async #resolveEnvVarValue(
client: Vercel,
projectId: string,
teamId: string | null | undefined,
env: ResponseBodyEnvs
): Promise<VercelEnvironmentVariableValue | null> {
// Non-encrypted vars: use value from list response if present
if (env.type !== "encrypted" || !env.id) {
if (env.value === undefined || env.value === null) return null;
return toVercelEnvironmentVariableValue(env);
}
// Encrypted vars: fetch decrypted value via individual endpoint
// (list endpoint's decrypt param is deprecated)
const result = await callVercelWithRecovery(
client.projects.getProjectEnv({
idOrName: projectId,
id: env.id,
...(teamId && { teamId }),
}),
VercelSchemas.getProjectEnv,
{ context: "resolveEnvVarValue" }
);
if (result.isErr()) {
logger.warn("Failed to decrypt Vercel env var", {
projectId,
envVarKey: env.key,
error: result.error instanceof Error ? result.error.message : String(result.error),
});
return null;
}
// API returns union: ResponseBody1 has no value, ResponseBody2/3 have value
const decryptedValue = (result.value as { value?: string }).value;
if (typeof decryptedValue !== "string") return null;
return {
key: env.key,
value: decryptedValue,
target: normalizeTarget(env.target),
type: env.type,
isSecret: false,
};
}
/**
* Fetch ALL shared (team-level) env var records, following pagination.
*
* Unlike the project env endpoint, the shared endpoint (`/v1/env`) DOES
* paginate (≈25/page) and the SDK's `listSharedEnvVariable` exposes no cursor
* param — so we walk pages via a raw fetch using `pagination.next` (a
* millisecond-timestamp cursor) until it is null. Shared vars are an edge
* case, so we load every page up front and return the full set.
*/
static #fetchAllSharedEnvsRaw(params: {
accessToken: string;
teamId: string;
projectId?: string;
}): ResultAsync<RawSharedEnvVar[], VercelApiError> {
const { accessToken, teamId, projectId } = params;
return ResultAsync.fromPromise(
(async () => {
const all: RawSharedEnvVar[] = [];
let until: number | undefined = undefined;
const MAX_PAGES = 200; // safety cap (1000-var ceiling / ~25 per page)
for (let page = 0; page < MAX_PAGES; page++) {
const url = new URL("https://api.vercel.com/v1/env");
url.searchParams.set("teamId", teamId);
if (projectId) url.searchParams.set("projectId", projectId);
if (until !== undefined) url.searchParams.set("until", String(until));
const response = await fetch(url.toString(), {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
const body = await response.text().catch(() => "");
const error = new Error(
`Failed to fetch Vercel shared environment variables: ${response.status} ${response.statusText}${body}`
) as Error & { status?: number };
error.status = response.status;
throw error;
}
const json = SharedEnvPageSchema.parse(await response.json());
all.push(...json.data);
// `next` is a millisecond-timestamp cursor; treat 0/null/undefined as "done".
const next = json.pagination?.next;
if (!next) break;
until = next;
if (page === MAX_PAGES - 1) {
logger.warn("Vercel shared env var pagination hit max page cap", { teamId, projectId });
}
}
return all;
})(),
(error) => toVercelApiError(error)
);
}
static getVercelSharedEnvironmentVariables(
accessToken: string,
teamId: string,
projectId?: string // Optional: filter by project
): ResultAsync<
Array<{
id: string;
key: string;
type: string;
isSecret: boolean;
target: string[];
}>,
VercelApiError
> {
return this.#fetchAllSharedEnvsRaw({ accessToken, teamId, projectId }).map((envVars) => {
return envVars
.filter(
(env): env is RawSharedEnvVar & { id: string; key: string } =>
typeof env.id === "string" && typeof env.key === "string"
)
.map((env) => {
const type = env.type || "plain";
return {
id: env.id,
key: env.key,
type,
isSecret: isVercelSecretType(type),
target: normalizeTarget(env.target),
};
});
});
}
static getVercelSharedEnvironmentVariableValues(
client: Vercel,
accessToken: string,
teamId: string,
projectId?: string // Optional: filter by project
): ResultAsync<
Array<{
key: string;
value: string;
target: string[];
type: string;
isSecret: boolean;
applyToAllCustomEnvironments?: boolean;
}>,
VercelApiError
> {
return this.#fetchAllSharedEnvsRaw({ accessToken, teamId, projectId }).andThen((envVars) => {
if (envVars.length === 0) {
return okAsync([]);
}
const concurrencyLimit = pLimit(5);
return ResultAsync.fromPromise(
Promise.all(
envVars.map((env) =>
concurrencyLimit(async () => {
if (!env.id || !env.key) return null;
const envId = env.id;
const envKey = env.key;
const type = env.type || "plain";
const isSecret = isVercelSecretType(type);
if (isSecret) return null;
const listValue = env.value;
const applyToAllCustomEnvs = env.applyToAllCustomEnvironments;
if (listValue) {
return {
key: envKey,
value: listValue,
target: normalizeTarget(env.target),
type,
isSecret,
applyToAllCustomEnvironments: applyToAllCustomEnvs,
};
}
// Try to get the decrypted value for this shared env var
const getResult = await callVercelWithRecovery(
client.environment.getSharedEnvVar({
id: envId,
teamId,
}),
VercelSchemas.getSharedEnvVar,
{ context: "getSharedEnvVar" }
);
if (getResult.isOk()) {
if (!getResult.value.value) return null;
return {
key: envKey,
value: getResult.value.value,
target: normalizeTarget(env.target),
type,
isSecret,
applyToAllCustomEnvironments: applyToAllCustomEnvs,
};
}
logger.warn("Failed to get decrypted value for shared env var", {
teamId,
projectId,
envId,
envKey,
error:
getResult.error instanceof Error
? getResult.error.message
: String(getResult.error),
});
return null;
})
)
),
(error) => {
logger.error("Failed to process shared environment variable values", {
teamId,
projectId,
error: error instanceof Error ? error.message : String(error),
});
return toVercelApiError(error);
}
).map((results) => results.filter((r): r is NonNullable<typeof r> => r !== null));
});
}
static getVercelProjects(
client: Vercel,
teamId?: string | null
): ResultAsync<VercelProject[], VercelApiError> {
return ResultAsync.fromPromise(
(async () => {
const allProjects: VercelProject[] = [];
let from: string | undefined;
do {
const response = await callVercelWithRecovery(
client.projects.getProjects({
...(teamId && { teamId }),
limit: "100",
...(from && { from }),
}),
VercelSchemas.getProjects,
{ context: "getVercelProjects" }
).match(
(val) => val,
(err) => {
throw err;
}
);
const projects = Array.isArray(response)
? response
: "projects" in response
? response.projects
: [];
allProjects.push(...projects.map(({ id, name }): VercelProject => ({ id, name })));
// Get pagination token for next page
const pagination =
!Array.isArray(response) && "pagination" in response ? response.pagination : undefined;
from =
pagination && "next" in pagination && pagination.next !== null
? String(pagination.next)
: undefined;
} while (from);
return allProjects;
})(),
(error) => {
logger.error("Failed to fetch Vercel projects", { teamId, error });
return toVercelApiError(error);
}
);
}
static async updateVercelOrgIntegrationToken(params: {
integrationId: string;
accessToken: string;
tokenType?: string;
teamId: string | null;
userId?: string;
installationId?: string;
raw?: Record<string, any>;
}): Promise<void> {
await $transaction(prisma, async (tx) => {
const integration = await tx.organizationIntegration.findUnique({
where: { id: params.integrationId },
include: { tokenReference: true },
});
if (!integration) {
throw new Error("Vercel integration not found");
}
const secretStore = getSecretStore(integration.tokenReference.provider, {
prismaClient: tx,
});
const secretValue: VercelSecret = {
accessToken: params.accessToken,
tokenType: params.tokenType,
teamId: params.teamId,
userId: params.userId,
installationId: params.installationId,
raw: params.raw,
};
await secretStore.setSecret(integration.tokenReference.key, secretValue);
await tx.organizationIntegration.update({
where: { id: params.integrationId },
data: {
integrationData: {
teamId: params.teamId,
userId: params.userId,
installationId: params.installationId,
} as any,
},
});
});
}
static async createVercelOrgIntegration(params: {
accessToken: string;
tokenType?: string;
teamId: string | null;
userId?: string;
installationId?: string;
organization: Pick<Organization, "id">;
raw?: Record<string, any>;
origin: "marketplace" | "dashboard";
}): Promise<OrganizationIntegration> {
const result = await $transaction(prisma, async (tx) => {
const secretStore = getSecretStore("DATABASE", {
prismaClient: tx,
});
const integrationFriendlyId = generateFriendlyId("org_integration");
const secretValue: VercelSecret = {
accessToken: params.accessToken,
tokenType: params.tokenType,
teamId: params.teamId,
userId: params.userId,
installationId: params.installationId,
raw: params.raw,
};
await secretStore.setSecret(integrationFriendlyId, secretValue);
const reference = await tx.secretReference.create({
data: {
provider: "DATABASE",
key: integrationFriendlyId,
},
});
return await tx.organizationIntegration.create({
data: {
friendlyId: integrationFriendlyId,
organizationId: params.organization.id,
service: "VERCEL",
externalOrganizationId: params.teamId,
tokenReferenceId: reference.id,
integrationData: {
teamId: params.teamId,
userId: params.userId,
installationId: params.installationId,
origin: params.origin,
} as any,
},
});
});
if (!result) {
throw new Error("Failed to create Vercel organization integration");
}
return result;
}
static async findVercelOrgIntegrationByTeamId(
organizationId: string,
teamId: string | null
): Promise<(OrganizationIntegration & { tokenReference: SecretReference }) | null> {
return prisma.organizationIntegration.findFirst({
where: {
organizationId,
service: "VERCEL",
externalOrganizationId: teamId,
deletedAt: null,
},
include: {
tokenReference: true,
},
});
}
static async findVercelOrgIntegrationForProject(
projectId: string
): Promise<(OrganizationIntegration & { tokenReference: SecretReference }) | null> {
const projectIntegration = await prisma.organizationProjectIntegration.findFirst({
where: {
projectId,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
include: {
organizationIntegration: {
include: {
tokenReference: true,
},
},
},
});
return projectIntegration?.organizationIntegration ?? null;
}
static async findVercelOrgIntegrationByOrganization(
organizationId: string
): Promise<(OrganizationIntegration & { tokenReference: SecretReference }) | null> {
return prisma.organizationIntegration.findFirst({
where: {
organizationId,
service: "VERCEL",
deletedAt: null,
},
include: {
tokenReference: true,
},
});
}
static syncApiKeysToVercel(params: {
projectId: string;
vercelProjectId: string;
teamId: string | null;
vercelStagingEnvironment?: { environmentId: string; displayName: string } | null;
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
}): ResultAsync<{ created: number; updated: number; errors: string[] }, VercelApiError> {
return this.getVercelClient(params.orgIntegration).andThen((client) =>
ResultAsync.fromPromise(
(async () => {
// Get all environments for the project (exclude DEVELOPMENT — we don't push keys to Vercel's development target)
const environments = await prisma.runtimeEnvironment.findMany({
where: {
projectId: params.projectId,
type: {
in: ["PRODUCTION", "STAGING", "PREVIEW"],
},
},
select: {
id: true,
type: true,
apiKey: true,
},
});
// Build the list of env vars to sync
const envVarsToSync: Array<{
key: string;
value: string;
target: string[];
type: "sensitive" | "encrypted" | "plain";
environmentType: string;
}> = [];
for (const runtimeEnv of environments) {
const vercelTarget = envTypeToVercelTarget(
runtimeEnv.type as TriggerEnvironmentType,
params.vercelStagingEnvironment?.environmentId
);
if (!vercelTarget) {
continue;
}
envVarsToSync.push({
key: "TRIGGER_SECRET_KEY",
value: runtimeEnv.apiKey,
target: vercelTarget,
type: "sensitive",
environmentType: runtimeEnv.type,
});
}
if (envVarsToSync.length === 0) {
return { created: 0, updated: 0, errors: [] as string[] };
}
await this.removeAllVercelEnvVarsByKey({
client,
vercelProjectId: params.vercelProjectId,
teamId: params.teamId,
key: "TRIGGER_SECRET_KEY",
});
const result = await this.batchUpsertVercelEnvVars({
client,
vercelProjectId: params.vercelProjectId,
teamId: params.teamId,
envVars: envVarsToSync,
});
logger.info("Synced API keys to Vercel", {
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
syncedCount: result.created + result.updated,
created: result.created,
updated: result.updated,
errors: result.errors,
});
return result;
})(),
(error) => {
logger.error("Failed to sync API keys to Vercel", {
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
error,
});
return toVercelApiError(error);
}
)
);
}
static syncSingleApiKeyToVercel(params: {
projectId: string;
environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT";
apiKey: string;
}): ResultAsync<void, VercelApiError> {
return ResultAsync.fromPromise(
(async () => {
const projectIntegration = await prisma.organizationProjectIntegration.findFirst({
where: {
projectId: params.projectId,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
include: {
organizationIntegration: {
include: {
tokenReference: true,
},
},
},
});
if (!projectIntegration) {
return; // No integration, nothing to sync
}
const orgIntegration = projectIntegration.organizationIntegration;
const clientResult = await this.getVercelClient(orgIntegration);
if (clientResult.isErr()) throw clientResult.error;
const client = clientResult.value;
const teamId = await this.getTeamIdFromIntegration(orgIntegration);
const integrationData = projectIntegration.integrationData as any;
const vercelStagingEnvironment = integrationData?.config?.vercelStagingEnvironment;
const vercelTarget = envTypeToVercelTarget(
params.environmentType,
vercelStagingEnvironment?.environmentId
);
if (!vercelTarget) {
return;
}
await this.upsertVercelEnvVar({
client,
vercelProjectId: projectIntegration.externalEntityId,
teamId,
key: "TRIGGER_SECRET_KEY",
value: params.apiKey,
target: vercelTarget,
type: "sensitive",
});
logger.info("Synced regenerated API key to Vercel", {
projectId: params.projectId,
vercelProjectId: projectIntegration.externalEntityId,
environmentType: params.environmentType,
target: vercelTarget,
});
})(),
(error) => {
logger.error("Failed to sync API key to Vercel", {
projectId: params.projectId,
environmentType: params.environmentType,
error,
});
return toVercelApiError(error);
}
);
}
static upsertEnvVarForCustomEnvironment(params: {
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
vercelProjectId: string;
teamId: string | null;
key: string;
value: string;
customEnvironmentId: string;
type: "sensitive" | "encrypted" | "plain";
}): ResultAsync<void, VercelApiError> {
return this.getVercelClient(params.orgIntegration).andThen((client) =>
ResultAsync.fromPromise(
(async () => {
const { vercelProjectId, teamId, key, value, customEnvironmentId, type } = params;
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "upsertEnvVarForCustomEnvironment" }
).match(
(val) => val,
(err) => {
throw err;
}
);
const envs = extractVercelEnvs(existingEnvs);
const existingEnv = envs.find((env) => {
if (env.key !== key) return false;
return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
});
// Always delete-then-create rather than editProjectEnv, because Vercel rejects
// in-place type changes (e.g. encrypted -> sensitive).
if (existingEnv && existingEnv.id) {
await client.projects.batchRemoveProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: { ids: [existingEnv.id] },
});
}
await client.projects.createProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: {
key,
value,
type,
customEnvironmentIds: [customEnvironmentId],
} as any,
});
})(),
(error) => toVercelApiError(error)
)
);
}
static removeEnvVarForCustomEnvironment(params: {
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
vercelProjectId: string;
teamId: string | null;
key: string;
customEnvironmentId: string;
}): ResultAsync<void, VercelApiError> {
return this.getVercelClient(params.orgIntegration).andThen((client) =>
ResultAsync.fromPromise(
(async () => {
const { vercelProjectId, teamId, key, customEnvironmentId } = params;
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "removeEnvVarForCustomEnvironment" }
).match(
(val) => val,
(err) => {
throw err;
}
);
const envs = extractVercelEnvs(existingEnvs);
const existingEnv = envs.find((env) => {
if (env.key !== key) return false;
return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
});
if (existingEnv && existingEnv.id) {
await client.projects.batchRemoveProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: { ids: [existingEnv.id] },
});
}
})(),
(error) => toVercelApiError(error)
)
);
}
static pullEnvVarsFromVercel(params: {
projectId: string;
vercelProjectId: string;
teamId: string | null;
vercelStagingEnvironment?: { environmentId: string; displayName: string } | null;
syncEnvVarsMapping: SyncEnvVarsMapping;
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
}): ResultAsync<{ syncedCount: number; errors: string[] }, VercelApiError> {
logger.info("pullEnvVarsFromVercel: Starting", {
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
teamId: params.teamId,
vercelStagingEnvironment: params.vercelStagingEnvironment,
syncEnvVarsMappingKeys: Object.keys(params.syncEnvVarsMapping),
});
return this.getVercelClientAndToken(params.orgIntegration).andThen(({ client, accessToken }) =>
ResultAsync.fromPromise(
(async () => {
const errors: string[] = [];
let syncedCount = 0;
// Get all runtime environments for the project
const runtimeEnvironments = await prisma.runtimeEnvironment.findMany({
where: {
projectId: params.projectId,
type: {
in: ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"],
},
},
select: {
id: true,
type: true,
},
});
const envMapping: Array<{
triggerEnvType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT";
vercelTarget: string;
runtimeEnvironmentId: string;
}> = [];
for (const runtimeEnv of runtimeEnvironments) {
const vercelTarget = envTypeToVercelTarget(
runtimeEnv.type as TriggerEnvironmentType,
params.vercelStagingEnvironment?.environmentId
);
if (!vercelTarget) {
continue;
}
envMapping.push({
triggerEnvType: runtimeEnv.type as
| "PRODUCTION"
| "STAGING"
| "PREVIEW"
| "DEVELOPMENT",
vercelTarget: vercelTarget[0],
runtimeEnvironmentId: runtimeEnv.id,
});
}
if (envMapping.length === 0) {
logger.warn("No environments to sync for Vercel integration", {
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
});
return { syncedCount: 0, errors: [] as string[] };
}
const envVarRepository = new EnvironmentVariablesRepository();
// Fetch shared env vars once (they apply across all targets)
let sharedEnvVars: Array<{
key: string;
value: string;
target: string[];
type: string;
isSecret: boolean;
applyToAllCustomEnvironments?: boolean;
}> = [];
if (params.teamId) {
const sharedResult = await this.getVercelSharedEnvironmentVariableValues(
client,
accessToken,
params.teamId,
params.vercelProjectId
);
sharedEnvVars = sharedResult.unwrapOr([]);
}
// Process each environment mapping
for (const mapping of envMapping) {
const iterResult = await ResultAsync.fromPromise(
(async () => {
// Exclude reserved keys before decrypting (a reserved-only batch gets rejected).
const shouldIncludeKey = (key: string) =>
!isReservedForExternalSync(key) &&
shouldSyncEnvVar(
params.syncEnvVarsMapping,
key,
mapping.triggerEnvType as TriggerEnvironmentType
);
const envVarsResult = await this.getVercelEnvironmentVariableValues(
client,
params.vercelProjectId,
params.teamId,
mapping.vercelTarget,
shouldIncludeKey
);
if (envVarsResult.isErr()) {
logger.warn("pullEnvVarsFromVercel: Failed to get env vars", {
triggerEnvType: mapping.triggerEnvType,
vercelTarget: mapping.vercelTarget,
error: envVarsResult.error.message,
});
errors.push(
`Failed to get env vars for ${mapping.triggerEnvType}: ${envVarsResult.error.message}`
);
return;
}
const projectEnvVars = envVarsResult.value;
const standardTargets = ["production", "preview", "development"];
const isCustomEnvironment = !standardTargets.includes(mapping.vercelTarget);
const filteredSharedEnvVars = sharedEnvVars.filter((envVar) => {
const matchesTarget = envVar.target.includes(mapping.vercelTarget);
const matchesCustomEnv =
isCustomEnvironment && envVar.applyToAllCustomEnvironments === true;
return matchesTarget || matchesCustomEnv;
});
const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key));
const sharedEnvVarsToAdd = filteredSharedEnvVars.filter(
(v) => !projectEnvVarKeys.has(v.key)
);
const mergedEnvVars = [...projectEnvVars, ...sharedEnvVarsToAdd];
if (mergedEnvVars.length === 0) {
return;
}
const varsToSync = mergedEnvVars.filter((envVar) => {
if (envVar.isSecret) {
return false;
}
if (isReservedForExternalSync(envVar.key)) {
return false;
}
return shouldSyncEnvVar(
params.syncEnvVarsMapping,
envVar.key,
mapping.triggerEnvType as TriggerEnvironmentType
);
});
if (varsToSync.length === 0) {
return;
}
const existingSecretKeys = new Set<string>();
const existingValues = new Map<string, string>();
const existingVarValues = await prisma.environmentVariableValue.findMany({
where: {
environmentId: mapping.runtimeEnvironmentId,
variable: {
projectId: params.projectId,
key: {
in: varsToSync.map((v) => v.key),
},
},
},
select: {
isSecret: true,
valueReference: {
select: {
key: true,
},
},
variable: {
select: {
key: true,
},
},
},
});
if (existingVarValues.length > 0) {
const secretStore = getSecretStore("DATABASE", { prismaClient: prisma });
const SecretValue = z.object({ secret: z.string() });
for (const varValue of existingVarValues) {
if (varValue.isSecret) {
existingSecretKeys.add(varValue.variable.key);
}
if (varValue.valueReference?.key) {
const existingSecret = await ResultAsync.fromPromise(
secretStore.getSecret(SecretValue, varValue.valueReference.key),
() => null
).unwrapOr(null);
if (existingSecret) {
existingValues.set(varValue.variable.key, existingSecret.secret);
}
}
}
}
const changedVars = varsToSync.filter((v) => {
const existingValue = existingValues.get(v.key);
return existingValue === undefined || existingValue !== v.value;
});
if (changedVars.length === 0) {
return;
}
const secretVars = changedVars.filter((v) => existingSecretKeys.has(v.key));
const nonSecretVars = changedVars.filter((v) => !existingSecretKeys.has(v.key));
if (nonSecretVars.length > 0) {
const result = await envVarRepository.create(params.projectId, {
override: true,
environmentIds: [mapping.runtimeEnvironmentId],
isSecret: false,
variables: nonSecretVars.map((v) => ({
key: v.key,
value: v.value,
})),
lastUpdatedBy: {
type: "integration",
integration: "vercel",
},
});
if (result.success) {
syncedCount += nonSecretVars.length;
} else {
const errorMsg = `Failed to sync env vars for ${mapping.triggerEnvType}: ${result.error}`;
errors.push(errorMsg);
logger.error(errorMsg, {
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
vercelTarget: mapping.vercelTarget,
error: result.error,
variableErrors: result.variableErrors,
attemptedKeys: nonSecretVars.map((v) => v.key),
});
}
}
if (secretVars.length > 0) {
const result = await envVarRepository.create(params.projectId, {
override: true,
environmentIds: [mapping.runtimeEnvironmentId],
isSecret: true,
variables: secretVars.map((v) => ({
key: v.key,
value: v.value,
})),
lastUpdatedBy: {
type: "integration",
integration: "vercel",
},
});
if (result.success) {
syncedCount += secretVars.length;
} else {
const errorMsg = `Failed to sync secret env vars for ${mapping.triggerEnvType}: ${result.error}`;
errors.push(errorMsg);
logger.error(errorMsg, {
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
vercelTarget: mapping.vercelTarget,
error: result.error,
variableErrors: result.variableErrors,
attemptedKeys: secretVars.map((v) => v.key),
});
}
}
})(),
(error) => error
);
if (iterResult.isErr()) {
const errorMsg = `Failed to process env vars for ${mapping.triggerEnvType}: ${iterResult.error instanceof Error ? iterResult.error.message : "Unknown error"}`;
errors.push(errorMsg);
logger.error(errorMsg, {
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
vercelTarget: mapping.vercelTarget,
error: iterResult.error,
});
}
}
return { syncedCount, errors };
})(),
(error) => {
logger.error("Failed to pull env vars from Vercel", {
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
error,
});
return toVercelApiError(error);
}
)
);
}
static async batchUpsertVercelEnvVars(params: {
client: Vercel;
vercelProjectId: string;
teamId: string | null;
envVars: Array<{
key: string;
value: string;
target: string[];
type: "sensitive" | "encrypted" | "plain";
environmentType?: string; // For logging purposes
}>;
}): Promise<{ created: number; updated: number; errors: string[] }> {
const { client, vercelProjectId, teamId, envVars } = params;
const errors: string[] = [];
let created = 0;
let updated = 0;
if (envVars.length === 0) {
return { created: 0, updated: 0, errors: [] };
}
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "batchUpsertVercelEnvVars" }
).match(
(val) => val,
(err) => {
throw err;
}
);
const existingEnvsList = extractVercelEnvs(existingEnvs);
const toCreate: Array<{
key: string;
value: string;
target: string[];
type: "sensitive" | "encrypted" | "plain";
}> = [];
const toUpdate: Array<{
id: string;
key: string;
value: string;
target: string[];
type: "sensitive" | "encrypted" | "plain";
environmentType?: string;
}> = [];
for (const envVar of envVars) {
const existingEnv = existingEnvsList.find((existing) => {
if (existing.key !== envVar.key) {
return false;
}
const envTargets = normalizeTarget(existing.target);
return (
envVar.target.length === envTargets.length &&
envVar.target.every((t) => envTargets.includes(t))
);
});
if (existingEnv && existingEnv.id) {
toUpdate.push({
id: existingEnv.id,
key: envVar.key,
value: envVar.value,
target: envVar.target,
type: envVar.type,
environmentType: envVar.environmentType,
});
} else {
toCreate.push({
key: envVar.key,
value: envVar.value,
target: envVar.target,
type: envVar.type,
});
}
}
if (toCreate.length > 0) {
const createResult = await ResultAsync.fromPromise(
client.projects.createProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: toCreate.map((item) => ({
key: item.key,
value: item.value,
target: item.target as any,
type: item.type,
})) as any,
}),
(error) => error
);
if (createResult.isOk()) {
created = toCreate.length;
} else {
const errorMsg = `Failed to batch create env vars: ${createResult.error instanceof Error ? createResult.error.message : "Unknown error"}`;
errors.push(errorMsg);
logger.error(errorMsg, {
vercelProjectId,
teamId,
count: toCreate.length,
error: createResult.error,
});
}
}
// Update existing env vars (Vercel doesn't support batch updates)
for (const envVar of toUpdate) {
const updateResult = await ResultAsync.fromPromise(
client.projects.editProjectEnv({
idOrName: vercelProjectId,
id: envVar.id,
...(teamId && { teamId }),
requestBody: {
value: envVar.value,
target: envVar.target as any,
type: envVar.type,
},
}),
(error) => error
);
if (updateResult.isOk()) {
updated++;
} else {
const errorMsg = `Failed to update ${envVar.environmentType || envVar.key} env var: ${updateResult.error instanceof Error ? updateResult.error.message : "Unknown error"}`;
errors.push(errorMsg);
logger.error(errorMsg, {
vercelProjectId,
teamId,
envVarId: envVar.id,
key: envVar.key,
error: updateResult.error,
});
}
}
return { created, updated, errors };
}
private static async removeAllVercelEnvVarsByKey(params: {
client: Vercel;
vercelProjectId: string;
teamId: string | null;
key: string;
}): Promise<void> {
const { client, vercelProjectId, teamId, key } = params;
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "removeAllVercelEnvVarsByKey" }
).match(
(val) => val,
(err) => {
throw err;
}
);
const envs = extractVercelEnvs(existingEnvs);
const idsToRemove = envs.filter((env) => env.key === key && env.id).map((env) => env.id!);
if (idsToRemove.length === 0) {
return;
}
await client.projects.batchRemoveProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: { ids: idsToRemove },
});
}
private static async upsertVercelEnvVar(params: {
client: Vercel;
vercelProjectId: string;
teamId: string | null;
key: string;
value: string;
target: string[];
type: "sensitive" | "encrypted" | "plain";
}): Promise<void> {
const { client, vercelProjectId, teamId, key, value, target, type } = params;
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "upsertVercelEnvVar" }
).match(
(val) => val,
(err) => {
throw err;
}
);
const envs = extractVercelEnvs(existingEnvs);
// Vercel can have multiple env vars with the same key but different targets
const existingEnv = envs.find((existing) => {
if (existing.key !== key) {
return false;
}
const envTargets = normalizeTarget(existing.target);
return target.length === envTargets.length && target.every((t) => envTargets.includes(t));
});
// Always delete-then-create rather than editProjectEnv, because Vercel rejects
// in-place type changes (e.g. encrypted -> sensitive). Same approach used by
// syncApiKeysToVercel via removeAllVercelEnvVarsByKey.
if (existingEnv && existingEnv.id) {
await client.projects.batchRemoveProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: { ids: [existingEnv.id] },
});
}
await client.projects.createProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: {
key,
value,
target: target as any,
type,
},
});
}
static getAutoAssignCustomDomains(
client: Vercel,
vercelProjectId: string,
teamId?: string | null
): ResultAsync<boolean | null, VercelApiError> {
// Vercel SDK lacks a getProject method — updateProject with empty body reads without modifying.
return wrapVercelCallWithRecovery(
client.projects.updateProject({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: {},
}),
VercelSchemas.updateProject,
"Failed to get Vercel project autoAssignCustomDomains",
{ vercelProjectId, teamId },
toVercelApiError
).map((project) => project.autoAssignCustomDomains ?? null);
}
/** Disable autoAssignCustomDomains — required for atomic deployments. */
static disableAutoAssignCustomDomains(
client: Vercel,
vercelProjectId: string,
teamId?: string | null
): ResultAsync<void, VercelApiError> {
return wrapVercelCallWithRecovery(
client.projects.updateProject({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: {
autoAssignCustomDomains: false,
},
}),
VercelSchemas.updateProject,
"Failed to disable autoAssignCustomDomains",
{ vercelProjectId, teamId },
toVercelApiError
).map(() => undefined);
}
static uninstallVercelIntegration(
integration: OrganizationIntegration & { tokenReference: SecretReference }
): ResultAsync<{ authInvalid: boolean }, VercelApiError> {
return this.getVercelClient(integration).andThen((client) =>
ResultAsync.fromPromise(
(async () => {
const secret = await getSecretStore(integration.tokenReference.provider).getSecret(
VercelSecretSchema,
integration.tokenReference.key
);
if (!secret?.installationId) {
throw new Error("Installation ID not found in Vercel integration");
}
return secret.installationId;
})(),
toVercelApiError
).andThen((installationId) =>
ResultAsync.fromPromise(
client.integrations.deleteConfiguration({
id: installationId,
}),
(error) => error
)
.map(() => ({ authInvalid: false }))
.orElse((error) => {
const isAuthError = isVercelAuthError(error);
logger.error("Failed to uninstall Vercel integration", {
installationId,
error: error instanceof Error ? error.message : "Unknown error",
isAuthError,
});
// Auth errors (401/403): still clean up on our side, return flag for caller
if (isAuthError) {
return okAsync({ authInvalid: true });
}
return errAsync(toVercelApiError(error));
})
)
);
}
}