@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.
- package/dist/cache/CacheManager.d.ts.map +1 -0
- package/dist/cache/CacheManager.js +131 -0
- package/dist/cache/CacheManager.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/ir/index.d.ts +11 -0
- package/dist/ir/index.d.ts.map +1 -0
- package/dist/ir/index.js +9 -0
- package/dist/ir/index.js.map +1 -0
- package/dist/ir/normalize.d.ts +10 -0
- package/dist/ir/normalize.d.ts.map +1 -0
- package/dist/ir/normalize.js +207 -0
- package/dist/ir/normalize.js.map +1 -0
- package/dist/ir/persistence.d.ts +26 -0
- package/dist/ir/persistence.d.ts.map +1 -0
- package/dist/ir/persistence.js +21 -0
- package/dist/ir/persistence.js.map +1 -0
- package/dist/ir/sync.d.ts +12 -0
- package/dist/ir/sync.d.ts.map +1 -0
- package/dist/ir/sync.js +36 -0
- package/dist/ir/sync.js.map +1 -0
- package/dist/ir/types.d.ts +143 -0
- package/dist/ir/types.d.ts.map +1 -0
- package/dist/ir/types.js +13 -0
- package/dist/ir/types.js.map +1 -0
- package/dist/ir/views/dnd5e.d.ts +40 -0
- package/dist/ir/views/dnd5e.d.ts.map +1 -0
- package/dist/ir/views/dnd5e.js +50 -0
- package/dist/ir/views/dnd5e.js.map +1 -0
- package/dist/render/character.d.ts +19 -0
- package/dist/render/character.d.ts.map +1 -0
- package/dist/render/character.js +156 -0
- package/dist/render/character.js.map +1 -0
- package/dist/render/h.d.ts +27 -0
- package/dist/render/h.d.ts.map +1 -0
- package/dist/render/h.js +64 -0
- package/dist/render/h.js.map +1 -0
- package/dist/render/index.d.ts +11 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +8 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/mount.d.ts +31 -0
- package/dist/render/mount.d.ts.map +1 -0
- package/dist/render/mount.js +63 -0
- package/dist/render/mount.js.map +1 -0
- package/dist/supabase/fields.d.ts.map +1 -0
- package/dist/supabase/fields.js +120 -0
- package/dist/supabase/fields.js.map +1 -0
- package/dist/types/character.d.ts.map +1 -0
- package/dist/types/character.js +5 -0
- package/dist/types/character.js.map +1 -0
- package/package.json +73 -0
- package/src/browser.js +51 -0
- package/src/cache/CacheManager.ts +174 -0
- package/src/common/browser-polyfill.js +319 -0
- package/src/common/debug.js +123 -0
- package/src/common/html-utils.js +134 -0
- package/src/common/theme-manager.js +265 -0
- package/src/index.ts +25 -0
- package/src/ir/__fixtures__/dnd5e-character.json +75962 -0
- package/src/ir/__fixtures__/non-dnd-character.json +14218 -0
- package/src/ir/index.ts +10 -0
- package/src/ir/normalize.ts +245 -0
- package/src/ir/persistence.ts +37 -0
- package/src/ir/sync.ts +49 -0
- package/src/ir/types.ts +161 -0
- package/src/ir/views/dnd5e.ts +94 -0
- package/src/lib/indexeddb-cache.js +320 -0
- package/src/modules/action-announcements.js +102 -0
- package/src/modules/action-display.js +1557 -0
- package/src/modules/action-executor.js +860 -0
- package/src/modules/action-filters.js +167 -0
- package/src/modules/action-options.js +117 -0
- package/src/modules/card-creator.js +142 -0
- package/src/modules/character-portrait.js +169 -0
- package/src/modules/character-trait-popups.js +959 -0
- package/src/modules/character-traits.js +814 -0
- package/src/modules/class-feature-edge-cases.js +1320 -0
- package/src/modules/color-utils.js +69 -0
- package/src/modules/combat-maneuver-edge-cases.js +660 -0
- package/src/modules/companions-manager.js +178 -0
- package/src/modules/concentration-tracker.js +178 -0
- package/src/modules/data-manager.js +514 -0
- package/src/modules/dice-roller.js +719 -0
- package/src/modules/effects-manager.js +743 -0
- package/src/modules/feature-modals.js +1264 -0
- package/src/modules/formula-resolver.js +444 -0
- package/src/modules/gm-mode.js +184 -0
- package/src/modules/health-modals.js +399 -0
- package/src/modules/hp-management.js +752 -0
- package/src/modules/inventory-manager.js +242 -0
- package/src/modules/macro-system.js +825 -0
- package/src/modules/notification-system.js +92 -0
- package/src/modules/racial-feature-edge-cases.js +746 -0
- package/src/modules/resource-manager.js +775 -0
- package/src/modules/sheet-builder.js +654 -0
- package/src/modules/spell-action-modals.js +583 -0
- package/src/modules/spell-cards.js +602 -0
- package/src/modules/spell-casting.js +723 -0
- package/src/modules/spell-display.js +314 -0
- package/src/modules/spell-edge-cases.js +509 -0
- package/src/modules/spell-macros.js +201 -0
- package/src/modules/spell-modals.js +1221 -0
- package/src/modules/spell-slots.js +224 -0
- package/src/modules/status-bar-bridge.js +101 -0
- package/src/modules/ui-utilities.js +284 -0
- package/src/modules/warlock-invocations.js +219 -0
- package/src/modules/window-management.js +211 -0
- package/src/render/character.ts +234 -0
- package/src/render/h.ts +74 -0
- package/src/render/index.ts +10 -0
- package/src/render/mount.ts +94 -0
- package/src/supabase/client.js +1383 -0
- package/src/supabase/config.js +60 -0
- package/src/supabase/fields.ts +129 -0
- 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
|
+
}
|