diff --git a/src/lib/api/infrastructure.ts b/src/lib/api/infrastructure.ts index 275dcf47b..155922124 100644 --- a/src/lib/api/infrastructure.ts +++ b/src/lib/api/infrastructure.ts @@ -26,16 +26,27 @@ import { /** * Enrich a 403 Forbidden error detail with actionable guidance. * - * For env-var tokens (SENTRY_AUTH_TOKEN / SENTRY_TOKEN): extracts the specific - * missing scope from the API response when available, otherwise suggests - * checking token scopes. Includes a link to the token settings page. + * "Your organization has disabled this feature for members" is an org-level + * policy (Organization.flags.disable_member_project_creation), not a token + * scope or auth problem. We return targeted guidance and skip the generic + * scope/re-auth enrichment entirely — suggesting re-authentication for this + * error would be actively wrong and has caused user confusion (CLI-SERVER-E). * - * For OAuth tokens: suggests the user may lack access and should re-authenticate. - * - * @param rawDetail - The original detail string from the API 403 response - * @returns Enriched detail string with actionable suggestions + * All other 403s fall through to the existing logic: + * - env-var tokens → suggest checking token scopes + * - OAuth tokens → suggest re-authentication */ function enrich403Detail(rawDetail: string | undefined): string { + // Org-level policy — re-auth and token scope advice do not apply here. + if (rawDetail?.includes("disabled this feature")) { + return [ + rawDetail, + "", + "This is an org-level policy setting, not an auth issue.", + "You need org:admin/manager/owner role, or team:admin role on the team.", + ].join("\n "); + } + const lines: string[] = []; if (rawDetail) { lines.push(rawDetail, ""); diff --git a/src/lib/init/tools/create-sentry-project.ts b/src/lib/init/tools/create-sentry-project.ts index 88f118abe..431d3af05 100644 --- a/src/lib/init/tools/create-sentry-project.ts +++ b/src/lib/init/tools/create-sentry-project.ts @@ -1,4 +1,5 @@ import { createProjectWithDsn } from "../../api-client.js"; +import { ApiError } from "../../errors.js"; import { resolveOrCreateTeam } from "../../resolve-team.js"; import { slugify } from "../../utils.js"; import { tryGetExistingProjectData } from "../existing-project.js"; @@ -14,6 +15,13 @@ import type { InitToolDefinition, ToolContext } from "./types.js"; * 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 * slug can be reused as the team slug. + * + * New Sentry orgs have member project creation disabled by default + * (Organization.flags.disable_member_project_creation = true). When the org + * restricts project creation for members, we surface a clear error with an + * escape hatch: the user can pass `sentry init /` once an + * admin creates the project, which resolves to an existing project and skips + * creation entirely (preflight.ts:261). */ export async function createSentryProject( payload: CreateSentryProjectPayload | EnsureSentryProjectPayload, @@ -92,6 +100,25 @@ export async function createSentryProject( }, }; } 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. + if ( + error instanceof ApiError && + error.status === 403 && + error.detail?.includes("disabled this feature") + ) { + return { + ok: false, + error: + `Project creation is disabled for members in "${context.org}".\n` + + "Ask an org owner to either enable project creation for members\n" + + "or create the project for you. Once the project exists, run:\n" + + ` sentry init ${context.org}/`, + }; + } return { ok: false, error: formatToolError(error) }; } } diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 132146dd8..021682e26 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -40,11 +40,21 @@ import { z } from "zod"; * Based on the `@sentry/api` list-organizations response type. * Core identifiers are required; other SDK fields are available but optional, * allowing test mocks and list-endpoint responses to omit them. + * + * `allowMemberProjectCreation` and `orgRole` are present in detail responses + * (GET /api/0/organizations/{slug}/) but absent from list responses, hence + * optional. `allowMemberProjectCreation` being false means + * Organization.flags.disable_member_project_creation is set — project creation + * requires org:write scope or team:admin on the target team. */ export type SentryOrganization = Partial & { id: string; slug: string; name: string; + /** False when org admins have restricted project creation to owners/managers/team-admins. Default for new orgs. */ + allowMemberProjectCreation?: boolean; + /** The authenticated user's role in this org ("member", "admin", "manager", "owner"). */ + orgRole?: string; }; // Project diff --git a/test/lib/api/infrastructure.test.ts b/test/lib/api/infrastructure.test.ts index 9a1265c7f..cddbd34b8 100644 --- a/test/lib/api/infrastructure.test.ts +++ b/test/lib/api/infrastructure.test.ts @@ -122,6 +122,29 @@ describe("throwApiError", () => { // Test preload sets SENTRY_AUTH_TOKEN, so isEnvTokenActive() returns true // by default in these tests. + test("does not suggest token scopes for org-policy disabled-feature 403s", () => { + const mockResponse = new Response("", { + status: 403, + statusText: "Forbidden", + }); + + try { + throwApiError( + { + detail: "Your organization has disabled this feature for members.", + }, + mockResponse, + "Failed to create project" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.enriched403).toBe(true); + expect(apiError.detail).toContain("disabled this feature"); + expect(apiError.detail).toContain("org-level policy"); + expect(apiError.detail).not.toContain("SENTRY_AUTH_TOKEN"); + } + }); + test("enriches 403 with env-var token hints", () => { const mockResponse = new Response("", { status: 403, @@ -261,6 +284,31 @@ describe("throwApiError", () => { } }); + test("does not suggest re-authentication for org-policy disabled-feature 403s", () => { + const mockResponse = new Response("", { + status: 403, + statusText: "Forbidden", + }); + + try { + throwApiError( + { + detail: + "Your organization has disabled this feature for members.", + }, + mockResponse, + "Failed to create project" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.enriched403).toBe(true); + expect(apiError.detail).toContain("disabled this feature"); + expect(apiError.detail).toContain("org-level policy"); + expect(apiError.detail).not.toContain("Re-authenticate"); + expect(apiError.detail).not.toContain("sentry auth login"); + } + }); + test("suggests re-authentication for OAuth tokens", () => { const mockResponse = new Response("", { status: 403, diff --git a/test/lib/init/tools/create-sentry-project.test.ts b/test/lib/init/tools/create-sentry-project.test.ts index 7916d7de0..97c4ae22b 100644 --- a/test/lib/init/tools/create-sentry-project.test.ts +++ b/test/lib/init/tools/create-sentry-project.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as apiClient from "../../../../src/lib/api-client.js"; import { ApiError } from "../../../../src/lib/errors.js"; -import { createSentryProject } from "../../../../src/lib/init/tools/create-sentry-project.js"; +import { + createSentryProject, + createSentryProjectTool, +} from "../../../../src/lib/init/tools/create-sentry-project.js"; import type { CreateSentryProjectPayload, EnsureSentryProjectPayload, @@ -123,6 +126,19 @@ describe("createSentryProject", () => { expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); }); + test("returns error when project name produces an empty slug", async () => { + const result = await createSentryProject(makePayload({ name: "---" }), { + dryRun: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("produces an empty slug"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + }); + test("creates a new project with the pre-resolved org and team", async () => { getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); @@ -222,6 +238,42 @@ describe("createSentryProject", () => { ); }); + test("returns clear error with sentry-init guidance when org disables member creation", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError( + "Failed to create project: 403 Forbidden", + 403, + "Your organization has disabled this feature for members.", + undefined, + true + ) + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("disabled for members"); + expect(result.error).toContain("sentry init acme/"); + expect(result.error).not.toContain("Re-authenticate"); + }); + + test("tool describe uses payload.detail when provided", () => { + const payload = { ...makePayload(), detail: "Setting up my-app..." }; + expect(createSentryProjectTool.describe(payload)).toBe( + "Setting up my-app..." + ); + }); + + test("tool describe falls back to project name and platform", () => { + expect(createSentryProjectTool.describe(makePayload())).toContain("my-app"); + }); + test("uses the final project slug for deferred team resolution in dry-run mode", async () => { getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404));