@carmaclouds/core 2.3.1

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 (118) hide show
  1. package/dist/cache/CacheManager.d.ts.map +1 -0
  2. package/dist/cache/CacheManager.js +131 -0
  3. package/dist/cache/CacheManager.js.map +1 -0
  4. package/dist/index.d.ts +18 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +22 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/ir/index.d.ts +11 -0
  9. package/dist/ir/index.d.ts.map +1 -0
  10. package/dist/ir/index.js +9 -0
  11. package/dist/ir/index.js.map +1 -0
  12. package/dist/ir/normalize.d.ts +10 -0
  13. package/dist/ir/normalize.d.ts.map +1 -0
  14. package/dist/ir/normalize.js +207 -0
  15. package/dist/ir/normalize.js.map +1 -0
  16. package/dist/ir/persistence.d.ts +26 -0
  17. package/dist/ir/persistence.d.ts.map +1 -0
  18. package/dist/ir/persistence.js +21 -0
  19. package/dist/ir/persistence.js.map +1 -0
  20. package/dist/ir/sync.d.ts +12 -0
  21. package/dist/ir/sync.d.ts.map +1 -0
  22. package/dist/ir/sync.js +36 -0
  23. package/dist/ir/sync.js.map +1 -0
  24. package/dist/ir/types.d.ts +143 -0
  25. package/dist/ir/types.d.ts.map +1 -0
  26. package/dist/ir/types.js +13 -0
  27. package/dist/ir/types.js.map +1 -0
  28. package/dist/ir/views/dnd5e.d.ts +40 -0
  29. package/dist/ir/views/dnd5e.d.ts.map +1 -0
  30. package/dist/ir/views/dnd5e.js +50 -0
  31. package/dist/ir/views/dnd5e.js.map +1 -0
  32. package/dist/render/character.d.ts +19 -0
  33. package/dist/render/character.d.ts.map +1 -0
  34. package/dist/render/character.js +156 -0
  35. package/dist/render/character.js.map +1 -0
  36. package/dist/render/h.d.ts +27 -0
  37. package/dist/render/h.d.ts.map +1 -0
  38. package/dist/render/h.js +64 -0
  39. package/dist/render/h.js.map +1 -0
  40. package/dist/render/index.d.ts +11 -0
  41. package/dist/render/index.d.ts.map +1 -0
  42. package/dist/render/index.js +8 -0
  43. package/dist/render/index.js.map +1 -0
  44. package/dist/render/mount.d.ts +31 -0
  45. package/dist/render/mount.d.ts.map +1 -0
  46. package/dist/render/mount.js +63 -0
  47. package/dist/render/mount.js.map +1 -0
  48. package/dist/supabase/fields.d.ts.map +1 -0
  49. package/dist/supabase/fields.js +120 -0
  50. package/dist/supabase/fields.js.map +1 -0
  51. package/dist/types/character.d.ts.map +1 -0
  52. package/dist/types/character.js +5 -0
  53. package/dist/types/character.js.map +1 -0
  54. package/package.json +73 -0
  55. package/src/browser.js +51 -0
  56. package/src/cache/CacheManager.ts +174 -0
  57. package/src/common/browser-polyfill.js +319 -0
  58. package/src/common/debug.js +123 -0
  59. package/src/common/html-utils.js +134 -0
  60. package/src/common/theme-manager.js +265 -0
  61. package/src/index.ts +25 -0
  62. package/src/ir/__fixtures__/dnd5e-character.json +75962 -0
  63. package/src/ir/__fixtures__/non-dnd-character.json +14218 -0
  64. package/src/ir/index.ts +10 -0
  65. package/src/ir/normalize.ts +245 -0
  66. package/src/ir/persistence.ts +37 -0
  67. package/src/ir/sync.ts +49 -0
  68. package/src/ir/types.ts +161 -0
  69. package/src/ir/views/dnd5e.ts +94 -0
  70. package/src/lib/indexeddb-cache.js +320 -0
  71. package/src/modules/action-announcements.js +102 -0
  72. package/src/modules/action-display.js +1557 -0
  73. package/src/modules/action-executor.js +860 -0
  74. package/src/modules/action-filters.js +167 -0
  75. package/src/modules/action-options.js +117 -0
  76. package/src/modules/card-creator.js +142 -0
  77. package/src/modules/character-portrait.js +169 -0
  78. package/src/modules/character-trait-popups.js +959 -0
  79. package/src/modules/character-traits.js +814 -0
  80. package/src/modules/class-feature-edge-cases.js +1320 -0
  81. package/src/modules/color-utils.js +69 -0
  82. package/src/modules/combat-maneuver-edge-cases.js +660 -0
  83. package/src/modules/companions-manager.js +178 -0
  84. package/src/modules/concentration-tracker.js +178 -0
  85. package/src/modules/data-manager.js +514 -0
  86. package/src/modules/dice-roller.js +719 -0
  87. package/src/modules/effects-manager.js +743 -0
  88. package/src/modules/feature-modals.js +1264 -0
  89. package/src/modules/formula-resolver.js +444 -0
  90. package/src/modules/gm-mode.js +184 -0
  91. package/src/modules/health-modals.js +399 -0
  92. package/src/modules/hp-management.js +752 -0
  93. package/src/modules/inventory-manager.js +242 -0
  94. package/src/modules/macro-system.js +825 -0
  95. package/src/modules/notification-system.js +92 -0
  96. package/src/modules/racial-feature-edge-cases.js +746 -0
  97. package/src/modules/resource-manager.js +775 -0
  98. package/src/modules/sheet-builder.js +654 -0
  99. package/src/modules/spell-action-modals.js +583 -0
  100. package/src/modules/spell-cards.js +602 -0
  101. package/src/modules/spell-casting.js +723 -0
  102. package/src/modules/spell-display.js +314 -0
  103. package/src/modules/spell-edge-cases.js +509 -0
  104. package/src/modules/spell-macros.js +201 -0
  105. package/src/modules/spell-modals.js +1221 -0
  106. package/src/modules/spell-slots.js +224 -0
  107. package/src/modules/status-bar-bridge.js +101 -0
  108. package/src/modules/ui-utilities.js +284 -0
  109. package/src/modules/warlock-invocations.js +219 -0
  110. package/src/modules/window-management.js +211 -0
  111. package/src/render/character.ts +234 -0
  112. package/src/render/h.ts +74 -0
  113. package/src/render/index.ts +10 -0
  114. package/src/render/mount.ts +94 -0
  115. package/src/supabase/client.js +1383 -0
  116. package/src/supabase/config.js +60 -0
  117. package/src/supabase/fields.ts +129 -0
  118. package/src/types/character.ts +85 -0
