/ src / services / api / grove.ts
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  }