Skip to content

Commit 0128f16

Browse files
committed
feat(vercel): implement envSlugArrayField for JSON-encoded EnvSlug arrays and enhance Vercel integration with transaction handling to prevent race conditions
1 parent 5762f53 commit 0128f16

File tree

3 files changed

+131
-74
lines changed

3 files changed

+131
-74
lines changed

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { findProjectBySlug } from "~/models/project.server";
4343
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
4444
import { logger } from "~/services/logger.server";
4545
import { requireUserId } from "~/services/session.server";
46+
import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server";
4647
import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
4748
import {
4849
VercelSettingsPresenter,
@@ -54,7 +55,7 @@ import {
5455
type VercelProjectIntegrationData,
5556
type SyncEnvVarsMapping,
5657
type EnvSlug,
57-
jsonArrayField,
58+
envSlugArrayField,
5859
envTypeToSlug,
5960
getAvailableEnvSlugs,
6061
getAvailableEnvSlugsForBuildSettings,
@@ -93,9 +94,9 @@ function parseVercelStagingEnvironment(
9394

9495
const UpdateVercelConfigFormSchema = z.object({
9596
action: z.literal("update-config"),
96-
atomicBuilds: jsonArrayField,
97-
pullEnvVarsBeforeBuild: jsonArrayField,
98-
discoverEnvVars: jsonArrayField,
97+
atomicBuilds: envSlugArrayField,
98+
pullEnvVarsBeforeBuild: envSlugArrayField,
99+
discoverEnvVars: envSlugArrayField,
99100
vercelStagingEnvironment: z.string().nullable().optional(),
100101
});
101102

@@ -106,9 +107,9 @@ const DisconnectVercelFormSchema = z.object({
106107
const CompleteOnboardingFormSchema = z.object({
107108
action: z.literal("complete-onboarding"),
108109
vercelStagingEnvironment: z.string().nullable().optional(),
109-
pullEnvVarsBeforeBuild: jsonArrayField,
110-
atomicBuilds: jsonArrayField,
111-
discoverEnvVars: jsonArrayField,
110+
pullEnvVarsBeforeBuild: envSlugArrayField,
111+
atomicBuilds: envSlugArrayField,
112+
discoverEnvVars: envSlugArrayField,
112113
syncEnvVarsMapping: z.string().optional(),
113114
next: z.string().optional(),
114115
skipRedirect: z.string().optional().transform((val) => val === "true"),
@@ -242,9 +243,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
242243
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
243244

244245
const result = await vercelService.updateVercelIntegrationConfig(project.id, {
245-
atomicBuilds: atomicBuilds as EnvSlug[] | null,
246-
pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null,
247-
discoverEnvVars: discoverEnvVars as EnvSlug[] | null,
246+
atomicBuilds,
247+
pullEnvVarsBeforeBuild,
248+
discoverEnvVars,
248249
vercelStagingEnvironment: parsedStagingEnv,
249250
});
250251

@@ -283,9 +284,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
283284

284285
const result = await vercelService.completeOnboarding(project.id, {
285286
vercelStagingEnvironment: parsedStagingEnv,
286-
pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null,
287-
atomicBuilds: atomicBuilds as EnvSlug[] | null,
288-
discoverEnvVars: discoverEnvVars as EnvSlug[] | null,
287+
pullEnvVarsBeforeBuild,
288+
atomicBuilds,
289+
discoverEnvVars,
289290
syncEnvVarsMapping: parsedSyncEnvVarsMapping,
290291
});
291292

@@ -295,13 +296,11 @@ export async function action({ request, params }: ActionFunctionArgs) {
295296
}
296297

297298
if (next) {
298-
const urlResult = Result.fromThrowable(() => new URL(next), (e) => e)();
299-
if (urlResult.isOk() && urlResult.value.protocol === "https:") {
300-
return json({ success: true, redirectTo: next });
301-
}
302-
if (urlResult.isErr()) {
303-
logger.warn("Invalid next URL provided", { next, error: urlResult.error });
299+
const sanitizedNext = sanitizeVercelNextUrl(next);
300+
if (sanitizedNext) {
301+
return json({ success: true, redirectTo: sanitizedNext });
304302
}
303+
logger.warn("Rejected next URL - not same-origin or vercel.com", { next });
305304
}
306305

307306
return json({ success: true, redirectTo: settingsPath });

apps/webapp/app/services/vercelIntegration.server.ts

Lines changed: 97 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
SecretReference,
66
} from "@trigger.dev/database";
77
import { ResultAsync } from "neverthrow";
8-
import { prisma } from "~/db.server";
8+
import { prisma, $transaction } from "~/db.server";
99
import { logger } from "~/services/logger.server";
1010
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
1111
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
@@ -178,82 +178,124 @@ export class VercelIntegrationService {
178178
() => undefined
179179
);
180180

181-
const existing = await this.getVercelProjectIntegration(params.projectId);
182-
if (existing) {
183-
const updated = await this.#prismaClient.organizationProjectIntegration.update({
184-
where: { id: existing.id },
185-
data: {
186-
externalEntityId: params.vercelProjectId,
187-
integrationData: {
188-
...existing.parsedIntegrationData,
189-
vercelProjectId: params.vercelProjectId,
190-
vercelProjectName: params.vercelProjectName,
191-
vercelTeamId: teamId,
192-
vercelTeamSlug,
181+
// Use a serializable transaction to prevent duplicate project integrations
182+
// from concurrent selectVercelProject calls (read-then-write race condition).
183+
const txResult = await $transaction(
184+
this.#prismaClient,
185+
"selectVercelProject",
186+
async (tx) => {
187+
const existing = await tx.organizationProjectIntegration.findFirst({
188+
where: {
189+
projectId: params.projectId,
190+
deletedAt: null,
191+
organizationIntegration: {
192+
service: "VERCEL",
193+
deletedAt: null,
194+
},
193195
},
194-
},
195-
});
196+
include: {
197+
organizationIntegration: true,
198+
},
199+
});
196200

197-
const syncResultAsync = await VercelIntegrationRepository.syncApiKeysToVercel({
198-
projectId: params.projectId,
199-
vercelProjectId: params.vercelProjectId,
200-
teamId,
201-
vercelStagingEnvironment: existing.parsedIntegrationData.config.vercelStagingEnvironment,
202-
orgIntegration,
203-
});
204-
const syncResult = syncResultAsync.isOk()
205-
? { success: syncResultAsync.value.errors.length === 0, errors: syncResultAsync.value.errors }
206-
: { success: false, errors: [syncResultAsync.error.message] };
201+
if (existing) {
202+
const parsedData = VercelProjectIntegrationDataSchema.safeParse(
203+
existing.integrationData
204+
);
205+
206+
const updated = await tx.organizationProjectIntegration.update({
207+
where: { id: existing.id },
208+
data: {
209+
externalEntityId: params.vercelProjectId,
210+
integrationData: {
211+
...(parsedData.success ? parsedData.data : {}),
212+
vercelProjectId: params.vercelProjectId,
213+
vercelProjectName: params.vercelProjectName,
214+
vercelTeamId: teamId,
215+
vercelTeamSlug,
216+
},
217+
},
218+
});
219+
220+
return {
221+
integration: updated,
222+
wasCreated: false,
223+
vercelStagingEnvironment: parsedData.success
224+
? parsedData.data.config.vercelStagingEnvironment
225+
: null,
226+
};
227+
}
228+
229+
const integrationData = createDefaultVercelIntegrationData(
230+
params.vercelProjectId,
231+
params.vercelProjectName,
232+
teamId,
233+
vercelTeamSlug
234+
);
235+
236+
const created = await tx.organizationProjectIntegration.create({
237+
data: {
238+
organizationIntegrationId: orgIntegration.id,
239+
projectId: params.projectId,
240+
externalEntityId: params.vercelProjectId,
241+
integrationData: integrationData,
242+
installedBy: params.userId,
243+
},
244+
});
207245

208-
return { integration: updated, syncResult };
246+
return {
247+
integration: created,
248+
wasCreated: true,
249+
vercelStagingEnvironment: null,
250+
};
251+
},
252+
{ isolationLevel: "Serializable" }
253+
);
254+
255+
if (!txResult) {
256+
throw new Error("Failed to select Vercel project: transaction returned undefined");
209257
}
210258

211-
const integration = await this.createVercelProjectIntegration({
212-
organizationIntegrationId: orgIntegration.id,
213-
projectId: params.projectId,
214-
vercelProjectId: params.vercelProjectId,
215-
vercelProjectName: params.vercelProjectName,
216-
vercelTeamId: teamId,
217-
vercelTeamSlug,
218-
installedByUserId: params.userId,
219-
});
259+
const { integration, wasCreated, vercelStagingEnvironment } = txResult;
220260

221261
const syncResultAsync = await VercelIntegrationRepository.syncApiKeysToVercel({
222262
projectId: params.projectId,
223263
vercelProjectId: params.vercelProjectId,
224264
teamId,
225-
vercelStagingEnvironment: null,
265+
vercelStagingEnvironment,
226266
orgIntegration,
227267
});
228268
const syncResult = syncResultAsync.isOk()
229269
? { success: syncResultAsync.value.errors.length === 0, errors: syncResultAsync.value.errors }
230270
: { success: false, errors: [syncResultAsync.error.message] };
231271

232-
const disableResult = await VercelIntegrationRepository.getVercelClient(orgIntegration)
233-
.andThen((client) =>
234-
VercelIntegrationRepository.disableAutoAssignCustomDomains(
235-
client,
236-
params.vercelProjectId,
237-
teamId
238-
)
239-
);
272+
if (wasCreated) {
273+
const disableResult = await VercelIntegrationRepository.getVercelClient(orgIntegration)
274+
.andThen((client) =>
275+
VercelIntegrationRepository.disableAutoAssignCustomDomains(
276+
client,
277+
params.vercelProjectId,
278+
teamId
279+
)
280+
);
281+
282+
if (disableResult.isErr()) {
283+
logger.warn("Failed to disable autoAssignCustomDomains during project selection", {
284+
projectId: params.projectId,
285+
vercelProjectId: params.vercelProjectId,
286+
error: disableResult.error.message,
287+
});
288+
}
240289

241-
if (disableResult.isErr()) {
242-
logger.warn("Failed to disable autoAssignCustomDomains during project selection", {
290+
logger.info("Vercel project selected and API keys synced", {
243291
projectId: params.projectId,
244292
vercelProjectId: params.vercelProjectId,
245-
error: disableResult.error.message,
293+
vercelProjectName: params.vercelProjectName,
294+
syncSuccess: syncResult.success,
295+
syncErrors: syncResult.errors,
246296
});
247297
}
248298

249-
logger.info("Vercel project selected and API keys synced", {
250-
projectId: params.projectId,
251-
vercelProjectId: params.vercelProjectId,
252-
vercelProjectName: params.vercelProjectName,
253-
syncSuccess: syncResult.success,
254-
syncErrors: syncResult.errors,
255-
});
256-
257299
return { integration, syncResult };
258300
}
259301

apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,22 @@ export const jsonArrayField = z.string().optional().transform((val) => {
2323
);
2424
});
2525

26+
/**
27+
* Zod transform for form fields that submit JSON-encoded EnvSlug arrays.
28+
* Parses the string as JSON and validates each element is a valid EnvSlug.
29+
* Invalid elements are filtered out rather than rejecting the whole array.
30+
*/
31+
export const envSlugArrayField = z.string().optional().transform((val): EnvSlug[] | null => {
32+
if (!val) return null;
33+
return safeJsonParse(val).match(
34+
(parsed) => {
35+
if (!Array.isArray(parsed)) return null;
36+
return parsed.filter((item): item is EnvSlug => EnvSlugSchema.safeParse(item).success);
37+
},
38+
() => null
39+
);
40+
});
41+
2642
export const VercelIntegrationConfigSchema = z.object({
2743
atomicBuilds: z.array(EnvSlugSchema).nullable().optional(),
2844
pullEnvVarsBeforeBuild: z.array(EnvSlugSchema).nullable().optional(),

0 commit comments

Comments
 (0)