Skip to content
24 changes: 16 additions & 8 deletions packages/vercel-sandbox/src/auth/infer-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,24 @@ async function getTempDir(): Promise<string> {
}

describe("selectTeam", () => {
test("returns the first team", async () => {
test("returns defaultTeamId when set", async () => {
fetchApiMock.mockResolvedValue({
teams: [{ slug: "one" }, { slug: "two" }],
user: { defaultTeamId: "team_abc123", username: "my-user" },
});
const team = await selectTeam("token");
expect(fetchApiMock).toHaveBeenCalledWith({
endpoint: "/v2/teams?limit=1",
endpoint: "/v2/user",
token: "token",
});
expect(team).toBe("one");
expect(team).toBe("team_abc123");
});

test("falls back to username when defaultTeamId is null", async () => {
fetchApiMock.mockResolvedValue({
user: { defaultTeamId: null, username: "my-user" },
});
const team = await selectTeam("token");
expect(team).toBe("my-user");
});
});

Expand Down Expand Up @@ -95,18 +103,18 @@ describe("inferScope", () => {
});
});

test("infers the team", async () => {
test("infers the team from the user's defaultTeamId", async () => {
fetchApiMock.mockImplementation(async ({ endpoint }) => {
if (endpoint === "/v2/teams?limit=1") {
return { teams: [{ slug: "inferred-team" }] };
if (endpoint === "/v2/user") {
return { user: { defaultTeamId: "team_default", username: "my-user" } };
}
return {};
});
const scope = await inferScope({ token: "token" });
expect(scope).toEqual({
created: false,
projectId: "vercel-sandbox-default-project",
teamId: "inferred-team",
teamId: "team_default",
});
});

Expand Down
37 changes: 18 additions & 19 deletions packages/vercel-sandbox/src/auth/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,11 @@ import { fetchApi } from "./api.js";
import { NotOk } from "./error.js";
import { readLinkedProject } from "./linked-project.js";

const TeamsSchema = z.object({
teams: z
.array(
z.object({
slug: z.string(),
}),
)
.min(1, `No teams found. Please create a team first.`),
const UserSchema = z.object({
user: z.object({
defaultTeamId: z.string().nullable(),
username: z.string(),
}),
});

const DEFAULT_PROJECT_NAME = "vercel-sandbox-default-project";
Expand All @@ -21,16 +18,17 @@ const DEFAULT_PROJECT_NAME = "vercel-sandbox-default-project";
* First checks for a locally linked project in `.vercel/project.json`.
* If found, uses the `projectId` and `orgId` from there.
*
* Otherwise, if `teamId` is not provided, selects the first available team for the account.
* Ensures a default project exists within the team, creating it if necessary.
* Otherwise, if `teamId` is not provided, falls back to the authenticated user's
* default team (`defaultTeamId`), or their personal team (username) if no default
* is configured. Ensures a default project exists within the team, creating it if necessary.
*
* @param opts.token - Vercel API authentication token.
* @param opts.teamId - Optional team slug. If omitted, the first team is selected.
* @param opts.teamId - Optional team slug. If omitted, the user's default team is used.
* @param opts.cwd - Optional directory to search for `.vercel/project.json`. Defaults to `process.cwd()`.
* @returns The resolved scope with `projectId`, `teamId`, and whether the project was `created`.
*
* @throws {NotOk} If the API returns an error other than 404 when checking the project.
* @throws {ZodError} If no teams exist for the account.
* @throws {ZodError} If the user API response is missing a username.
*
* @example
* ```ts
Expand Down Expand Up @@ -76,17 +74,18 @@ export async function inferScope(opts: {
}

/**
* Selects a team for the current token by querying the Teams API and
* returning the slug of the first team in the result set.
* Falls back to the authenticated user's default team by fetching
* their profile from the `/v2/user` endpoint. Uses `defaultTeamId`
* if set, otherwise falls back to the user's personal team (username).
*
* @param token - Authentication token used to call the Vercel API.
* @returns A promise that resolves to the first team's slug.
* @returns A promise that resolves to the user's default team ID or username.
*/
export async function selectTeam(token: string) {
const {
teams: [team],
} = await fetchApi({ token, endpoint: "/v2/teams?limit=1" }).then(
TeamsSchema.parse,
user,
} = await fetchApi({ token, endpoint: "/v2/user" }).then(
UserSchema.parse,
);
return team.slug;
return user.defaultTeamId ?? user.username;
}
Loading