From 0e60e1d05020b76a5650bcfb48dd4379409d671c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Thu, 28 May 2026 20:19:13 +0200 Subject: [PATCH 01/17] feat(project): fall back to org-scoped endpoint for member project creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sentry project create and sentry init both previously failed with 403 for org members who can't create teams — the team-scoped endpoint (POST /teams/{org}/{team}/projects/) requires project:write, which members don't have. Add a fallback to POST /organizations/{org}/projects/, which only requires project:read and auto-creates a personal team (team-{username}) for the caller. This mirrors what the Sentry onboarding UI does. Changes: - projects.ts: add createProjectWithAutoTeam (+ ProjectWithAutoTeam type) that calls POST /organizations/{org}/projects/ - resolve-team.ts: re-throw ApiError 403 from autoCreateTeam instead of wrapping in CliError, so callers can detect and fall back - create.ts: on 403 from team resolution or project creation, call createProjectWithAutoTeamFallback; skip fallback when --team is explicit - create-sentry-project.ts: same fallback via resolveProjectCreation helper Both helpers are extracted to keep the calling functions under the complexity limit (biome noExcessiveCognitiveComplexity, max 15). --- src/commands/project/create.ts | 128 +++++++++++++++++--- src/lib/api-client.ts | 2 + src/lib/api/projects.ts | 43 +++++++ src/lib/init/tools/create-sentry-project.ts | 106 +++++++++++----- src/lib/resolve-team.ts | 6 + 5 files changed, 237 insertions(+), 48 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 2b1fa5287..723f3281d 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -19,8 +19,10 @@ import type { SentryContext } from "../../context.js"; import { type CreatedProjectDetails, + createProjectWithAutoTeam, createProjectWithDsn, listTeams, + tryGetPrimaryDsn, } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; @@ -52,6 +54,7 @@ import { type ResolvedConcreteTeam, resolveOrCreateTeam, } from "../../lib/resolve-team.js"; +import { buildProjectUrl } from "../../lib/sentry-urls.js"; import { slugify } from "../../lib/utils.js"; const log = logger.withTag("project.create"); @@ -224,6 +227,58 @@ async function handleCreateProject404(opts: { ); } +/** + * Fallback project creation via POST /organizations/{org}/projects/. + * + * Used when the team-scoped flow 403s (member lacks project:write or can't + * create teams). Returns the created project details plus the team slug the + * server auto-created. Surfaces a clear policy error if the org has disabled + * member project creation entirely. + */ +async function createProjectWithAutoTeamFallback(opts: { + orgSlug: string; + name: string; + platform: string; +}): Promise< + CreatedProjectDetails & { + teamSlug: string; + teamSource: ResolvedConcreteTeam["source"]; + } +> { + const { orgSlug, name, platform } = opts; + let autoTeamProject: Awaited>; + try { + autoTeamProject = await createProjectWithAutoTeam(orgSlug, { + name, + platform, + }); + } catch (expError) { + if ( + expError instanceof ApiError && + expError.status === 403 && + expError.detail?.includes("disabled this feature") + ) { + throw new ApiError( + `Failed to create project '${name}' in ${orgSlug} (HTTP 403).\n\n` + + "Your organization has disabled project creation for members.\n" + + "Ask an org owner or manager to enable it in Organization Settings → Member Roles,\n" + + "or ask them to create the project and add you to it.", + 403, + expError.detail, + expError.endpoint + ); + } + throw expError; + } + return { + project: autoTeamProject, + dsn: await tryGetPrimaryDsn(orgSlug, autoTeamProject.slug), + url: buildProjectUrl(orgSlug, autoTeamProject.slug), + teamSlug: autoTeamProject.team_slug, + teamSource: "auto-created", + }; +} + /** * Create a project (with DSN + URL) with user-friendly error handling. * Wraps API errors with actionable messages instead of raw HTTP status codes. @@ -389,19 +444,17 @@ export const createCommand = buildCommand({ } const orgSlug = resolved.org; - // Resolve team — auto-creates a team if the org has none - const team: ResolvedConcreteTeam = await resolveOrCreateTeam(orgSlug, { - team: flags.team, - detectedFrom: resolved.detectedFrom, - usageHint: USAGE_HINT, - autoCreateSlug: slugify(name), - dryRun: flags["dry-run"], - }); - const expectedSlug = slugify(name); - // Dry-run mode: show what would be created without creating it + // Dry-run mode: resolve team (or preview auto-create) without hitting create APIs if (flags["dry-run"]) { + const team: ResolvedConcreteTeam = await resolveOrCreateTeam(orgSlug, { + team: flags.team, + detectedFrom: resolved.detectedFrom, + usageHint: USAGE_HINT, + autoCreateSlug: expectedSlug, + dryRun: true, + }); const result: ProjectCreatedResult = { project: { id: "", slug: expectedSlug, name, platform }, orgSlug, @@ -417,20 +470,55 @@ export const createCommand = buildCommand({ return yield new CommandOutput(result); } - // Create the project, fetch DSN, and build URL - const { project, dsn, url } = await createProjectWithErrors({ - orgSlug, - teamSlug: team.slug, - name, - platform, - detectedFrom: resolved.detectedFrom, - }); + // Attempt the normal team-based flow. + // If either step 403s (member can't create/see teams, or lacks project:write on + // the team), fall back to POST /organizations/{org}/projects/ which mirrors + // what the Sentry onboarding UI uses: auto-creates a personal team for the + // caller and only requires project:read scope. + let teamSlug: string; + let teamSource: ResolvedConcreteTeam["source"]; + let projectDetails: CreatedProjectDetails; + + try { + const team: ResolvedConcreteTeam = await resolveOrCreateTeam(orgSlug, { + team: flags.team, + detectedFrom: resolved.detectedFrom, + usageHint: USAGE_HINT, + autoCreateSlug: expectedSlug, + }); + teamSlug = team.slug; + teamSource = team.source; + projectDetails = await createProjectWithErrors({ + orgSlug, + teamSlug, + name, + platform, + detectedFrom: resolved.detectedFrom, + }); + } catch (error) { + // 403 means the user lacks permission to create or access teams, or to + // create projects on the resolved team. Fall back to the org-scoped endpoint + // which requires only project:read and auto-creates a personal team. + // Skip the fallback when --team was explicit: the 403 is meaningful there. + if (!(error instanceof ApiError && error.status === 403) || flags.team) { + throw error; + } + const fallback = await createProjectWithAutoTeamFallback({ + orgSlug, + name, + platform, + }); + teamSlug = fallback.teamSlug; + teamSource = fallback.teamSource; + projectDetails = fallback; + } + const { project, dsn, url } = projectDetails; const result: ProjectCreatedResult = { project, orgSlug, - teamSlug: team.slug, - teamSource: team.source, + teamSlug, + teamSource, requestedPlatform: platform, dsn, url, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 02d37c76e..9d5d81af8 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -93,6 +93,7 @@ export { export { type CreatedProjectDetails, createProject, + createProjectWithAutoTeam, createProjectWithDsn, deleteProject, findProjectByDsnKey, @@ -104,6 +105,7 @@ export { listProjectsPaginated, matchesWordBoundary, type ProjectSearchResult, + type ProjectWithAutoTeam, type ProjectWithOrg, resolveOrgDisplayName, tryGetPrimaryDsn, diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index 86c369ae2..69171b3ac 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -27,6 +27,7 @@ import { } from "../db/project-cache.js"; import { getCachedOrganizations } from "../db/regions.js"; import { type AuthGuardSuccess, withAuthGuard } from "../errors.js"; +import { resolveOrgRegion } from "../region.js"; import { getApiBaseUrl } from "../sentry-client.js"; import { buildProjectUrl } from "../sentry-urls.js"; import { isAllDigits } from "../utils.js"; @@ -226,6 +227,48 @@ export async function createProjectWithDsn( return { project, dsn, url }; } +/** Result of creating a project via the org-scoped member-accessible endpoint. */ +export type ProjectWithAutoTeam = SentryProject & { + /** The personal team that was auto-created for the requesting user. */ + team_slug: string; +}; + +/** + * Create a new project via the org-scoped member-accessible endpoint. + * + * Unlike `createProject` (which posts to `/teams/{org}/{team}/projects/` and + * requires `project:write`), this endpoint only requires `project:read` scope + * and is accessible to org members. The server auto-creates a personal team + * named `team-{username}` for the caller with Team Admin role, then creates + * the project under it. + * + * The org must have `allowMemberProjectCreation = true` (i.e. the org flag + * `disable_member_project_creation` must be false). A 403 is returned + * otherwise — callers should surface that as an org policy error, not an + * auth issue. + * + * This mirrors the endpoint called by the Sentry onboarding UI when a member + * selects a platform for the first time. + * + * @param orgSlug - The organization slug + * @param body - Project creation parameters (name required, platform optional) + * @returns The created project with the auto-created team slug + * @throws {ApiError} 403 if member project creation is disabled for the org + * @throws {ApiError} 409 if a project with the same slug already exists + */ +export async function createProjectWithAutoTeam( + orgSlug: string, + body: CreateProjectBody +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/projects/`, + { method: "POST", body } + ); + return data; +} + /** * Delete a project from an organization. * diff --git a/src/lib/init/tools/create-sentry-project.ts b/src/lib/init/tools/create-sentry-project.ts index 431d3af05..7e1765348 100644 --- a/src/lib/init/tools/create-sentry-project.ts +++ b/src/lib/init/tools/create-sentry-project.ts @@ -1,6 +1,10 @@ -import { createProjectWithDsn } from "../../api-client.js"; +import { + createProjectWithAutoTeam, + createProjectWithDsn, +} from "../../api-client.js"; import { ApiError } from "../../errors.js"; import { resolveOrCreateTeam } from "../../resolve-team.js"; +import { buildProjectUrl } from "../../sentry-urls.js"; import { slugify } from "../../utils.js"; import { tryGetExistingProjectData } from "../existing-project.js"; import type { @@ -11,6 +15,63 @@ import type { import { formatToolError } from "./shared.js"; import type { InitToolDefinition, ToolContext } from "./types.js"; +type ProjectData = { + projectSlug: string; + projectId: string; + dsn: string; + url: string; +}; + +/** + * Resolve project creation using the team-based flow, falling back to the + * org-scoped endpoint on 403 (member lacks team creation permission). + */ +async function resolveProjectCreation(opts: { + org: string; + name: string; + platform: string | null | undefined; + explicitTeam: string | undefined; + slugHint: string; +}): Promise { + const { org, name, platform, explicitTeam, slugHint } = opts; + try { + const teamSlug = explicitTeam + ? explicitTeam + : ( + await resolveOrCreateTeam(org, { + autoCreateSlug: slugHint, + usageHint: "sentry init", + }) + ).slug; + const result = await createProjectWithDsn(org, teamSlug, { + name, + platform, + }); + return { + projectSlug: result.project.slug, + projectId: result.project.id, + dsn: result.dsn ?? "", + url: result.url, + }; + } catch (innerError) { + // Fall back to org-scoped endpoint on 403, unless an explicit team was + // given (in which case the 403 is meaningful feedback, not a permissions gap). + if ( + !(innerError instanceof ApiError && innerError.status === 403) || + explicitTeam + ) { + throw innerError; + } + const autoTeam = await createProjectWithAutoTeam(org, { name, platform }); + return { + projectSlug: autoTeam.slug, + projectId: autoTeam.id, + url: buildProjectUrl(org, autoTeam.slug), + dsn: "", // init callers use the URL; DSN is fetched separately when needed + }; + } +} + /** * Create a new Sentry project using the org that preflight already resolved. * Team creation is deferred here for empty-org init flows so the final project @@ -57,16 +118,6 @@ export async function createSentryProject( }; } - const teamSlug = context.team - ? context.team - : ( - await resolveOrCreateTeam(context.org, { - autoCreateSlug: slug, - usageHint: "sentry init", - dryRun: context.dryRun, - }) - ).slug; - if (context.dryRun) { return { ok: true, @@ -80,31 +131,30 @@ export async function createSentryProject( }; } - const { project, dsn, url } = await createProjectWithDsn( - context.org, - teamSlug, - { - name, - platform: payload.params.platform, - } - ); + // Try the normal team-based flow. If the user is an org member who can't + // create or see teams (403), fall back to POST /organizations/{org}/projects/ + // which requires only project:read scope and auto-creates a personal team. + const projectData = await resolveProjectCreation({ + org: context.org, + name, + platform: payload.params.platform, + explicitTeam: context.team, + slugHint: slug, + }); return { ok: true, data: { orgSlug: context.org, - projectSlug: project.slug, - projectId: project.id, - dsn: dsn ?? "", - url, + projectSlug: projectData.projectSlug, + projectId: projectData.projectId, + dsn: projectData.dsn, + url: projectData.url, }, }; } catch (error) { - // Org-level policy: members cannot create projects. The generic 403 - // enrichment would suggest re-authentication, which is wrong here. - // Surface a clear message with the escape hatch: once an admin creates - // the project, `sentry init /` resolves to the existing - // project and skips creation entirely. + // Org-level policy: member project creation is disabled on this org. + // Surface a clear message with the escape hatch. if ( error instanceof ApiError && error.status === 403 && diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts index ee6022ab2..ee6d835df 100644 --- a/src/lib/resolve-team.ts +++ b/src/lib/resolve-team.ts @@ -234,6 +234,12 @@ async function autoCreateTeam( if (error instanceof AuthError) { throw error; } + // 403 means the user lacks permission to create teams (e.g., org member role). + // Re-throw as ApiError so callers can fall back to the experimental + // member-accessible project creation endpoint instead of showing a dead-end error. + if (error instanceof ApiError && error.status === 403) { + throw error; + } // Other failures (permissions, network, etc.) — surface with manual fallback throw new CliError( `No teams found in org '${orgSlug}' and automatic team creation failed.\n\n` + From aefadeda405e3e957c4409a4d93e1f899dc0f8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Thu, 28 May 2026 22:08:15 +0200 Subject: [PATCH 02/17] fix: update tests and fix null platform type for member project creation fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create-sentry-project.ts: coerce platform null → undefined to match CreateProjectBody type (TS2322) - create.test.ts: add createProjectWithAutoTeamSpy; split the 403 test into three cases: non-403 errors surface directly, 403 tries fallback, 403 with org policy disabled surfaces policy error --- src/lib/init/tools/create-sentry-project.ts | 4 +- test/commands/project/create.test.ts | 70 ++++++++++++++++++--- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/lib/init/tools/create-sentry-project.ts b/src/lib/init/tools/create-sentry-project.ts index 7e1765348..c741fbb87 100644 --- a/src/lib/init/tools/create-sentry-project.ts +++ b/src/lib/init/tools/create-sentry-project.ts @@ -33,7 +33,9 @@ async function resolveProjectCreation(opts: { explicitTeam: string | undefined; slugHint: string; }): Promise { - const { org, name, platform, explicitTeam, slugHint } = opts; + const { org, name, explicitTeam, slugHint } = opts; + // Coerce null → undefined: CreateProjectBody.platform is string | undefined. + const platform = opts.platform ?? undefined; try { const teamSlug = explicitTeam ? explicitTeam diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 96d571c7d..4d6f44c07 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -82,6 +82,9 @@ describe("project create", () => { // With vitest auto-mock, createProjectWithDsn is a vi.fn() stub that // doesn't internally call createProject, so we assert on this spy. const createProjectWithDsnSpy = vi.mocked(projectsApi.createProjectWithDsn); + const createProjectWithAutoTeamSpy = vi.mocked( + projectsApi.createProjectWithAutoTeam + ); const createTeamSpy = vi.mocked(teamsApi.createTeam); const tryGetPrimaryDsnSpy = vi.mocked(projectsApi.tryGetPrimaryDsn); const listOrgsSpy = vi.mocked(orgsApi.listOrganizations); @@ -102,6 +105,14 @@ describe("project create", () => { dsn: "https://abc@o123.ingest.us.sentry.io/999", url: "https://sentry.io/organizations/acme-corp/projects/my-app/", }); + // Default: org-scoped fallback is disabled (matches the common org config). + createProjectWithAutoTeamSpy.mockRejectedValue( + new ApiError( + "Forbidden", + 403, + "Your organization has disabled this feature for members." + ) + ); createTeamSpy.mockResolvedValue(sampleTeam); tryGetPrimaryDsnSpy.mockResolvedValue( "https://abc@o123.ingest.us.sentry.io/999" @@ -115,6 +126,7 @@ describe("project create", () => { afterEach(() => { listTeamsSpy.mockReset(); createProjectWithDsnSpy.mockReset(); + createProjectWithAutoTeamSpy.mockReset(); createTeamSpy.mockReset(); tryGetPrimaryDsnSpy.mockReset(); listOrgsSpy.mockReset(); @@ -444,8 +456,10 @@ describe("project create", () => { }); test("wraps other API errors with context, preserving ApiError type", async () => { + // createProjectWithDsn fails with a non-403 server error (e.g. 500). + // The fallback is only attempted on 403; this should surface directly. createProjectWithDsnSpy.mockRejectedValue( - new ApiError("API request failed: 403 Forbidden", 403, "No permission") + new ApiError("Internal Server Error", 500, "Something went wrong") ); const { context } = createMockContext(); @@ -454,17 +468,53 @@ describe("project create", () => { const err = (await func .call(context, { json: false }, "my-app", "node") .catch((e: Error) => e)) as ApiError; - // Stays ApiError (not a plain CliError wrapper) so the 401–499 - // user-error silencing in error-reporting.ts still applies. + // Stays ApiError (not a plain CliError wrapper) so 5xx errors are + // captured for error reporting. expect(err).toBeInstanceOf(ApiError); - expect(err.status).toBe(403); - expect(err.detail).toBe("No permission"); + expect(err.status).toBe(500); expect(err.message).toContain("Failed to create project"); - expect(err.message).toContain("403"); - // Detail is NOT duplicated in message — ApiError.format() appends it. - expect(err.message).not.toContain("No permission"); - // But format() surfaces it for the user - expect(err.format()).toContain("No permission"); + expect(err.message).toContain("500"); + expect(err.format()).toContain("Something went wrong"); + }); + + test("falls back to org-scoped endpoint when team-based creation 403s", async () => { + // Simulate: member has a team (via listTeams) but can't create projects on it. + // The fallback to POST /organizations/{org}/projects/ should kick in. + createProjectWithDsnSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + createProjectWithAutoTeamSpy.mockResolvedValue({ + ...sampleProject, + team_slug: "team-testuser", + }); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-app", "node"); + + expect(createProjectWithAutoTeamSpy).toHaveBeenCalledWith("acme-corp", { + name: "my-app", + platform: "node", + }); + }); + + test("surfaces policy error when org has disabled member project creation", async () => { + // Both paths 403: team-based creation fails, and the fallback returns + // the org-level policy error ("disabled this feature"). + createProjectWithDsnSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + // createProjectWithAutoTeamSpy already defaults to "disabled this feature" 403 + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = (await func + .call(context, { json: false }, "my-app", "node") + .catch((e: Error) => e)) as ApiError; + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(403); + expect(err.message).toContain("disabled project creation for members"); }); test("outputs JSON when --json flag is set", async () => { From 68a5d9dea34465f9a1e5c5bdf8f8a739ef09ae3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Thu, 28 May 2026 22:22:10 +0200 Subject: [PATCH 03/17] fix: fetch DSN in org-scoped fallback path during sentry init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback to POST /organizations/{org}/projects/ was returning dsn: "" — the comment claimed DSN was fetched separately downstream, but no such secondary fetch exists in the init flow. Mastra would receive an empty DSN and generate SDK config without a valid key. Mirror create.ts which correctly calls tryGetPrimaryDsn after the auto-team project is created. --- src/lib/init/tools/create-sentry-project.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/init/tools/create-sentry-project.ts b/src/lib/init/tools/create-sentry-project.ts index c741fbb87..8d3ba2044 100644 --- a/src/lib/init/tools/create-sentry-project.ts +++ b/src/lib/init/tools/create-sentry-project.ts @@ -1,6 +1,7 @@ import { createProjectWithAutoTeam, createProjectWithDsn, + tryGetPrimaryDsn, } from "../../api-client.js"; import { ApiError } from "../../errors.js"; import { resolveOrCreateTeam } from "../../resolve-team.js"; @@ -69,7 +70,7 @@ async function resolveProjectCreation(opts: { projectSlug: autoTeam.slug, projectId: autoTeam.id, url: buildProjectUrl(org, autoTeam.slug), - dsn: "", // init callers use the URL; DSN is fetched separately when needed + dsn: (await tryGetPrimaryDsn(org, autoTeam.slug)) ?? "", }; } } From 0eb9b54500b08a7d1db7f6622d0374bfe549508d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 10:08:48 +0200 Subject: [PATCH 04/17] fix: handle listTeams 403 and fallback 409 in member project creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in the org-scoped fallback path: 1. listTeams 403 → ResolutionError: when listTeams itself 403s (member lacks team:read), resolve-team.ts was wrapping it in ResolutionError instead of re-throwing the ApiError. The catch condition in create.ts checks instanceof ApiError, so the fallback never triggered. Fix: extract handleListTeamsError helper (also reduces resolveOrCreateTeam complexity back under the biome limit) and re-throw 403 as-is. 2. Fallback 409 → raw ApiError: if createProjectWithAutoTeam 409s (project already exists from a prior creation via the org endpoint), the raw ApiError propagated instead of the friendly 'project already exists' CliError that the primary path uses. Fix: handle 409 in createProjectWithAutoTeamFallback the same way createProjectWithErrors does. --- src/commands/project/create.ts | 36 ++++++++++++++--------- src/lib/resolve-team.ts | 54 +++++++++++++++++++++++----------- 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 723f3281d..84ba204a3 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -253,20 +253,28 @@ async function createProjectWithAutoTeamFallback(opts: { platform, }); } catch (expError) { - if ( - expError instanceof ApiError && - expError.status === 403 && - expError.detail?.includes("disabled this feature") - ) { - throw new ApiError( - `Failed to create project '${name}' in ${orgSlug} (HTTP 403).\n\n` + - "Your organization has disabled project creation for members.\n" + - "Ask an org owner or manager to enable it in Organization Settings → Member Roles,\n" + - "or ask them to create the project and add you to it.", - 403, - expError.detail, - expError.endpoint - ); + if (expError instanceof ApiError) { + if ( + expError.status === 403 && + expError.detail?.includes("disabled this feature") + ) { + throw new ApiError( + `Failed to create project '${name}' in ${orgSlug} (HTTP 403).\n\n` + + "Your organization has disabled project creation for members.\n" + + "Ask an org owner or manager to enable it in Organization Settings → Member Roles,\n" + + "or ask them to create the project and add you to it.", + 403, + expError.detail, + expError.endpoint + ); + } + if (expError.status === 409) { + const slug = slugify(name); + throw new CliError( + `A project named '${name}' already exists in ${orgSlug}.\n\n` + + `View it: sentry project view ${orgSlug}/${slug}` + ); + } } throw expError; } diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts index ee6d835df..889098430 100644 --- a/src/lib/resolve-team.ts +++ b/src/lib/resolve-team.ts @@ -111,6 +111,42 @@ export type ResolvedTeam = ResolvedConcreteTeam | DeferredResolvedTeam; * @throws {ContextError} When team cannot be resolved * @throws {ResolutionError} When org slug returns 404 */ + +/** + * Handle errors from `listTeams` during team resolution. + * + * - 404 → org not found (builds a rich error with org list) + * - 403 → member lacks team:read; re-thrown as `ApiError` so callers that + * implement a member-accessible fallback can detect it and use + * POST /organizations/{org}/projects/ instead. + * - other → generic ResolutionError (5xx, network, etc.) + */ +async function handleListTeamsError( + error: unknown, + orgSlug: string, + options: ResolveTeamOptions +): Promise { + if (error instanceof ApiError) { + if (error.status === 404) { + return await buildOrgNotFoundError( + orgSlug, + options.usageHint, + options.detectedFrom + ); + } + if (error.status === 403) { + throw error; + } + throw new ResolutionError( + `Organization '${orgSlug}'`, + `could not be accessed (${error.status})`, + `${options.usageHint} --team `, + ["The organization may not exist, or you may lack access"] + ); + } + throw error; +} + export async function resolveOrCreateTeam( orgSlug: string, options: ResolveTeamOptions & { @@ -133,23 +169,7 @@ export async function resolveOrCreateTeam( try { teams = await listTeams(orgSlug); } catch (error) { - if (error instanceof ApiError) { - if (error.status === 404) { - return await buildOrgNotFoundError( - orgSlug, - options.usageHint, - options.detectedFrom - ); - } - // 403, 5xx, etc. — can't determine if org is wrong or something else - throw new ResolutionError( - `Organization '${orgSlug}'`, - `could not be accessed (${error.status})`, - `${options.usageHint} --team `, - ["The organization may not exist, or you may lack access"] - ); - } - throw error; + return await handleListTeamsError(error, orgSlug, options); } // No teams — auto-create one if a slug was provided From f24ba878dbac9e540b40206291f85222099292c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 10:18:28 +0200 Subject: [PATCH 05/17] =?UTF-8?q?ref:=20unexport=20ProjectWithAutoTeam=20?= =?UTF-8?q?=E2=80=94=20no=20external=20consumers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api-client.ts | 1 - src/lib/api/projects.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 9d5d81af8..e4bfb2543 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -105,7 +105,6 @@ export { listProjectsPaginated, matchesWordBoundary, type ProjectSearchResult, - type ProjectWithAutoTeam, type ProjectWithOrg, resolveOrgDisplayName, tryGetPrimaryDsn, diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index 69171b3ac..e939645fb 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -228,7 +228,7 @@ export async function createProjectWithDsn( } /** Result of creating a project via the org-scoped member-accessible endpoint. */ -export type ProjectWithAutoTeam = SentryProject & { +type ProjectWithAutoTeam = SentryProject & { /** The personal team that was auto-created for the requesting user. */ team_slug: string; }; From d11ee6dff745f6035d382d656b76f50871f41040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 11:02:31 +0200 Subject: [PATCH 06/17] fix(test): update listTeams 403 test to reflect new fallback behavior handleListTeamsError now re-throws ApiError(403) instead of wrapping in ResolutionError, so the outer catch triggers the org-scoped fallback. The test was asserting the old CliError/ResolutionError path which no longer fires. Updated to assert the new path: fallback attempted, policy-error surfaces when the fallback is also blocked. --- test/commands/project/create.test.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 4d6f44c07..323d5d676 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -670,8 +670,11 @@ describe("project create", () => { expect(err.message).toContain("Your organizations"); }); - test("resolveOrCreateTeam with non-404 listTeams failure shows generic error", async () => { - // listTeams returns 403 — org may exist, but user lacks access + test("listTeams 403 triggers org-scoped fallback, surfaces policy error if fallback also blocked", async () => { + // listTeams returns 403 (member lacks team:read). handleListTeamsError re-throws + // the raw ApiError so the outer catch can route to the org-scoped fallback. + // The default beforeEach mock has createProjectWithAutoTeam reject with + // "disabled this feature", so the final error is the policy-disabled message. listTeamsSpy.mockRejectedValue( new ApiError("API request failed: 403 Forbidden", 403) ); @@ -679,15 +682,12 @@ describe("project create", () => { const { context } = createMockContext(); const func = await createCommand.loader(); - const err = await func + const err = (await func .call(context, { json: false }, "my-app", "node") - .catch((e: Error) => e); - expect(err).toBeInstanceOf(CliError); - expect(err.message).toContain("could not be accessed"); - expect(err.message).toContain("403"); - expect(err.message).toContain("may not exist, or you may lack access"); - // Should NOT say "Organization is required" — we don't know that - expect(err.message).not.toContain("is required"); + .catch((e: Error) => e)) as ApiError; + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(403); + expect(err.message).toContain("disabled project creation for members"); }); test("auto-corrects dot-separated platform to hyphen-separated", async () => { From 3affebe8127391e64522b55920a29bcc77b3e3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 11:09:22 +0200 Subject: [PATCH 07/17] fix: handle 403 in --dry-run team resolution for member users --dry-run called resolveOrCreateTeam without catching the ApiError(403) that handleListTeamsError now re-throws when listTeams 403s. A member who would succeed with a normal run (fallback to org-scoped endpoint) got a raw 403 error on --dry-run instead. Extract resolveDryRunTeam helper that mirrors the non-dry-run catch: on 403 and no explicit --team, return a placeholder team slug that previews the auto-created team outcome without hitting the API. --- src/commands/project/create.ts | 37 +++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 84ba204a3..e60b6cbb7 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -227,6 +227,39 @@ async function handleCreateProject404(opts: { ); } +/** + * Resolve the team to show in a --dry-run preview. + * + * Mirrors the non-dry-run fallback: if resolveOrCreateTeam 403s (member lacks + * team:read), the real run would use POST /organizations/{org}/projects/ which + * auto-creates a personal team. Show a placeholder instead of failing. + */ +async function resolveDryRunTeam( + orgSlug: string, + opts: { + team?: string; + detectedFrom?: string; + autoCreateSlug: string; + } +): Promise { + try { + return await resolveOrCreateTeam(orgSlug, { + team: opts.team, + detectedFrom: opts.detectedFrom, + usageHint: USAGE_HINT, + autoCreateSlug: opts.autoCreateSlug, + dryRun: true, + }); + } catch (error) { + // 403 from listTeams: member lacks team:read. The real run falls back to the + // org-scoped endpoint which auto-creates a personal team. Preview that outcome. + if (!(error instanceof ApiError && error.status === 403) || opts.team) { + throw error; + } + return { slug: "team-", source: "auto-created" }; + } +} + /** * Fallback project creation via POST /organizations/{org}/projects/. * @@ -456,12 +489,10 @@ export const createCommand = buildCommand({ // Dry-run mode: resolve team (or preview auto-create) without hitting create APIs if (flags["dry-run"]) { - const team: ResolvedConcreteTeam = await resolveOrCreateTeam(orgSlug, { + const team = await resolveDryRunTeam(orgSlug, { team: flags.team, detectedFrom: resolved.detectedFrom, - usageHint: USAGE_HINT, autoCreateSlug: expectedSlug, - dryRun: true, }); const result: ProjectCreatedResult = { project: { id: "", slug: expectedSlug, name, platform }, From 2c055cdb6d4a7999468f0788dd10bac3a9f99290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 11:37:05 +0200 Subject: [PATCH 08/17] fix: allow sentry init to reach org-scoped fallback when listTeams 403s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleListTeamsError re-throws ApiError(403) so the project create command can detect it and use the org-scoped fallback. But preflight.ts resolveTeam converted every non-WizardCancelledError into WizardError, aborting the wizard before create-sentry-project.ts:resolveProjectCreation (which has the createProjectWithAutoTeam fallback) ever ran. Fix: catch ApiError(403) in resolveTeam and return undefined — same as the existing 'deferred' path for empty orgs. The wizard then continues to createSentryProject, context.team is undefined, resolveProjectCreation calls resolveOrCreateTeam which 403s again, and the catch there routes to createProjectWithAutoTeam. The fallback is now reachable for members in both sentry project create and sentry init. --- src/lib/init/preflight.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts index 2d02c43e4..2c869b979 100644 --- a/src/lib/init/preflight.ts +++ b/src/lib/init/preflight.ts @@ -337,6 +337,12 @@ async function resolveTeam( if (error instanceof WizardCancelledError) { throw error; } + // 403 from listTeams: member lacks team:read. Return undefined (same as the + // "deferred" path) so the wizard continues to the project creation tool, + // where resolveProjectCreation has the createProjectWithAutoTeam fallback. + if (error instanceof ApiError && error.status === 403) { + return; + } throw error instanceof WizardError ? error : new WizardError(error instanceof Error ? error.message : String(error)); From df51dad4104a880717ffd1201cccab1458e27cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 11:46:23 +0200 Subject: [PATCH 09/17] cleanup: apply AGENTS.md patterns to member project creation fallback - projects.ts: extract MEMBER_PROJECT_CREATION_DISABLED_DETAIL constant (shared across two call sites; prevents silent drift if server message changes) and add cache seeding to createProjectWithAutoTeam (mirrors createProjectWithDsn) - api-client.ts: re-export the new constant - create.ts: import and use the constant; add log.debug() in both silent 403-fallback catch blocks (AGENTS.md: no silent catch without logging); remove narrating comment ('Attempt the normal team-based flow.'); fix USAGE_HINT JSDoc ('without positionals' was wrong, value includes them) - create-sentry-project.ts: add file-level module JSDoc (AGENTS.md: lib files must have one); use constant instead of string literal; add @param tags to resolveProjectCreation - resolve-team.ts: replace 'experimental' with 'org-scoped endpoint' in autoCreateTeam comment --- src/commands/project/create.ts | 10 +++++++--- src/lib/api-client.ts | 1 + src/lib/api/projects.ts | 20 ++++++++++++++++++++ src/lib/init/tools/create-sentry-project.ts | 21 ++++++++++++++++++++- src/lib/resolve-team.ts | 4 ++-- 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index e60b6cbb7..06b2e4b00 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -22,6 +22,7 @@ import { createProjectWithAutoTeam, createProjectWithDsn, listTeams, + MEMBER_PROJECT_CREATION_DISABLED_DETAIL, tryGetPrimaryDsn, } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; @@ -59,7 +60,7 @@ import { slugify } from "../../lib/utils.js"; const log = logger.withTag("project.create"); -/** Usage hint template — base command without positionals */ +/** Full usage hint shown in errors and help text. */ const USAGE_HINT = "sentry project create / "; type CreateFlags = { @@ -256,6 +257,9 @@ async function resolveDryRunTeam( if (!(error instanceof ApiError && error.status === 403) || opts.team) { throw error; } + log.debug( + "403 on listTeams in dry-run — previewing org-scoped fallback outcome" + ); return { slug: "team-", source: "auto-created" }; } } @@ -289,7 +293,7 @@ async function createProjectWithAutoTeamFallback(opts: { if (expError instanceof ApiError) { if ( expError.status === 403 && - expError.detail?.includes("disabled this feature") + expError.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL) ) { throw new ApiError( `Failed to create project '${name}' in ${orgSlug} (HTTP 403).\n\n` + @@ -509,7 +513,6 @@ export const createCommand = buildCommand({ return yield new CommandOutput(result); } - // Attempt the normal team-based flow. // If either step 403s (member can't create/see teams, or lacks project:write on // the team), fall back to POST /organizations/{org}/projects/ which mirrors // what the Sentry onboarding UI uses: auto-creates a personal team for the @@ -542,6 +545,7 @@ export const createCommand = buildCommand({ if (!(error instanceof ApiError && error.status === 403) || flags.team) { throw error; } + log.debug("403 on team-based flow — falling back to org-scoped endpoint"); const fallback = await createProjectWithAutoTeamFallback({ orgSlug, name, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e4bfb2543..87f750b87 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -103,6 +103,7 @@ export { getProjectKeys, listProjects, listProjectsPaginated, + MEMBER_PROJECT_CREATION_DISABLED_DETAIL, matchesWordBoundary, type ProjectSearchResult, type ProjectWithOrg, diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index e939645fb..af8f5d526 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -233,6 +233,14 @@ type ProjectWithAutoTeam = SentryProject & { team_slug: string; }; +/** + * Substring present in the 403 detail when the org has disabled member project + * creation. Callers match against this to distinguish a policy 403 from an auth + * 403, so they can surface a clear "ask your admin" message instead of a generic + * permission error or a re-auth prompt. + */ +export const MEMBER_PROJECT_CREATION_DISABLED_DETAIL = "disabled this feature"; + /** * Create a new project via the org-scoped member-accessible endpoint. * @@ -266,6 +274,18 @@ export async function createProjectWithAutoTeam( `/organizations/${orgSlug}/projects/`, { method: "POST", body } ); + + // Seed project cache so subsequent commands skip redundant API lookups. + // Mirrors what createProjectWithDsn does for the team-scoped endpoint. + try { + const orgName = resolveOrgDisplayName(orgSlug, data.organization?.name); + cacheProjectsForOrg(orgSlug, orgName, [ + { id: data.id, slug: data.slug, name: data.name }, + ]); + } catch { + // Best-effort — don't let cache failures break project creation + } + return data; } diff --git a/src/lib/init/tools/create-sentry-project.ts b/src/lib/init/tools/create-sentry-project.ts index 8d3ba2044..61b3ec778 100644 --- a/src/lib/init/tools/create-sentry-project.ts +++ b/src/lib/init/tools/create-sentry-project.ts @@ -1,6 +1,16 @@ +/** + * Sentry project creation tool for the init wizard. + * + * Implements the `create-sentry-project` and `ensure-sentry-project` wizard + * operations. Uses the team-scoped endpoint when the caller has team access, + * falling back to POST /organizations/{org}/projects/ for org members who + * lack team:write. + */ + import { createProjectWithAutoTeam, createProjectWithDsn, + MEMBER_PROJECT_CREATION_DISABLED_DETAIL, tryGetPrimaryDsn, } from "../../api-client.js"; import { ApiError } from "../../errors.js"; @@ -26,6 +36,15 @@ type ProjectData = { /** * Resolve project creation using the team-based flow, falling back to the * org-scoped endpoint on 403 (member lacks team creation permission). + * + * @param opts.org - Organization slug + * @param opts.name - Project display name + * @param opts.platform - Platform identifier (null/undefined → omitted from request) + * @param opts.explicitTeam - Team slug from `--team` flag; suppresses fallback when set + * @param opts.slugHint - Slug used for auto-creating a team when org has none + * @returns Resolved project identifiers and DSN + * @throws When the team-scoped flow fails for any reason other than a member + * permission 403, or when an explicit team was given and it 403s. */ async function resolveProjectCreation(opts: { org: string; @@ -161,7 +180,7 @@ export async function createSentryProject( if ( error instanceof ApiError && error.status === 403 && - error.detail?.includes("disabled this feature") + error.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL) ) { return { ok: false, diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts index 889098430..dcca1883d 100644 --- a/src/lib/resolve-team.ts +++ b/src/lib/resolve-team.ts @@ -255,8 +255,8 @@ async function autoCreateTeam( throw error; } // 403 means the user lacks permission to create teams (e.g., org member role). - // Re-throw as ApiError so callers can fall back to the experimental - // member-accessible project creation endpoint instead of showing a dead-end error. + // Re-throw as ApiError so callers can fall back to the org-scoped endpoint + // (POST /organizations/{org}/projects/) instead of showing a dead-end error. if (error instanceof ApiError && error.status === 403) { throw error; } From 1e0d45f2dd080da5d2454f7287db5f4de69da6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 11:57:46 +0200 Subject: [PATCH 10/17] fix: surface friendly error for 409 in sentry init project creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When createProjectWithDsn or createProjectWithAutoTeam returns 409 (project already exists), the error propagated to formatToolError which produced raw API text. Init tools never throw — they return { ok: false }. Add a 409 branch in createSentryProject's outer catch alongside the existing 403-policy handler. This covers both the team-scoped and org-scoped fallback paths since both propagate to the same catch block. --- src/lib/init/tools/create-sentry-project.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/init/tools/create-sentry-project.ts b/src/lib/init/tools/create-sentry-project.ts index 61b3ec778..a7d80f8d1 100644 --- a/src/lib/init/tools/create-sentry-project.ts +++ b/src/lib/init/tools/create-sentry-project.ts @@ -191,6 +191,17 @@ export async function createSentryProject( ` sentry init ${context.org}/`, }; } + // 409: project already exists (from either the team-scoped or org-scoped + // endpoint — both propagate here). Surface a friendly message with a view + // hint rather than the raw API error text. + if (error instanceof ApiError && error.status === 409) { + return { + ok: false, + error: + `A project named "${name}" already exists in "${context.org}".\n` + + `View it: sentry project view ${context.org}/${slugify(name)}`, + }; + } return { ok: false, error: formatToolError(error) }; } } From 51d02b39085cc1c90fd1da640abe85a5f6576cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 12:10:43 +0200 Subject: [PATCH 11/17] fix: three correctness gaps in member project creation fallback 1. DSN cache seeding (projects.ts): createProjectWithAutoTeam now calls tryGetPrimaryDsn and seeds setCachedProjectByDsnKey, mirroring the exact block from createProjectWithDsn (lines 209-225). Previously only cacheProjectsForOrg was seeded, leaving DSN-based resolution broken. 2. Policy-403 short-circuit (create-sentry-project.ts): resolveProjectCreation now checks MEMBER_PROJECT_CREATION_DISABLED_DETAIL before falling back to createProjectWithAutoTeam. Org-policy 403 is re-thrown immediately so the outer catch surfaces the friendly message without a wasted API round-trip. Follows the same check pattern as createProjectWithAutoTeamFallback in create.ts. 3. Dry-run team validation (create-sentry-project.ts): extract validateTeamForDryRun helper (mirrors preflight.ts:resolveTeam pattern) and call it before the context.dryRun early return. Previously our refactor moved the early return before resolveProjectCreation, skipping team validation in dry-run entirely. Uses deferAutoCreateOnEmptyOrg:true (same as preflight.ts line 317). --- src/lib/api/projects.ts | 19 ++++++++++ src/lib/init/tools/create-sentry-project.ts | 39 +++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index af8f5d526..e71ea6f36 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -286,6 +286,25 @@ export async function createProjectWithAutoTeam( // Best-effort — don't let cache failures break project creation } + // Also seed the DSN-based project cache for DSN resolution + const dsn = await tryGetPrimaryDsn(orgSlug, data.slug); + if (dsn) { + try { + const publicKey = extractPublicKeyFromDsn(dsn); + if (publicKey) { + setCachedProjectByDsnKey(publicKey, { + orgSlug, + orgName: resolveOrgDisplayName(orgSlug, data.organization?.name), + projectSlug: data.slug, + projectName: data.name, + projectId: data.id, + }); + } + } catch { + // Best-effort — don't let cache failures break project creation + } + } + return data; } diff --git a/src/lib/init/tools/create-sentry-project.ts b/src/lib/init/tools/create-sentry-project.ts index a7d80f8d1..699b4a90e 100644 --- a/src/lib/init/tools/create-sentry-project.ts +++ b/src/lib/init/tools/create-sentry-project.ts @@ -84,6 +84,12 @@ async function resolveProjectCreation(opts: { ) { throw innerError; } + // Policy 403: org has disabled member project creation. The org-scoped + // endpoint enforces the same flag — re-throw immediately so the outer + // catch surfaces the friendly disabled-policy message without a wasted round-trip. + if (innerError.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL)) { + throw innerError; + } const autoTeam = await createProjectWithAutoTeam(org, { name, platform }); return { projectSlug: autoTeam.slug, @@ -94,6 +100,35 @@ async function resolveProjectCreation(opts: { } } +/** + * Validate team access for a dry-run, mirroring preflight.ts:resolveTeam. + * + * Calls resolveOrCreateTeam with dryRun=true and deferAutoCreateOnEmptyOrg=true + * so no real teams are created. A 403 is swallowed — the real run falls back + * to the org-scoped endpoint. + * + * @throws Non-403 errors from resolveOrCreateTeam (org not found, network, etc.) + */ +async function validateTeamForDryRun( + org: string, + team: string | undefined, + autoCreateSlug: string +): Promise { + try { + await resolveOrCreateTeam(org, { + team, + autoCreateSlug, + usageHint: "sentry init", + dryRun: true, + deferAutoCreateOnEmptyOrg: true, + }); + } catch (teamErr) { + if (!(teamErr instanceof ApiError && teamErr.status === 403)) { + throw teamErr; + } + } +} + /** * Create a new Sentry project using the org that preflight already resolved. * Team creation is deferred here for empty-org init flows so the final project @@ -140,6 +175,10 @@ export async function createSentryProject( }; } + // Validate team access before the dry-run early return — mirrors + // preflight.ts:resolveTeam which calls resolveOrCreateTeam unconditionally. + await validateTeamForDryRun(context.org, context.team, slug); + if (context.dryRun) { return { ok: true, From 9346deb6387ba1ffd0e677c6ee94c074207b4f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 12:29:04 +0200 Subject: [PATCH 12/17] fix: resolve three root causes in member project creation fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. validateTeamForDryRun now runs only during dry-run (issue: it was called unconditionally before the dryRun check, causing listTeams to fire on every real run before resolveProjectCreation also called it). 2. Add isExplicitTeam to ResolvedInitContext (types.ts + preflight.ts). context.team was set by BOTH --team flag AND auto-selection, so resolveProjectCreation was suppressing the org-scoped fallback for auto-selected teams too. isExplicitTeam=Boolean(initial.team) in buildResolvedInitContext distinguishes flag-driven from auto-selected. Pass context.isExplicitTeam ? context.team : undefined as explicitTeam. 3. createProjectWithAutoTeam now returns CreatedAutoTeamProjectDetails ({ project, dsn, url, team_slug }) — parallel to CreatedProjectDetails from createProjectWithDsn. The DSN was already fetched internally for cache seeding but discarded, forcing both callers to call tryGetPrimaryDsn again. Callers now use result.dsn/url directly; unused tryGetPrimaryDsn and buildProjectUrl imports removed from create.ts and create-sentry-project.ts. --- src/commands/project/create.ts | 17 ++++++--------- src/lib/api-client.ts | 1 + src/lib/api/projects.ts | 20 +++++++++++++----- src/lib/init/preflight.ts | 1 + src/lib/init/tools/create-sentry-project.ts | 23 +++++++++------------ src/lib/init/types.ts | 7 +++++++ 6 files changed, 40 insertions(+), 29 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 06b2e4b00..9ff9d000c 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -23,7 +23,6 @@ import { createProjectWithDsn, listTeams, MEMBER_PROJECT_CREATION_DISABLED_DETAIL, - tryGetPrimaryDsn, } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; @@ -55,7 +54,6 @@ import { type ResolvedConcreteTeam, resolveOrCreateTeam, } from "../../lib/resolve-team.js"; -import { buildProjectUrl } from "../../lib/sentry-urls.js"; import { slugify } from "../../lib/utils.js"; const log = logger.withTag("project.create"); @@ -283,12 +281,9 @@ async function createProjectWithAutoTeamFallback(opts: { } > { const { orgSlug, name, platform } = opts; - let autoTeamProject: Awaited>; + let result: Awaited>; try { - autoTeamProject = await createProjectWithAutoTeam(orgSlug, { - name, - platform, - }); + result = await createProjectWithAutoTeam(orgSlug, { name, platform }); } catch (expError) { if (expError instanceof ApiError) { if ( @@ -316,10 +311,10 @@ async function createProjectWithAutoTeamFallback(opts: { throw expError; } return { - project: autoTeamProject, - dsn: await tryGetPrimaryDsn(orgSlug, autoTeamProject.slug), - url: buildProjectUrl(orgSlug, autoTeamProject.slug), - teamSlug: autoTeamProject.team_slug, + project: result.project, + dsn: result.dsn, + url: result.url, + teamSlug: result.team_slug, teamSource: "auto-created", }; } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 87f750b87..ab8affdad 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -91,6 +91,7 @@ export { listOrganizationsUncached, } from "./api/organizations.js"; export { + type CreatedAutoTeamProjectDetails, type CreatedProjectDetails, createProject, createProjectWithAutoTeam, diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index e71ea6f36..f251513cc 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -227,9 +227,18 @@ export async function createProjectWithDsn( return { project, dsn, url }; } -/** Result of creating a project via the org-scoped member-accessible endpoint. */ +/** Raw response shape from the org-scoped project creation endpoint. */ type ProjectWithAutoTeam = SentryProject & { - /** The personal team that was auto-created for the requesting user. */ + /** The personal team auto-created by the server for the requesting user. */ + team_slug: string; +}; + +/** + * Result of creating a project via the org-scoped member-accessible endpoint. + * Parallel to {@link CreatedProjectDetails} for the team-scoped endpoint. + */ +export type CreatedAutoTeamProjectDetails = CreatedProjectDetails & { + /** The personal team auto-created by the server for the requesting user. */ team_slug: string; }; @@ -267,13 +276,15 @@ export const MEMBER_PROJECT_CREATION_DISABLED_DETAIL = "disabled this feature"; export async function createProjectWithAutoTeam( orgSlug: string, body: CreateProjectBody -): Promise { +): Promise { const regionUrl = await resolveOrgRegion(orgSlug); const { data } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/projects/`, { method: "POST", body } ); + const dsn = await tryGetPrimaryDsn(orgSlug, data.slug); + const url = buildProjectUrl(orgSlug, data.slug); // Seed project cache so subsequent commands skip redundant API lookups. // Mirrors what createProjectWithDsn does for the team-scoped endpoint. @@ -287,7 +298,6 @@ export async function createProjectWithAutoTeam( } // Also seed the DSN-based project cache for DSN resolution - const dsn = await tryGetPrimaryDsn(orgSlug, data.slug); if (dsn) { try { const publicKey = extractPublicKeyFromDsn(dsn); @@ -305,7 +315,7 @@ export async function createProjectWithAutoTeam( } } - return data; + return { project: data, dsn, url, team_slug: data.team_slug }; } /** diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts index 2c869b979..d54577c75 100644 --- a/src/lib/init/preflight.ts +++ b/src/lib/init/preflight.ts @@ -98,6 +98,7 @@ function buildResolvedInitContext( features: initial.features, org, team, + isExplicitTeam: Boolean(initial.team), project: selection.project, app: initial.app, authToken: getAuthToken(), diff --git a/src/lib/init/tools/create-sentry-project.ts b/src/lib/init/tools/create-sentry-project.ts index 699b4a90e..0de12cbd1 100644 --- a/src/lib/init/tools/create-sentry-project.ts +++ b/src/lib/init/tools/create-sentry-project.ts @@ -11,11 +11,9 @@ import { createProjectWithAutoTeam, createProjectWithDsn, MEMBER_PROJECT_CREATION_DISABLED_DETAIL, - tryGetPrimaryDsn, } from "../../api-client.js"; import { ApiError } from "../../errors.js"; import { resolveOrCreateTeam } from "../../resolve-team.js"; -import { buildProjectUrl } from "../../sentry-urls.js"; import { slugify } from "../../utils.js"; import { tryGetExistingProjectData } from "../existing-project.js"; import type { @@ -90,12 +88,12 @@ async function resolveProjectCreation(opts: { if (innerError.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL)) { throw innerError; } - const autoTeam = await createProjectWithAutoTeam(org, { name, platform }); + const result = await createProjectWithAutoTeam(org, { name, platform }); return { - projectSlug: autoTeam.slug, - projectId: autoTeam.id, - url: buildProjectUrl(org, autoTeam.slug), - dsn: (await tryGetPrimaryDsn(org, autoTeam.slug)) ?? "", + projectSlug: result.project.slug, + projectId: result.project.id, + url: result.url, + dsn: result.dsn ?? "", }; } } @@ -145,7 +143,7 @@ export async function createSentryProject( payload: CreateSentryProjectPayload | EnsureSentryProjectPayload, context: Pick< ToolContext, - "dryRun" | "existingProject" | "org" | "team" | "project" + "dryRun" | "existingProject" | "isExplicitTeam" | "org" | "team" | "project" > ): Promise { const name = context.project ?? payload.params.name; @@ -175,11 +173,10 @@ export async function createSentryProject( }; } - // Validate team access before the dry-run early return — mirrors - // preflight.ts:resolveTeam which calls resolveOrCreateTeam unconditionally. - await validateTeamForDryRun(context.org, context.team, slug); - if (context.dryRun) { + // Validate team access in dry-run — mirrors preflight.ts:resolveTeam. + // Not needed in real runs: resolveProjectCreation handles its own resolution. + await validateTeamForDryRun(context.org, context.team, slug); return { ok: true, data: { @@ -199,7 +196,7 @@ export async function createSentryProject( org: context.org, name, platform: payload.params.platform, - explicitTeam: context.team, + explicitTeam: context.isExplicitTeam ? context.team : undefined, slugHint: slug, }); diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 36a76c1a0..e59b2a549 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -47,6 +47,13 @@ export type ResolvedInitContext = { * Omitted when init defers empty-org auto-creation until project creation. */ team?: string; + /** + * True only when `team` was supplied via the `--team` CLI flag. + * False/absent when the team was auto-selected by preflight. + * Used by project creation tools to decide whether to suppress the + * org-scoped fallback on 403 (only suppress for explicitly named teams). + */ + isExplicitTeam?: boolean; project?: string; /** Pre-selected app name for monorepo runs. Passed through from `--app`. */ app?: string; From 358134ee8324656d3274f0e590d37af6051866e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 12:44:50 +0200 Subject: [PATCH 13/17] fix: update test mock shape and add policy-403 short-circuit in create command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create.test.ts: createProjectWithAutoTeamSpy mock now matches CreatedAutoTeamProjectDetails shape ({ project, dsn, url, team_slug }) instead of spreading sampleProject flat. Return type changed in 9346deb. - create.ts: add MEMBER_PROJECT_CREATION_DISABLED_DETAIL check in the outer catch before calling createProjectWithAutoTeamFallback — mirrors the same guard in resolveProjectCreation. Avoids a wasted API round-trip when the org has disabled member project creation. --- src/commands/project/create.ts | 5 +++++ test/commands/project/create.test.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 9ff9d000c..dee79ae7c 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -540,6 +540,11 @@ export const createCommand = buildCommand({ if (!(error instanceof ApiError && error.status === 403) || flags.team) { throw error; } + // Policy 403: org has disabled member project creation. The org-scoped + // endpoint enforces the same flag — re-throw to avoid a wasted round-trip. + if (error.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL)) { + throw error; + } log.debug("403 on team-based flow — falling back to org-scoped endpoint"); const fallback = await createProjectWithAutoTeamFallback({ orgSlug, diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 323d5d676..b77268d9e 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -484,7 +484,9 @@ describe("project create", () => { new ApiError("Forbidden", 403, "You do not have permission") ); createProjectWithAutoTeamSpy.mockResolvedValue({ - ...sampleProject, + project: sampleProject, + dsn: "https://abc@o123.ingest.us.sentry.io/999", + url: "https://sentry.io/organizations/acme-corp/projects/my-app/", team_slug: "team-testuser", }); From cf65ba7899113480791440ae9247dec199d9e46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 12:55:24 +0200 Subject: [PATCH 14/17] fix: separate team-to-use from fallback-suppression in resolveProjectCreation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous explicitTeam parameter served two conflated purposes: 1. Which team slug to use for project creation 2. Whether to suppress the 403 fallback isExplicitTeam ? context.team : undefined discarded pre-selected teams (auto-selected by preflight), causing resolveOrCreateTeam to be called again — breaking multi-team orgs where the user already chose a team. Split into two parameters: - team: the team to use (context.team, whether explicit or auto-selected) - suppressFallback: only suppress when --team was passed (isExplicitTeam) This preserves preflight team selection while allowing the org-scoped fallback for members who lack project:write on their auto-selected team. Also update the test to not assert dryRun:false on resolveOrCreateTeam — the dry-run path exits before resolveProjectCreation is called. --- src/lib/init/tools/create-sentry-project.ts | 27 +++++++++++-------- .../init/tools/create-sentry-project.test.ts | 1 - 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/lib/init/tools/create-sentry-project.ts b/src/lib/init/tools/create-sentry-project.ts index 0de12cbd1..f708759bd 100644 --- a/src/lib/init/tools/create-sentry-project.ts +++ b/src/lib/init/tools/create-sentry-project.ts @@ -38,25 +38,29 @@ type ProjectData = { * @param opts.org - Organization slug * @param opts.name - Project display name * @param opts.platform - Platform identifier (null/undefined → omitted from request) - * @param opts.explicitTeam - Team slug from `--team` flag; suppresses fallback when set + * @param opts.team - Pre-resolved team slug (explicit or auto-selected by preflight). + * When undefined the team is resolved fresh via resolveOrCreateTeam. + * @param opts.suppressFallback - When true, a 403 from the team-scoped flow is + * surfaced directly rather than triggering the org-scoped fallback. Set only + * when the team was explicitly named via `--team` — a 403 there is meaningful + * user feedback, not a permission gap. * @param opts.slugHint - Slug used for auto-creating a team when org has none * @returns Resolved project identifiers and DSN - * @throws When the team-scoped flow fails for any reason other than a member - * permission 403, or when an explicit team was given and it 403s. */ async function resolveProjectCreation(opts: { org: string; name: string; platform: string | null | undefined; - explicitTeam: string | undefined; + team: string | undefined; + suppressFallback: boolean; slugHint: string; }): Promise { - const { org, name, explicitTeam, slugHint } = opts; + const { org, name, team, suppressFallback, slugHint } = opts; // Coerce null → undefined: CreateProjectBody.platform is string | undefined. const platform = opts.platform ?? undefined; try { - const teamSlug = explicitTeam - ? explicitTeam + const teamSlug = team + ? team : ( await resolveOrCreateTeam(org, { autoCreateSlug: slugHint, @@ -74,11 +78,11 @@ async function resolveProjectCreation(opts: { url: result.url, }; } catch (innerError) { - // Fall back to org-scoped endpoint on 403, unless an explicit team was - // given (in which case the 403 is meaningful feedback, not a permissions gap). + // Fall back to org-scoped endpoint on 403, unless the fallback is suppressed + // (explicit --team means the 403 is meaningful feedback, not a permission gap). if ( !(innerError instanceof ApiError && innerError.status === 403) || - explicitTeam + suppressFallback ) { throw innerError; } @@ -196,7 +200,8 @@ export async function createSentryProject( org: context.org, name, platform: payload.params.platform, - explicitTeam: context.isExplicitTeam ? context.team : undefined, + team: context.team, + suppressFallback: Boolean(context.isExplicitTeam), slugHint: slug, }); diff --git a/test/lib/init/tools/create-sentry-project.test.ts b/test/lib/init/tools/create-sentry-project.test.ts index bb1789d34..360081d0f 100644 --- a/test/lib/init/tools/create-sentry-project.test.ts +++ b/test/lib/init/tools/create-sentry-project.test.ts @@ -249,7 +249,6 @@ describe("createSentryProject", () => { expect.objectContaining({ autoCreateSlug: "my-app", usageHint: "sentry init", - dryRun: false, }) ); expect(createProjectWithDsnSpy).toHaveBeenCalledWith( From e25eb66a5c81684f1c53d5e8dd25b650782bb021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 13:24:22 +0200 Subject: [PATCH 15/17] test: add coverage for org-scoped member project creation fallback Adds 10 new tests across three files to bring coverage from ~57% to target 80% for the new fallback paths: create-sentry-project.test.ts: - fallback to createProjectWithAutoTeam on 403 from team-based creation - suppressFallback:true (isExplicitTeam) prevents fallback for --team flag - policy 403 (disabled this feature) skips fallback without round-trip - 409 from fallback surfaces friendly 'already exists' error preflight.test.ts: - isExplicitTeam:true when --team flag provided - isExplicitTeam:false when no flag (auto-selected team) - resolveTeam 403 swallowed, context.team:undefined (enables fallback downstream) api-client.coverage.test.ts: - createProjectWithAutoTeam happy path (POST url, DSN, team_slug in result) - 403 from disabled org policy propagated - DSN:null when key fetch returns empty list --- test/lib/api-client.coverage.test.ts | 80 +++++++++++++ test/lib/init/preflight.test.ts | 36 ++++++ .../init/tools/create-sentry-project.test.ts | 108 ++++++++++++++++++ 3 files changed, 224 insertions(+) diff --git a/test/lib/api-client.coverage.test.ts b/test/lib/api-client.coverage.test.ts index 5127ebe5b..6dcdca63c 100644 --- a/test/lib/api-client.coverage.test.ts +++ b/test/lib/api-client.coverage.test.ts @@ -14,6 +14,7 @@ import { apiRequest, apiRequestToRegion, createProject, + createProjectWithAutoTeam, createTeam, getCurrentUser, getDetailedTrace, @@ -35,6 +36,7 @@ import { listTeamsPaginated, listTraceLogs, listTransactions, + MEMBER_PROJECT_CREATION_DISABLED_DETAIL, rawApiRequest, tryGetPrimaryDsn, updateIssueStatus, @@ -680,6 +682,84 @@ describe("projects.ts", () => { }); }); + describe("createProjectWithAutoTeam", () => { + const autoTeamProject = { + id: "99", + slug: "auto-proj", + name: "Auto Project", + platform: "node", + dateCreated: "2026-01-01T00:00:00Z", + team_slug: "team-testuser", + }; + const dsnKeys = [ + { + id: "k1", + isActive: true, + dsn: { public: "https://key@o1.ingest.sentry.io/99", secret: "" }, + }, + ]; + + test("POSTs to /organizations/{org}/projects/ and returns project with DSN and team_slug", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + if (req.url.includes("/projects/") && req.method === "POST") { + return new Response(JSON.stringify(autoTeamProject), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + // DSN key fetch + return new Response(JSON.stringify(dsnKeys), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await createProjectWithAutoTeam("test-org", { + name: "Auto Project", + }); + expect(result.project.slug).toBe("auto-proj"); + expect(result.team_slug).toBe("team-testuser"); + expect(result.dsn).toContain("key"); + expect(result.url).toContain("auto-proj"); + }); + + test("propagates 403 when org has disabled member project creation", async () => { + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify({ detail: MEMBER_PROJECT_CREATION_DISABLED_DETAIL }), + { status: 403, headers: { "Content-Type": "application/json" } } + ) + ); + + await expect( + createProjectWithAutoTeam("test-org", { name: "Blocked" }) + ).rejects.toMatchObject({ status: 403 }); + }); + + test("returns dsn:null when DSN fetch fails", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + if (req.method === "POST") { + return new Response(JSON.stringify(autoTeamProject), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await createProjectWithAutoTeam("test-org", { + name: "Auto Project", + }); + expect(result.dsn).toBeNull(); + }); + }); + describe("getProjectKeys", () => { test("returns project client keys", async () => { const keys = [ diff --git a/test/lib/init/preflight.test.ts b/test/lib/init/preflight.test.ts index a067dc18e..5c330dc00 100644 --- a/test/lib/init/preflight.test.ts +++ b/test/lib/init/preflight.test.ts @@ -437,4 +437,40 @@ describe("resolveInitContext", () => { expect(context?.authToken).toBe("sntrys_test"); }); + + test("sets isExplicitTeam:true when --team flag is provided", async () => { + resolveOrCreateTeamSpy.mockResolvedValue({ + slug: "backend", + source: "explicit", + } as any); + + const { ui } = createMockUI(); + const context = await resolveInitContext( + makeOptions({ team: "backend" }), + ui + ); + + expect(context?.isExplicitTeam).toBe(true); + expect(context?.team).toBe("backend"); + }); + + test("sets isExplicitTeam:false when no --team flag is provided", async () => { + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context?.isExplicitTeam).toBe(false); + }); + + test("swallows 403 from listTeams and resolves context with team:undefined", async () => { + resolveOrCreateTeamSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No team:read access") + ); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + // 403 is swallowed so the wizard can proceed to the org-scoped fallback + expect(context).not.toBeNull(); + expect(context?.team).toBeUndefined(); + }); }); diff --git a/test/lib/init/tools/create-sentry-project.test.ts b/test/lib/init/tools/create-sentry-project.test.ts index 360081d0f..62d687a0b 100644 --- a/test/lib/init/tools/create-sentry-project.test.ts +++ b/test/lib/init/tools/create-sentry-project.test.ts @@ -297,6 +297,114 @@ describe("createSentryProject", () => { expect(createSentryProjectTool.describe(makePayload())).toContain("my-app"); }); + // ── org-scoped fallback (createProjectWithAutoTeam) ───────────────────── + + test("falls back to org-scoped endpoint on 403 from team-based creation", async () => { + const createProjectWithAutoTeamSpy = vi + .spyOn(apiClient, "createProjectWithAutoTeam") + .mockResolvedValue({ + project: { + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any, + dsn: "https://abc@o1.ingest.sentry.io/42", + url: "https://sentry.io/settings/acme/projects/my-app/", + team_slug: "team-testuser", + }); + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No project:write access") + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(createProjectWithAutoTeamSpy).toHaveBeenCalledWith("acme", { + name: "my-app", + platform: "javascript-react", + }); + createProjectWithAutoTeamSpy.mockRestore(); + }); + + test("suppresses fallback when team was set via --team (isExplicitTeam)", async () => { + const createProjectWithAutoTeamSpy = vi + .spyOn(apiClient, "createProjectWithAutoTeam") + .mockResolvedValue({} as any); + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No project:write access") + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: "backend", + isExplicitTeam: true, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); + createProjectWithAutoTeamSpy.mockRestore(); + }); + + test("does not fall back on policy 403 (disabled this feature) — avoids wasted round-trip", async () => { + const createProjectWithAutoTeamSpy = vi + .spyOn(apiClient, "createProjectWithAutoTeam") + .mockResolvedValue({} as any); + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError( + "Forbidden", + 403, + "Your organization has disabled this feature for members." + ) + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); + expect(result.error).toContain("disabled for members"); + createProjectWithAutoTeamSpy.mockRestore(); + }); + + test("surfaces friendly 409 error when fallback project already exists", async () => { + const createProjectWithAutoTeamSpy = vi + .spyOn(apiClient, "createProjectWithAutoTeam") + .mockRejectedValue(new ApiError("Conflict", 409, "Slug already in use")); + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No project:write access") + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("already exists"); + createProjectWithAutoTeamSpy.mockRestore(); + }); + + // ── dry-run ────────────────────────────────────────────────────────────── + test("uses the final project slug for deferred team resolution in dry-run mode", async () => { getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); From 1c1fa038e1fe5b9b2681ac808d396cb5dd667497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 13:33:39 +0200 Subject: [PATCH 16/17] ref: unexport CreatedAutoTeamProjectDetails and clean up tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - projects.ts + api-client.ts: type was exported but never consumed externally — only used as the return type of createProjectWithAutoTeam within projects.ts. Make it module-private (same fix as ProjectWithAutoTeam). - create-sentry-project.test.ts: move createProjectWithAutoTeamSpy to beforeEach/afterEach alongside the other spies; extract sampleAutoTeamResult constant so per-test mocks don't repeat the object; remove per-test mockRestore() calls and section comment. --- src/lib/api-client.ts | 1 - src/lib/api/projects.ts | 2 +- .../init/tools/create-sentry-project.test.ts | 51 ++++++++----------- 3 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index ab8affdad..87f750b87 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -91,7 +91,6 @@ export { listOrganizationsUncached, } from "./api/organizations.js"; export { - type CreatedAutoTeamProjectDetails, type CreatedProjectDetails, createProject, createProjectWithAutoTeam, diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index f251513cc..d73878629 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -237,7 +237,7 @@ type ProjectWithAutoTeam = SentryProject & { * Result of creating a project via the org-scoped member-accessible endpoint. * Parallel to {@link CreatedProjectDetails} for the team-scoped endpoint. */ -export type CreatedAutoTeamProjectDetails = CreatedProjectDetails & { +type CreatedAutoTeamProjectDetails = CreatedProjectDetails & { /** The personal team auto-created by the server for the requesting user. */ team_slug: string; }; diff --git a/test/lib/init/tools/create-sentry-project.test.ts b/test/lib/init/tools/create-sentry-project.test.ts index 62d687a0b..a249ba94d 100644 --- a/test/lib/init/tools/create-sentry-project.test.ts +++ b/test/lib/init/tools/create-sentry-project.test.ts @@ -64,7 +64,21 @@ function makeEnsurePayload( }; } +const sampleAutoTeamResult = { + project: { + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any, + dsn: "https://abc@o1.ingest.sentry.io/42", + url: "https://sentry.io/settings/acme/projects/my-app/", + team_slug: "team-testuser", +}; + let createProjectWithDsnSpy: ReturnType; +let createProjectWithAutoTeamSpy: ReturnType; let getProjectSpy: ReturnType; let tryGetPrimaryDsnSpy: ReturnType; let resolveOrCreateTeamSpy: ReturnType; @@ -83,6 +97,9 @@ beforeEach(() => { dsn: "https://abc@o1.ingest.sentry.io/42", url: "https://sentry.io/settings/acme/projects/my-app/", }); + createProjectWithAutoTeamSpy = vi + .spyOn(apiClient, "createProjectWithAutoTeam") + .mockResolvedValue(sampleAutoTeamResult); getProjectSpy = vi.spyOn(apiClient, "getProject").mockResolvedValue({ id: "42", slug: "my-app", @@ -103,6 +120,7 @@ beforeEach(() => { afterEach(() => { createProjectWithDsnSpy.mockRestore(); + createProjectWithAutoTeamSpy.mockRestore(); getProjectSpy.mockRestore(); tryGetPrimaryDsnSpy.mockRestore(); resolveOrCreateTeamSpy.mockRestore(); @@ -297,23 +315,7 @@ describe("createSentryProject", () => { expect(createSentryProjectTool.describe(makePayload())).toContain("my-app"); }); - // ── org-scoped fallback (createProjectWithAutoTeam) ───────────────────── - test("falls back to org-scoped endpoint on 403 from team-based creation", async () => { - const createProjectWithAutoTeamSpy = vi - .spyOn(apiClient, "createProjectWithAutoTeam") - .mockResolvedValue({ - project: { - id: "42", - slug: "my-app", - name: "my-app", - platform: "javascript-react", - dateCreated: "2026-04-16T00:00:00Z", - } as any, - dsn: "https://abc@o1.ingest.sentry.io/42", - url: "https://sentry.io/settings/acme/projects/my-app/", - team_slug: "team-testuser", - }); getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); createProjectWithDsnSpy.mockRejectedValueOnce( new ApiError("Forbidden", 403, "No project:write access") @@ -331,13 +333,9 @@ describe("createSentryProject", () => { name: "my-app", platform: "javascript-react", }); - createProjectWithAutoTeamSpy.mockRestore(); }); test("suppresses fallback when team was set via --team (isExplicitTeam)", async () => { - const createProjectWithAutoTeamSpy = vi - .spyOn(apiClient, "createProjectWithAutoTeam") - .mockResolvedValue({} as any); getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); createProjectWithDsnSpy.mockRejectedValueOnce( new ApiError("Forbidden", 403, "No project:write access") @@ -353,13 +351,9 @@ describe("createSentryProject", () => { expect(result.ok).toBe(false); expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); - createProjectWithAutoTeamSpy.mockRestore(); }); - test("does not fall back on policy 403 (disabled this feature) — avoids wasted round-trip", async () => { - const createProjectWithAutoTeamSpy = vi - .spyOn(apiClient, "createProjectWithAutoTeam") - .mockResolvedValue({} as any); + test("does not fall back on policy 403 — avoids wasted round-trip to org-scoped endpoint", async () => { getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); createProjectWithDsnSpy.mockRejectedValueOnce( new ApiError( @@ -379,13 +373,12 @@ describe("createSentryProject", () => { expect(result.ok).toBe(false); expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); expect(result.error).toContain("disabled for members"); - createProjectWithAutoTeamSpy.mockRestore(); }); test("surfaces friendly 409 error when fallback project already exists", async () => { - const createProjectWithAutoTeamSpy = vi - .spyOn(apiClient, "createProjectWithAutoTeam") - .mockRejectedValue(new ApiError("Conflict", 409, "Slug already in use")); + createProjectWithAutoTeamSpy.mockRejectedValueOnce( + new ApiError("Conflict", 409, "Slug already in use") + ); getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); createProjectWithDsnSpy.mockRejectedValueOnce( new ApiError("Forbidden", 403, "No project:write access") From f4d0188893ae705a858dde900ac5bacc8e279aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 13:47:10 +0200 Subject: [PATCH 17/17] ref: extract seedProjectCaches helper to deduplicate post-creation caching Both createProjectWithDsn and createProjectWithAutoTeam had an identical ~25-line block seeding cacheProjectsForOrg and setCachedProjectByDsnKey. Extract into a private seedProjectCaches(orgSlug, project, dsn) helper so future cache logic changes apply consistently to both paths. --- src/lib/api/projects.ts | 98 +++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 57 deletions(-) diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index d73878629..b7f69506d 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -165,6 +165,45 @@ export type CreatedProjectDetails = { url: string; }; +/** + * Seed both project caches after a successful creation. + * + * Best-effort: cache failures are silently swallowed so they never break + * project creation. Called by both `createProjectWithDsn` (team-scoped) + * and `createProjectWithAutoTeam` (org-scoped) to keep cache behaviour + * consistent across both creation paths. + */ +function seedProjectCaches( + orgSlug: string, + project: SentryProject, + dsn: string | null +): void { + try { + const orgName = resolveOrgDisplayName(orgSlug, project.organization?.name); + cacheProjectsForOrg(orgSlug, orgName, [ + { id: project.id, slug: project.slug, name: project.name }, + ]); + } catch { + // Best-effort — don't let cache failures break project creation + } + if (dsn) { + try { + const publicKey = extractPublicKeyFromDsn(dsn); + if (publicKey) { + setCachedProjectByDsnKey(publicKey, { + orgSlug, + orgName: resolveOrgDisplayName(orgSlug, project.organization?.name), + projectSlug: project.slug, + projectName: project.name, + projectId: project.id, + }); + } + } catch { + // Best-effort — don't let cache failures break project creation + } + } +} + /** * Extract the public key from a Sentry DSN URL. * DSN format: https://@/ @@ -196,34 +235,7 @@ export async function createProjectWithDsn( const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); const url = buildProjectUrl(orgSlug, project.slug); - // Seed project cache so subsequent commands skip redundant API lookups - try { - const orgName = resolveOrgDisplayName(orgSlug, project.organization?.name); - cacheProjectsForOrg(orgSlug, orgName, [ - { id: project.id, slug: project.slug, name: project.name }, - ]); - } catch { - // Best-effort — don't let cache failures break project creation - } - - // Also seed the DSN-based project cache for DSN resolution - if (dsn) { - try { - const publicKey = extractPublicKeyFromDsn(dsn); - if (publicKey) { - setCachedProjectByDsnKey(publicKey, { - orgSlug, - orgName: resolveOrgDisplayName(orgSlug, project.organization?.name), - projectSlug: project.slug, - projectName: project.name, - projectId: project.id, - }); - } - } catch { - // Best-effort — don't let cache failures break project creation - } - } - + seedProjectCaches(orgSlug, project, dsn); return { project, dsn, url }; } @@ -286,35 +298,7 @@ export async function createProjectWithAutoTeam( const dsn = await tryGetPrimaryDsn(orgSlug, data.slug); const url = buildProjectUrl(orgSlug, data.slug); - // Seed project cache so subsequent commands skip redundant API lookups. - // Mirrors what createProjectWithDsn does for the team-scoped endpoint. - try { - const orgName = resolveOrgDisplayName(orgSlug, data.organization?.name); - cacheProjectsForOrg(orgSlug, orgName, [ - { id: data.id, slug: data.slug, name: data.name }, - ]); - } catch { - // Best-effort — don't let cache failures break project creation - } - - // Also seed the DSN-based project cache for DSN resolution - if (dsn) { - try { - const publicKey = extractPublicKeyFromDsn(dsn); - if (publicKey) { - setCachedProjectByDsnKey(publicKey, { - orgSlug, - orgName: resolveOrgDisplayName(orgSlug, data.organization?.name), - projectSlug: data.slug, - projectName: data.name, - projectId: data.id, - }); - } - } catch { - // Best-effort — don't let cache failures break project creation - } - } - + seedProjectCaches(orgSlug, data, dsn); return { project: data, dsn, url, team_slug: data.team_slug }; }