Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
Expand Down
27 changes: 27 additions & 0 deletions src/lib/init/tools/create-sentry-project.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <org>/<project-slug>` 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,
Expand Down Expand Up @@ -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 <org>/<slug>` 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}/<project-slug>`,
};
}
return { ok: false, error: formatToolError(error) };
}
}
Expand Down
10 changes: 10 additions & 0 deletions src/types/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SdkOrganizationList[number]> & {
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
Expand Down
48 changes: 48 additions & 0 deletions test/lib/api/infrastructure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 53 additions & 1 deletion test/lib/init/tools/create-sentry-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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));

Expand Down
Loading