Skip to content

Commit 84c8396

Browse files
committed
fix: paginate shared Vercel env var fetch in onboarding + pull
getVercelSharedEnvironmentVariables/...Values read only the first page (~25) of the shared env endpoint, silently dropping the rest. The shared endpoint (/v1/env) paginates with a next-cursor the SDK can't follow, so add a raw-fetch cursor walk (#fetchAllSharedEnvsRaw) that loads every page, via a new getVercelClientAndToken accessor. Onboarding and pullEnvVarsFromVercel already decrypt and save the shared values.
1 parent df964ea commit 84c8396

2 files changed

Lines changed: 97 additions & 31 deletions

File tree

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

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

200+
/** Minimal shape of a shared (team-level) env var record from `GET /v1/env`. */
201+
type RawSharedEnvVar = {
202+
id?: string;
203+
key?: string;
204+
type?: string;
205+
target?: string[] | string;
206+
value?: string;
207+
applyToAllCustomEnvironments?: boolean;
208+
};
209+
200210
/** Narrowed Vercel project type – only id and name. */
201211
export type VercelProject = Pick<ResponseBodyProjects, "id" | "name">;
202212

@@ -298,6 +308,17 @@ export class VercelIntegrationRepository {
298308
static getVercelClient(
299309
integration: OrganizationIntegration & { tokenReference: SecretReference }
300310
): ResultAsync<Vercel, VercelApiError> {
311+
return this.getVercelClientAndToken(integration).map(({ client }) => client);
312+
}
313+
314+
/**
315+
* Resolve both the Vercel SDK client and the raw bearer token. The raw token
316+
* is needed to paginate shared env vars via `fetch`, since the SDK's
317+
* `listSharedEnvVariable` exposes no `until` cursor param.
318+
*/
319+
static getVercelClientAndToken(
320+
integration: OrganizationIntegration & { tokenReference: SecretReference }
321+
): ResultAsync<{ client: Vercel; accessToken: string }, VercelApiError> {
301322
return ResultAsync.fromPromise(
302323
(async () => {
303324
const secretStore = getSecretStore(integration.tokenReference.provider);
@@ -308,7 +329,7 @@ export class VercelIntegrationRepository {
308329
if (!secret) {
309330
throw new Error("Failed to get Vercel access token");
310331
}
311-
return new Vercel({ bearerToken: secret.accessToken });
332+
return { client: new Vercel({ bearerToken: secret.accessToken }), accessToken: secret.accessToken };
312333
})(),
313334
(error) => toVercelApiError(error)
314335
);
@@ -558,8 +579,71 @@ export class VercelIntegrationRepository {
558579
};
559580
}
560581

582+
/**
583+
* Fetch ALL shared (team-level) env var records, following pagination.
584+
*
585+
* Unlike the project env endpoint, the shared endpoint (`/v1/env`) DOES
586+
* paginate (≈25/page) and the SDK's `listSharedEnvVariable` exposes no cursor
587+
* param — so we walk pages via a raw fetch using `pagination.next` (a
588+
* millisecond-timestamp cursor) until it is null. Shared vars are an edge
589+
* case, so we load every page up front and return the full set.
590+
*/
591+
static #fetchAllSharedEnvsRaw(params: {
592+
accessToken: string;
593+
teamId: string;
594+
projectId?: string;
595+
}): ResultAsync<RawSharedEnvVar[], VercelApiError> {
596+
const { accessToken, teamId, projectId } = params;
597+
return ResultAsync.fromPromise(
598+
(async () => {
599+
const all: RawSharedEnvVar[] = [];
600+
let until: number | undefined = undefined;
601+
const MAX_PAGES = 200; // safety cap (1000-var ceiling / ~25 per page)
602+
603+
for (let page = 0; page < MAX_PAGES; page++) {
604+
const url = new URL("https://api.vercel.com/v1/env");
605+
url.searchParams.set("teamId", teamId);
606+
if (projectId) url.searchParams.set("projectId", projectId);
607+
if (until !== undefined) url.searchParams.set("until", String(until));
608+
609+
const response = await fetch(url.toString(), {
610+
method: "GET",
611+
headers: { Authorization: `Bearer ${accessToken}` },
612+
});
613+
614+
if (!response.ok) {
615+
const body = await response.text().catch(() => "");
616+
const error = new Error(
617+
`Failed to fetch Vercel shared environment variables: ${response.status} ${response.statusText}${body}`
618+
) as Error & { status?: number };
619+
error.status = response.status;
620+
throw error;
621+
}
622+
623+
const json = (await response.json()) as {
624+
data?: RawSharedEnvVar[];
625+
pagination?: { next?: number | null } | null;
626+
};
627+
all.push(...(json.data ?? []));
628+
629+
// `next` is a millisecond-timestamp cursor; treat 0/null/undefined as "done".
630+
const next = json.pagination?.next;
631+
if (!next) break;
632+
until = next;
633+
634+
if (page === MAX_PAGES - 1) {
635+
logger.warn("Vercel shared env var pagination hit max page cap", { teamId, projectId });
636+
}
637+
}
638+
639+
return all;
640+
})(),
641+
(error) => toVercelApiError(error)
642+
);
643+
}
644+
561645
static getVercelSharedEnvironmentVariables(
562-
client: Vercel,
646+
accessToken: string,
563647
teamId: string,
564648
projectId?: string // Optional: filter by project
565649
): ResultAsync<Array<{
@@ -569,19 +653,9 @@ export class VercelIntegrationRepository {
569653
isSecret: boolean;
570654
target: string[];
571655
}>, 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 || [];
656+
return this.#fetchAllSharedEnvsRaw({ accessToken, teamId, projectId }).map((envVars) => {
583657
return envVars
584-
.filter((env): env is typeof env & { id: string; key: string } =>
658+
.filter((env): env is RawSharedEnvVar & { id: string; key: string } =>
585659
typeof env.id === "string" && typeof env.key === "string"
586660
)
587661
.map((env) => {
@@ -599,6 +673,7 @@ export class VercelIntegrationRepository {
599673

600674
static getVercelSharedEnvironmentVariableValues(
601675
client: Vercel,
676+
accessToken: string,
602677
teamId: string,
603678
projectId?: string // Optional: filter by project
604679
): ResultAsync<
@@ -612,17 +687,7 @@ export class VercelIntegrationRepository {
612687
}>,
613688
VercelApiError
614689
> {
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 || [];
690+
return this.#fetchAllSharedEnvsRaw({ accessToken, teamId, projectId }).andThen((envVars) => {
626691
if (envVars.length === 0) {
627692
return okAsync([]);
628693
}
@@ -641,8 +706,8 @@ export class VercelIntegrationRepository {
641706

642707
if (isSecret) return null;
643708

644-
const listValue = (env as any).value as string | undefined;
645-
const applyToAllCustomEnvs = (env as any).applyToAllCustomEnvironments as boolean | undefined;
709+
const listValue = env.value;
710+
const applyToAllCustomEnvs = env.applyToAllCustomEnvironments;
646711

647712
if (listValue) {
648713
return {
@@ -1201,7 +1266,7 @@ export class VercelIntegrationRepository {
12011266
syncEnvVarsMappingKeys: Object.keys(params.syncEnvVarsMapping),
12021267
});
12031268

1204-
return this.getVercelClient(params.orgIntegration).andThen((client) =>
1269+
return this.getVercelClientAndToken(params.orgIntegration).andThen(({ client, accessToken }) =>
12051270
ResultAsync.fromPromise(
12061271
(async () => {
12071272
const errors: string[] = [];
@@ -1267,6 +1332,7 @@ export class VercelIntegrationRepository {
12671332
if (params.teamId) {
12681333
const sharedResult = await this.getVercelSharedEnvironmentVariableValues(
12691334
client,
1335+
accessToken,
12701336
params.teamId,
12711337
params.vercelProjectId
12721338
);

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)