@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,370 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import { initPlans } from '../billing/plansConfig';
|
|
3
|
+
import { Plan } from '../billing/Plan';
|
|
4
|
+
import { MockPaymentEngine } from '../billing/PaymentEngine';
|
|
5
|
+
import { StartupAPIEnv } from '../StartupAPIEnv';
|
|
6
|
+
import { AccountInfoSchema, MemberSchema } from '../schemas/account';
|
|
7
|
+
import { BillingStateSchema } from '../schemas/billing';
|
|
8
|
+
import type { AccountInfo, Member } from '../schemas/account';
|
|
9
|
+
import type { BillingState } from '../schemas/billing';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A Durable Object representing an Account (Tenant).
|
|
13
|
+
* This class handles account-specific data, settings, and memberships.
|
|
14
|
+
*/
|
|
15
|
+
export class AccountDO extends DurableObject {
|
|
16
|
+
static ROLE_USER = 0;
|
|
17
|
+
static ROLE_ADMIN = 1;
|
|
18
|
+
|
|
19
|
+
sql: SqlStorage;
|
|
20
|
+
paymentEngine: MockPaymentEngine;
|
|
21
|
+
|
|
22
|
+
constructor(state: DurableObjectState, env: StartupAPIEnv) {
|
|
23
|
+
super(state, env);
|
|
24
|
+
this.sql = state.storage.sql;
|
|
25
|
+
this.paymentEngine = new MockPaymentEngine();
|
|
26
|
+
|
|
27
|
+
// Initialize plans if not already done
|
|
28
|
+
if (!Plan.isInitialized()) {
|
|
29
|
+
initPlans();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Initialize database schema
|
|
33
|
+
this.sql.exec(`
|
|
34
|
+
CREATE TABLE IF NOT EXISTS account_info (
|
|
35
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
36
|
+
name TEXT,
|
|
37
|
+
plan TEXT,
|
|
38
|
+
billing TEXT,
|
|
39
|
+
personal INTEGER
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS members (
|
|
43
|
+
user_id TEXT PRIMARY KEY,
|
|
44
|
+
role INTEGER,
|
|
45
|
+
joined_at INTEGER
|
|
46
|
+
);
|
|
47
|
+
`);
|
|
48
|
+
|
|
49
|
+
// Ensure the single row exists
|
|
50
|
+
this.sql.exec('INSERT OR IGNORE INTO account_info (id, plan) VALUES (1, "free")');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getImage(key: string): Promise<{ value: ArrayBuffer; mime_type: string } | null> {
|
|
54
|
+
const r2Key = `account/${this.ctx.id.toString()}/${key}`;
|
|
55
|
+
const object = await this.env.IMAGE_STORAGE.get(r2Key);
|
|
56
|
+
if (!object) return null;
|
|
57
|
+
return {
|
|
58
|
+
value: await object.arrayBuffer(),
|
|
59
|
+
mime_type: object.httpMetadata?.contentType || 'image/jpeg',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async storeImage(key: string, value: ArrayBuffer, mime_type: string): Promise<{ success: boolean }> {
|
|
64
|
+
const r2Key = `account/${this.ctx.id.toString()}/${key}`;
|
|
65
|
+
await this.env.IMAGE_STORAGE.put(r2Key, value, {
|
|
66
|
+
httpMetadata: { contentType: mime_type },
|
|
67
|
+
});
|
|
68
|
+
return { success: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async deleteImage(key: string): Promise<{ success: boolean }> {
|
|
72
|
+
const r2Key = `account/${this.ctx.id.toString()}/${key}`;
|
|
73
|
+
await this.env.IMAGE_STORAGE.delete(r2Key);
|
|
74
|
+
return { success: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async getInfo(): Promise<AccountInfo> {
|
|
78
|
+
try {
|
|
79
|
+
const result = this.sql.exec('SELECT * FROM account_info WHERE id = 1');
|
|
80
|
+
const row = result.next().value as any;
|
|
81
|
+
if (!row) return {};
|
|
82
|
+
|
|
83
|
+
return AccountInfoSchema.parse({
|
|
84
|
+
name: row.name,
|
|
85
|
+
plan: row.plan,
|
|
86
|
+
personal: row.personal === 1,
|
|
87
|
+
billing: row.billing ? JSON.parse(row.billing) : undefined,
|
|
88
|
+
});
|
|
89
|
+
} catch (_e) {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async updateInfo(data: Record<string, any>): Promise<{ success: boolean }> {
|
|
95
|
+
try {
|
|
96
|
+
const validatedData = AccountInfoSchema.partial().parse(data);
|
|
97
|
+
const updates: string[] = [];
|
|
98
|
+
const values: any[] = [];
|
|
99
|
+
|
|
100
|
+
if ('name' in validatedData) {
|
|
101
|
+
updates.push('name = ?');
|
|
102
|
+
values.push(typeof validatedData.name === 'string' ? validatedData.name.substring(0, 50) : validatedData.name);
|
|
103
|
+
}
|
|
104
|
+
if ('plan' in validatedData) {
|
|
105
|
+
updates.push('plan = ?');
|
|
106
|
+
values.push(validatedData.plan);
|
|
107
|
+
}
|
|
108
|
+
if ('personal' in validatedData) {
|
|
109
|
+
updates.push('personal = ?');
|
|
110
|
+
values.push(validatedData.personal ? 1 : 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (updates.length > 0) {
|
|
114
|
+
this.ctx.storage.transactionSync(() => {
|
|
115
|
+
this.sql.exec(`UPDATE account_info SET ${updates.join(', ')} WHERE id = 1`, ...values);
|
|
116
|
+
|
|
117
|
+
// If plan is updated manually (e.g. via Admin API), update billing state too
|
|
118
|
+
if ('plan' in data) {
|
|
119
|
+
const currentState = this.getBillingState();
|
|
120
|
+
if (currentState.plan_slug !== data.plan) {
|
|
121
|
+
const newState: BillingState = {
|
|
122
|
+
...currentState,
|
|
123
|
+
plan_slug: data.plan,
|
|
124
|
+
};
|
|
125
|
+
this.sql.exec('UPDATE account_info SET billing = ? WHERE id = 1', JSON.stringify(newState));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return { success: true };
|
|
131
|
+
} catch (e) {
|
|
132
|
+
throw new Error(e instanceof Error ? e.message : String(e));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async getMembers(): Promise<Member[]> {
|
|
137
|
+
const result = Array.from(this.sql.exec('SELECT user_id, role, joined_at FROM members'));
|
|
138
|
+
const membersWithNames = await Promise.all(
|
|
139
|
+
result.map(async (m: any) => {
|
|
140
|
+
try {
|
|
141
|
+
const userStub = this.env.USER.get(this.env.USER.idFromString(m.user_id));
|
|
142
|
+
const profile = await userStub.getProfile();
|
|
143
|
+
const image = await userStub.getImage('avatar');
|
|
144
|
+
|
|
145
|
+
let picture = profile.picture || null;
|
|
146
|
+
if (image) {
|
|
147
|
+
picture = `/users/api/users/${m.user_id}/avatar`;
|
|
148
|
+
} else {
|
|
149
|
+
picture = null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return MemberSchema.parse({
|
|
153
|
+
...m,
|
|
154
|
+
name: profile.name || 'Unknown User',
|
|
155
|
+
picture: picture,
|
|
156
|
+
});
|
|
157
|
+
} catch (_e) {
|
|
158
|
+
return MemberSchema.parse({ ...m, name: 'Unknown User', picture: null });
|
|
159
|
+
}
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
return membersWithNames;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async addMember(user_id: string, role: number): Promise<{ success: boolean }> {
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
|
|
168
|
+
// Update Account DO
|
|
169
|
+
this.sql.exec('INSERT OR REPLACE INTO members (user_id, role, joined_at) VALUES (?, ?, ?)', user_id, role, now);
|
|
170
|
+
|
|
171
|
+
// Update SystemDO index
|
|
172
|
+
await this.syncMemberCount();
|
|
173
|
+
|
|
174
|
+
// Sync with User DO
|
|
175
|
+
try {
|
|
176
|
+
const userStub = this.env.USER.get(this.env.USER.idFromString(user_id));
|
|
177
|
+
await userStub.addMembership(this.ctx.id.toString(), role, false);
|
|
178
|
+
} catch (_e) {
|
|
179
|
+
console.error('Failed to sync membership to UserDO', _e);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { success: true };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async updateMemberRole(userId: string, role: number): Promise<{ success: boolean }> {
|
|
186
|
+
this.sql.exec('UPDATE members SET role = ? WHERE user_id = ?', role, userId);
|
|
187
|
+
|
|
188
|
+
// Sync with User DO
|
|
189
|
+
try {
|
|
190
|
+
const userStub = this.env.USER.get(this.env.USER.idFromString(userId));
|
|
191
|
+
await userStub.addMembership(this.ctx.id.toString(), role, false);
|
|
192
|
+
} catch (_e) {
|
|
193
|
+
console.error('Failed to sync membership role to UserDO', _e);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { success: true };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async removeMember(userId: string): Promise<{ success: boolean }> {
|
|
200
|
+
this.sql.exec('DELETE FROM members WHERE user_id = ?', userId);
|
|
201
|
+
|
|
202
|
+
// Update SystemDO index
|
|
203
|
+
await this.syncMemberCount();
|
|
204
|
+
|
|
205
|
+
// Sync with User DO
|
|
206
|
+
try {
|
|
207
|
+
const userStub = this.env.USER.get(this.env.USER.idFromString(userId));
|
|
208
|
+
await userStub.deleteMembership(this.ctx.id.toString());
|
|
209
|
+
} catch (_e) {
|
|
210
|
+
console.error('Failed to sync membership removal to UserDO', _e);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { success: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async syncMemberCount(): Promise<void> {
|
|
217
|
+
try {
|
|
218
|
+
const result = this.sql.exec('SELECT COUNT(*) as count FROM members');
|
|
219
|
+
const row = result.next().value as any;
|
|
220
|
+
const count = row ? row.count : 0;
|
|
221
|
+
|
|
222
|
+
const systemStub = this.env.SYSTEM.get(this.env.SYSTEM.idFromName('global'));
|
|
223
|
+
await systemStub.updateMemberCount(this.ctx.id.toString(), count);
|
|
224
|
+
} catch (_e) {
|
|
225
|
+
console.error('Failed to update member count in SystemDO', _e);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async delete(): Promise<{ success: boolean }> {
|
|
230
|
+
// Get all members to notify their UserDOs
|
|
231
|
+
const members = Array.from(this.sql.exec('SELECT user_id FROM members'));
|
|
232
|
+
for (const member of members as any[]) {
|
|
233
|
+
try {
|
|
234
|
+
const userStub = this.env.USER.get(this.env.USER.idFromString(member.user_id));
|
|
235
|
+
await userStub.deleteMembership(this.ctx.id.toString());
|
|
236
|
+
} catch (_e) {
|
|
237
|
+
console.error(`Failed to notify UserDO ${member.user_id} of account deletion`, _e);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Delete all account images from R2
|
|
242
|
+
const prefix = `account/${this.ctx.id.toString()}/`;
|
|
243
|
+
const listed = await this.env.IMAGE_STORAGE.list({ prefix });
|
|
244
|
+
const keys = listed.objects.map((o) => o.key);
|
|
245
|
+
if (keys.length > 0) {
|
|
246
|
+
await this.env.IMAGE_STORAGE.delete(keys);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Wipe all Durable Object storage
|
|
250
|
+
await this.ctx.storage.deleteAll();
|
|
251
|
+
|
|
252
|
+
return { success: true };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Billing Implementation
|
|
256
|
+
|
|
257
|
+
private getBillingState(): BillingState {
|
|
258
|
+
try {
|
|
259
|
+
const result = this.sql.exec('SELECT billing FROM account_info WHERE id = 1');
|
|
260
|
+
const row = result.next().value as any;
|
|
261
|
+
if (row && row.billing) {
|
|
262
|
+
return BillingStateSchema.parse(JSON.parse(row.billing));
|
|
263
|
+
}
|
|
264
|
+
} catch (_e) {
|
|
265
|
+
// ignore
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
plan_slug: 'free',
|
|
269
|
+
status: 'active',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private setBillingState(state: BillingState): void {
|
|
274
|
+
this.ctx.storage.transactionSync(() => {
|
|
275
|
+
this.sql.exec('UPDATE account_info SET billing = ?, plan = ? WHERE id = 1', JSON.stringify(state), state.plan_slug);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async getBillingInfo(): Promise<{ state: BillingState; plan_details: any }> {
|
|
280
|
+
const state = this.getBillingState();
|
|
281
|
+
const plan = Plan.get(state.plan_slug);
|
|
282
|
+
|
|
283
|
+
// Create a serializable version of the plan
|
|
284
|
+
const planDetails = plan
|
|
285
|
+
? {
|
|
286
|
+
slug: plan.slug,
|
|
287
|
+
name: plan.name,
|
|
288
|
+
capabilities: plan.capabilities,
|
|
289
|
+
downgrade_to_slug: plan.downgrade_to_slug,
|
|
290
|
+
grace_period: plan.grace_period,
|
|
291
|
+
schedules: plan.schedules.map((s) => ({
|
|
292
|
+
charge_amount: s.charge_amount,
|
|
293
|
+
charge_period: s.charge_period,
|
|
294
|
+
is_default: s.is_default,
|
|
295
|
+
})),
|
|
296
|
+
}
|
|
297
|
+
: null;
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
state,
|
|
301
|
+
plan_details: planDetails,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async subscribe(plan_slug: string, schedule_idx: number = 0): Promise<{ success: boolean; state: BillingState }> {
|
|
306
|
+
const plan = Plan.get(plan_slug);
|
|
307
|
+
|
|
308
|
+
if (!plan) {
|
|
309
|
+
throw new Error('Plan not found');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const currentState = this.getBillingState();
|
|
313
|
+
|
|
314
|
+
// Call hook if changing plans (simplification)
|
|
315
|
+
if (currentState.plan_slug !== plan_slug) {
|
|
316
|
+
if (currentState.plan_slug) {
|
|
317
|
+
const oldPlan = Plan.get(currentState.plan_slug);
|
|
318
|
+
if (oldPlan?.account_deactivate_hook) {
|
|
319
|
+
await oldPlan.account_deactivate_hook(this.ctx.id.toString());
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (plan.account_activate_hook) {
|
|
323
|
+
await plan.account_activate_hook(this.ctx.id.toString());
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Setup recurring payment
|
|
328
|
+
try {
|
|
329
|
+
await this.paymentEngine.setupRecurring(this.ctx.id.toString(), plan_slug, schedule_idx);
|
|
330
|
+
} catch (e) {
|
|
331
|
+
throw new Error(`Payment setup failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const newState: BillingState = {
|
|
335
|
+
...currentState,
|
|
336
|
+
plan_slug,
|
|
337
|
+
status: 'active',
|
|
338
|
+
schedule_idx,
|
|
339
|
+
next_billing_date: Date.now() + (plan.schedules[schedule_idx]?.charge_period || 30) * 24 * 60 * 60 * 1000,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
this.setBillingState(newState);
|
|
343
|
+
|
|
344
|
+
return { success: true, state: newState };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async cancelSubscription(): Promise<{ success: boolean; state: BillingState }> {
|
|
348
|
+
const currentState = this.getBillingState();
|
|
349
|
+
const currentPlan = Plan.get(currentState.plan_slug);
|
|
350
|
+
|
|
351
|
+
if (!currentPlan) {
|
|
352
|
+
throw new Error('No active plan');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await this.paymentEngine.cancelRecurring(this.ctx.id.toString());
|
|
356
|
+
|
|
357
|
+
// Downgrade logic (immediate or scheduled - simplification: scheduled if downgrade_to_slug exists)
|
|
358
|
+
// For this prototype, we'll mark it as canceled and set the next plan if applicable.
|
|
359
|
+
|
|
360
|
+
const newState: BillingState = {
|
|
361
|
+
...currentState,
|
|
362
|
+
status: 'canceled',
|
|
363
|
+
next_plan_slug: currentPlan.downgrade_to_slug,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
this.setBillingState(newState);
|
|
367
|
+
|
|
368
|
+
return { success: true, state: newState };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import { StartupAPIEnv } from '../StartupAPIEnv';
|
|
3
|
+
import { OAuthCredentialSchema } from '../schemas/credential';
|
|
4
|
+
import type { OAuthCredential, OAuthCredentialOutput } from '../schemas/credential';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Durable Object representing all OAuth credentials for a specific provider.
|
|
8
|
+
* Each instance is identified by the provider name (e.g., "google", "twitch").
|
|
9
|
+
*/
|
|
10
|
+
export class CredentialDO extends DurableObject {
|
|
11
|
+
sql: SqlStorage;
|
|
12
|
+
|
|
13
|
+
constructor(state: DurableObjectState, env: StartupAPIEnv) {
|
|
14
|
+
super(state, env);
|
|
15
|
+
this.sql = state.storage.sql;
|
|
16
|
+
|
|
17
|
+
this.sql.exec(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS credentials (
|
|
19
|
+
subject_id TEXT PRIMARY KEY,
|
|
20
|
+
user_id TEXT NOT NULL,
|
|
21
|
+
access_token TEXT,
|
|
22
|
+
refresh_token TEXT,
|
|
23
|
+
expires_at INTEGER,
|
|
24
|
+
scope TEXT,
|
|
25
|
+
profile_data TEXT,
|
|
26
|
+
created_at INTEGER,
|
|
27
|
+
updated_at INTEGER
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_user_id ON credentials(user_id);
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async get(subjectId: string): Promise<any | null> {
|
|
34
|
+
const result = this.sql.exec('SELECT * FROM credentials WHERE subject_id = ?', subjectId);
|
|
35
|
+
const row = result.next().value as any;
|
|
36
|
+
if (!row) return null;
|
|
37
|
+
|
|
38
|
+
row.profile_data = JSON.parse(row.profile_data);
|
|
39
|
+
return row;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async list(userId: string): Promise<any[]> {
|
|
43
|
+
const result = this.sql.exec('SELECT * FROM credentials WHERE user_id = ?', userId);
|
|
44
|
+
const credentials = [];
|
|
45
|
+
for (const row of result) {
|
|
46
|
+
(row as any).profile_data = JSON.parse((row as any).profile_data);
|
|
47
|
+
credentials.push(row);
|
|
48
|
+
}
|
|
49
|
+
return credentials;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async put(data: OAuthCredential): Promise<{ success: boolean }> {
|
|
53
|
+
console.log('[auth] Parsing Cred', data);
|
|
54
|
+
|
|
55
|
+
const validatedData: OAuthCredentialOutput = OAuthCredentialSchema.parse(data);
|
|
56
|
+
|
|
57
|
+
console.log('[auth] Validated Cred', validatedData);
|
|
58
|
+
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
|
|
61
|
+
this.sql.exec(
|
|
62
|
+
`INSERT OR REPLACE INTO credentials
|
|
63
|
+
(subject_id, user_id, access_token, refresh_token, expires_at, scope, profile_data, created_at, updated_at)
|
|
64
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
65
|
+
validatedData.subject_id,
|
|
66
|
+
validatedData.user_id,
|
|
67
|
+
validatedData.access_token,
|
|
68
|
+
validatedData.refresh_token,
|
|
69
|
+
validatedData.expires_at,
|
|
70
|
+
validatedData.scope,
|
|
71
|
+
JSON.stringify(validatedData.profile_data),
|
|
72
|
+
validatedData.created_at || now,
|
|
73
|
+
now,
|
|
74
|
+
);
|
|
75
|
+
return { success: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async delete(subjectId: string): Promise<{ success: boolean }> {
|
|
79
|
+
this.sql.exec('DELETE FROM credentials WHERE subject_id = ?', subjectId);
|
|
80
|
+
return { success: true };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import { StartupAPIEnv } from '../StartupAPIEnv';
|
|
3
|
+
import { SystemUserSchema } from '../schemas/user';
|
|
4
|
+
import { SystemAccountSchema } from '../schemas/account';
|
|
5
|
+
import type { SystemUser } from '../schemas/user';
|
|
6
|
+
import type { SystemAccount } from '../schemas/account';
|
|
7
|
+
|
|
8
|
+
export class SystemDO extends DurableObject {
|
|
9
|
+
sql: SqlStorage;
|
|
10
|
+
|
|
11
|
+
constructor(state: DurableObjectState, env: StartupAPIEnv) {
|
|
12
|
+
super(state, env);
|
|
13
|
+
this.sql = state.storage.sql;
|
|
14
|
+
|
|
15
|
+
this.sql.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
name TEXT,
|
|
19
|
+
email TEXT,
|
|
20
|
+
provider TEXT,
|
|
21
|
+
created_at INTEGER
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
name TEXT,
|
|
27
|
+
status TEXT,
|
|
28
|
+
plan TEXT,
|
|
29
|
+
member_count INTEGER DEFAULT 0,
|
|
30
|
+
created_at INTEGER
|
|
31
|
+
);
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async listUsers(query?: string): Promise<any[]> {
|
|
36
|
+
let sql = 'SELECT * FROM users';
|
|
37
|
+
const args: any[] = [];
|
|
38
|
+
|
|
39
|
+
if (query) {
|
|
40
|
+
sql += ' WHERE name LIKE ? OR email LIKE ?';
|
|
41
|
+
args.push(`%${query}%`, `%${query}%`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
sql += ' ORDER BY created_at DESC LIMIT 50';
|
|
45
|
+
|
|
46
|
+
const result = this.sql.exec(sql, ...args);
|
|
47
|
+
const users = Array.from(result).map((u: any) => {
|
|
48
|
+
const adminIds = (this.env.ADMIN_IDS || '').split(',').map((id) => id.trim());
|
|
49
|
+
const isAdmin =
|
|
50
|
+
adminIds.includes(u.id) ||
|
|
51
|
+
(this.env.ENVIRONMENT === 'test' &&
|
|
52
|
+
adminIds.some((id) => {
|
|
53
|
+
try {
|
|
54
|
+
return u.id === this.env.USER.idFromName(id).toString();
|
|
55
|
+
} catch (_e) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
...u,
|
|
62
|
+
is_admin: isAdmin,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
return users;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async getUserMemberships(userId: string): Promise<any[]> {
|
|
69
|
+
const userStub = this.env.USER.get(this.env.USER.idFromString(userId));
|
|
70
|
+
return await userStub.getMemberships();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async getUser(userId: string): Promise<any> {
|
|
74
|
+
try {
|
|
75
|
+
const userStub = this.env.USER.get(this.env.USER.idFromString(userId));
|
|
76
|
+
const profile = await userStub.getProfile();
|
|
77
|
+
return profile;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
throw new Error(e instanceof Error ? e.message : String(e));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async registerUser(data: SystemUser): Promise<{ success: boolean }> {
|
|
84
|
+
const validatedData = SystemUserSchema.parse(data);
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
|
|
87
|
+
this.sql.exec(
|
|
88
|
+
'INSERT OR REPLACE INTO users (id, name, email, provider, created_at) VALUES (?, ?, ?, ?, ?)',
|
|
89
|
+
validatedData.id,
|
|
90
|
+
validatedData.name,
|
|
91
|
+
validatedData.email || null,
|
|
92
|
+
validatedData.provider || null,
|
|
93
|
+
now,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return { success: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async deleteUser(userId: string): Promise<{ success: boolean }> {
|
|
100
|
+
// Delete from index
|
|
101
|
+
this.sql.exec('DELETE FROM users WHERE id = ?', userId);
|
|
102
|
+
|
|
103
|
+
// Call UserDO to delete its data
|
|
104
|
+
try {
|
|
105
|
+
const stub = this.env.USER.get(this.env.USER.idFromString(userId));
|
|
106
|
+
await stub.delete();
|
|
107
|
+
} catch (e) {
|
|
108
|
+
console.error('Failed to clear UserDO data', e);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { success: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async updateUser(userId: string, data: Partial<SystemUser>): Promise<{ success: boolean }> {
|
|
115
|
+
const validatedData = SystemUserSchema.partial().parse(data);
|
|
116
|
+
// Update UserDO
|
|
117
|
+
try {
|
|
118
|
+
const userStub = this.env.USER.get(this.env.USER.idFromString(userId));
|
|
119
|
+
await userStub.updateProfile(validatedData);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.error('Failed to update UserDO', e);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Update Index
|
|
125
|
+
if (validatedData.name || validatedData.email) {
|
|
126
|
+
const updates: string[] = [];
|
|
127
|
+
const args: any[] = [];
|
|
128
|
+
if (validatedData.name !== undefined) {
|
|
129
|
+
updates.push('name = ?');
|
|
130
|
+
args.push(validatedData.name);
|
|
131
|
+
}
|
|
132
|
+
if (validatedData.email !== undefined) {
|
|
133
|
+
updates.push('email = ?');
|
|
134
|
+
args.push(validatedData.email);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (updates.length > 0) {
|
|
138
|
+
args.push(userId);
|
|
139
|
+
this.sql.exec(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...args);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { success: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async listAccounts(query?: string): Promise<any[]> {
|
|
147
|
+
let sql = 'SELECT * FROM accounts';
|
|
148
|
+
const args: any[] = [];
|
|
149
|
+
|
|
150
|
+
if (query) {
|
|
151
|
+
sql += ' WHERE name LIKE ?';
|
|
152
|
+
args.push(`%${query}%`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
sql += ' ORDER BY created_at DESC LIMIT 50';
|
|
156
|
+
|
|
157
|
+
const result = this.sql.exec(sql, ...args);
|
|
158
|
+
return Array.from(result);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async getAccount(accountId: string): Promise<any> {
|
|
162
|
+
try {
|
|
163
|
+
const stub = this.env.ACCOUNT.get(this.env.ACCOUNT.idFromString(accountId));
|
|
164
|
+
const info = await stub.getInfo();
|
|
165
|
+
const billing = await stub.getBillingInfo();
|
|
166
|
+
|
|
167
|
+
return { ...info, billing };
|
|
168
|
+
} catch (e) {
|
|
169
|
+
throw new Error(e instanceof Error ? e.message : String(e));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async registerAccount(data: SystemAccount): Promise<{ success: boolean; id: string }> {
|
|
174
|
+
const validatedData = SystemAccountSchema.parse(data);
|
|
175
|
+
let accountIdStr = validatedData.id;
|
|
176
|
+
const accountName = validatedData.name;
|
|
177
|
+
|
|
178
|
+
if (!accountIdStr) {
|
|
179
|
+
const id = this.env.ACCOUNT.newUniqueId();
|
|
180
|
+
accountIdStr = id.toString();
|
|
181
|
+
|
|
182
|
+
// Initialize AccountDO
|
|
183
|
+
const stub = this.env.ACCOUNT.get(id);
|
|
184
|
+
await stub.updateInfo({
|
|
185
|
+
name: accountName,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// If owner provided, add them as ADMIN
|
|
189
|
+
if (validatedData.ownerId) {
|
|
190
|
+
await stub.addMember(validatedData.ownerId, 1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
|
|
196
|
+
this.sql.exec(
|
|
197
|
+
'INSERT OR REPLACE INTO accounts (id, name, status, plan, member_count, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
198
|
+
accountIdStr,
|
|
199
|
+
accountName,
|
|
200
|
+
validatedData.status || 'active',
|
|
201
|
+
validatedData.plan || 'free',
|
|
202
|
+
validatedData.ownerId ? 1 : 0,
|
|
203
|
+
now,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
return { success: true, id: accountIdStr };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async deleteAccount(accountId: string): Promise<{ success: boolean }> {
|
|
210
|
+
// Delete from index
|
|
211
|
+
this.sql.exec('DELETE FROM accounts WHERE id = ?', accountId);
|
|
212
|
+
|
|
213
|
+
// Call AccountDO to delete its data
|
|
214
|
+
try {
|
|
215
|
+
const stub = this.env.ACCOUNT.get(this.env.ACCOUNT.idFromString(accountId));
|
|
216
|
+
await stub.delete();
|
|
217
|
+
} catch (e) {
|
|
218
|
+
console.error('Failed to clear AccountDO data', e);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { success: true };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async updateMemberCount(accountId: string, count: number): Promise<void> {
|
|
225
|
+
this.sql.exec('UPDATE accounts SET member_count = ? WHERE id = ?', count, accountId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async updateAccount(accountId: string, data: Partial<SystemAccount>): Promise<{ success: boolean }> {
|
|
229
|
+
const validatedData = SystemAccountSchema.partial().parse(data);
|
|
230
|
+
|
|
231
|
+
// Update AccountDO
|
|
232
|
+
try {
|
|
233
|
+
const stub = this.env.ACCOUNT.get(this.env.ACCOUNT.idFromString(accountId));
|
|
234
|
+
await stub.updateInfo(validatedData);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
console.error('Failed to update AccountDO', e);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Update Index
|
|
240
|
+
const updates: string[] = [];
|
|
241
|
+
const args: any[] = [];
|
|
242
|
+
|
|
243
|
+
if (validatedData.name !== undefined) {
|
|
244
|
+
updates.push('name = ?');
|
|
245
|
+
args.push(validatedData.name);
|
|
246
|
+
}
|
|
247
|
+
if (validatedData.status !== undefined) {
|
|
248
|
+
updates.push('status = ?');
|
|
249
|
+
args.push(validatedData.status);
|
|
250
|
+
}
|
|
251
|
+
// Plan update usually via billing, but if forced:
|
|
252
|
+
if (validatedData.plan !== undefined) {
|
|
253
|
+
updates.push('plan = ?');
|
|
254
|
+
args.push(validatedData.plan);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (updates.length > 0) {
|
|
258
|
+
args.push(accountId);
|
|
259
|
+
this.sql.exec(`UPDATE accounts SET ${updates.join(', ')} WHERE id = ?`, ...args);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { success: true };
|
|
263
|
+
}
|
|
264
|
+
}
|