feat(project): fall back to org-scoped endpoint for member project creation#1030
Conversation
|
…eation
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).
ff4627a to
0e60e1d
Compare
…ion fallback - 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
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.
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.
Self-review — 4 findings🔴
|
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.
--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.
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.
- 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
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.
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).
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.
…e command
- 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.
…Creation 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.
Codecov Results 📊✅ Patch coverage is 81.71%. Project has 4297 uncovered lines. Files with missing lines (5)
Coverage diff@@ Coverage Diff @@
## main #PR +/-##
==========================================
- Coverage 82.01% 82.00% -0.01%
==========================================
Files 329 329 —
Lines 23806 23870 +64
Branches 15543 15595 +52
==========================================
+ Hits 19522 19573 +51
- Misses 4284 4297 +13
- Partials 1643 1648 +5Generated by Codecov Action |
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
- 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.
…ching 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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f4d0188. Configure here.

Problem
sentry project createandsentry initboth failed with 403 for org members. The team-scoped endpoint (POST /teams/{org}/{team}/projects/) requiresproject:write, which thememberrole doesn't have. Trying to auto-create a team first also 403s since that requiresteam:write.Solution
Fall back to
POST /organizations/{org}/projects/on 403. This endpoint:project:read(members have this)team-{username}) for the callerThe fallback is skipped when
--teamis passed explicitly — in that case the 403 is meaningful feedback, not a permissions gap.Before:
After:
Changes
src/lib/api/projects.ts—createProjectWithAutoTeam()+ProjectWithAutoTeamtype, callsPOST /organizations/{org}/projects/src/lib/resolve-team.ts— re-throwApiError403 fromautoCreateTeam(instead of wrapping inCliError) so callers can detect and fall backsrc/commands/project/create.ts—createProjectWithAutoTeamFallback()helper; mainfunccalls it on 403src/lib/init/tools/create-sentry-project.ts—resolveProjectCreation()helper with the same fallback forsentry initBoth helpers are extracted to keep parent functions under biome's complexity limit (max 15).