@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,144 @@
1
+ import { StartupAPIEnv } from '../StartupAPIEnv';
2
+ import { CookieManager } from '../CookieManager';
3
+ import { getUserFromSession, checkAndClearStaleSession, isAdmin, parseCookies, getActiveProviders } from './utils';
4
+ import { Plan } from '../billing/Plan';
5
+ import { UserProfileSchema } from '../schemas/user';
6
+ import { SystemAccountSchema, MemberSchema } from '../schemas/account';
7
+ import { ImpersonateSchema } from '../schemas/admin';
8
+
9
+ export async function handleAdmin(
10
+ request: Request,
11
+ env: StartupAPIEnv,
12
+ usersPath: string,
13
+ cookieManager: CookieManager,
14
+ ): Promise<Response> {
15
+ const user = await getUserFromSession(request, env, cookieManager);
16
+ if (!user || !isAdmin(user, env)) {
17
+ return checkAndClearStaleSession(request, env, cookieManager, new Response('Forbidden', { status: 403 }));
18
+ }
19
+
20
+ const url = new URL(request.url);
21
+ const path = url.pathname.replace(usersPath + 'admin', '');
22
+
23
+ if (path === '/' || path === '') {
24
+ url.pathname = '/users/admin/';
25
+ const newRequest = new Request(url.toString(), request);
26
+ newRequest.headers.set('x-skip-worker', 'true');
27
+ const response = await env.ASSETS.fetch(newRequest);
28
+ if (!response.ok) return response;
29
+
30
+ let html = await response.text();
31
+ html = html.replace(/\{\{ssr:([a-z0-9_]+)\}\}/g, (match, key) => {
32
+ const replacements: Record<string, string> = {
33
+ plans_json: JSON.stringify(Plan.getAll()).replace(/"/g, '&quot;'),
34
+ providers: getActiveProviders(env).join(','),
35
+ };
36
+ return replacements[key] !== undefined ? replacements[key] : match;
37
+ });
38
+
39
+ return new Response(html, {
40
+ headers: { 'Content-Type': 'text/html' },
41
+ });
42
+ }
43
+
44
+ const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global'));
45
+
46
+ if (path.startsWith('/api/')) {
47
+ try {
48
+ const apiPath = path.replace('/api/', '');
49
+ const parts = apiPath.split('/');
50
+
51
+ if (parts[0] === 'users') {
52
+ if (parts.length === 1 && request.method === 'GET') {
53
+ return Response.json(await systemStub.listUsers(url.searchParams.get('q') || undefined));
54
+ }
55
+ if (parts.length === 2) {
56
+ const userId = parts[1];
57
+ if (request.method === 'GET') return Response.json(await systemStub.getUser(userId));
58
+ if (request.method === 'DELETE') return Response.json(await systemStub.deleteUser(userId));
59
+ if (request.method === 'PATCH' || request.method === 'PUT') {
60
+ const data = await request.json();
61
+ const validatedData = UserProfileSchema.partial().parse(data);
62
+ return Response.json(await systemStub.updateUser(userId, validatedData));
63
+ }
64
+ }
65
+ if (parts.length === 3 && parts[2] === 'memberships' && request.method === 'GET') {
66
+ const userId = parts[1];
67
+ return Response.json(await systemStub.getUserMemberships(userId));
68
+ }
69
+ } else if (parts[0] === 'accounts') {
70
+ if (parts.length === 1) {
71
+ if (request.method === 'GET') return Response.json(await systemStub.listAccounts(url.searchParams.get('q') || undefined));
72
+ if (request.method === 'POST') {
73
+ const data = await request.json();
74
+ const validatedData = SystemAccountSchema.parse(data);
75
+ return Response.json(await systemStub.registerAccount(validatedData));
76
+ }
77
+ }
78
+ if (parts.length === 2) {
79
+ const accountId = parts[1];
80
+ if (request.method === 'GET') return Response.json(await systemStub.getAccount(accountId));
81
+ if (request.method === 'PUT') {
82
+ const data = await request.json();
83
+ const validatedData = SystemAccountSchema.partial().parse(data);
84
+ return Response.json(await systemStub.updateAccount(accountId, validatedData));
85
+ }
86
+ if (request.method === 'DELETE') return Response.json(await systemStub.deleteAccount(accountId));
87
+ }
88
+ if (parts.length >= 3 && parts[2] === 'members') {
89
+ const accountId = parts[1];
90
+ const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accountId));
91
+ if (parts.length === 3) {
92
+ if (request.method === 'GET') return Response.json(await accountStub.getMembers());
93
+ if (request.method === 'POST') {
94
+ const data = await request.json();
95
+ const { user_id, role } = MemberSchema.parse(data);
96
+ return Response.json(await accountStub.addMember(user_id, role));
97
+ }
98
+ } else if (parts.length === 4 && request.method === 'DELETE') {
99
+ return Response.json(await accountStub.removeMember(parts[3]));
100
+ }
101
+ }
102
+ } else if (parts[0] === 'impersonate' && request.method === 'POST') {
103
+ const body = await request.json();
104
+ const data = ImpersonateSchema.parse(body);
105
+ const user_id = data.user_id || data.userId;
106
+ if (!user_id) return new Response('Missing user_id', { status: 400 });
107
+
108
+ if (user_id === user.id) {
109
+ return new Response('Cannot impersonate yourself', { status: 400 });
110
+ }
111
+
112
+ const userDOId = env.USER.idFromString(user_id);
113
+ const userStub = env.USER.get(userDOId);
114
+ const session = await userStub.createSession({ provider: 'admin-impersonation', impersonator: user.id });
115
+
116
+ const cookieHeader = request.headers.get('Cookie');
117
+ const cookies = parseCookies(cookieHeader || '');
118
+ const currentSessionEncrypted = cookies['session_id'];
119
+
120
+ const headers = new Headers();
121
+ const newSessionIdEncrypted = await cookieManager.encrypt(`${session.sessionId}:${user_id}`);
122
+ headers.set('Set-Cookie', `session_id=${newSessionIdEncrypted}; Path=/; HttpOnly; Secure; SameSite=Lax`);
123
+ if (currentSessionEncrypted) {
124
+ const backupSession = await cookieManager.decrypt(currentSessionEncrypted);
125
+ if (backupSession) {
126
+ const backupSessionEncrypted = await cookieManager.encrypt(backupSession);
127
+ headers.append('Set-Cookie', `backup_session_id=${backupSessionEncrypted}; Path=/; HttpOnly; Secure; SameSite=Lax`);
128
+ }
129
+ }
130
+
131
+ return Response.json({ success: true }, { headers });
132
+ }
133
+
134
+ return new Response('Not Found', { status: 404 });
135
+ } catch (e) {
136
+ return new Response(e instanceof Error ? e.message : String(e), { status: 400 });
137
+ }
138
+ }
139
+
140
+ url.pathname = '/users/admin' + path;
141
+ const newRequest = new Request(url.toString(), request);
142
+ newRequest.headers.set('x-skip-worker', 'true');
143
+ return env.ASSETS.fetch(newRequest);
144
+ }
@@ -0,0 +1,54 @@
1
+ import { StartupAPIEnv } from '../StartupAPIEnv';
2
+ import { CookieManager } from '../CookieManager';
3
+ import { parseCookies } from './utils';
4
+
5
+ export async function handleLogout(
6
+ request: Request,
7
+ env: StartupAPIEnv,
8
+ url: URL,
9
+ usersPath: string,
10
+ cookieManager: CookieManager,
11
+ ): Promise<Response> {
12
+ const cookieHeader = request.headers.get('Cookie');
13
+ if (cookieHeader) {
14
+ const cookies = parseCookies(cookieHeader);
15
+ const sessionCookieEncrypted = cookies['session_id'];
16
+
17
+ if (sessionCookieEncrypted) {
18
+ const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted);
19
+ if (sessionCookie && sessionCookie.includes(':')) {
20
+ const [sessionId, doId] = sessionCookie.split(':');
21
+ try {
22
+ const id = env.USER.idFromString(doId);
23
+ const stub = env.USER.get(id);
24
+ await stub.deleteSession(sessionId);
25
+ } catch (_e) {
26
+ console.error('Error deleting session:', _e);
27
+ // Continue to clear cookie even if DO call fails
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ const headers = new Headers();
34
+ headers.set('Set-Cookie', 'session_id=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0');
35
+
36
+ let redirectUrl = '/';
37
+ const returnUrl = url.searchParams.get('return_url');
38
+ if (returnUrl) {
39
+ const origin = env.AUTH_ORIGIN && env.AUTH_ORIGIN !== '' ? env.AUTH_ORIGIN : url.origin;
40
+ try {
41
+ const parsedReturn = new URL(returnUrl, origin);
42
+ if (parsedReturn.origin === origin) {
43
+ redirectUrl = parsedReturn.toString();
44
+ }
45
+ } catch (_e) {
46
+ if (returnUrl.startsWith('/')) {
47
+ redirectUrl = returnUrl;
48
+ }
49
+ }
50
+ }
51
+
52
+ headers.set('Location', redirectUrl);
53
+ return new Response(null, { status: 302, headers });
54
+ }
@@ -0,0 +1,274 @@
1
+ import { StartupAPIEnv } from '../StartupAPIEnv';
2
+ import { CookieManager } from '../CookieManager';
3
+ import { getUserFromSession, checkAndClearStaleSession, isAdmin, getActiveProviders } from './utils';
4
+ import { Plan } from '../billing/Plan';
5
+
6
+ export async function handleSSR(
7
+ request: Request,
8
+ env: StartupAPIEnv,
9
+ url: URL,
10
+ usersPath: string,
11
+ cookieManager: CookieManager,
12
+ ): Promise<Response> {
13
+ const user = await getUserFromSession(request, env, cookieManager);
14
+ if (!user) {
15
+ return checkAndClearStaleSession(request, env, cookieManager, Response.redirect(url.origin + '/', 302));
16
+ }
17
+
18
+ const { id: doId, sessionId: _sessionId, profile: initialProfile, credential } = user;
19
+
20
+ try {
21
+ const id = env.USER.idFromString(doId);
22
+ const userStub = env.USER.get(id);
23
+
24
+ // Get HTML from assets
25
+ const assetUrl = new URL(url.toString());
26
+ assetUrl.pathname = url.pathname.replace(usersPath, '/users/');
27
+ const assetRequest = new Request(assetUrl.toString(), request);
28
+ assetRequest.headers.set('x-skip-worker', 'true');
29
+ let assetResponse = await env.ASSETS.fetch(assetRequest);
30
+
31
+ // Follow one level of redirect if needed (e.g. for canonical URLs)
32
+ if (assetResponse.status === 301 || assetResponse.status === 302) {
33
+ const location = assetResponse.headers.get('Location');
34
+ if (location) {
35
+ const followUrl = new URL(location, assetUrl.toString());
36
+ const followRequest = new Request(followUrl.toString(), request);
37
+ followRequest.headers.set('x-skip-worker', 'true');
38
+ assetResponse = await env.ASSETS.fetch(followRequest);
39
+ }
40
+ }
41
+
42
+ if (!assetResponse.ok) {
43
+ return assetResponse;
44
+ }
45
+
46
+ let html = await assetResponse.text();
47
+
48
+ const data: any = {
49
+ valid: true,
50
+ profile: { ...initialProfile },
51
+ credential,
52
+ };
53
+
54
+ const image = await userStub.getImage('avatar');
55
+ if (image) {
56
+ const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/';
57
+ data.profile.picture = usersPathNormalized + 'me/avatar';
58
+ } else {
59
+ data.profile.picture = null;
60
+ }
61
+
62
+ data.is_admin = isAdmin({ id: doId, profile: data.profile, credential }, env);
63
+
64
+ // Fetch memberships to find current account
65
+ const memberships = await userStub.getMemberships();
66
+ const currentMembership = memberships.find((m: any) => m.is_current) || memberships[0];
67
+
68
+ // Fetch credentials
69
+ const credentials = await userStub.listCredentials();
70
+
71
+ let account = null;
72
+ let accountMembers = null;
73
+ if (currentMembership) {
74
+ const accountId = env.ACCOUNT.idFromString(currentMembership.account_id);
75
+ const accountStub = env.ACCOUNT.get(accountId);
76
+ const accountInfo = await accountStub.getInfo();
77
+ const billing = await accountStub.getBillingInfo();
78
+ account = {
79
+ ...accountInfo,
80
+ billing,
81
+ id: currentMembership.account_id,
82
+ role: currentMembership.role,
83
+ };
84
+ // Fetch members only if it's the accounts page or if needed
85
+ if (url.pathname.endsWith('/accounts.html') || url.pathname.endsWith('/accounts')) {
86
+ accountMembers = await accountStub.getMembers();
87
+ }
88
+ }
89
+
90
+ // Prepare SSR values
91
+ const replacements: Record<string, string> = {
92
+ plans_json: JSON.stringify(Plan.getAll()).replace(/"/g, '&quot;'),
93
+ providers: getActiveProviders(env).join(','),
94
+ profile_json: JSON.stringify(data).replace(/"/g, '&quot;'),
95
+ credentials_json: JSON.stringify(credentials).replace(/"/g, '&quot;'),
96
+ profile_name: data.profile.name || 'Anonymous',
97
+ profile_id: doId,
98
+ profile_email: data.profile.email || '',
99
+ profile_picture: data.profile.picture || '',
100
+ profile_picture_display: data.profile.picture ? 'display: block;' : 'display: none;',
101
+ profile_placeholder_display: data.profile.picture ? 'display: none;' : 'display: flex;',
102
+ profile_remove_btn_display: data.profile.picture ? 'display: flex;' : 'display: none;',
103
+ profile_provider_label: data.profile.provider
104
+ ? `(from ${data.profile.provider.charAt(0).toUpperCase() + data.profile.provider.slice(1)})`
105
+ : '',
106
+ nav_account_display: account && (account.role === 1 || data.is_admin) ? 'display: block;' : 'display: none;',
107
+ credentials_list_html: renderCredentialsList(credentials, data.credential?.provider),
108
+ link_credentials_html: renderLinkCredentialsList(getActiveProviders(env), url.href),
109
+ };
110
+
111
+ if (account) {
112
+ replacements['account_json'] = JSON.stringify(account).replace(/"/g, '&quot;');
113
+ replacements['account_name'] = account.name || 'Account';
114
+ replacements['account_id'] = account.id;
115
+
116
+ const allPlans = Plan.getAll();
117
+ if (allPlans.length <= 1) {
118
+ replacements['account_plan_name'] = '';
119
+ } else {
120
+ replacements['account_plan_name'] = account.billing?.plan_details?.name || account.billing?.state?.plan_slug || 'free';
121
+ }
122
+
123
+ const accountAvatar = await env.ACCOUNT.get(env.ACCOUNT.idFromString(account.id)).getImage('avatar');
124
+ const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/';
125
+ const accountPicture = accountAvatar ? `${usersPathNormalized}api/me/accounts/${account.id}/avatar` : null;
126
+
127
+ replacements['account_picture'] = accountPicture || '';
128
+ replacements['account_picture_display'] = accountPicture ? 'display: block;' : 'display: none;';
129
+ replacements['account_placeholder_display'] = accountPicture ? 'display: none;' : 'display: flex;';
130
+ replacements['account_remove_btn_display'] = accountPicture ? 'display: flex;' : 'display: none;';
131
+
132
+ const isAccountAdmin = account.role === 1 || data.is_admin;
133
+ replacements['account_info_section_display'] = isAccountAdmin ? 'display: block;' : 'display: none;';
134
+ replacements['account_members_section_display'] = isAccountAdmin ? 'display: block;' : 'display: none;';
135
+
136
+ if (accountMembers) {
137
+ replacements['account_members_json'] = JSON.stringify(accountMembers).replace(/"/g, '&quot;');
138
+ replacements['account_members_list_html'] = renderAccountMembersList(accountMembers, doId);
139
+ } else {
140
+ replacements['account_members_json'] = '[]';
141
+ replacements['account_members_list_html'] = '<p>Loading members...</p>';
142
+ }
143
+ } else {
144
+ replacements['account_json'] = 'null';
145
+ replacements['account_name'] = '';
146
+ replacements['account_id'] = '';
147
+ replacements['account_plan_name'] = '';
148
+ replacements['account_picture'] = '';
149
+ replacements['account_picture_display'] = 'display: none;';
150
+ replacements['account_placeholder_display'] = 'display: flex;';
151
+ replacements['account_remove_btn_display'] = 'display: none;';
152
+ replacements['account_info_section_display'] = 'display: none;';
153
+ replacements['account_members_section_display'] = 'display: none;';
154
+ replacements['account_members_json'] = '[]';
155
+ replacements['account_members_list_html'] = '';
156
+ }
157
+
158
+ html = renderSSR(html, replacements);
159
+
160
+ return new Response(html, {
161
+ headers: {
162
+ 'Content-Type': 'text/html',
163
+ },
164
+ });
165
+ } catch (e) {
166
+ console.error('[handleSSR] Error:', e instanceof Error ? e.message : String(e), e instanceof Error ? e.stack : '');
167
+ return new Response(`Error rendering page: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
168
+ }
169
+ }
170
+
171
+ function renderSSR(html: string, replacements: Record<string, string>): string {
172
+ return html.replace(/\{\{ssr:([a-z0-9_]+)\}\}/g, (match, key) => {
173
+ return replacements[key] !== undefined ? replacements[key] : match;
174
+ });
175
+ }
176
+
177
+ function renderCredentialsList(credentials: any[], currentProvider?: string): string {
178
+ if (!credentials || credentials.length === 0) {
179
+ return '<p>No credentials linked.</p>';
180
+ }
181
+
182
+ return credentials
183
+ .map((c) => {
184
+ const isCurrent = c.provider === currentProvider;
185
+ return `
186
+ <div class="credential-item ${isCurrent ? 'active' : ''}">
187
+ <div class="credential-info">
188
+ <div class="provider-icon">
189
+ ${getProviderIcon(c.provider)}
190
+ </div>
191
+ <div>
192
+ <div style="font-weight: 600;">
193
+ ${c.provider.charAt(0).toUpperCase() + c.provider.slice(1)}
194
+ ${isCurrent ? '<span class="current-badge">logged in</span>' : ''}
195
+ </div>
196
+ <div style="font-size: 0.8rem; color: #666;">${c.email || c.subject_id}</div>
197
+ </div>
198
+ </div>
199
+ <button class="remove-btn" onclick="removeCredential('${c.provider}')" ${isCurrent || credentials.length === 1 ? 'disabled title="' + (isCurrent ? 'Cannot remove the method you are currently logged in with' : 'Cannot remove your last login method') + '"' : ''}>
200
+ Remove
201
+ </button>
202
+ </div>
203
+ `;
204
+ })
205
+ .join('');
206
+ }
207
+
208
+ function getProviderIcon(provider: string): string {
209
+ if (provider === 'google') {
210
+ return '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.66l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>';
211
+ } else if (provider === 'twitch') {
212
+ return '<svg viewBox="0 0 24 24" width="24" height="24" class="twitch-icon"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z" fill="currentColor"/></svg>';
213
+ }
214
+ return '';
215
+ }
216
+
217
+ function renderLinkCredentialsList(providers: string[], returnUrl?: string): string {
218
+ if (providers.length === 0) {
219
+ return '';
220
+ }
221
+
222
+ const query = returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : '';
223
+
224
+ return providers
225
+ .map((provider) => {
226
+ return `
227
+ <a href="/users/auth/${provider}${query}" class="link-account-btn ${provider}">
228
+ ${getProviderIcon(provider).replace('width="24" height="24"', 'width="20" height="20"')}
229
+ ${provider.charAt(0).toUpperCase() + provider.slice(1)}
230
+ </a>
231
+ `;
232
+ })
233
+ .join('');
234
+ }
235
+
236
+ function renderAccountMembersList(members: any[], currentUserId: string): string {
237
+ if (!members || members.length === 0) {
238
+ return '<p>No members found.</p>';
239
+ }
240
+
241
+ return members
242
+ .map((m) => {
243
+ const isSelf = m.user_id === currentUserId;
244
+ const avatarContent = m.picture
245
+ ? `<img src="${m.picture}" class="member-avatar" alt="${m.name}" />`
246
+ : `<div class="member-avatar">
247
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
248
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
249
+ <circle cx="12" cy="7" r="4"></circle>
250
+ </svg>
251
+ </div>`;
252
+
253
+ return `
254
+ <div class="member-item">
255
+ <div class="member-info">
256
+ ${avatarContent}
257
+ <div class="member-details">
258
+ <div class="member-name" title="${m.name}${isSelf ? ' (You)' : ''}">${m.name} ${isSelf ? '(You)' : ''}</div>
259
+ <div class="member-role">
260
+ <select onchange="updateRole('${m.user_id}', this.value)" ${isSelf ? 'disabled title="You cannot change your own role"' : ''} class="role-select">
261
+ <option value="0" ${m.role === 0 ? 'selected' : ''}>Member</option>
262
+ <option value="1" ${m.role === 1 ? 'selected' : ''}>Admin</option>
263
+ </select>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ <button class="remove-btn" onclick="removeMember('${m.user_id}')" ${isSelf ? 'disabled title="You cannot remove yourself"' : ''}>
268
+ Remove
269
+ </button>
270
+ </div>
271
+ `;
272
+ })
273
+ .join('');
274
+ }
@@ -0,0 +1,168 @@
1
+ import { StartupAPIEnv } from '../StartupAPIEnv';
2
+ import { CookieManager } from '../CookieManager';
3
+ import { getUserFromSession, checkAndClearStaleSession, parseCookies, isAdmin } from './utils';
4
+ import { Plan } from '../billing/Plan';
5
+ import { UserProfileSchema } from '../schemas/user';
6
+ import { DeleteCredentialSchema } from '../schemas/credential';
7
+
8
+ const DEFAULT_USERS_PATH = '/users/';
9
+
10
+ export async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise<Response> {
11
+ const user = await getUserFromSession(request, env, cookieManager);
12
+ if (!user) {
13
+ return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 }));
14
+ }
15
+
16
+ const { id: doId, profile: initialProfile, credential } = user;
17
+
18
+ try {
19
+ const id = env.USER.idFromString(doId);
20
+ const userStub = env.USER.get(id);
21
+
22
+ const data: any = {
23
+ valid: true,
24
+ profile: { ...initialProfile },
25
+ credential,
26
+ plans: Plan.getAll(),
27
+ };
28
+
29
+ const image = await userStub.getImage('avatar');
30
+ if (image) {
31
+ const usersPath = env.USERS_PATH || DEFAULT_USERS_PATH;
32
+ data.profile.picture = usersPath + 'me/avatar';
33
+ } else {
34
+ data.profile.picture = null;
35
+ }
36
+
37
+ data.is_admin = isAdmin({ id: doId, profile: data.profile, credential }, env);
38
+
39
+ const cookieHeader = request.headers.get('Cookie') || '';
40
+ const cookies = parseCookies(cookieHeader);
41
+ data.is_impersonated = !!cookies['backup_session_id'];
42
+
43
+ // Fetch credentials
44
+ data.credentials = await userStub.listCredentials();
45
+
46
+ // Fetch memberships to find current account
47
+ const memberships = await userStub.getMemberships();
48
+ const currentMembership = memberships.find((m: any) => m.is_current) || memberships[0];
49
+
50
+ if (currentMembership) {
51
+ const accountId = env.ACCOUNT.idFromString(currentMembership.account_id);
52
+ const accountStub = env.ACCOUNT.get(accountId);
53
+ const accountInfo = await accountStub.getInfo();
54
+ const billing = await accountStub.getBillingInfo();
55
+ data.account = {
56
+ ...accountInfo,
57
+ billing,
58
+ id: currentMembership.account_id,
59
+ role: currentMembership.role,
60
+ };
61
+ }
62
+
63
+ return Response.json(data);
64
+ } catch (_e) {
65
+ return new Response('Unauthorized', { status: 401 });
66
+ }
67
+ }
68
+
69
+ export async function handleUpdateProfile(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise<Response> {
70
+ const user = await getUserFromSession(request, env, cookieManager);
71
+ if (!user) {
72
+ return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 }));
73
+ }
74
+
75
+ try {
76
+ const profileData = await request.json();
77
+ const validatedData = UserProfileSchema.partial().parse(profileData);
78
+ const userStub = env.USER.get(env.USER.idFromString(user.id));
79
+ await userStub.updateProfile(validatedData);
80
+
81
+ return Response.json({ success: true });
82
+ } catch (e) {
83
+ return new Response(e instanceof Error ? e.message : String(e), { status: 400 });
84
+ }
85
+ }
86
+
87
+ export async function handleListCredentials(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise<Response> {
88
+ const user = await getUserFromSession(request, env, cookieManager);
89
+ if (!user) {
90
+ return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 }));
91
+ }
92
+
93
+ const userStub = env.USER.get(env.USER.idFromString(user.id));
94
+ return Response.json(await userStub.listCredentials());
95
+ }
96
+
97
+ export async function handleDeleteCredential(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise<Response> {
98
+ const user = await getUserFromSession(request, env, cookieManager);
99
+ if (!user) {
100
+ return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 }));
101
+ }
102
+
103
+ try {
104
+ const data = await request.json();
105
+ const { provider } = DeleteCredentialSchema.parse(data);
106
+ const userStub = env.USER.get(env.USER.idFromString(user.id));
107
+
108
+ return Response.json(await userStub.deleteCredential(provider));
109
+ } catch (e) {
110
+ return new Response(e instanceof Error ? e.message : String(e), { status: 400 });
111
+ }
112
+ }
113
+
114
+ export async function handleMeImage(request: Request, env: StartupAPIEnv, type: string, cookieManager: CookieManager): Promise<Response> {
115
+ const user = await getUserFromSession(request, env, cookieManager);
116
+ if (!user) {
117
+ return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 }));
118
+ }
119
+
120
+ try {
121
+ const id = env.USER.idFromString(user.id);
122
+ const stub = env.USER.get(id);
123
+
124
+ if (request.method === 'PUT') {
125
+ const contentType = request.headers.get('Content-Type');
126
+ if (!contentType || !contentType.startsWith('image/')) {
127
+ return new Response('Invalid image type', { status: 400 });
128
+ }
129
+
130
+ const blob = await request.arrayBuffer();
131
+ if (blob.byteLength > 1024 * 1024) {
132
+ return new Response('Image too large (max 1MB)', { status: 400 });
133
+ }
134
+
135
+ await stub.storeImage(type, blob, contentType);
136
+ return Response.json({ success: true });
137
+ }
138
+
139
+ if (request.method === 'DELETE') {
140
+ await stub.deleteImage(type);
141
+ return Response.json({ success: true });
142
+ }
143
+
144
+ return handleUserImage(request, env, user.id, type, cookieManager);
145
+ } catch (e) {
146
+ console.error('[handleMeImage] Error:', e instanceof Error ? e.message : String(e), e instanceof Error ? e.stack : '');
147
+ return new Response(`Error fetching image: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
148
+ }
149
+ }
150
+
151
+ export async function handleUserImage(
152
+ request: Request,
153
+ env: StartupAPIEnv,
154
+ userId: string,
155
+ type: string,
156
+ _cookieManager: CookieManager,
157
+ ): Promise<Response> {
158
+ try {
159
+ const id = env.USER.idFromString(userId);
160
+ const stub = env.USER.get(id);
161
+
162
+ const image = await stub.getImage(type);
163
+ if (!image) return new Response('Not Found', { status: 404 });
164
+ return new Response(image.value, { headers: { 'Content-Type': image.mime_type } });
165
+ } catch (_e) {
166
+ return new Response('Error fetching image', { status: 500 });
167
+ }
168
+ }