@startup-api/cloudflare 0.0.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 (39) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +114 -0
  3. package/package.json +53 -0
  4. package/public/index.html +405 -0
  5. package/public/users/accounts.html +504 -0
  6. package/public/users/admin/index.html +765 -0
  7. package/public/users/power-strip.js +658 -0
  8. package/public/users/profile.html +443 -0
  9. package/public/users/style.css +493 -0
  10. package/src/CookieManager.ts +56 -0
  11. package/src/PowerStrip.ts +23 -0
  12. package/src/StartupAPIEnv.ts +12 -0
  13. package/src/auth/GoogleProvider.ts +67 -0
  14. package/src/auth/OAuthProvider.ts +52 -0
  15. package/src/auth/TwitchProvider.ts +64 -0
  16. package/src/auth/index.ts +231 -0
  17. package/src/billing/PaymentEngine.ts +20 -0
  18. package/src/billing/Plan.ts +80 -0
  19. package/src/billing/plansConfig.ts +48 -0
  20. package/src/handlers/account.ts +246 -0
  21. package/src/handlers/admin.ts +144 -0
  22. package/src/handlers/auth.ts +54 -0
  23. package/src/handlers/ssr.ts +274 -0
  24. package/src/handlers/user.ts +168 -0
  25. package/src/handlers/utils.ts +120 -0
  26. package/src/index.ts +190 -0
  27. package/src/schemas/account.ts +37 -0
  28. package/src/schemas/admin.ts +10 -0
  29. package/src/schemas/billing.ts +11 -0
  30. package/src/schemas/credential.ts +38 -0
  31. package/src/schemas/membership.ts +9 -0
  32. package/src/schemas/session.ts +10 -0
  33. package/src/schemas/user.ts +22 -0
  34. package/src/storage/AccountDO.ts +370 -0
  35. package/src/storage/CredentialDO.ts +82 -0
  36. package/src/storage/SystemDO.ts +264 -0
  37. package/src/storage/UserDO.ts +385 -0
  38. package/worker-configuration.d.ts +11696 -0
  39. package/wrangler.template.jsonc +55 -0
