Skip to content

Commit 3c370fe

Browse files
authored
fix(dashboard): normalize numeric org IDs from DSN auto-detection (#593)
## Summary - Fixes `dashboard list` and `dashboard view` failing with 404/403 when org is auto-detected from a DSN and the project cache is cold - Adds `normalizeNumericOrg()` to `resolveOrg()` that converts bare numeric org IDs to slugs via DB cache or `listOrganizationsUncached` API fallback - Normalizes explicit org args in `resolveOrgFromTarget` via `resolveEffectiveOrg` to handle `o`-prefixed DSN forms ## Root Cause `resolveOrgFromDsn()` returns the raw numeric org ID from the DSN host (e.g., `"1169445"` from `o1169445.ingest.us.sentry.io`) when the project cache is cold. Dashboard API endpoints reject numeric IDs. Meanwhile, `dashboard create` works because it uses `resolveAllTargets` which does a full project API lookup. `resolveEffectiveOrg()` cannot be reused directly because it only handles `o`-prefixed DSN forms — the DSN parser strips the `o` prefix before `resolveOrgFromDsn` returns. ## Changes | File | Change | |------|--------| | `src/lib/resolve-target.ts` | Add `normalizeNumericOrg()` helper, update `resolveOrg()` DSN path | | `src/commands/dashboard/resolve.ts` | Normalize explicit/org-all org via `resolveEffectiveOrg` | | `test/isolated/resolve-target.test.ts` | Tests for cache hit, API fallback, and slug passthrough | | `test/commands/dashboard/resolve.test.ts` | Tests for `resolveEffectiveOrg` integration | Closes #447
1 parent f57249b commit 3c370fe

File tree

5 files changed

+211
-5
lines changed

5 files changed

+211
-5
lines changed

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,9 @@ mock.module("./some-module", () => ({
905905
<!-- lore:019cce8d-f2c5-726e-8a04-3f48caba45ec -->
906906
* **Input validation layer: src/lib/input-validation.ts guards CLI arg parsing**: Four validators in \`src/lib/input-validation.ts\` guard against agent-hallucinated inputs: \`rejectControlChars\` (ASCII < 0x20), \`rejectPreEncoded\` (%XX), \`validateResourceId\` (rejects ?, #, %, whitespace), \`validateEndpoint\` (rejects \`..\` traversal). Applied in \`parseSlashOrgProject\`, bare-slug path in \`parseOrgProjectArg\`, \`parseIssueArg\`, and \`normalizeEndpoint\` (api.ts). NOT applied in \`parseSlashSeparatedArg\` for no-slash plain IDs — those may contain structural separators (newlines for log view batch IDs) that callers split downstream. Validation targets user-facing parse boundaries only; env vars and DB cache values are trusted.
907907
908+
<!-- lore:019cafbb-24ad-75a3-b037-5efbe6a1e85d -->
909+
* **DSN org prefix normalization in arg-parsing.ts**: DSN org ID normalization has four code paths: (1) \`extractOrgIdFromHost\` in \`dsn/parser.ts\` strips \`o\` prefix during DSN parsing → bare \`"1081365"\`. (2) \`stripDsnOrgPrefix()\` strips \`o\` from user-typed inputs like \`o1081365/\`, applied in \`parseOrgProjectArg()\` and \`resolveEffectiveOrg()\`. (3) \`normalizeNumericOrg()\` in \`resolve-target.ts\` handles bare numeric IDs from cold-cache DSN detection — checks \`getOrgByNumericId()\` from DB cache, falls back to \`listOrganizationsUncached()\` to populate the mapping. Called from \`resolveOrg()\` step 4 (DSN auto-detect path). (4) Dashboard's \`resolveOrgFromTarget()\` pipes explicit org through \`resolveEffectiveOrg()\` for \`o\`-prefixed forms. Critical: many API endpoints reject numeric org IDs with 404/403 — always normalize to slugs before API calls.
910+
908911
<!-- lore:019cd2d1-aa47-7fc1-92f9-cc6c49b19460 -->
909912
* **Magic @ selectors resolve issues dynamically via sort-based list API queries**: Magic \`@\` selectors (\`@latest\`, \`@most\_frequent\`) in \`parseIssueArg\` are detected early (before \`validateResourceId\`) because \`@\` is not in the forbidden charset. \`SELECTOR\_MAP\` provides case-insensitive matching with common variations (\`@mostfrequent\`, \`@most-frequent\`). Resolution in \`resolveSelector\` (issue/utils.ts) maps selectors to \`IssueSort\` values (\`date\`, \`freq\`), calls \`listIssuesPaginated\` with \`perPage: 1\` and \`query: 'is:unresolved'\`. Supports org-prefixed form: \`sentry/@latest\`. Unrecognized \`@\`-prefixed strings fall through to suffix-only parsing (not an error). The \`ParsedIssueArg\` union includes \`{ type: 'selector'; selector: IssueSelector; org?: string }\`.
910913

src/commands/dashboard/resolve.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
ValidationError,
1919
} from "../../lib/errors.js";
2020
import { fuzzyMatch } from "../../lib/fuzzy.js";
21+
import { resolveEffectiveOrg } from "../../lib/region.js";
2122
import { resolveOrg } from "../../lib/resolve-target.js";
2223
import { setOrgProjectContext } from "../../lib/telemetry.js";
2324
import { isAllDigits } from "../../lib/utils.js";
@@ -64,9 +65,11 @@ export async function resolveOrgFromTarget(
6465
): Promise<string> {
6566
switch (parsed.type) {
6667
case "explicit":
67-
case "org-all":
68-
setOrgProjectContext([parsed.org], []);
69-
return parsed.org;
68+
case "org-all": {
69+
const org = await resolveEffectiveOrg(parsed.org);
70+
setOrgProjectContext([org], []);
71+
return org;
72+
}
7073
case "project-search":
7174
case "auto-detect": {
7275
// resolveOrg already sets telemetry context

src/lib/resolve-target.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
setCachedProject,
3333
setCachedProjectByDsnKey,
3434
} from "./db/project-cache.js";
35+
import { getOrgByNumericId } from "./db/regions.js";
3536
import type { DetectedDsn, DsnDetectionResult } from "./dsn/index.js";
3637
import {
3738
detectAllDsns,
@@ -251,6 +252,48 @@ export async function resolveOrgFromDsn(
251252
};
252253
}
253254

255+
/**
256+
* Normalize a bare numeric org ID to an org slug.
257+
*
258+
* When the project cache is cold, resolveOrgFromDsn returns the raw numeric
259+
* org ID from the DSN host (e.g., "1169445"). Many API endpoints reject
260+
* numeric IDs (dashboards return 404/403). This resolves them:
261+
*
262+
* 1. Local DB cache lookup (getOrgByNumericId — fast, no API call)
263+
* 2. Refresh org list via listOrganizationsUncached to populate mapping
264+
* 3. Falls back to original ID if resolution fails
265+
*
266+
* Non-numeric identifiers (already slugs) are returned unchanged.
267+
*
268+
* @param orgId - Raw org identifier from DSN (numeric ID or slug)
269+
* @returns Org slug for API calls
270+
*/
271+
async function normalizeNumericOrg(orgId: string): Promise<string> {
272+
if (!isAllDigits(orgId)) {
273+
return orgId;
274+
}
275+
276+
// Fast path: check local DB cache for numeric ID → slug mapping
277+
const cached = getOrgByNumericId(orgId);
278+
if (cached) {
279+
return cached.slug;
280+
}
281+
282+
// Slow path: fetch org list to populate numeric ID → slug mapping.
283+
// resolveEffectiveOrg doesn't handle bare numeric IDs (only o-prefixed),
284+
// so we do a targeted refresh via listOrganizationsUncached().
285+
try {
286+
const { listOrganizationsUncached } = await import("./api-client.js");
287+
await listOrganizationsUncached();
288+
} catch {
289+
return orgId;
290+
}
291+
292+
// Retry cache after refresh
293+
const afterRefresh = getOrgByNumericId(orgId);
294+
return afterRefresh?.slug ?? orgId;
295+
}
296+
254297
/**
255298
* Resolve a DSN without orgId by searching for the project via DSN public key.
256299
* Uses the /api/0/projects?query=dsn:<key> endpoint.
@@ -1019,7 +1062,12 @@ export async function resolveOrg(
10191062
try {
10201063
const result = await resolveOrgFromDsn(cwd);
10211064
if (result) {
1022-
setOrgProjectContext([result.org], []);
1065+
// resolveOrgFromDsn may return a bare numeric org ID when the project
1066+
// cache is cold. Normalize to a slug so API endpoints that reject
1067+
// numeric IDs (e.g., dashboards) work correctly.
1068+
const resolvedOrg = await normalizeNumericOrg(result.org);
1069+
setOrgProjectContext([resolvedOrg], []);
1070+
return { org: resolvedOrg, detectedFrom: result.detectedFrom };
10231071
}
10241072
return result;
10251073
} catch {

test/commands/dashboard/resolve.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
ValidationError,
2424
} from "../../../src/lib/errors.js";
2525
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
26+
import * as region from "../../../src/lib/region.js";
27+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
2628
import * as resolveTarget from "../../../src/lib/resolve-target.js";
2729

2830
// ---------------------------------------------------------------------------
@@ -333,26 +335,46 @@ describe("resolveDashboardId", () => {
333335

334336
describe("resolveOrgFromTarget", () => {
335337
let resolveOrgSpy: ReturnType<typeof spyOn>;
338+
let resolveEffectiveOrgSpy: ReturnType<typeof spyOn>;
336339

337340
beforeEach(() => {
338341
resolveOrgSpy = spyOn(resolveTarget, "resolveOrg");
342+
// Default: resolveEffectiveOrg returns the input unchanged
343+
resolveEffectiveOrgSpy = spyOn(
344+
region,
345+
"resolveEffectiveOrg"
346+
).mockImplementation((slug: string) => Promise.resolve(slug));
339347
});
340348

341349
afterEach(() => {
342350
resolveOrgSpy.mockRestore();
351+
resolveEffectiveOrgSpy.mockRestore();
343352
});
344353

345-
test("explicit type returns org directly", async () => {
354+
test("explicit type normalizes org via resolveEffectiveOrg", async () => {
346355
const parsed = parseOrgProjectArg("my-org/my-project");
347356
const org = await resolveOrgFromTarget(
348357
parsed,
349358
"/tmp",
350359
"sentry dashboard view"
351360
);
352361
expect(org).toBe("my-org");
362+
expect(resolveEffectiveOrgSpy).toHaveBeenCalledWith("my-org");
353363
expect(resolveOrgSpy).not.toHaveBeenCalled();
354364
});
355365

366+
test("explicit type with o-prefixed numeric ID resolves to slug", async () => {
367+
resolveEffectiveOrgSpy.mockResolvedValue("my-org");
368+
const parsed = parseOrgProjectArg("o1169445/my-project");
369+
const org = await resolveOrgFromTarget(
370+
parsed,
371+
"/tmp",
372+
"sentry dashboard list"
373+
);
374+
expect(org).toBe("my-org");
375+
expect(resolveEffectiveOrgSpy).toHaveBeenCalledWith("o1169445");
376+
});
377+
356378
test("auto-detect with null resolveOrg throws ContextError", async () => {
357379
resolveOrgSpy.mockResolvedValue(null);
358380
const parsed = parseOrgProjectArg(undefined);
@@ -361,6 +383,18 @@ describe("resolveOrgFromTarget", () => {
361383
resolveOrgFromTarget(parsed, "/tmp", "sentry dashboard view")
362384
).rejects.toThrow(ContextError);
363385
});
386+
387+
test("auto-detect delegates to resolveOrg", async () => {
388+
resolveOrgSpy.mockResolvedValue({ org: "detected-org" });
389+
const parsed = parseOrgProjectArg(undefined);
390+
const org = await resolveOrgFromTarget(
391+
parsed,
392+
"/tmp",
393+
"sentry dashboard list"
394+
);
395+
expect(org).toBe("detected-org");
396+
expect(resolveOrgSpy).toHaveBeenCalled();
397+
});
364398
});
365399

366400
// ---------------------------------------------------------------------------

test/isolated/resolve-target.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ const mockGetProject = mock(() =>
5959
);
6060
const mockFindProjectByDsnKey = mock(() => Promise.resolve(null));
6161
const mockFindProjectsByPattern = mock(() => Promise.resolve([]));
62+
const mockListOrganizationsUncached = mock(() => Promise.resolve([]));
63+
const mockGetOrgByNumericId = mock(
64+
() => undefined as { slug: string; regionUrl: string } | undefined
65+
);
6266

6367
// Mock all dependency modules
6468
mock.module("../../src/lib/db/defaults.js", () => ({
@@ -91,10 +95,38 @@ mock.module("../../src/lib/db/dsn-cache.js", () => ({
9195
setCachedDsn: mockSetCachedDsn,
9296
}));
9397

98+
mock.module("../../src/lib/db/regions.js", () => ({
99+
getOrgByNumericId: mockGetOrgByNumericId,
100+
getOrgRegion: mock(() => {
101+
/* returns undefined */
102+
}),
103+
setOrgRegion: mock(() => {
104+
/* no-op */
105+
}),
106+
setOrgRegions: mock(() => {
107+
/* no-op */
108+
}),
109+
clearOrgRegions: mock(() => {
110+
/* no-op */
111+
}),
112+
getAllOrgRegions: mock(() => new Map()),
113+
getCachedOrganizations: mock(() => []),
114+
getCachedOrgRole: mock(() => {
115+
/* returns undefined */
116+
}),
117+
disableOrgCache: mock(() => {
118+
/* no-op */
119+
}),
120+
enableOrgCache: mock(() => {
121+
/* no-op */
122+
}),
123+
}));
124+
94125
mock.module("../../src/lib/api-client.js", () => ({
95126
getProject: mockGetProject,
96127
findProjectByDsnKey: mockFindProjectByDsnKey,
97128
findProjectsByPattern: mockFindProjectsByPattern,
129+
listOrganizationsUncached: mockListOrganizationsUncached,
98130
}));
99131

100132
import { ContextError } from "../../src/lib/errors.js";
@@ -124,6 +156,8 @@ function resetAllMocks() {
124156
mockGetProject.mockReset();
125157
mockFindProjectByDsnKey.mockReset();
126158
mockFindProjectsByPattern.mockReset();
159+
mockListOrganizationsUncached.mockReset();
160+
mockGetOrgByNumericId.mockReset();
127161

128162
// Set sensible defaults
129163
mockGetDefaultOrganization.mockReturnValue(null);
@@ -146,6 +180,8 @@ function resetAllMocks() {
146180
mockGetCachedProjectByDsnKey.mockReturnValue(null);
147181
mockGetCachedDsn.mockReturnValue(null);
148182
mockFindProjectsByPattern.mockResolvedValue([]);
183+
mockListOrganizationsUncached.mockResolvedValue([]);
184+
mockGetOrgByNumericId.mockReturnValue(undefined);
149185
}
150186

151187
// ============================================================================
@@ -223,6 +259,88 @@ describe("resolveOrg", () => {
223259
expect(result?.org).toBe("123");
224260
});
225261

262+
test("normalizes numeric orgId to slug via DB cache", async () => {
263+
mockGetDefaultOrganization.mockReturnValue(null);
264+
mockDetectDsn.mockResolvedValue({
265+
raw: "https://abc@o1169445.ingest.us.sentry.io/456",
266+
protocol: "https",
267+
publicKey: "abc",
268+
host: "o1169445.ingest.us.sentry.io",
269+
projectId: "456",
270+
orgId: "1169445",
271+
source: "env",
272+
});
273+
mockGetCachedProject.mockReturnValue(null);
274+
mockGetOrgByNumericId.mockReturnValue({
275+
slug: "my-org",
276+
regionUrl: "https://us.sentry.io",
277+
});
278+
279+
const result = await resolveOrg({ cwd: "/test" });
280+
281+
expect(result).not.toBeNull();
282+
expect(result?.org).toBe("my-org");
283+
expect(mockGetOrgByNumericId).toHaveBeenCalledWith("1169445");
284+
});
285+
286+
test("normalizes numeric orgId via API when DB cache misses", async () => {
287+
mockGetDefaultOrganization.mockReturnValue(null);
288+
mockDetectDsn.mockResolvedValue({
289+
raw: "https://abc@o1169445.ingest.us.sentry.io/456",
290+
protocol: "https",
291+
publicKey: "abc",
292+
host: "o1169445.ingest.us.sentry.io",
293+
projectId: "456",
294+
orgId: "1169445",
295+
source: "env",
296+
});
297+
mockGetCachedProject.mockReturnValue(null);
298+
299+
// First call returns undefined (cache miss), second call returns slug
300+
// (after listOrganizationsUncached populates the cache)
301+
let callCount = 0;
302+
mockGetOrgByNumericId.mockImplementation(() => {
303+
callCount += 1;
304+
if (callCount >= 2) {
305+
return { slug: "my-org", regionUrl: "https://us.sentry.io" };
306+
}
307+
return;
308+
});
309+
mockListOrganizationsUncached.mockResolvedValue([]);
310+
311+
const result = await resolveOrg({ cwd: "/test" });
312+
313+
expect(result).not.toBeNull();
314+
expect(result?.org).toBe("my-org");
315+
expect(mockListOrganizationsUncached).toHaveBeenCalled();
316+
});
317+
318+
test("skips normalization for non-numeric org slug from DSN cache", async () => {
319+
mockGetDefaultOrganization.mockReturnValue(null);
320+
mockDetectDsn.mockResolvedValue({
321+
raw: "https://abc@o123.ingest.sentry.io/456",
322+
protocol: "https",
323+
publicKey: "abc",
324+
host: "o123.ingest.sentry.io",
325+
projectId: "456",
326+
orgId: "123",
327+
source: "env",
328+
});
329+
mockGetCachedProject.mockReturnValue({
330+
orgSlug: "cached-org",
331+
orgName: "Cached Organization",
332+
projectSlug: "project",
333+
projectName: "Project",
334+
});
335+
336+
const result = await resolveOrg({ cwd: "/test" });
337+
338+
expect(result).not.toBeNull();
339+
expect(result?.org).toBe("cached-org");
340+
// getOrgByNumericId should not be called because "cached-org" is not all digits
341+
expect(mockGetOrgByNumericId).not.toHaveBeenCalled();
342+
});
343+
226344
test("returns null when no org found from any source", async () => {
227345
mockGetDefaultOrganization.mockReturnValue(null);
228346
mockDetectDsn.mockResolvedValue(null);

0 commit comments

Comments
 (0)