diff --git a/.changeset/slick-lands-repeat.md b/.changeset/slick-lands-repeat.md new file mode 100644 index 00000000..827f9adf --- /dev/null +++ b/.changeset/slick-lands-repeat.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": patch +"@vercel/vercel-sandbox": patch +--- + +Smarter fallback team selection for scope inference: tries `defaultTeamId` first, then the best hobby-plan OWNER team (personal team or most recently updated). Filters fallback candidates by `billing.plan === 'hobby'` to avoid selecting pro/enterprise teams. Skips teams that return 403 and shows a helpful error when no team allows sandbox creation. diff --git a/packages/sandbox/src/commands/login.ts b/packages/sandbox/src/commands/login.ts index 870c9998..feefc60a 100644 --- a/packages/sandbox/src/commands/login.ts +++ b/packages/sandbox/src/commands/login.ts @@ -4,7 +4,12 @@ import { output } from "../util/output"; import { default as open } from "open"; import ora from "ora"; import { acquireRelease } from "../util/disposables"; -import { OAuth, pollForToken } from "@vercel/sandbox/dist/auth/index.js"; +import { + OAuth, + pollForToken, + getAuth, + inferScope, +} from "@vercel/sandbox/dist/auth/index.js"; import createDebugger from "debug"; const debug = createDebugger("sandbox:login"); @@ -108,6 +113,30 @@ export const login = cmd.command({ spinner.succeed( `${chalk.cyan("Congratulations!")} You are now signed in.`, ); + + const auth = getAuth(); + if (auth?.token) { + try { + const { teamId, teamSlug, projectId, projectSlug } = + await inferScope({ + token: auth.token, + }); + process.stderr.write( + chalk.dim(" │ ") + + "team: " + + chalk.cyan(teamSlug ?? teamId) + + "\n", + ); + process.stderr.write( + chalk.dim(" ╰ ") + + "project: " + + chalk.cyan(projectSlug ?? projectId) + + "\n", + ); + } catch { + // Scope inference is best-effort; don't fail the login + } + } } }, }); diff --git a/packages/sandbox/src/util/infer-scope.ts b/packages/sandbox/src/util/infer-scope.ts index c3d6325f..fc18707f 100644 --- a/packages/sandbox/src/util/infer-scope.ts +++ b/packages/sandbox/src/util/infer-scope.ts @@ -72,15 +72,14 @@ async function inferFromJwt(jwt: string) { } async function inferFromToken(token: string, requestedTeam?: string) { - const { teamId, projectId } = await Auth.inferScope({ + const { teamId, teamSlug, projectId, projectSlug } = await Auth.inferScope({ token, teamId: requestedTeam, }); - // Auth.inferScope returns team slug and project name (not IDs). return { owner: teamId, project: projectId, - ownerSlug: teamId, - projectSlug: projectId, + ownerSlug: teamSlug ?? teamId, + projectSlug: projectSlug ?? projectId, }; } diff --git a/packages/vercel-sandbox/src/auth/index.ts b/packages/vercel-sandbox/src/auth/index.ts index f2c6d3ce..c155d378 100644 --- a/packages/vercel-sandbox/src/auth/index.ts +++ b/packages/vercel-sandbox/src/auth/index.ts @@ -6,4 +6,4 @@ export type * from "./file.js"; export * from "./oauth.js"; export type * from "./oauth.js"; export { pollForToken } from "./poll-for-token.js"; -export { inferScope, selectTeam } from "./project.js"; +export { inferScope } from "./project.js"; diff --git a/packages/vercel-sandbox/src/auth/infer-scope.test.ts b/packages/vercel-sandbox/src/auth/infer-scope.test.ts index c8e08102..e7790493 100644 --- a/packages/vercel-sandbox/src/auth/infer-scope.test.ts +++ b/packages/vercel-sandbox/src/auth/infer-scope.test.ts @@ -1,4 +1,4 @@ -import { inferScope, selectTeam } from "./project.js"; +import { inferScope } from "./project.js"; import { beforeEach, describe, @@ -27,17 +27,209 @@ async function getTempDir(): Promise { return dir; } -describe("selectTeam", () => { - test("returns the first team", async () => { - fetchApiMock.mockResolvedValue({ - teams: [{ slug: "one" }, { slug: "two" }], +function mockUserAndTeams({ + defaultTeamId = null as string | null, + username = "my-user", + teams = [] as Array<{ + id: string; + slug: string; + updatedAt: number; + membership: { role: string }; + billing: { plan: string }; + }>, +} = {}) { + return (opts: { endpoint: string }) => { + if (opts.endpoint === "/v2/user") { + return Promise.resolve({ user: { defaultTeamId, username } }); + } + if (opts.endpoint.startsWith("/v2/teams")) { + return Promise.resolve({ + teams, + pagination: { count: teams.length, next: null }, + }); + } + return Promise.resolve({}); + }; +} + +describe("team selection from paginated results", () => { + test("prefers personal team (matching username slug) over most recently updated", async () => { + fetchApiMock.mockImplementation( + mockUserAndTeams({ + defaultTeamId: null, + username: "my-user", + teams: [ + { + id: "team_other", + slug: "other-team", + updatedAt: 300, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + { + id: "team_personal", + slug: "my-user", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + ], + }), + ); + + const scope = await inferScope({ token: "token" }); + expect(scope.teamId).toBe("team_personal"); + }); + + test("picks most recently updated hobby owner team when no username match", async () => { + fetchApiMock.mockImplementation( + mockUserAndTeams({ + defaultTeamId: null, + username: "my-user", + teams: [ + { + id: "team_old", + slug: "old-team", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + { + id: "team_recent", + slug: "recent-team", + updatedAt: 300, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + ], + }), + ); + + const scope = await inferScope({ token: "token" }); + expect(scope.teamId).toBe("team_recent"); + }); + + test("filters out non-OWNER and non-hobby teams", async () => { + fetchApiMock.mockImplementation( + mockUserAndTeams({ + defaultTeamId: null, + username: "my-user", + teams: [ + { + id: "team_member", + slug: "member-team", + updatedAt: 300, + membership: { role: "MEMBER" }, + billing: { plan: "hobby" }, + }, + { + id: "team_pro", + slug: "pro-team", + updatedAt: 200, + membership: { role: "OWNER" }, + billing: { plan: "pro" }, + }, + { + id: "team_good", + slug: "good-team", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + ], + }), + ); + + const scope = await inferScope({ token: "token" }); + expect(scope.teamId).toBe("team_good"); + }); + + test("falls back to username when no hobby owner teams found", async () => { + fetchApiMock.mockImplementation( + mockUserAndTeams({ + defaultTeamId: null, + username: "my-user", + teams: [], + }), + ); + + const scope = await inferScope({ token: "token" }); + expect(scope.teamId).toBe("my-user"); + }); + + test("skips hobby team that matches already-tried defaultTeamId", async () => { + fetchApiMock.mockImplementation(async ({ endpoint }) => { + if (endpoint === "/v2/user") { + return { + user: { defaultTeamId: "team_abc", username: "my-user" }, + }; + } + if (endpoint.startsWith("/v2/teams")) { + return { + teams: [ + { + id: "team_abc", + slug: "abc-team", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + ], + pagination: { count: 1, next: null }, + }; + } + // All project checks fail with 403 + throw new NotOk({ statusCode: 403, responseText: "Forbidden" }); }); - const team = await selectTeam("token"); - expect(fetchApiMock).toHaveBeenCalledWith({ - endpoint: "/v2/teams?limit=1", - token: "token", + + await expect(inferScope({ token: "token" })).rejects.toThrowError( + /none of the available teams allow sandbox creation/, + ); + // team_abc tried once (defaultTeamId), then skipped in pagination, then username fallback + const projectCalls = fetchApiMock.mock.calls.filter(([{ endpoint }]) => + endpoint.includes("vercel-sandbox-default-project"), + ); + expect(projectCalls).toHaveLength(2); + }); + + test("paginates through multiple pages to find a usable team", async () => { + fetchApiMock.mockImplementation(async ({ endpoint }) => { + if (endpoint === "/v2/user") { + return { user: { defaultTeamId: null, username: "my-user" } }; + } + if (endpoint === "/v2/teams?limit=20") { + return { + teams: [ + { + id: "team_pro", + slug: "pro-team", + updatedAt: 300, + membership: { role: "OWNER" }, + billing: { plan: "pro" }, + }, + ], + pagination: { count: 1, next: 12345 }, + }; + } + if (endpoint === "/v2/teams?limit=20&until=12345") { + return { + teams: [ + { + id: "team_hobby", + slug: "hobby-team", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + ], + pagination: { count: 1, next: null }, + }; + } + return {}; }); - expect(team).toBe("one"); + + const scope = await inferScope({ token: "token" }); + expect(scope.teamId).toBe("team_hobby"); }); }); @@ -52,7 +244,7 @@ describe("inferScope", () => { }); }); - describe("team creation", () => { + describe("project creation", () => { test("project 404 triggers project creation", async () => { fetchApiMock.mockImplementation(async ({ method }) => { if (!method || method === "GET") { @@ -68,7 +260,7 @@ describe("inferScope", () => { }); }); - test("non-404 throws", async () => { + test("non-404 throws when teamId is explicit", async () => { fetchApiMock.mockImplementation(async ({ method }) => { if (!method || method === "GET") { throw new NotOk({ statusCode: 403, responseText: "Forbidden" }); @@ -95,18 +287,200 @@ describe("inferScope", () => { }); }); - test("infers the team", async () => { - fetchApiMock.mockImplementation(async ({ endpoint }) => { - if (endpoint === "/v2/teams?limit=1") { - return { teams: [{ slug: "inferred-team" }] }; - } - return {}; + describe("fallback team selection with 403 handling", () => { + test("falls back to hobby owner team when defaultTeamId returns 403", async () => { + fetchApiMock.mockImplementation(async ({ endpoint }) => { + if (endpoint === "/v2/user") { + return { + user: { defaultTeamId: "team_readonly", username: "my-user" }, + }; + } + if (endpoint.startsWith("/v2/teams")) { + return { + teams: [ + { + id: "team_writable", + slug: "my-user", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + ], + pagination: { count: 1, next: null }, + }; + } + // Project check: 403 for readonly default team, success for owner team + if (endpoint.includes("teamId=team_readonly")) { + throw new NotOk({ statusCode: 403, responseText: "Forbidden" }); + } + return {}; + }); + + const scope = await inferScope({ token: "token" }); + expect(scope).toEqual({ + created: false, + projectId: "vercel-sandbox-default-project", + teamId: "team_writable", + teamSlug: "my-user", + }); }); - const scope = await inferScope({ token: "token" }); - expect(scope).toEqual({ - created: false, - projectId: "vercel-sandbox-default-project", - teamId: "inferred-team", + + test("throws helpful error when all candidates return 403", async () => { + fetchApiMock.mockImplementation(async ({ endpoint }) => { + if (endpoint === "/v2/user") { + return { + user: { defaultTeamId: "team_readonly", username: "my-user" }, + }; + } + if (endpoint.startsWith("/v2/teams")) { + return { + teams: [ + { + id: "team_owner", + slug: "my-user", + updatedAt: 200, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + ], + pagination: { count: 1, next: null }, + }; + } + throw new NotOk({ statusCode: 403, responseText: "Forbidden" }); + }); + + await expect(inferScope({ token: "token" })).rejects.toThrowError( + /Authenticated as "my-user" but none of the available teams allow sandbox creation\. Specify a team explicitly with --scope/, + ); + }); + + test("uses defaultTeamId when it succeeds", async () => { + fetchApiMock.mockImplementation(async ({ endpoint }) => { + if (endpoint === "/v2/user") { + return { + user: { defaultTeamId: "team_default", username: "my-user" }, + }; + } + if (endpoint.startsWith("/v2/teams")) { + return { teams: [], pagination: { count: 0, next: null } }; + } + return {}; + }); + + const scope = await inferScope({ token: "token" }); + expect(scope).toEqual({ + created: false, + projectId: "vercel-sandbox-default-project", + teamId: "team_default", + }); + }); + + test("creates project in fallback team when it returns 404", async () => { + fetchApiMock.mockImplementation(async ({ endpoint, method }) => { + if (endpoint === "/v2/user") { + return { + user: { defaultTeamId: "team_default", username: "my-user" }, + }; + } + if (endpoint.startsWith("/v2/teams")) { + return { teams: [], pagination: { count: 0, next: null } }; + } + if ( + endpoint.includes("teamId=team_default") && + (!method || method === "GET") + ) { + throw new NotOk({ statusCode: 404, responseText: "Not Found" }); + } + return {}; + }); + + const scope = await inferScope({ token: "token" }); + expect(scope).toEqual({ + created: true, + projectId: "vercel-sandbox-default-project", + teamId: "team_default", + }); + }); + + test("falls back to hobby owner team when defaultTeamId returns 402 RESOURCE_CREATION_BLOCKED", async () => { + fetchApiMock.mockImplementation(async ({ endpoint }) => { + if (endpoint === "/v2/user") { + return { + user: { defaultTeamId: "team_blocked", username: "my-user" }, + }; + } + if (endpoint.startsWith("/v2/teams")) { + return { + teams: [ + { + id: "team_writable", + slug: "my-user", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + ], + pagination: { count: 1, next: null }, + }; + } + // 402 for the blocked team (RESOURCE_CREATION_BLOCKED) + if (endpoint.includes("teamId=team_blocked")) { + throw new NotOk({ + statusCode: 402, + responseText: "RESOURCE_CREATION_BLOCKED: Your Team encountered an unknown problem.", + }); + } + return {}; + }); + + const scope = await inferScope({ token: "token" }); + expect(scope).toEqual({ + created: false, + projectId: "vercel-sandbox-default-project", + teamId: "team_writable", + teamSlug: "my-user", + }); + }); + + test("tries next candidate when project creation returns 403", async () => { + fetchApiMock.mockImplementation(async ({ endpoint, method }) => { + if (endpoint === "/v2/user") { + return { + user: { defaultTeamId: "team_nocreate", username: "my-user" }, + }; + } + if (endpoint.startsWith("/v2/teams")) { + return { + teams: [ + { + id: "team_good", + slug: "good-team", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + ], + pagination: { count: 1, next: null }, + }; + } + // team_nocreate: project check 404, project creation 403 + if (endpoint.includes("teamId=team_nocreate")) { + if (!method || method === "GET") { + throw new NotOk({ statusCode: 404, responseText: "Not Found" }); + } + throw new NotOk({ statusCode: 403, responseText: "Forbidden" }); + } + // team_good: success + return {}; + }); + + const scope = await inferScope({ token: "token" }); + expect(scope).toEqual({ + created: false, + projectId: "vercel-sandbox-default-project", + teamId: "team_good", + teamSlug: "good-team", + }); }); }); @@ -122,15 +496,25 @@ describe("inferScope", () => { }), ); + fetchApiMock.mockImplementation(async ({ endpoint }) => { + if (endpoint.includes("/v2/teams/")) { + return { slug: "linked-team" }; + } + if (endpoint.includes("/v2/projects/")) { + return { name: "linked-project" }; + } + return {}; + }); + const scope = await inferScope({ token: "token", cwd: dir }); expect(scope).toEqual({ created: false, projectId: "prj_linked", teamId: "team_linked", + teamSlug: "linked-team", + projectSlug: "linked-project", }); - // Should not call API when using linked project - expect(fetchApiMock).not.toHaveBeenCalled(); }); test("falls back to default project when .vercel/project.json does not exist", async () => { diff --git a/packages/vercel-sandbox/src/auth/project.ts b/packages/vercel-sandbox/src/auth/project.ts index a97cec5f..85c78641 100644 --- a/packages/vercel-sandbox/src/auth/project.ts +++ b/packages/vercel-sandbox/src/auth/project.ts @@ -3,34 +3,58 @@ import { fetchApi } from "./api.js"; import { NotOk } from "./error.js"; import { readLinkedProject } from "./linked-project.js"; +const UserSchema = z.object({ + user: z.object({ + defaultTeamId: z.string().nullable(), + username: z.string(), + }), +}); + +const TeamSchema = z.object({ + id: z.string(), + slug: z.string(), + updatedAt: z.number(), + membership: z.object({ + role: z.string(), + }), + billing: z.object({ + plan: z.string(), + }), +}); + const TeamsSchema = z.object({ - teams: z - .array( - z.object({ - slug: z.string(), - }), - ) - .min(1, `No teams found. Please create a team first.`), + teams: z.array(TeamSchema), + pagination: z.object({ + count: z.number(), + next: z.number().nullable(), + }), }); const DEFAULT_PROJECT_NAME = "vercel-sandbox-default-project"; +/** Status codes that mean "this team can't be used, try the next one". */ +function isSkippableTeamError(e: unknown): boolean { + return e instanceof NotOk && (e.response.statusCode === 402 || e.response.statusCode === 403); +} + /** * Resolves the team and project scope for sandbox operations. * * 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, builds an ordered list of candidate + * teams to try: the user's `defaultTeamId` first (if set), then hobby-plan + * teams where the user has an OWNER role (preferring the personal team matching + * the username, then the most recently updated). Tries each candidate until one + * succeeds. * * @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, candidate teams are resolved automatically. * @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. * * @example * ```ts @@ -42,19 +66,120 @@ export async function inferScope(opts: { token: string; teamId?: string; cwd?: string; -}): Promise<{ projectId: string; teamId: string; created: boolean }> { +}): Promise<{ + projectId: string; + teamId: string; + created: boolean; + teamSlug?: string; + projectSlug?: string; +}> { const linkedProject = await readLinkedProject(opts.cwd ?? process.cwd()); if (linkedProject) { - return { ...linkedProject, created: false }; + const slugs = await resolveLinkedProjectSlugs( + opts.token, + linkedProject.teamId, + linkedProject.projectId, + ); + return { ...linkedProject, created: false, ...slugs }; + } + + if (opts.teamId) { + return tryTeam(opts.token, opts.teamId); + } + + const userData = await fetchApi({ + token: opts.token, + endpoint: "/v2/user", + }).then(UserSchema.parse); + const { defaultTeamId, username } = userData.user; + + // 1. Try defaultTeamId first + if (defaultTeamId) { + try { + const result = await tryTeam(opts.token, defaultTeamId); + // Resolve team slug (best-effort) + try { + const team = await fetchApi({ + token: opts.token, + endpoint: `/v2/teams/${encodeURIComponent(defaultTeamId)}`, + }).then(z.object({ slug: z.string() }).parse); + return { ...result, teamSlug: team.slug }; + } catch { + return result; + } + } catch (e) { + if (!isSkippableTeamError(e)) throw e; + } + } + + // 2. Paginate teams in pages of 20, try best hobby team per page + let next: number | null = null; + do { + const endpoint: string = + next === null + ? "/v2/teams?limit=20" + : `/v2/teams?limit=20&until=${next}`; + const page = await fetchApi({ token: opts.token, endpoint }).then( + TeamsSchema.parse, + ); + + next = page.pagination.next; + + const hobbyOwnerTeams = page.teams.filter( + (t) => t.membership.role === "OWNER" && t.billing.plan === "hobby", + ); + if (hobbyOwnerTeams.length === 0) { + continue; + } + + const bestHobbyTeam = + hobbyOwnerTeams.find((t) => t.slug === username) ?? + hobbyOwnerTeams.sort((a, b) => b.updatedAt - a.updatedAt)[0]; + + if (bestHobbyTeam && bestHobbyTeam.id !== defaultTeamId) { + try { + const result = await tryTeam(opts.token, bestHobbyTeam.id); + return { ...result, teamSlug: bestHobbyTeam.slug }; + } catch (e) { + if (!isSkippableTeamError(e)) throw e; + } + } + } while (next !== null); + + // 3. Fall back to username as personal team + try { + const result = await tryTeam(opts.token, username); + return { ...result, teamSlug: username }; + } catch (e) { + if (!isSkippableTeamError(e)) throw e; } - const teamId = opts.teamId ?? (await selectTeam(opts.token)); + throw new NotOk({ + statusCode: 403, + responseText: `Authenticated as "${username}" but none of the available teams allow sandbox creation. Specify a team explicitly with --scope .`, + }); +} + +/** + * Attempts to use a specific team for sandbox operations by checking for + * (or creating) the default project within that team. + * + * @returns The resolved scope if the team is usable. + * @throws {NotOk} On authorization or other API errors. + */ +async function tryTeam( + token: string, + teamId: string, +): Promise<{ projectId: string; teamId: string; created: boolean }> { + const teamParam = teamId.startsWith("team_") + ? `teamId=${encodeURIComponent(teamId)}` + : `slug=${encodeURIComponent(teamId)}`; let created = false; try { await fetchApi({ - token: opts.token, - endpoint: `/v2/projects/${encodeURIComponent(DEFAULT_PROJECT_NAME)}?slug=${encodeURIComponent(teamId)}`, + token, + endpoint: `/v2/projects/${encodeURIComponent(DEFAULT_PROJECT_NAME)}?${teamParam}`, }); } catch (e) { if (!(e instanceof NotOk) || e.response.statusCode !== 404) { @@ -62,8 +187,8 @@ export async function inferScope(opts: { } await fetchApi({ - token: opts.token, - endpoint: `/v11/projects?slug=${encodeURIComponent(teamId)}`, + token, + endpoint: `/v11/projects?${teamParam}`, method: "POST", body: JSON.stringify({ name: DEFAULT_PROJECT_NAME, @@ -76,17 +201,31 @@ 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. - * - * @param token - Authentication token used to call the Vercel API. - * @returns A promise that resolves to the first team's slug. + * Best-effort resolution of team slug and project name for a linked project. + * Both IDs may be opaque (e.g. `team_xxx`, `prj_xxx`), so we fetch the + * human-readable names from the API in parallel. */ -export async function selectTeam(token: string) { - const { - teams: [team], - } = await fetchApi({ token, endpoint: "/v2/teams?limit=1" }).then( - TeamsSchema.parse, - ); - return team.slug; +async function resolveLinkedProjectSlugs( + token: string, + teamId: string, + projectId: string, +): Promise<{ teamSlug?: string; projectSlug?: string }> { + try { + const teamParam = teamId.startsWith("team_") + ? `teamId=${encodeURIComponent(teamId)}` + : `slug=${encodeURIComponent(teamId)}`; + const [teamData, projectData] = await Promise.all([ + fetchApi({ + token, + endpoint: `/v2/teams/${encodeURIComponent(teamId)}`, + }).then(z.object({ slug: z.string() }).parse), + fetchApi({ + token, + endpoint: `/v2/projects/${encodeURIComponent(projectId)}?${teamParam}`, + }).then(z.object({ name: z.string() }).parse), + ]); + return { teamSlug: teamData.slug, projectSlug: projectData.name }; + } catch { + return {}; + } }