Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0e60e1d
feat(project): fall back to org-scoped endpoint for member project cr…
betegon May 28, 2026
aefaded
fix: update tests and fix null platform type for member project creat…
betegon May 28, 2026
68a5d9d
fix: fetch DSN in org-scoped fallback path during sentry init
betegon May 28, 2026
0eb9b54
fix: handle listTeams 403 and fallback 409 in member project creation
betegon May 29, 2026
f24ba87
ref: unexport ProjectWithAutoTeam — no external consumers
betegon May 29, 2026
d11ee6d
fix(test): update listTeams 403 test to reflect new fallback behavior
betegon May 29, 2026
3affebe
fix: handle 403 in --dry-run team resolution for member users
betegon May 29, 2026
2c055cd
fix: allow sentry init to reach org-scoped fallback when listTeams 403s
betegon May 29, 2026
df51dad
cleanup: apply AGENTS.md patterns to member project creation fallback
betegon May 29, 2026
1e0d45f
fix: surface friendly error for 409 in sentry init project creation
betegon May 29, 2026
51d02b3
fix: three correctness gaps in member project creation fallback
betegon May 29, 2026
9346deb
fix: resolve three root causes in member project creation fallback
betegon May 29, 2026
358134e
fix: update test mock shape and add policy-403 short-circuit in creat…
betegon May 29, 2026
cf65ba7
fix: separate team-to-use from fallback-suppression in resolveProject…
betegon May 29, 2026
e25eb66
test: add coverage for org-scoped member project creation fallback
betegon May 29, 2026
1c1fa03
ref: unexport CreatedAutoTeamProjectDetails and clean up tests
betegon May 29, 2026
f4d0188
ref: extract seedProjectCaches helper to deduplicate post-creation ca…
betegon May 29, 2026
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
173 changes: 152 additions & 21 deletions src/commands/project/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
import type { SentryContext } from "../../context.js";
import {
type CreatedProjectDetails,
createProjectWithAutoTeam,
createProjectWithDsn,
listTeams,
MEMBER_PROJECT_CREATION_DISABLED_DETAIL,
} from "../../lib/api-client.js";
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
import { buildCommand } from "../../lib/command.js";
Expand Down Expand Up @@ -56,7 +58,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 <org>/<name> <platform>";

type CreateFlags = {
Expand Down Expand Up @@ -224,6 +226,99 @@ 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<ResolvedConcreteTeam> {
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;
}
log.debug(
"403 on listTeams in dry-run — previewing org-scoped fallback outcome"
);
return { slug: "team-<username>", source: "auto-created" };
}
}

/**
* 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 result: Awaited<ReturnType<typeof createProjectWithAutoTeam>>;
try {
result = await createProjectWithAutoTeam(orgSlug, { name, platform });
} catch (expError) {
if (expError instanceof ApiError) {
if (
expError.status === 403 &&
expError.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL)
) {
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
);
Comment thread
betegon marked this conversation as resolved.
}
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;
}
return {
project: result.project,
dsn: result.dsn,
url: result.url,
teamSlug: result.team_slug,
teamSource: "auto-created",
};
Comment thread
betegon marked this conversation as resolved.
}

/**
* Create a project (with DSN + URL) with user-friendly error handling.
* Wraps API errors with actionable messages instead of raw HTTP status codes.
Expand Down Expand Up @@ -389,19 +484,15 @@ 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 = await resolveDryRunTeam(orgSlug, {
team: flags.team,
detectedFrom: resolved.detectedFrom,
autoCreateSlug: expectedSlug,
});
Comment thread
sentry[bot] marked this conversation as resolved.
const result: ProjectCreatedResult = {
project: { id: "", slug: expectedSlug, name, platform },
orgSlug,
Expand All @@ -417,20 +508,60 @@ 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,
});
// 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.
Comment thread
betegon marked this conversation as resolved.
// Skip the fallback when --team was explicit: the 403 is meaningful there.
if (!(error instanceof ApiError && error.status === 403) || flags.team) {
throw error;
Comment thread
betegon marked this conversation as resolved.
Comment thread
betegon marked this conversation as resolved.
}
// 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,
name,
platform,
});
teamSlug = fallback.teamSlug;
Comment thread
betegon marked this conversation as resolved.
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,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export {
export {
type CreatedProjectDetails,
createProject,
createProjectWithAutoTeam,
createProjectWithDsn,
deleteProject,
findProjectByDsnKey,
Expand All @@ -102,6 +103,7 @@ export {
getProjectKeys,
Comment thread
betegon marked this conversation as resolved.
listProjects,
listProjectsPaginated,
MEMBER_PROJECT_CREATION_DISABLED_DETAIL,
matchesWordBoundary,
type ProjectSearchResult,
type ProjectWithOrg,
Expand Down
130 changes: 103 additions & 27 deletions src/lib/api/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -164,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://<public_key>@<host>/<project_id>
Expand Down Expand Up @@ -195,35 +235,71 @@ 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
}
seedProjectCaches(orgSlug, project, dsn);
return { project, dsn, url };
}

// 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
}
}
/** Raw response shape from the org-scoped project creation endpoint. */
type ProjectWithAutoTeam = SentryProject & {
/** The personal team auto-created by the server for the requesting user. */
team_slug: string;
};

return { project, dsn, url };
/**
* Result of creating a project via the org-scoped member-accessible endpoint.
* Parallel to {@link CreatedProjectDetails} for the team-scoped endpoint.
*/
type CreatedAutoTeamProjectDetails = CreatedProjectDetails & {
/** The personal team auto-created by the server for the requesting user. */
team_slug: string;
};
Comment thread
betegon marked this conversation as resolved.

/**
* 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.
*
* 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<CreatedAutoTeamProjectDetails> {
const regionUrl = await resolveOrgRegion(orgSlug);
const { data } = await apiRequestToRegion<ProjectWithAutoTeam>(
regionUrl,
`/organizations/${orgSlug}/projects/`,
{ method: "POST", body }
);
const dsn = await tryGetPrimaryDsn(orgSlug, data.slug);
const url = buildProjectUrl(orgSlug, data.slug);

seedProjectCaches(orgSlug, data, dsn);
return { project: data, dsn, url, team_slug: data.team_slug };
Comment thread
betegon marked this conversation as resolved.
}

/**
Expand Down
Loading
Loading