@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,385 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import { StartupAPIEnv } from '../StartupAPIEnv';
3
+ import { UserProfileSchema } from '../schemas/user';
4
+ import type { UserProfile } from '../schemas/user';
5
+
6
+ /**
7
+ * A Durable Object representing a User.
8
+ * This class handles the storage and management of user profiles,
9
+ * OAuth2 credentials, and login sessions using a SQLite backend.
10
+ */
11
+ export class UserDO extends DurableObject {
12
+ sql: SqlStorage;
13
+
14
+ /**
15
+ * Initializes the User Durable Object.
16
+ * Sets up the SQLite database schema if it doesn't already exist.
17
+ *
18
+ * @param state - The state of the Durable Object, including storage.
19
+ * @param env - The environment variables and bindings.
20
+ */
21
+ constructor(state: DurableObjectState, env: StartupAPIEnv) {
22
+ super(state, env);
23
+ this.sql = state.storage.sql;
24
+
25
+ // Initialize database schema
26
+ this.sql.exec(`
27
+ CREATE TABLE IF NOT EXISTS profile (
28
+ id INTEGER PRIMARY KEY CHECK (id = 1),
29
+ name TEXT,
30
+ email TEXT,
31
+ picture TEXT,
32
+ provider TEXT,
33
+ verified_email INTEGER
34
+ );
35
+
36
+ CREATE TABLE IF NOT EXISTS sessions (
37
+ id TEXT PRIMARY KEY,
38
+ created_at INTEGER,
39
+ expires_at INTEGER,
40
+ meta TEXT
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS memberships (
44
+ account_id TEXT PRIMARY KEY,
45
+ role INTEGER,
46
+ is_current INTEGER
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS user_credentials (
50
+ provider TEXT NOT NULL,
51
+ subject_id TEXT NOT NULL,
52
+ PRIMARY KEY (provider, subject_id)
53
+ );
54
+ `);
55
+
56
+ // Ensure the single row exists
57
+ this.sql.exec('INSERT OR IGNORE INTO profile (id) VALUES (1)');
58
+ }
59
+
60
+ /**
61
+ * Validates a session ID and returns the user profile if valid.
62
+ *
63
+ * @param sessionId - The sessionId to validate.
64
+ * @returns A Promise resolving to the session status and user profile.
65
+ */
66
+ async validateSession(
67
+ sessionId: string,
68
+ ): Promise<{ valid: boolean; profile?: UserProfile; credential?: Record<string, any>; error?: string }> {
69
+ try {
70
+ // Check session
71
+ const sessionResult = this.sql.exec('SELECT * FROM sessions WHERE id = ?', sessionId);
72
+ const session = sessionResult.next().value as any;
73
+
74
+ if (!session) {
75
+ return { valid: false };
76
+ }
77
+
78
+ if (session.expires_at < Date.now()) {
79
+ return { valid: false, error: 'Expired' };
80
+ }
81
+
82
+ // Get profile data from local 'profile' table
83
+ const profile = await this.getProfile();
84
+
85
+ // Determine login context (provider and subject_id)
86
+ const sessionMeta = session.meta ? JSON.parse(session.meta) : {};
87
+ const loginProvider = sessionMeta.provider;
88
+ const credential: Record<string, any> = {};
89
+
90
+ if (loginProvider) {
91
+ credential.provider = loginProvider;
92
+ const credResult = this.sql.exec('SELECT subject_id FROM user_credentials WHERE provider = ?', loginProvider);
93
+ const credRow = credResult.next().value as any;
94
+ if (credRow) {
95
+ credential.subject_id = credRow.subject_id;
96
+ }
97
+ } else {
98
+ // Fallback: get first available credential if no provider in session
99
+ const credResult = this.sql.exec('SELECT provider, subject_id FROM user_credentials LIMIT 1');
100
+ const credRow = credResult.next().value as any;
101
+ if (credRow) {
102
+ credential.provider = credRow.provider;
103
+ credential.subject_id = credRow.subject_id;
104
+ }
105
+ }
106
+
107
+ // Ensure the ID is set
108
+ profile.id = this.ctx.id.toString();
109
+
110
+ return { valid: true, profile, credential };
111
+ } catch (_e) {
112
+ return { valid: false };
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Retrieves the user's profile data.
118
+ *
119
+ * @returns A Promise resolving to the user profile.
120
+ */
121
+ async getProfile(): Promise<UserProfile> {
122
+ try {
123
+ const result = this.sql.exec('SELECT * FROM profile WHERE id = 1');
124
+ const row = result.next().value as any;
125
+ if (!row) return {};
126
+
127
+ return UserProfileSchema.parse({
128
+ name: row.name,
129
+ email: row.email,
130
+ picture: row.picture,
131
+ provider: row.provider,
132
+ verified_email: row.verified_email === 1,
133
+ });
134
+ } catch (_e) {
135
+ return {};
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Updates the user's profile data.
141
+ * Uses a transaction to ensure atomic updates of multiple fields.
142
+ *
143
+ * @param data - The JSON profile data to update.
144
+ * @returns A Promise resolving to a success or error response.
145
+ */
146
+ async updateProfile(data: Record<string, any>): Promise<{ success: boolean; error?: string }> {
147
+ try {
148
+ const validatedData = UserProfileSchema.partial().parse(data);
149
+ const updates: string[] = [];
150
+ const values: any[] = [];
151
+
152
+ if ('name' in validatedData) {
153
+ updates.push('name = ?');
154
+ values.push(validatedData.name);
155
+ }
156
+ if ('email' in validatedData) {
157
+ updates.push('email = ?');
158
+ values.push(validatedData.email);
159
+ }
160
+ if ('picture' in validatedData) {
161
+ updates.push('picture = ?');
162
+ values.push(validatedData.picture);
163
+ }
164
+ if ('provider' in validatedData) {
165
+ updates.push('provider = ?');
166
+ values.push(validatedData.provider);
167
+ }
168
+ if ('verified_email' in validatedData) {
169
+ updates.push('verified_email = ?');
170
+ values.push(validatedData.verified_email ? 1 : 0);
171
+ }
172
+
173
+ if (updates.length > 0) {
174
+ this.ctx.storage.transactionSync(() => {
175
+ this.sql.exec(`UPDATE profile SET ${updates.join(', ')} WHERE id = 1`, ...values);
176
+ });
177
+ }
178
+ return { success: true };
179
+ } catch (e) {
180
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
181
+ }
182
+ }
183
+
184
+ async addCredential(provider: string, subject_id: string): Promise<{ success: boolean }> {
185
+ this.sql.exec('INSERT OR REPLACE INTO user_credentials (provider, subject_id) VALUES (?, ?)', provider, subject_id);
186
+ return { success: true };
187
+ }
188
+
189
+ async listCredentials(): Promise<any[]> {
190
+ const credentialsMapping = this.sql.exec('SELECT DISTINCT provider FROM user_credentials');
191
+ const credentials = [];
192
+ for (const row of credentialsMapping) {
193
+ const stub = this.env.CREDENTIAL.get(this.env.CREDENTIAL.idFromName(row.provider as string));
194
+ const providerCreds = await stub.list(this.ctx.id.toString());
195
+ credentials.push(
196
+ ...providerCreds.map((c: any) => ({
197
+ provider: row.provider,
198
+ subject_id: c.subject_id,
199
+ email: c.profile_data?.email,
200
+ created_at: c.created_at,
201
+ })),
202
+ );
203
+ }
204
+ return credentials;
205
+ }
206
+
207
+ async deleteCredential(provider: string): Promise<{ success: boolean }> {
208
+ const result = this.sql.exec('SELECT provider, subject_id FROM user_credentials');
209
+ const all = Array.from(result) as any[];
210
+
211
+ if (all.length <= 1) {
212
+ throw new Error('Cannot delete the last credential');
213
+ }
214
+
215
+ const cred = all.find((c) => c.provider === provider);
216
+ if (cred) {
217
+ const stub = this.env.CREDENTIAL.get(this.env.CREDENTIAL.idFromName(cred.provider));
218
+ await stub.delete(cred.subject_id);
219
+ this.sql.exec('DELETE FROM user_credentials WHERE provider = ? AND subject_id = ?', cred.provider, cred.subject_id);
220
+ }
221
+
222
+ return { success: true };
223
+ }
224
+
225
+ /**
226
+ * Creates a new login session for the user.
227
+ * Generates a random session ID and sets a 24-hour expiration.
228
+ *
229
+ * @param meta - Optional metadata to store with the session.
230
+ * @returns A Promise resolving to a JSON response with the session ID and expiration time.
231
+ */
232
+ async createSession(meta?: Record<string, any>): Promise<{ sessionId: string; expiresAt: number }> {
233
+ // Basic session creation
234
+ const sessionId = crypto.randomUUID();
235
+ const now = Date.now();
236
+ const expiresAt = now + 24 * 60 * 60 * 1000; // 24 hours
237
+
238
+ this.sql.exec(
239
+ 'INSERT INTO sessions (id, created_at, expires_at, meta) VALUES (?, ?, ?, ?)',
240
+ sessionId,
241
+ now,
242
+ expiresAt,
243
+ meta ? JSON.stringify(meta) : null,
244
+ );
245
+
246
+ return { sessionId, expiresAt };
247
+ }
248
+
249
+ /**
250
+ * Deletes a login session.
251
+ *
252
+ * @param sessionId - The sessionId to delete.
253
+ * @returns A Promise resolving to a JSON response indicating success.
254
+ */
255
+ async deleteSession(sessionId: string): Promise<{ success: boolean }> {
256
+ try {
257
+ this.sql.exec('DELETE FROM sessions WHERE id = ?', sessionId);
258
+ } catch (_e) {
259
+ // Ignore
260
+ }
261
+ return { success: true };
262
+ }
263
+
264
+ async getMemberships(): Promise<any[]> {
265
+ try {
266
+ const result = this.sql.exec('SELECT account_id, role, is_current FROM memberships');
267
+ return Array.from(result);
268
+ } catch (_e) {
269
+ return [];
270
+ }
271
+ }
272
+
273
+ async addMembership(account_id: string, role: number, is_current?: boolean): Promise<{ success: boolean }> {
274
+ if (is_current) {
275
+ this.sql.exec('UPDATE memberships SET is_current = 0');
276
+ }
277
+
278
+ this.sql.exec(
279
+ 'INSERT OR REPLACE INTO memberships (account_id, role, is_current) VALUES (?, ?, ?)',
280
+ account_id,
281
+ role,
282
+ is_current ? 1 : 0,
283
+ );
284
+ return { success: true };
285
+ }
286
+
287
+ async deleteMembership(account_id: string): Promise<{ success: boolean }> {
288
+ this.sql.exec('DELETE FROM memberships WHERE account_id = ?', account_id);
289
+ return { success: true };
290
+ }
291
+
292
+ async switchAccount(account_id: string): Promise<{ success: boolean }> {
293
+ // Verify membership exists
294
+ const result = this.sql.exec('SELECT account_id FROM memberships WHERE account_id = ?', account_id);
295
+ const membership = result.next().value;
296
+
297
+ if (!membership) {
298
+ throw new Error('Membership not found');
299
+ }
300
+
301
+ try {
302
+ this.ctx.storage.transactionSync(() => {
303
+ // Unset current
304
+ this.sql.exec('UPDATE memberships SET is_current = 0');
305
+ // Set new current
306
+ this.sql.exec('UPDATE memberships SET is_current = 1 WHERE account_id = ?', account_id);
307
+ });
308
+ return { success: true };
309
+ } catch (e) {
310
+ throw new Error(e instanceof Error ? e.message : String(e));
311
+ }
312
+ }
313
+
314
+ async getCurrentAccount(): Promise<{ account_id: string; role: number } | null> {
315
+ const result = this.sql.exec('SELECT account_id, role FROM memberships WHERE is_current = 1');
316
+ const membership = result.next().value as any;
317
+
318
+ if (!membership) {
319
+ // Fallback: Return the first membership if no current is set
320
+ const fallback = this.sql.exec('SELECT account_id, role FROM memberships LIMIT 1');
321
+ const fallbackMembership = fallback.next().value as any;
322
+ if (fallbackMembership) {
323
+ return fallbackMembership;
324
+ }
325
+ return null;
326
+ }
327
+
328
+ return membership;
329
+ }
330
+
331
+ async getImage(key: string): Promise<{ value: ArrayBuffer; mime_type: string } | null> {
332
+ const r2Key = `user/${this.ctx.id.toString()}/${key}`;
333
+ const object = await this.env.IMAGE_STORAGE.get(r2Key);
334
+ if (!object) return null;
335
+ return {
336
+ value: await object.arrayBuffer(),
337
+ mime_type: object.httpMetadata?.contentType || 'image/jpeg',
338
+ };
339
+ }
340
+
341
+ async storeImage(key: string, value: ArrayBuffer, mime_type: string): Promise<{ success: boolean }> {
342
+ const r2Key = `user/${this.ctx.id.toString()}/${key}`;
343
+ await this.env.IMAGE_STORAGE.put(r2Key, value, {
344
+ httpMetadata: { contentType: mime_type },
345
+ });
346
+ return { success: true };
347
+ }
348
+
349
+ async deleteImage(key: string): Promise<{ success: boolean }> {
350
+ const r2Key = `user/${this.ctx.id.toString()}/${key}`;
351
+ await this.env.IMAGE_STORAGE.delete(r2Key);
352
+
353
+ if (key === 'avatar') {
354
+ this.sql.exec('UPDATE profile SET picture = NULL WHERE id = 1');
355
+ }
356
+
357
+ return { success: true };
358
+ }
359
+
360
+ async delete(): Promise<{ success: boolean }> {
361
+ // Delete all credentials from provider-specific CredentialDOs
362
+ const credentialsMapping = this.sql.exec('SELECT provider, subject_id FROM user_credentials');
363
+ for (const row of credentialsMapping) {
364
+ try {
365
+ const stub = this.env.CREDENTIAL.get(this.env.CREDENTIAL.idFromName(row.provider as string));
366
+ await stub.delete(row.subject_id as string);
367
+ } catch (_e) {
368
+ console.error(`Failed to delete credential mapping for provider ${row.provider}`, _e);
369
+ }
370
+ }
371
+
372
+ // Delete all user images from R2
373
+ const prefix = `user/${this.ctx.id.toString()}/`;
374
+ const listed = await this.env.IMAGE_STORAGE.list({ prefix });
375
+ const keys = listed.objects.map((o) => o.key);
376
+ if (keys.length > 0) {
377
+ await this.env.IMAGE_STORAGE.delete(keys);
378
+ }
379
+
380
+ // Wipe all Durable Object storage
381
+ await this.ctx.storage.deleteAll();
382
+
383
+ return { success: true };
384
+ }
385
+ }