Skip to content

Commit 774a979

Browse files
authored
fix(webapp): paginate shared Vercel env var fetch in onboarding + pull (#3879)
Closes #3850 <img width="1709" height="1115" alt="Screenshot 2026-06-09 at 18 44 40" src="https://github.com/user-attachments/assets/edc1f091-0937-403d-a6e9-9d9477e77ed0" />
1 parent df964ea commit 774a979

2 files changed

Lines changed: 104 additions & 31 deletions

File tree

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

Lines changed: 101 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,26 @@ export type VercelEnvironmentVariableValue = {
197197
isSecret: boolean;
198198
};
199199

200+
/** Minimal shape of a shared (team-level) env var record from `GET /v1/env`. */
201+
const RawSharedEnvVarSchema = z
202+
.object({
203+
id: z.string().optional(),
204+
key: z.string().optional(),
205+
type: z.string().optional(),
206+
target: z.union([z.array(z.string()), z.string()]).optional(),
207+
value: z.string().optional(),
208+
applyToAllCustomEnvironments: z.boolean().optional(),
209+
})
210+
.passthrough();
211+
212+
type RawSharedEnvVar = z.infer<typeof RawSharedEnvVarSchema>;
213+
214+
/** Page shape of `GET /v1/env` (shared env vars), validated at the boundary. */
215+
const SharedEnvPageSchema = z.object({
216+
data: z.array(RawSharedEnvVarSchema).default([]),
217+
pagination: z.object({ next: z.number().nullish() }).nullish(),
218+
});
219+
200220
/** Narrowed Vercel project type – only id and name. */
201221
export type VercelProject = Pick<ResponseBodyProjects, "id" | "name">;
202222

@@ -298,6 +318,17 @@ export class VercelIntegrationRepository {
298318
static getVercelClient(
299319
integration: OrganizationIntegration & { tokenReference: SecretReference }
300320
): ResultAsync<Vercel, VercelApiError> {
321+
return this.getVercelClientAndToken(integration).map(({ client }) => client);
322+
}
323+
324+
/**
325+
* Resolve both the Vercel SDK client and the raw bearer token. The raw token
326+
* is needed to paginate shared env vars via `fetch`, since the SDK's
327+
* `listSharedEnvVariable` exposes no `until` cursor param.
328+
*/
329+
static getVercelClientAndToken(
330+
integration: OrganizationIntegration & { tokenReference: SecretReference }
331+
): ResultAsync<{ client: Vercel; accessToken: string }, VercelApiError> {
301332
return ResultAsync.fromPromise(
302333
(async () => {
303334
const secretStore = getSecretStore(integration.tokenReference.provider);
@@ -308,7 +339,7 @@ export class VercelIntegrationRepository {
308339
if (!secret) {
309340
throw new Error("Failed to get Vercel access token");
310341
}
311-
return new Vercel({ bearerToken: secret.accessToken });
342+
return { client: new Vercel({ bearerToken: secret.accessToken }), accessToken: secret.accessToken };
312343
})(),
313344
(error) => toVercelApiError(error)
314345
);
@@ -558,8 +589,68 @@ export class VercelIntegrationRepository {
558589
};
559590
}
560591

592+
/**
593+
* Fetch ALL shared (team-level) env var records, following pagination.
594+
*
595+
* Unlike the project env endpoint, the shared endpoint (`/v1/env`) DOES
596+
* paginate (≈25/page) and the SDK's `listSharedEnvVariable` exposes no cursor
597+
* param — so we walk pages via a raw fetch using `pagination.next` (a
598+
* millisecond-timestamp cursor) until it is null. Shared vars are an edge
599+
* case, so we load every page up front and return the full set.
600+
*/
601+
static #fetchAllSharedEnvsRaw(params: {
602+
accessToken: string;
603+
teamId: string;
604+
projectId?: string;
605+
}): ResultAsync<RawSharedEnvVar[], VercelApiError> {
606+
const { accessToken, teamId, projectId } = params;
607+
return ResultAsync.fromPromise(
608+
(async () => {
609+
const all: RawSharedEnvVar[] = [];
610+
let until: number | undefined = undefined;
611+
const MAX_PAGES = 200; // safety cap (1000-var ceiling / ~25 per page)
612+
613+
for (let page = 0; page < MAX_PAGES; page++) {
614+
const url = new URL("https://api.vercel.com/v1/env");
615+
url.searchParams.set("teamId", teamId);
616+
if (projectId) url.searchParams.set("projectId", projectId);
617+
if (until !== undefined) url.searchParams.set("until", String(until));
618+
619+
const response = await fetch(url.toString(), {
620+
method: "GET",
621+
headers: { Authorization: `Bearer ${accessToken}` },
622+
});
623+
624+
if (!response.ok) {
625+
const body = await response.text().catch(() => "");
626+
const error = new Error(
627+
`Failed to fetch Vercel shared environment variables: ${response.status} ${response.statusText}${body}`
628+
) as Error & { status?: number };
629+
error.status = response.status;
630+
throw error;
631+
}
632+
633+
const json = SharedEnvPageSchema.parse(await response.json());
634+
all.push(...json.data);
635+
636+
// `next` is a millisecond-timestamp cursor; treat 0/null/undefined as "done".
637+
const next = json.pagination?.next;
638+
if (!next) break;
639+
until = next;
640+
641+
if (page === MAX_PAGES - 1) {
642+
logger.warn("Vercel shared env var pagination hit max page cap", { teamId, projectId });
643+
}
644+
}
645+
646+
return all;
647+
})(),
648+
(error) => toVercelApiError(error)
649+
);
650+
}
651+
561652
static getVercelSharedEnvironmentVariables(
562-
client: Vercel,
653+
accessToken: string,
563654
teamId: string,
564655
projectId?: string // Optional: filter by project
565656
): ResultAsync<Array<{
@@ -569,19 +660,9 @@ export class VercelIntegrationRepository {
569660
isSecret: boolean;
570661
target: string[];
571662
}>, VercelApiError> {
572-
return wrapVercelCallWithRecovery(
573-
client.environment.listSharedEnvVariable({
574-
teamId,
575-
...(projectId && { projectId }),
576-
}),
577-
VercelSchemas.listSharedEnvVariable,
578-
"Failed to fetch Vercel shared environment variables",
579-
{ teamId, projectId },
580-
toVercelApiError
581-
).map((response) => {
582-
const envVars = response.data || [];
663+
return this.#fetchAllSharedEnvsRaw({ accessToken, teamId, projectId }).map((envVars) => {
583664
return envVars
584-
.filter((env): env is typeof env & { id: string; key: string } =>
665+
.filter((env): env is RawSharedEnvVar & { id: string; key: string } =>
585666
typeof env.id === "string" && typeof env.key === "string"
586667
)
587668
.map((env) => {
@@ -599,6 +680,7 @@ export class VercelIntegrationRepository {
599680

600681
static getVercelSharedEnvironmentVariableValues(
601682
client: Vercel,
683+
accessToken: string,
602684
teamId: string,
603685
projectId?: string // Optional: filter by project
604686
): ResultAsync<
@@ -612,17 +694,7 @@ export class VercelIntegrationRepository {
612694
}>,
613695
VercelApiError
614696
> {
615-
return wrapVercelCallWithRecovery(
616-
client.environment.listSharedEnvVariable({
617-
teamId,
618-
...(projectId && { projectId }),
619-
}),
620-
VercelSchemas.listSharedEnvVariable,
621-
"Failed to fetch Vercel shared environment variable values",
622-
{ teamId, projectId },
623-
toVercelApiError
624-
).andThen((listResponse) => {
625-
const envVars = listResponse.data || [];
697+
return this.#fetchAllSharedEnvsRaw({ accessToken, teamId, projectId }).andThen((envVars) => {
626698
if (envVars.length === 0) {
627699
return okAsync([]);
628700
}
@@ -641,8 +713,8 @@ export class VercelIntegrationRepository {
641713

642714
if (isSecret) return null;
643715

644-
const listValue = (env as any).value as string | undefined;
645-
const applyToAllCustomEnvs = (env as any).applyToAllCustomEnvironments as boolean | undefined;
716+
const listValue = env.value;
717+
const applyToAllCustomEnvs = env.applyToAllCustomEnvironments;
646718

647719
if (listValue) {
648720
return {
@@ -1201,7 +1273,7 @@ export class VercelIntegrationRepository {
12011273
syncEnvVarsMappingKeys: Object.keys(params.syncEnvVarsMapping),
12021274
});
12031275

1204-
return this.getVercelClient(params.orgIntegration).andThen((client) =>
1276+
return this.getVercelClientAndToken(params.orgIntegration).andThen(({ client, accessToken }) =>
12051277
ResultAsync.fromPromise(
12061278
(async () => {
12071279
const errors: string[] = [];
@@ -1267,6 +1339,7 @@ export class VercelIntegrationRepository {
12671339
if (params.teamId) {
12681340
const sharedResult = await this.getVercelSharedEnvironmentVariableValues(
12691341
client,
1342+
accessToken,
12701343
params.teamId,
12711344
params.vercelProjectId
12721345
);

apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,7 @@ export class VercelSettingsPresenter extends BasePresenter {
458458
};
459459
}
460460

461-
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
461+
const clientResult = await VercelIntegrationRepository.getVercelClientAndToken(orgIntegration);
462462
if (clientResult.isErr()) {
463463
return {
464464
customEnvironments: [],
@@ -473,7 +473,7 @@ export class VercelSettingsPresenter extends BasePresenter {
473473
isOnboardingComplete: false,
474474
};
475475
}
476-
const client = clientResult.value;
476+
const { client, accessToken } = clientResult.value;
477477
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
478478

479479
const projectIntegration = await (this._replica as PrismaClient).organizationProjectIntegration.findFirst({
@@ -531,7 +531,7 @@ export class VercelSettingsPresenter extends BasePresenter {
531531
// Only fetch shared env vars if teamId is available
532532
teamId
533533
? VercelIntegrationRepository.getVercelSharedEnvironmentVariables(
534-
client,
534+
accessToken,
535535
teamId,
536536
projectIntegration.externalEntityId
537537
)

0 commit comments

Comments
 (0)