From 1c74654310247d11c3189eeecb7652ba2983494a Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 9 Apr 2026 21:46:09 +0000 Subject: [PATCH 01/10] feat(dashboard): add Claude Code subscription limits to sidebar --- src/anthropic/client.ts | 101 +++++++++++ src/api/router.ts | 2 + src/api/routers/claudeCodeLimits.ts | 40 ++++ src/db/repositories/credentialsRepository.ts | 31 ++++ tests/unit/anthropic/client.test.ts | 162 +++++++++++++++++ .../unit/api/routers/claudeCodeLimits.test.ts | 171 ++++++++++++++++++ vitest.config.ts | 1 + .../components/global/claude-code-limits.tsx | 63 +++++++ web/src/components/layout/sidebar.tsx | 2 + 9 files changed, 573 insertions(+) create mode 100644 src/anthropic/client.ts create mode 100644 src/api/routers/claudeCodeLimits.ts create mode 100644 tests/unit/anthropic/client.test.ts create mode 100644 tests/unit/api/routers/claudeCodeLimits.test.ts create mode 100644 web/src/components/global/claude-code-limits.tsx diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts new file mode 100644 index 000000000..4fbe7bf51 --- /dev/null +++ b/src/anthropic/client.ts @@ -0,0 +1,101 @@ +const ANTHROPIC_ACCOUNT_URL = 'https://api.anthropic.com/api/account'; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const FETCH_TIMEOUT_MS = 10_000; // 10 seconds + +export interface ClaudeSubscriptionLimits { + plan: string; + messagesUsed: number; + messagesLimit: number; + tokensUsed: number; + tokensLimit: number; + resetsAt: string; + tokenMasked: string; +} + +interface CacheEntry { + data: ClaudeSubscriptionLimits; + timestamp: number; +} + +/** + * Per-token cache. Keyed by masked token representation to avoid storing raw + * tokens as cache keys. Uses a Map keyed by full token for lookup; only the + * masked value is surfaced in returned data. + */ +const cacheByToken = new Map(); + +/** + * Masks a token, showing only the last 4 characters. + */ +function maskToken(token: string): string { + return `****${token.slice(-4)}`; +} + +/** + * Fetch Claude subscription limits for the given OAuth token. + * Returns null on any error (network, auth, unexpected shape, etc.). + * Results are cached in memory for 5 minutes per unique token. + */ +export async function fetchClaudeSubscriptionLimits( + oauthToken: string, +): Promise { + // Return cached result if still valid + const cached = cacheByToken.get(oauthToken); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return cached.data; + } + + try { + const response = await fetch(ANTHROPIC_ACCOUNT_URL, { + headers: { + Authorization: `Bearer ${oauthToken}`, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + return null; + } + + const json = (await response.json()) as Record; + + // Parse defensively — return null if the shape doesn't match expectations + const usage = json.usage as Record | undefined; + + if (!usage) { + return null; + } + + const plan = typeof json.plan === 'string' ? json.plan : 'unknown'; + const messagesUsed = typeof usage.messages_used === 'number' ? usage.messages_used : 0; + const messagesLimit = typeof usage.messages_limit === 'number' ? usage.messages_limit : 0; + const tokensUsed = typeof usage.tokens_used === 'number' ? usage.tokens_used : 0; + const tokensLimit = typeof usage.tokens_limit === 'number' ? usage.tokens_limit : 0; + const resetsAt = typeof usage.resets_at === 'string' ? usage.resets_at : ''; + + const result: ClaudeSubscriptionLimits = { + plan, + messagesUsed, + messagesLimit, + tokensUsed, + tokensLimit, + resetsAt, + tokenMasked: maskToken(oauthToken), + }; + + cacheByToken.set(oauthToken, { data: result, timestamp: Date.now() }); + return result; + } catch { + // Return null on any failure (network error, timeout, parse error, etc.) + return null; + } +} + +/** + * Clear the in-memory limits cache (useful for testing). + */ +export function clearAnthropicLimitsCache(): void { + cacheByToken.clear(); +} diff --git a/src/api/router.ts b/src/api/router.ts index 9982ebd60..5d7171ae5 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -2,6 +2,7 @@ import { agentConfigsRouter } from './routers/agentConfigs.js'; import { agentDefinitionsRouter } from './routers/agentDefinitions.js'; import { agentTriggerConfigsRouter } from './routers/agentTriggerConfigs.js'; import { authRouter } from './routers/auth.js'; +import { claudeCodeLimitsRouter } from './routers/claudeCodeLimits.js'; import { integrationsDiscoveryRouter } from './routers/integrationsDiscovery.js'; import { organizationRouter } from './routers/organization.js'; import { pmDiscoveryRouter } from './routers/pm-discovery.js'; @@ -35,6 +36,7 @@ export const appRouter = router({ workItems: workItemsRouter, users: usersRouter, workflowStatuses: workflowStatusesRouter, + claudeCodeLimits: claudeCodeLimitsRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/api/routers/claudeCodeLimits.ts b/src/api/routers/claudeCodeLimits.ts new file mode 100644 index 000000000..58ff3f7c3 --- /dev/null +++ b/src/api/routers/claudeCodeLimits.ts @@ -0,0 +1,40 @@ +import { fetchClaudeSubscriptionLimits } from '../../anthropic/client.js'; +import { listAllClaudeCodeCredentials } from '../../db/repositories/credentialsRepository.js'; +import { router, superAdminProcedure } from '../trpc.js'; + +export const claudeCodeLimitsRouter = router({ + /** + * Fetch Claude Code subscription limits for all unique OAuth tokens configured + * across org projects, plus the global env var if set. + * + * Superadmin only. Returns masked token + limits data — never raw tokens. + */ + query: superAdminProcedure.query(async ({ ctx }) => { + // Gather tokens from project credentials + const projectCredentials = await listAllClaudeCodeCredentials(ctx.effectiveOrgId); + + // Build a deduplicated set of tokens (value → first seen) + const tokenMap = new Map(); + const tokens: string[] = []; + + for (const cred of projectCredentials) { + if (!tokenMap.has(cred.value)) { + tokenMap.set(cred.value, true); + tokens.push(cred.value); + } + } + + // Also include the global env var if set + const globalToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; + if (globalToken && !tokenMap.has(globalToken)) { + tokenMap.set(globalToken, true); + tokens.push(globalToken); + } + + // Fetch limits for each unique token in parallel + const results = await Promise.all(tokens.map((token) => fetchClaudeSubscriptionLimits(token))); + + // Filter nulls (API errors / unavailable) + return results.filter((r) => r !== null); + }), +}); diff --git a/src/db/repositories/credentialsRepository.ts b/src/db/repositories/credentialsRepository.ts index 75079b98e..58ca53f29 100644 --- a/src/db/repositories/credentialsRepository.ts +++ b/src/db/repositories/credentialsRepository.ts @@ -159,6 +159,37 @@ export async function listProjectCredentialsMeta( .where(eq(projectCredentials.projectId, projectId)); } +// ============================================================================ +// Cross-project credential queries +// ============================================================================ + +/** + * List all CLAUDE_CODE_OAUTH_TOKEN credentials across all projects in an org. + * Returns decrypted values for use in server-side API calls only. + * Never expose raw tokens to the client. + */ +export async function listAllClaudeCodeCredentials( + orgId: string, +): Promise<{ projectId: string; value: string }[]> { + const db = getDb(); + + const rows = await db + .select({ + projectId: projectCredentials.projectId, + value: projectCredentials.value, + }) + .from(projectCredentials) + .innerJoin(projects, eq(projectCredentials.projectId, projects.id)) + .where( + and(eq(projects.orgId, orgId), eq(projectCredentials.envVarKey, 'CLAUDE_CODE_OAUTH_TOKEN')), + ); + + return rows.map((row) => ({ + projectId: row.projectId, + value: decryptCredential(row.value, row.projectId), + })); +} + // ============================================================================ // Integration metadata queries // ============================================================================ diff --git a/tests/unit/anthropic/client.test.ts b/tests/unit/anthropic/client.test.ts new file mode 100644 index 000000000..d15cf6083 --- /dev/null +++ b/tests/unit/anthropic/client.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + clearAnthropicLimitsCache, + fetchClaudeSubscriptionLimits, +} from '../../../src/anthropic/client.js'; + +describe('fetchClaudeSubscriptionLimits', () => { + beforeEach(() => { + clearAnthropicLimitsCache(); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearAnthropicLimitsCache(); + }); + + function makeFetchResponse(data: unknown, ok = true, status = 200) { + return Promise.resolve({ + ok, + status, + statusText: ok ? 'OK' : 'Unauthorized', + json: () => Promise.resolve(data), + }); + } + + const sampleResponse = { + plan: 'claude_max', + usage: { + messages_used: 1234, + messages_limit: 20000, + tokens_used: 500000, + tokens_limit: 10000000, + resets_at: '2026-05-01T00:00:00Z', + }, + }; + + it('returns limits data on success', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-oauth-token'); + + expect(result).not.toBeNull(); + expect(result?.plan).toBe('claude_max'); + expect(result?.messagesUsed).toBe(1234); + expect(result?.messagesLimit).toBe(20000); + expect(result?.tokensUsed).toBe(500000); + expect(result?.tokensLimit).toBe(10000000); + expect(result?.resetsAt).toBe('2026-05-01T00:00:00Z'); + }); + + it('masks the token showing only last 4 chars', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('sk-ant-oauth-abcd1234'); + + expect(result?.tokenMasked).toBe('****1234'); + }); + + it('sends Authorization header with Bearer token', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + await fetchClaudeSubscriptionLimits('my-oauth-token'); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-oauth-token', + }), + }), + ); + }); + + it('returns null on 4xx response', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({}, false, 401) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('bad-token'); + + expect(result).toBeNull(); + }); + + it('returns null on 5xx response', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({}, false, 500) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).toBeNull(); + }); + + it('returns null on network error', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error')); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).toBeNull(); + }); + + it('returns null on timeout', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new DOMException('Timeout', 'AbortError')); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no usage field', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({ plan: 'claude_max' }) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).toBeNull(); + }); + + it('caches results for subsequent calls with the same token', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + await fetchClaudeSubscriptionLimits('my-token'); + // Second call should use cache (fetch called only once) + await fetchClaudeSubscriptionLimits('my-token'); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not share cache between different tokens', async () => { + vi.mocked(fetch) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType); + + await fetchClaudeSubscriptionLimits('token-a'); + await fetchClaudeSubscriptionLimits('token-b'); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('clearAnthropicLimitsCache allows re-fetching', async () => { + vi.mocked(fetch) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType); + + await fetchClaudeSubscriptionLimits('my-token'); + clearAnthropicLimitsCache(); + await fetchClaudeSubscriptionLimits('my-token'); + + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/api/routers/claudeCodeLimits.test.ts b/tests/unit/api/routers/claudeCodeLimits.test.ts new file mode 100644 index 000000000..19a43405f --- /dev/null +++ b/tests/unit/api/routers/claudeCodeLimits.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createMockContext, + createMockSuperAdmin, + createMockUser, +} from '../../../helpers/factories.js'; +import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; + +const { mockListAllClaudeCodeCredentials, mockFetchClaudeSubscriptionLimits } = vi.hoisted(() => ({ + mockListAllClaudeCodeCredentials: vi.fn(), + mockFetchClaudeSubscriptionLimits: vi.fn(), +})); + +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ + listAllClaudeCodeCredentials: mockListAllClaudeCodeCredentials, +})); + +vi.mock('../../../../src/anthropic/client.js', () => ({ + fetchClaudeSubscriptionLimits: mockFetchClaudeSubscriptionLimits, +})); + +import { claudeCodeLimitsRouter } from '../../../../src/api/routers/claudeCodeLimits.js'; + +const createCaller = createCallerFor(claudeCodeLimitsRouter); + +const sampleLimits = { + plan: 'claude_max', + messagesUsed: 1000, + messagesLimit: 20000, + tokensUsed: 500000, + tokensLimit: 10000000, + resetsAt: '2026-05-01T00:00:00Z', + tokenMasked: '****abcd', +}; + +describe('claudeCodeLimitsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Clear the global env var between tests + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + }); + + describe('query', () => { + it('requires superadmin role — rejects regular users', async () => { + const caller = createCaller(createMockContext({ role: 'member' })); + await expectTRPCError(caller.query(), 'FORBIDDEN'); + }); + + it('requires superadmin role — rejects admin users', async () => { + const caller = createCaller(createMockContext({ role: 'admin' })); + await expectTRPCError(caller.query(), 'FORBIDDEN'); + }); + + it('returns empty array when no credentials and no env var', async () => { + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([]); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + expect(result).toEqual([]); + }); + + it('fetches limits for credentials found in DB', async () => { + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ + { projectId: 'proj-1', value: 'token-aaa' }, + ]); + mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(sampleLimits); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(sampleLimits); + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('token-aaa'); + }); + + it('deduplicates tokens from multiple projects', async () => { + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ + { projectId: 'proj-1', value: 'shared-token' }, + { projectId: 'proj-2', value: 'shared-token' }, + { projectId: 'proj-3', value: 'other-token' }, + ]); + mockFetchClaudeSubscriptionLimits + .mockResolvedValueOnce({ ...sampleLimits, tokenMasked: '****oken' }) + .mockResolvedValueOnce({ ...sampleLimits, tokenMasked: '****oken2' }); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + // Should only call fetch twice (once per unique token) + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + }); + + it('includes global env var token', async () => { + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'global-env-token'; + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([]); + mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(sampleLimits); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('global-env-token'); + expect(result).toHaveLength(1); + }); + + it('deduplicates global env var against project credentials', async () => { + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'shared-token'; + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ + { projectId: 'proj-1', value: 'shared-token' }, + ]); + mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(sampleLimits); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + // Even though token appears in both DB and env, fetch only once + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + }); + + it('filters out null results from failed API calls', async () => { + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ + { projectId: 'proj-1', value: 'token-good' }, + { projectId: 'proj-2', value: 'token-bad' }, + ]); + mockFetchClaudeSubscriptionLimits + .mockResolvedValueOnce(sampleLimits) // token-good succeeds + .mockResolvedValueOnce(null); // token-bad fails + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(sampleLimits); + }); + + it('returns empty array when all API calls return null', async () => { + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ + { projectId: 'proj-1', value: 'token-bad' }, + ]); + mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(null); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 44cef87da..ce606e496 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -159,6 +159,7 @@ export default defineConfig({ 'tests/unit/openrouter/**/*.test.ts', 'tests/unit/sentry/**/*.test.ts', 'tests/unit/docker/**/*.test.ts', + 'tests/unit/anthropic/**/*.test.ts', 'tests/unit/*.test.ts', ], ...sharedTest, diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx new file mode 100644 index 000000000..6a8e4422e --- /dev/null +++ b/web/src/components/global/claude-code-limits.tsx @@ -0,0 +1,63 @@ +import { useQuery } from '@tanstack/react-query'; +import { trpc } from '@/lib/trpc.js'; + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +function formatResetDate(resetsAt: string): string { + if (!resetsAt) return ''; + try { + const date = new Date(resetsAt); + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } catch { + return resetsAt; + } +} + +/** + * Displays Claude Code subscription limits for all unique tokens configured + * across org projects. Shown only to superadmins; auto-hides when no data. + */ +export function ClaudeCodeLimitsSection() { + const { data } = useQuery({ + ...trpc.claudeCodeLimits.query.queryOptions(), + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + // Hide if no data returned (no tokens configured or API unavailable) + if (!data || data.length === 0) { + return null; + } + + return ( +
+
+ Limits +
+
+ {data.map((limits) => ( +
+
+ {limits.tokenMasked} +
+
{limits.plan}
+ {limits.messagesLimit > 0 && ( +
+ Msgs: {formatNumber(limits.messagesUsed)} / {formatNumber(limits.messagesLimit)} +
+ )} + {limits.tokensLimit > 0 && ( +
+ Tokens: {formatNumber(limits.tokensUsed)} / {formatNumber(limits.tokensLimit)} +
+ )} + {limits.resetsAt && ( +
Resets {formatResetDate(limits.resetsAt)}
+ )} +
+ ))} +
+
+ ); +} diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 7ade0e166..18e4836d2 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -14,6 +14,7 @@ import { Zap, } from 'lucide-react'; import { useEffect, useState } from 'react'; +import { ClaudeCodeLimitsSection } from '@/components/global/claude-code-limits.js'; import { OrgNameBanner, OrgSwitcher } from '@/components/layout/org-switcher.js'; import { ProjectFormDialog } from '@/components/projects/project-form-dialog.js'; import { @@ -243,6 +244,7 @@ export function Sidebar({ user }: SidebarProps) { {globalNav.map((item) => ( ))} + )} From f60514d4e6010014379b893450ccb9c654c65ff0 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 9 Apr 2026 22:08:05 +0000 Subject: [PATCH 02/10] fix: address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use array index as React key in claude-code-limits.tsx to avoid reconciliation bugs when tokens share the same trailing 4 chars; suppress noArrayIndexKey lint rule with explanation - Fix contradictory JSDoc on cacheByToken — remove false claim that raw tokens are not stored as cache keys - Remove unused createMockUser import in claudeCodeLimits.test.ts Co-Authored-By: Claude Sonnet 4.6 --- src/anthropic/client.ts | 5 +-- .../unit/api/routers/claudeCodeLimits.test.ts | 6 +-- .../components/global/claude-code-limits.tsx | 45 ++++++++++--------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts index 4fbe7bf51..6a5266103 100644 --- a/src/anthropic/client.ts +++ b/src/anthropic/client.ts @@ -18,9 +18,8 @@ interface CacheEntry { } /** - * Per-token cache. Keyed by masked token representation to avoid storing raw - * tokens as cache keys. Uses a Map keyed by full token for lookup; only the - * masked value is surfaced in returned data. + * Per-token cache. Keyed by full token for lookup; only the masked value is + * surfaced in returned data. */ const cacheByToken = new Map(); diff --git a/tests/unit/api/routers/claudeCodeLimits.test.ts b/tests/unit/api/routers/claudeCodeLimits.test.ts index 19a43405f..83df55034 100644 --- a/tests/unit/api/routers/claudeCodeLimits.test.ts +++ b/tests/unit/api/routers/claudeCodeLimits.test.ts @@ -1,9 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - createMockContext, - createMockSuperAdmin, - createMockUser, -} from '../../../helpers/factories.js'; +import { createMockContext, createMockSuperAdmin } from '../../../helpers/factories.js'; import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; const { mockListAllClaudeCodeCredentials, mockFetchClaudeSubscriptionLimits } = vi.hoisted(() => ({ diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx index 6a8e4422e..842eb0f60 100644 --- a/web/src/components/global/claude-code-limits.tsx +++ b/web/src/components/global/claude-code-limits.tsx @@ -36,27 +36,32 @@ export function ClaudeCodeLimitsSection() { Limits
- {data.map((limits) => ( -
-
- {limits.tokenMasked} -
-
{limits.plan}
- {limits.messagesLimit > 0 && ( -
- Msgs: {formatNumber(limits.messagesUsed)} / {formatNumber(limits.messagesLimit)} -
- )} - {limits.tokensLimit > 0 && ( -
- Tokens: {formatNumber(limits.tokensUsed)} / {formatNumber(limits.tokensLimit)} + {data.map((limits, i) => { + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: tokenMasked is not guaranteed unique (tokens may share trailing 4 chars); index is safe here as list order is server-determined and stable +
+
+ {limits.tokenMasked}
- )} - {limits.resetsAt && ( -
Resets {formatResetDate(limits.resetsAt)}
- )} -
- ))} +
{limits.plan}
+ {limits.messagesLimit > 0 && ( +
+ Msgs: {formatNumber(limits.messagesUsed)} / {formatNumber(limits.messagesLimit)} +
+ )} + {limits.tokensLimit > 0 && ( +
+ Tokens: {formatNumber(limits.tokensUsed)} / {formatNumber(limits.tokensLimit)} +
+ )} + {limits.resetsAt && ( +
+ Resets {formatResetDate(limits.resetsAt)} +
+ )} +
+ ); + })}
); From 4be27a45e9febd44d2d0578a8a52c341f80f4aba Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Apr 2026 15:53:06 +0000 Subject: [PATCH 03/10] fix(anthropic): use correct oauth/profile endpoint for subscription info The previous implementation called https://api.anthropic.com/api/account which does not exist in Anthropic's API for OAuth tokens. The Claude Code CLI actually uses https://api.anthropic.com/api/oauth/profile to fetch subscription/organization info. Update fetchClaudeSubscriptionLimits to call the correct endpoint and parse the organization.organization_type field for the plan name. Per-token usage stats (messages/tokens used vs. limit) are not available from this endpoint, so those fields return 0 and the UI hides them automatically. Co-Authored-By: Claude Sonnet 4.6 --- src/anthropic/client.ts | 47 +++++++++++++-------- tests/unit/anthropic/client.test.ts | 63 ++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 32 deletions(-) diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts index 6a5266103..b3e93c42b 100644 --- a/src/anthropic/client.ts +++ b/src/anthropic/client.ts @@ -1,4 +1,10 @@ -const ANTHROPIC_ACCOUNT_URL = 'https://api.anthropic.com/api/account'; +/** + * OAuth profile endpoint used by the Claude Code CLI to fetch subscription info. + * Returns organization type (plan), rate limit tier, and account display name. + * Note: per-token usage stats (messages/tokens used) are not available via this + * endpoint — they are only surfaced via rate-limit response headers during API calls. + */ +const ANTHROPIC_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'; const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const FETCH_TIMEOUT_MS = 10_000; // 10 seconds @@ -31,9 +37,14 @@ function maskToken(token: string): string { } /** - * Fetch Claude subscription limits for the given OAuth token. + * Fetch Claude subscription info for the given OAuth token via the oauth/profile endpoint. * Returns null on any error (network, auth, unexpected shape, etc.). * Results are cached in memory for 5 minutes per unique token. + * + * Note: per-token usage stats (messages/tokens used vs. limit) are not available + * from this endpoint. The returned `messagesUsed`, `messagesLimit`, `tokensUsed`, + * `tokensLimit`, and `resetsAt` fields will always be 0/"" — the UI hides them + * when the limit is 0. */ export async function fetchClaudeSubscriptionLimits( oauthToken: string, @@ -45,7 +56,7 @@ export async function fetchClaudeSubscriptionLimits( } try { - const response = await fetch(ANTHROPIC_ACCOUNT_URL, { + const response = await fetch(ANTHROPIC_PROFILE_URL, { headers: { Authorization: `Bearer ${oauthToken}`, 'anthropic-version': '2023-06-01', @@ -60,27 +71,29 @@ export async function fetchClaudeSubscriptionLimits( const json = (await response.json()) as Record; - // Parse defensively — return null if the shape doesn't match expectations - const usage = json.usage as Record | undefined; + // Parse defensively — return null if the shape doesn't match expectations. + // The profile response contains: { organization: { organization_type, rate_limit_tier, ... }, account: { ... } } + const organization = json.organization as Record | undefined; - if (!usage) { + if (!organization) { return null; } - const plan = typeof json.plan === 'string' ? json.plan : 'unknown'; - const messagesUsed = typeof usage.messages_used === 'number' ? usage.messages_used : 0; - const messagesLimit = typeof usage.messages_limit === 'number' ? usage.messages_limit : 0; - const tokensUsed = typeof usage.tokens_used === 'number' ? usage.tokens_used : 0; - const tokensLimit = typeof usage.tokens_limit === 'number' ? usage.tokens_limit : 0; - const resetsAt = typeof usage.resets_at === 'string' ? usage.resets_at : ''; + // organization_type is e.g. "claude_max", "claude_pro", "claude_enterprise", "claude_team" + const plan = + typeof organization.organization_type === 'string' + ? organization.organization_type + : 'unknown'; const result: ClaudeSubscriptionLimits = { plan, - messagesUsed, - messagesLimit, - tokensUsed, - tokensLimit, - resetsAt, + // Usage stats (messages/tokens) are not available from this endpoint; + // the UI hides these fields when limit is 0. + messagesUsed: 0, + messagesLimit: 0, + tokensUsed: 0, + tokensLimit: 0, + resetsAt: '', tokenMasked: maskToken(oauthToken), }; diff --git a/tests/unit/anthropic/client.test.ts b/tests/unit/anthropic/client.test.ts index d15cf6083..a5653a00c 100644 --- a/tests/unit/anthropic/client.test.ts +++ b/tests/unit/anthropic/client.test.ts @@ -25,18 +25,23 @@ describe('fetchClaudeSubscriptionLimits', () => { }); } + // Reflects the actual api/oauth/profile response shape used by the Claude Code CLI const sampleResponse = { - plan: 'claude_max', - usage: { - messages_used: 1234, - messages_limit: 20000, - tokens_used: 500000, - tokens_limit: 10000000, - resets_at: '2026-05-01T00:00:00Z', + account: { + display_name: 'Test User', + created_at: '2025-01-01T00:00:00Z', + }, + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_5x', + has_extra_usage_enabled: false, + billing_type: 'subscription', + subscription_created_at: '2025-01-01T00:00:00Z', + uuid: 'org-uuid-123', }, }; - it('returns limits data on success', async () => { + it('returns subscription info on success', async () => { vi.mocked(fetch).mockReturnValueOnce( makeFetchResponse(sampleResponse) as ReturnType, ); @@ -45,11 +50,12 @@ describe('fetchClaudeSubscriptionLimits', () => { expect(result).not.toBeNull(); expect(result?.plan).toBe('claude_max'); - expect(result?.messagesUsed).toBe(1234); - expect(result?.messagesLimit).toBe(20000); - expect(result?.tokensUsed).toBe(500000); - expect(result?.tokensLimit).toBe(10000000); - expect(result?.resetsAt).toBe('2026-05-01T00:00:00Z'); + // Usage stats are not available from the profile endpoint; always 0 + expect(result?.messagesUsed).toBe(0); + expect(result?.messagesLimit).toBe(0); + expect(result?.tokensUsed).toBe(0); + expect(result?.tokensLimit).toBe(0); + expect(result?.resetsAt).toBe(''); }); it('masks the token showing only last 4 chars', async () => { @@ -79,6 +85,19 @@ describe('fetchClaudeSubscriptionLimits', () => { ); }); + it('calls the oauth/profile endpoint', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + await fetchClaudeSubscriptionLimits('my-oauth-token'); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/profile', + expect.any(Object), + ); + }); + it('returns null on 4xx response', async () => { vi.mocked(fetch).mockReturnValueOnce( makeFetchResponse({}, false, 401) as ReturnType, @@ -115,9 +134,10 @@ describe('fetchClaudeSubscriptionLimits', () => { expect(result).toBeNull(); }); - it('returns null when response has no usage field', async () => { + it('returns null when response has no organization field', async () => { vi.mocked(fetch).mockReturnValueOnce( - makeFetchResponse({ plan: 'claude_max' }) as ReturnType, + // Response missing the organization field (invalid shape) + makeFetchResponse({ account: { display_name: 'Test' } }) as ReturnType, ); const result = await fetchClaudeSubscriptionLimits('some-token'); @@ -125,6 +145,19 @@ describe('fetchClaudeSubscriptionLimits', () => { expect(result).toBeNull(); }); + it('returns unknown plan when organization_type is missing', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({ + organization: { rate_limit_tier: 'default' }, + }) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).not.toBeNull(); + expect(result?.plan).toBe('unknown'); + }); + it('caches results for subsequent calls with the same token', async () => { vi.mocked(fetch).mockReturnValueOnce( makeFetchResponse(sampleResponse) as ReturnType, From de95c1e7a29a9661d067094fbe4f4c764c06839d Mon Sep 17 00:00:00 2001 From: Wojtek Siudzinski Date: Fri, 10 Apr 2026 18:43:16 +0200 Subject: [PATCH 04/10] fix(anthropic): switch from profile to usage endpoint for per-bucket utilization --- src/anthropic/client.ts | 94 +++++++----- tests/unit/anthropic/client.test.ts | 134 +++++++++++++----- .../unit/api/routers/claudeCodeLimits.test.ts | 11 +- .../components/global/claude-code-limits.tsx | 74 ++++++---- 4 files changed, 201 insertions(+), 112 deletions(-) diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts index b3e93c42b..3e7e879e7 100644 --- a/src/anthropic/client.ts +++ b/src/anthropic/client.ts @@ -1,21 +1,30 @@ /** - * OAuth profile endpoint used by the Claude Code CLI to fetch subscription info. - * Returns organization type (plan), rate limit tier, and account display name. - * Note: per-token usage stats (messages/tokens used) are not available via this - * endpoint — they are only surfaced via rate-limit response headers during API calls. + * OAuth usage endpoint — returns per-bucket utilization percentages and reset times + * for the authenticated subscription. */ -const ANTHROPIC_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'; +const ANTHROPIC_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const FETCH_TIMEOUT_MS = 10_000; // 10 seconds -export interface ClaudeSubscriptionLimits { - plan: string; - messagesUsed: number; - messagesLimit: number; - tokensUsed: number; - tokensLimit: number; +/** A single rate-limit bucket from the usage API. */ +export interface UsageBucket { + /** Human-readable label (e.g. "5-Hour Window", "Sonnet 7-Day") */ + label: string; + /** Utilization percentage 0–100 */ + utilization: number; + /** ISO-8601 reset timestamp */ resetsAt: string; +} + +export interface ClaudeSubscriptionLimits { tokenMasked: string; + buckets: UsageBucket[]; + extraUsage: { + isEnabled: boolean; + monthlyLimit: number | null; + usedCredits: number | null; + utilization: number | null; + } | null; } interface CacheEntry { @@ -36,15 +45,21 @@ function maskToken(token: string): string { return `****${token.slice(-4)}`; } +/** Maps API response keys to human-readable labels. */ +const BUCKET_LABELS: Record = { + five_hour: '5-Hour Window', + seven_day: '7-Day Overall', + seven_day_oauth_apps: '7-Day OAuth Apps', + seven_day_opus: '7-Day Opus', + seven_day_sonnet: '7-Day Sonnet', + seven_day_cowork: '7-Day Cowork', + iguana_necktie: 'Iguana Necktie', +}; + /** - * Fetch Claude subscription info for the given OAuth token via the oauth/profile endpoint. + * Fetch Claude subscription usage for the given OAuth token via the /api/oauth/usage endpoint. * Returns null on any error (network, auth, unexpected shape, etc.). * Results are cached in memory for 5 minutes per unique token. - * - * Note: per-token usage stats (messages/tokens used vs. limit) are not available - * from this endpoint. The returned `messagesUsed`, `messagesLimit`, `tokensUsed`, - * `tokensLimit`, and `resetsAt` fields will always be 0/"" — the UI hides them - * when the limit is 0. */ export async function fetchClaudeSubscriptionLimits( oauthToken: string, @@ -56,11 +71,12 @@ export async function fetchClaudeSubscriptionLimits( } try { - const response = await fetch(ANTHROPIC_PROFILE_URL, { + const response = await fetch(ANTHROPIC_USAGE_URL, { headers: { Authorization: `Bearer ${oauthToken}`, - 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'oauth-2025-04-20', 'Content-Type': 'application/json', + 'User-Agent': 'claude-code/2.1.87', }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); @@ -71,30 +87,32 @@ export async function fetchClaudeSubscriptionLimits( const json = (await response.json()) as Record; - // Parse defensively — return null if the shape doesn't match expectations. - // The profile response contains: { organization: { organization_type, rate_limit_tier, ... }, account: { ... } } - const organization = json.organization as Record | undefined; - - if (!organization) { - return null; + // Parse usage buckets — each key (except extra_usage) is either null or + // { utilization: number, resets_at: string } + const buckets: UsageBucket[] = []; + for (const [key, label] of Object.entries(BUCKET_LABELS)) { + const raw = json[key] as { utilization?: number; resets_at?: string } | null | undefined; + if (raw && typeof raw.utilization === 'number' && typeof raw.resets_at === 'string') { + buckets.push({ label, utilization: raw.utilization, resetsAt: raw.resets_at }); + } } - // organization_type is e.g. "claude_max", "claude_pro", "claude_enterprise", "claude_team" - const plan = - typeof organization.organization_type === 'string' - ? organization.organization_type - : 'unknown'; + // Parse extra_usage block + let extraUsage: ClaudeSubscriptionLimits['extraUsage'] = null; + const rawExtra = json.extra_usage as Record | null | undefined; + if (rawExtra && typeof rawExtra.is_enabled === 'boolean') { + extraUsage = { + isEnabled: rawExtra.is_enabled, + monthlyLimit: typeof rawExtra.monthly_limit === 'number' ? rawExtra.monthly_limit : null, + usedCredits: typeof rawExtra.used_credits === 'number' ? rawExtra.used_credits : null, + utilization: typeof rawExtra.utilization === 'number' ? rawExtra.utilization : null, + }; + } const result: ClaudeSubscriptionLimits = { - plan, - // Usage stats (messages/tokens) are not available from this endpoint; - // the UI hides these fields when limit is 0. - messagesUsed: 0, - messagesLimit: 0, - tokensUsed: 0, - tokensLimit: 0, - resetsAt: '', tokenMasked: maskToken(oauthToken), + buckets, + extraUsage, }; cacheByToken.set(oauthToken, { data: result, timestamp: Date.now() }); diff --git a/tests/unit/anthropic/client.test.ts b/tests/unit/anthropic/client.test.ts index a5653a00c..d83fd3ede 100644 --- a/tests/unit/anthropic/client.test.ts +++ b/tests/unit/anthropic/client.test.ts @@ -25,23 +25,24 @@ describe('fetchClaudeSubscriptionLimits', () => { }); } - // Reflects the actual api/oauth/profile response shape used by the Claude Code CLI + // Reflects the actual /api/oauth/usage response shape const sampleResponse = { - account: { - display_name: 'Test User', - created_at: '2025-01-01T00:00:00Z', - }, - organization: { - organization_type: 'claude_max', - rate_limit_tier: 'default_claude_max_5x', - has_extra_usage_enabled: false, - billing_type: 'subscription', - subscription_created_at: '2025-01-01T00:00:00Z', - uuid: 'org-uuid-123', + five_hour: { utilization: 33, resets_at: '2026-04-10T19:00:00.772723+00:00' }, + seven_day: { utilization: 3, resets_at: '2026-04-17T09:59:59.772747+00:00' }, + seven_day_oauth_apps: null, + seven_day_opus: null, + seven_day_sonnet: { utilization: 44, resets_at: '2026-04-10T16:59:59.772755+00:00' }, + seven_day_cowork: null, + iguana_necktie: null, + extra_usage: { + is_enabled: false, + monthly_limit: null, + used_credits: null, + utilization: null, }, }; - it('returns subscription info on success', async () => { + it('returns usage buckets on success', async () => { vi.mocked(fetch).mockReturnValueOnce( makeFetchResponse(sampleResponse) as ReturnType, ); @@ -49,13 +50,82 @@ describe('fetchClaudeSubscriptionLimits', () => { const result = await fetchClaudeSubscriptionLimits('test-oauth-token'); expect(result).not.toBeNull(); - expect(result?.plan).toBe('claude_max'); - // Usage stats are not available from the profile endpoint; always 0 - expect(result?.messagesUsed).toBe(0); - expect(result?.messagesLimit).toBe(0); - expect(result?.tokensUsed).toBe(0); - expect(result?.tokensLimit).toBe(0); - expect(result?.resetsAt).toBe(''); + expect(result?.buckets).toHaveLength(3); + expect(result?.buckets[0]).toEqual({ + label: '5-Hour Window', + utilization: 33, + resetsAt: '2026-04-10T19:00:00.772723+00:00', + }); + expect(result?.buckets[1]).toEqual({ + label: '7-Day Overall', + utilization: 3, + resetsAt: '2026-04-17T09:59:59.772747+00:00', + }); + expect(result?.buckets[2]).toEqual({ + label: '7-Day Sonnet', + utilization: 44, + resetsAt: '2026-04-10T16:59:59.772755+00:00', + }); + }); + + it('parses extra_usage when enabled with values', async () => { + const responseWithExtra = { + ...sampleResponse, + extra_usage: { + is_enabled: true, + monthly_limit: 100, + used_credits: 42.5, + utilization: 42, + }, + }; + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(responseWithExtra) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-token'); + + expect(result?.extraUsage).toEqual({ + isEnabled: true, + monthlyLimit: 100, + usedCredits: 42.5, + utilization: 42, + }); + }); + + it('returns extra_usage as non-enabled when disabled', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-token'); + + expect(result?.extraUsage).toEqual({ + isEnabled: false, + monthlyLimit: null, + usedCredits: null, + utilization: null, + }); + }); + + it('skips null buckets', async () => { + const sparseResponse = { + five_hour: null, + seven_day: { utilization: 10, resets_at: '2026-04-17T00:00:00Z' }, + seven_day_oauth_apps: null, + seven_day_opus: null, + seven_day_sonnet: null, + seven_day_cowork: null, + iguana_necktie: null, + extra_usage: null, + }; + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sparseResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-token'); + + expect(result?.buckets).toHaveLength(1); + expect(result?.buckets[0]?.label).toBe('7-Day Overall'); }); it('masks the token showing only last 4 chars', async () => { @@ -85,7 +155,7 @@ describe('fetchClaudeSubscriptionLimits', () => { ); }); - it('calls the oauth/profile endpoint', async () => { + it('calls the oauth/usage endpoint', async () => { vi.mocked(fetch).mockReturnValueOnce( makeFetchResponse(sampleResponse) as ReturnType, ); @@ -93,7 +163,7 @@ describe('fetchClaudeSubscriptionLimits', () => { await fetchClaudeSubscriptionLimits('my-oauth-token'); expect(fetch).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/profile', + 'https://api.anthropic.com/api/oauth/usage', expect.any(Object), ); }); @@ -134,28 +204,16 @@ describe('fetchClaudeSubscriptionLimits', () => { expect(result).toBeNull(); }); - it('returns null when response has no organization field', async () => { - vi.mocked(fetch).mockReturnValueOnce( - // Response missing the organization field (invalid shape) - makeFetchResponse({ account: { display_name: 'Test' } }) as ReturnType, - ); - - const result = await fetchClaudeSubscriptionLimits('some-token'); - - expect(result).toBeNull(); - }); - - it('returns unknown plan when organization_type is missing', async () => { + it('returns empty buckets when response has no recognized fields', async () => { vi.mocked(fetch).mockReturnValueOnce( - makeFetchResponse({ - organization: { rate_limit_tier: 'default' }, - }) as ReturnType, + makeFetchResponse({ something_unexpected: true }) as ReturnType, ); const result = await fetchClaudeSubscriptionLimits('some-token'); expect(result).not.toBeNull(); - expect(result?.plan).toBe('unknown'); + expect(result?.buckets).toEqual([]); + expect(result?.extraUsage).toBeNull(); }); it('caches results for subsequent calls with the same token', async () => { diff --git a/tests/unit/api/routers/claudeCodeLimits.test.ts b/tests/unit/api/routers/claudeCodeLimits.test.ts index 83df55034..4abe58a5b 100644 --- a/tests/unit/api/routers/claudeCodeLimits.test.ts +++ b/tests/unit/api/routers/claudeCodeLimits.test.ts @@ -20,13 +20,12 @@ import { claudeCodeLimitsRouter } from '../../../../src/api/routers/claudeCodeLi const createCaller = createCallerFor(claudeCodeLimitsRouter); const sampleLimits = { - plan: 'claude_max', - messagesUsed: 1000, - messagesLimit: 20000, - tokensUsed: 500000, - tokensLimit: 10000000, - resetsAt: '2026-05-01T00:00:00Z', tokenMasked: '****abcd', + buckets: [ + { label: '5-Hour Window', utilization: 33, resetsAt: '2026-04-10T19:00:00Z' }, + { label: '7-Day Overall', utilization: 3, resetsAt: '2026-04-17T10:00:00Z' }, + ], + extraUsage: { isEnabled: false, monthlyLimit: null, usedCredits: null, utilization: null }, }; describe('claudeCodeLimitsRouter', () => { diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx index 842eb0f60..525e031b0 100644 --- a/web/src/components/global/claude-code-limits.tsx +++ b/web/src/components/global/claude-code-limits.tsx @@ -1,22 +1,24 @@ import { useQuery } from '@tanstack/react-query'; import { trpc } from '@/lib/trpc.js'; -function formatNumber(n: number): string { - return n.toLocaleString(); -} - function formatResetDate(resetsAt: string): string { if (!resetsAt) return ''; try { const date = new Date(resetsAt); - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } catch { return resetsAt; } } +function utilizationColor(pct: number): string { + if (pct >= 90) return 'bg-red-500'; + if (pct >= 70) return 'bg-yellow-500'; + return 'bg-emerald-500'; +} + /** - * Displays Claude Code subscription limits for all unique tokens configured + * Displays Claude Code subscription usage for all unique tokens configured * across org projects. Shown only to superadmins; auto-hides when no data. */ export function ClaudeCodeLimitsSection() { @@ -33,35 +35,47 @@ export function ClaudeCodeLimitsSection() { return (
- Limits + Usage
- {data.map((limits, i) => { - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: tokenMasked is not guaranteed unique (tokens may share trailing 4 chars); index is safe here as list order is server-determined and stable -
-
- {limits.tokenMasked} -
-
{limits.plan}
- {limits.messagesLimit > 0 && ( -
- Msgs: {formatNumber(limits.messagesUsed)} / {formatNumber(limits.messagesLimit)} + {data.map((limits, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: tokenMasked is not guaranteed unique (tokens may share trailing 4 chars); index is safe here as list order is server-determined and stable +
+
+ {limits.tokenMasked} +
+ {limits.buckets.length === 0 && ( +
No usage data
+ )} + {limits.buckets.map((bucket) => ( +
+
+ {bucket.label} + {bucket.utilization}%
- )} - {limits.tokensLimit > 0 && ( -
- Tokens: {formatNumber(limits.tokensUsed)} / {formatNumber(limits.tokensLimit)} +
+
- )} - {limits.resetsAt && ( -
- Resets {formatResetDate(limits.resetsAt)} +
+ Resets {formatResetDate(bucket.resetsAt)}
- )} -
- ); - })} +
+ ))} + {limits.extraUsage?.isEnabled && ( +
+ Extra usage enabled + {limits.extraUsage.usedCredits != null && limits.extraUsage.monthlyLimit != null && ( + + {' '}— ${limits.extraUsage.usedCredits.toFixed(2)} / ${limits.extraUsage.monthlyLimit.toFixed(2)} + + )} +
+ )} +
+ ))}
); From fc45362955f56f1e9df2357ba307abf7c4284924 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Apr 2026 16:50:51 +0000 Subject: [PATCH 05/10] fix(lint): apply biome formatter to claude-code-limits component Split long lines in formatResetDate and extraUsage rendering to satisfy biome's line-length formatting rules. Co-Authored-By: Claude Sonnet 4.6 --- .../components/global/claude-code-limits.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx index 525e031b0..12e4b3584 100644 --- a/web/src/components/global/claude-code-limits.tsx +++ b/web/src/components/global/claude-code-limits.tsx @@ -5,7 +5,12 @@ function formatResetDate(resetsAt: string): string { if (!resetsAt) return ''; try { const date = new Date(resetsAt); - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); } catch { return resetsAt; } @@ -67,11 +72,14 @@ export function ClaudeCodeLimitsSection() { {limits.extraUsage?.isEnabled && (
Extra usage enabled - {limits.extraUsage.usedCredits != null && limits.extraUsage.monthlyLimit != null && ( - - {' '}— ${limits.extraUsage.usedCredits.toFixed(2)} / ${limits.extraUsage.monthlyLimit.toFixed(2)} - - )} + {limits.extraUsage.usedCredits != null && + limits.extraUsage.monthlyLimit != null && ( + + {' '} + — ${limits.extraUsage.usedCredits.toFixed(2)} / $ + {limits.extraUsage.monthlyLimit.toFixed(2)} + + )}
)}
From 754db2c7bb9fa7371d804dfb4212569aeb9243bd Mon Sep 17 00:00:00 2001 From: Wojtek Siudzinski Date: Fri, 10 Apr 2026 18:57:17 +0200 Subject: [PATCH 06/10] fix(anthropic): add separator above Claude code limits usage section --- web/src/components/global/claude-code-limits.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx index 12e4b3584..c573ad72a 100644 --- a/web/src/components/global/claude-code-limits.tsx +++ b/web/src/components/global/claude-code-limits.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { trpc } from '@/lib/trpc.js'; +import { Separator } from '@/components/ui/separator.js'; function formatResetDate(resetsAt: string): string { if (!resetsAt) return ''; @@ -38,14 +39,15 @@ export function ClaudeCodeLimitsSection() { } return ( -
+
+
Usage
{data.map((limits, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: tokenMasked is not guaranteed unique (tokens may share trailing 4 chars); index is safe here as list order is server-determined and stable -
+
{limits.tokenMasked}
From 8a8a329b1396304b28149f3f394aae5c4aa0206a Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Apr 2026 17:03:11 +0000 Subject: [PATCH 07/10] fix(lint): reduce cognitive complexity and fix import order - Extract parseBuckets() and parseExtraUsage() helpers from fetchClaudeSubscriptionLimits() to reduce cognitive complexity from 16 to below the max of 15 - Fix import order in claude-code-limits.tsx (Separator before trpc) Co-Authored-By: Claude Sonnet 4.6 --- src/anthropic/client.ts | 53 ++++++++++--------- .../components/global/claude-code-limits.tsx | 2 +- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts index 3e7e879e7..21277da4b 100644 --- a/src/anthropic/client.ts +++ b/src/anthropic/client.ts @@ -56,6 +56,32 @@ const BUCKET_LABELS: Record = { iguana_necktie: 'Iguana Necktie', }; +/** Parse usage buckets from the API response JSON. */ +function parseBuckets(json: Record): UsageBucket[] { + const buckets: UsageBucket[] = []; + for (const [key, label] of Object.entries(BUCKET_LABELS)) { + const raw = json[key] as { utilization?: number; resets_at?: string } | null | undefined; + if (raw && typeof raw.utilization === 'number' && typeof raw.resets_at === 'string') { + buckets.push({ label, utilization: raw.utilization, resetsAt: raw.resets_at }); + } + } + return buckets; +} + +/** Parse the extra_usage block from the API response JSON. */ +function parseExtraUsage(json: Record): ClaudeSubscriptionLimits['extraUsage'] { + const rawExtra = json.extra_usage as Record | null | undefined; + if (!rawExtra || typeof rawExtra.is_enabled !== 'boolean') { + return null; + } + return { + isEnabled: rawExtra.is_enabled, + monthlyLimit: typeof rawExtra.monthly_limit === 'number' ? rawExtra.monthly_limit : null, + usedCredits: typeof rawExtra.used_credits === 'number' ? rawExtra.used_credits : null, + utilization: typeof rawExtra.utilization === 'number' ? rawExtra.utilization : null, + }; +} + /** * Fetch Claude subscription usage for the given OAuth token via the /api/oauth/usage endpoint. * Returns null on any error (network, auth, unexpected shape, etc.). @@ -86,33 +112,10 @@ export async function fetchClaudeSubscriptionLimits( } const json = (await response.json()) as Record; - - // Parse usage buckets — each key (except extra_usage) is either null or - // { utilization: number, resets_at: string } - const buckets: UsageBucket[] = []; - for (const [key, label] of Object.entries(BUCKET_LABELS)) { - const raw = json[key] as { utilization?: number; resets_at?: string } | null | undefined; - if (raw && typeof raw.utilization === 'number' && typeof raw.resets_at === 'string') { - buckets.push({ label, utilization: raw.utilization, resetsAt: raw.resets_at }); - } - } - - // Parse extra_usage block - let extraUsage: ClaudeSubscriptionLimits['extraUsage'] = null; - const rawExtra = json.extra_usage as Record | null | undefined; - if (rawExtra && typeof rawExtra.is_enabled === 'boolean') { - extraUsage = { - isEnabled: rawExtra.is_enabled, - monthlyLimit: typeof rawExtra.monthly_limit === 'number' ? rawExtra.monthly_limit : null, - usedCredits: typeof rawExtra.used_credits === 'number' ? rawExtra.used_credits : null, - utilization: typeof rawExtra.utilization === 'number' ? rawExtra.utilization : null, - }; - } - const result: ClaudeSubscriptionLimits = { tokenMasked: maskToken(oauthToken), - buckets, - extraUsage, + buckets: parseBuckets(json), + extraUsage: parseExtraUsage(json), }; cacheByToken.set(oauthToken, { data: result, timestamp: Date.now() }); diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx index c573ad72a..d329da671 100644 --- a/web/src/components/global/claude-code-limits.tsx +++ b/web/src/components/global/claude-code-limits.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import { trpc } from '@/lib/trpc.js'; import { Separator } from '@/components/ui/separator.js'; +import { trpc } from '@/lib/trpc.js'; function formatResetDate(resetsAt: string): string { if (!resetsAt) return ''; From a4aebdf322fb0ecf9e9fe046b05af8f3a5d8ff29 Mon Sep 17 00:00:00 2001 From: Wojtek Siudzinski Date: Sun, 2 Aug 2026 16:21:36 +0200 Subject: [PATCH 08/10] feat(credentials): add organization-level shared credentials Credentials can now be defined once per organization and are inherited by every project in it; a project-level credential with the same env var key overrides the org value for that project. - New org_credentials table (migration 0062) with AAD = org_id encryption - Org fallback wired into the two chokepoint resolvers (resolveProjectCredential / resolveAllProjectCredentials), so worker env injection, secretBuilder, PM discovery, webhook signature verification, personas, and hasIntegration checks all inherit org values with zero worker-side changes - organization.credentials.{list,set,delete} tRPC procedures gated by per-org admin role (users.ts refinement pattern) - projects.credentials.list now merges the org tier and reports source: 'project' | 'org' plus hasOrgFallback per row - New /settings/credentials page with a grouped key catalog (SCM, PM, alerting from the credential-role registry; engines from ENGINE_SECRETS) plus custom keys - ProjectSecretField shows 'Inherited from org' state, override placeholder, and revert-to-org delete semantics - CLI: cascade org credentials-set / credentials-list / credentials-delete - Shared maskCredentialValue helper replaces the inline masking expression Co-Authored-By: Claude Fable 5 --- src/api/routers/_shared/maskCredential.ts | 8 + src/api/routers/organization.ts | 105 ++++++++++++ src/api/routers/projects.ts | 73 ++++++-- src/backends/codex/index.ts | 3 + src/cli/dashboard/org/credentials-delete.ts | 39 +++++ src/cli/dashboard/org/credentials-list.ts | 35 ++++ src/cli/dashboard/org/credentials-set.ts | 40 +++++ src/db/crypto.ts | 3 +- src/db/migrations/0062_org_credentials.sql | 21 +++ src/db/migrations/meta/_journal.json | 7 + src/db/repositories/credentialsRepository.ts | 40 ++++- .../repositories/orgCredentialsRepository.ts | 119 +++++++++++++ src/db/schema/index.ts | 1 + src/db/schema/orgCredentials.ts | 26 +++ .../db/credentialsRepository.test.ts | 61 +++++++ tests/integration/helpers/db.ts | 1 + tests/unit/api/routers/organization.test.ts | 156 +++++++++++++++++- tests/unit/api/routers/projects.test.ts | 109 ++++++++++++ .../credentialsRepository.test.ts | 92 +++++++++-- .../orgCredentialsRepository.test.ts | 154 +++++++++++++++++ web/src/components/layout/sidebar.tsx | 2 + .../projects/project-secret-field.tsx | 32 +++- .../settings/org-credential-catalog.ts | 66 ++++++++ .../components/settings/org-secret-field.tsx | 130 +++++++++++++++ web/src/routes/route-tree.ts | 2 + web/src/routes/settings/credentials.tsx | 139 ++++++++++++++++ 26 files changed, 1417 insertions(+), 47 deletions(-) create mode 100644 src/api/routers/_shared/maskCredential.ts create mode 100644 src/cli/dashboard/org/credentials-delete.ts create mode 100644 src/cli/dashboard/org/credentials-list.ts create mode 100644 src/cli/dashboard/org/credentials-set.ts create mode 100644 src/db/migrations/0062_org_credentials.sql create mode 100644 src/db/repositories/orgCredentialsRepository.ts create mode 100644 src/db/schema/orgCredentials.ts create mode 100644 tests/unit/db/repositories/orgCredentialsRepository.test.ts create mode 100644 web/src/components/settings/org-credential-catalog.ts create mode 100644 web/src/components/settings/org-secret-field.tsx create mode 100644 web/src/routes/settings/credentials.tsx diff --git a/src/api/routers/_shared/maskCredential.ts b/src/api/routers/_shared/maskCredential.ts new file mode 100644 index 000000000..a23c3dc4b --- /dev/null +++ b/src/api/routers/_shared/maskCredential.ts @@ -0,0 +1,8 @@ +/** + * Mask a credential value for list previews: short values collapse to '****', + * longer ones keep the last 4 characters ('****abcd'). Shared by the project + * and organization credential list endpoints so the masking rule cannot drift. + */ +export function maskCredentialValue(value: string): string { + return value.length <= 12 ? '****' : `****${value.slice(-4)}`; +} diff --git a/src/api/routers/organization.ts b/src/api/routers/organization.ts index 86e92866e..8b7fe52f8 100644 --- a/src/api/routers/organization.ts +++ b/src/api/routers/organization.ts @@ -1,11 +1,49 @@ +import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { + deleteOrgCredential, + listOrgCredentials, + listOrgCredentialsMeta, + writeOrgCredential, +} from '../../db/repositories/orgCredentialsRepository.js'; import { createOrganization, getOrganization, listAllOrganizations, updateOrganization, } from '../../db/repositories/settingsRepository.js'; +import { captureException } from '../../sentry.js'; +import { resolveActorRoleInOrg } from '../context.js'; import { adminProcedure, protectedProcedure, router, superAdminProcedure } from '../trpc.js'; +import { maskCredentialValue } from './_shared/maskCredential.js'; + +type Role = 'member' | 'admin' | 'superadmin'; + +/** + * Resolve the caller's role *in the effective org*. The `adminProcedure` + * middleware is a coarse global-role gate; this refines it with the per-org + * membership role (same pattern as users.ts) so an admin who has switched + * into an org where they are only a member cannot manage that org's + * credentials. + */ +function resolveActorRole(ctx: { + user: { id: string; role: Role; orgId: string }; + effectiveOrgId: string; +}): Promise { + return resolveActorRoleInOrg({ + userId: ctx.user.id, + globalRole: ctx.user.role, + homeOrgId: ctx.user.orgId, + orgId: ctx.effectiveOrgId, + }); +} + +/** Require the caller to be an admin (or superadmin) in the effective org. */ +function assertOrgAdmin(actorRole: Role): void { + if (actorRole !== 'admin' && actorRole !== 'superadmin') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' }); + } +} export const organizationRouter = router({ get: protectedProcedure.query(async ({ ctx }) => { @@ -41,4 +79,71 @@ export const organizationRouter = router({ .mutation(async ({ input }) => { await updateOrganization(input.id, { name: input.name }); }), + + // Organization-scoped shared credentials (org_credentials table). Projects + // inherit these; a project_credentials row with the same env var key + // overrides the org value for that project. + credentials: router({ + /** + * List masked metadata for all org-scoped credentials. + * Never returns plaintext values — only masked last-4-chars preview. + */ + list: adminProcedure.query(async ({ ctx }) => { + assertOrgAdmin(await resolveActorRole(ctx)); + try { + const rows = await listOrgCredentials(ctx.effectiveOrgId); + return rows.map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: maskCredentialValue(row.value), + })); + } catch (err) { + // Decryption key missing/wrong — return metadata without value preview + captureException(err, { + tags: { source: 'org_credentials_list' }, + extra: { orgId: ctx.effectiveOrgId }, + level: 'warning', + }); + const meta = await listOrgCredentialsMeta(ctx.effectiveOrgId); + return meta.map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: '****', + })); + } + }), + + /** + * Upsert an org-scoped credential (write-only — never exposes plaintext). + */ + set: adminProcedure + .input( + z.object({ + envVarKey: z.string().regex(/^[A-Z_][A-Z0-9_]*$/), + value: z.string().min(1), + name: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + assertOrgAdmin(await resolveActorRole(ctx)); + await writeOrgCredential( + ctx.effectiveOrgId, + input.envVarKey, + input.value, + input.name ?? null, + ); + }), + + /** + * Delete an org-scoped credential. + */ + delete: adminProcedure + .input(z.object({ envVarKey: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + assertOrgAdmin(await resolveActorRole(ctx)); + await deleteOrgCredential(ctx.effectiveOrgId, input.envVarKey); + }), + }), }); diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index b066024fa..e5e115354 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -16,6 +16,10 @@ import { listProjectCredentialsMeta, writeProjectCredential, } from '../../db/repositories/credentialsRepository.js'; +import { + listOrgCredentials, + listOrgCredentialsMeta, +} from '../../db/repositories/orgCredentialsRepository.js'; import { listProjectsForOrg } from '../../db/repositories/runsRepository.js'; import { createProject, @@ -36,6 +40,7 @@ import { computeContentHash } from '../../router/worker-dockerfile-compose.js'; import { captureException } from '../../sentry.js'; import { logger } from '../../utils/logging.js'; import { protectedProcedure, publicProcedure, router, superAdminProcedure } from '../trpc.js'; +import { maskCredentialValue } from './_shared/maskCredential.js'; /** * The current worker-image/dockerfile state read alongside the ownership check. @@ -790,11 +795,15 @@ export const projectsRouter = router({ }), }), - // Project-scoped credentials (project_credentials table) + // Project-scoped credentials (project_credentials table), merged with the + // inherited org_credentials tier (project rows override org rows per key) credentials: router({ /** - * List masked metadata for all project-scoped credentials. - * Never returns plaintext values — only masked last-4-chars preview. + * List masked metadata for all credentials visible to the project: + * project-scoped rows plus inherited org-scoped rows not overridden by + * a project row. Never returns plaintext values — only masked + * last-4-chars preview. `source` marks the tier a row comes from; + * `hasOrgFallback` marks project rows that shadow an org value. */ list: protectedProcedure .input(z.object({ projectId: z.string() })) @@ -802,12 +811,29 @@ export const projectsRouter = router({ await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); try { const rows = await listProjectCredentials(input.projectId); - return rows.map((row) => ({ - envVarKey: row.envVarKey, - name: row.name, - isConfigured: true, - maskedValue: row.value.length <= 12 ? '****' : `****${row.value.slice(-4)}`, - })); + const orgRows = await listOrgCredentials(ctx.effectiveOrgId); + const orgKeys = new Set(orgRows.map((row) => row.envVarKey)); + const projectKeys = new Set(rows.map((row) => row.envVarKey)); + return [ + ...rows.map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: maskCredentialValue(row.value), + source: 'project' as const, + hasOrgFallback: orgKeys.has(row.envVarKey), + })), + ...orgRows + .filter((row) => !projectKeys.has(row.envVarKey)) + .map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: maskCredentialValue(row.value), + source: 'org' as const, + hasOrgFallback: false, + })), + ]; } catch (err) { // Decryption key missing/wrong — return metadata without value preview captureException(err, { @@ -816,12 +842,29 @@ export const projectsRouter = router({ level: 'warning', }); const meta = await listProjectCredentialsMeta(input.projectId); - return meta.map((row) => ({ - envVarKey: row.envVarKey, - name: row.name, - isConfigured: true, - maskedValue: '****', - })); + const orgMeta = await listOrgCredentialsMeta(ctx.effectiveOrgId); + const orgKeys = new Set(orgMeta.map((row) => row.envVarKey)); + const projectKeys = new Set(meta.map((row) => row.envVarKey)); + return [ + ...meta.map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: '****', + source: 'project' as const, + hasOrgFallback: orgKeys.has(row.envVarKey), + })), + ...orgMeta + .filter((row) => !projectKeys.has(row.envVarKey)) + .map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: '****', + source: 'org' as const, + hasOrgFallback: false, + })), + ]; } }), diff --git a/src/backends/codex/index.ts b/src/backends/codex/index.ts index 9004b3cbb..666d7b285 100644 --- a/src/backends/codex/index.ts +++ b/src/backends/codex/index.ts @@ -738,6 +738,9 @@ async function captureRefreshedToken( if (newJson === originalJson) return; try { + // Intentionally project-scoped: when the seed value was inherited from an + // org-level CODEX_AUTH_JSON credential, this creates a project override + // (the org value goes stale for this project). Accepted v1 behavior. await writeProjectCredential(projectId, 'CODEX_AUTH_JSON', newJson); logWriter('INFO', 'Captured refreshed Codex auth token and updated project credential', {}); } catch (error) { diff --git a/src/cli/dashboard/org/credentials-delete.ts b/src/cli/dashboard/org/credentials-delete.ts new file mode 100644 index 000000000..4401e6044 --- /dev/null +++ b/src/cli/dashboard/org/credentials-delete.ts @@ -0,0 +1,39 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; +import { confirm } from '../_shared/confirm.js'; + +export default class OrgCredentialsDelete extends DashboardCommand { + static override description = 'Delete an organization-scoped credential.'; + + static override flags = { + ...DashboardCommand.baseFlags, + key: Flags.string({ + description: 'Environment variable key to delete', + required: true, + }), + yes: Flags.boolean({ description: 'Skip confirmation', char: 'y', default: false }), + }; + + async run(): Promise { + const { flags } = await this.parse(OrgCredentialsDelete); + + await confirm(`Delete organization credential ${flags.key}?`, flags.yes); + + try { + await this.withSpinner('Deleting credential...', () => + this.client.organization.credentials.delete.mutate({ + envVarKey: flags.key, + }), + ); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.success(`Deleted organization credential ${flags.key}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/org/credentials-list.ts b/src/cli/dashboard/org/credentials-list.ts new file mode 100644 index 000000000..d7ef7f614 --- /dev/null +++ b/src/cli/dashboard/org/credentials-list.ts @@ -0,0 +1,35 @@ +import { DashboardCommand } from '../_shared/base.js'; + +export default class OrgCredentialsList extends DashboardCommand { + static override description = 'List organization-scoped credentials (values masked).'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(OrgCredentialsList); + + try { + const creds = await this.client.organization.credentials.list.query(); + + if (flags.json) { + this.outputJson(creds); + return; + } + + if (creds.length === 0) { + this.log('No organization credentials configured.'); + return; + } + + this.outputTable(creds as unknown as Record[], [ + { key: 'envVarKey', header: 'Key' }, + { key: 'name', header: 'Name' }, + { key: 'maskedValue', header: 'Value (masked)' }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/org/credentials-set.ts b/src/cli/dashboard/org/credentials-set.ts new file mode 100644 index 000000000..d9fd16949 --- /dev/null +++ b/src/cli/dashboard/org/credentials-set.ts @@ -0,0 +1,40 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class OrgCredentialsSet extends DashboardCommand { + static override description = + 'Set an organization-scoped credential (upsert by env var key). Inherited by all projects; project credentials with the same key override it.'; + + static override flags = { + ...DashboardCommand.baseFlags, + key: Flags.string({ + description: 'Environment variable key (e.g. GITHUB_TOKEN_IMPLEMENTER)', + required: true, + }), + value: Flags.string({ description: 'Credential value', required: true }), + name: Flags.string({ description: 'Human-readable name for the credential' }), + }; + + async run(): Promise { + const { flags } = await this.parse(OrgCredentialsSet); + + try { + await this.withSpinner('Setting credential...', () => + this.client.organization.credentials.set.mutate({ + envVarKey: flags.key, + value: flags.value, + name: flags.name, + }), + ); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.success(`Set organization credential ${flags.key}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/db/crypto.ts b/src/db/crypto.ts index e1375ea7d..de0d5d934 100644 --- a/src/db/crypto.ts +++ b/src/db/crypto.ts @@ -54,7 +54,8 @@ export function isEncryptedValue(value: string): boolean { * Encrypt a credential value using AES-256-GCM. * Returns `enc:v1:::`. * If no master key is configured, returns the plaintext unchanged. - * @param aad - Additional Authenticated Data (orgId) to bind the ciphertext to the org. + * @param aad - Additional Authenticated Data binding the ciphertext to its owner + * (orgId for org_credentials, projectId for project_credentials). */ export function encryptCredential(plaintext: string, aad: string): string { const key = getMasterKey(); diff --git a/src/db/migrations/0062_org_credentials.sql b/src/db/migrations/0062_org_credentials.sql new file mode 100644 index 000000000..ed7759d93 --- /dev/null +++ b/src/db/migrations/0062_org_credentials.sql @@ -0,0 +1,21 @@ +-- 0062_org_credentials.sql +-- Organization-scoped shared credentials. Projects inherit these at +-- resolution time; a project_credentials row with the same env_var_key +-- overrides the org value for that project. Values are encrypted with +-- AAD = org_id (project credentials use AAD = project_id). +BEGIN; + +CREATE TABLE IF NOT EXISTS org_credentials ( + id SERIAL PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + env_var_key TEXT NOT NULL, + value TEXT NOT NULL, + name TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_org_credentials_org_env_var_key + ON org_credentials(org_id, env_var_key); + +COMMIT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 5871c7a4b..8e3b63dd1 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -428,6 +428,13 @@ "when": 1795000000000, "tag": "0060_agent_config_review_event_policy", "breakpoints": false + }, + { + "idx": 62, + "version": "7", + "when": 1797000000000, + "tag": "0062_org_credentials", + "breakpoints": false } ] } diff --git a/src/db/repositories/credentialsRepository.ts b/src/db/repositories/credentialsRepository.ts index 75079b98e..1df579423 100644 --- a/src/db/repositories/credentialsRepository.ts +++ b/src/db/repositories/credentialsRepository.ts @@ -1,15 +1,23 @@ import { and, eq } from 'drizzle-orm'; import { getDb } from '../client.js'; import { decryptCredential, encryptCredential } from '../crypto.js'; -import { projectCredentials, projectIntegrations, projects } from '../schema/index.js'; +import { + orgCredentials, + projectCredentials, + projectIntegrations, + projects, +} from '../schema/index.js'; // ============================================================================ -// Project-scoped credential resolution (reads from project_credentials table) +// Project-scoped credential resolution (reads from project_credentials table, +// falling back to the org_credentials tier — project values override org) // ============================================================================ /** * Resolve a single credential for a project by env var key. - * Reads from the project_credentials table using projectId as AAD for decryption. + * Reads from the project_credentials table using projectId as AAD for + * decryption. When the project has no row for the key, falls back to the + * owning organization's org_credentials tier (AAD = orgId). */ export async function resolveProjectCredential( projectId: string, @@ -24,13 +32,23 @@ export async function resolveProjectCredential( and(eq(projectCredentials.projectId, projectId), eq(projectCredentials.envVarKey, envVarKey)), ); - if (!row) return null; - return decryptCredential(row.value, projectId); + if (row) return decryptCredential(row.value, projectId); + + const [orgRow] = await db + .select({ value: orgCredentials.value, orgId: orgCredentials.orgId }) + .from(orgCredentials) + .innerJoin(projects, eq(projects.orgId, orgCredentials.orgId)) + .where(and(eq(projects.id, projectId), eq(orgCredentials.envVarKey, envVarKey))); + + if (!orgRow) return null; + return decryptCredential(orgRow.value, orgRow.orgId); } /** * Resolve all credentials for a project as a flat env-var-key → value map. - * Single query against project_credentials, using projectId as AAD. + * Merges the owning organization's org_credentials tier first, then overlays + * project_credentials rows so project values win on key collisions. Each tier + * decrypts with its own AAD (orgId vs projectId). * Throws if the project does not exist. */ export async function resolveAllProjectCredentials( @@ -39,19 +57,27 @@ export async function resolveAllProjectCredentials( const db = getDb(); const [project] = await db - .select({ id: projects.id }) + .select({ id: projects.id, orgId: projects.orgId }) .from(projects) .where(eq(projects.id, projectId)); if (!project) { throw new Error(`Project not found: ${projectId}`); } + const orgRows = await db + .select({ envVarKey: orgCredentials.envVarKey, value: orgCredentials.value }) + .from(orgCredentials) + .where(eq(orgCredentials.orgId, project.orgId)); + const rows = await db .select({ envVarKey: projectCredentials.envVarKey, value: projectCredentials.value }) .from(projectCredentials) .where(eq(projectCredentials.projectId, projectId)); const result: Record = {}; + for (const row of orgRows) { + result[row.envVarKey] = decryptCredential(row.value, project.orgId); + } for (const row of rows) { result[row.envVarKey] = decryptCredential(row.value, projectId); } diff --git a/src/db/repositories/orgCredentialsRepository.ts b/src/db/repositories/orgCredentialsRepository.ts new file mode 100644 index 000000000..2e9a7b099 --- /dev/null +++ b/src/db/repositories/orgCredentialsRepository.ts @@ -0,0 +1,119 @@ +import { and, eq } from 'drizzle-orm'; +import { getDb } from '../client.js'; +import { decryptCredential, encryptCredential } from '../crypto.js'; +import { orgCredentials } from '../schema/index.js'; + +// ============================================================================ +// Organization-scoped credential storage (org_credentials table) +// +// Org credentials are the shared tier below project_credentials: projects +// inherit them at resolution time, and a project_credentials row with the +// same env_var_key overrides the org value. Values are encrypted with +// AAD = orgId (project credentials use AAD = projectId). +// ============================================================================ + +/** + * Resolve a single org credential by env var key. + * Returns the decrypted plaintext value, or null if not found. + */ +export async function resolveOrgCredential( + orgId: string, + envVarKey: string, +): Promise { + const db = getDb(); + + const [row] = await db + .select({ value: orgCredentials.value }) + .from(orgCredentials) + .where(and(eq(orgCredentials.orgId, orgId), eq(orgCredentials.envVarKey, envVarKey))); + + if (!row) return null; + return decryptCredential(row.value, orgId); +} + +/** + * Resolve all org credentials as a flat env-var-key → value map. + */ +export async function resolveAllOrgCredentials(orgId: string): Promise> { + const db = getDb(); + + const rows = await db + .select({ envVarKey: orgCredentials.envVarKey, value: orgCredentials.value }) + .from(orgCredentials) + .where(eq(orgCredentials.orgId, orgId)); + + const result: Record = {}; + for (const row of rows) { + result[row.envVarKey] = decryptCredential(row.value, orgId); + } + return result; +} + +/** + * Write (upsert) an org credential with automatic encryption. + * The plaintext value is encrypted using orgId as AAD before storage. + */ +export async function writeOrgCredential( + orgId: string, + envVarKey: string, + value: string, + name?: string | null, +): Promise { + const db = getDb(); + const encryptedValue = encryptCredential(value, orgId); + await db + .insert(orgCredentials) + .values({ orgId, envVarKey, value: encryptedValue, name: name ?? null }) + .onConflictDoUpdate({ + target: [orgCredentials.orgId, orgCredentials.envVarKey], + set: { value: encryptedValue, name: name ?? null, updatedAt: new Date() }, + }); +} + +/** + * List all org credentials as an array of decrypted key-value records. + */ +export async function listOrgCredentials( + orgId: string, +): Promise<{ envVarKey: string; value: string; name: string | null }[]> { + const db = getDb(); + + const rows = await db + .select({ + envVarKey: orgCredentials.envVarKey, + value: orgCredentials.value, + name: orgCredentials.name, + }) + .from(orgCredentials) + .where(eq(orgCredentials.orgId, orgId)); + + return rows.map((row) => ({ + envVarKey: row.envVarKey, + value: decryptCredential(row.value, orgId), + name: row.name, + })); +} + +/** + * List org credential metadata (key + name) without reading or decrypting values. + * Used as a fallback when decryption fails (missing/wrong master key). + */ +export async function listOrgCredentialsMeta( + orgId: string, +): Promise<{ envVarKey: string; name: string | null }[]> { + const db = getDb(); + return db + .select({ envVarKey: orgCredentials.envVarKey, name: orgCredentials.name }) + .from(orgCredentials) + .where(eq(orgCredentials.orgId, orgId)); +} + +/** + * Delete a row from org_credentials. + */ +export async function deleteOrgCredential(orgId: string, envVarKey: string): Promise { + const db = getDb(); + await db + .delete(orgCredentials) + .where(and(eq(orgCredentials.orgId, orgId), eq(orgCredentials.envVarKey, envVarKey))); +} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index a52d3db52..2425726d4 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -3,6 +3,7 @@ export { agentDefinitions } from './agentDefinitions.js'; export { agentTriggerConfigs } from './agentTriggerConfigs.js'; export { projectIntegrations } from './integrations.js'; export { organizations } from './organizations.js'; +export { orgCredentials } from './orgCredentials.js'; export { orgMemberships } from './orgMemberships.js'; export { projectCredentials } from './projectCredentials.js'; export { projects } from './projects.js'; diff --git a/src/db/schema/orgCredentials.ts b/src/db/schema/orgCredentials.ts new file mode 100644 index 000000000..d985bc0bb --- /dev/null +++ b/src/db/schema/orgCredentials.ts @@ -0,0 +1,26 @@ +import { pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations.js'; + +/** + * Organization-scoped shared credentials. Projects inherit these at + * resolution time; a project_credentials row with the same env_var_key + * overrides the org value for that project. Values are encrypted with + * AAD = org_id (project credentials use AAD = project_id). + */ +export const orgCredentials = pgTable( + 'org_credentials', + { + id: serial('id').primaryKey(), + orgId: text('org_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + envVarKey: text('env_var_key').notNull(), + value: text('value').notNull(), + name: text('name'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [uniqueIndex('uq_org_credentials_org_env_var_key').on(table.orgId, table.envVarKey)], +); diff --git a/tests/integration/db/credentialsRepository.test.ts b/tests/integration/db/credentialsRepository.test.ts index da43bb466..8c602dfaf 100644 --- a/tests/integration/db/credentialsRepository.test.ts +++ b/tests/integration/db/credentialsRepository.test.ts @@ -6,6 +6,11 @@ import { resolveProjectCredential, writeProjectCredential, } from '../../../src/db/repositories/credentialsRepository.js'; +import { + deleteOrgCredential, + listOrgCredentials, + writeOrgCredential, +} from '../../../src/db/repositories/orgCredentialsRepository.js'; import { truncateAll } from '../helpers/db.js'; import { seedOrg, seedProject } from '../helpers/seed.js'; @@ -111,4 +116,60 @@ describe('credentialsRepository (integration)', () => { expect(cred?.value).toBe('plaintext-secret'); // decrypted on read }); }); + + // ========================================================================= + // Org-tier inheritance (org_credentials) + // ========================================================================= + + describe('org credential inheritance', () => { + it('org credential CRUD round-trips', async () => { + await writeOrgCredential('test-org', 'ORG_KEY', 'org-value', 'Org Key'); + + const creds = await listOrgCredentials('test-org'); + expect(creds).toHaveLength(1); + expect(creds[0]).toEqual({ envVarKey: 'ORG_KEY', value: 'org-value', name: 'Org Key' }); + + await deleteOrgCredential('test-org', 'ORG_KEY'); + expect(await listOrgCredentials('test-org')).toEqual([]); + }); + + it('project inherits an org-only credential', async () => { + await writeOrgCredential('test-org', 'GITHUB_TOKEN_IMPLEMENTER', 'org-shared-token'); + + const single = await resolveProjectCredential('test-project', 'GITHUB_TOKEN_IMPLEMENTER'); + expect(single).toBe('org-shared-token'); + + const all = await resolveAllProjectCredentials('test-project'); + expect(all.GITHUB_TOKEN_IMPLEMENTER).toBe('org-shared-token'); + }); + + it('project credential with the same key overrides the org value', async () => { + await writeOrgCredential('test-org', 'SHARED_KEY', 'org-value'); + await writeProjectCredential('test-project', 'SHARED_KEY', 'project-value'); + + expect(await resolveProjectCredential('test-project', 'SHARED_KEY')).toBe('project-value'); + + const all = await resolveAllProjectCredentials('test-project'); + expect(all.SHARED_KEY).toBe('project-value'); + }); + + it('deleting the project override reverts to the org value', async () => { + await writeOrgCredential('test-org', 'SHARED_KEY', 'org-value'); + await writeProjectCredential('test-project', 'SHARED_KEY', 'project-value'); + await deleteProjectCredential('test-project', 'SHARED_KEY'); + + expect(await resolveProjectCredential('test-project', 'SHARED_KEY')).toBe('org-value'); + }); + + it('inheritance decrypts each tier with its own AAD under encryption', async () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'a'.repeat(64)); + + await writeOrgCredential('test-org', 'ORG_ENC_KEY', 'org-secret'); + await writeProjectCredential('test-project', 'PROJECT_ENC_KEY', 'project-secret'); + + const all = await resolveAllProjectCredentials('test-project'); + expect(all.ORG_ENC_KEY).toBe('org-secret'); + expect(all.PROJECT_ENC_KEY).toBe('project-secret'); + }); + }); }); diff --git a/tests/integration/helpers/db.ts b/tests/integration/helpers/db.ts index 3e1745090..2405bf6fa 100644 --- a/tests/integration/helpers/db.ts +++ b/tests/integration/helpers/db.ts @@ -117,6 +117,7 @@ export async function truncateAll() { agent_runs, pr_work_items, project_credentials, + org_credentials, project_integrations, agent_trigger_configs, agent_configs, diff --git a/tests/unit/api/routers/organization.test.ts b/tests/unit/api/routers/organization.test.ts index 69a4f0ca1..bd02cf4f5 100644 --- a/tests/unit/api/routers/organization.test.ts +++ b/tests/unit/api/routers/organization.test.ts @@ -1,14 +1,26 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockSuperAdmin, createMockUser } from '../../../helpers/factories.js'; import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; -const { mockGetOrganization, mockUpdateOrganization, mockListAllOrganizations } = vi.hoisted( - () => ({ - mockGetOrganization: vi.fn(), - mockUpdateOrganization: vi.fn(), - mockListAllOrganizations: vi.fn(), - }), -); +const { + mockGetOrganization, + mockUpdateOrganization, + mockListAllOrganizations, + mockListOrgCredentials, + mockListOrgCredentialsMeta, + mockWriteOrgCredential, + mockDeleteOrgCredential, + mockGetOrgMembership, +} = vi.hoisted(() => ({ + mockGetOrganization: vi.fn(), + mockUpdateOrganization: vi.fn(), + mockListAllOrganizations: vi.fn(), + mockListOrgCredentials: vi.fn(), + mockListOrgCredentialsMeta: vi.fn(), + mockWriteOrgCredential: vi.fn(), + mockDeleteOrgCredential: vi.fn(), + mockGetOrgMembership: vi.fn(), +})); vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ getOrganization: mockGetOrganization, @@ -16,6 +28,24 @@ vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ listAllOrganizations: mockListAllOrganizations, })); +vi.mock('../../../../src/db/repositories/orgCredentialsRepository.js', () => ({ + listOrgCredentials: mockListOrgCredentials, + listOrgCredentialsMeta: mockListOrgCredentialsMeta, + writeOrgCredential: mockWriteOrgCredential, + deleteOrgCredential: mockDeleteOrgCredential, +})); + +// Per-org actor-role refinement reads memberships through this repository. +// Default (no membership row) falls back to the global role in the home org, +// so admin/member fixtures acting in their home org behave as their global role. +vi.mock('../../../../src/db/repositories/orgMembershipsRepository.js', () => ({ + getOrgMembership: mockGetOrgMembership, +})); + +vi.mock('../../../../src/sentry.js', () => ({ + captureException: vi.fn(), +})); + import { organizationRouter } from '../../../../src/api/routers/organization.js'; const createCaller = createCallerFor(organizationRouter); @@ -117,4 +147,114 @@ describe('organizationRouter', () => { await expect(caller.list()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); }); + + describe('credentials', () => { + beforeEach(() => { + vi.clearAllMocks(); + // No membership row → resolveActorRoleInOrg falls back to the global + // role in the home org. + mockGetOrgMembership.mockResolvedValue(null); + }); + + describe('list', () => { + it('returns masked values scoped to the effective org', async () => { + mockListOrgCredentials.mockResolvedValue([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', value: 'ghp_1234567890abcd', name: 'GH' }, + { envVarKey: 'SHORT_KEY', value: 'short', name: null }, + ]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list(); + + expect(mockListOrgCredentials).toHaveBeenCalledWith('org-1'); + expect(result).toEqual([ + { + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + name: 'GH', + isConfigured: true, + maskedValue: '****abcd', + }, + { envVarKey: 'SHORT_KEY', name: null, isConfigured: true, maskedValue: '****' }, + ]); + }); + + it('falls back to metadata when decryption fails', async () => { + mockListOrgCredentials.mockRejectedValue(new Error('decrypt failed')); + mockListOrgCredentialsMeta.mockResolvedValue([{ envVarKey: 'KEY_A', name: 'A' }]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list(); + + expect(result).toEqual([ + { envVarKey: 'KEY_A', name: 'A', isConfigured: true, maskedValue: '****' }, + ]); + }); + + it('throws FORBIDDEN for a global member', async () => { + const memberUser = createMockUser({ role: 'member' }); + const caller = createCaller({ user: memberUser, effectiveOrgId: memberUser.orgId }); + await expectTRPCError(caller.credentials.list(), 'FORBIDDEN'); + }); + + it('throws FORBIDDEN for a global admin who is only a member in the effective org', async () => { + mockGetOrgMembership.mockResolvedValue({ role: 'member' }); + const caller = createCaller({ user: mockUser, effectiveOrgId: 'other-org' }); + await expectTRPCError(caller.credentials.list(), 'FORBIDDEN'); + }); + }); + + describe('set', () => { + it('writes to the effective org', async () => { + mockWriteOrgCredential.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.credentials.set({ + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + value: 'ghp_new', + name: 'GH', + }); + + expect(mockWriteOrgCredential).toHaveBeenCalledWith( + 'org-1', + 'GITHUB_TOKEN_IMPLEMENTER', + 'ghp_new', + 'GH', + ); + }); + + it('rejects invalid env var keys', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.credentials.set({ envVarKey: 'lower_case', value: 'x' }), + ).rejects.toThrow(); + expect(mockWriteOrgCredential).not.toHaveBeenCalled(); + }); + + it('throws FORBIDDEN for a global member', async () => { + const memberUser = createMockUser({ role: 'member' }); + const caller = createCaller({ user: memberUser, effectiveOrgId: memberUser.orgId }); + await expectTRPCError( + caller.credentials.set({ envVarKey: 'SOME_KEY', value: 'x' }), + 'FORBIDDEN', + ); + }); + }); + + describe('delete', () => { + it('deletes from the effective org', async () => { + mockDeleteOrgCredential.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.credentials.delete({ envVarKey: 'SOME_KEY' }); + + expect(mockDeleteOrgCredential).toHaveBeenCalledWith('org-1', 'SOME_KEY'); + }); + + it('throws FORBIDDEN for a global admin who is only a member in the effective org', async () => { + mockGetOrgMembership.mockResolvedValue({ role: 'member' }); + const caller = createCaller({ user: mockUser, effectiveOrgId: 'other-org' }); + await expectTRPCError(caller.credentials.delete({ envVarKey: 'SOME_KEY' }), 'FORBIDDEN'); + }); + }); + }); }); diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index 8bffd60d9..377113a36 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -25,6 +25,8 @@ const { mockListProjectCredentialsMeta, mockWriteProjectCredential, mockDeleteProjectCredential, + mockListOrgCredentials, + mockListOrgCredentialsMeta, mockCaptureException, } = vi.hoisted(() => ({ mockListProjectsForOrg: vi.fn(), @@ -40,6 +42,8 @@ const { mockListProjectCredentialsMeta: vi.fn(), mockWriteProjectCredential: vi.fn(), mockDeleteProjectCredential: vi.fn(), + mockListOrgCredentials: vi.fn(), + mockListOrgCredentialsMeta: vi.fn(), mockCaptureException: vi.fn(), })); @@ -65,6 +69,11 @@ vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ deleteProjectCredential: mockDeleteProjectCredential, })); +vi.mock('../../../../src/db/repositories/orgCredentialsRepository.js', () => ({ + listOrgCredentials: mockListOrgCredentials, + listOrgCredentialsMeta: mockListOrgCredentialsMeta, +})); + vi.mock('../../../../src/sentry.js', () => ({ captureException: mockCaptureException, })); @@ -593,6 +602,11 @@ describe('projectsRouter', () => { // ============================================================================ describe('credentials', () => { + beforeEach(() => { + mockListOrgCredentials.mockResolvedValue([]); + mockListOrgCredentialsMeta.mockResolvedValue([]); + }); + describe('list', () => { it('throws UNAUTHORIZED when not authenticated', async () => { const caller = createCaller({ user: null, effectiveOrgId: null }); @@ -615,16 +629,73 @@ describe('projectsRouter', () => { name: 'OpenRouter Key', isConfigured: true, maskedValue: '****5678', + source: 'project', + hasOrgFallback: false, }, { envVarKey: 'SHORT', name: null, isConfigured: true, maskedValue: '****', + source: 'project', + hasOrgFallback: false, }, ]); }); + it('appends inherited org rows not overridden by a project row', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockListProjectCredentials.mockResolvedValue([ + { envVarKey: 'OPENROUTER_API_KEY', name: null, value: 'sk-or-project-1234567' }, + ]); + mockListOrgCredentials.mockResolvedValue([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', name: 'GH', value: 'ghp_org_1234567890' }, + ]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list({ projectId: 'p1' }); + + expect(result).toEqual([ + { + envVarKey: 'OPENROUTER_API_KEY', + name: null, + isConfigured: true, + maskedValue: '****4567', + source: 'project', + hasOrgFallback: false, + }, + { + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + name: 'GH', + isConfigured: true, + maskedValue: '****7890', + source: 'org', + hasOrgFallback: false, + }, + ]); + }); + + it('marks a project row that shadows an org value with hasOrgFallback', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockListProjectCredentials.mockResolvedValue([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', name: null, value: 'ghp_project_override1' }, + ]); + mockListOrgCredentials.mockResolvedValue([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', name: 'GH', value: 'ghp_org_1234567890' }, + ]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list({ projectId: 'p1' }); + + // Single row per key: project wins, org row filtered out + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + source: 'project', + hasOrgFallback: true, + }); + }); + it('calls listProjectCredentials with projectId', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); mockListProjectCredentials.mockResolvedValue([]); @@ -663,17 +734,55 @@ describe('projectsRouter', () => { name: 'GH Implementer', isConfigured: true, maskedValue: '****', + source: 'project', + hasOrgFallback: false, }, { envVarKey: 'OPENROUTER_API_KEY', name: null, isConfigured: true, maskedValue: '****', + source: 'project', + hasOrgFallback: false, }, ]); expect(mockListProjectCredentialsMeta).toHaveBeenCalledWith('p1'); }); + it('meta fallback also merges org-tier metadata', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockListProjectCredentials.mockRejectedValueOnce(new Error('bad key')); + mockListProjectCredentialsMeta.mockResolvedValueOnce([ + { envVarKey: 'PROJECT_KEY', name: null }, + ]); + mockListOrgCredentialsMeta.mockResolvedValueOnce([ + { envVarKey: 'PROJECT_KEY', name: 'Org shadow' }, + { envVarKey: 'ORG_ONLY_KEY', name: 'Org only' }, + ]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list({ projectId: 'p1' }); + + expect(result).toEqual([ + { + envVarKey: 'PROJECT_KEY', + name: null, + isConfigured: true, + maskedValue: '****', + source: 'project', + hasOrgFallback: true, + }, + { + envVarKey: 'ORG_ONLY_KEY', + name: 'Org only', + isConfigured: true, + maskedValue: '****', + source: 'org', + hasOrgFallback: false, + }, + ]); + }); + it('reports decryption failure to Sentry', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); const decryptionError = new Error('bad key'); diff --git a/tests/unit/db/repositories/credentialsRepository.test.ts b/tests/unit/db/repositories/credentialsRepository.test.ts index d850fc69e..60c97c04d 100644 --- a/tests/unit/db/repositories/credentialsRepository.test.ts +++ b/tests/unit/db/repositories/credentialsRepository.test.ts @@ -27,7 +27,25 @@ describe('credentialsRepository', () => { expect(result).toBe('ghp_impl_token'); }); - it('returns null when not found', async () => { + it('does not query the org tier when the project row exists', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ value: 'ghp_impl_token' }]); + + await resolveProjectCredential('proj1', 'GITHUB_TOKEN_IMPLEMENTER'); + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + + it('falls back to the org credential when the project row is missing', async () => { + // Project miss, then org join hit + mockDb.chain.where.mockResolvedValueOnce([]); + mockDb.chain.where.mockResolvedValueOnce([{ value: 'org-shared-token', orgId: 'org-1' }]); + + const result = await resolveProjectCredential('proj1', 'GITHUB_TOKEN_IMPLEMENTER'); + expect(result).toBe('org-shared-token'); + expect(mockDb.db.select).toHaveBeenCalledTimes(2); + }); + + it('returns null when neither project nor org row exists', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); mockDb.chain.where.mockResolvedValueOnce([]); const result = await resolveProjectCredential('proj1', 'MISSING_KEY'); @@ -46,13 +64,28 @@ describe('credentialsRepository', () => { const result = await resolveProjectCredential('proj1', 'SOME_KEY'); expect(result).toBe('my-secret'); }); + + it('uses orgId as AAD when decrypting an org-tier fallback value', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + const encryptedValue = encryptCredential('org-secret', 'org-1'); + mockDb.chain.where.mockResolvedValueOnce([]); + mockDb.chain.where.mockResolvedValueOnce([{ value: encryptedValue, orgId: 'org-1' }]); + + const result = await resolveProjectCredential('proj1', 'SOME_KEY'); + expect(result).toBe('org-secret'); + }); }); describe('resolveAllProjectCredentials', () => { it('returns all project credentials as key-value map', async () => { - // First select: project existence check - mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1' }]); - // Second select: project_credentials rows + // First select: project existence check (now includes orgId) + mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1', orgId: 'org-1' }]); + // Second select: org_credentials rows (none) + mockDb.chain.where.mockResolvedValueOnce([]); + // Third select: project_credentials rows mockDb.chain.where.mockResolvedValueOnce([ { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', value: 'ghp_impl' }, { envVarKey: 'TRELLO_API_KEY', value: 'trello-key' }, @@ -67,10 +100,49 @@ describe('credentialsRepository', () => { }); }); + it('merges org credentials underneath project credentials (project wins)', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1', orgId: 'org-1' }]); + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', value: 'org-shared' }, + { envVarKey: 'SENTRY_API_TOKEN', value: 'org-sentry' }, + ]); + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', value: 'project-override' }, + ]); + + const result = await resolveAllProjectCredentials('proj1'); + expect(result).toEqual({ + GITHUB_TOKEN_IMPLEMENTER: 'project-override', + SENTRY_API_TOKEN: 'org-sentry', + }); + }); + + it('decrypts each tier with its own AAD (orgId vs projectId)', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + + mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1', orgId: 'org-1' }]); + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'ORG_ONLY_KEY', value: encryptCredential('org-value', 'org-1') }, + ]); + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'PROJECT_KEY', value: encryptCredential('project-value', 'proj1') }, + ]); + + const result = await resolveAllProjectCredentials('proj1'); + expect(result).toEqual({ + ORG_ONLY_KEY: 'org-value', + PROJECT_KEY: 'project-value', + }); + }); + it('returns empty object when no credentials', async () => { // Project exists - mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1' }]); - // No credentials + mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1', orgId: 'org-1' }]); + // No org credentials + mockDb.chain.where.mockResolvedValueOnce([]); + // No project credentials mockDb.chain.where.mockResolvedValueOnce([]); const result = await resolveAllProjectCredentials('proj1'); @@ -86,14 +158,14 @@ describe('credentialsRepository', () => { ); }); - it('issues two queries: project existence check then project_credentials', async () => { - mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1' }]); + it('issues three queries: project check, org_credentials, project_credentials', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1', orgId: 'org-1' }]); + mockDb.chain.where.mockResolvedValueOnce([]); mockDb.chain.where.mockResolvedValueOnce([{ envVarKey: 'KEY1', value: 'val1' }]); await resolveAllProjectCredentials('proj1'); - // One select for project existence, one for project_credentials - expect(mockDb.db.select).toHaveBeenCalledTimes(2); + expect(mockDb.db.select).toHaveBeenCalledTimes(3); }); }); diff --git a/tests/unit/db/repositories/orgCredentialsRepository.test.ts b/tests/unit/db/repositories/orgCredentialsRepository.test.ts new file mode 100644 index 000000000..1ceb7e5b3 --- /dev/null +++ b/tests/unit/db/repositories/orgCredentialsRepository.test.ts @@ -0,0 +1,154 @@ +import { randomBytes } from 'node:crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDbWithGetDb } from '../../../helpers/mockDb.js'; +import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/db/client.js', () => mockDbClientModule); + +import { + deleteOrgCredential, + listOrgCredentials, + listOrgCredentialsMeta, + resolveAllOrgCredentials, + resolveOrgCredential, + writeOrgCredential, +} from '../../../../src/db/repositories/orgCredentialsRepository.js'; + +describe('orgCredentialsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDbWithGetDb({ withUpsert: true }); + }); + + describe('resolveOrgCredential', () => { + it('returns decrypted value when found', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ value: 'org-shared-token' }]); + + const result = await resolveOrgCredential('org-1', 'GITHUB_TOKEN_IMPLEMENTER'); + expect(result).toBe('org-shared-token'); + }); + + it('returns null when not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await resolveOrgCredential('org-1', 'MISSING_KEY'); + expect(result).toBeNull(); + }); + + it('round-trips encryption with orgId as AAD when CREDENTIAL_MASTER_KEY is set', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + const encryptedValue = encryptCredential('org-secret', 'org-1'); + mockDb.chain.where.mockResolvedValueOnce([{ value: encryptedValue }]); + + const result = await resolveOrgCredential('org-1', 'SOME_KEY'); + expect(result).toBe('org-secret'); + }); + + it('fails to decrypt a value encrypted with a different org AAD', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + const encryptedValue = encryptCredential('org-secret', 'other-org'); + mockDb.chain.where.mockResolvedValueOnce([{ value: encryptedValue }]); + + await expect(resolveOrgCredential('org-1', 'SOME_KEY')).rejects.toThrow(); + }); + }); + + describe('resolveAllOrgCredentials', () => { + it('returns all org credentials as key-value map', async () => { + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', value: 'ghp_shared' }, + { envVarKey: 'CLAUDE_CODE_OAUTH_TOKEN', value: 'oat-token' }, + ]); + + const result = await resolveAllOrgCredentials('org-1'); + expect(result).toEqual({ + GITHUB_TOKEN_IMPLEMENTER: 'ghp_shared', + CLAUDE_CODE_OAUTH_TOKEN: 'oat-token', + }); + }); + + it('returns empty object when no credentials', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await resolveAllOrgCredentials('org-1'); + expect(result).toEqual({}); + }); + }); + + describe('writeOrgCredential', () => { + it('encrypts with orgId as AAD before upserting', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + await writeOrgCredential('org-1', 'SOME_KEY', 'plaintext-secret', 'Label'); + + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + const inserted = mockDb.chain.values.mock.calls[0][0] as { + orgId: string; + envVarKey: string; + value: string; + name: string | null; + }; + expect(inserted.orgId).toBe('org-1'); + expect(inserted.envVarKey).toBe('SOME_KEY'); + expect(inserted.name).toBe('Label'); + expect(inserted.value).not.toBe('plaintext-secret'); + + const { decryptCredential } = await import('../../../../src/db/crypto.js'); + expect(decryptCredential(inserted.value, 'org-1')).toBe('plaintext-secret'); + }); + + it('stores plaintext when no master key is configured', async () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', ''); + + await writeOrgCredential('org-1', 'SOME_KEY', 'plaintext-secret'); + + const inserted = mockDb.chain.values.mock.calls[0][0] as { value: string }; + expect(inserted.value).toBe('plaintext-secret'); + }); + }); + + describe('listOrgCredentials', () => { + it('returns decrypted rows', async () => { + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'KEY_A', value: 'value-a', name: 'A' }, + { envVarKey: 'KEY_B', value: 'value-b', name: null }, + ]); + + const result = await listOrgCredentials('org-1'); + expect(result).toEqual([ + { envVarKey: 'KEY_A', value: 'value-a', name: 'A' }, + { envVarKey: 'KEY_B', value: 'value-b', name: null }, + ]); + }); + }); + + describe('listOrgCredentialsMeta', () => { + it('returns envVarKey and name without values', async () => { + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'KEY_A', name: 'A' }, + { envVarKey: 'KEY_B', name: null }, + ]); + + const result = await listOrgCredentialsMeta('org-1'); + expect(result).toEqual([ + { envVarKey: 'KEY_A', name: 'A' }, + { envVarKey: 'KEY_B', name: null }, + ]); + }); + }); + + describe('deleteOrgCredential', () => { + it('issues a delete', async () => { + await deleteOrgCredential('org-1', 'KEY_A'); + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 7ade0e166..ae411c3f3 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -8,6 +8,7 @@ import { ChevronDown, ChevronRight, FolderGit2, + KeyRound, Plus, Settings, Users, @@ -44,6 +45,7 @@ const globalNav = [ const settingsNav = [ { to: '/settings/general' as const, label: 'General', icon: Settings }, + { to: '/settings/credentials' as const, label: 'Credentials', icon: KeyRound }, { to: '/settings/users' as const, label: 'Users', icon: Users }, ]; diff --git a/web/src/components/projects/project-secret-field.tsx b/web/src/components/projects/project-secret-field.tsx index cd1c58f55..a32d89070 100644 --- a/web/src/components/projects/project-secret-field.tsx +++ b/web/src/components/projects/project-secret-field.tsx @@ -16,6 +16,10 @@ export interface ProjectCredentialMeta { name: string | null; isConfigured: boolean; maskedValue: string; + /** Which tier the value comes from; 'org' = inherited, no project row exists. */ + source?: 'project' | 'org'; + /** True when a project row shadows an org-level value with the same key. */ + hasOrgFallback?: boolean; } /** @@ -92,9 +96,15 @@ export function ProjectSecretField({
{credential?.isConfigured ? ( - - {credential.maskedValue} - + credential.source === 'org' ? ( + + Inherited from org {credential.maskedValue} + + ) : ( + + {credential.maskedValue} + + ) ) : ( not configured @@ -108,7 +118,13 @@ export function ProjectSecretField({ type="password" value={value} onChange={(e) => setValue(e.target.value)} - placeholder={credential?.isConfigured ? 'Enter new value to update...' : placeholder} + placeholder={ + credential?.isConfigured + ? credential.source === 'org' + ? 'Override organization value...' + : 'Enter new value to update...' + : placeholder + } autoComplete="off" className="flex-1" /> @@ -130,13 +146,17 @@ export function ProjectSecretField({ {isVerifying ? : 'Verify'} )} - {credential?.isConfigured && ( + {credential?.isConfigured && credential.source !== 'org' && ( + {credential?.isConfigured && ( + + )} +
+ {saveMutation.isError && ( +

{saveMutation.error.message}

+ )} + {deleteMutation.isError && ( +

{deleteMutation.error.message}

+ )} + {savedFeedback && ( +
+ + Saved +
+ )} +
+ ); +} diff --git a/web/src/routes/route-tree.ts b/web/src/routes/route-tree.ts index 509514a29..fe73a646a 100644 --- a/web/src/routes/route-tree.ts +++ b/web/src/routes/route-tree.ts @@ -16,6 +16,7 @@ import { projectWorkRoute } from './projects/$projectId.work.js'; import { projectsIndexRoute } from './projects/index.js'; import { prRunsRoute } from './prs/$projectId.$prNumber.js'; import { runDetailRoute } from './runs/$runId.js'; +import { settingsCredentialsRoute } from './settings/credentials.js'; import { settingsGeneralRoute } from './settings/general.js'; import { settingsProfileRoute } from './settings/profile.js'; import { settingsUsersRoute } from './settings/users.js'; @@ -36,6 +37,7 @@ export const routeTree = rootRoute.addChildren([ projectLifecycleRoute, ]), settingsGeneralRoute, + settingsCredentialsRoute, settingsProfileRoute, settingsUsersRoute, globalDefinitionsRoute, diff --git a/web/src/routes/settings/credentials.tsx b/web/src/routes/settings/credentials.tsx new file mode 100644 index 000000000..455c87395 --- /dev/null +++ b/web/src/routes/settings/credentials.tsx @@ -0,0 +1,139 @@ +/** + * Organization credentials page. Credentials set here are inherited by every + * project in the organization; a project-level credential with the same env + * var key overrides the org value for that project. + */ + +import { useQuery } from '@tanstack/react-query'; +import { createRoute } from '@tanstack/react-router'; +import { useMemo, useState } from 'react'; +import { buildOrgCredentialCatalog } from '@/components/settings/org-credential-catalog.js'; +import { OrgSecretField } from '@/components/settings/org-secret-field.js'; +import { Input } from '@/components/ui/input.js'; +import { trpc } from '@/lib/trpc.js'; +import { rootRoute } from '../__root.js'; + +const ENV_VAR_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/; + +function CustomCredentialSection({ + credentials, + knownKeys, +}: { + credentials: { + envVarKey: string; + name: string | null; + isConfigured: boolean; + maskedValue: string; + }[]; + knownKeys: Set; +}) { + const [newKey, setNewKey] = useState(''); + const customCredentials = credentials.filter((c) => !knownKeys.has(c.envVarKey)); + const [pendingKeys, setPendingKeys] = useState([]); + + const visiblePending = pendingKeys.filter( + (key) => !customCredentials.some((c) => c.envVarKey === key), + ); + const keyValid = ENV_VAR_KEY_PATTERN.test(newKey); + + return ( +
+
+

Custom

+

+ Any other environment variable to share with every project in this organization. +

+
+ {customCredentials.map((credential) => ( + + ))} + {visiblePending.map((key) => ( + + ))} +
+ setNewKey(e.target.value.toUpperCase())} + placeholder="ENV_VAR_NAME" + className="flex-1 font-mono" + /> + +
+ {newKey && !keyValid && ( +

+ Key must be UPPER_SNAKE_CASE (letters, digits, underscores; not starting with a digit). +

+ )} +
+ ); +} + +function OrgCredentialsPage() { + const credentialsQuery = useQuery(trpc.organization.credentials.list.queryOptions()); + const { sections, knownKeys } = useMemo(buildOrgCredentialCatalog, []); + const credentials = credentialsQuery.data ?? []; + const byKey = new Map(credentials.map((c) => [c.envVarKey, c])); + + return ( +
+
+

Organization Credentials

+

+ Shared by every project in this organization. A credential set on a project with the same + key overrides the organization value for that project. +

+
+ + {credentialsQuery.isError && ( +

+ {credentialsQuery.error.message.includes('FORBIDDEN') || + credentialsQuery.error.message.includes('Admin') + ? 'Organization admin access is required to manage shared credentials.' + : credentialsQuery.error.message} +

+ )} + + {!credentialsQuery.isError && + sections.map((section) => ( +
+

{section.title}

+ {section.entries.map((entry) => ( + + ))} +
+ ))} + + {!credentialsQuery.isError && ( + + )} +
+ ); +} + +export const settingsCredentialsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings/credentials', + component: OrgCredentialsPage, +}); From 53b26f76e7ca70e1fe15ec43dcb452f53c33aa17 Mon Sep 17 00:00:00 2001 From: Wojtek Siudzinski Date: Sun, 2 Aug 2026 17:15:07 +0200 Subject: [PATCH 09/10] feat(credentials): surface Claude Code limits in credential views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the Claude Code subscription limits feature (previously a superadmin-only sidebar widget) into the credential settings surfaces: - claudeCodeLimits.forOrg (org-admin gated, same audience as the org credentials page): usage for every token source in the org — the shared org credential, each project-level override (with project name), and the server env token — with source attribution instead of value dedup. - claudeCodeLimits.forProject (project access): usage for the credential candidates one project can run on — its override, the inherited org token, and the env token — with the credential-system winner marked active, rendered as a picker preview under CLAUDE_CODE_OAUTH_TOKEN on the project engine tab. - Failed sources now return limits: null instead of being silently dropped, so the UI can distinguish 'no data' from 'not configured'. - Shared ClaudeUsageCard display component; sidebar section deleted. - listAllClaudeCodeCredentials now returns projectName for attribution. - Org-role refinement helpers extracted to _shared/orgRole.ts (reused by organization.credentials.* and claudeCodeLimits.forOrg). - Anthropic client added to the auth-header provenance accept list (LLM subscription API, not a PM/SCM/alerting integration). Co-Authored-By: Claude Fable 5 --- src/api/routers/_shared/orgRole.ts | 29 +++ src/api/routers/claudeCodeLimits.ts | 123 +++++++--- src/api/routers/organization.ts | 31 +-- src/db/repositories/credentialsRepository.ts | 7 +- .../unit/api/routers/claudeCodeLimits.test.ts | 222 ++++++++++-------- .../auth-header-provenance.test.ts | 5 + .../components/global/claude-code-limits.tsx | 92 -------- web/src/components/layout/sidebar.tsx | 2 - .../projects/claude-code-limits-preview.tsx | 33 +++ .../projects/project-harness-form.tsx | 25 +- .../components/shared/claude-usage-card.tsx | 117 +++++++++ web/src/routes/settings/credentials.tsx | 38 +++ 12 files changed, 458 insertions(+), 266 deletions(-) create mode 100644 src/api/routers/_shared/orgRole.ts delete mode 100644 web/src/components/global/claude-code-limits.tsx create mode 100644 web/src/components/projects/claude-code-limits-preview.tsx create mode 100644 web/src/components/shared/claude-usage-card.tsx diff --git a/src/api/routers/_shared/orgRole.ts b/src/api/routers/_shared/orgRole.ts new file mode 100644 index 000000000..005cce4f4 --- /dev/null +++ b/src/api/routers/_shared/orgRole.ts @@ -0,0 +1,29 @@ +import { TRPCError } from '@trpc/server'; +import { resolveActorRoleInOrg } from '../../context.js'; + +type Role = 'member' | 'admin' | 'superadmin'; + +/** + * Resolve the caller's role *in the effective org*. The `adminProcedure` + * middleware is a coarse global-role gate; this refines it with the per-org + * membership role (users.ts pattern) so an admin who has switched into an org + * where they are only a member cannot perform admin actions there. + */ +export function resolveActorRole(ctx: { + user: { id: string; role: Role; orgId: string }; + effectiveOrgId: string; +}): Promise { + return resolveActorRoleInOrg({ + userId: ctx.user.id, + globalRole: ctx.user.role, + homeOrgId: ctx.user.orgId, + orgId: ctx.effectiveOrgId, + }); +} + +/** Require the caller to be an admin (or superadmin) in the effective org. */ +export function assertOrgAdmin(actorRole: Role): void { + if (actorRole !== 'admin' && actorRole !== 'superadmin') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' }); + } +} diff --git a/src/api/routers/claudeCodeLimits.ts b/src/api/routers/claudeCodeLimits.ts index 58ff3f7c3..7f410a080 100644 --- a/src/api/routers/claudeCodeLimits.ts +++ b/src/api/routers/claudeCodeLimits.ts @@ -1,40 +1,109 @@ +import { z } from 'zod'; import { fetchClaudeSubscriptionLimits } from '../../anthropic/client.js'; -import { listAllClaudeCodeCredentials } from '../../db/repositories/credentialsRepository.js'; -import { router, superAdminProcedure } from '../trpc.js'; +import { + listAllClaudeCodeCredentials, + listProjectCredentials, +} from '../../db/repositories/credentialsRepository.js'; +import { resolveOrgCredential } from '../../db/repositories/orgCredentialsRepository.js'; +import { adminProcedure, protectedProcedure, router } from '../trpc.js'; +import { assertOrgAdmin, resolveActorRole } from './_shared/orgRole.js'; +import { verifyProjectOrgAccess } from './_shared/projectAccess.js'; + +const CLAUDE_CODE_TOKEN_KEY = 'CLAUDE_CODE_OAUTH_TOKEN'; + +export type ClaudeCodeLimitsScope = 'org' | 'project' | 'env'; + +export interface LimitsSource { + scope: ClaudeCodeLimitsScope; + projectId?: string; + projectName?: string; + token: string; +} + +/** + * Fetch usage limits for each source, preserving source attribution. + * Sources whose token yields no limits data (API error, revoked token) come + * back with `limits: null` so the UI can distinguish "no data" from "not + * configured". The Anthropic client caches per token for 5 minutes, so + * duplicate tokens across sources cost one HTTP call. Raw tokens never leave + * the server — only the masked preview inside `limits`. + */ +async function fetchLimitsForSources(sources: T[]) { + return Promise.all( + sources.map(async ({ token, ...source }) => ({ + ...source, + limits: await fetchClaudeSubscriptionLimits(token), + })), + ); +} export const claudeCodeLimitsRouter = router({ /** - * Fetch Claude Code subscription limits for all unique OAuth tokens configured - * across org projects, plus the global env var if set. - * - * Superadmin only. Returns masked token + limits data — never raw tokens. + * Claude Code subscription usage for every credential source in the + * effective org: the org-level shared token, each project-level override, + * and the server's global env token. Org-admin gated — same audience as + * the organization credentials settings page. */ - query: superAdminProcedure.query(async ({ ctx }) => { - // Gather tokens from project credentials - const projectCredentials = await listAllClaudeCodeCredentials(ctx.effectiveOrgId); + forOrg: adminProcedure.query(async ({ ctx }) => { + assertOrgAdmin(await resolveActorRole(ctx)); - // Build a deduplicated set of tokens (value → first seen) - const tokenMap = new Map(); - const tokens: string[] = []; + const sources: LimitsSource[] = []; - for (const cred of projectCredentials) { - if (!tokenMap.has(cred.value)) { - tokenMap.set(cred.value, true); - tokens.push(cred.value); - } - } + const orgToken = await resolveOrgCredential(ctx.effectiveOrgId, CLAUDE_CODE_TOKEN_KEY); + if (orgToken) sources.push({ scope: 'org', token: orgToken }); - // Also include the global env var if set - const globalToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; - if (globalToken && !tokenMap.has(globalToken)) { - tokenMap.set(globalToken, true); - tokens.push(globalToken); + const projectCredentials = await listAllClaudeCodeCredentials(ctx.effectiveOrgId); + for (const cred of projectCredentials) { + sources.push({ + scope: 'project', + projectId: cred.projectId, + projectName: cred.projectName, + token: cred.value, + }); } - // Fetch limits for each unique token in parallel - const results = await Promise.all(tokens.map((token) => fetchClaudeSubscriptionLimits(token))); + const envToken = process.env[CLAUDE_CODE_TOKEN_KEY]; + if (envToken) sources.push({ scope: 'env', token: envToken }); - // Filter nulls (API errors / unavailable) - return results.filter((r) => r !== null); + return fetchLimitsForSources(sources); }), + + /** + * Claude Code subscription usage for the credential candidates visible to + * one project: its own override (if set), the inherited org token (if set), + * and the server's global env token. `active` marks the credential-system + * winner (project override beats org; env is informational). Used by the + * project settings engine tab as a picker preview. + */ + forProject: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + + // Project override — project rows only, deliberately NOT the inheriting + // resolver: the point is contrasting the override with the org value. + const projectRows = await listProjectCredentials(input.projectId); + const projectToken = projectRows.find((r) => r.envVarKey === CLAUDE_CODE_TOKEN_KEY)?.value; + + const orgToken = await resolveOrgCredential(ctx.effectiveOrgId, CLAUDE_CODE_TOKEN_KEY); + const envToken = process.env[CLAUDE_CODE_TOKEN_KEY]; + + const sources: (LimitsSource & { active: boolean })[] = []; + if (projectToken) { + sources.push({ + scope: 'project', + projectId: input.projectId, + token: projectToken, + active: true, + }); + } + if (orgToken) { + sources.push({ scope: 'org', token: orgToken, active: !projectToken }); + } + if (envToken) { + sources.push({ scope: 'env', token: envToken, active: false }); + } + + return fetchLimitsForSources(sources); + }), }); diff --git a/src/api/routers/organization.ts b/src/api/routers/organization.ts index 8b7fe52f8..7edd15d4c 100644 --- a/src/api/routers/organization.ts +++ b/src/api/routers/organization.ts @@ -1,4 +1,3 @@ -import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { deleteOrgCredential, @@ -13,37 +12,9 @@ import { updateOrganization, } from '../../db/repositories/settingsRepository.js'; import { captureException } from '../../sentry.js'; -import { resolveActorRoleInOrg } from '../context.js'; import { adminProcedure, protectedProcedure, router, superAdminProcedure } from '../trpc.js'; import { maskCredentialValue } from './_shared/maskCredential.js'; - -type Role = 'member' | 'admin' | 'superadmin'; - -/** - * Resolve the caller's role *in the effective org*. The `adminProcedure` - * middleware is a coarse global-role gate; this refines it with the per-org - * membership role (same pattern as users.ts) so an admin who has switched - * into an org where they are only a member cannot manage that org's - * credentials. - */ -function resolveActorRole(ctx: { - user: { id: string; role: Role; orgId: string }; - effectiveOrgId: string; -}): Promise { - return resolveActorRoleInOrg({ - userId: ctx.user.id, - globalRole: ctx.user.role, - homeOrgId: ctx.user.orgId, - orgId: ctx.effectiveOrgId, - }); -} - -/** Require the caller to be an admin (or superadmin) in the effective org. */ -function assertOrgAdmin(actorRole: Role): void { - if (actorRole !== 'admin' && actorRole !== 'superadmin') { - throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' }); - } -} +import { assertOrgAdmin, resolveActorRole } from './_shared/orgRole.js'; export const organizationRouter = router({ get: protectedProcedure.query(async ({ ctx }) => { diff --git a/src/db/repositories/credentialsRepository.ts b/src/db/repositories/credentialsRepository.ts index 309458222..865b1cbf9 100644 --- a/src/db/repositories/credentialsRepository.ts +++ b/src/db/repositories/credentialsRepository.ts @@ -190,18 +190,20 @@ export async function listProjectCredentialsMeta( // ============================================================================ /** - * List all CLAUDE_CODE_OAUTH_TOKEN credentials across all projects in an org. + * List all project-level CLAUDE_CODE_OAUTH_TOKEN credentials across an org, + * with the owning project's name for display attribution. * Returns decrypted values for use in server-side API calls only. * Never expose raw tokens to the client. */ export async function listAllClaudeCodeCredentials( orgId: string, -): Promise<{ projectId: string; value: string }[]> { +): Promise<{ projectId: string; projectName: string; value: string }[]> { const db = getDb(); const rows = await db .select({ projectId: projectCredentials.projectId, + projectName: projects.name, value: projectCredentials.value, }) .from(projectCredentials) @@ -212,6 +214,7 @@ export async function listAllClaudeCodeCredentials( return rows.map((row) => ({ projectId: row.projectId, + projectName: row.projectName, value: decryptCredential(row.value, row.projectId), })); } diff --git a/tests/unit/api/routers/claudeCodeLimits.test.ts b/tests/unit/api/routers/claudeCodeLimits.test.ts index 4abe58a5b..e637ab129 100644 --- a/tests/unit/api/routers/claudeCodeLimits.test.ts +++ b/tests/unit/api/routers/claudeCodeLimits.test.ts @@ -1,24 +1,52 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockContext, createMockSuperAdmin } from '../../../helpers/factories.js'; +import { createMockContext, createMockUser } from '../../../helpers/factories.js'; import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; -const { mockListAllClaudeCodeCredentials, mockFetchClaudeSubscriptionLimits } = vi.hoisted(() => ({ +const { + mockListAllClaudeCodeCredentials, + mockListProjectCredentials, + mockResolveOrgCredential, + mockFetchClaudeSubscriptionLimits, + mockVerifyProjectOrgAccess, + mockGetOrgMembership, +} = vi.hoisted(() => ({ mockListAllClaudeCodeCredentials: vi.fn(), + mockListProjectCredentials: vi.fn(), + mockResolveOrgCredential: vi.fn(), mockFetchClaudeSubscriptionLimits: vi.fn(), + mockVerifyProjectOrgAccess: vi.fn(), + mockGetOrgMembership: vi.fn(), })); vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ listAllClaudeCodeCredentials: mockListAllClaudeCodeCredentials, + listProjectCredentials: mockListProjectCredentials, +})); + +vi.mock('../../../../src/db/repositories/orgCredentialsRepository.js', () => ({ + resolveOrgCredential: mockResolveOrgCredential, })); vi.mock('../../../../src/anthropic/client.js', () => ({ fetchClaudeSubscriptionLimits: mockFetchClaudeSubscriptionLimits, })); +vi.mock('../../../../src/api/routers/_shared/projectAccess.js', () => ({ + verifyProjectOrgAccess: mockVerifyProjectOrgAccess, +})); + +// Per-org actor-role refinement reads memberships through this repository. +// Default (no membership row) falls back to the global role in the home org. +vi.mock('../../../../src/db/repositories/orgMembershipsRepository.js', () => ({ + getOrgMembership: mockGetOrgMembership, +})); + import { claudeCodeLimitsRouter } from '../../../../src/api/routers/claudeCodeLimits.js'; const createCaller = createCallerFor(claudeCodeLimitsRouter); +const mockAdmin = createMockUser({ role: 'admin' }); + const sampleLimits = { tokenMasked: '****abcd', buckets: [ @@ -31,136 +59,124 @@ const sampleLimits = { describe('claudeCodeLimitsRouter', () => { beforeEach(() => { vi.clearAllMocks(); - // Clear the global env var between tests delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + mockGetOrgMembership.mockResolvedValue(null); + mockResolveOrgCredential.mockResolvedValue(null); + mockListAllClaudeCodeCredentials.mockResolvedValue([]); + mockListProjectCredentials.mockResolvedValue([]); + mockVerifyProjectOrgAccess.mockResolvedValue(undefined); }); - describe('query', () => { - it('requires superadmin role — rejects regular users', async () => { + describe('forOrg', () => { + it('rejects members (global role)', async () => { const caller = createCaller(createMockContext({ role: 'member' })); - await expectTRPCError(caller.query(), 'FORBIDDEN'); + await expectTRPCError(caller.forOrg(), 'FORBIDDEN'); }); - it('requires superadmin role — rejects admin users', async () => { - const caller = createCaller(createMockContext({ role: 'admin' })); - await expectTRPCError(caller.query(), 'FORBIDDEN'); + it('rejects a global admin who is only a member in the effective org', async () => { + mockGetOrgMembership.mockResolvedValue({ role: 'member' }); + const caller = createCaller({ user: mockAdmin, effectiveOrgId: 'other-org' }); + await expectTRPCError(caller.forOrg(), 'FORBIDDEN'); }); - it('returns empty array when no credentials and no env var', async () => { - mockListAllClaudeCodeCredentials.mockResolvedValueOnce([]); - - const caller = createCaller({ - user: createMockSuperAdmin(), - effectiveOrgId: 'org-1', - }); - const result = await caller.query(); - - expect(result).toEqual([]); + it('returns empty array when no token source is configured', async () => { + const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); + expect(await caller.forOrg()).toEqual([]); }); - it('fetches limits for credentials found in DB', async () => { - mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ - { projectId: 'proj-1', value: 'token-aaa' }, + it('labels org, project, and env sources with attribution', async () => { + mockResolveOrgCredential.mockResolvedValue('org-token'); + mockListAllClaudeCodeCredentials.mockResolvedValue([ + { projectId: 'proj-1', projectName: 'Project One', value: 'proj-token' }, ]); - mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(sampleLimits); + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-token'; + mockFetchClaudeSubscriptionLimits.mockResolvedValue(sampleLimits); + + const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); + const result = await caller.forOrg(); + + expect(result).toEqual([ + { scope: 'org', limits: sampleLimits }, + { + scope: 'project', + projectId: 'proj-1', + projectName: 'Project One', + limits: sampleLimits, + }, + { scope: 'env', limits: sampleLimits }, + ]); + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('org-token'); + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('proj-token'); + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('env-token'); + }); - const caller = createCaller({ - user: createMockSuperAdmin(), - effectiveOrgId: 'org-1', - }); - const result = await caller.query(); + it('keeps a failed source with limits: null instead of dropping it', async () => { + mockResolveOrgCredential.mockResolvedValue('org-token'); + mockFetchClaudeSubscriptionLimits.mockResolvedValue(null); - expect(result).toHaveLength(1); - expect(result[0]).toEqual(sampleLimits); - expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('token-aaa'); - }); + const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); + const result = await caller.forOrg(); - it('deduplicates tokens from multiple projects', async () => { - mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ - { projectId: 'proj-1', value: 'shared-token' }, - { projectId: 'proj-2', value: 'shared-token' }, - { projectId: 'proj-3', value: 'other-token' }, - ]); - mockFetchClaudeSubscriptionLimits - .mockResolvedValueOnce({ ...sampleLimits, tokenMasked: '****oken' }) - .mockResolvedValueOnce({ ...sampleLimits, tokenMasked: '****oken2' }); - - const caller = createCaller({ - user: createMockSuperAdmin(), - effectiveOrgId: 'org-1', - }); - const result = await caller.query(); - - // Should only call fetch twice (once per unique token) - expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledTimes(2); - expect(result).toHaveLength(2); + expect(result).toEqual([{ scope: 'org', limits: null }]); }); - it('includes global env var token', async () => { - process.env.CLAUDE_CODE_OAUTH_TOKEN = 'global-env-token'; - mockListAllClaudeCodeCredentials.mockResolvedValueOnce([]); - mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(sampleLimits); + it('scopes lookups to the effective org', async () => { + const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); + await caller.forOrg(); + + expect(mockResolveOrgCredential).toHaveBeenCalledWith('org-1', 'CLAUDE_CODE_OAUTH_TOKEN'); + expect(mockListAllClaudeCodeCredentials).toHaveBeenCalledWith('org-1'); + }); + }); - const caller = createCaller({ - user: createMockSuperAdmin(), - effectiveOrgId: 'org-1', - }); - const result = await caller.query(); + describe('forProject', () => { + const member = createMockUser({ role: 'member' }); - expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('global-env-token'); - expect(result).toHaveLength(1); + it('verifies project org access', async () => { + const caller = createCaller({ user: member, effectiveOrgId: member.orgId }); + await caller.forProject({ projectId: 'p1' }); + expect(mockVerifyProjectOrgAccess).toHaveBeenCalledWith('p1', 'org-1'); }); - it('deduplicates global env var against project credentials', async () => { - process.env.CLAUDE_CODE_OAUTH_TOKEN = 'shared-token'; - mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ - { projectId: 'proj-1', value: 'shared-token' }, + it('marks the project override active over the org token', async () => { + mockListProjectCredentials.mockResolvedValue([ + { envVarKey: 'CLAUDE_CODE_OAUTH_TOKEN', value: 'proj-token', name: null }, ]); - mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(sampleLimits); + mockResolveOrgCredential.mockResolvedValue('org-token'); + mockFetchClaudeSubscriptionLimits.mockResolvedValue(sampleLimits); - const caller = createCaller({ - user: createMockSuperAdmin(), - effectiveOrgId: 'org-1', - }); - const result = await caller.query(); + const caller = createCaller({ user: member, effectiveOrgId: member.orgId }); + const result = await caller.forProject({ projectId: 'p1' }); - // Even though token appears in both DB and env, fetch only once - expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledTimes(1); - expect(result).toHaveLength(1); + expect(result).toEqual([ + { scope: 'project', projectId: 'p1', active: true, limits: sampleLimits }, + { scope: 'org', active: false, limits: sampleLimits }, + ]); }); - it('filters out null results from failed API calls', async () => { - mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ - { projectId: 'proj-1', value: 'token-good' }, - { projectId: 'proj-2', value: 'token-bad' }, - ]); - mockFetchClaudeSubscriptionLimits - .mockResolvedValueOnce(sampleLimits) // token-good succeeds - .mockResolvedValueOnce(null); // token-bad fails - - const caller = createCaller({ - user: createMockSuperAdmin(), - effectiveOrgId: 'org-1', - }); - const result = await caller.query(); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual(sampleLimits); + it('marks the org token active when no project override exists', async () => { + mockResolveOrgCredential.mockResolvedValue('org-token'); + mockFetchClaudeSubscriptionLimits.mockResolvedValue(sampleLimits); + + const caller = createCaller({ user: member, effectiveOrgId: member.orgId }); + const result = await caller.forProject({ projectId: 'p1' }); + + expect(result).toEqual([{ scope: 'org', active: true, limits: sampleLimits }]); }); - it('returns empty array when all API calls return null', async () => { - mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ - { projectId: 'proj-1', value: 'token-bad' }, - ]); - mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(null); + it('includes the env token as an inactive informational source', async () => { + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-token'; + mockFetchClaudeSubscriptionLimits.mockResolvedValue(sampleLimits); - const caller = createCaller({ - user: createMockSuperAdmin(), - effectiveOrgId: 'org-1', - }); - const result = await caller.query(); + const caller = createCaller({ user: member, effectiveOrgId: member.orgId }); + const result = await caller.forProject({ projectId: 'p1' }); + + expect(result).toEqual([{ scope: 'env', active: false, limits: sampleLimits }]); + }); - expect(result).toEqual([]); + it('returns empty array when nothing is configured', async () => { + const caller = createCaller({ user: member, effectiveOrgId: member.orgId }); + expect(await caller.forProject({ projectId: 'p1' })).toEqual([]); }); }); }); diff --git a/tests/unit/integrations/auth-header-provenance.test.ts b/tests/unit/integrations/auth-header-provenance.test.ts index 07730bc6d..e9f0f7877 100644 --- a/tests/unit/integrations/auth-header-provenance.test.ts +++ b/tests/unit/integrations/auth-header-provenance.test.ts @@ -49,6 +49,11 @@ const ACCEPT_LIST: Array<{ path: string; reason: string }> = [ path: 'src/sentry/client.ts', reason: 'Sentry client auth — alerting integration is out of spec 009 scope.', }, + { + path: 'src/anthropic/client.ts', + reason: + 'Anthropic OAuth usage endpoint — LLM subscription API, not a PM/SCM/alerting integration; outside spec 009 scope.', + }, ]; // Patterns that suggest manual auth-header assembly: diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx deleted file mode 100644 index d329da671..000000000 --- a/web/src/components/global/claude-code-limits.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Separator } from '@/components/ui/separator.js'; -import { trpc } from '@/lib/trpc.js'; - -function formatResetDate(resetsAt: string): string { - if (!resetsAt) return ''; - try { - const date = new Date(resetsAt); - return date.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }); - } catch { - return resetsAt; - } -} - -function utilizationColor(pct: number): string { - if (pct >= 90) return 'bg-red-500'; - if (pct >= 70) return 'bg-yellow-500'; - return 'bg-emerald-500'; -} - -/** - * Displays Claude Code subscription usage for all unique tokens configured - * across org projects. Shown only to superadmins; auto-hides when no data. - */ -export function ClaudeCodeLimitsSection() { - const { data } = useQuery({ - ...trpc.claudeCodeLimits.query.queryOptions(), - staleTime: 5 * 60 * 1000, // 5 minutes - }); - - // Hide if no data returned (no tokens configured or API unavailable) - if (!data || data.length === 0) { - return null; - } - - return ( -
- -
- Usage -
-
- {data.map((limits, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: tokenMasked is not guaranteed unique (tokens may share trailing 4 chars); index is safe here as list order is server-determined and stable -
-
- {limits.tokenMasked} -
- {limits.buckets.length === 0 && ( -
No usage data
- )} - {limits.buckets.map((bucket) => ( -
-
- {bucket.label} - {bucket.utilization}% -
-
-
-
-
- Resets {formatResetDate(bucket.resetsAt)} -
-
- ))} - {limits.extraUsage?.isEnabled && ( -
- Extra usage enabled - {limits.extraUsage.usedCredits != null && - limits.extraUsage.monthlyLimit != null && ( - - {' '} - — ${limits.extraUsage.usedCredits.toFixed(2)} / $ - {limits.extraUsage.monthlyLimit.toFixed(2)} - - )} -
- )} -
- ))} -
-
- ); -} diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 1a10b1a7c..ae411c3f3 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -15,7 +15,6 @@ import { Zap, } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { ClaudeCodeLimitsSection } from '@/components/global/claude-code-limits.js'; import { OrgNameBanner, OrgSwitcher } from '@/components/layout/org-switcher.js'; import { ProjectFormDialog } from '@/components/projects/project-form-dialog.js'; import { @@ -246,7 +245,6 @@ export function Sidebar({ user }: SidebarProps) { {globalNav.map((item) => ( ))} - )} diff --git a/web/src/components/projects/claude-code-limits-preview.tsx b/web/src/components/projects/claude-code-limits-preview.tsx new file mode 100644 index 000000000..f37321f4a --- /dev/null +++ b/web/src/components/projects/claude-code-limits-preview.tsx @@ -0,0 +1,33 @@ +/** + * Picker preview for the project engine tab: shows Claude Code subscription + * usage for each credential candidate this project can use — its own override, + * the inherited org token, and the server env token — with the active one + * marked, so the operator can compare limits before choosing which token the + * project should run on. Renders nothing when no candidate is configured. + */ + +import { useQuery } from '@tanstack/react-query'; +import { ClaudeUsageCard } from '@/components/shared/claude-usage-card.js'; +import { trpc } from '@/lib/trpc.js'; + +export function ClaudeCodeLimitsPreview({ projectId }: { projectId: string }) { + const limitsQuery = useQuery({ + ...trpc.claudeCodeLimits.forProject.queryOptions({ projectId }), + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const sources = limitsQuery.data ?? []; + if (limitsQuery.isError || sources.length === 0) return null; + + return ( +
+

+ Subscription usage per available token — the active one is what this project runs on. +

+ {sources.map((source) => ( + + ))} +
+ ); +} diff --git a/web/src/components/projects/project-harness-form.tsx b/web/src/components/projects/project-harness-form.tsx index abf65152c..20d842552 100644 --- a/web/src/components/projects/project-harness-form.tsx +++ b/web/src/components/projects/project-harness-form.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { HelpCircle } from 'lucide-react'; import { useState } from 'react'; +import { ClaudeCodeLimitsPreview } from '@/components/projects/claude-code-limits-preview.js'; import { ENGINE_SECRETS } from '@/components/projects/engine-secrets.js'; import { ProjectSecretField } from '@/components/projects/project-secret-field.js'; import { useProjectUpdate } from '@/components/projects/use-project-update.js'; @@ -294,17 +295,21 @@ export function ProjectHarnessForm({ project }: { project: Project }) { const description = secret.description + (sharedNote ? ` · ${sharedNote}` : ''); return ( - c.envVarKey === secret.envVarKey, +
+ c.envVarKey === secret.envVarKey, + )} + /> + {secret.envVarKey === 'CLAUDE_CODE_OAUTH_TOKEN' && ( + )} - /> +
); })}
diff --git a/web/src/components/shared/claude-usage-card.tsx b/web/src/components/shared/claude-usage-card.tsx new file mode 100644 index 000000000..e39240acc --- /dev/null +++ b/web/src/components/shared/claude-usage-card.tsx @@ -0,0 +1,117 @@ +/** + * Shared display for Claude Code subscription usage of one credential source. + * Consumed by the org credentials settings page and the project engine tab + * picker preview. Pure display — data comes from claudeCodeLimits.* queries. + */ + +import { Badge } from '@/components/ui/badge.js'; + +export interface ClaudeUsageBucket { + label: string; + utilization: number; + resetsAt: string; +} + +export interface ClaudeUsageLimits { + tokenMasked: string; + buckets: ClaudeUsageBucket[]; + extraUsage: { + isEnabled: boolean; + monthlyLimit: number | null; + usedCredits: number | null; + utilization: number | null; + } | null; +} + +export interface ClaudeUsageSource { + scope: 'org' | 'project' | 'env'; + projectId?: string; + projectName?: string; + active?: boolean; + limits: ClaudeUsageLimits | null; +} + +function formatResetDate(resetsAt: string): string { + if (!resetsAt) return ''; + try { + const date = new Date(resetsAt); + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } catch { + return resetsAt; + } +} + +function utilizationColor(pct: number): string { + if (pct >= 90) return 'bg-red-500'; + if (pct >= 70) return 'bg-yellow-500'; + return 'bg-emerald-500'; +} + +export function sourceLabel(source: ClaudeUsageSource): string { + if (source.scope === 'org') return 'Organization'; + if (source.scope === 'env') return 'Server environment'; + return source.projectName ? `Project: ${source.projectName}` : 'This project'; +} + +export function ClaudeUsageCard({ source }: { source: ClaudeUsageSource }) { + return ( +
+
+ {sourceLabel(source)} + {source.limits && ( + + {source.limits.tokenMasked} + + )} + {source.active && ( + + active + + )} +
+ {!source.limits && ( +

+ Usage unavailable — token may be invalid or the usage API unreachable. +

+ )} + {source.limits?.buckets.length === 0 && ( +

No usage data

+ )} + {source.limits?.buckets.map((bucket) => ( +
+
+ {bucket.label} + {bucket.utilization}% +
+
+
+
+
+ Resets {formatResetDate(bucket.resetsAt)} +
+
+ ))} + {source.limits?.extraUsage?.isEnabled && ( +
+ Extra usage enabled + {source.limits.extraUsage.usedCredits != null && + source.limits.extraUsage.monthlyLimit != null && ( + + {' '} + — ${source.limits.extraUsage.usedCredits.toFixed(2)} / $ + {source.limits.extraUsage.monthlyLimit.toFixed(2)} + + )} +
+ )} +
+ ); +} diff --git a/web/src/routes/settings/credentials.tsx b/web/src/routes/settings/credentials.tsx index 455c87395..23921b3dd 100644 --- a/web/src/routes/settings/credentials.tsx +++ b/web/src/routes/settings/credentials.tsx @@ -9,10 +9,46 @@ import { createRoute } from '@tanstack/react-router'; import { useMemo, useState } from 'react'; import { buildOrgCredentialCatalog } from '@/components/settings/org-credential-catalog.js'; import { OrgSecretField } from '@/components/settings/org-secret-field.js'; +import { ClaudeUsageCard } from '@/components/shared/claude-usage-card.js'; import { Input } from '@/components/ui/input.js'; import { trpc } from '@/lib/trpc.js'; import { rootRoute } from '../__root.js'; +/** + * Claude Code subscription usage for every token source in the org: the + * shared org credential, per-project overrides, and the server env token. + * Hidden entirely when no source is configured. + */ +function ClaudeCodeUsageSection() { + const limitsQuery = useQuery({ + ...trpc.claudeCodeLimits.forOrg.queryOptions(), + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const sources = limitsQuery.data ?? []; + if (limitsQuery.isError || sources.length === 0) return null; + + return ( +
+
+

Claude Code Usage

+

+ Subscription limits for each configured Claude Code OAuth token. +

+
+
+ {sources.map((source) => ( + + ))} +
+
+ ); +} + const ENV_VAR_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/; function CustomCredentialSection({ @@ -128,6 +164,8 @@ function OrgCredentialsPage() { {!credentialsQuery.isError && ( )} + + {!credentialsQuery.isError && }
); } From fb628fa4d257582fe2b929838bfe2643ea143fae Mon Sep 17 00:00:00 2001 From: Wojtek Siudzinski Date: Sun, 2 Aug 2026 17:28:40 +0200 Subject: [PATCH 10/10] fix(credentials): harden Claude Code limits endpoints per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses four adversarially-verified review findings: - Drop the server env token from both limits procedures. It was doubly wrong: the tRPC handlers run in the dashboard service while workers get the ROUTER service's env (the dashboard's view can be wrong in both directions), and it exposed a host-level operator secret's usage/billing data to tenant org members. Operators who want env-token usage visible should store it as an org credential — the feature's whole point. - Fix the active flag semantics that followed from env removal: project override wins, else org token is active. - Per-row decrypt resilience: listAllClaudeCodeCredentials skips undecryptable rows with a warning instead of 500ing the endpoint; new getProjectOwnCredential reads exactly one project-tier row (no org fallback, null on decrypt failure) instead of decrypting every project credential just to find the token; forOrg treats an undecryptable org token as absent. Co-Authored-By: Claude Fable 5 --- src/api/routers/claudeCodeLimits.ts | 58 +++++++++++------- src/db/repositories/credentialsRepository.ts | 58 ++++++++++++++++-- .../unit/api/routers/claudeCodeLimits.test.ts | 36 +++++------ .../credentialsRepository.test.ts | 61 +++++++++++++++++++ .../components/shared/claude-usage-card.tsx | 3 +- 5 files changed, 164 insertions(+), 52 deletions(-) diff --git a/src/api/routers/claudeCodeLimits.ts b/src/api/routers/claudeCodeLimits.ts index 7f410a080..869d6506e 100644 --- a/src/api/routers/claudeCodeLimits.ts +++ b/src/api/routers/claudeCodeLimits.ts @@ -1,17 +1,26 @@ import { z } from 'zod'; import { fetchClaudeSubscriptionLimits } from '../../anthropic/client.js'; import { + getProjectOwnCredential, listAllClaudeCodeCredentials, - listProjectCredentials, } from '../../db/repositories/credentialsRepository.js'; import { resolveOrgCredential } from '../../db/repositories/orgCredentialsRepository.js'; +import { logger } from '../../utils/logging.js'; import { adminProcedure, protectedProcedure, router } from '../trpc.js'; import { assertOrgAdmin, resolveActorRole } from './_shared/orgRole.js'; import { verifyProjectOrgAccess } from './_shared/projectAccess.js'; const CLAUDE_CODE_TOKEN_KEY = 'CLAUDE_CODE_OAUTH_TOKEN'; -export type ClaudeCodeLimitsScope = 'org' | 'project' | 'env'; +// The server env token (process.env.CLAUDE_CODE_OAUTH_TOKEN) is deliberately +// NOT surfaced here: (a) this tRPC handler runs in the dashboard service while +// workers receive the ROUTER service's env — the dashboard's view of it can be +// wrong in both directions; (b) it is a host-level operator secret, and its +// usage/billing data must not be exposed to tenant org members. Operators who +// want its usage visible should store the token as an org credential instead — +// which is exactly what this feature is for. + +export type ClaudeCodeLimitsScope = 'org' | 'project'; export interface LimitsSource { scope: ClaudeCodeLimitsScope; @@ -20,6 +29,19 @@ export interface LimitsSource { token: string; } +/** Resolve the org token, treating decrypt failures as absent (never 500). */ +async function safeResolveOrgToken(orgId: string): Promise { + try { + return await resolveOrgCredential(orgId, CLAUDE_CODE_TOKEN_KEY); + } catch (err) { + logger.warn('Failed to resolve org CLAUDE_CODE_OAUTH_TOKEN', { + orgId, + error: String(err), + }); + return null; + } +} + /** * Fetch usage limits for each source, preserving source attribution. * Sources whose token yields no limits data (API error, revoked token) come @@ -40,16 +62,16 @@ async function fetchLimitsForSources(sources: T[]) { export const claudeCodeLimitsRouter = router({ /** * Claude Code subscription usage for every credential source in the - * effective org: the org-level shared token, each project-level override, - * and the server's global env token. Org-admin gated — same audience as - * the organization credentials settings page. + * effective org: the org-level shared token and each project-level + * override. Org-admin gated — same audience as the organization + * credentials settings page. */ forOrg: adminProcedure.query(async ({ ctx }) => { assertOrgAdmin(await resolveActorRole(ctx)); const sources: LimitsSource[] = []; - const orgToken = await resolveOrgCredential(ctx.effectiveOrgId, CLAUDE_CODE_TOKEN_KEY); + const orgToken = await safeResolveOrgToken(ctx.effectiveOrgId); if (orgToken) sources.push({ scope: 'org', token: orgToken }); const projectCredentials = await listAllClaudeCodeCredentials(ctx.effectiveOrgId); @@ -62,31 +84,24 @@ export const claudeCodeLimitsRouter = router({ }); } - const envToken = process.env[CLAUDE_CODE_TOKEN_KEY]; - if (envToken) sources.push({ scope: 'env', token: envToken }); - return fetchLimitsForSources(sources); }), /** * Claude Code subscription usage for the credential candidates visible to - * one project: its own override (if set), the inherited org token (if set), - * and the server's global env token. `active` marks the credential-system - * winner (project override beats org; env is informational). Used by the - * project settings engine tab as a picker preview. + * one project: its own override (if set) and the inherited org token (if + * set). `active` marks the credential-system winner (project override + * beats org). Used by the project settings engine tab as a picker preview. */ forProject: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); - // Project override — project rows only, deliberately NOT the inheriting - // resolver: the point is contrasting the override with the org value. - const projectRows = await listProjectCredentials(input.projectId); - const projectToken = projectRows.find((r) => r.envVarKey === CLAUDE_CODE_TOKEN_KEY)?.value; - - const orgToken = await resolveOrgCredential(ctx.effectiveOrgId, CLAUDE_CODE_TOKEN_KEY); - const envToken = process.env[CLAUDE_CODE_TOKEN_KEY]; + // Project tier only, deliberately NOT the inheriting resolver: the + // point is contrasting the override with the org value. + const projectToken = await getProjectOwnCredential(input.projectId, CLAUDE_CODE_TOKEN_KEY); + const orgToken = await safeResolveOrgToken(ctx.effectiveOrgId); const sources: (LimitsSource & { active: boolean })[] = []; if (projectToken) { @@ -100,9 +115,6 @@ export const claudeCodeLimitsRouter = router({ if (orgToken) { sources.push({ scope: 'org', token: orgToken, active: !projectToken }); } - if (envToken) { - sources.push({ scope: 'env', token: envToken, active: false }); - } return fetchLimitsForSources(sources); }), diff --git a/src/db/repositories/credentialsRepository.ts b/src/db/repositories/credentialsRepository.ts index 865b1cbf9..6536e688f 100644 --- a/src/db/repositories/credentialsRepository.ts +++ b/src/db/repositories/credentialsRepository.ts @@ -1,4 +1,5 @@ import { and, eq } from 'drizzle-orm'; +import { logger } from '../../utils/logging.js'; import { getDb } from '../client.js'; import { decryptCredential, encryptCredential } from '../crypto.js'; import { @@ -194,6 +195,10 @@ export async function listProjectCredentialsMeta( * with the owning project's name for display attribution. * Returns decrypted values for use in server-side API calls only. * Never expose raw tokens to the client. + * + * Rows that fail to decrypt (master-key rotation, AAD mismatch) are skipped + * with a warning instead of failing the whole query — one corrupted row must + * not hide usage data for the healthy tokens. */ export async function listAllClaudeCodeCredentials( orgId: string, @@ -212,11 +217,54 @@ export async function listAllClaudeCodeCredentials( and(eq(projects.orgId, orgId), eq(projectCredentials.envVarKey, 'CLAUDE_CODE_OAUTH_TOKEN')), ); - return rows.map((row) => ({ - projectId: row.projectId, - projectName: row.projectName, - value: decryptCredential(row.value, row.projectId), - })); + const result: { projectId: string; projectName: string; value: string }[] = []; + for (const row of rows) { + try { + result.push({ + projectId: row.projectId, + projectName: row.projectName, + value: decryptCredential(row.value, row.projectId), + }); + } catch (err) { + logger.warn('Skipping undecryptable CLAUDE_CODE_OAUTH_TOKEN credential', { + projectId: row.projectId, + error: String(err), + }); + } + } + return result; +} + +/** + * Read a single credential from the project tier ONLY — no org fallback. + * Used where the project-vs-org distinction is the point (e.g. contrasting a + * project override with the inherited org value). Returns null when the row + * is absent or cannot be decrypted. + */ +export async function getProjectOwnCredential( + projectId: string, + envVarKey: string, +): Promise { + const db = getDb(); + + const [row] = await db + .select({ value: projectCredentials.value }) + .from(projectCredentials) + .where( + and(eq(projectCredentials.projectId, projectId), eq(projectCredentials.envVarKey, envVarKey)), + ); + + if (!row) return null; + try { + return decryptCredential(row.value, projectId); + } catch (err) { + logger.warn('Failed to decrypt project credential', { + projectId, + envVarKey, + error: String(err), + }); + return null; + } } // ============================================================================ diff --git a/tests/unit/api/routers/claudeCodeLimits.test.ts b/tests/unit/api/routers/claudeCodeLimits.test.ts index e637ab129..7fcf506d2 100644 --- a/tests/unit/api/routers/claudeCodeLimits.test.ts +++ b/tests/unit/api/routers/claudeCodeLimits.test.ts @@ -4,14 +4,14 @@ import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarne const { mockListAllClaudeCodeCredentials, - mockListProjectCredentials, + mockGetProjectOwnCredential, mockResolveOrgCredential, mockFetchClaudeSubscriptionLimits, mockVerifyProjectOrgAccess, mockGetOrgMembership, } = vi.hoisted(() => ({ mockListAllClaudeCodeCredentials: vi.fn(), - mockListProjectCredentials: vi.fn(), + mockGetProjectOwnCredential: vi.fn(), mockResolveOrgCredential: vi.fn(), mockFetchClaudeSubscriptionLimits: vi.fn(), mockVerifyProjectOrgAccess: vi.fn(), @@ -20,7 +20,7 @@ const { vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ listAllClaudeCodeCredentials: mockListAllClaudeCodeCredentials, - listProjectCredentials: mockListProjectCredentials, + getProjectOwnCredential: mockGetProjectOwnCredential, })); vi.mock('../../../../src/db/repositories/orgCredentialsRepository.js', () => ({ @@ -59,11 +59,10 @@ const sampleLimits = { describe('claudeCodeLimitsRouter', () => { beforeEach(() => { vi.clearAllMocks(); - delete process.env.CLAUDE_CODE_OAUTH_TOKEN; mockGetOrgMembership.mockResolvedValue(null); mockResolveOrgCredential.mockResolvedValue(null); mockListAllClaudeCodeCredentials.mockResolvedValue([]); - mockListProjectCredentials.mockResolvedValue([]); + mockGetProjectOwnCredential.mockResolvedValue(null); mockVerifyProjectOrgAccess.mockResolvedValue(undefined); }); @@ -84,12 +83,11 @@ describe('claudeCodeLimitsRouter', () => { expect(await caller.forOrg()).toEqual([]); }); - it('labels org, project, and env sources with attribution', async () => { + it('labels org and project sources with attribution', async () => { mockResolveOrgCredential.mockResolvedValue('org-token'); mockListAllClaudeCodeCredentials.mockResolvedValue([ { projectId: 'proj-1', projectName: 'Project One', value: 'proj-token' }, ]); - process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-token'; mockFetchClaudeSubscriptionLimits.mockResolvedValue(sampleLimits); const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); @@ -103,11 +101,9 @@ describe('claudeCodeLimitsRouter', () => { projectName: 'Project One', limits: sampleLimits, }, - { scope: 'env', limits: sampleLimits }, ]); expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('org-token'); expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('proj-token'); - expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('env-token'); }); it('keeps a failed source with limits: null instead of dropping it', async () => { @@ -120,6 +116,13 @@ describe('claudeCodeLimitsRouter', () => { expect(result).toEqual([{ scope: 'org', limits: null }]); }); + it('treats an undecryptable org token as absent instead of erroring', async () => { + mockResolveOrgCredential.mockRejectedValue(new Error('decrypt failed')); + + const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); + expect(await caller.forOrg()).toEqual([]); + }); + it('scopes lookups to the effective org', async () => { const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); await caller.forOrg(); @@ -139,9 +142,7 @@ describe('claudeCodeLimitsRouter', () => { }); it('marks the project override active over the org token', async () => { - mockListProjectCredentials.mockResolvedValue([ - { envVarKey: 'CLAUDE_CODE_OAUTH_TOKEN', value: 'proj-token', name: null }, - ]); + mockGetProjectOwnCredential.mockResolvedValue('proj-token'); mockResolveOrgCredential.mockResolvedValue('org-token'); mockFetchClaudeSubscriptionLimits.mockResolvedValue(sampleLimits); @@ -152,6 +153,7 @@ describe('claudeCodeLimitsRouter', () => { { scope: 'project', projectId: 'p1', active: true, limits: sampleLimits }, { scope: 'org', active: false, limits: sampleLimits }, ]); + expect(mockGetProjectOwnCredential).toHaveBeenCalledWith('p1', 'CLAUDE_CODE_OAUTH_TOKEN'); }); it('marks the org token active when no project override exists', async () => { @@ -164,16 +166,6 @@ describe('claudeCodeLimitsRouter', () => { expect(result).toEqual([{ scope: 'org', active: true, limits: sampleLimits }]); }); - it('includes the env token as an inactive informational source', async () => { - process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-token'; - mockFetchClaudeSubscriptionLimits.mockResolvedValue(sampleLimits); - - const caller = createCaller({ user: member, effectiveOrgId: member.orgId }); - const result = await caller.forProject({ projectId: 'p1' }); - - expect(result).toEqual([{ scope: 'env', active: false, limits: sampleLimits }]); - }); - it('returns empty array when nothing is configured', async () => { const caller = createCaller({ user: member, effectiveOrgId: member.orgId }); expect(await caller.forProject({ projectId: 'p1' })).toEqual([]); diff --git a/tests/unit/db/repositories/credentialsRepository.test.ts b/tests/unit/db/repositories/credentialsRepository.test.ts index 60c97c04d..7b4d33e2c 100644 --- a/tests/unit/db/repositories/credentialsRepository.test.ts +++ b/tests/unit/db/repositories/credentialsRepository.test.ts @@ -7,6 +7,8 @@ vi.mock('../../../../src/db/client.js', () => mockDbClientModule); import { getIntegrationProvider, + getProjectOwnCredential, + listAllClaudeCodeCredentials, listProjectCredentialsMeta, resolveAllProjectCredentials, resolveProjectCredential, @@ -193,6 +195,65 @@ describe('credentialsRepository', () => { }); }); + describe('getProjectOwnCredential', () => { + it('returns the project-tier value without org fallback', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ value: 'proj-token' }]); + + const result = await getProjectOwnCredential('proj1', 'CLAUDE_CODE_OAUTH_TOKEN'); + expect(result).toBe('proj-token'); + // Single query — never touches the org tier + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + + it('returns null when the project row is absent (no org fallback)', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getProjectOwnCredential('proj1', 'CLAUDE_CODE_OAUTH_TOKEN'); + expect(result).toBeNull(); + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + + it('returns null instead of throwing when decryption fails', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + // Encrypted with a different AAD — decryption with proj1 fails + const foreign = encryptCredential('secret', 'other-project'); + mockDb.chain.where.mockResolvedValueOnce([{ value: foreign }]); + + const result = await getProjectOwnCredential('proj1', 'CLAUDE_CODE_OAUTH_TOKEN'); + expect(result).toBeNull(); + }); + }); + + describe('listAllClaudeCodeCredentials', () => { + it('skips undecryptable rows instead of failing the whole query', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + mockDb.chain.where.mockResolvedValueOnce([ + { + projectId: 'proj-good', + projectName: 'Good', + value: encryptCredential('good-token', 'proj-good'), + }, + { + projectId: 'proj-bad', + projectName: 'Bad', + // Encrypted with the wrong AAD — decryption throws for proj-bad + value: encryptCredential('bad-token', 'some-other-project'), + }, + ]); + + const result = await listAllClaudeCodeCredentials('org-1'); + expect(result).toEqual([ + { projectId: 'proj-good', projectName: 'Good', value: 'good-token' }, + ]); + }); + }); + describe('getIntegrationProvider', () => { it('returns provider when integration is found', async () => { mockDb.chain.where.mockResolvedValueOnce([{ provider: 'trello' }]); diff --git a/web/src/components/shared/claude-usage-card.tsx b/web/src/components/shared/claude-usage-card.tsx index e39240acc..d7f033db8 100644 --- a/web/src/components/shared/claude-usage-card.tsx +++ b/web/src/components/shared/claude-usage-card.tsx @@ -24,7 +24,7 @@ export interface ClaudeUsageLimits { } export interface ClaudeUsageSource { - scope: 'org' | 'project' | 'env'; + scope: 'org' | 'project'; projectId?: string; projectName?: string; active?: boolean; @@ -54,7 +54,6 @@ function utilizationColor(pct: number): string { export function sourceLabel(source: ClaudeUsageSource): string { if (source.scope === 'org') return 'Organization'; - if (source.scope === 'env') return 'Server environment'; return source.projectName ? `Project: ${source.projectName}` : 'This project'; }