@kamel-ahmed/proxy-claude 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +622 -0
  3. package/bin/cli.js +124 -0
  4. package/package.json +80 -0
  5. package/public/app.js +228 -0
  6. package/public/css/src/input.css +523 -0
  7. package/public/css/style.css +1 -0
  8. package/public/favicon.svg +10 -0
  9. package/public/index.html +381 -0
  10. package/public/js/components/account-manager.js +245 -0
  11. package/public/js/components/claude-config.js +420 -0
  12. package/public/js/components/dashboard/charts.js +589 -0
  13. package/public/js/components/dashboard/filters.js +362 -0
  14. package/public/js/components/dashboard/stats.js +110 -0
  15. package/public/js/components/dashboard.js +236 -0
  16. package/public/js/components/logs-viewer.js +100 -0
  17. package/public/js/components/models.js +36 -0
  18. package/public/js/components/server-config.js +349 -0
  19. package/public/js/config/constants.js +102 -0
  20. package/public/js/data-store.js +386 -0
  21. package/public/js/settings-store.js +58 -0
  22. package/public/js/store.js +78 -0
  23. package/public/js/translations/en.js +351 -0
  24. package/public/js/translations/id.js +396 -0
  25. package/public/js/translations/pt.js +287 -0
  26. package/public/js/translations/tr.js +342 -0
  27. package/public/js/translations/zh.js +357 -0
  28. package/public/js/utils/account-actions.js +189 -0
  29. package/public/js/utils/error-handler.js +96 -0
  30. package/public/js/utils/model-config.js +42 -0
  31. package/public/js/utils/validators.js +77 -0
  32. package/public/js/utils.js +69 -0
  33. package/public/views/accounts.html +329 -0
  34. package/public/views/dashboard.html +484 -0
  35. package/public/views/logs.html +97 -0
  36. package/public/views/models.html +331 -0
  37. package/public/views/settings.html +1329 -0
  38. package/src/account-manager/credentials.js +243 -0
  39. package/src/account-manager/index.js +380 -0
  40. package/src/account-manager/onboarding.js +117 -0
  41. package/src/account-manager/rate-limits.js +237 -0
  42. package/src/account-manager/storage.js +136 -0
  43. package/src/account-manager/strategies/base-strategy.js +104 -0
  44. package/src/account-manager/strategies/hybrid-strategy.js +195 -0
  45. package/src/account-manager/strategies/index.js +79 -0
  46. package/src/account-manager/strategies/round-robin-strategy.js +76 -0
  47. package/src/account-manager/strategies/sticky-strategy.js +138 -0
  48. package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
  49. package/src/account-manager/strategies/trackers/index.js +8 -0
  50. package/src/account-manager/strategies/trackers/token-bucket-tracker.js +121 -0
  51. package/src/auth/database.js +169 -0
  52. package/src/auth/oauth.js +419 -0
  53. package/src/auth/token-extractor.js +117 -0
  54. package/src/cli/accounts.js +512 -0
  55. package/src/cli/refresh.js +201 -0
  56. package/src/cli/setup.js +338 -0
  57. package/src/cloudcode/index.js +29 -0
  58. package/src/cloudcode/message-handler.js +386 -0
  59. package/src/cloudcode/model-api.js +248 -0
  60. package/src/cloudcode/rate-limit-parser.js +181 -0
  61. package/src/cloudcode/request-builder.js +93 -0
  62. package/src/cloudcode/session-manager.js +47 -0
  63. package/src/cloudcode/sse-parser.js +121 -0
  64. package/src/cloudcode/sse-streamer.js +293 -0
  65. package/src/cloudcode/streaming-handler.js +492 -0
  66. package/src/config.js +107 -0
  67. package/src/constants.js +278 -0
  68. package/src/errors.js +238 -0
  69. package/src/fallback-config.js +29 -0
  70. package/src/format/content-converter.js +193 -0
  71. package/src/format/index.js +20 -0
  72. package/src/format/request-converter.js +248 -0
  73. package/src/format/response-converter.js +120 -0
  74. package/src/format/schema-sanitizer.js +673 -0
  75. package/src/format/signature-cache.js +88 -0
  76. package/src/format/thinking-utils.js +558 -0
  77. package/src/index.js +146 -0
  78. package/src/modules/usage-stats.js +205 -0
  79. package/src/server.js +861 -0
  80. package/src/utils/claude-config.js +245 -0
  81. package/src/utils/helpers.js +51 -0
  82. package/src/utils/logger.js +142 -0
  83. package/src/utils/native-module-helper.js +162 -0
  84. package/src/webui/index.js +707 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Token Bucket Tracker
