File size: 7,985 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 | import axios from 'axios'
import { getOauthConfig } from '../../constants/oauth.js'
import {
getOauthAccountInfo,
getSubscriptionType,
isClaudeAISubscriber,
} from '../../utils/auth.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { logForDebugging } from '../../utils/debug.js'
import { logError } from '../../utils/log.js'
import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js'
import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js'
import type {
ReferralCampaign,
ReferralEligibilityResponse,
ReferralRedemptionsResponse,
ReferrerRewardInfo,
} from '../oauth/types.js'
// Cache expiration time: 24 hours (eligibility changes only on subscription/experiment changes)
const CACHE_EXPIRATION_MS = 24 * 60 * 60 * 1000
// Track in-flight fetch to prevent duplicate API calls
let fetchInProgress: Promise<ReferralEligibilityResponse | null> | null = null
export async function fetchReferralEligibility(
campaign: ReferralCampaign = 'claude_code_guest_pass',
): Promise<ReferralEligibilityResponse> {
const { accessToken, orgUUID } = await prepareApiRequest()
const headers = {
...getOAuthHeaders(accessToken),
'x-organization-uuid': orgUUID,
}
const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/referral/eligibility`
const response = await axios.get(url, {
headers,
params: { campaign },
timeout: 5000, // 5 second timeout for background fetch
})
return response.data
}
export async function fetchReferralRedemptions(
campaign: string = 'claude_code_guest_pass',
): Promise<ReferralRedemptionsResponse> {
const { accessToken, orgUUID } = await prepareApiRequest()
const headers = {
...getOAuthHeaders(accessToken),
'x-organization-uuid': orgUUID,
}
const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/referral/redemptions`
const response = await axios.get<ReferralRedemptionsResponse>(url, {
headers,
params: { campaign },
timeout: 10000, // 10 second timeout
})
return response.data
}
/**
* Prechecks for if user can access guest passes feature
*/
function shouldCheckForPasses(): boolean {
return !!(
getOauthAccountInfo()?.organizationUuid &&
isClaudeAISubscriber() &&
getSubscriptionType() === 'max'
)
}
/**
* Check cached passes eligibility from GlobalConfig
* Returns current cached state and cache status
*/
export function checkCachedPassesEligibility(): {
eligible: boolean
needsRefresh: boolean
hasCache: boolean
} {
if (!shouldCheckForPasses()) {
return {
eligible: false,
needsRefresh: false,
hasCache: false,
}
}
const orgId = getOauthAccountInfo()?.organizationUuid
if (!orgId) {
return {
eligible: false,
needsRefresh: false,
hasCache: false,
}
}
const config = getGlobalConfig()
const cachedEntry = config.passesEligibilityCache?.[orgId]
if (!cachedEntry) {
// No cached entry, needs fetch
return {
eligible: false,
needsRefresh: true,
hasCache: false,
}
}
const { eligible, timestamp } = cachedEntry
const now = Date.now()
const needsRefresh = now - timestamp > CACHE_EXPIRATION_MS
return {
eligible,
needsRefresh,
hasCache: true,
}
}
const CURRENCY_SYMBOLS: Record<string, string> = {
USD: '$',
EUR: '€',
GBP: '£',
BRL: 'R$',
CAD: 'CA$',
AUD: 'A$',
NZD: 'NZ$',
SGD: 'S$',
}
export function formatCreditAmount(reward: ReferrerRewardInfo): string {
const symbol = CURRENCY_SYMBOLS[reward.currency] ?? `${reward.currency} `
const amount = reward.amount_minor_units / 100
const formatted = amount % 1 === 0 ? amount.toString() : amount.toFixed(2)
return `${symbol}${formatted}`
}
/**
* Get cached referrer reward info from eligibility cache
* Returns the reward info if the user is in a v1 campaign, null otherwise
*/
export function getCachedReferrerReward(): ReferrerRewardInfo | null {
const orgId = getOauthAccountInfo()?.organizationUuid
if (!orgId) return null
const config = getGlobalConfig()
const cachedEntry = config.passesEligibilityCache?.[orgId]
return cachedEntry?.referrer_reward ?? null
}
/**
* Get the cached remaining passes count from eligibility cache
* Returns the number of remaining passes, or null if not available
*/
export function getCachedRemainingPasses(): number | null {
const orgId = getOauthAccountInfo()?.organizationUuid
if (!orgId) return null
const config = getGlobalConfig()
const cachedEntry = config.passesEligibilityCache?.[orgId]
return cachedEntry?.remaining_passes ?? null
}
/**
* Fetch passes eligibility and store in GlobalConfig
* Returns the fetched response or null on error
*/
export async function fetchAndStorePassesEligibility(): Promise<ReferralEligibilityResponse | null> {
// Return existing promise if fetch is already in progress
if (fetchInProgress) {
logForDebugging('Passes: Reusing in-flight eligibility fetch')
return fetchInProgress
}
const orgId = getOauthAccountInfo()?.organizationUuid
if (!orgId) {
return null
}
// Store the promise to share with concurrent calls
fetchInProgress = (async () => {
try {
const response = await fetchReferralEligibility()
const cacheEntry = {
...response,
timestamp: Date.now(),
}
saveGlobalConfig(current => ({
...current,
passesEligibilityCache: {
...current.passesEligibilityCache,
[orgId]: cacheEntry,
},
}))
logForDebugging(
`Passes eligibility cached for org ${orgId}: ${response.eligible}`,
)
return response
} catch (error) {
logForDebugging('Failed to fetch and cache passes eligibility')
logError(error as Error)
return null
} finally {
// Clear the promise when done
fetchInProgress = null
}
})()
return fetchInProgress
}
/**
* Get cached passes eligibility data or fetch if needed
* Main entry point for all eligibility checks
*
* This function never blocks on network - it returns cached data immediately
* and fetches in the background if needed. On cold start (no cache), it returns
* null and the passes command won't be available until the next session.
*/
export async function getCachedOrFetchPassesEligibility(): Promise<ReferralEligibilityResponse | null> {
if (!shouldCheckForPasses()) {
return null
}
const orgId = getOauthAccountInfo()?.organizationUuid
if (!orgId) {
return null
}
const config = getGlobalConfig()
const cachedEntry = config.passesEligibilityCache?.[orgId]
const now = Date.now()
// No cache - trigger background fetch and return null (non-blocking)
// The passes command won't be available this session, but will be next time
if (!cachedEntry) {
logForDebugging(
'Passes: No cache, fetching eligibility in background (command unavailable this session)',
)
void fetchAndStorePassesEligibility()
return null
}
// Cache exists but is stale - return stale cache and trigger background refresh
if (now - cachedEntry.timestamp > CACHE_EXPIRATION_MS) {
logForDebugging(
'Passes: Cache stale, returning cached data and refreshing in background',
)
void fetchAndStorePassesEligibility() // Background refresh
const { timestamp, ...response } = cachedEntry
return response as ReferralEligibilityResponse
}
// Cache is fresh - return it immediately
logForDebugging('Passes: Using fresh cached eligibility data')
const { timestamp, ...response } = cachedEntry
return response as ReferralEligibilityResponse
}
/**
* Prefetch passes eligibility on startup
*/
export async function prefetchPassesEligibility(): Promise<void> {
// Skip network requests if nonessential traffic is disabled
if (isEssentialTrafficOnly()) {
return
}
void getCachedOrFetchPassesEligibility()
}
|