@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.
- package/LICENSE +13 -0
- package/README.md +114 -0
- package/package.json +53 -0
- package/public/index.html +405 -0
- package/public/users/accounts.html +504 -0
- package/public/users/admin/index.html +765 -0
- package/public/users/power-strip.js +658 -0
- package/public/users/profile.html +443 -0
- package/public/users/style.css +493 -0
- package/src/CookieManager.ts +56 -0
- package/src/PowerStrip.ts +23 -0
- package/src/StartupAPIEnv.ts +12 -0
- package/src/auth/GoogleProvider.ts +67 -0
- package/src/auth/OAuthProvider.ts +52 -0
- package/src/auth/TwitchProvider.ts +64 -0
- package/src/auth/index.ts +231 -0
- package/src/billing/PaymentEngine.ts +20 -0
- package/src/billing/Plan.ts +80 -0
- package/src/billing/plansConfig.ts +48 -0
- package/src/handlers/account.ts +246 -0
- package/src/handlers/admin.ts +144 -0
- package/src/handlers/auth.ts +54 -0
- package/src/handlers/ssr.ts +274 -0
- package/src/handlers/user.ts +168 -0
- package/src/handlers/utils.ts +120 -0
- package/src/index.ts +190 -0
- package/src/schemas/account.ts +37 -0
- package/src/schemas/admin.ts +10 -0
- package/src/schemas/billing.ts +11 -0
- package/src/schemas/credential.ts +38 -0
- package/src/schemas/membership.ts +9 -0
- package/src/schemas/session.ts +10 -0
- package/src/schemas/user.ts +22 -0
- package/src/storage/AccountDO.ts +370 -0
- package/src/storage/CredentialDO.ts +82 -0
- package/src/storage/SystemDO.ts +264 -0
- package/src/storage/UserDO.ts +385 -0
- package/worker-configuration.d.ts +11696 -0
- 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
|
+
}
|