From b09614fd68bdfc0c9a077d3b9ffa480fa833a557 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 16:21:38 +0300 Subject: [PATCH] refactor(quota): secure managed provider credentials (#2160) - add shared owner-only credential storage for OpenCode Go, Ollama Cloud, and Cursor - validate credentials before atomic writes using 0700 directories and 0600 files - replace provider-specific credential routes with an allowlisted lifecycle API - stop automatically reading Ollama's legacy cookie file - stop reading or modifying Cursor's database during regular quota requests - add explicit one-time Cursor credential import without mutating Cursor storage - persist refreshed Cursor credentials only in OpenChamber-managed storage - add Ollama Cloud and Cursor credential controls to provider settings - preserve OpenCode Go tracking through the shared credential flow - add VS Code credential management and Cursor quota parity - reject authentication redirects, enforce request timeouts, and fail on unparseable usage pages - mask stored secrets in API responses and extend quota security coverage - update quota provider documentation --- .../providers/OpenCodeGoCredentials.tsx | 110 ------------------ .../sections/providers/ProvidersPage.tsx | 6 +- .../sections/providers/QuotaCredentials.tsx | 51 ++++++++ packages/vscode/src/bridge-system-runtime.ts | 32 +++-- packages/vscode/src/opencodeGoQuota.ts | 52 +-------- packages/vscode/src/quotaCredentials.ts | 75 ++++++++++++ packages/vscode/src/quotaProviders.ts | 43 +++---- packages/vscode/webview/main.tsx | 7 +- .../web/server/lib/quota/DOCUMENTATION.md | 6 +- .../server/lib/quota/credentials/providers.js | 43 +++++++ .../web/server/lib/quota/credentials/store.js | 48 ++++++++ .../lib/quota/credentials/store.test.js | 26 +++++ .../lib/quota/opencode-go-credentials.js | 61 +--------- .../lib/quota/opencode-go-credentials.test.js | 2 +- .../web/server/lib/quota/providers/cursor.js | 67 +++++------ .../lib/quota/providers/ollama-cloud.js | 65 ++++------- .../lib/quota/providers/ollama-cloud.test.js | 12 ++ .../server/lib/quota/providers/opencode-go.js | 3 +- packages/web/server/lib/quota/routes.js | 88 +++++++++----- packages/web/server/lib/quota/routes.test.js | 2 +- 20 files changed, 431 insertions(+), 368 deletions(-) delete mode 100644 packages/ui/src/components/sections/providers/OpenCodeGoCredentials.tsx create mode 100644 packages/ui/src/components/sections/providers/QuotaCredentials.tsx create mode 100644 packages/vscode/src/quotaCredentials.ts create mode 100644 packages/web/server/lib/quota/credentials/providers.js create mode 100644 packages/web/server/lib/quota/credentials/store.js create mode 100644 packages/web/server/lib/quota/credentials/store.test.js create mode 100644 packages/web/server/lib/quota/providers/ollama-cloud.test.js diff --git a/packages/ui/src/components/sections/providers/OpenCodeGoCredentials.tsx b/packages/ui/src/components/sections/providers/OpenCodeGoCredentials.tsx deleted file mode 100644 index 74052e85..00000000 --- a/packages/ui/src/components/sections/providers/OpenCodeGoCredentials.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { toast } from '@/components/ui'; -import { useI18n } from '@/lib/i18n'; -import { runtimeFetch } from '@/lib/runtime-fetch'; - -interface CredentialStatus { - configured: boolean; - workspaceId?: string; - authCookieMasked?: string; -} - -export const OpenCodeGoCredentials: React.FC = () => { - const { t } = useI18n(); - const [status, setStatus] = React.useState(null); - const [workspaceId, setWorkspaceId] = React.useState(''); - const [authCookie, setAuthCookie] = React.useState(''); - const [busy, setBusy] = React.useState<'save' | 'validate' | 'delete' | null>(null); - const workspaceInputRef = React.useRef(null); - const cookieInputRef = React.useRef(null); - - React.useEffect(() => { - const loadStatus = async () => { - try { - const response = await runtimeFetch('/api/quota/credentials/opencode-go'); - if (!response.ok) throw new Error('load failed'); - const next = await response.json() as CredentialStatus; - setStatus(next); - setWorkspaceId(next.workspaceId ?? ''); - } catch { - setStatus({ configured: false }); - } - }; - void loadStatus(); - }, []); - - const save = async () => { - const submittedWorkspaceId = workspaceInputRef.current?.value.trim() ?? workspaceId.trim(); - const submittedAuthCookie = cookieInputRef.current?.value.trim() ?? authCookie.trim(); - setBusy('save'); - try { - const response = await runtimeFetch('/api/quota/credentials/opencode-go', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId: submittedWorkspaceId, authCookie: submittedAuthCookie }), - }); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(payload?.error); - setStatus(payload); - setAuthCookie(''); - toast.success(t('settings.providers.page.openCodeGo.saved')); - } catch (error) { - const message = error instanceof Error ? error.message : ''; - toast.error(message || t('settings.providers.page.openCodeGo.saveFailed')); - } finally { setBusy(null); } - }; - - const validate = async () => { - setBusy('validate'); - try { - const response = await runtimeFetch('/api/quota/credentials/opencode-go/validate', { method: 'POST' }); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(payload?.error || t('settings.providers.page.openCodeGo.invalid')); - toast.success(t('settings.providers.page.openCodeGo.valid')); - } catch (error) { - const message = error instanceof Error ? error.message : ''; - toast.error(message || t('settings.providers.page.openCodeGo.invalid')); - } finally { setBusy(null); } - }; - - const remove = async () => { - setBusy('delete'); - try { - const response = await runtimeFetch('/api/quota/credentials/opencode-go', { method: 'DELETE' }); - if (!response.ok) throw new Error('delete failed'); - setStatus({ configured: false }); - setWorkspaceId(''); - setAuthCookie(''); - toast.success(t('settings.providers.page.openCodeGo.deleted')); - } catch { - toast.error(t('settings.providers.page.openCodeGo.deleteFailed')); - } finally { setBusy(null); } - }; - - return ( -
-
-

{t('settings.providers.page.openCodeGo.title')}

-

{t('settings.providers.page.openCodeGo.description')}

-
-
- - -

{t('settings.providers.page.openCodeGo.help')}

-
- - {status?.configured && } - {status?.configured && } -
-
-
- ); -}; diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index bade92b8..a1d2f156 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -24,7 +24,7 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { opencodeClient } from '@/lib/opencode/client'; import { shouldLoadAvailableProviders } from './providerAvailability'; -import { OpenCodeGoCredentials } from './OpenCodeGoCredentials'; +import { QuotaCredentials } from './QuotaCredentials'; const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), { notation: 'compact', @@ -947,7 +947,9 @@ export const ProvidersPage: React.FC = () => { - {(selectedProvider.id === 'opencode' || selectedProvider.id === 'opencode-go') && } + {(selectedProvider.id === 'opencode' || selectedProvider.id === 'opencode-go') && } + {(selectedProvider.id === 'ollama' || selectedProvider.id === 'ollama-cloud') && } + {selectedProvider.id === 'cursor' && } {/* Connection Details */}
diff --git a/packages/ui/src/components/sections/providers/QuotaCredentials.tsx b/packages/ui/src/components/sections/providers/QuotaCredentials.tsx new file mode 100644 index 00000000..948abcf6 --- /dev/null +++ b/packages/ui/src/components/sections/providers/QuotaCredentials.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { toast } from '@/components/ui'; +import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; + +type ProviderId = 'opencode-go' | 'ollama-cloud' | 'cursor'; +type Status = { configured: boolean; workspaceId?: string; secretMasked?: string }; + +export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: string }> = ({ providerId, providerName }) => { + const { t } = useI18n(); + const [status, setStatus] = React.useState(null); + const [values, setValues] = React.useState>({}); + const [busy, setBusy] = React.useState(false); + const route = `/api/quota/credentials/${providerId}`; + React.useEffect(() => { void runtimeFetch(route).then(async (response) => { + if (!response.ok) throw new Error(); + const next = await response.json() as Status; + setStatus(next); setValues(next.workspaceId ? { workspaceId: next.workspaceId } : {}); + }).catch(() => setStatus({ configured: false })); }, [route]); + const request = async (path: string, method: string, body?: object) => { + setBusy(true); + try { + const response = await runtimeFetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payload?.error); + if (payload?.configured !== undefined) setStatus(payload); + setValues((current) => current.workspaceId ? { workspaceId: current.workspaceId } : {} as Record); + toast.success(t('settings.providers.page.openCodeGo.saved')); + } catch (error) { toast.error(error instanceof Error && error.message ? error.message : t('settings.providers.page.openCodeGo.saveFailed')); } + finally { setBusy(false); } + }; + const field = (name: string, label: string, placeholder: string) => ; + return
+

{providerName}

+
+ {providerId === 'opencode-go' && field('workspaceId', t('settings.providers.page.openCodeGo.workspaceId'), 'wrk_...')} + {providerId === 'opencode-go' && field('authCookie', t('settings.providers.page.openCodeGo.authCookie'), 'auth=...')} + {providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')} + {providerId === 'cursor' && field('accessToken', t('settings.providers.page.auth.apiKeyLabel'), t('settings.providers.page.auth.apiKeyPlaceholder'))} + {providerId === 'cursor' && field('refreshToken', t('settings.providers.page.auth.apiKeyLabel'), t('settings.providers.page.auth.apiKeyPlaceholder'))} +
+ + {status?.configured && } + {providerId === 'cursor' && } + {status?.configured && } +
+
+
; +}; diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index bb928bd5..c1ea3e0f 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -6,7 +6,8 @@ import { randomUUID } from 'crypto'; import { removeProviderConfig, getProviderSources } from './opencodeConfig'; import { getProviderAuth, removeProviderAuth } from './opencodeAuth'; import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders'; -import { deleteOpenCodeGoCredential, fetchOpenCodeGoUsage, getOpenCodeGoCredentialStatus, normalizeOpenCodeGoCredential, readOpenCodeGoCredential, writeOpenCodeGoCredential } from './opencodeGoQuota'; +import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; +import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials'; import { getSessionActivitySnapshot } from './sessionActivityWatcher'; import type { BridgeContext, BridgeResponse } from './bridge'; @@ -482,21 +483,30 @@ export async function handleSystemBridgeMessage( } } - case 'api:quota:opencode-go-credentials': { - const { method, credential: input } = (payload || {}) as { method?: string; credential?: unknown }; + case 'api:quota:credentials': { + const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown }; try { - if (method === 'GET') return { id, type, success: true, data: getOpenCodeGoCredentialStatus() }; - if (method === 'DELETE') { deleteOpenCodeGoCredential(); return { id, type, success: true, data: { configured: false } }; } + if (!providerId || !['opencode-go', 'ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' }; + if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) }; + if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; } + if (method === 'IMPORT') { + if (providerId !== 'cursor') return { id, type, success: false, error: 'Import unavailable' }; + const credential = importCursorCredential(); + await validateCredential(providerId, credential); + return { id, type, success: true, data: writeCredential(providerId, credential) }; + } if (method === 'PUT') { - const credential = normalizeOpenCodeGoCredential(input); - if (!credential) return { id, type, success: false, error: 'Workspace ID and auth cookie are required' }; - await fetchOpenCodeGoUsage(credential); - return { id, type, success: true, data: writeOpenCodeGoCredential(credential) }; + const credential = normalizeCredential(providerId, input); + if (!credential) return { id, type, success: false, error: 'Invalid credential' }; + if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string }); + else await validateCredential(providerId, credential); + return { id, type, success: true, data: writeCredential(providerId, credential) }; } if (method === 'VALIDATE') { - const credential = readOpenCodeGoCredential(); + const credential = readCredential(providerId); if (!credential) return { id, type, success: false, error: 'Not configured' }; - await fetchOpenCodeGoUsage(credential); + if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string }); + else await validateCredential(providerId, credential); return { id, type, success: true, data: { valid: true } }; } return { id, type, success: false, error: 'Unsupported method' }; diff --git a/packages/vscode/src/opencodeGoQuota.ts b/packages/vscode/src/opencodeGoQuota.ts index 5e329050..6102ae0c 100644 --- a/packages/vscode/src/opencodeGoQuota.ts +++ b/packages/vscode/src/opencodeGoQuota.ts @@ -1,53 +1,5 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - type OpenCodeGoCredential = { workspaceId: string; authCookie: string }; -const targetPath = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota', 'opencode-go.json'); - -export const normalizeOpenCodeGoCredential = (value: unknown): OpenCodeGoCredential | null => { - if (!value || typeof value !== 'object') return null; - const data = value as Record; - const workspaceId = typeof data.workspaceId === 'string' ? data.workspaceId.trim() : ''; - let authCookie = typeof data.authCookie === 'string' ? data.authCookie.trim() : ''; - if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim(); - return workspaceId && authCookie && !/[\r\n]/.test(workspaceId + authCookie) ? { workspaceId, authCookie } : null; -}; - -export const readOpenCodeGoCredential = (): OpenCodeGoCredential | null => { - try { - return normalizeOpenCodeGoCredential(JSON.parse(fs.readFileSync(targetPath(), 'utf8'))); - } catch (error) { - if ((error as { code?: string }).code !== 'ENOENT') { - console.warn('Failed to read OpenCode Go credentials'); - } - return null; - } -}; - -export const getOpenCodeGoCredentialStatus = () => { - const value = readOpenCodeGoCredential(); - return value ? { configured: true, workspaceId: value.workspaceId, authCookieMasked: '••••••••' } : { configured: false }; -}; - -export const writeOpenCodeGoCredential = (value: OpenCodeGoCredential) => { - const target = targetPath(); - const temporary = `${target}.${process.pid}.${Date.now()}.tmp`; - fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); - try { - fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); - fs.chmodSync(temporary, 0o600); - fs.renameSync(temporary, target); - fs.chmodSync(target, 0o600); - } finally { - if (fs.existsSync(temporary)) fs.unlinkSync(temporary); - } - return getOpenCodeGoCredentialStatus(); -}; - -export const deleteOpenCodeGoCredential = () => { try { fs.unlinkSync(targetPath()); } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error; } }; - const toWindow = (usedPercent: number, resetInSec: number) => ({ usedPercent: Math.min(100, Math.max(0, usedPercent)), remainingPercent: 100 - Math.min(100, Math.max(0, usedPercent)), @@ -59,8 +11,8 @@ const toWindow = (usedPercent: number, resetInSec: number) => ({ }); export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => { - const response = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(credential.workspaceId)}/go`, { headers: { Accept: 'text/html', Cookie: `auth=${credential.authCookie}` }, signal: AbortSignal.timeout(15_000) }); - if (response.status === 401 || response.status === 403 || (response.redirected && /\/auth(?:\/|$|\?)/.test(new URL(response.url).pathname))) throw new Error('OpenCode Go authentication failed'); + const response = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(credential.workspaceId)}/go`, { headers: { Accept: 'text/html', Cookie: `auth=${credential.authCookie}` }, redirect: 'manual', signal: AbortSignal.timeout(15_000) }); + if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed'); if (!response.ok) throw new Error(`OpenCode Go dashboard returned HTTP ${response.status}`); const html = (await response.text()).replaceAll('"', '"').replaceAll('"', '"').replaceAll('\\u0022', '"').replaceAll('\\"', '"'); const windows: Record> = {}; diff --git a/packages/vscode/src/quotaCredentials.ts b/packages/vscode/src/quotaCredentials.ts new file mode 100644 index 00000000..10ba6631 --- /dev/null +++ b/packages/vscode/src/quotaCredentials.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +export type ManagedProvider = 'opencode-go' | 'ollama-cloud' | 'cursor'; +export type ManagedCredential = Record; +const providers = new Set(['opencode-go', 'ollama-cloud', 'cursor']); +const directory = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota'); +const target = (provider: ManagedProvider) => { + if (!providers.has(provider)) throw new Error('Unsupported credential provider'); + return path.join(directory(), `${provider}.json`); +}; +const clean = (value: unknown) => typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : ''; + +export const normalizeCredential = (provider: ManagedProvider, value: unknown): ManagedCredential | null => { + const data = value && typeof value === 'object' ? value as Record : {}; + if (provider === 'opencode-go') { + const workspaceId = clean(data.workspaceId); + let authCookie = clean(data.authCookie); + if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim(); + return workspaceId && authCookie ? { workspaceId, authCookie } : null; + } + if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null; + const accessToken = clean(data.accessToken); + const refreshToken = clean(data.refreshToken); + return accessToken || refreshToken ? { accessToken, refreshToken } : null; +}; + +export const readCredential = (provider: ManagedProvider) => { + try { return normalizeCredential(provider, JSON.parse(fs.readFileSync(target(provider), 'utf8'))); } + catch (error) { if ((error as { code?: string }).code !== 'ENOENT') console.warn(`Failed to read ${provider} quota credentials`); return null; } +}; +export const credentialStatus = (provider: ManagedProvider) => { + const value = readCredential(provider); + if (!value) return { configured: false }; + return { configured: true, ...(provider === 'opencode-go' ? { workspaceId: value.workspaceId } : {}), ...(provider === 'cursor' ? { hasRefreshToken: Boolean(value.refreshToken) } : {}), secretMasked: '••••••••' }; +}; +export const writeCredential = (provider: ManagedProvider, value: ManagedCredential) => { + const dir = directory(); const file = target(provider); const temp = `${file}.${process.pid}.${Date.now()}.tmp`; + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.chmodSync(dir, 0o700); + try { fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); fs.renameSync(temp, file); fs.chmodSync(file, 0o600); } + finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); } + return credentialStatus(provider); +}; +export const deleteCredential = (provider: ManagedProvider) => { try { fs.unlinkSync(target(provider)); } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error; } }; + +export const importCursorCredential = () => { + const db = path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + if (process.platform !== 'darwin' || !fs.existsSync(db)) throw new Error('Cursor credential import is unavailable'); + const rows = JSON.parse(execFileSync('sqlite3', ['-json', db, "SELECT key,value FROM ItemTable WHERE key IN ('cursorAuth/accessToken','cursorAuth/refreshToken');"], { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }) || '[]') as Array<{ key: string; value: string }>; + const credential = normalizeCredential('cursor', { accessToken: rows.find((row) => row.key.endsWith('accessToken'))?.value, refreshToken: rows.find((row) => row.key.endsWith('refreshToken'))?.value }); + if (!credential) throw new Error('Cursor credentials are unavailable'); + return credential; +}; + +export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => { + if (provider === 'ollama-cloud') { + const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) }); + if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed'); + const html = await response.text(); + if (!/Session\s+usage|Weekly\s+usage|Premium[^0-9]*[0-9]+\s*\/\s*[0-9]+/i.test(html)) throw new Error('Ollama Cloud usage data could not be parsed'); + } + if (provider === 'cursor') { + if (!credential.accessToken && credential.refreshToken) { + const refresh = await fetch('https://api2.cursor.sh/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', client_id: 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB', refresh_token: credential.refreshToken }), signal: AbortSignal.timeout(15_000) }); + const payload = await refresh.json().catch(() => null) as { access_token?: string } | null; + if (!refresh.ok || !payload?.access_token) throw new Error('Cursor authentication failed'); + credential.accessToken = payload.access_token; + } + if (!credential.accessToken) throw new Error('Cursor access token is required'); + const response = await fetch('https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage', { method: 'POST', headers: { Authorization: `Bearer ${credential.accessToken}`, 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' }, body: '{}', signal: AbortSignal.timeout(15_000) }); + if (!response.ok) throw new Error('Cursor authentication failed'); + } +}; diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 76331f20..6e5231de 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -1,7 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { fetchOpenCodeGoUsage, readOpenCodeGoCredential } from './opencodeGoQuota'; +import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; +import { readCredential } from './quotaCredentials'; type AuthEntry = Record | string; type AuthFile = Record; @@ -124,7 +125,6 @@ export type ProviderResult = { const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode'); const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json'); -const OLLAMA_CLOUD_COOKIE_PATH = path.join(os.homedir(), '.config', 'ollama-quota', 'cookie'); const ANTIGRAVITY_ACCOUNTS_PATHS = [ @@ -220,19 +220,6 @@ const readJsonFile = (filePath: string): Record | null => { } }; -const readTextFile = (filePath: string): string | null => { - if (!fs.existsSync(filePath)) { - return null; - } - try { - const content = fs.readFileSync(filePath, 'utf8').trim(); - return content || null; - } catch (error) { - console.warn(`Failed to read text file: ${filePath}`, error); - return null; - } -}; - const getAuthEntry = (auth: AuthFile, aliases: string[]) => { for (const alias of aliases) { if (auth[alias]) { @@ -389,7 +376,9 @@ const durationToSeconds = (duration?: number, unit?: string) => { export const listConfiguredQuotaProviders = () => { const auth = readAuthFile(); const configured = new Set(); - if (readOpenCodeGoCredential()) configured.add('opencode-go'); + if (readCredential('opencode-go')) configured.add('opencode-go'); + if (readCredential('ollama-cloud')) configured.add('ollama-cloud'); + if (readCredential('cursor')) configured.add('cursor'); const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])); if (anthropicAuth && ((anthropicAuth as Record).access || (anthropicAuth as Record).token)) { @@ -446,9 +435,6 @@ export const listConfiguredQuotaProviders = () => { configured.add('github-copilot-addon'); } - if (readTextFile(OLLAMA_CLOUD_COOKIE_PATH)) { - configured.add('ollama-cloud'); - } const waferAuth = normalizeAuthEntry(getAuthEntry(auth, ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai'])); if (waferAuth && ((waferAuth as Record).key || (waferAuth as Record).token)) { @@ -1346,7 +1332,7 @@ const parseOllamaSettingsHtml = (html: string) => { }; const fetchOllamaCloudQuota = async (): Promise => { - const cookie = readTextFile(OLLAMA_CLOUD_COOKIE_PATH); + const cookie = readCredential('ollama-cloud')?.cookie; if (!cookie) { return buildResult({ @@ -1395,6 +1381,19 @@ const fetchOllamaCloudQuota = async (): Promise => { } }; +const fetchCursorQuota = async (): Promise => { + const accessToken = readCredential('cursor')?.accessToken; + if (!accessToken) return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: false, error: 'Not configured' }); + try { + const response = await fetch('https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' }, body: '{}', signal: AbortSignal.timeout(15_000) }); + if (!response.ok) throw new Error(response.status === 401 ? 'Cursor session expired' : `API error: ${response.status}`); + const payload = await response.json() as Record; + const plan = (payload.planUsage as Record | undefined) ?? {}; + const usedPercent = toNumber(plan.totalPercentUsed); + return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: true, configured: true, usage: { windows: { billing_cycle: toUsageWindow({ usedPercent, windowSeconds: null, resetAt: toTimestamp(payload.billingCycleEnd) }) } } }); + } catch (error) { return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); } +}; + const fetchOpenRouterQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record | null; @@ -1895,7 +1894,7 @@ export const fetchQuotaForProvider = async (providerId: string): Promise typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : ''; + +export const normalizers = { + 'opencode-go': (value) => { + const workspaceId = clean(value?.workspaceId); + let authCookie = clean(value?.authCookie); + if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim(); + return workspaceId && authCookie ? { workspaceId, authCookie } : null; + }, + 'ollama-cloud': (value) => { + const cookie = clean(value?.cookie); + return cookie ? { cookie } : null; + }, + cursor: (value) => { + const accessToken = clean(value?.accessToken); + const refreshToken = clean(value?.refreshToken); + return accessToken || refreshToken ? { accessToken, refreshToken } : null; + }, +}; + +export const readManagedCredential = (providerId) => { + const normalize = normalizers[providerId]; + return normalize ? readQuotaCredential(providerId, normalize) : null; +}; + +export const writeManagedCredential = (providerId, value) => { + const credential = normalizers[providerId]?.(value); + if (!credential) throw new Error('Invalid credential'); + writeQuotaCredential(providerId, credential); + return getManagedCredentialStatus(providerId); +}; + +export const getManagedCredentialStatus = (providerId) => { + const credential = readManagedCredential(providerId); + if (!credential) return { configured: false }; + if (providerId === 'opencode-go') return { configured: true, workspaceId: credential.workspaceId, secretMasked: '••••••••' }; + if (providerId === 'cursor') return { configured: true, hasRefreshToken: Boolean(credential.refreshToken), secretMasked: '••••••••' }; + return { configured: true, secretMasked: '••••••••' }; +}; + +export const deleteManagedCredential = (providerId) => deleteQuotaCredential(providerId); diff --git a/packages/web/server/lib/quota/credentials/store.js b/packages/web/server/lib/quota/credentials/store.js new file mode 100644 index 00000000..d8d30b29 --- /dev/null +++ b/packages/web/server/lib/quota/credentials/store.js @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const MANAGED_QUOTA_PROVIDERS = new Set(['opencode-go', 'ollama-cloud', 'cursor']); + +const credentialsDirectory = () => path.join( + process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'), + 'quota', +); + +const credentialPath = (providerId) => { + if (!MANAGED_QUOTA_PROVIDERS.has(providerId)) throw new Error('Unsupported credential provider'); + return path.join(credentialsDirectory(), `${providerId}.json`); +}; + +export const readQuotaCredential = (providerId, normalize) => { + try { + return normalize(JSON.parse(fs.readFileSync(credentialPath(providerId), 'utf8'))); + } catch (error) { + if (error?.code !== 'ENOENT') console.warn(`Failed to read ${providerId} quota credentials`); + return null; + } +}; + +export const writeQuotaCredential = (providerId, credential) => { + const target = credentialPath(providerId); + const directory = path.dirname(target); + const temporary = `${target}.${process.pid}.${Date.now()}.tmp`; + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.chmodSync(directory, 0o700); + try { + fs.writeFileSync(temporary, `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 }); + fs.chmodSync(temporary, 0o600); + fs.renameSync(temporary, target); + fs.chmodSync(target, 0o600); + } finally { + try { fs.unlinkSync(temporary); } catch {} + } +}; + +export const deleteQuotaCredential = (providerId) => { + try { fs.unlinkSync(credentialPath(providerId)); } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +}; diff --git a/packages/web/server/lib/quota/credentials/store.test.js b/packages/web/server/lib/quota/credentials/store.test.js new file mode 100644 index 00000000..c40ca8c4 --- /dev/null +++ b/packages/web/server/lib/quota/credentials/store.test.js @@ -0,0 +1,26 @@ +import { afterAll, describe, expect, it } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from './store.js'; + +const previousDataDir = process.env.OPENCHAMBER_DATA_DIR; +const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-quota-store-')); +process.env.OPENCHAMBER_DATA_DIR = temporaryDirectory; + +describe('quota credential store', () => { + it('uses owner-only permissions and rejects arbitrary provider paths', () => { + writeQuotaCredential('ollama-cloud', { cookie: 'secret' }); + expect(fs.statSync(path.join(temporaryDirectory, 'quota')).mode & 0o777).toBe(0o700); + expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'ollama-cloud.json')).mode & 0o777).toBe(0o600); + expect(readQuotaCredential('ollama-cloud', (value) => value)).toEqual({ cookie: 'secret' }); + expect(() => writeQuotaCredential('../escape', {})).toThrow('Unsupported credential provider'); + deleteQuotaCredential('ollama-cloud'); + }); +}); + +afterAll(() => { + if (previousDataDir === undefined) delete process.env.OPENCHAMBER_DATA_DIR; + else process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); +}); diff --git a/packages/web/server/lib/quota/opencode-go-credentials.js b/packages/web/server/lib/quota/opencode-go-credentials.js index d2bf31f6..d2ff5b48 100644 --- a/packages/web/server/lib/quota/opencode-go-credentials.js +++ b/packages/web/server/lib/quota/opencode-go-credentials.js @@ -1,60 +1,11 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; +import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js'; -const credentialsPath = () => path.join( - process.env.OPENCHAMBER_DATA_DIR - ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) - : path.join(os.homedir(), '.config', 'openchamber'), - 'quota', - 'opencode-go.json', -); +export const normalizeOpenCodeGoCredential = normalizers['opencode-go']; -export const normalizeOpenCodeGoCredential = (value) => { - const workspaceId = typeof value?.workspaceId === 'string' ? value.workspaceId.trim() : ''; - let authCookie = typeof value?.authCookie === 'string' ? value.authCookie.trim() : ''; - if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim(); - if (!workspaceId || !authCookie || /[\r\n]/.test(workspaceId) || /[\r\n]/.test(authCookie)) { - return null; - } - return { workspaceId, authCookie }; -}; +export const readOpenCodeGoCredential = () => readManagedCredential('opencode-go'); -export const readOpenCodeGoCredential = () => { - try { - const parsed = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8')); - return normalizeOpenCodeGoCredential(parsed); - } catch (error) { - if (error?.code !== 'ENOENT') console.warn('Failed to read OpenCode Go credentials'); - return null; - } -}; +export const getOpenCodeGoCredentialStatus = () => getManagedCredentialStatus('opencode-go'); -export const getOpenCodeGoCredentialStatus = () => { - const credential = readOpenCodeGoCredential(); - return credential ? { configured: true, workspaceId: credential.workspaceId, authCookieMasked: '••••••••' } : { configured: false }; -}; +export const writeOpenCodeGoCredential = (value) => writeManagedCredential('opencode-go', value); -export const writeOpenCodeGoCredential = (value) => { - const credential = normalizeOpenCodeGoCredential(value); - if (!credential) throw new Error('Workspace ID and auth cookie are required'); - const target = credentialsPath(); - const directory = path.dirname(target); - const temporary = `${target}.${process.pid}.${Date.now()}.tmp`; - fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); - try { - fs.writeFileSync(temporary, `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 }); - fs.chmodSync(temporary, 0o600); - fs.renameSync(temporary, target); - fs.chmodSync(target, 0o600); - } finally { - try { fs.unlinkSync(temporary); } catch {} - } - return getOpenCodeGoCredentialStatus(); -}; - -export const deleteOpenCodeGoCredential = () => { - try { fs.unlinkSync(credentialsPath()); } catch (error) { - if (error?.code !== 'ENOENT') throw error; - } -}; +export const deleteOpenCodeGoCredential = () => deleteManagedCredential('opencode-go'); diff --git a/packages/web/server/lib/quota/opencode-go-credentials.test.js b/packages/web/server/lib/quota/opencode-go-credentials.test.js index 4dadfe06..59a3ae75 100644 --- a/packages/web/server/lib/quota/opencode-go-credentials.test.js +++ b/packages/web/server/lib/quota/opencode-go-credentials.test.js @@ -13,7 +13,7 @@ afterEach(() => deleteOpenCodeGoCredential()); describe('OpenCode Go credential store', () => { it('normalizes, masks, and stores credentials with owner-only permissions', () => { const status = writeOpenCodeGoCredential({ workspaceId: ' wrk_test ', authCookie: ' auth=secret ' }); - expect(status).toEqual({ configured: true, workspaceId: 'wrk_test', authCookieMasked: '••••••••' }); + expect(status).toEqual({ configured: true, workspaceId: 'wrk_test', secretMasked: '••••••••' }); expect(readOpenCodeGoCredential()).toEqual({ workspaceId: 'wrk_test', authCookie: 'secret' }); expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'opencode-go.json')).mode & 0o777).toBe(0o600); }); diff --git a/packages/web/server/lib/quota/providers/cursor.js b/packages/web/server/lib/quota/providers/cursor.js index 2e82bd60..9393844a 100644 --- a/packages/web/server/lib/quota/providers/cursor.js +++ b/packages/web/server/lib/quota/providers/cursor.js @@ -1,7 +1,8 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; import { execFileSync } from 'child_process'; +import { readManagedCredential, writeManagedCredential } from '../credentials/providers.js'; import { buildResult, formatMoney, @@ -54,25 +55,6 @@ const readStateValue = (key) => { } }; -const writeStateValue = (key, value) => { - if (!existsSync(STATE_DB)) return false; - try { - const escaped = String(value).replace(/'/g, "''"); - const escapedKey = String(key).replace(/'/g, "''"); - execFileSync('sqlite3', [ - STATE_DB, - `INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('${escapedKey}', '${escaped}');` - ], { - encoding: 'utf8', - windowsHide: true, - stdio: ['ignore', 'ignore', 'ignore'] - }); - return true; - } catch { - return false; - } -}; - const readFileToken = (path) => { try { if (!path || !existsSync(path)) return null; @@ -83,16 +65,6 @@ const readFileToken = (path) => { } }; -const writeFileToken = (path, value) => { - try { - if (!path) return false; - writeFileSync(path, `${value}\n`, { encoding: 'utf8', mode: 0o600 }); - return true; - } catch { - return false; - } -}; - const loadAuthState = () => { const envAccessToken = process.env.CURSOR_TOKEN || process.env.CURSOR_ACCESS_TOKEN || null; const envRefreshToken = process.env.CURSOR_REFRESH_TOKEN || null; @@ -113,15 +85,15 @@ const loadAuthState = () => { return { accessToken: fileAccessToken, refreshToken: fileRefreshToken, - source: 'file', - accessTokenPath + source: 'file' }; } + const managed = readManagedCredential(providerId); return { - accessToken: readStateValue('cursorAuth/accessToken'), - refreshToken: readStateValue('cursorAuth/refreshToken'), - source: 'sqlite' + accessToken: managed?.accessToken || null, + refreshToken: managed?.refreshToken || null, + source: 'managed' }; }; @@ -133,8 +105,18 @@ const tokenNeedsRefresh = (token) => { }; const persistAccessToken = (auth, accessToken) => { - if (auth.source === 'sqlite') writeStateValue('cursorAuth/accessToken', accessToken); - if (auth.source === 'file') writeFileToken(auth.accessTokenPath, accessToken); + if (auth.source === 'managed') writeManagedCredential(providerId, { accessToken, refreshToken: auth.refreshToken || '' }); +}; + +export const importCursorCredential = async () => { + const credential = { + accessToken: readStateValue('cursorAuth/accessToken') || '', + refreshToken: readStateValue('cursorAuth/refreshToken') || '', + }; + if (!credential.accessToken && !credential.refreshToken) throw new Error('Cursor credentials are unavailable'); + const accessToken = await resolveCredentialAccessToken({ ...credential, source: 'import' }); + if (!accessToken) throw new Error('Cursor credentials are invalid'); + return writeManagedCredential(providerId, { ...credential, accessToken }); }; const refreshAccessToken = async (auth) => { @@ -165,13 +147,20 @@ const refreshAccessToken = async (auth) => { return body.access_token; }; -const resolveAccessToken = async () => { - const auth = loadAuthState(); +const resolveCredentialAccessToken = async (auth) => { if (!auth.accessToken && !auth.refreshToken) return null; if (!tokenNeedsRefresh(auth.accessToken)) return auth.accessToken; return refreshAccessToken(auth); }; +const resolveAccessToken = async () => resolveCredentialAccessToken(loadAuthState()); + +export const validateCursorCredential = async (credential) => { + const accessToken = await resolveCredentialAccessToken({ ...credential, source: 'validation' }); + if (!accessToken) throw new Error('Cursor credentials are invalid'); + await connectPost(USAGE_URL, accessToken); +}; + const connectPost = async (url, accessToken) => { const response = await fetch(url, { method: 'POST', diff --git a/packages/web/server/lib/quota/providers/ollama-cloud.js b/packages/web/server/lib/quota/providers/ollama-cloud.js index 29808ab1..1ea99691 100644 --- a/packages/web/server/lib/quota/providers/ollama-cloud.js +++ b/packages/web/server/lib/quota/providers/ollama-cloud.js @@ -1,26 +1,11 @@ -import { homedir } from 'os'; -import { readFileSync, existsSync } from 'fs'; -import { join } from 'path'; import { buildResult, toUsageWindow, toNumber } from '../utils/index.js'; - -const COOKIE_PATH = join(homedir(), '.config', 'ollama-quota', 'cookie'); +import { readManagedCredential } from '../credentials/providers.js'; export const providerId = 'ollama-cloud'; export const providerName = 'Ollama Cloud'; const aliases = ['ollama-cloud', 'ollamacloud']; -const readCookieFile = () => { - try { - if (!existsSync(COOKIE_PATH)) return null; - const content = readFileSync(COOKIE_PATH, 'utf-8'); - const trimmed = content.trim(); - return trimmed || null; - } catch { - return null; - } -}; - -const parseOllamaSettingsHtml = (html) => { +export const parseOllamaSettingsHtml = (html) => { const windows = {}; const sessionMatch = html.match(/Session\s+usage[^0-9]*([0-9.]+)%/i); if (sessionMatch) { @@ -54,14 +39,29 @@ const parseOllamaSettingsHtml = (html) => { }; export const isConfigured = () => { - const cookie = readCookieFile(); - return Boolean(cookie); + return Boolean(readManagedCredential(providerId)); +}; + +export const fetchOllamaCloudUsage = async (credential, fetchImpl = fetch) => { + const response = await fetchImpl('https://ollama.com/settings', { + method: 'GET', + headers: { Cookie: credential.cookie, 'User-Agent': 'OpenChamber quota provider' }, + redirect: 'manual', + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) { + throw new Error('Ollama Cloud authentication failed'); + } + if (!response.ok) throw new Error(`Ollama Cloud returned HTTP ${response.status}`); + const windows = parseOllamaSettingsHtml(await response.text()); + if (Object.keys(windows).length === 0) throw new Error('Ollama Cloud usage data could not be parsed'); + return windows; }; export const fetchQuota = async () => { - const cookie = readCookieFile(); + const credential = readManagedCredential(providerId); - if (!cookie) { + if (!credential) { return buildResult({ providerId, providerName, @@ -72,26 +72,7 @@ export const fetchQuota = async () => { } try { - const response = await fetch('https://ollama.com/settings', { - method: 'GET', - headers: { - Cookie: cookie, - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' - } - }); - - if (!response.ok) { - return buildResult({ - providerId, - providerName, - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const html = await response.text(); - const windows = parseOllamaSettingsHtml(html); + const windows = await fetchOllamaCloudUsage(credential); return buildResult({ providerId, @@ -109,4 +90,4 @@ export const fetchQuota = async () => { error: error instanceof Error ? error.message : 'Request failed' }); } -}; \ No newline at end of file +}; diff --git a/packages/web/server/lib/quota/providers/ollama-cloud.test.js b/packages/web/server/lib/quota/providers/ollama-cloud.test.js new file mode 100644 index 00000000..8cf5bf7c --- /dev/null +++ b/packages/web/server/lib/quota/providers/ollama-cloud.test.js @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'bun:test'; +import { fetchOllamaCloudUsage } from './ollama-cloud.js'; + +describe('Ollama Cloud quota provider', () => { + it('rejects redirects without forwarding credentials', async () => { + await expect(fetchOllamaCloudUsage({ cookie: 'session=secret' }, async () => new Response('', { status: 302 }))).rejects.toThrow('authentication failed'); + }); + + it('rejects successful pages without usage data', async () => { + await expect(fetchOllamaCloudUsage({ cookie: 'session=secret' }, async () => new Response(''))).rejects.toThrow('could not be parsed'); + }); +}); diff --git a/packages/web/server/lib/quota/providers/opencode-go.js b/packages/web/server/lib/quota/providers/opencode-go.js index aec2a420..0fb6263e 100644 --- a/packages/web/server/lib/quota/providers/opencode-go.js +++ b/packages/web/server/lib/quota/providers/opencode-go.js @@ -44,9 +44,10 @@ export const fetchOpenCodeGoUsage = async (credential, fetchImpl = fetch) => { Cookie: `auth=${credential.authCookie}`, 'User-Agent': 'OpenChamber quota provider', }, + redirect: 'manual', signal: AbortSignal.timeout(15_000), }); - if (response.status === 401 || response.status === 403 || (response.redirected && /\/auth(?:\/|$|\?)/.test(new URL(response.url).pathname))) { + if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) { throw new Error('OpenCode Go authentication failed'); } if (!response.ok) throw new Error(`OpenCode Go dashboard returned HTTP ${response.status}`); diff --git a/packages/web/server/lib/quota/routes.js b/packages/web/server/lib/quota/routes.js index 1526a34c..8e37438e 100644 --- a/packages/web/server/lib/quota/routes.js +++ b/packages/web/server/lib/quota/routes.js @@ -1,65 +1,95 @@ import express from 'express'; -import { - deleteOpenCodeGoCredential, - getOpenCodeGoCredentialStatus, - normalizeOpenCodeGoCredential, - readOpenCodeGoCredential, - writeOpenCodeGoCredential, -} from './opencode-go-credentials.js'; +import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js'; import { fetchOpenCodeGoUsage } from './providers/opencode-go.js'; +import { fetchOllamaCloudUsage } from './providers/ollama-cloud.js'; +import { importCursorCredential, validateCursorCredential } from './providers/cursor.js'; + +const validators = { + 'opencode-go': fetchOpenCodeGoUsage, + 'ollama-cloud': fetchOllamaCloudUsage, + cursor: validateCursorCredential, +}; + +const getProvider = (req, res) => { + const providerId = req.params.providerId; + if (!normalizers[providerId]) { + res.status(404).json({ code: 'UNSUPPORTED_PROVIDER', error: 'Unsupported credential provider' }); + return null; + } + return providerId; +}; + +const credentialError = (res, error) => res.status(400).json({ + code: 'INVALID_CREDENTIAL', + error: error instanceof Error ? error.message : 'Credential validation failed', +}); export function registerQuotaRoutes(app, { getQuotaProviders }) { app.get('/api/quota/providers', async (_req, res) => { try { const { listConfiguredQuotaProviders } = await getQuotaProviders(); - const providers = listConfiguredQuotaProviders(); - res.json({ providers }); + res.json({ providers: listConfiguredQuotaProviders() }); } catch (error) { console.error('Failed to list quota providers:', error); res.status(500).json({ error: error.message || 'Failed to list quota providers' }); } }); - app.get('/api/quota/credentials/opencode-go', (_req, res) => { - res.json(getOpenCodeGoCredentialStatus()); + app.get('/api/quota/credentials/:providerId', (req, res) => { + const providerId = getProvider(req, res); + if (providerId) res.json(getManagedCredentialStatus(providerId)); }); - app.put('/api/quota/credentials/opencode-go', express.json({ limit: '16kb' }), async (req, res) => { + app.put('/api/quota/credentials/:providerId', express.json({ limit: '16kb' }), async (req, res) => { + const providerId = getProvider(req, res); + if (!providerId) return; + const credential = normalizers[providerId](req.body); + if (!credential) return credentialError(res, new Error('Invalid credential')); try { - const credential = normalizeOpenCodeGoCredential(req.body); - if (!credential) return res.status(400).json({ error: 'Workspace ID and auth cookie are required' }); - await fetchOpenCodeGoUsage(credential); - res.json(writeOpenCodeGoCredential(credential)); + await validators[providerId](credential); + res.json(writeManagedCredential(providerId, credential)); } catch (error) { - res.status(400).json({ error: error instanceof Error ? error.message : 'Credential validation failed' }); + credentialError(res, error); } }); - app.post('/api/quota/credentials/opencode-go/validate', async (_req, res) => { + app.post('/api/quota/credentials/:providerId/validate', async (req, res) => { + const providerId = getProvider(req, res); + if (!providerId) return; + const credential = readManagedCredential(providerId); + if (!credential) return res.status(404).json({ code: 'NOT_CONFIGURED', error: 'Not configured' }); try { - const credential = readOpenCodeGoCredential(); - if (!credential) return res.status(404).json({ error: 'Not configured' }); - await fetchOpenCodeGoUsage(credential); + await validators[providerId](credential); res.json({ valid: true }); } catch (error) { - res.status(400).json({ valid: false, error: error instanceof Error ? error.message : 'Credential validation failed' }); + credentialError(res, error); } }); - app.delete('/api/quota/credentials/opencode-go', (_req, res) => { - deleteOpenCodeGoCredential(); + app.post('/api/quota/credentials/:providerId/import', async (req, res) => { + const providerId = getProvider(req, res); + if (!providerId) return; + if (providerId !== 'cursor') return res.status(404).json({ code: 'IMPORT_UNAVAILABLE', error: 'Import unavailable' }); + try { + res.json(await importCursorCredential()); + } catch (error) { + credentialError(res, error); + } + }); + + app.delete('/api/quota/credentials/:providerId', (req, res) => { + const providerId = getProvider(req, res); + if (!providerId) return; + deleteManagedCredential(providerId); res.json({ configured: false }); }); app.get('/api/quota/:providerId', async (req, res) => { try { const { providerId } = req.params; - if (!providerId) { - return res.status(400).json({ error: 'Provider ID is required' }); - } + if (!providerId) return res.status(400).json({ error: 'Provider ID is required' }); const { fetchQuotaForProvider } = await getQuotaProviders(); - const result = await fetchQuotaForProvider(providerId); - res.json(result); + res.json(await fetchQuotaForProvider(providerId)); } catch (error) { console.error('Failed to fetch quota:', error); res.status(500).json({ error: error.message || 'Failed to fetch quota' }); diff --git a/packages/web/server/lib/quota/routes.test.js b/packages/web/server/lib/quota/routes.test.js index f3b34ba0..c46b3f0e 100644 --- a/packages/web/server/lib/quota/routes.test.js +++ b/packages/web/server/lib/quota/routes.test.js @@ -31,7 +31,7 @@ describe('OpenCode Go credential routes', () => { body: JSON.stringify({ workspaceId: 'wrk_test', authCookie: 'auth=secret' }), }); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ configured: true, workspaceId: 'wrk_test', authCookieMasked: '••••••••' }); + expect(await response.json()).toEqual({ configured: true, workspaceId: 'wrk_test', secretMasked: '••••••••' }); } finally { globalThis.fetch = originalFetch; server.close();