Files
triggerdotdev--trigger.dev/apps/webapp/app/models/vercelIntegration.server.ts
Oskar Otwinowski 910011d44e feat(vercel): automatic version skew protection at connect + atomic deployments deprecation (#4741)
Connecting a Vercel project now writes
TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION=1
(plain, create-if-absent only - an existing value, including "0", is
never
touched; presence is target-containment aware, branch-scoped records do
not
count, a truncated env listing skips the write). The onboarding wizard
no
longer offers automatic atomic deployments (default off); the settings
row is
labelled Deprecated and enabling it requires confirming a dialog that
points
to task version skew protection and the docs (TRI-13001).
2026-08-21 18:09:27 +02:00

2115 lines
68 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,
isVercelStandardTarget,
SKEW_PROTECTION_ENV_VAR_KEY,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server";
import { boundedIn } from "@trigger.dev/database";
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 readProjectEnvs(
response: unknown,
logContext: Record<string, unknown>
): ResponseBodyEnvs[] {
const cursor = (response as { pagination?: { next?: unknown } } | null | undefined)?.pagination
?.next;
if (typeof cursor === "number" && cursor > 0) {
logger.error(
"Vercel project env list returned a pagination cursor — this endpoint has always returned every record in one response, so this read is incomplete and needs paginating",
logContext
);
}
return extractVercelEnvs(response as FilterProjectEnvsResponseBody);
}
function extractVercelEnvs(response: FilterProjectEnvsResponseBody): ResponseBodyEnvs[] {
if ("envs" in response && Array.isArray(response.envs)) {
return response.envs;
}
return [];
}
function isVercelEnvListComplete(response: FilterProjectEnvsResponseBody): boolean {
if (!("pagination" in response) || !response.pagination) {
return true;
}
const next = "next" in response.pagination ? response.pagination.next : null;
return !(typeof next === "number" && next > 0);
}
function hasVercelEnvVarForTarget(envs: ResponseBodyEnvs[], key: string, target: string): boolean {
return envs.some((env) => {
if (env.key !== key) return false;
if (typeof env.gitBranch === "string" && env.gitBranch.length > 0) return false;
if (normalizeTarget(env.target).includes(target)) return true;
return (env.customEnvironmentIds ?? []).includes(target);
});
}
function isVercelSecretType(type: string): boolean {
return type === "secret" || type === "sensitive";
}
export type CreateEnvVarsIfAbsentResult = {
written: string[];
skipped: string[];
conflicted: string[];
failed: string[];
unresolved: string[];
errors: string[];
};
function extractCreateProjectEnvFailures(response: unknown): string[] {
if (!response || typeof response !== "object" || !("failed" in response)) {
return [];
}
const failed = (response as { failed?: unknown }).failed;
if (!Array.isArray(failed)) {
return [];
}
return failed.map((entry) => {
const error = (entry as { error?: { code?: unknown; message?: unknown } } | null)?.error;
const code = typeof error?.code === "string" ? error.code : "unknown";
const message = typeof error?.message === "string" ? error.message : "";
return message ? `${code}: ${message}` : code;
});
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
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(),
});
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) => {
return readProjectEnvs(response, { projectId, teamId }).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 = readProjectEnvs(response, { projectId, teamId }).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;
}> = [];
const skewProtectionTargetSet = new Set<string>();
for (const runtimeEnv of environments) {
const vercelTarget = envTypeToVercelTarget(
runtimeEnv.type as TriggerEnvironmentType,
params.vercelStagingEnvironment?.environmentId
);
if (!vercelTarget) {
continue;
}
for (const target of vercelTarget) {
skewProtectionTargetSet.add(target);
}
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[] };
}
const skewProtectionTargets = Array.from(skewProtectionTargetSet);
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,
});
const skewResult = await this.createVercelEnvVarsIfAbsent({
client,
vercelProjectId: params.vercelProjectId,
teamId: params.teamId,
key: SKEW_PROTECTION_ENV_VAR_KEY,
value: "1",
type: "plain",
targets: skewProtectionTargets,
});
if (skewResult.unresolved.length > 0 || skewResult.failed.length > 0) {
logger.error("Skew protection env var did not reach every target at connect", {
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
key: SKEW_PROTECTION_ENV_VAR_KEY,
...skewResult,
});
}
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 = readProjectEnvs(existingEnvs, { vercelProjectId, teamId });
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 = readProjectEnvs(existingEnvs, { vercelProjectId, teamId });
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: boundedIn(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 = readProjectEnvs(existingEnvs, { vercelProjectId, teamId });
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 createVercelEnvVarsIfAbsent(params: {
client: Vercel;
vercelProjectId: string;
teamId: string | null;
key: string;
value: string;
type: "sensitive" | "encrypted" | "plain";
targets: string[];
}): Promise<CreateEnvVarsIfAbsentResult> {
const { client, vercelProjectId, teamId, key, value, type, targets } = params;
const result: CreateEnvVarsIfAbsentResult = {
written: [],
skipped: [],
conflicted: [],
failed: [],
unresolved: [],
errors: [],
};
if (targets.length === 0) {
return result;
}
const logContext = { key, vercelProjectId, teamId, targets };
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "createVercelEnvVarsIfAbsent" }
).match(
(val) => val,
(error) => {
logger.error("Could not read Vercel env vars — skew protection was not written", {
...logContext,
outcome: "read_failed",
error,
});
return null;
}
);
if (!existingEnvs) {
return { ...result, unresolved: targets, errors: ["Failed to read Vercel env vars"] };
}
if (!isVercelEnvListComplete(existingEnvs)) {
logger.error("Vercel env var list was truncated — skew protection was not written", {
...logContext,
outcome: "list_truncated",
});
return { ...result, unresolved: targets, errors: ["Vercel env var list was truncated"] };
}
const envs = extractVercelEnvs(existingEnvs);
const targetsToCreate: string[] = [];
for (const target of targets) {
if (hasVercelEnvVarForTarget(envs, key, target)) {
result.skipped.push(target);
} else {
targetsToCreate.push(target);
}
}
for (const target of targetsToCreate) {
const requestBody = isVercelStandardTarget(target)
? { key, value, type, target: [target] }
: { key, value, type, customEnvironmentIds: [target] };
const createResult = await ResultAsync.fromPromise(
client.projects.createProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody,
}),
(error) => error
);
if (createResult.isErr()) {
const errorMsg = `Failed to create ${key} env var for ${target}: ${createResult.error instanceof Error ? createResult.error.message : "Unknown error"}`;
result.failed.push(target);
result.errors.push(errorMsg);
logger.error(errorMsg, {
...logContext,
target,
outcome: "failed",
error: createResult.error,
});
continue;
}
const failures = extractCreateProjectEnvFailures(createResult.value);
if (failures.length > 0) {
result.conflicted.push(target);
result.errors.push(...failures);
logger.warn("Vercel rejected a skew protection env var record", {
...logContext,
target,
outcome: "conflict",
failures,
});
continue;
}
result.written.push(target);
}
logger.info("Finished writing Vercel env var", {
...logContext,
outcome: "attempted",
written: result.written,
skipped: result.skipped,
conflicted: result.conflicted,
failed: result.failed,
});
return result;
}
static ensureEnvVarForCustomEnvironment(params: {
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
vercelProjectId: string;
teamId: string | null;
key: string;
value: string;
type: "sensitive" | "encrypted" | "plain";
customEnvironmentId: string;
}): ResultAsync<CreateEnvVarsIfAbsentResult, VercelApiError> {
return this.getVercelClient(params.orgIntegration).andThen((client) =>
ResultAsync.fromPromise(
this.createVercelEnvVarsIfAbsent({
client,
vercelProjectId: params.vercelProjectId,
teamId: params.teamId,
key: params.key,
value: params.value,
type: params.type,
targets: [params.customEnvironmentId],
}),
(error) => toVercelApiError(error)
)
);
}
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 = readProjectEnvs(existingEnvs, { vercelProjectId, teamId });
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 = readProjectEnvs(existingEnvs, { vercelProjectId, teamId });
// 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));
})
)
);
}
}