33 *
44 * Supports authenticated requests via GitHub token stored in extension storage.
55 * Authenticated requests have a 5,000/hour rate limit vs 60/hour unauthenticated.
6+ *
7+ * Token storage is resilient with fallbacks:
8+ * 1. browser.storage.sync (primary - syncs across devices)
9+ * 2. browser.storage.local (fallback - local only)
10+ * 3. localStorage (fallback - works in content scripts)
11+ * 4. Memory (last resort - session only)
612 */
713
814import { CACHE_TTL } from '../cache' ;
915
16+ // Storage keys
17+ const TOKEN_KEY = 'githubToken' ;
18+ const TOKEN_LS_KEY = 'gitstack-github-token' ;
19+
1020// In-memory cache for repository tree
1121const treeCache = new Map < string , { paths : string [ ] ; timestamp : number } > ( ) ;
1222
1323// Cached token to avoid repeated storage lookups
1424let cachedToken : string | null = null ;
1525let tokenChecked = false ;
26+ let storageAvailable = true ;
1627
1728// Listen for storage changes (when popup saves/removes token)
1829try {
1930 browser . storage . onChanged . addListener ( ( changes , areaName ) => {
20- if ( areaName === 'sync' && changes . githubToken ) {
21- cachedToken = ( changes . githubToken . newValue as string ) ?? null ;
31+ if ( ( areaName === 'sync' || areaName === 'local' ) && changes [ TOKEN_KEY ] ) {
32+ cachedToken = ( changes [ TOKEN_KEY ] . newValue as string ) ?? null ;
2233 tokenChecked = true ;
2334 console . log ( '[GitStack] Token updated from storage:' , cachedToken ? 'set' : 'removed' ) ;
2435 }
2536 } ) ;
2637} catch ( e ) {
2738 // Storage listener not available (e.g., in non-extension context)
39+ storageAvailable = false ;
2840}
2941
3042export interface RepoInfo {
@@ -37,27 +49,75 @@ export interface RepoInfo {
3749}
3850
3951/**
40- * Get GitHub token from extension storage
52+ * Try to get token from extension storage with retry
4153 */
42- async function getGitHubToken ( ) : Promise < string | null > {
43- if ( tokenChecked ) return cachedToken ;
54+ async function tryExtensionStorage ( retries = 2 ) : Promise < string | null > {
55+ for ( let i = 0 ; i < retries ; i ++ ) {
56+ try {
57+ // Try sync storage first (syncs across devices)
58+ const syncResult = await browser . storage . sync . get ( TOKEN_KEY ) as { [ key : string ] : string } ;
59+ if ( syncResult [ TOKEN_KEY ] ) {
60+ return syncResult [ TOKEN_KEY ] ;
61+ }
62+
63+ // Try local storage
64+ const localResult = await browser . storage . local . get ( TOKEN_KEY ) as { [ key : string ] : string } ;
65+ if ( localResult [ TOKEN_KEY ] ) {
66+ return localResult [ TOKEN_KEY ] ;
67+ }
68+
69+ return null ;
70+ } catch ( e ) {
71+ if ( i < retries - 1 ) {
72+ await new Promise ( r => setTimeout ( r , 100 * ( i + 1 ) ) ) ;
73+ }
74+ }
75+ }
76+ return null ;
77+ }
4478
79+ /**
80+ * Try to get token from localStorage (works in content scripts)
81+ */
82+ function tryLocalStorage ( ) : string | null {
4583 try {
46- // Try to get token from extension storage
47- const result = await browser . storage . sync . get ( 'githubToken' ) as { githubToken ?: string } ;
48- cachedToken = result . githubToken ?? null ;
49- tokenChecked = true ;
84+ const token = localStorage . getItem ( TOKEN_LS_KEY ) ;
85+ return token || null ;
86+ } catch {
87+ return null ;
88+ }
89+ }
5090
51- if ( cachedToken ) {
52- console . log ( '[GitStack] Using authenticated GitHub API requests' ) ;
91+ /**
92+ * Get GitHub token from storage with fallback chain
93+ */
94+ async function getGitHubToken ( ) : Promise < string | null > {
95+ // Return cached if we've already checked
96+ if ( tokenChecked && cachedToken ) return cachedToken ;
97+
98+ // 1. Try extension storage (with retry)
99+ if ( storageAvailable ) {
100+ const extToken = await tryExtensionStorage ( ) ;
101+ if ( extToken ) {
102+ cachedToken = extToken ;
103+ tokenChecked = true ;
104+ console . log ( '[GitStack] Using authenticated GitHub API requests (from extension storage)' ) ;
105+ return cachedToken ;
53106 }
107+ }
54108
55- return cachedToken ;
56- } catch ( e ) {
57- console . warn ( '[GitStack] Could not access extension storage:' , e ) ;
109+ // 2. Try localStorage fallback
110+ const lsToken = tryLocalStorage ( ) ;
111+ if ( lsToken ) {
112+ cachedToken = lsToken ;
58113 tokenChecked = true ;
59- return null ;
114+ console . log ( '[GitStack] Using authenticated GitHub API requests (from localStorage fallback)' ) ;
115+ return cachedToken ;
60116 }
117+
118+ // 3. No token found
119+ tokenChecked = true ;
120+ return null ;
61121}
62122
63123/**
@@ -236,21 +296,93 @@ export async function fetchRawFile(
236296}
237297
238298/**
239- * Set GitHub token (called from popup settings)
299+ * Set GitHub token with fallback storage layers
300+ * Returns status: 'success' | 'partial' | 'memory_only' | 'failed'
240301 */
241- export async function setGitHubToken ( token : string | null ) : Promise < void > {
242- try {
243- if ( token ) {
244- await browser . storage . sync . set ( { githubToken : token } ) ;
245- cachedToken = token ;
246- } else {
247- await browser . storage . sync . remove ( 'githubToken' ) ;
248- cachedToken = null ;
302+ export async function setGitHubToken ( token : string | null ) : Promise < {
303+ success : boolean ;
304+ status : 'success' | 'partial' | 'memory_only' | 'failed' ;
305+ message : string ;
306+ } > {
307+ let syncSuccess = false ;
308+ let localSuccess = false ;
309+ let lsSuccess = false ;
310+
311+ // Always update memory cache first
312+ cachedToken = token ;
313+ tokenChecked = true ;
314+
315+ if ( token ) {
316+ // Try sync storage (best - syncs across devices)
317+ try {
318+ await browser . storage . sync . set ( { [ TOKEN_KEY ] : token } ) ;
319+ syncSuccess = true ;
320+ } catch ( e ) {
321+ console . warn ( '[GitStack] Sync storage failed:' , e ) ;
249322 }
250- tokenChecked = true ;
251- console . log ( '[GitStack] GitHub token updated' ) ;
252- } catch ( e ) {
253- console . warn ( '[GitStack] Could not save token:' , e ) ;
323+
324+ // Try local storage (extension-only fallback)
325+ try {
326+ await browser . storage . local . set ( { [ TOKEN_KEY ] : token } ) ;
327+ localSuccess = true ;
328+ } catch ( e ) {
329+ console . warn ( '[GitStack] Local storage failed:' , e ) ;
330+ }
331+
332+ // Try localStorage (works in content scripts)
333+ try {
334+ localStorage . setItem ( TOKEN_LS_KEY , token ) ;
335+ lsSuccess = true ;
336+ } catch ( e ) {
337+ console . warn ( '[GitStack] localStorage failed:' , e ) ;
338+ }
339+ } else {
340+ // Remove token from all storage layers
341+ try {
342+ await browser . storage . sync . remove ( TOKEN_KEY ) ;
343+ syncSuccess = true ;
344+ } catch { }
345+
346+ try {
347+ await browser . storage . local . remove ( TOKEN_KEY ) ;
348+ localSuccess = true ;
349+ } catch { }
350+
351+ try {
352+ localStorage . removeItem ( TOKEN_LS_KEY ) ;
353+ lsSuccess = true ;
354+ } catch { }
355+ }
356+
357+ // Determine status
358+ if ( syncSuccess || localSuccess ) {
359+ console . log ( '[GitStack] Token saved to extension storage' ) ;
360+ return {
361+ success : true ,
362+ status : 'success' ,
363+ message : 'Token saved successfully'
364+ } ;
365+ } else if ( lsSuccess ) {
366+ console . log ( '[GitStack] Token saved to localStorage only (extension storage unavailable)' ) ;
367+ return {
368+ success : true ,
369+ status : 'partial' ,
370+ message : 'Token saved (may not sync across devices)'
371+ } ;
372+ } else if ( token ) {
373+ // Only memory worked
374+ console . log ( '[GitStack] Token stored in memory only (all storage failed)' ) ;
375+ return {
376+ success : true ,
377+ status : 'memory_only' ,
378+ message : 'Token active for this session only'
379+ } ;
380+ } else {
381+ return {
382+ success : true ,
383+ status : 'success' ,
384+ message : 'Token removed'
385+ } ;
254386 }
255387}
256388
@@ -261,3 +393,48 @@ export async function hasGitHubToken(): Promise<boolean> {
261393 const token = await getGitHubToken ( ) ;
262394 return token !== null && token . length > 0 ;
263395}
396+
397+ /**
398+ * Validate token by making a test API call
399+ */
400+ export async function validateGitHubToken ( token : string ) : Promise < {
401+ valid : boolean ;
402+ scopes : string [ ] ;
403+ rateLimit : number ;
404+ error ?: string ;
405+ } > {
406+ try {
407+ const res = await fetch ( 'https://api.github.com/user' , {
408+ headers : {
409+ 'Authorization' : `Bearer ${ token } ` ,
410+ 'Accept' : 'application/vnd.github+json' ,
411+ }
412+ } ) ;
413+
414+ if ( res . ok ) {
415+ const scopes = res . headers . get ( 'X-OAuth-Scopes' ) ?. split ( ', ' ) || [ ] ;
416+ const rateLimit = parseInt ( res . headers . get ( 'X-RateLimit-Limit' ) || '5000' , 10 ) ;
417+ return { valid : true , scopes, rateLimit } ;
418+ } else if ( res . status === 401 ) {
419+ return { valid : false , scopes : [ ] , rateLimit : 0 , error : 'Invalid token' } ;
420+ } else {
421+ return { valid : false , scopes : [ ] , rateLimit : 0 , error : `API error: ${ res . status } ` } ;
422+ }
423+ } catch ( e ) {
424+ return { valid : false , scopes : [ ] , rateLimit : 0 , error : 'Network error' } ;
425+ }
426+ }
427+
428+ /**
429+ * Clear token from all storage layers
430+ */
431+ export async function clearGitHubToken ( ) : Promise < void > {
432+ cachedToken = null ;
433+ tokenChecked = true ;
434+
435+ try { await browser . storage . sync . remove ( TOKEN_KEY ) ; } catch { }
436+ try { await browser . storage . local . remove ( TOKEN_KEY ) ; } catch { }
437+ try { localStorage . removeItem ( TOKEN_LS_KEY ) ; } catch { }
438+
439+ console . log ( '[GitStack] Token cleared from all storage' ) ;
440+ }
0 commit comments