grove.ts
1 import axios from 'axios' 2 import memoize from 'lodash-es/memoize.js' 3 import { 4 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 5 logEvent, 6 } from 'src/services/analytics/index.js' 7 import { getOauthAccountInfo, isConsumerSubscriber } from 'src/utils/auth.js' 8 import { logForDebugging } from 'src/utils/debug.js' 9 import { gracefulShutdown } from 'src/utils/gracefulShutdown.js' 10 import { isEssentialTrafficOnly } from 'src/utils/privacyLevel.js' 11 import { writeToStderr } from 'src/utils/process.js' 12 import { getOauthConfig } from '../../constants/oauth.js' 13 import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 14 import { 15 getAuthHeaders, 16 getUserAgent, 17 withOAuth401Retry, 18 } from '../../utils/http.js' 19 import { logError } from '../../utils/log.js' 20 import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 21 22 // Cache expiration: 24 hours 23 const GROVE_CACHE_EXPIRATION_MS = 24 * 60 * 60 * 1000 24 25 export type AccountSettings = { 26 grove_enabled: boolean | null 27 grove_notice_viewed_at: string | null 28 } 29 30 export type GroveConfig = { 31 grove_enabled: boolean 32 domain_excluded: boolean 33 notice_is_grace_period: boolean 34 notice_reminder_frequency: number | null 35 } 36 37 /** 38 * Result type that distinguishes between API failure and success. 39 * - success: true means API call succeeded (data may still contain null fields) 40 * - success: false means API call failed after retry 41 */ 42 export type ApiResult<T> = { success: true; data: T } | { success: false } 43 44 /** 45 * Get the current Grove settings for the user account. 46 * Returns ApiResult to distinguish between API failure and success. 47 * Uses existing OAuth 401 retry, then returns failure if that doesn't help. 48 * 49 * Memoized for the session to avoid redundant per-render requests. 50 * Cache is invalidated in updateGroveSettings() so post-toggle reads are fresh. 51 */ 52 export const getGroveSettings = memoize( 53 async (): Promise<ApiResult<AccountSettings>> => { 54 // Grove is a notification feature; during an outage, skipping it is correct. 55 if (isEssentialTrafficOnly()) { 56 return { success: false } 57 } 58 try { 59 const response = await withOAuth401Retry(() => { 60 const authHeaders = getAuthHeaders() 61 if (authHeaders.error) { 62 throw new Error(`Failed to get auth headers: ${authHeaders.error}`) 63 } 64 return axios.get<AccountSettings>( 65 `${getOauthConfig().BASE_API_URL}/api/oauth/account/settings`, 66 { 67 headers: { 68 ...authHeaders.headers, 69 'User-Agent': getClaudeCodeUserAgent(), 70 }, 71 }, 72 ) 73 }) 74 return { success: true, data: response.data } 75 } catch (err) { 76 logError(err) 77 // Don't cache failures — transient network issues would lock the user 78 // out of privacy settings for the entire session (deadlock: dialog needs 79 // success to render the toggle, toggle calls updateGroveSettings which 80 // is the only other place the cache is cleared). 81 getGroveSettings.cache.clear?.() 82 return { success: false } 83 } 84 }, 85 ) 86 87 /** 88 * Mark that the Grove notice has been viewed by the user 89 */ 90 export async function markGroveNoticeViewed(): Promise<void> { 91 try { 92 await withOAuth401Retry(() => { 93 const authHeaders = getAuthHeaders() 94 if (authHeaders.error) { 95 throw new Error(`Failed to get auth headers: ${authHeaders.error}`) 96 } 97 return axios.post( 98 `${getOauthConfig().BASE_API_URL}/api/oauth/account/grove_notice_viewed`, 99 {}, 100 { 101 headers: { 102 ...authHeaders.headers, 103 'User-Agent': getClaudeCodeUserAgent(), 104 }, 105 }, 106 ) 107 }) 108 // This mutates grove_notice_viewed_at server-side — Grove.tsx:87 reads it 109 // to decide whether to show the dialog. Without invalidation a same-session 110 // remount would read stale viewed_at:null and re-show the dialog. 111 getGroveSettings.cache.clear?.() 112 } catch (err) { 113 logError(err) 114 } 115 } 116 117 /** 118 * Update Grove settings for the user account 119 */ 120 export async function updateGroveSettings( 121 groveEnabled: boolean, 122 ): Promise<void> { 123 try { 124 await withOAuth401Retry(() => { 125 const authHeaders = getAuthHeaders() 126 if (authHeaders.error) { 127 throw new Error(`Failed to get auth headers: ${authHeaders.error}`) 128 } 129 return axios.patch( 130 `${getOauthConfig().BASE_API_URL}/api/oauth/account/settings`, 131 { 132 grove_enabled: groveEnabled, 133 }, 134 { 135 headers: { 136 ...authHeaders.headers, 137 'User-Agent': getClaudeCodeUserAgent(), 138 }, 139 }, 140 ) 141 }) 142 // Invalidate memoized settings so the post-toggle confirmation 143 // read in privacy-settings.tsx picks up the new value. 144 getGroveSettings.cache.clear?.() 145 } catch (err) { 146 logError(err) 147 } 148 } 149 150 /** 151 * Check if user is qualified for Grove (non-blocking, cache-first). 152 * 153 * This function never blocks on network - it returns cached data immediately 154 * and fetches in the background if needed. On cold start (no cache), it returns 155 * false and the Grove dialog won't show until the next session. 156 */ 157 export async function isQualifiedForGrove(): Promise<boolean> { 158 if (!isConsumerSubscriber()) { 159 return false 160 } 161 162 const accountId = getOauthAccountInfo()?.accountUuid 163 if (!accountId) { 164 return false 165 } 166 167 const globalConfig = getGlobalConfig() 168 const cachedEntry = globalConfig.groveConfigCache?.[accountId] 169 const now = Date.now() 170 171 // No cache - trigger background fetch and return false (non-blocking) 172 // The Grove dialog won't show this session, but will next time if eligible 173 if (!cachedEntry) { 174 logForDebugging( 175 'Grove: No cache, fetching config in background (dialog skipped this session)', 176 ) 177 void fetchAndStoreGroveConfig(accountId) 178 return false 179 } 180 181 // Cache exists but is stale - return cached value and refresh in background 182 if (now - cachedEntry.timestamp > GROVE_CACHE_EXPIRATION_MS) { 183 logForDebugging( 184 'Grove: Cache stale, returning cached data and refreshing in background', 185 ) 186 void fetchAndStoreGroveConfig(accountId) 187 return cachedEntry.grove_enabled 188 } 189 190 // Cache is fresh - return it immediately 191 logForDebugging('Grove: Using fresh cached config') 192 return cachedEntry.grove_enabled 193 } 194 195 /** 196 * Fetch Grove config from API and store in cache 197 */ 198 async function fetchAndStoreGroveConfig(accountId: string): Promise<void> { 199 try { 200 const result = await getGroveNoticeConfig() 201 if (!result.success) { 202 return 203 } 204 const groveEnabled = result.data.grove_enabled 205 const cachedEntry = getGlobalConfig().groveConfigCache?.[accountId] 206 if ( 207 cachedEntry?.grove_enabled === groveEnabled && 208 Date.now() - cachedEntry.timestamp <= GROVE_CACHE_EXPIRATION_MS 209 ) { 210 return 211 } 212 saveGlobalConfig(current => ({ 213 ...current, 214 groveConfigCache: { 215 ...current.groveConfigCache, 216 [accountId]: { 217 grove_enabled: groveEnabled, 218 timestamp: Date.now(), 219 }, 220 }, 221 })) 222 } catch (err) { 223 logForDebugging(`Grove: Failed to fetch and store config: ${err}`) 224 } 225 } 226 227 /** 228 * Get Grove Statsig configuration from the API. 229 * Returns ApiResult to distinguish between API failure and success. 230 * Uses existing OAuth 401 retry, then returns failure if that doesn't help. 231 */ 232 export const getGroveNoticeConfig = memoize( 233 async (): Promise<ApiResult<GroveConfig>> => { 234 // Grove is a notification feature; during an outage, skipping it is correct. 235 if (isEssentialTrafficOnly()) { 236 return { success: false } 237 } 238 try { 239 const response = await withOAuth401Retry(() => { 240 const authHeaders = getAuthHeaders() 241 if (authHeaders.error) { 242 throw new Error(`Failed to get auth headers: ${authHeaders.error}`) 243 } 244 return axios.get<GroveConfig>( 245 `${getOauthConfig().BASE_API_URL}/api/claude_code_grove`, 246 { 247 headers: { 248 ...authHeaders.headers, 249 'User-Agent': getUserAgent(), 250 }, 251 timeout: 3000, // Short timeout - if slow, skip Grove dialog 252 }, 253 ) 254 }) 255 256 // Map the API response to the GroveConfig type 257 const { 258 grove_enabled, 259 domain_excluded, 260 notice_is_grace_period, 261 notice_reminder_frequency, 262 } = response.data 263 264 return { 265 success: true, 266 data: { 267 grove_enabled, 268 domain_excluded: domain_excluded ?? false, 269 notice_is_grace_period: notice_is_grace_period ?? true, 270 notice_reminder_frequency, 271 }, 272 } 273 } catch (err) { 274 logForDebugging(`Failed to fetch Grove notice config: ${err}`) 275 return { success: false } 276 } 277 }, 278 ) 279 280 /** 281 * Determines whether the Grove dialog should be shown. 282 * Returns false if either API call failed (after retry) - we hide the dialog on API failure. 283 */ 284 export function calculateShouldShowGrove( 285 settingsResult: ApiResult<AccountSettings>, 286 configResult: ApiResult<GroveConfig>, 287 showIfAlreadyViewed: boolean, 288 ): boolean { 289 // Hide dialog on API failure (after retry) 290 if (!settingsResult.success || !configResult.success) { 291 return false 292 } 293 294 const settings = settingsResult.data 295 const config = configResult.data 296 297 const hasChosen = settings.grove_enabled !== null 298 if (hasChosen) { 299 return false 300 } 301 if (showIfAlreadyViewed) { 302 return true 303 } 304 if (!config.notice_is_grace_period) { 305 return true 306 } 307 // Check if we need to remind the user to accept the terms and choose 308 // whether to help improve Claude. 309 const reminderFrequency = config.notice_reminder_frequency 310 if (reminderFrequency !== null && settings.grove_notice_viewed_at) { 311 const daysSinceViewed = Math.floor( 312 (Date.now() - new Date(settings.grove_notice_viewed_at).getTime()) / 313 (1000 * 60 * 60 * 24), 314 ) 315 return daysSinceViewed >= reminderFrequency 316 } else { 317 // Show if never viewed before 318 const viewedAt = settings.grove_notice_viewed_at 319 return viewedAt === null || viewedAt === undefined 320 } 321 } 322 323 export async function checkGroveForNonInteractive(): Promise<void> { 324 const [settingsResult, configResult] = await Promise.all([ 325 getGroveSettings(), 326 getGroveNoticeConfig(), 327 ]) 328 329 // Check if user hasn't made a choice yet (returns false on API failure) 330 const shouldShowGrove = calculateShouldShowGrove( 331 settingsResult, 332 configResult, 333 false, 334 ) 335 336 if (shouldShowGrove) { 337 // shouldShowGrove is only true if both API calls succeeded 338 const config = configResult.success ? configResult.data : null 339 logEvent('tengu_grove_print_viewed', { 340 dismissable: 341 config?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 342 }) 343 if (config === null || config.notice_is_grace_period) { 344 // Grace period is still active - show informational message and continue 345 writeToStderr( 346 '\nAn update to our Consumer Terms and Privacy Policy will take effect on October 8, 2025. Run `claude` to review the updated terms.\n\n', 347 ) 348 await markGroveNoticeViewed() 349 } else { 350 // Grace period has ended - show error message and exit 351 writeToStderr( 352 '\n[ACTION REQUIRED] An update to our Consumer Terms and Privacy Policy has taken effect on October 8, 2025. You must run `claude` to review the updated terms.\n\n', 353 ) 354 await gracefulShutdown(1) 355 } 356 } 357 }