@@ -0,0 +1,1383 @@
1
+ /**
2
+ * Supabase Client for Token Persistence
3
+ * Stores and retrieves DiceCloud auth tokens across sessions/browsers
4
+ */
5
+
6
+ // Supabase configuration
7
+ const SUPABASE_URL = 'https://luiesmfjdcmpywavvfqm.supabase.co';
8
+ const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imx1aWVzbWZqZGNtcHl3YXZ2ZnFtIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk4ODYxNDksImV4cCI6MjA4NTQ2MjE0OX0.oqjHFf2HhCLcanh0HVryoQH7iSV7E9dHHZJdYehxZ0U';
9
+
10
+ /**
11
+ * Supabase Token Manager
12
+ */
13
+ class SupabaseTokenManager {
14
+ constructor() {
15
+ this.supabaseUrl = SUPABASE_URL;
16
+ this.supabaseKey = SUPABASE_ANON_KEY;
17
+ this.tableName = 'auth_tokens';
18
+
19
+ // HEAVY character data cache to prevent egress costs
20
+ // Cache persists for 1 hour in memory + 24 hours in localStorage
21
+ this.characterCache = new Map();
22
+ this.cacheExpiryMs = 60 * 60 * 1000; // 1 hour memory cache TTL
23
+ this.persistentCacheExpiryMs = 24 * 60 * 60 * 1000; // 24 hours localStorage TTL
24
+ this.cacheStorageKey = 'owlcloud_character_cache';
25
+
26
+ // Load persistent cache on initialization
27
+ this.loadPersistentCache();
28
+ }
29
+
30
+ /**
31
+ * Generate a unique user ID based on browser fingerprint
32
+ * Uses a persistent stored ID if available to survive browser updates/resolution changes
33
+ */
34
+ generateUserId() {
35
+ // Try to return cached ID synchronously (set by async method)
36
+ if (this._cachedUserId) {
37
+ return this._cachedUserId;
38
+ }
39
+
40
+ // Fallback to fingerprint-based ID (used on first run before stored ID is loaded)
41
+ return this._generateFingerprintUserId();
42
+ }
43
+
44
+ /**
45
+ * Generate fingerprint-based user ID (internal helper)
46
+ */
47
+ _generateFingerprintUserId() {
48
+ // Create a simple fingerprint from available browser info
49
+ const fingerprint = [
50
+ navigator.userAgent,
51
+ navigator.language,
52
+ // Use fallback for screen in service workers
53
+ (typeof screen !== 'undefined' ? screen.width + 'x' + screen.height : 'unknown'),
54
+ new Date().getTimezoneOffset()
55
+ ].join('|');
56
+
57
+ // Simple hash to create a consistent ID
58
+ let hash = 0;
59
+ for (let i = 0; i < fingerprint.length; i++) {
60
+ const char = fingerprint.charCodeAt(i);
61
+ hash = ((hash << 5) - hash) + char;
62
+ hash = hash & hash; // Convert to 32-bit integer
63
+ }
64
+ return 'user_' + Math.abs(hash).toString(36);
65
+ }
66
+
67
+ /**
68
+ * Get or create a persistent user ID that survives browser updates
69
+ * This prevents auth loss when Firefox updates change the userAgent
70
+ */
71
+ async getOrCreatePersistentUserId() {
72
+ try {
73
+ // Check if we have a stored persistent ID
74
+ const stored = await browserAPI.storage.local.get(['persistentBrowserUserId']);
75
+
76
+ if (stored.persistentBrowserUserId) {
77
+ this._cachedUserId = stored.persistentBrowserUserId;
78
+ return stored.persistentBrowserUserId;
79
+ }
80
+
81
+ // Generate new ID based on fingerprint and store it
82
+ const newId = this._generateFingerprintUserId();
83
+ await browserAPI.storage.local.set({ persistentBrowserUserId: newId });
84
+ this._cachedUserId = newId;
85
+
86
+ debug.log('🔑 Created persistent browser user ID:', newId);
87
+ return newId;
88
+ } catch (error) {
89
+ debug.error('Failed to get/create persistent user ID:', error);
90
+ // Fall back to fingerprint-based ID
91
+ return this._generateFingerprintUserId();
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Generate a unique session ID for this browser instance
97
+ */
98
+ generateSessionId() {
99
+ // Create a more unique session ID using additional entropy
100
+ const sessionData = [
101
+ navigator.userAgent,
102
+ navigator.language,
103
+ // Use fallback for screen in service workers
104
+ (typeof screen !== 'undefined' ? screen.width + 'x' + screen.height : 'unknown'),
105
+ new Date().getTimezoneOffset(),
106
+ Math.random().toString(36),
107
+ Date.now().toString(36)
108
+ ].join('|');
109
+
110
+ let hash = 0;
111
+ for (let i = 0; i < sessionData.length; i++) {
112
+ const char = sessionData.charCodeAt(i);
113
+ hash = ((hash << 5) - hash) + char;
114
+ hash = hash & hash;
115
+ }
116
+ return 'session_' + Math.abs(hash).toString(36);
117
+ }
118
+
119
+ /**
120
+ * Normalize date to ISO 8601 format for PostgreSQL
121
+ * Handles Meteor date formats like "Sat Jan 25 2025 12:00:00 GMT+0300"
122
+ */
123
+ normalizeDate(dateValue) {
124
+ if (!dateValue) return null;
125
+ try {
126
+ const date = new Date(dateValue);
127
+ if (isNaN(date.getTime())) {
128
+ debug.warn('⚠️ Invalid date value:', dateValue);
129
+ return null;
130
+ }
131
+ return date.toISOString();
132
+ } catch (e) {
133
+ debug.warn('⚠️ Failed to normalize date:', dateValue, e);
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Store auth token in Supabase
140
+ */
141
+ async storeToken(tokenData) {
142
+ try {
143
+ debug.log('🌐 Storing token in Supabase...');
144
+
145
+ // Clear any previous invalidation/conflict info FIRST when logging in
146
+ // This prevents stale data from causing login failures
147
+ await browserAPI.storage.local.remove(['sessionInvalidated', 'sessionConflict']);
148
+
149
+ // Use persistent browser ID for consistent storage/retrieval across browser updates
150
+ const visitorId = await this.getOrCreatePersistentUserId();
151
+ const sessionId = this.generateSessionId();
152
+
153
+ // Invalidate all OTHER sessions for this DiceCloud account (different browsers)
154
+ // This ensures only one browser can be logged in at a time per account
155
+ if (tokenData.userId) {
156
+ await this.invalidateOtherSessions(tokenData.userId, sessionId);
157
+ }
158
+
159
+ // Check for existing sessions with different tokens (same browser, different account)
160
+ const conflictCheck = await this.checkForTokenConflicts(visitorId, tokenData.token);
161
+
162
+ if (conflictCheck.hasConflict) {
163
+ debug.log('⚠️ Token conflict detected - different browser logged in');
164
+ // Store conflict info for later display
165
+ await this.storeConflictInfo(conflictCheck);
166
+ }
167
+
168
+ // Normalize token_expires to ISO 8601 format for PostgreSQL
169
+ const normalizedTokenExpires = this.normalizeDate(tokenData.tokenExpires);
170
+
171
+ const payload = {
172
+ user_id: visitorId, // Browser fingerprint for cross-session lookup
173
+ session_id: sessionId, // Unique session identifier
174
+ dicecloud_token: tokenData.token,
175
+ username: tokenData.username || 'DiceCloud User',
176
+ user_id_dicecloud: tokenData.userId, // Store DiceCloud ID separately
177
+ token_expires: normalizedTokenExpires,
178
+ browser_info: {
179
+ userAgent: navigator.userAgent,
180
+ authId: tokenData.authId, // Store authId in browser_info for reference
181
+ timestamp: new Date().toISOString(),
182
+ sessionId: sessionId
183
+ },
184
+ updated_at: new Date().toISOString(),
185
+ last_seen: new Date().toISOString(),
186
+ // Clear any previous invalidation (this is a fresh login)
187
+ invalidated_at: null,
188
+ invalidated_by_session: null,
189
+ invalidated_reason: null
190
+ };
191
+
192
+ // Only include Discord fields if provided, to avoid overwriting existing data with null
193
+ if (tokenData.discordUserId) {
194
+ payload.discord_user_id = tokenData.discordUserId;
195
+ }
196
+ if (tokenData.discordUsername) {
197
+ payload.discord_username = tokenData.discordUsername;
198
+ }
199
+ if (tokenData.discordGlobalName) {
200
+ payload.discord_global_name = tokenData.discordGlobalName;
201
+ }
202
+
203
+ debug.log('🌐 Storing with browser ID:', visitorId, 'Session ID:', sessionId, 'DiceCloud ID:', tokenData.authId);
204
+ if (tokenData.discordUserId) {
205
+ debug.log('🔗 Linking Discord account:', tokenData.discordUsername);
206
+ }
207
+
208
+ const response = await fetch(`${this.supabaseUrl}/rest/v1/${this.tableName}`, {
209
+ method: 'POST',
210
+ headers: {
211
+ 'apikey': this.supabaseKey,
212
+ 'Authorization': `Bearer ${this.supabaseKey}`,
213
+ 'Content-Type': 'application/json',
214
+ 'Prefer': 'resolution=merge-duplicates,return=minimal'
215
+ },
216
+ body: JSON.stringify(payload)
217
+ });
218
+
219
+ debug.log('📥 Supabase POST response status:', response.status);
220
+
221
+ if (!response.ok) {
222
+ const errorText = await response.text();
223
+ debug.log('⚠️ Supabase POST failed, trying PATCH. Error:', response.status, errorText);
224
+
225
+ // Try to update if insert fails (user already exists)
226
+ const updatePayload = {
227
+ dicecloud_token: tokenData.token,
228
+ username: tokenData.username || 'DiceCloud User',
229
+ user_id_dicecloud: tokenData.userId,
230
+ token_expires: normalizedTokenExpires,
231
+ session_id: sessionId, // Update session ID to match local storage
232
+ browser_info: {
233
+ userAgent: navigator.userAgent,
234
+ authId: tokenData.authId,
235
+ timestamp: new Date().toISOString(),
236
+ sessionId: sessionId
237
+ },
238
+ updated_at: new Date().toISOString(),
239
+ last_seen: new Date().toISOString(),
240
+ // Clear any previous invalidation (this is a fresh login)
241
+ invalidated_at: null,
242
+ invalidated_by_session: null,
243
+ invalidated_reason: null
244
+ };
245
+
246
+ // Only include Discord fields if provided, to avoid overwriting existing data with null
247
+ if (tokenData.discordUserId) {
248
+ updatePayload.discord_user_id = tokenData.discordUserId;
249
+ }
250
+ if (tokenData.discordUsername) {
251
+ updatePayload.discord_username = tokenData.discordUsername;
252
+ }
253
+ if (tokenData.discordGlobalName) {
254
+ updatePayload.discord_global_name = tokenData.discordGlobalName;
255
+ }
256
+
257
+ const updateResponse = await fetch(`${this.supabaseUrl}/rest/v1/${this.tableName}?user_id=eq.${visitorId}`, {
258
+ method: 'PATCH',
259
+ headers: {
260
+ 'apikey': this.supabaseKey,
261
+ 'Authorization': `Bearer ${this.supabaseKey}`,
262
+ 'Content-Type': 'application/json',
263
+ 'Prefer': 'return=minimal'
264
+ },
265
+ body: JSON.stringify(updatePayload)
266
+ });
267
+
268
+ debug.log('📥 Supabase PATCH response status:', updateResponse.status);
269
+
270
+ if (!updateResponse.ok) {
271
+ const patchErrorText = await updateResponse.text();
272
+ debug.error('❌ Supabase PATCH also failed:', updateResponse.status, patchErrorText);
273
+ throw new Error(`Supabase update failed: ${updateResponse.status} - ${patchErrorText}`);
274
+ }
275
+ }
276
+
277
+ // Store session ID locally AFTER database update succeeds to avoid race condition
278
+ await this.storeCurrentSession(sessionId);
279
+
280
+ debug.log('✅ Token stored in Supabase successfully');
281
+ return { success: true };
282
+ } catch (error) {
283
+ debug.error('❌ Failed to store token in Supabase:', error);
284
+ return { success: false, error: error.message };
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Retrieve auth token from Supabase
290
+ */
291
+ async retrieveToken() {
292
+ try {
293
+ debug.log('🌐 Retrieving token from Supabase...');
294
+
295
+ // Use persistent user ID to survive browser updates
296
+ const userId = await this.getOrCreatePersistentUserId();
297
+ debug.log('🔍 Using persistent user ID for lookup:', userId);
298
+
299
+ const url = `${this.supabaseUrl}/rest/v1/${this.tableName}?user_id=eq.${userId}&select=*`;
300
+ debug.log('🌐 Supabase query URL:', url);
301
+
302
+ const response = await fetch(url, {
303
+ method: 'GET',
304
+ headers: {
305
+ 'apikey': this.supabaseKey,
306
+ 'Authorization': `Bearer ${this.supabaseKey}`,
307
+ 'Content-Type': 'application/json'
308
+ }
309
+ });
310
+
311
+ debug.log('📥 Supabase response status:', response.status);
312
+ debug.log('📥 Supabase response headers:', response.headers);
313
+
314
+ if (!response.ok) {
315
+ const errorText = await response.text();
316
+ debug.error('❌ Supabase fetch failed:', response.status, errorText);
317
+ throw new Error(`Supabase fetch failed: ${response.status} - ${errorText}`);
318
+ }
319
+
320
+ const data = await response.json();
321
+ debug.log('📦 Supabase response data:', data);
322
+
323
+ if (data && data.length > 0) {
324
+ const tokenData = data[0];
325
+ debug.log('🔍 Found token data:', tokenData);
326
+
327
+ // Check if this session was invalidated by another login
328
+ if (tokenData.invalidated_at) {
329
+ debug.log('🚫 Session was invalidated at:', tokenData.invalidated_at);
330
+ debug.log('🚫 Invalidated reason:', tokenData.invalidated_reason);
331
+
332
+ // Clear local auth data to prevent auto-restore loops
333
+ await browserAPI.storage.local.remove(['diceCloudToken', 'diceCloudUserId', 'tokenExpires', 'username', 'currentSessionId']);
334
+
335
+ // Store info about who logged us out for display
336
+ await browserAPI.storage.local.set({
337
+ sessionInvalidated: {
338
+ at: tokenData.invalidated_at,
339
+ reason: tokenData.invalidated_reason || 'logged_in_elsewhere',
340
+ bySession: tokenData.invalidated_by_session
341
+ }
342
+ });
343
+
344
+ // Delete the invalidated record from Supabase to clean up
345
+ await this.deleteToken();
346
+
347
+ return {
348
+ success: false,
349
+ error: 'Session invalidated',
350
+ invalidated: true,
351
+ reason: tokenData.invalidated_reason || 'Another browser logged in with this account'
352
+ };
353
+ }
354
+
355
+ // Check if token is expired
356
+ if (tokenData.token_expires) {
357
+ const expiryDate = new Date(tokenData.token_expires);
358
+ const now = new Date();
359
+ debug.log('⏰ Token expiry check:', { expiryDate, now, expired: now >= expiryDate });
360
+
361
+ if (now >= expiryDate) {
362
+ debug.log('⚠️ Supabase token expired, removing...');
363
+ await this.deleteToken();
364
+ return { success: false, error: 'Token expired' };
365
+ }
366
+ }
367
+
368
+ // Restore session ID locally to match database for session validity checks
369
+ if (tokenData.session_id) {
370
+ await this.storeCurrentSession(tokenData.session_id);
371
+ debug.log('💾 Restored session ID from Supabase:', tokenData.session_id);
372
+ }
373
+
374
+ debug.log('✅ Token retrieved from Supabase');
375
+ return {
376
+ success: true,
377
+ token: tokenData.dicecloud_token,
378
+ username: tokenData.username,
379
+ userId: tokenData.user_id_dicecloud,
380
+ tokenExpires: tokenData.token_expires,
381
+ discordUserId: tokenData.discord_user_id,
382
+ discordUsername: tokenData.discord_username,
383
+ discordGlobalName: tokenData.discord_global_name
384
+ };
385
+ } else {
386
+ debug.log('ℹ️ No token found in Supabase for user:', userId);
387
+ return { success: false, error: 'No token found' };
388
+ }
389
+ } catch (error) {
390
+ debug.error('❌ Failed to retrieve token from Supabase:', error);
391
+ return { success: false, error: error.message };
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Get raw token data from database (used for session checks)
397
+ * Returns the full database record without processing
398
+ */
399
+ async getTokenFromDatabase() {
400
+ try {
401
+ debug.log('🌐 Getting raw token data from Supabase...');
402
+
403
+ // Use persistent user ID
404
+ const userId = await this.getOrCreatePersistentUserId();
405
+ debug.log('🔍 Using persistent user ID:', userId);
406
+
407
+ const url = `${this.supabaseUrl}/rest/v1/${this.tableName}?user_id=eq.${userId}&select=*`;
408
+
409
+ const response = await fetch(url, {
410
+ method: 'GET',
411
+ headers: {
412
+ 'apikey': this.supabaseKey,
413
+ 'Authorization': `Bearer ${this.supabaseKey}`,
414
+ 'Content-Type': 'application/json'
415
+ }
416
+ });
417
+
418
+ if (!response.ok) {
419
+ const errorText = await response.text();
420
+ debug.error('❌ Supabase fetch failed:', response.status, errorText);
421
+ return { success: false, error: `Fetch failed: ${response.status}` };
422
+ }
423
+
424
+ const data = await response.json();
425
+
426
+ if (data && data.length > 0) {
427
+ debug.log('✅ Found token data in database');
428
+ return {
429
+ success: true,
430
+ tokenData: data[0]
431
+ };
432
+ } else {
433
+ debug.log('ℹ️ No token found in database');
434
+ return { success: false, error: 'No token found' };
435
+ }
436
+ } catch (error) {
437
+ debug.error('❌ Failed to get token from database:', error);
438
+ return { success: false, error: error.message };
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Delete token from Supabase (logout)
444
+ */
445
+ async deleteToken() {
446
+ try {
447
+ debug.log('🌐 Deleting token from Supabase...');
448
+
449
+ const userId = await this.getOrCreatePersistentUserId();
450
+ const response = await fetch(`${this.supabaseUrl}/rest/v1/${this.tableName}?user_id=eq.${userId}`, {
451
+ method: 'DELETE',
452
+ headers: {
453
+ 'apikey': this.supabaseKey,
454
+ 'Authorization': `Bearer ${this.supabaseKey}`,
455
+ 'Content-Type': 'application/json'
456
+ }
457
+ });
458
+
459
+ if (!response.ok) {
460
+ throw new Error(`Supabase delete failed: ${response.status}`);
461
+ }
462
+
463
+ debug.log('✅ Token deleted from Supabase');
464
+ return { success: true };
465
+ } catch (error) {
466
+ debug.error('❌ Failed to delete token from Supabase:', error);
467
+ return { success: false, error: error.message };
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Store character data in Supabase (alias for storeCharacter)
473
+ * Used by popup for character cloud sync
474
+ */
475
+ async storeCharacterData(characterSyncData) {
476
+ try {
477
+ debug.log('🎭 Storing character data in cloud:', characterSyncData.characterId);
478
+
479
+ // Extract character data from the sync payload
480
+ const characterData = characterSyncData.characterData;
481
+
482
+ // Use the existing storeCharacter method
483
+ const result = await this.storeCharacter(characterData);
484
+
485
+ if (result.success) {
486
+ debug.log('✅ Character data stored in cloud successfully');
487
+ } else {
488
+ debug.error('❌ Failed to store character data in cloud:', result.error);
489
+ }
490
+
491
+ return result;
492
+ } catch (error) {
493
+ debug.error('❌ Error in storeCharacterData:', error);
494
+ return { success: false, error: error.message };
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Get character data from Supabase (alias for getCharacter)
500
+ * Used by popup for character cloud sync
501
+ * Implements caching to reduce egress costs
502
+ */
503
+ async getCharacterData(diceCloudUserId, forceRefresh = false) {
504
+ try {
505
+ const cacheKey = `characters_${diceCloudUserId}`;
506
+
507
+ // Check cache first (unless force refresh)
508
+ if (!forceRefresh) {
509
+ const cached = this.characterCache.get(cacheKey);
510
+ if (cached && (Date.now() - cached.timestamp < this.cacheExpiryMs)) {
511
+ debug.log('📦 Using cached character data (cache hit)');
512
+ return {
513
+ success: true,
514
+ characters: cached.characters,
515
+ count: cached.count,
516
+ fromCache: true
517
+ };
518
+ }
519
+ }
520
+
521
+ debug.log('🎭 Retrieving character data from cloud for user:', diceCloudUserId);
522
+
523
+ // Get all characters for this user
524
+ const response = await fetch(
525
+ `${this.supabaseUrl}/rest/v1/clouds_characters?user_id_dicecloud=eq.${diceCloudUserId}&select=*`,
526
+ {
527
+ headers: {
528
+ 'apikey': this.supabaseKey,
529
+ 'Authorization': `Bearer ${this.supabaseKey}`
530
+ }
531
+ }
532
+ );
533
+
534
+ if (!response.ok) {
535
+ throw new Error(`Failed to fetch characters: ${response.status}`);
536
+ }
537
+
538
+ const data = await response.json();
539
+
540
+ // Format the response to match expected structure
541
+ const characters = {};
542
+ data.forEach(character => {
543
+ characters[character.dicecloud_character_id] = {
544
+ characterData: character,
545
+ timestamp: character.updated_at
546
+ };
547
+ });
548
+
549
+ // Store in cache
550
+ this.characterCache.set(cacheKey, {
551
+ characters: characters,
552
+ count: data.length,
553
+ timestamp: Date.now()
554
+ });
555
+
556
+ // Save to persistent storage for 24h caching
557
+ await this.savePersistentCache();
558
+
559
+ debug.log(`📦 Retrieved ${data.length} characters from cloud (cached for 1h memory + 24h storage)`);
560
+ return {
561
+ success: true,
562
+ characters: characters,
563
+ count: data.length,
564
+ fromCache: false
565
+ };
566
+ } catch (error) {
567
+ debug.error('❌ Failed to get character data:', error);
568
+ return { success: false, error: error.message };
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Store character data in Supabase
574
+ * Links character to Discord pairing for bot commands
575
+ */
576
+ async storeCharacter(characterData, pairingCode = null) {
577
+ try {
578
+ debug.log('🎭 Storing character in Supabase:', characterData.name);
579
+
580
+ const payload = {
581
+ user_id_dicecloud: characterData.dicecloudUserId || characterData.userId || null,
582
+ dicecloud_character_id: characterData.id,
583
+ character_name: characterData.name || 'Unknown',
584
+ race: characterData.race || null,
585
+ class: characterData.class || null,
586
+ level: characterData.level || 1,
587
+ alignment: characterData.alignment || null,
588
+ hit_points: characterData.hitPoints || { current: 0, max: 0 },
589
+ hit_dice: characterData.hitDice || { current: 0, max: 0, type: 'd8' },
590
+ temporary_hp: characterData.temporaryHP || 0,
591
+ death_saves: characterData.deathSaves || { successes: 0, failures: 0 },
592
+ inspiration: characterData.inspiration || false,
593
+ armor_class: characterData.armorClass || 10,
594
+ speed: characterData.speed || 30,
595
+ initiative: characterData.initiative || 0,
596
+ proficiency_bonus: characterData.proficiencyBonus || 2,
597
+ attributes: characterData.attributes || {},
598
+ attribute_mods: characterData.attributeMods || {},
599
+ saves: characterData.saves || {},
600
+ skills: characterData.skills || {},
601
+ spell_slots: characterData.spellSlots || {},
602
+ resources: characterData.resources || [],
603
+ conditions: characterData.conditions || [],
604
+ raw_dicecloud_data: characterData, // Store the FULL character object
605
+ updated_at: new Date().toISOString()
606
+ };
607
+
608
+ // If pairing code provided, look up the pairing to link
609
+ if (pairingCode) {
610
+ const pairingResponse = await fetch(
611
+ `${this.supabaseUrl}/rest/v1/clouds_pairings?pairing_code=eq.${pairingCode}&select=id,discord_user_id`,
612
+ {
613
+ headers: {
614
+ 'apikey': this.supabaseKey,
615
+ 'Authorization': `Bearer ${this.supabaseKey}`
616
+ }
617
+ }
618
+ );
619
+ if (pairingResponse.ok) {
620
+ const pairings = await pairingResponse.json();
621
+ if (pairings.length > 0) {
622
+ // TODO: pairing_id field requires database migration - commented out for now
623
+ // payload.pairing_id = pairings[0].id;
624
+ payload.discord_user_id = pairings[0].discord_user_id;
625
+ }
626
+ }
627
+ } else {
628
+ // No pairing code provided - try to get Discord user ID from auth_tokens or pairings
629
+ let discordUserId = null;
630
+
631
+ // First, check auth_tokens
632
+ try {
633
+ const persistentUserId = await this.getOrCreatePersistentUserId();
634
+ const authResponse = await fetch(
635
+ `${this.supabaseUrl}/rest/v1/auth_tokens?user_id=eq.${persistentUserId}&select=discord_user_id`,
636
+ {
637
+ headers: {
638
+ 'apikey': this.supabaseKey,
639
+ 'Authorization': `Bearer ${this.supabaseKey}`
640
+ }
641
+ }
642
+ );
643
+ if (authResponse.ok) {
644
+ const authTokens = await authResponse.json();
645
+ if (authTokens.length > 0 && authTokens[0].discord_user_id) {
646
+ discordUserId = authTokens[0].discord_user_id;
647
+ debug.log('✅ Found Discord user ID from auth_tokens:', discordUserId);
648
+ }
649
+ }
650
+ } catch (error) {
651
+ debug.log('⚠️ Failed to check auth_tokens for Discord user ID:', error.message);
652
+ }
653
+
654
+ // If not in auth_tokens, check pairings table for this DiceCloud user
655
+ if (!discordUserId && payload.user_id_dicecloud) {
656
+ try {
657
+ const pairingResponse = await fetch(
658
+ `${this.supabaseUrl}/rest/v1/clouds_pairings?dicecloud_user_id=eq.${payload.user_id_dicecloud}&status=eq.connected&select=discord_user_id`,
659
+ {
660
+ headers: {
661
+ 'apikey': this.supabaseKey,
662
+ 'Authorization': `Bearer ${this.supabaseKey}`
663
+ }
664
+ }
665
+ );
666
+ if (pairingResponse.ok) {
667
+ const pairings = await pairingResponse.json();
668
+ if (pairings.length > 0 && pairings[0].discord_user_id) {
669
+ discordUserId = pairings[0].discord_user_id;
670
+ debug.log('✅ Found Discord user ID from pairings:', discordUserId);
671
+ }
672
+ }
673
+ } catch (error) {
674
+ debug.log('⚠️ Failed to check pairings for Discord user ID:', error.message);
675
+ }
676
+ }
677
+
678
+ // Set the discord_user_id or use placeholder
679
+ if (discordUserId) {
680
+ payload.discord_user_id = discordUserId;
681
+ } else {
682
+ payload.discord_user_id = 'not_linked';
683
+ debug.log('⚠️ No Discord user ID found, using placeholder');
684
+ }
685
+ }
686
+
687
+ // Try to upsert (insert or update on conflict)
688
+ const response = await fetch(
689
+ `${this.supabaseUrl}/rest/v1/clouds_characters`,
690
+ {
691
+ method: 'POST',
692
+ headers: {
693
+ 'apikey': this.supabaseKey,
694
+ 'Authorization': `Bearer ${this.supabaseKey}`,
695
+ 'Content-Type': 'application/json',
696
+ 'Prefer': 'resolution=merge-duplicates,return=minimal'
697
+ },
698
+ body: JSON.stringify(payload)
699
+ }
700
+ );
701
+
702
+ if (!response.ok) {
703
+ const errorText = await response.text();
704
+ debug.log('⚠️ Character POST failed, trying PATCH:', errorText);
705
+
706
+ // Try update instead
707
+ const updateResponse = await fetch(
708
+ `${this.supabaseUrl}/rest/v1/rollcloud_characters?dicecloud_character_id=eq.${characterData.id}`,
709
+ {
710
+ method: 'PATCH',
711
+ headers: {
712
+ 'apikey': this.supabaseKey,
713
+ 'Authorization': `Bearer ${this.supabaseKey}`,
714
+ 'Content-Type': 'application/json',
715
+ 'Prefer': 'return=minimal'
716
+ },
717
+ body: JSON.stringify(payload)
718
+ }
719
+ );
720
+
721
+ if (!updateResponse.ok) {
722
+ const patchError = await updateResponse.text();
723
+ throw new Error(`Character update failed: ${patchError}`);
724
+ }
725
+ }
726
+
727
+ debug.log('✅ Character stored in Supabase:', characterData.name);
728
+
729
+ // Invalidate cache for this character
730
+ this.invalidateCharacterCache(characterData.id, payload.user_id_dicecloud, payload.discord_user_id);
731
+
732
+ return { success: true };
733
+ } catch (error) {
734
+ debug.error('❌ Failed to store character:', error);
735
+ return { success: false, error: error.message };
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Retrieve character data from Supabase by DiceCloud ID
741
+ * Implements caching to reduce egress costs
742
+ */
743
+ async getCharacter(diceCloudCharacterId, forceRefresh = false) {
744
+ try {
745
+ const cacheKey = `character_${diceCloudCharacterId}`;
746
+
747
+ // Check cache first (unless force refresh)
748
+ if (!forceRefresh) {
749
+ const cached = this.characterCache.get(cacheKey);
750
+ if (cached && (Date.now() - cached.timestamp < this.cacheExpiryMs)) {
751
+ debug.log('📦 Using cached character (cache hit)');
752
+ return { success: true, character: cached.character, fromCache: true };
753
+ }
754
+ }
755
+
756
+ const response = await fetch(
757
+ `${this.supabaseUrl}/rest/v1/clouds_characters?dicecloud_character_id=eq.${diceCloudCharacterId}&select=*`,
758
+ {
759
+ headers: {
760
+ 'apikey': this.supabaseKey,
761
+ 'Authorization': `Bearer ${this.supabaseKey}`
762
+ }
763
+ }
764
+ );
765
+
766
+ if (!response.ok) {
767
+ throw new Error(`Failed to fetch character: ${response.status}`);
768
+ }
769
+
770
+ const data = await response.json();
771
+ if (data.length > 0) {
772
+ // Store in cache
773
+ this.characterCache.set(cacheKey, {
774
+ character: data[0],
775
+ timestamp: Date.now()
776
+ });
777
+
778
+ // Save to persistent storage
779
+ await this.savePersistentCache();
780
+
781
+ return { success: true, character: data[0], fromCache: false };
782
+ }
783
+ return { success: false, error: 'Character not found' };
784
+ } catch (error) {
785
+ debug.error('❌ Failed to get character:', error);
786
+ return { success: false, error: error.message };
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Get character by Discord user ID (for bot commands)
792
+ * Implements caching to reduce egress costs
793
+ */
794
+ async getCharacterByDiscordUser(discordUserId, forceRefresh = false) {
795
+ try {
796
+ const cacheKey = `discord_${discordUserId}`;
797
+
798
+ // Check cache first (unless force refresh)
799
+ if (!forceRefresh) {
800
+ const cached = this.characterCache.get(cacheKey);
801
+ if (cached && (Date.now() - cached.timestamp < this.cacheExpiryMs)) {
802
+ debug.log('📦 Using cached Discord character (cache hit)');
803
+ return { success: true, character: cached.character, fromCache: true };
804
+ }
805
+ }
806
+
807
+ const response = await fetch(
808
+ `${this.supabaseUrl}/rest/v1/clouds_characters?discord_user_id=eq.${discordUserId}&select=*&order=updated_at.desc&limit=1`,
809
+ {
810
+ headers: {
811
+ 'apikey': this.supabaseKey,
812
+ 'Authorization': `Bearer ${this.supabaseKey}`
813
+ }
814
+ }
815
+ );
816
+
817
+ if (!response.ok) {
818
+ throw new Error(`Failed to fetch character: ${response.status}`);
819
+ }
820
+
821
+ const data = await response.json();
822
+ if (data.length > 0) {
823
+ // Store in cache
824
+ this.characterCache.set(cacheKey, {
825
+ character: data[0],
826
+ timestamp: Date.now()
827
+ });
828
+
829
+ // Save to persistent storage
830
+ await this.savePersistentCache();
831
+
832
+ return { success: true, character: data[0], fromCache: false };
833
+ }
834
+ return { success: false, error: 'No character linked to this Discord user' };
835
+ } catch (error) {
836
+ debug.error('❌ Failed to get character by Discord user:', error);
837
+ return { success: false, error: error.message };
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Get auth tokens by DiceCloud user ID
843
+ */
844
+ async getAuthTokens(dicecloudUserId) {
845
+ try {
846
+ const response = await fetch(
847
+ `${this.supabaseUrl}/rest/v1/auth_tokens?user_id_dicecloud=eq.${dicecloudUserId}&select=*`,
848
+ {
849
+ headers: {
850
+ 'apikey': this.supabaseKey,
851
+ 'Authorization': `Bearer ${this.supabaseKey}`
852
+ }
853
+ }
854
+ );
855
+
856
+ if (!response.ok) {
857
+ throw new Error(`Failed to get auth tokens: ${response.status}`);
858
+ }
859
+
860
+ return await response.json();
861
+ } catch (error) {
862
+ debug.error('❌ Failed to get auth tokens:', error);
863
+ throw error;
864
+ }
865
+ }
866
+
867
+ /**
868
+ * Update auth tokens with Discord information
869
+ */
870
+ async updateAuthTokens(dicecloudUserId, updateData) {
871
+ try {
872
+ const response = await fetch(
873
+ `${this.supabaseUrl}/rest/v1/auth_tokens?user_id_dicecloud=eq.${dicecloudUserId}`,
874
+ {
875
+ method: 'PATCH',
876
+ headers: {
877
+ 'apikey': this.supabaseKey,
878
+ 'Authorization': `Bearer ${this.supabaseKey}`,
879
+ 'Content-Type': 'application/json',
880
+ 'Prefer': 'return=minimal'
881
+ },
882
+ body: JSON.stringify(updateData)
883
+ }
884
+ );
885
+
886
+ if (!response.ok) {
887
+ const errorText = await response.text();
888
+ throw new Error(`Failed to update auth tokens: ${errorText}`);
889
+ }
890
+
891
+ debug.log('✅ Auth tokens updated successfully');
892
+ return true;
893
+ } catch (error) {
894
+ debug.error('❌ Failed to update auth tokens:', error);
895
+ throw error;
896
+ }
897
+ }
898
+
899
+ /**
900
+ * Store current session ID locally
901
+ */
902
+ async storeCurrentSession(sessionId) {
903
+ try {
904
+ await browserAPI.storage.local.set({
905
+ currentSessionId: sessionId,
906
+ sessionStartTime: Date.now()
907
+ });
908
+ debug.log('💾 Stored current session ID:', sessionId);
909
+ } catch (error) {
910
+ debug.error('❌ Failed to store session ID:', error);
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Invalidate all other sessions for the same DiceCloud account
916
+ * Called when logging in to ensure only one browser is logged in at a time
917
+ * Only invalidates sessions from OTHER browsers (different user_id)
918
+ */
919
+ async invalidateOtherSessions(diceCloudUserId, currentSessionId) {
920
+ try {
921
+ if (!diceCloudUserId) {
922
+ debug.warn('⚠️ No DiceCloud user ID provided, skipping invalidation');
923
+ return;
924
+ }
925
+
926
+ debug.log('🔒 Invalidating other sessions for DiceCloud user:', diceCloudUserId);
927
+
928
+ // Get our persistent browser ID to exclude our own sessions
929
+ const ourBrowserId = await this.getOrCreatePersistentUserId();
930
+ debug.log('🔍 Our browser ID:', ourBrowserId);
931
+
932
+ // Find all sessions for this DiceCloud account
933
+ const queryUrl = `${this.supabaseUrl}/rest/v1/${this.tableName}?user_id_dicecloud=eq.${encodeURIComponent(diceCloudUserId)}&select=user_id,session_id,username,browser_info,invalidated_at`;
934
+ debug.log('🔍 Query URL:', queryUrl);
935
+
936
+ const response = await fetch(queryUrl, {
937
+ headers: {
938
+ 'apikey': this.supabaseKey,
939
+ 'Authorization': `Bearer ${this.supabaseKey}`
940
+ }
941
+ });
942
+
943
+ if (!response.ok) {
944
+ const errorText = await response.text();
945
+ debug.warn('⚠️ Failed to fetch other sessions:', response.status, errorText);
946
+ return;
947
+ }
948
+
949
+ const otherSessions = await response.json();
950
+ debug.log('🔍 Found sessions for this account:', otherSessions.length, otherSessions);
951
+
952
+ if (otherSessions.length === 0) {
953
+ debug.log('ℹ️ No other sessions found for this DiceCloud account');
954
+ return;
955
+ }
956
+
957
+ // Mark sessions from OTHER browsers as invalidated (exclude our browser)
958
+ let invalidatedCount = 0;
959
+ for (const session of otherSessions) {
960
+ // Skip our own browser's sessions - we're replacing them, not invalidating
961
+ if (session.user_id === ourBrowserId) {
962
+ debug.log('⏭️ Skipping our own browser session:', session.session_id);
963
+ continue;
964
+ }
965
+
966
+ // Skip already invalidated sessions
967
+ if (session.invalidated_at) {
968
+ debug.log('⏭️ Session already invalidated:', session.session_id);
969
+ continue;
970
+ }
971
+
972
+ debug.log('🚫 Invalidating session from other browser:', session.session_id, 'browser:', session.user_id);
973
+
974
+ // Update the session to mark it as invalidated
975
+ const invalidateResponse = await fetch(
976
+ `${this.supabaseUrl}/rest/v1/${this.tableName}?user_id=eq.${encodeURIComponent(session.user_id)}`,
977
+ {
978
+ method: 'PATCH',
979
+ headers: {
980
+ 'apikey': this.supabaseKey,
981
+ 'Authorization': `Bearer ${this.supabaseKey}`,
982
+ 'Content-Type': 'application/json',
983
+ 'Prefer': 'return=representation'
984
+ },
985
+ body: JSON.stringify({
986
+ invalidated_at: new Date().toISOString(),
987
+ invalidated_by_session: currentSessionId,
988
+ invalidated_reason: 'logged_in_elsewhere'
989
+ })
990
+ }
991
+ );
992
+
993
+ if (invalidateResponse.ok) {
994
+ const result = await invalidateResponse.json();
995
+ debug.log('✅ Session invalidated:', session.session_id, 'Result:', result);
996
+ invalidatedCount++;
997
+ } else {
998
+ const errorText = await invalidateResponse.text();
999
+ debug.warn('⚠️ Failed to invalidate session:', session.session_id, 'Status:', invalidateResponse.status, 'Error:', errorText);
1000
+
1001
+ // If the column doesn't exist, log a helpful message
1002
+ if (errorText.includes('column') && errorText.includes('does not exist')) {
1003
+ debug.error('❌ Database migration needed! Run supabase/add_session_invalidation.sql');
1004
+ }
1005
+ }
1006
+ }
1007
+
1008
+ debug.log(`🔒 Invalidation complete: ${invalidatedCount} session(s) invalidated`);
1009
+ } catch (error) {
1010
+ debug.error('❌ Error invalidating other sessions:', error);
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Check for token conflicts with existing sessions
1016
+ */
1017
+ async checkForTokenConflicts(userId, newToken) {
1018
+ try {
1019
+ const response = await fetch(
1020
+ `${this.supabaseUrl}/rest/v1/${this.tableName}?user_id=eq.${userId}&select=dicecloud_token,session_id,browser_info,username,last_seen`,
1021
+ {
1022
+ headers: {
1023
+ 'apikey': this.supabaseKey,
1024
+ 'Authorization': `Bearer ${this.supabaseKey}`
1025
+ }
1026
+ }
1027
+ );
1028
+
1029
+ if (!response.ok) {
1030
+ debug.warn('⚠️ Failed to check for conflicts:', response.status);
1031
+ return { hasConflict: false };
1032
+ }
1033
+
1034
+ const existingSessions = await response.json();
1035
+ debug.log('🔍 Checking for conflicts with existing sessions:', existingSessions.length);
1036
+
1037
+ for (const session of existingSessions) {
1038
+ if (session.dicecloud_token !== newToken) {
1039
+ debug.log('⚠️ Found session with different token:', session.session_id);
1040
+ return {
1041
+ hasConflict: true,
1042
+ conflictingSession: {
1043
+ sessionId: session.session_id,
1044
+ username: session.username,
1045
+ lastSeen: session.last_seen,
1046
+ browserInfo: session.browser_info
1047
+ }
1048
+ };
1049
+ }
1050
+ }
1051
+
1052
+ return { hasConflict: false };
1053
+ } catch (error) {
1054
+ debug.error('❌ Error checking for conflicts:', error);
1055
+ return { hasConflict: false };
1056
+ }
1057
+ }
1058
+
1059
+ /**
1060
+ * Store conflict information for later display
1061
+ */
1062
+ async storeConflictInfo(conflictCheck) {
1063
+ try {
1064
+ await browserAPI.storage.local.set({
1065
+ sessionConflict: {
1066
+ detected: true,
1067
+ conflictingSession: conflictCheck.conflictingSession,
1068
+ detectedAt: Date.now()
1069
+ }
1070
+ });
1071
+ debug.log('💾 Stored conflict information');
1072
+ } catch (error) {
1073
+ debug.error('❌ Failed to store conflict info:', error);
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * Check if current session has been invalidated by another login
1079
+ */
1080
+ async checkSessionValidity() {
1081
+ try {
1082
+ const { currentSessionId, sessionConflict, sessionInvalidated } = await browserAPI.storage.local.get(['currentSessionId', 'sessionConflict', 'sessionInvalidated']);
1083
+
1084
+ debug.log('🔍 checkSessionValidity - currentSessionId:', currentSessionId);
1085
+ debug.log('🔍 checkSessionValidity - sessionConflict:', sessionConflict);
1086
+ debug.log('🔍 checkSessionValidity - sessionInvalidated:', sessionInvalidated);
1087
+
1088
+ if (!currentSessionId) {
1089
+ debug.log('✅ No session ID stored - session valid (new/fresh state)');
1090
+ return { valid: true, reason: 'no_session' };
1091
+ }
1092
+
1093
+ // Check if we already have an invalidation notification (another browser logged in)
1094
+ if (sessionInvalidated) {
1095
+ debug.log('⚠️ Found sessionInvalidated flag in local storage');
1096
+ return {
1097
+ valid: false,
1098
+ reason: 'invalidated_by_other_login',
1099
+ invalidatedAt: sessionInvalidated.at,
1100
+ invalidatedReason: sessionInvalidated.reason
1101
+ };
1102
+ }
1103
+
1104
+ // Check if we already have a conflict notification
1105
+ if (sessionConflict && sessionConflict.detected) {
1106
+ debug.log('⚠️ Found sessionConflict flag in local storage');
1107
+ return { valid: false, reason: 'conflict_detected', conflict: sessionConflict };
1108
+ }
1109
+
1110
+ // Verify session still exists in Supabase and check for invalidation
1111
+ const userId = await this.getOrCreatePersistentUserId();
1112
+ debug.log('🔍 Checking session in database for user:', userId);
1113
+
1114
+ const response = await fetch(
1115
+ `${this.supabaseUrl}/rest/v1/${this.tableName}?user_id=eq.${userId}&select=dicecloud_token,username,session_id,invalidated_at,invalidated_reason,invalidated_by_session`,
1116
+ {
1117
+ headers: {
1118
+ 'apikey': this.supabaseKey,
1119
+ 'Authorization': `Bearer ${this.supabaseKey}`
1120
+ }
1121
+ }
1122
+ );
1123
+
1124
+ if (!response.ok) {
1125
+ debug.warn('⚠️ Database check failed with status:', response.status);
1126
+ return { valid: false, reason: 'check_failed' };
1127
+ }
1128
+
1129
+ const sessions = await response.json();
1130
+ debug.log('🔍 Found sessions in database:', sessions.length, sessions);
1131
+
1132
+ if (sessions.length === 0) {
1133
+ debug.warn('⚠️ No session found in database for this browser');
1134
+ return { valid: false, reason: 'session_not_found' };
1135
+ }
1136
+
1137
+ const session = sessions[0];
1138
+
1139
+ // Check if session was invalidated by another login
1140
+ if (session.invalidated_at) {
1141
+ debug.log('🚫 Session was invalidated by another login at:', session.invalidated_at);
1142
+
1143
+ // Store invalidation info locally so we don't keep checking
1144
+ await browserAPI.storage.local.set({
1145
+ sessionInvalidated: {
1146
+ at: session.invalidated_at,
1147
+ reason: session.invalidated_reason || 'logged_in_elsewhere',
1148
+ bySession: session.invalidated_by_session
1149
+ }
1150
+ });
1151
+
1152
+ return {
1153
+ valid: false,
1154
+ reason: 'invalidated_by_other_login',
1155
+ invalidatedAt: session.invalidated_at,
1156
+ invalidatedReason: session.invalidated_reason
1157
+ };
1158
+ }
1159
+
1160
+ // Check if session ID matches (another browser might have taken over)
1161
+ if (session.session_id !== currentSessionId) {
1162
+ debug.log('⚠️ Session ID mismatch - local:', currentSessionId, 'remote:', session.session_id);
1163
+ return { valid: false, reason: 'session_replaced' };
1164
+ }
1165
+
1166
+ // Check if local token matches remote token
1167
+ const { diceCloudToken } = await browserAPI.storage.local.get('diceCloudToken');
1168
+ if (diceCloudToken && session.dicecloud_token !== diceCloudToken) {
1169
+ debug.log('⚠️ Token mismatch - local token differs from database');
1170
+ return { valid: false, reason: 'token_mismatch' };
1171
+ }
1172
+
1173
+ debug.log('✅ Session is valid');
1174
+
1175
+ // Session is valid - update heartbeat
1176
+ await this.updateSessionHeartbeat(currentSessionId);
1177
+
1178
+ return { valid: true };
1179
+ } catch (error) {
1180
+ debug.error('❌ Error checking session validity:', error);
1181
+ return { valid: true }; // Assume valid on error to avoid false logouts
1182
+ }
1183
+ }
1184
+
1185
+ /**
1186
+ * Update session heartbeat to keep session alive
1187
+ */
1188
+ async updateSessionHeartbeat(sessionId) {
1189
+ try {
1190
+ const userId = await this.getOrCreatePersistentUserId();
1191
+ const response = await fetch(
1192
+ `${this.supabaseUrl}/rest/v1/${this.tableName}?user_id=eq.${userId}&session_id=eq.${sessionId}`,
1193
+ {
1194
+ method: 'PATCH',
1195
+ headers: {
1196
+ 'apikey': this.supabaseKey,
1197
+ 'Authorization': `Bearer ${this.supabaseKey}`,
1198
+ 'Content-Type': 'application/json',
1199
+ 'Prefer': 'return=minimal'
1200
+ },
1201
+ body: JSON.stringify({
1202
+ last_seen: new Date().toISOString()
1203
+ })
1204
+ }
1205
+ );
1206
+
1207
+ if (!response.ok) {
1208
+ debug.error('❌ Failed to update session heartbeat:', response.status);
1209
+ }
1210
+ } catch (error) {
1211
+ debug.error('❌ Error updating session heartbeat:', error);
1212
+ }
1213
+ }
1214
+
1215
+ /**
1216
+ * Clear conflict information
1217
+ */
1218
+ async clearConflictInfo() {
1219
+ try {
1220
+ await browserAPI.storage.local.remove(['sessionConflict']);
1221
+ debug.log('🗑️ Cleared conflict information');
1222
+ } catch (error) {
1223
+ debug.error('❌ Failed to clear conflict info:', error);
1224
+ }
1225
+ }
1226
+
1227
+ /**
1228
+ * Invalidate character cache for a specific character
1229
+ * Called when character data is updated
1230
+ */
1231
+ async invalidateCharacterCache(characterId, diceCloudUserId, discordUserId) {
1232
+ const keysToInvalidate = [];
1233
+
1234
+ if (characterId) {
1235
+ keysToInvalidate.push(`character_${characterId}`);
1236
+ }
1237
+ if (diceCloudUserId) {
1238
+ keysToInvalidate.push(`characters_${diceCloudUserId}`);
1239
+ }
1240
+ if (discordUserId && discordUserId !== 'not_linked') {
1241
+ keysToInvalidate.push(`discord_${discordUserId}`);
1242
+ }
1243
+
1244
+ keysToInvalidate.forEach(key => {
1245
+ if (this.characterCache.has(key)) {
1246
+ this.characterCache.delete(key);
1247
+ debug.log('🗑️ Invalidated cache:', key);
1248
+ }
1249
+ });
1250
+
1251
+ // Update persistent storage to reflect invalidation
1252
+ await this.savePersistentCache();
1253
+ }
1254
+
1255
+ /**
1256
+ * Clear all character cache (memory and persistent storage)
1257
+ */
1258
+ async clearCharacterCache() {
1259
+ this.characterCache.clear();
1260
+
1261
+ // Clear persistent storage too
1262
+ if (typeof browserAPI !== 'undefined') {
1263
+ await browserAPI.storage.local.remove([this.cacheStorageKey]);
1264
+ }
1265
+
1266
+ debug.log('🗑️ Cleared all character cache (memory + storage)');
1267
+ }
1268
+
1269
+ /**
1270
+ * Get cache stats for debugging
1271
+ */
1272
+ getCacheStats() {
1273
+ return {
1274
+ size: this.characterCache.size,
1275
+ memoryExpiryMs: this.cacheExpiryMs,
1276
+ persistentExpiryMs: this.persistentCacheExpiryMs,
1277
+ entries: Array.from(this.characterCache.keys())
1278
+ };
1279
+ }
1280
+
1281
+ /**
1282
+ * Load persistent cache from localStorage
1283
+ * Runs on initialization to restore cached data across sessions
1284
+ */
1285
+ async loadPersistentCache() {
1286
+ try {
1287
+ if (typeof browserAPI === 'undefined') {
1288
+ debug.log('⚠️ browserAPI not available yet, skipping cache load');
1289
+ return;
1290
+ }
1291
+
1292
+ const stored = await browserAPI.storage.local.get([this.cacheStorageKey]);
1293
+ if (!stored || !stored[this.cacheStorageKey]) {
1294
+ debug.log('📦 No persistent cache found');
1295
+ return;
1296
+ }
1297
+
1298
+ const persistentCache = stored[this.cacheStorageKey];
1299
+ const now = Date.now();
1300
+ let loadedCount = 0;
1301
+
1302
+ for (const [key, value] of Object.entries(persistentCache)) {
1303
+ // Check if cache entry is still valid (within 24 hour TTL)
1304
+ if (value.timestamp && (now - value.timestamp < this.persistentCacheExpiryMs)) {
1305
+ this.characterCache.set(key, value);
1306
+ loadedCount++;
1307
+ }
1308
+ }
1309
+
1310
+ debug.log(`📦 Loaded ${loadedCount} entries from persistent cache`);
1311
+ } catch (error) {
1312
+ debug.error('❌ Failed to load persistent cache:', error);
1313
+ }
1314
+ }
1315
+
1316
+ /**
1317
+ * Save current cache to localStorage for persistence
1318
+ */
1319
+ async savePersistentCache() {
1320
+ try {
1321
+ if (typeof browserAPI === 'undefined') {
1322
+ return;
1323
+ }
1324
+
1325
+ const cacheObject = {};
1326
+ for (const [key, value] of this.characterCache.entries()) {
1327
+ cacheObject[key] = value;
1328
+ }
1329
+
1330
+ await browserAPI.storage.local.set({
1331
+ [this.cacheStorageKey]: cacheObject
1332
+ });
1333
+
1334
+ debug.log('💾 Saved cache to persistent storage');
1335
+ } catch (error) {
1336
+ debug.error('❌ Failed to save persistent cache:', error);
1337
+ }
1338
+ }
1339
+ }
1340
+
1341
+ // Export for use in other modules
1342
+ // Always export to window/self for browser extensions
1343
+ if (typeof window !== 'undefined') {
1344
+ window.SupabaseTokenManager = SupabaseTokenManager;
1345
+
1346
+ // Create global Supabase client for authentication (email/password login)
1347
+ // This is separate from SupabaseTokenManager which handles DiceCloud tokens
1348
+ console.log('🔍 [Supabase Client] Checking for createSupabaseClient function...', typeof window.createSupabaseClient);
1349
+ if (typeof window.createSupabaseClient === 'function') {
1350
+ try {
1351
+ window.supabaseClient = window.createSupabaseClient(SUPABASE_URL, SUPABASE_ANON_KEY);
1352
+ console.log('✅ [Supabase Client] Created global Supabase auth client');
1353
+ debug.log('✅ Created global Supabase auth client');
1354
+ } catch (error) {
1355
+ console.error('❌ [Supabase Client] Failed to create Supabase client:', error);
1356
+ debug.error('❌ Failed to create Supabase client:', error);
1357
+ }
1358
+ } else {
1359
+ console.warn('⚠️ [Supabase Client] createSupabaseClient function not available yet - will retry on DOMContentLoaded');
1360
+ // Retry after DOM loads (when module scripts have finished)
1361
+ window.addEventListener('DOMContentLoaded', () => {
1362
+ console.log('🔍 [Supabase Client] DOMContentLoaded - retrying createSupabaseClient check...', typeof window.createSupabaseClient);
1363
+ if (typeof window.createSupabaseClient === 'function' && !window.supabaseClient) {
1364
+ try {
1365
+ window.supabaseClient = window.createSupabaseClient(SUPABASE_URL, SUPABASE_ANON_KEY);
1366
+ console.log('✅ [Supabase Client] Created global Supabase auth client (after DOMContentLoaded)');
1367
+ debug.log('✅ Created global Supabase auth client (after DOMContentLoaded)');
1368
+ } catch (error) {
1369
+ console.error('❌ [Supabase Client] Failed to create Supabase client:', error);
1370
+ debug.error('❌ Failed to create Supabase client:', error);
1371
+ }
1372
+ }
1373
+ });
1374
+ }
1375
+ } else if (typeof self !== 'undefined') {
1376
+ // Service worker context
1377
+ self.SupabaseTokenManager = SupabaseTokenManager;
1378
+ }
1379
+
1380
+ // Also export as module for Node.js environments
1381
+ if (typeof module !== 'undefined' && module.exports) {
1382
+ module.exports = SupabaseTokenManager;
1383
+ }