diff --git a/.changeset/slick-lands-repeat.md b/.changeset/slick-lands-repeat.md new file mode 100644 index 0000000..b359e2c --- /dev/null +++ b/.changeset/slick-lands-repeat.md @@ -0,0 +1,5 @@ +--- +"@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/vercel-sandbox/src/auth/index.ts b/packages/vercel-sandbox/src/auth/index.ts index f2c6d3c..7a57cfb 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, selectTeams } 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 c8e0810..afb800f 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, selectTeams } from "./project.js"; import { beforeEach, describe, @@ -27,17 +27,231 @@ async function getTempDir(): Promise { return dir; } -describe("selectTeam", () => { - test("returns the first team", async () => { - fetchApiMock.mockResolvedValue({ - teams: [{ slug: "one" }, { slug: "two" }], - }); - const team = await selectTeam("token"); - expect(fetchApiMock).toHaveBeenCalledWith({ - endpoint: "/v2/teams?limit=1", - token: "token", - }); - expect(team).toBe("one"); +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 }); + } + return Promise.resolve({}); + }; +} + +describe("selectTeams", () => { + test("returns defaultTeamId first, then best hobby owner team", async () => { + fetchApiMock.mockImplementation( + mockUserAndTeams({ + defaultTeamId: "team_default", + username: "my-user", + teams: [ + { + id: "team_default", + slug: "default-team", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + { + id: "team_other", + slug: "other-team", + updatedAt: 200, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + ], + }), + ); + + const result = await selectTeams("token"); + expect(result.candidateTeamIds).toEqual(["team_default", "team_other"]); + expect(result.username).toBe("my-user"); + }); + + 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 result = await selectTeams("token"); + expect(result.candidateTeamIds).toEqual(["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 result = await selectTeams("token"); + expect(result.candidateTeamIds).toEqual(["team_recent"]); + }); + + test("filters out non-OWNER teams", async () => { + fetchApiMock.mockImplementation( + mockUserAndTeams({ + defaultTeamId: null, + username: "my-user", + teams: [ + { + id: "team_owner", + slug: "owner-team", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + { + id: "team_member", + slug: "member-team", + updatedAt: 200, + membership: { role: "MEMBER" }, + billing: { plan: "hobby" }, + }, + ], + }), + ); + + const result = await selectTeams("token"); + expect(result.candidateTeamIds).toEqual(["team_owner"]); + }); + + test("filters out non-hobby plan teams", async () => { + fetchApiMock.mockImplementation( + mockUserAndTeams({ + defaultTeamId: null, + username: "my-user", + teams: [ + { + id: "team_hobby", + slug: "hobby-team", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + { + id: "team_pro", + slug: "pro-team", + updatedAt: 200, + membership: { role: "OWNER" }, + billing: { plan: "pro" }, + }, + ], + }), + ); + + const result = await selectTeams("token"); + expect(result.candidateTeamIds).toEqual(["team_hobby"]); + }); + + test("falls back to username when no teams and no defaultTeamId", async () => { + fetchApiMock.mockImplementation( + mockUserAndTeams({ + defaultTeamId: null, + username: "my-user", + teams: [], + }), + ); + + const result = await selectTeams("token"); + expect(result.candidateTeamIds).toEqual(["my-user"]); + }); + + test("does not duplicate defaultTeamId when it matches best hobby owner team", async () => { + fetchApiMock.mockImplementation( + mockUserAndTeams({ + defaultTeamId: "team_abc", + username: "my-user", + teams: [ + { + id: "team_abc", + slug: "abc-team", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + ], + }), + ); + + const result = await selectTeams("token"); + expect(result.candidateTeamIds).toEqual(["team_abc"]); + }); + + test("defaultTeamId may differ from best hobby owner team", async () => { + fetchApiMock.mockImplementation( + mockUserAndTeams({ + defaultTeamId: "team_nonowner", + username: "my-user", + teams: [ + { + id: "team_owner", + slug: "my-user", + updatedAt: 100, + membership: { role: "OWNER" }, + billing: { plan: "hobby" }, + }, + { + id: "team_nonowner", + slug: "nonowner-team", + updatedAt: 200, + membership: { role: "MEMBER" }, + billing: { plan: "pro" }, + }, + ], + }), + ); + + const result = await selectTeams("token"); + // defaultTeamId first (even though not hobby OWNER), then best hobby owner team as fallback + expect(result.candidateTeamIds).toEqual(["team_nonowner", "team_owner"]); }); }); @@ -52,7 +266,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 +282,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 +309,155 @@ 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" }, + }, + ], + }; + } + // 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", + }); }); - 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" }, + }, + ], + }; + } + 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: [] }; + } + 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: [] }; + } + 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("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" }, + }, + ], + }; + } + // 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", + }); }); }); diff --git a/packages/vercel-sandbox/src/auth/project.ts b/packages/vercel-sandbox/src/auth/project.ts index a97cec5..51b507e 100644 --- a/packages/vercel-sandbox/src/auth/project.ts +++ b/packages/vercel-sandbox/src/auth/project.ts @@ -3,14 +3,27 @@ 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 TeamsSchema = z.object({ - teams: z - .array( - z.object({ - slug: z.string(), + teams: z.array( + z.object({ + id: z.string(), + slug: z.string(), + updatedAt: z.number(), + membership: z.object({ + role: z.string(), }), - ) - .min(1, `No teams found. Please create a team first.`), + billing: z.object({ + plan: z.string(), + }), + }), + ), }); const DEFAULT_PROJECT_NAME = "vercel-sandbox-default-project"; @@ -21,16 +34,18 @@ 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, 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 @@ -48,13 +63,49 @@ export async function inferScope(opts: { return { ...linkedProject, created: false }; } - const teamId = opts.teamId ?? (await selectTeam(opts.token)); + if (opts.teamId) { + return tryTeam(opts.token, opts.teamId); + } + + const { candidateTeamIds, username } = await selectTeams(opts.token); + + for (const teamId of candidateTeamIds) { + try { + return await tryTeam(opts.token, teamId); + } catch (e) { + if (e instanceof NotOk && e.response.statusCode === 403) { + continue; + } + throw e; + } + } + + 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 +113,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 +127,50 @@ 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. + * Builds an ordered list of candidate team IDs to try for sandbox creation. + * + * Fetches the user profile and their teams in parallel. Returns the user's + * `defaultTeamId` first (if set), followed by the best hobby-plan OWNER team: + * the one whose slug matches the username, or the most recently updated. * * @param token - Authentication token used to call the Vercel API. - * @returns A promise that resolves to the first team's slug. + * @returns The ordered candidate team IDs and the username. */ -export async function selectTeam(token: string) { - const { - teams: [team], - } = await fetchApi({ token, endpoint: "/v2/teams?limit=1" }).then( - TeamsSchema.parse, +export async function selectTeams( + token: string, +): Promise<{ candidateTeamIds: string[]; username: string }> { + const [userData, teamsData] = await Promise.all([ + fetchApi({ token, endpoint: "/v2/user" }).then(UserSchema.parse), + fetchApi({ token, endpoint: "/v2/teams?limit=100" }).then( + TeamsSchema.parse, + ), + ]); + + const { defaultTeamId, username } = userData.user; + + const hobbyOwnerTeams = teamsData.teams.filter( + (t) => t.membership.role === "OWNER" && t.billing.plan === "hobby", ); - return team.slug; + + // Pick the personal team (slug matches username), or the most recently updated + const bestHobbyTeam = + hobbyOwnerTeams.find((t) => t.slug === username) ?? + hobbyOwnerTeams.sort((a, b) => b.updatedAt - a.updatedAt)[0]; + + const candidateTeamIds: string[] = []; + + if (defaultTeamId) { + candidateTeamIds.push(defaultTeamId); + } + + if (bestHobbyTeam && !candidateTeamIds.includes(bestHobbyTeam.id)) { + candidateTeamIds.push(bestHobbyTeam.id); + } + + // If no teams found at all, try the username as personal team + if (candidateTeamIds.length === 0) { + candidateTeamIds.push(username); + } + + return { candidateTeamIds, username }; }