Skip to content

Commit fb39fee

Browse files
committed
fix(dashboard): normalize numeric org IDs from DSN auto-detection (#447)
When the project cache is cold, resolveOrgFromDsn returns bare numeric org IDs (e.g., "1169445") extracted from DSN hosts. Dashboard API endpoints reject these with 404/403, while other commands using resolveAllTargets work because they do a full project API lookup. Add normalizeNumericOrg() to resolveOrg() that converts bare numeric IDs to slugs via the org regions DB cache or listOrganizationsUncached API fallback. Also normalize explicit org args in dashboard commands via resolveEffectiveOrg to handle o-prefixed DSN forms. Closes #447
1 parent 8c2f8b3 commit fb39fee

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
@@ -13,6 +13,7 @@ import {
1313
import type { parseOrgProjectArg } from "../../lib/arg-parsing.js";
1414
import { ContextError, ValidationError } from "../../lib/errors.js";
1515
import { fuzzyMatch } from "../../lib/fuzzy.js";
16+
import { resolveEffectiveOrg } from "../../lib/region.js";
1617
import { resolveOrg } from "../../lib/resolve-target.js";
1718
import { setOrgProjectContext } from "../../lib/telemetry.js";
1819
import { isAllDigits } from "../../lib/utils.js";
@@ -59,9 +60,11 @@ export async function resolveOrgFromTarget(
5960
): Promise<string> {
6061
switch (parsed.type) {
6162
case "explicit":
62-
case "org-all":
63-
setOrgProjectContext([parsed.org], []);
64-
return parsed.org;
63+
case "org-all": {
64+
const org = await resolveEffectiveOrg(parsed.org);
65+
setOrgProjectContext([org], []);
66+
return org;
67+
}
6568
case "project-search":
6669
case "auto-detect": {
6770
// 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
@@ -17,6 +17,8 @@ import * as apiClient from "../../../src/lib/api-client.js";
1717
import { parseOrgProjectArg } from "../../../src/lib/arg-parsing.js";
1818
import { ContextError, ValidationError } from "../../../src/lib/errors.js";
1919
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
20+
import * as region from "../../../src/lib/region.js";
21+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
2022
import * as resolveTarget from "../../../src/lib/resolve-target.js";
2123

2224
// ---------------------------------------------------------------------------
@@ -327,26 +329,46 @@ describe("resolveDashboardId", () => {
327329

328330
describe("resolveOrgFromTarget", () => {
329331
let resolveOrgSpy: ReturnType<typeof spyOn>;
332+
let resolveEffectiveOrgSpy: ReturnType<typeof spyOn>;
330333

331334
beforeEach(() => {
332335
resolveOrgSpy = spyOn(resolveTarget, "resolveOrg");
336+
// Default: resolveEffectiveOrg returns the input unchanged
337+
resolveEffectiveOrgSpy = spyOn(
338+
region,
339+
"resolveEffectiveOrg"
340+
).mockImplementation((slug: string) => Promise.resolve(slug));
333341
});
334342

335343
afterEach(() => {
336344
resolveOrgSpy.mockRestore();
345+
resolveEffectiveOrgSpy.mockRestore();
337346
});
338347

339-
test("explicit type returns org directly", async () => {
348+
test("explicit type normalizes org via resolveEffectiveOrg", async () => {
340349
const parsed = parseOrgProjectArg("my-org/my-project");
341350
const org = await resolveOrgFromTarget(
342351
parsed,
343352
"/tmp",
344353
"sentry dashboard view"
345354
);
346355
expect(org).toBe("my-org");
356+
expect(resolveEffectiveOrgSpy).toHaveBeenCalledWith("my-org");
347357
expect(resolveOrgSpy).not.toHaveBeenCalled();
348358
});
349359

360+
test("explicit type with o-prefixed numeric ID resolves to slug", async () => {
361+
resolveEffectiveOrgSpy.mockResolvedValue("my-org");
362+
const parsed = parseOrgProjectArg("o1169445/my-project");
363+
const org = await resolveOrgFromTarget(
364+
parsed,
365+
"/tmp",
366+
"sentry dashboard list"
367+
);
368+
expect(org).toBe("my-org");
369+
expect(resolveEffectiveOrgSpy).toHaveBeenCalledWith("o1169445");
370+
});
371+
350372
test("auto-detect with null resolveOrg throws ContextError", async () => {
351373
resolveOrgSpy.mockResolvedValue(null);
352374
const parsed = parseOrgProjectArg(undefined);
@@ -355,4 +377,16 @@ describe("resolveOrgFromTarget", () => {
355377
resolveOrgFromTarget(parsed, "/tmp", "sentry dashboard view")
356378
).rejects.toThrow(ContextError);
357379
});
380+
381+
test("auto-detect delegates to resolveOrg", async () => {
382+
resolveOrgSpy.mockResolvedValue({ org: "detected-org" });
383+
const parsed = parseOrgProjectArg(undefined);
384+
const org = await resolveOrgFromTarget(
385+
parsed,
386+
"/tmp",
387+
"sentry dashboard list"
388+
);
389+
expect(org).toBe("detected-org");
390+
expect(resolveOrgSpy).toHaveBeenCalled();
391+
});
358392
});

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)