Skip to content

Commit ceed97d

Browse files
committed
feat(vercel): update tokens, refine env lookup & cleanup
Update Vercel org integration handling to update existing integration tokens instead of always creating a new record. When an existing org integration is detected, log the update, call the repository update method with the new token and metadata, and re-fetch the integration to use the up-to-date record. This avoids duplicate integrations and keeps stored tokens current. Refactor Vercel repo imports and environment lookup to improve error handling and typing. Return early when getVercelCustomEnvironments fails, use the typed result (.data) and VercelCustomEnvironment for safer lookup, and remove several commented section headers and unused helpers to simplify the module. Also remove an unused presenter import and tidy up minor formatting. Why: prevent duplicate org integrations, ensure tokens are refreshed correctly, and make environment name resolution more robust and typed.
1 parent 47dce1d commit ceed97d

File tree

4 files changed

+608
-353
lines changed

4 files changed

+608
-353
lines changed

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

Lines changed: 184 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,30 @@ export type VercelCustomEnvironment = {
6262
};
6363
};
6464

65+
export type VercelAPIResult<T> = {
66+
success: true;
67+
data: T;
68+
} | {
69+
success: false;
70+
authInvalid: boolean;
71+
error: string;
72+
};
73+
74+
function isVercelAuthError(error: unknown): boolean {
75+
if (error && typeof error === 'object' && 'status' in error) {
76+
const status = (error as { status?: number }).status;
77+
return status === 401 || status === 403;
78+
}
79+
if (error && typeof error === 'object' && 'response' in error) {
80+
const response = (error as { response?: { status?: number } }).response;
81+
return response?.status === 401 || response?.status === 403;
82+
}
83+
if (error && typeof error === 'string' && (error.includes('401') || error.includes('403'))) {
84+
return true;
85+
}
86+
return false;
87+
}
88+
6589
export class VercelIntegrationRepository {
6690
static async getVercelClient(
6791
integration: OrganizationIntegration & { tokenReference: SecretReference }
@@ -82,6 +106,30 @@ export class VercelIntegrationRepository {
82106
});
83107
}
84108