@@ -0,0 +1,493 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ body {
6
+ font-family:
7
+ system-ui,
8
+ -apple-system,
9
+ sans-serif;
10
+ padding: 2rem;
11
+ margin: 0 auto;
12
+ background: #f9f9f9;
13
+ }
14
+
15
+ .main-layout,
16
+ .header-area {
17
+ max-width: 1280px;
18
+ margin-left: auto;
19
+ margin-right: auto;
20
+ }
21
+
22
+ .main-layout {
23
+ display: flex;
24
+ gap: 3rem;
25
+ margin-top: 2rem;
26
+ }
27
+
28
+ .sidebar {
29
+ width: 240px;
30
+ flex-shrink: 0;
31
+ }
32
+
33
+ .content-area {
34
+ flex: 1;
35
+ min-width: 0;
36
+ display: flex;
37
+ flex-direction: column;
38
+ }
39
+
40
+ .header-area {
41
+ margin-bottom: 2rem;
42
+ padding-left: calc(240px + 3rem);
43
+ }
44
+
45
+ @media (max-width: 768px) {
46
+ .main-layout {
47
+ flex-direction: column;
48
+ gap: 2rem;
49
+ }
50
+ .sidebar {
51
+ width: 100%;
52
+ }
53
+ .header-area {
54
+ padding-left: 0;
55
+ }
56
+ }
57
+
58
+ .nav-list {
59
+ list-style: none;
60
+ padding: 0;
61
+ margin: 0;
62
+ }
63
+
64
+ .nav-item {
65
+ margin-bottom: 0.5rem;
66
+ }
67
+
68
+ .nav-link {
69
+ display: block;
70
+ padding: 0.75rem 1rem;
71
+ color: #555;
72
+ text-decoration: none;
73
+ border-radius: 6px;
74
+ transition: all 0.2s;
75
+ font-weight: 500;
76
+ font-size: 0.9rem;
77
+ }
78
+
79
+ .nav-link:hover {
80
+ background: #f0f0f0;
81
+ color: #1a73e8;
82
+ }
83
+
84
+ .nav-link.active {
85
+ color: #1a73e8;
86
+ font-weight: 600;
87
+ border-left: 3px solid #1a73e8;
88
+ border-radius: 0;
89
+ padding-left: calc(1rem - 3px);
90
+ }
91
+
92
+ h1.page-subtitle {
93
+ color: #666;
94
+ margin-bottom: 0.25rem;
95
+ font-size: 1.1rem;
96
+ text-transform: uppercase;
97
+ letter-spacing: 0.05rem;
98
+ margin-top: 0;
99
+ }
100
+
101
+ .page-title {
102
+ font-size: 2.5rem;
103
+ font-weight: bold;
104
+ color: #333;
105
+ margin-bottom: 0.5rem;
106
+ white-space: nowrap;
107
+ overflow: hidden;
108
+ text-overflow: ellipsis;
109
+ max-width: 100%;
110
+ }
111
+
112
+ .subtitle {
113
+ font-size: 0.75rem;
114
+ color: #888;
115
+ margin-bottom: 2rem;
116
+ font-family: monospace;
117
+ display: flex;
118
+ align-items: center;
119
+ gap: 0.5rem;
120
+ max-width: 100%;
121
+ }
122
+
123
+ section {
124
+ background: white;
125
+ padding: 1.5rem;
126
+ border-radius: 8px;
127
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
128
+ margin-bottom: 2rem;
129
+ }
130
+
131
+ .form-group {
132
+ margin-bottom: 1.5rem;
133
+ }
134
+
135
+ .form-group label {
136
+ display: block;
137
+ margin-bottom: 0.5rem;
138
+ font-weight: 500;
139
+ color: #555;
140
+ }
141
+
142
+ .form-group input,
143
+ .form-group select {
144
+ width: 100%;
145
+ padding: 0.75rem;
146
+ border: 1px solid #ddd;
147
+ border-radius: 4px;
148
+ box-sizing: border-box;
149
+ font-size: 1rem;
150
+ }
151
+
152
+ .form-group input:disabled {
153
+ background: #f0f0f0;
154
+ color: #888;
155
+ }
156
+
157
+ button {
158
+ padding: 0.75rem 1.5rem;
159
+ background: #1a73e8;
160
+ color: white;
161
+ border: none;
162
+ border-radius: 4px;
163
+ cursor: pointer;
164
+ font-size: 1rem;
165
+ font-weight: 500;
166
+ }
167
+
168
+ button:hover {
169
+ background: #1557b0;
170
+ }
171
+
172
+ button.secondary-btn {
173
+ background: #fff;
174
+ color: #1a73e8;
175
+ border: 1px solid #1a73e8;
176
+ }
177
+
178
+ button.secondary-btn:hover {
179
+ background: #f8f9fa;
180
+ }
181
+
182
+ button:disabled {
183
+ background: #ccc;
184
+ cursor: not-allowed;
185
+ }
186
+
187
+ #toast {
188
+ position: fixed;
189
+ bottom: 1rem;
190
+ right: 1rem;
191
+ padding: 1rem;
192
+ background: #333;
193
+ color: white;
194
+ border-radius: 4px;
195
+ opacity: 0;
196
+ transition: opacity 0.3s;
197
+ z-index: 10000;
198
+ }
199
+
200
+ .back-link {
201
+ display: inline-block;
202
+ margin-bottom: 1rem;
203
+ color: #1a73e8;
204
+ text-decoration: none;
205
+ }
206
+
207
+ .back-link:hover {
208
+ text-decoration: underline;
209
+ }
210
+
211
+ .remove-btn {
212
+ background: transparent;
213
+ color: #d93025;
214
+ border: 1px solid #d93025;
215
+ padding: 0.4rem 0.8rem;
216
+ font-size: 0.85rem;
217
+ }
218
+
219
+ .remove-btn:hover {
220
+ background: #fce8e6;
221
+ }
222
+
223
+ .remove-btn:disabled {
224
+ background: #fafafa;
225
+ border-color: #eee;
226
+ color: #999;
227
+ cursor: not-allowed;
228
+ }
229
+
230
+ .btn-link {
231
+ display: inline-block;
232
+ padding: 0.75rem 1rem;
233
+ background: white;
234
+ color: #1a73e8;
235
+ border: 1px solid #1a73e8;
236
+ border-radius: 4px;
237
+ text-decoration: none;
238
+ font-weight: 500;
239
+ font-size: 0.875rem;
240
+ transition: background 0.2s;
241
+ }
242
+
243
+ .btn-link:hover {
244
+ background: #f8f9fa;
245
+ }
246
+
247
+ .remove-image-btn {
248
+ position: absolute;
249
+ top: -5px;
250
+ right: -5px;
251
+ width: 20px;
252
+ height: 20px;
253
+ border-radius: 50%;
254
+ background: #bdc1c6;
255
+ color: white;
256
+ border: 2px solid white;
257
+ cursor: pointer;
258
+ display: flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ padding: 0;
262
+ line-height: 1;
263
+ font-weight: bold;
264
+ font-size: 12px;
265
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
266
+ transition: background-color 0.2s;
267
+ z-index: 10;
268
+ }
269
+
270
+ .remove-image-btn:hover {
271
+ background: #ea4335;
272
+ }
273
+
274
+ /* Profile specific */
275
+ .avatar-section {
276
+ display: flex;
277
+ align-items: center;
278
+ gap: 1.5rem;
279
+ margin-bottom: 2rem;
280
+ }
281
+
282
+ .avatar-large {
283
+ width: 100px;
284
+ height: 100px;
285
+ border-radius: 50%;
286
+ object-fit: cover;
287
+ border: 3px solid white;
288
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
289
+ }
290
+
291
+ .account-avatar-large {
292
+ width: 100px;
293
+ height: 100px;
294
+ border-radius: 8px;
295
+ object-fit: cover;
296
+ border: 3px solid white;
297
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
298
+ }
299
+
300
+ .credential-item {
301
+ display: flex;
302
+ justify-content: space-between;
303
+ align-items: center;
304
+ padding: 1rem;
305
+ border: 1px solid #eee;
306
+ border-radius: 8px;
307
+ margin-bottom: 0.75rem;
308
+ }
309
+
310
+ .credential-item.active {
311
+ border-color: #1a73e8;
312
+ background-color: #e8f0fe;
313
+ }
314
+
315
+ .current-badge {
316
+ font-size: 0.75rem;
317
+ background: #1a73e8;
318
+ color: white;
319
+ padding: 0.125rem 0.375rem;
320
+ border-radius: 0.75rem;
321
+ margin-left: 0.5rem;
322
+ font-weight: normal;
323
+ }
324
+
325
+ .credential-info {
326
+ display: flex;
327
+ align-items: center;
328
+ gap: 1rem;
329
+ }
330
+
331
+ .provider-icon {
332
+ width: 32px;
333
+ height: 32px;
334
+ display: flex;
335
+ align-items: center;
336
+ justify-content: center;
337
+ }
338
+
339
+ .twitch-icon {
340
+ color: #9146ff;
341
+ }
342
+
343
+ .link-account-btn {
344
+ display: flex;
345
+ align-items: center;
346
+ gap: 0.5rem;
347
+ padding: 0.75rem 1rem;
348
+ border-radius: 4px;
349
+ text-decoration: none;
350
+ font-weight: 500;
351
+ font-size: 0.875rem;
352
+ border: 1px solid #ddd;
353
+ color: #333;
354
+ transition: background 0.2s;
355
+ }
356
+
357
+ .link-account-btn.google:hover {
358
+ background: #f8f9fa;
359
+ }
360
+
361
+ .link-account-btn.twitch {
362
+ background: #9146ff;
363
+ color: white;
364
+ border-color: #9146ff;
365
+ }
366
+
367
+ .link-account-btn.twitch .twitch-icon {
368
+ color: white;
369
+ }
370
+
371
+ .link-account-btn.twitch:hover {
372
+ background: #7d2ee6;
373
+ }
374
+
375
+ /* Accounts specific */
376
+ .member-item {
377
+ display: flex;
378
+ justify-content: space-between;
379
+ align-items: center;
380
+ padding: 1rem;
381
+ border-bottom: 1px solid #eee;
382
+ }
383
+
384
+ .member-item:last-child {
385
+ border-bottom: none;
386
+ }
387
+
388
+ .member-info {
389
+ display: flex;
390
+ align-items: center;
391
+ gap: 1rem;
392
+ min-width: 0;
393
+ }
394
+
395
+ .member-avatar {
396
+ width: 32px;
397
+ height: 32px;
398
+ border-radius: 50%;
399
+ object-fit: cover;
400
+ flex-shrink: 0;
401
+ background: #f1f3f4;
402
+ display: flex;
403
+ align-items: center;
404
+ justify-content: center;
405
+ color: #5f6368;
406
+ }
407
+
408
+ .member-avatar svg {
409
+ width: 20px;
410
+ height: 20px;
411
+ }
412
+
413
+ .member-details {
414
+ flex: 1;
415
+ min-width: 0;
416
+ display: flex;
417
+ align-items: center;
418
+ gap: 1rem;
419
+ }
420
+
421
+ .member-name {
422
+ font-weight: 600;
423
+ white-space: nowrap;
424
+ overflow: hidden;
425
+ text-overflow: ellipsis;
426
+ flex: 1;
427
+ }
428
+
429
+ .member-role {
430
+ width: 80px;
431
+ display: flex;
432
+ justify-content: center;
433
+ }
434
+
435
+ .role-badge {
436
+ font-size: 0.75rem;
437
+ padding: 0.25rem 0.5rem;
438
+ border-radius: 1rem;
439
+ background: #f1f3f4;
440
+ color: #5f6368;
441
+ font-weight: 500;
442
+ }
443
+
444
+ .role-badge.admin {
445
+ background: #e8f0fe;
446
+ color: #1a73e8;
447
+ }
448
+
449
+ .role-select {
450
+ padding: 0.25rem 0.5rem;
451
+ border-radius: 4px;
452
+ border: 1px solid #ddd;
453
+ font-size: 0.85rem;
454
+ background: #fff;
455
+ }
456
+
457
+ .role-select:disabled {
458
+ background: #f1f3f4;
459
+ color: #5f6368;
460
+ border-color: transparent;
461
+ appearance: none;
462
+ -webkit-appearance: none;
463
+ cursor: default;
464
+ }
465
+
466
+ .id-text {
467
+ white-space: nowrap;
468
+ overflow: hidden;
469
+ text-overflow: ellipsis;
470
+ max-width: 25ch;
471
+ }
472
+
473
+ .copy-btn {
474
+ background: none;
475
+ border: none;
476
+ padding: 0.25rem;
477
+ cursor: pointer;
478
+ color: #1a73e8;
479
+ display: flex;
480
+ align-items: center;
481
+ justify-content: center;
482
+ border-radius: 4px;
483
+ transition: background 0.2s;
484
+ }
485
+
486
+ .copy-btn:hover {
487
+ background: #f0f0f0;
488
+ }
489
+
490
+ .copy-btn svg {
491
+ width: 14px;
492
+ height: 14px;
493
+ }
@@ -0,0 +1,56 @@
1
+ export class CookieManager {
2
+ private keyPromise: Promise<CryptoKey> | null = null;
3
+
4
+ constructor(private secret: string) {}
5
+
6
+ private async getKey(): Promise<CryptoKey> {
7
+ if (this.keyPromise) return this.keyPromise;
8
+
9
+ this.keyPromise = (async () => {
10
+ const encoder = new TextEncoder();
11
+ const secretData = encoder.encode(this.secret);
12
+ const hash = await crypto.subtle.digest('SHA-256', secretData);
13
+ return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
14
+ })();
15
+
16
+ return this.keyPromise;
17
+ }
18
+
19
+ async encrypt(value: string): Promise<string> {
20
+ const key = await this.getKey();
21
+ const encoder = new TextEncoder();
22
+ const data = encoder.encode(value);
23
+ const iv = crypto.getRandomValues(new Uint8Array(12));
24
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
25
+
26
+ const combined = new Uint8Array(iv.length + ciphertext.byteLength);
27
+ combined.set(iv);
28
+ combined.set(new Uint8Array(ciphertext), iv.length);
29
+
30
+ return btoa(String.fromCharCode(...combined))
31
+ .replace(/\+/g, '-')
32
+ .replace(/\//g, '_')
33
+ .replace(/=+$/, '');
34
+ }
35
+
36
+ async decrypt(encrypted: string): Promise<string | null> {
37
+ try {
38
+ const key = await this.getKey();
39
+ const base64 = encrypted.replace(/-/g, '+').replace(/_/g, '/');
40
+ const combined = new Uint8Array(
41
+ atob(base64)
42
+ .split('')
43
+ .map((c) => c.charCodeAt(0)),
44
+ );
45
+
46
+ const iv = combined.slice(0, 12);
47
+ const ciphertext = combined.slice(12);
48
+
49
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
50
+ return new TextDecoder().decode(decrypted);
51
+ } catch (e) {
52
+ console.error('Failed to decrypt cookie:', e);
53
+ return null;
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,23 @@
1
+ export async function injectPowerStrip(response: Response, usersPath: string, providers: string[]): Promise<Response> {
2
+ const contentType = response.headers.get('Content-Type');
3
+
4
+ if (contentType && contentType.includes('text/html')) {
5
+ // Inject a script tag and a custom element into the proxied HTML pages.
6
+ // The script is loaded from the USERS_PATH, which is intercepted by this worker.
7
+ return new HTMLRewriter()
8
+ .on('body', {
9
+ element(element) {
10
+ element.prepend(
11
+ `<script src="${usersPath}power-strip.js" async></script>` +
12
+ `<power-strip providers="${providers.join(',')}" style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem;">` +
13
+ '<svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem;"><path d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>' +
14
+ '</power-strip>',
15
+ { html: true },
16
+ );
17
+ },
18
+ })
19
+ .transform(response);
20
+ }
21
+
22
+ return response;
23
+ }
@@ -0,0 +1,12 @@
1
+ export type StartupAPIEnv = {
2
+ ORIGIN_URL: URL;
3
+ USERS_PATH: string;
4
+ AUTH_ORIGIN: string;
5
+ GOOGLE_CLIENT_ID: string;
6
+ GOOGLE_CLIENT_SECRET: string;
7
+ TWITCH_CLIENT_ID: string;
8
+ TWITCH_CLIENT_SECRET: string;
9
+ ADMIN_IDS: string;
10
+ SESSION_SECRET: string;
11
+ ENVIRONMENT?: string;
12
+ } & Env;
@@ -0,0 +1,67 @@
1
+ import type { StartupAPIEnv } from '../StartupAPIEnv';
2
+
3
+ import { OAuthProvider, OAuthTokenResponse, UserProfile } from './OAuthProvider';
4
+
5
+ export class GoogleProvider extends OAuthProvider {
6
+ static create(env: StartupAPIEnv, redirectBase: string): GoogleProvider | null {
7
+ if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) return null;
8
+ return new GoogleProvider(env.GOOGLE_CLIENT_ID, env.GOOGLE_CLIENT_SECRET, redirectBase + '/google/callback', 'google');
9
+ }
10
+
11
+ getAuthUrl(state: string): string {
12
+ const params = new URLSearchParams({
13
+ client_id: this.clientId,
14
+ redirect_uri: this.redirectUri,
15
+ response_type: 'code',
16
+ scope: 'openid email profile',
17
+ state: state,
18
+ access_type: 'offline',
19
+ prompt: 'consent',
20
+ });
21
+ return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
22
+ }
23
+
24
+ getIcon(): string {
25
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
26
+ <circle cx="12" cy="12" r="11" fill="#4285F4" stroke="white" stroke-width="1"/>
27
+ <path d="M17.64 12.2c0-.41-.03-.81-.1-1.21H12v2.3h3.16c-.14.73-.57 1.35-1.19 1.79v1.48h1.92c1.12-1.03 1.75-2.55 1.75-4.36z" fill="white"/>
28
+ <path d="M12 18c1.62 0 2.98-.54 3.97-1.46l-1.92-1.48c-.54.37-1.23.59-2.05.59-1.57 0-2.91-1.06-3.39-2.48H6.65v1.53C7.64 16.69 9.68 18 12 18z" fill="white"/>
29
+ <path d="M8.61 13.17c-.12-.37-.19-.76-.19-1.17s.07-.8.19-1.17V9.3H6.65c-.41.81-.65 1.73-.65 2.7s.24 1.89.65 2.7l1.96-1.53z" fill="white"/>
30
+ <path d="M12 8.35c.88 0 1.67.3 2.3.91l1.73-1.73C14.98 6.51 13.62 6 12 6c-2.32 0-4.36 1.31-5.35 3.3L8.61 10.83c.48-1.42 1.82-2.48 3.39-2.48z" fill="white"/>
31
+ </svg>`;
32
+ }
33
+
34
+ async getToken(code: string): Promise<OAuthTokenResponse> {
35
+ const params = new URLSearchParams({
36
+ code,
37
+ client_id: this.clientId,
38
+ client_secret: this.clientSecret,
39
+ redirect_uri: this.redirectUri,
40
+ grant_type: 'authorization_code',
41
+ });
42
+
43
+ return this.fetchJson<OAuthTokenResponse>('https://oauth2.googleapis.com/token', {
44
+ method: 'POST',
45
+ headers: {
46
+ 'Content-Type': 'application/x-www-form-urlencoded',
47
+ },
48
+ body: params.toString(),
49
+ });
50
+ }
51
+
52
+ async getUserProfile(accessToken: string): Promise<UserProfile> {
53
+ const data = await this.fetchJson<any>('https://www.googleapis.com/oauth2/v2/userinfo', {
54
+ headers: {
55
+ Authorization: `Bearer ${accessToken}`,
56
+ },
57
+ });
58
+
59
+ return {
60
+ id: data.id,
61
+ email: data.email,
62
+ name: data.name,
63
+ picture: data.picture,
64
+ verified_email: data.verified_email,
65
+ };
66
+ }
67
+ }
@@ -0,0 +1,52 @@
1
+ export interface OAuthTokenResponse {
2
+ access_token: string;
3
+ refresh_token?: string;
4
+ expires_in?: number;
5
+ scope?: string | string[];
6
+ token_type?: string;
7
+ id_token?: string;
8
+ }
9
+
10
+ export interface UserProfile {
11
+ id: string;
12
+ email?: string;
13
+ name?: string;
14
+ picture?: string;
15
+ verified_email?: boolean;
16
+ }
17
+
18
+ export abstract class OAuthProvider {
19
+ protected clientId: string;
20
+ protected clientSecret: string;
21
+ protected redirectUri: string;
22
+ public name: string;
23
+
24
+ constructor(clientId: string, clientSecret: string, redirectUri: string, name: string) {
25
+ this.clientId = clientId.trim();
26
+ this.clientSecret = clientSecret.trim();
27
+ this.redirectUri = redirectUri.trim();
28
+ this.name = name.trim();
29
+ }
30
+
31
+ isMatch(path: string, authBasePath: string): boolean {
32
+ return path === `${authBasePath}/${this.name}`;
33
+ }
34
+
35
+ isCallback(path: string, authBasePath: string): boolean {
36
+ return path === `${authBasePath}/${this.name}/callback`;
37
+ }
38
+
39
+ abstract getAuthUrl(state: string): string;
40
+ abstract getIcon(): string;
41
+ abstract getToken(code: string): Promise<OAuthTokenResponse>;
42
+ abstract getUserProfile(token: string): Promise<UserProfile>;
43
+
44
+ protected async fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
45
+ const response = await fetch(url, options);
46
+ if (!response.ok) {
47
+ const text = await response.text();
48
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText} - ${text}`);
49
+ }
50
+ return response.json() as Promise<T>;
51
+ }
52
+ }