/** * Credentials Management * * Handles OAuth token handling and project discovery. */ import { ANTIGRAVITY_DB_PATH, TOKEN_REFRESH_INTERVAL_MS, LOAD_CODE_ASSIST_ENDPOINTS, LOAD_CODE_ASSIST_HEADERS, CLIENT_METADATA, DEFAULT_PROJECT_ID } from '../constants.js'; import { refreshAccessToken, parseRefreshParts, formatRefreshParts } from '../auth/oauth.js'; import { getAuthStatus } from '../auth/database.js'; import { logger } from '../utils/logger.js'; import { isNetworkError, throttledFetch } from '../utils/helpers.js'; import { onboardUser, getDefaultTierId } from './onboarding.js'; import { parseTierId } from '../cloudcode/model-api.js'; // Track accounts currently fetching subscription to avoid duplicate calls const subscriptionFetchInProgress = new Set(); /** * Fetch subscription tier and save it (blocking) * Used when we have a cached project but missing subscription data * * @param {string} token - OAuth access token * @param {Object} account - Account object * @param {Function} [onSave] - Callback to save account changes */ async function fetchAndSaveSubscription(token, account, onSave) { // Avoid duplicate fetches for the same account if (subscriptionFetchInProgress.has(account.email)) { return; } subscriptionFetchInProgress.add(account.email); try { // Call discoverProject just to get subscription info const { subscription } = await discoverProject(token, account.projectId); if (subscription && subscription.tier !== 'unknown') { account.subscription = subscription; if (onSave) { await onSave(); } logger.info(`[AccountManager] Updated subscription tier for ${account.email}: ${subscription.tier}`); } } catch (e) { logger.debug(`[AccountManager] Subscription fetch failed for ${account.email}: ${e.message}`); } finally { subscriptionFetchInProgress.delete(account.email); } } /** * Get OAuth token for an account * * @param {Object} account - Account object with email and credentials * @param {Map} tokenCache - Token cache map * @param {Function} onInvalid - Callback when account is invalid (email, reason) * @param {Function} onSave - Callback to save changes * @returns {Promise} OAuth access token * @throws {Error} If token refresh fails */ export async function getTokenForAccount(account, tokenCache, onInvalid, onSave) { // Check cache first const cached = tokenCache.get(account.email); if (cached && (Date.now() - cached.extractedAt) < TOKEN_REFRESH_INTERVAL_MS) { return cached.token; } // Get fresh token based on source let token; if (account.source === 'oauth' && account.refreshToken) { // OAuth account - use refresh token to get new access token try { const tokens = await refreshAccessToken(account.refreshToken); token = tokens.accessToken; // Clear invalid flag on success if (account.isInvalid) { account.isInvalid = false; account.invalidReason = null; if (onSave) await onSave(); } logger.success(`[AccountManager] Refreshed OAuth token for: ${account.email}`); } catch (error) { // Check if it's a transient network error if (isNetworkError(error)) { logger.warn(`[AccountManager] Failed to refresh token for ${account.email} due to network error: ${error.message}`); // Do NOT mark as invalid, just throw so caller knows it failed throw new Error(`AUTH_NETWORK_ERROR: ${error.message}`); } logger.error(`[AccountManager] Failed to refresh token for ${account.email}:`, error.message); // Mark account as invalid (credentials need re-auth) if (onInvalid) onInvalid(account.email, error.message); throw new Error(`AUTH_INVALID: ${account.email}: ${error.message}`); } } else if (account.source === 'manual' && account.apiKey) { token = account.apiKey; } else { // Extract from database const dbPath = account.dbPath || ANTIGRAVITY_DB_PATH; const authData = getAuthStatus(dbPath); token = authData.apiKey; } // Cache the token tokenCache.set(account.email, { token, extractedAt: Date.now() }); return token; } /** * Get project ID for an account * Aligned with opencode-antigravity-auth: parses refresh token for stored project IDs * * @param {Object} account - Account object * @param {string} token - OAuth access token * @param {Map} projectCache - Project cache map * @param {Function} [onSave] - Callback to save account changes * @returns {Promise} Project ID */ export async function getProjectForAccount(account, token, projectCache, onSave = null) { // Check cache first const cached = projectCache.get(account.email); if (cached) { return cached; } // Parse refresh token to get stored project IDs (aligned with opencode-antigravity-auth) const parts = account.refreshToken ? parseRefreshParts(account.refreshToken) : { refreshToken: null, projectId: undefined, managedProjectId: undefined }; // If we have a managedProjectId in the refresh token, use it if (parts.managedProjectId) { projectCache.set(account.email, parts.managedProjectId); // If subscription is missing/unknown, fetch it now (blocking) if (!account.subscription || account.subscription.tier === 'unknown') { await fetchAndSaveSubscription(token, account, onSave); } return parts.managedProjectId; } // Legacy: check account.projectId for backward compatibility if (account.projectId) { projectCache.set(account.email, account.projectId); // If subscription is missing/unknown, fetch it now (blocking) if (!account.subscription || account.subscription.tier === 'unknown') { await fetchAndSaveSubscription(token, account, onSave); } return account.projectId; } // Discover managed project, passing projectId for metadata.duetProject // Reference: opencode-antigravity-auth - discoverProject handles fallback internally const { project, subscription } = await discoverProject(token, parts.projectId); // Store managedProjectId back in refresh token (if we got a real project) if (project && project !== DEFAULT_PROJECT_ID) { let needsSave = false; if (account.refreshToken) { // OAuth accounts: encode in refresh token account.refreshToken = formatRefreshParts({ refreshToken: parts.refreshToken, projectId: parts.projectId, managedProjectId: project, }); needsSave = true; } else if (account.source === 'database' || account.source === 'manual') { // Database/manual accounts: store in projectId field account.projectId = project; needsSave = true; } // Save subscription tier if discovered if (subscription) { account.subscription = subscription; needsSave = true; } // Trigger save to persist the updated project and subscription if (needsSave && onSave) { try { await onSave(); } catch (e) { logger.warn(`[AccountManager] Failed to save updated project: ${e.message}`); } } } else if (subscription) { // Even if no project discovered, save subscription if we got it account.subscription = subscription; if (onSave) { try { await onSave(); } catch (e) { logger.warn(`[AccountManager] Failed to save subscription: ${e.message}`); } } } projectCache.set(account.email, project); return project; } /** * Discover project ID via Cloud Code API * * @param {string} token - OAuth access token * @param {string} [projectId] - Optional project ID from refresh token (for metadata.duetProject) * @returns {Promise<{project: string, subscription: {tier: string, projectId: string|null, detectedAt: string}|null}>} Project and subscription info */ export async function discoverProject(token, projectId = undefined) { let lastError = null; let gotSuccessfulResponse = false; let loadCodeAssistData = null; const metadata = { ...CLIENT_METADATA }; if (projectId) { metadata.duetProject = projectId; } for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { try { const response = await throttledFetch(`${endpoint}/v1internal:loadCodeAssist`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', ...LOAD_CODE_ASSIST_HEADERS }, body: JSON.stringify({ metadata, mode: 1 }) }); if (!response.ok) { const errorText = await response.text(); lastError = `${response.status} - ${errorText}`; logger.debug(`[AccountManager] loadCodeAssist failed at ${endpoint}: ${lastError}`); continue; } const data = await response.json(); gotSuccessfulResponse = true; loadCodeAssistData = data; logger.debug(`[AccountManager] loadCodeAssist response from ${endpoint}:`, JSON.stringify(data)); // Extract subscription tier from response const subscription = extractSubscriptionFromResponse(data); if (typeof data.cloudaicompanionProject === 'string') { logger.success(`[AccountManager] Discovered project: ${data.cloudaicompanionProject}`); return { project: data.cloudaicompanionProject, subscription }; } if (data.cloudaicompanionProject?.id) { logger.success(`[AccountManager] Discovered project: ${data.cloudaicompanionProject.id}`); return { project: data.cloudaicompanionProject.id, subscription }; } // No project found - log tier data and try to onboard the user logger.info(`[AccountManager] No project in loadCodeAssist response, attempting onboardUser...`); logger.debug(`[AccountManager] Tier data for onboarding: paidTier=${JSON.stringify(data.paidTier)}, currentTier=${JSON.stringify(data.currentTier)}, allowedTiers=${JSON.stringify(data.allowedTiers?.map(t => ({ id: t?.id, isDefault: t?.isDefault })))}`); break; } catch (error) { lastError = error.message; logger.debug(`[AccountManager] loadCodeAssist error at ${endpoint}:`, error.message); } } // If we got a successful response but no project, try onboarding if (gotSuccessfulResponse && loadCodeAssistData) { // Only use allowedTiers for onboarding (matching opencode-antigravity-auth and oauth.js) // Note: paidTier (g1-pro-tier, g1-ultra-tier) is NOT valid for onboardUser API // The paidTier is used for subscription detection only, not for onboarding const tierId = getDefaultTierId(loadCodeAssistData.allowedTiers) || 'free-tier'; logger.info(`[AccountManager] Onboarding user with tier: ${tierId}`); // Pass projectId for metadata.duetProject (without fallback, matching reference) // Reference: opencode-antigravity-auth passes parts.projectId (not fallback) to onboardManagedProject const onboardedProject = await onboardUser( token, tierId, projectId // Original projectId without fallback ); if (onboardedProject) { logger.success(`[AccountManager] Successfully onboarded, project: ${onboardedProject}`); const subscription = extractSubscriptionFromResponse(loadCodeAssistData); return { project: onboardedProject, subscription }; } logger.warn(`[AccountManager] Onboarding failed - account may not work correctly`); } // Only warn if all endpoints failed with errors (not just missing project) if (!gotSuccessfulResponse) { logger.warn(`[AccountManager] loadCodeAssist failed for all endpoints: ${lastError}`); } // Fallback: use projectId if available, otherwise use default // Reference: opencode-antigravity-auth/src/plugin/project.ts if (projectId) { return { project: projectId, subscription: null }; } return { project: DEFAULT_PROJECT_ID, subscription: null }; } /** * Extract subscription tier from loadCodeAssist response * * @param {Object} data - loadCodeAssist response data * @returns {{tier: string, projectId: string|null, detectedAt: string}|null} Subscription info */ function extractSubscriptionFromResponse(data) { if (!data) return null; // Priority: paidTier > currentTier (consistent with model-api.js) let tier = 'free'; let cloudProject = null; if (data.paidTier?.id) { tier = parseTierId(data.paidTier.id); } else if (data.currentTier?.id) { tier = parseTierId(data.currentTier.id); } // Get project ID if (typeof data.cloudaicompanionProject === 'string') { cloudProject = data.cloudaicompanionProject; } else if (data.cloudaicompanionProject?.id) { cloudProject = data.cloudaicompanionProject.id; } return { tier, projectId: cloudProject, detectedAt: new Date().toISOString() }; } /** * Clear project cache for an account * * @param {Map} projectCache - Project cache map * @param {string|null} email - Email to clear cache for, or null to clear all */ export function clearProjectCache(projectCache, email = null) { if (email) { projectCache.delete(email); } else { projectCache.clear(); } } /** * Clear token cache for an account * * @param {Map} tokenCache - Token cache map * @param {string|null} email - Email to clear cache for, or null to clear all */ export function clearTokenCache(tokenCache, email = null) { if (email) { tokenCache.delete(email); } else { tokenCache.clear(); } }