109+
static async validateVercelToken(
110+
integration: OrganizationIntegration & { tokenReference: SecretReference }
111+
): Promise<{ isValid: boolean }> {
112+
try {
113+
const client = await this.getVercelClient(integration);
114+
await client.user.getAuthUser();
115+
return { isValid: true };
116+
} catch (error) {
117+
const authInvalid = isVercelAuthError(error);
118+
if (authInvalid) {
119+
logger.debug("Vercel token validation failed - auth error", {
120+
integrationId: integration.id,
121+
error,
122+
});
123+
return { isValid: false };
124+
}
125+
logger.error("Vercel token validation failed - unexpected error", {
126+
integrationId: integration.id,
127+
error,
128+
});
129+
throw error;
130+
}
131+
}
132+
85133
static async getTeamIdFromIntegration(
86134
integration: OrganizationIntegration & { tokenReference: SecretReference }
87135
): Promise<string | null> {
@@ -162,7 +210,7 @@ export class VercelIntegrationRepository {
162210
client: Vercel,
163211
projectId: string,
164212
teamId?: string | null
165-
): Promise<VercelCustomEnvironment[]> {
213+
): Promise<VercelAPIResult<VercelCustomEnvironment[]>> {
166214
try {
167215
const response = await client.environment.getV9ProjectsIdOrNameCustomEnvironments({
168216
idOrName: projectId,
@@ -172,19 +220,28 @@ export class VercelIntegrationRepository {
172220
// The response contains environments array
173221
const environments = response.environments || [];
174222

175-
return environments.map((env: any) => ({
176-
id: env.id,
177-
slug: env.slug,
178-
description: env.description,
179-
branchMatcher: env.branchMatcher,
180-
}));
223+
return {
224+
success: true,
225+
data: environments.map((env: any) => ({
226+
id: env.id,
227+
slug: env.slug,
228+
description: env.description,
229+
branchMatcher: env.branchMatcher,
230+
})),
231+
};
181232
} catch (error) {
233+
const authInvalid = isVercelAuthError(error);
182234
logger.error("Failed to fetch Vercel custom environments", {
183235
projectId,
184236
teamId,
185237
error,
238+
authInvalid,
186239
});
187-
return [];
240+
return {
241+
success: false,
242+
authInvalid,
243+
error: error instanceof Error ? error.message : "Unknown error",
244+
};
188245
}
189246
}
190247

@@ -193,7 +250,7 @@ export class VercelIntegrationRepository {
193250
client: Vercel,
194251
projectId: string,
195252
teamId?: string | null
196-
): Promise<VercelEnvironmentVariable[]> {
253+
): Promise<VercelAPIResult<VercelEnvironmentVariable[]>> {
197254
try {
198255
const response = await client.projects.filterProjectEnvs({
199256
idOrName: projectId,
@@ -203,26 +260,35 @@ export class VercelIntegrationRepository {
203260
// The response is a union type - check if it has envs array
204261
const envs = extractEnvs(response);
205262

206-
return envs.map((env: any) => {
207-
const type = env.type as VercelEnvironmentVariable["type"];
208-
// Secret and sensitive types cannot have their values retrieved
209-
const isSecret = type === "secret" || type === "sensitive";
263+
return {
264+
success: true,
265+
data: envs.map((env: any) => {
266+
const type = env.type as VercelEnvironmentVariable["type"];
267+
// Secret and sensitive types cannot have their values retrieved
268+
const isSecret = type === "secret" || type === "sensitive";
210269

211-
return {
212-
id: env.id,
213-
key: env.key,
214-
type,
215-
isSecret,
216-
target: normalizeTarget(env.target),
217-
};
218-
});
270+
return {
271+
id: env.id,
272+
key: env.key,
273+
type,
274+
isSecret,
275+
target: normalizeTarget(env.target),
276+
};
277+
}),
278+
};
219279
} catch (error) {
280+
const authInvalid = isVercelAuthError(error);
220281
logger.error("Failed to fetch Vercel environment variables", {
221282
projectId,
222283
teamId,
223284
error,
285+
authInvalid,
224286
});
225-
return [];
287+
return {
288+
success: false,
289+
authInvalid,
290+
error: error instanceof Error ? error.message : "Unknown error",
291+
};
226292
}
227293
}
228294

@@ -294,15 +360,13 @@ export class VercelIntegrationRepository {
294360
client: Vercel,
295361
teamId: string,
296362
projectId?: string // Optional: filter by project
297-
): Promise<
298-
Array<{
363+
): Promise<VercelAPIResult<Array<{
299364
id: string;
300365
key: string;
301366
type: string;
302367
isSecret: boolean;
303368
target: string[];
304-
}>
305-
> {
369+
}>>> {
306370
try {
307371
const response = await client.environment.listSharedEnvVariable({
308372
teamId,
@@ -311,27 +375,36 @@ export class VercelIntegrationRepository {
311375

312376
const envVars = response.data || [];
313377

314-
return envVars.map((env) => {
315-
const type = (env.type as string) || "plain";
316-
const isSecret = type === "secret" || type === "sensitive";
378+
return {
379+
success: true,
380+
data: envVars.map((env) => {
381+
const type = (env.type as string) || "plain";
382+
const isSecret = type === "secret" || type === "sensitive";
317383

318-
return {
319-
id: env.id as string,
320-
key: env.key as string,
321-
type,
322-
isSecret,
323-
target: Array.isArray(env.target)
324-
? (env.target as string[])
325-
: [env.target].filter(Boolean) as string[],
326-
};
327-
});
384+
return {
385+
id: env.id as string,
386+
key: env.key as string,
387+
type,
388+
isSecret,
389+
target: Array.isArray(env.target)
390+
? (env.target as string[])
391+
: [env.target].filter(Boolean) as string[],
392+
};
393+
}),
394+
};
328395
} catch (error) {
396+
const authInvalid = isVercelAuthError(error);
329397
logger.error("Failed to fetch Vercel shared environment variables", {
330398
teamId,
331399
projectId,
332400
error,
401+
authInvalid,
333402
});
334-
return [];
403+
return {
404+
success: false,
405+
authInvalid,
406+
error: error instanceof Error ? error.message : "Unknown error",
407+
};
335408
}
336409
}
337410

@@ -505,27 +578,92 @@ export class VercelIntegrationRepository {
505578
static async getVercelProjects(
506579
client: Vercel,
507580
teamId?: string | null
508-
): Promise<Array<{ id: string; name: string }>> {
581+
): Promise<VercelAPIResult<Array<{ id: string; name: string }>>> {
509582
try {
510583
const response = await client.projects.getProjects({
511584
...(teamId && { teamId }),
512585
});
513586

514587
const projects = response.projects || [];
515588

516-
return projects.map((project: any) => ({
517-
id: project.id,
518-
name: project.name,
519-
}));
589+
return {
590+
success: true,
591+
data: projects.map((project: any) => ({
592+
id: project.id,
593+
name: project.name,
594+
})),
595+
};
520596
} catch (error) {
597+
const authInvalid = isVercelAuthError(error);
521598
logger.error("Failed to fetch Vercel projects", {
522599
teamId,
523600
error,
601+
authInvalid,
524602
});
525-
return [];
603+
return {
604+
success: false,
605+
authInvalid,
606+
error: error instanceof Error ? error.message : "Unknown error",
607+
};
526608
}
527609
}
528610

611+
static async updateVercelOrgIntegrationToken(params: {
612+
integrationId: string;
613+
accessToken: string;
614+
tokenType?: string;
615+
teamId: string | null;
616+
userId?: string;
617+
installationId?: string;
618+
raw?: Record<string, any>;
619+
}): Promise<void> {
620+
await $transaction(prisma, async (tx) => {
621+
// Get the existing integration to find the token reference
622+
const integration = await tx.organizationIntegration.findUnique({
623+
where: { id: params.integrationId },
624+
include: { tokenReference: true },
625+
});
626+
627+
if (!integration) {
628+
throw new Error("Vercel integration not found");
629+
}
630+
631+
const secretStore = getSecretStore(integration.tokenReference.provider, {
632+
prismaClient: tx,
633+
});
634+
635+
const secretValue: VercelSecret = {
636+
accessToken: params.accessToken,
637+
tokenType: params.tokenType,
638+
teamId: params.teamId,
639+
userId: params.userId,
640+
installationId: params.installationId,
641+
raw: params.raw,
642+
};
643+
644+
logger.debug("Updating Vercel secret", {
645+
integrationId: params.integrationId,
646+
teamId: params.teamId,
647+
installationId: params.installationId,
648+
});
649+
650+
// Update the secret with new token
651+
await secretStore.setSecret(integration.tokenReference.key, secretValue);
652+
653+
// Update integration metadata
654+
await tx.organizationIntegration.update({
655+
where: { id: params.integrationId },
656+
data: {
657+
integrationData: {
658+
teamId: params.teamId,
659+
userId: params.userId,
660+
installationId: params.installationId,
661+
} as any,
662+
},
663+
});
664+
});
665+
}
666+
529667
static async createVercelOrgIntegration(params: {
530668
accessToken: string;
531669
tokenType?: string;

0 commit comments

Comments
 (0)