3
+ *
4
+ * Client-side rate limiting using the token bucket algorithm.
5
+ * Each account has a bucket of tokens that regenerate over time.
6
+ * Requests consume tokens; accounts without tokens are deprioritized.
7
+ */
8
+
9
+ // Default configuration (matches opencode-antigravity-auth)
10
+ const DEFAULT_CONFIG = {
11
+ maxTokens: 50, // Maximum token capacity
12
+ tokensPerMinute: 6, // Regeneration rate
13
+ initialTokens: 50 // Starting tokens
14
+ };
15
+
16
+ export class TokenBucketTracker {
17
+ #buckets = new Map(); // email -> { tokens, lastUpdated }
18
+ #config;
19
+
20
+ /**
21
+ * Create a new TokenBucketTracker
22
+ * @param {Object} config - Token bucket configuration
23
+ */
24
+ constructor(config = {}) {
25
+ this.#config = { ...DEFAULT_CONFIG, ...config };
26
+ }
27
+
28
+ /**
29
+ * Get the current token count for an account
30
+ * @param {string} email - Account email
31
+ * @returns {number} Current token count (with regeneration applied)
32
+ */
33
+ getTokens(email) {
34
+ const bucket = this.#buckets.get(email);
35
+ if (!bucket) {
36
+ return this.#config.initialTokens;
37
+ }
38
+
39
+ // Apply token regeneration based on time elapsed
40
+ const now = Date.now();
41
+ const minutesElapsed = (now - bucket.lastUpdated) / (1000 * 60);
42
+ const regenerated = minutesElapsed * this.#config.tokensPerMinute;
43
+ const currentTokens = Math.min(
44
+ this.#config.maxTokens,
45
+ bucket.tokens + regenerated
46
+ );
47
+
48
+ return currentTokens;
49
+ }
50
+
51
+ /**
52
+ * Check if an account has tokens available
53
+ * @param {string} email - Account email
54
+ * @returns {boolean} True if account has at least 1 token
55
+ */
56
+ hasTokens(email) {
57
+ return this.getTokens(email) >= 1;
58
+ }
59
+
60
+ /**
61
+ * Consume a token from an account's bucket
62
+ * @param {string} email - Account email
63
+ * @returns {boolean} True if token was consumed, false if no tokens available
64
+ */
65
+ consume(email) {
66
+ const currentTokens = this.getTokens(email);
67
+ if (currentTokens < 1) {
68
+ return false;
69
+ }
70
+
71
+ this.#buckets.set(email, {
72
+ tokens: currentTokens - 1,
73
+ lastUpdated: Date.now()
74
+ });
75
+ return true;
76
+ }
77
+
78
+ /**
79
+ * Refund a token to an account's bucket (e.g., on request failure before processing)
80
+ * @param {string} email - Account email
81
+ */
82
+ refund(email) {
83
+ const currentTokens = this.getTokens(email);
84
+ const newTokens = Math.min(
85
+ this.#config.maxTokens,
86
+ currentTokens + 1
87
+ );
88
+ this.#buckets.set(email, {
89
+ tokens: newTokens,
90
+ lastUpdated: Date.now()
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Get the maximum token capacity
96
+ * @returns {number} Maximum tokens per bucket
97
+ */
98
+ getMaxTokens() {
99
+ return this.#config.maxTokens;
100
+ }
101
+
102
+ /**
103
+ * Reset the bucket for an account
104
+ * @param {string} email - Account email
105
+ */
106
+ reset(email) {
107
+ this.#buckets.set(email, {
108
+ tokens: this.#config.initialTokens,
109
+ lastUpdated: Date.now()
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Clear all tracked buckets
115
+ */
116
+ clear() {
117
+ this.#buckets.clear();
118
+ }
119
+ }
120
+
121
+ export default TokenBucketTracker;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * SQLite Database Access Module
3
+ * Provides cross-platform database operations for Antigravity state.
4
+ *
5
+ * Uses better-sqlite3 for:
6
+ * - Windows compatibility (no CLI dependency)
7
+ * - Native performance
8
+ * - Synchronous API (simple error handling)
9
+ *
10
+ * Includes auto-rebuild capability for handling Node.js version updates
11
+ * that cause native module incompatibility.
12
+ */
13
+
14
+ import { createRequire } from 'module';
15
+ import { ANTIGRAVITY_DB_PATH } from '../constants.js';
16
+ import { isModuleVersionError, attemptAutoRebuild, clearRequireCache } from '../utils/native-module-helper.js';
17
+ import { logger } from '../utils/logger.js';
18
+ import { NativeModuleError } from '../errors.js';
19
+
20
+ const require = createRequire(import.meta.url);
21
+
22
+ // Lazy-loaded Database constructor
23
+ let Database = null;
24
+ let moduleLoadError = null;
25
+
26
+ /**
27
+ * Load the better-sqlite3 module with auto-rebuild on version mismatch
28
+ * Uses synchronous require to maintain API compatibility
29
+ * @returns {Function} The Database constructor
30
+ * @throws {Error} If module cannot be loaded even after rebuild
31
+ */
32
+ function loadDatabaseModule() {
33
+ // Return cached module if already loaded
34
+ if (Database) return Database;
35
+
36
+ // Re-throw cached error if previous load failed permanently
37
+ if (moduleLoadError) throw moduleLoadError;
38
+
39
+ try {
40
+ Database = require('better-sqlite3');
41
+ return Database;
42
+ } catch (error) {
43
+ if (isModuleVersionError(error)) {
44
+ logger.warn('[Database] Native module version mismatch detected');
45
+
46
+ if (attemptAutoRebuild(error)) {
47
+ // Clear require cache and retry
48
+ try {
49
+ const resolvedPath = require.resolve('better-sqlite3');
50
+ // Clear the module and all its dependencies from cache
51
+ clearRequireCache(resolvedPath, require.cache);
52
+
53
+ Database = require('better-sqlite3');
54
+ logger.success('[Database] Module reloaded successfully after rebuild');
55
+ return Database;
56
+ } catch (retryError) {
57
+ // Rebuild succeeded but reload failed - user needs to restart
58
+ moduleLoadError = new NativeModuleError(
59
+ 'Native module rebuild completed. Please restart the server to apply the fix.',
60
+ true, // rebuildSucceeded
61
+ true // restartRequired
62
+ );
63
+ logger.info('[Database] Rebuild succeeded - server restart required');
64
+ throw moduleLoadError;
65
+ }
66
+ } else {
67
+ moduleLoadError = new NativeModuleError(
68
+ 'Failed to auto-rebuild native module. Please run manually:\n' +
69
+ ' npm rebuild better-sqlite3\n' +
70
+ 'Or if using npx, find the package location in the error and run:\n' +
71
+ ' cd /path/to/better-sqlite3 && npm rebuild',
72
+ false, // rebuildSucceeded
73
+ false // restartRequired
74
+ );
75
+ throw moduleLoadError;
76
+ }
77
+ }
78
+
79
+ // Non-version-mismatch error, just throw it
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Query Antigravity database for authentication status
86
+ * @param {string} [dbPath] - Optional custom database path
87
+ * @returns {Object} Parsed auth data with apiKey, email, name, etc.
88
+ * @throws {Error} If database doesn't exist, query fails, or no auth status found
89
+ */
90
+ export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
91
+ const Db = loadDatabaseModule();
92
+ let db;
93
+ try {
94
+ // Open database in read-only mode
95
+ db = new Db(dbPath, {
96
+ readonly: true,
97
+ fileMustExist: true
98
+ });
99
+
100
+ // Prepare and execute query
101
+ const stmt = db.prepare(
102
+ "SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus'"
103
+ );
104
+ const row = stmt.get();
105
+
106
+ if (!row || !row.value) {
107
+ throw new Error('No auth status found in database');
108
+ }
109
+
110
+ // Parse JSON value
111
+ const authData = JSON.parse(row.value);
112
+
113
+ if (!authData.apiKey) {
114
+ throw new Error('Auth data missing apiKey field');
115
+ }
116
+
117
+ return authData;
118
+ } catch (error) {
119
+ // Enhance error messages for common issues
120
+ if (error.code === 'SQLITE_CANTOPEN') {
121
+ throw new Error(
122
+ `Database not found at ${dbPath}. ` +
123
+ 'Make sure Antigravity is installed and you are logged in.'
124
+ );
125
+ }
126
+ // Re-throw with context if not already our error
127
+ if (error.message.includes('No auth status') || error.message.includes('missing apiKey')) {
128
+ throw error;
129
+ }
130
+ // Re-throw native module errors from loadDatabaseModule without wrapping
131
+ if (error instanceof NativeModuleError) {
132
+ throw error;
133
+ }
134
+ throw new Error(`Failed to read Antigravity database: ${error.message}`);
135
+ } finally {
136
+ // Always close database connection
137
+ if (db) {
138
+ db.close();
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Check if database exists and is accessible
145
+ * @param {string} [dbPath] - Optional custom database path
146
+ * @returns {boolean} True if database exists and can be opened
147
+ */
148
+ export function isDatabaseAccessible(dbPath = ANTIGRAVITY_DB_PATH) {
149
+ let db;
150
+ try {
151
+ const Db = loadDatabaseModule();
152
+ db = new Db(dbPath, {
153
+ readonly: true,
154
+ fileMustExist: true
155
+ });
156
+ return true;
157
+ } catch {
158
+ return false;
159
+ } finally {
160
+ if (db) {
161
+ db.close();
162
+ }
163
+ }
164
+ }
165
+
166
+ export default {
167
+ getAuthStatus,
168
+ isDatabaseAccessible
169
+ };
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Google OAuth with PKCE for Antigravity
3
+ *
4
+ * Implements the same OAuth flow as opencode-antigravity-auth
5
+ * to obtain refresh tokens for multiple Google accounts.
6
+ * Uses a local callback server to automatically capture the auth code.
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+ import http from 'http';
11
+ import {
12
+ ANTIGRAVITY_ENDPOINT_FALLBACKS,
13
+ LOAD_CODE_ASSIST_HEADERS,
14
+ OAUTH_CONFIG,
15
+ OAUTH_REDIRECT_URI
16
+ } from '../constants.js';
17
+ import { logger } from '../utils/logger.js';
18
+ import { onboardUser, getDefaultTierId } from '../account-manager/onboarding.js';
19
+
20
+ /**
21
+ * Generate PKCE code verifier and challenge
22
+ */
23
+ function generatePKCE() {
24
+ const verifier = crypto.randomBytes(32).toString('base64url');
25
+ const challenge = crypto
26
+ .createHash('sha256')
27
+ .update(verifier)
28
+ .digest('base64url');
29
+ return { verifier, challenge };
30
+ }
31
+
32
+ /**
33
+ * Generate authorization URL for Google OAuth
34
+ * Returns the URL and the PKCE verifier (needed for token exchange)
35
+ *
36
+ * @param {string} [customRedirectUri] - Optional custom redirect URI (e.g. for WebUI)
37
+ * @returns {{url: string, verifier: string, state: string}} Auth URL and PKCE data
38
+ */
39
+ export function getAuthorizationUrl(customRedirectUri = null) {
40
+ const { verifier, challenge } = generatePKCE();
41
+ const state = crypto.randomBytes(16).toString('hex');
42
+
43
+ const params = new URLSearchParams({
44
+ client_id: OAUTH_CONFIG.clientId,
45
+ redirect_uri: customRedirectUri || OAUTH_REDIRECT_URI,
46
+ response_type: 'code',
47
+ scope: OAUTH_CONFIG.scopes.join(' '),
48
+ access_type: 'offline',
49
+ prompt: 'consent',
50
+ code_challenge: challenge,
51
+ code_challenge_method: 'S256',
52
+ state: state
53
+ });
54
+
55
+ return {
56
+ url: `${OAUTH_CONFIG.authUrl}?${params.toString()}`,
57
+ verifier,
58
+ state
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Extract authorization code and state from user input.
64
+ * User can paste either:
65
+ * - Full callback URL: http://localhost:51121/oauth-callback?code=xxx&state=xxx
66
+ * - Just the code parameter: 4/0xxx...
67
+ *
68
+ * @param {string} input - User input (URL or code)
69
+ * @returns {{code: string, state: string|null}} Extracted code and optional state
70
+ */
71
+ export function extractCodeFromInput(input) {
72
+ if (!input || typeof input !== 'string') {
73
+ throw new Error('No input provided');
74
+ }
75
+
76
+ const trimmed = input.trim();
77
+
78
+ // Check if it looks like a URL
79
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
80
+ try {
81
+ const url = new URL(trimmed);
82
+ const code = url.searchParams.get('code');
83
+ const state = url.searchParams.get('state');
84
+ const error = url.searchParams.get('error');
85
+
86
+ if (error) {
87
+ throw new Error(`OAuth error: ${error}`);
88
+ }
89
+
90
+ if (!code) {
91
+ throw new Error('No authorization code found in URL');
92
+ }
93
+
94
+ return { code, state };
95
+ } catch (e) {
96
+ if (e.message.includes('OAuth error') || e.message.includes('No authorization code')) {
97
+ throw e;
98
+ }
99
+ throw new Error('Invalid URL format');
100
+ }
101
+ }
102
+
103
+ // Assume it's a raw code
104
+ // Google auth codes typically start with "4/" and are long
105
+ if (trimmed.length < 10) {
106
+ throw new Error('Input is too short to be a valid authorization code');
107
+ }
108
+
109
+ return { code: trimmed, state: null };
110
+ }
111
+
112
+ /**
113
+ * Start a local server to receive the OAuth callback
114
+ * Returns a promise that resolves with the authorization code
115
+ *
116
+ * @param {string} expectedState - Expected state parameter for CSRF protection
117
+ * @param {number} timeoutMs - Timeout in milliseconds (default 120000)
118
+ * @returns {Promise<string>} Authorization code from OAuth callback
119
+ */
120
+ export function startCallbackServer(expectedState, timeoutMs = 120000) {
121
+ return new Promise((resolve, reject) => {
122
+ const server = http.createServer((req, res) => {
123
+ const url = new URL(req.url, `http://localhost:${OAUTH_CONFIG.callbackPort}`);
124
+
125
+ if (url.pathname !== '/oauth-callback') {
126
+ res.writeHead(404);
127
+ res.end('Not found');
128
+ return;
129
+ }
130
+
131
+ const code = url.searchParams.get('code');
132
+ const state = url.searchParams.get('state');
133
+ const error = url.searchParams.get('error');
134
+
135
+ if (error) {
136
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
137
+ res.end(`
138
+ <html>
139
+ <head><meta charset="UTF-8"><title>Authentication Failed</title></head>
140
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
141
+ <h1 style="color: #dc3545;">❌ Authentication Failed</h1>
142
+ <p>Error: ${error}</p>
143
+ <p>You can close this window.</p>
144
+ </body>
145
+ </html>
146
+ `);
147
+ server.close();
148
+ reject(new Error(`OAuth error: ${error}`));
149
+ return;
150
+ }
151
+
152
+ if (state !== expectedState) {
153
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
154
+ res.end(`
155
+ <html>
156
+ <head><meta charset="UTF-8"><title>Authentication Failed</title></head>
157
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
158
+ <h1 style="color: #dc3545;">❌ Authentication Failed</h1>
159
+ <p>State mismatch - possible CSRF attack.</p>
160
+ <p>You can close this window.</p>
161
+ </body>
162
+ </html>
163
+ `);
164
+ server.close();
165
+ reject(new Error('State mismatch'));
166
+ return;
167
+ }
168
+
169
+ if (!code) {
170
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
171
+ res.end(`
172
+ <html>
173
+ <head><meta charset="UTF-8"><title>Authentication Failed</title></head>
174
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
175
+ <h1 style="color: #dc3545;">❌ Authentication Failed</h1>
176
+ <p>No authorization code received.</p>
177
+ <p>You can close this window.</p>
178
+ </body>
179
+ </html>
180
+ `);
181
+ server.close();
182
+ reject(new Error('No authorization code'));
183
+ return;
184
+ }
185
+
186
+ // Success!
187
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
188
+ res.end(`
189
+ <html>
190
+ <head><meta charset="UTF-8"><title>Authentication Successful</title></head>
191
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
192
+ <h1 style="color: #28a745;">✅ Authentication Successful!</h1>
193
+ <p>You can close this window and return to the terminal.</p>
194
+ <script>setTimeout(() => window.close(), 2000);</script>
195
+ </body>
196
+ </html>
197
+ `);
198
+
199
+ server.close();
200
+ resolve(code);
201
+ });
202
+
203
+ server.on('error', (err) => {
204
+ if (err.code === 'EADDRINUSE') {
205
+ reject(new Error(`Port ${OAUTH_CONFIG.callbackPort} is already in use. Close any other OAuth flows and try again.`));
206
+ } else {
207
+ reject(err);
208
+ }
209
+ });
210
+
211
+ server.listen(OAUTH_CONFIG.callbackPort, () => {
212
+ logger.info(`[OAuth] Callback server listening on port ${OAUTH_CONFIG.callbackPort}`);
213
+ });
214
+
215
+ // Timeout after specified duration
216
+ setTimeout(() => {
217
+ server.close();
218
+ reject(new Error('OAuth callback timeout - no response received'));
219
+ }, timeoutMs);
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Exchange authorization code for tokens
225
+ *
226
+ * @param {string} code - Authorization code from OAuth callback
227
+ * @param {string} verifier - PKCE code verifier
228
+ * @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number}>} OAuth tokens
229
+ */
230
+ export async function exchangeCode(code, verifier) {
231
+ const response = await fetch(OAUTH_CONFIG.tokenUrl, {
232
+ method: 'POST',
233
+ headers: {
234
+ 'Content-Type': 'application/x-www-form-urlencoded'
235
+ },
236
+ body: new URLSearchParams({
237
+ client_id: OAUTH_CONFIG.clientId,
238
+ client_secret: OAUTH_CONFIG.clientSecret,
239
+ code: code,
240
+ code_verifier: verifier,
241
+ grant_type: 'authorization_code',
242
+ redirect_uri: OAUTH_REDIRECT_URI
243
+ })
244
+ });
245
+
246
+ if (!response.ok) {
247
+ const error = await response.text();
248
+ logger.error(`[OAuth] Token exchange failed: ${response.status} ${error}`);
249
+ throw new Error(`Token exchange failed: ${error}`);
250
+ }
251
+
252
+ const tokens = await response.json();
253
+
254
+ if (!tokens.access_token) {
255
+ logger.error('[OAuth] No access token in response:', tokens);
256
+ throw new Error('No access token received');
257
+ }
258
+
259
+ logger.info(`[OAuth] Token exchange successful, access_token length: ${tokens.access_token?.length}`);
260
+
261
+ return {
262
+ accessToken: tokens.access_token,
263
+ refreshToken: tokens.refresh_token,
264
+ expiresIn: tokens.expires_in
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Refresh access token using refresh token
270
+ *
271
+ * @param {string} refreshToken - OAuth refresh token
272
+ * @returns {Promise<{accessToken: string, expiresIn: number}>} New access token
273
+ */
274
+ export async function refreshAccessToken(refreshToken) {
275
+ const response = await fetch(OAUTH_CONFIG.tokenUrl, {
276
+ method: 'POST',
277
+ headers: {
278
+ 'Content-Type': 'application/x-www-form-urlencoded'
279
+ },
280
+ body: new URLSearchParams({
281
+ client_id: OAUTH_CONFIG.clientId,
282
+ client_secret: OAUTH_CONFIG.clientSecret,
283
+ refresh_token: refreshToken,
284
+ grant_type: 'refresh_token'
285
+ })
286
+ });
287
+
288
+ if (!response.ok) {
289
+ const error = await response.text();
290
+ throw new Error(`Token refresh failed: ${error}`);
291
+ }
292
+
293
+ const tokens = await response.json();
294
+ return {
295
+ accessToken: tokens.access_token,
296
+ expiresIn: tokens.expires_in
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Get user email from access token
302
+ *
303
+ * @param {string} accessToken - OAuth access token
304
+ * @returns {Promise<string>} User's email address
305
+ */
306
+ export async function getUserEmail(accessToken) {
307
+ const response = await fetch(OAUTH_CONFIG.userInfoUrl, {
308
+ headers: {
309
+ 'Authorization': `Bearer ${accessToken}`
310
+ }
311
+ });
312
+
313
+ if (!response.ok) {
314
+ const errorText = await response.text();
315
+ logger.error(`[OAuth] getUserEmail failed: ${response.status} ${errorText}`);
316
+ throw new Error(`Failed to get user info: ${response.status}`);
317
+ }
318
+
319
+ const userInfo = await response.json();
320
+ return userInfo.email;
321
+ }
322
+
323
+ /**
324
+ * Discover project ID for the authenticated user
325
+ *
326
+ * @param {string} accessToken - OAuth access token
327
+ * @returns {Promise<string|null>} Project ID or null if not found
328
+ */
329
+ export async function discoverProjectId(accessToken) {
330
+ let loadCodeAssistData = null;
331
+
332
+ for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
333
+ try {
334
+ const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
335
+ method: 'POST',
336
+ headers: {
337
+ 'Authorization': `Bearer ${accessToken}`,
338
+ 'Content-Type': 'application/json',
339
+ ...LOAD_CODE_ASSIST_HEADERS
340
+ },
341
+ body: JSON.stringify({
342
+ metadata: {
343
+ ideType: 'IDE_UNSPECIFIED',
344
+ platform: 'PLATFORM_UNSPECIFIED',
345
+ pluginType: 'GEMINI'
346
+ }
347
+ })
348
+ });
349
+
350
+ if (!response.ok) continue;
351
+
352
+ const data = await response.json();
353
+ loadCodeAssistData = data;
354
+
355
+ if (typeof data.cloudaicompanionProject === 'string') {
356
+ return data.cloudaicompanionProject;
357
+ }
358
+ if (data.cloudaicompanionProject?.id) {
359
+ return data.cloudaicompanionProject.id;
360
+ }
361
+
362
+ // No project found - try to onboard
363
+ logger.info('[OAuth] No project in loadCodeAssist response, attempting onboardUser...');
364
+ break;
365
+ } catch (error) {
366
+ logger.warn(`[OAuth] Project discovery failed at ${endpoint}:`, error.message);
367
+ }
368
+ }
369
+
370
+ // Try onboarding if we got a response but no project
371
+ if (loadCodeAssistData) {
372
+ const tierId = getDefaultTierId(loadCodeAssistData.allowedTiers) || 'FREE';
373
+ logger.info(`[OAuth] Onboarding user with tier: ${tierId}`);
374
+
375
+ const onboardedProject = await onboardUser(accessToken, tierId);
376
+ if (onboardedProject) {
377
+ logger.success(`[OAuth] Successfully onboarded, project: ${onboardedProject}`);
378
+ return onboardedProject;
379
+ }
380
+ }
381
+
382
+ return null;
383
+ }
384
+
385
+ /**
386
+ * Complete OAuth flow: exchange code and get all account info
387
+ *
388
+ * @param {string} code - Authorization code from OAuth callback
389
+ * @param {string} verifier - PKCE code verifier
390
+ * @returns {Promise<{email: string, refreshToken: string, accessToken: string, projectId: string|null}>} Complete account info
391
+ */
392
+ export async function completeOAuthFlow(code, verifier) {
393
+ // Exchange code for tokens
394
+ const tokens = await exchangeCode(code, verifier);
395
+
396
+ // Get user email
397
+ const email = await getUserEmail(tokens.accessToken);
398
+
399
+ // Discover project ID
400
+ const projectId = await discoverProjectId(tokens.accessToken);
401
+
402
+ return {
403
+ email,
404
+ refreshToken: tokens.refreshToken,
405
+ accessToken: tokens.accessToken,
406
+ projectId
407
+ };
408
+ }
409
+
410
+ export default {
411
+ getAuthorizationUrl,
412
+ extractCodeFromInput,
413
+ startCallbackServer,
414
+ exchangeCode,
415
+ refreshAccessToken,
416
+ getUserEmail,
417
+ discoverProjectId,
418
+ completeOAuthFlow
419
+ };