triggerdotdev--trigger.dev
1886 行
61 KiB
TypeScript
1886 行
61 KiB
TypeScript
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));
|
|
})
|
|
)
|
|
);
|
|
}
|
|
}
|