@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,64 @@
|
|
|
1
|
+
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
2
|
+
|
|
3
|
+
import { OAuthProvider, OAuthTokenResponse, UserProfile } from './OAuthProvider';
|
|
4
|
+
|
|
5
|
+
export class TwitchProvider extends OAuthProvider {
|
|
6
|
+
static create(env: StartupAPIEnv, redirectBase: string): TwitchProvider | null {
|
|
7
|
+
if (!env.TWITCH_CLIENT_ID || !env.TWITCH_CLIENT_SECRET) return null;
|
|
8
|
+
return new TwitchProvider(env.TWITCH_CLIENT_ID, env.TWITCH_CLIENT_SECRET, redirectBase + '/twitch/callback', 'twitch');
|
|
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: 'user:read:email',
|
|
17
|
+
state: state,
|
|
18
|
+
});
|
|
19
|
+
return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getIcon(): string {
|
|
23
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
24
|
+
<circle cx="12" cy="12" r="11" fill="#9146FF" stroke="white" stroke-width="1"/>
|
|
25
|
+
<path d="M7 6H6v10h2v3l3-3h3l4-4V6H7zm9 6l-2 2h-3l-2 2v-2H8V7h8v5z" fill="white"/>
|
|
26
|
+
<path d="M14 8.5h1.5v2H14V8.5zm-3 0h1.5v2H11v-2z" fill="white"/>
|
|
27
|
+
</svg>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async getToken(code: string): Promise<OAuthTokenResponse> {
|
|
31
|
+
const params = new URLSearchParams({
|
|
32
|
+
code,
|
|
33
|
+
client_id: this.clientId,
|
|
34
|
+
client_secret: this.clientSecret,
|
|
35
|
+
redirect_uri: this.redirectUri,
|
|
36
|
+
grant_type: 'authorization_code',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return this.fetchJson<OAuthTokenResponse>('https://id.twitch.tv/oauth2/token', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: {
|
|
42
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
43
|
+
},
|
|
44
|
+
body: params.toString(),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getUserProfile(accessToken: string): Promise<UserProfile> {
|
|
49
|
+
const data = await this.fetchJson<{ data: any[] }>('https://api.twitch.tv/helix/users', {
|
|
50
|
+
headers: {
|
|
51
|
+
Authorization: `Bearer ${accessToken}`,
|
|
52
|
+
'Client-Id': this.clientId,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const user = data.data[0];
|
|
57
|
+
return {
|
|
58
|
+
id: user.id,
|
|
59
|
+
email: user.email,
|
|
60
|
+
name: user.display_name,
|
|
61
|
+
picture: user.profile_image_url,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
2
|
+
|
|
3
|
+
import { GoogleProvider } from './GoogleProvider';
|
|
4
|
+
import { TwitchProvider } from './TwitchProvider';
|
|
5
|
+
import { OAuthProvider } from './OAuthProvider';
|
|
6
|
+
import { CookieManager } from '../CookieManager';
|
|
7
|
+
|
|
8
|
+
export async function handleAuth(
|
|
9
|
+
request: Request,
|
|
10
|
+
env: StartupAPIEnv,
|
|
11
|
+
url: URL,
|
|
12
|
+
usersPath: string,
|
|
13
|
+
cookieManager: CookieManager,
|
|
14
|
+
): Promise<Response> {
|
|
15
|
+
const path = url.pathname;
|
|
16
|
+
const origin = env.AUTH_ORIGIN && env.AUTH_ORIGIN !== '' ? env.AUTH_ORIGIN : url.origin;
|
|
17
|
+
|
|
18
|
+
// Standardize redirectBase
|
|
19
|
+
const baseUsersPath = usersPath.startsWith('/') ? usersPath : '/' + usersPath;
|
|
20
|
+
const redirectBase = new URL((baseUsersPath.endsWith('/') ? baseUsersPath : baseUsersPath + '/') + 'auth', origin).toString();
|
|
21
|
+
|
|
22
|
+
// For internal matching, we still need authPath
|
|
23
|
+
const authPath = new URL(redirectBase).pathname;
|
|
24
|
+
|
|
25
|
+
// Instantiate providers
|
|
26
|
+
const providers: (OAuthProvider | null)[] = [GoogleProvider.create(env, redirectBase), TwitchProvider.create(env, redirectBase)];
|
|
27
|
+
|
|
28
|
+
const activeProviders = providers.filter((p): p is OAuthProvider => p !== null);
|
|
29
|
+
|
|
30
|
+
// Handle Auth Start
|
|
31
|
+
for (const provider of activeProviders) {
|
|
32
|
+
if (provider.isMatch(path, authPath)) {
|
|
33
|
+
const returnUrl = url.searchParams.get('return_url');
|
|
34
|
+
const stateObj = {
|
|
35
|
+
nonce: Math.random().toString(36).substring(2),
|
|
36
|
+
return_url: returnUrl,
|
|
37
|
+
};
|
|
38
|
+
// Use robust base64 encoding for state
|
|
39
|
+
const state = btoa(unescape(encodeURIComponent(JSON.stringify(stateObj))))
|
|
40
|
+
.replace(/\+/g, '-')
|
|
41
|
+
.replace(/\//g, '_')
|
|
42
|
+
.replace(/=+$/, '');
|
|
43
|
+
const authUrl = provider.getAuthUrl(state);
|
|
44
|
+
return Response.redirect(authUrl, 302);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Handle Auth Callback
|
|
49
|
+
for (const provider of activeProviders) {
|
|
50
|
+
if (provider.isCallback(path, authPath)) {
|
|
51
|
+
console.log(`[Auth] Callback received for ${provider.name}`);
|
|
52
|
+
const code = url.searchParams.get('code');
|
|
53
|
+
if (!code) return new Response('Missing code', { status: 400 });
|
|
54
|
+
|
|
55
|
+
const stateBase64 = url.searchParams.get('state');
|
|
56
|
+
let returnUrl: string | null = null;
|
|
57
|
+
if (stateBase64) {
|
|
58
|
+
try {
|
|
59
|
+
// Robust base64 decoding
|
|
60
|
+
const base64 = stateBase64.replace(/-/g, '+').replace(/_/g, '/');
|
|
61
|
+
const stateJson = decodeURIComponent(escape(atob(base64)));
|
|
62
|
+
const stateObj = JSON.parse(stateJson);
|
|
63
|
+
returnUrl = stateObj.return_url;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error('Failed to parse state', e);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const token = await provider.getToken(code);
|
|
71
|
+
const profile = await provider.getUserProfile(token.access_token);
|
|
72
|
+
|
|
73
|
+
const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global'));
|
|
74
|
+
|
|
75
|
+
// 1. Try to resolve existing user by credential
|
|
76
|
+
const credentialStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName(provider.name));
|
|
77
|
+
const resolveData = await credentialStub.get(profile.id);
|
|
78
|
+
|
|
79
|
+
let userIdStr: string | null = null;
|
|
80
|
+
let staleSessionId: string | null = null;
|
|
81
|
+
|
|
82
|
+
if (resolveData) {
|
|
83
|
+
userIdStr = resolveData.user_id;
|
|
84
|
+
} else {
|
|
85
|
+
// 2. Not found, check if user is already logged in (to link account)
|
|
86
|
+
const cookieHeader = request.headers.get('Cookie');
|
|
87
|
+
if (cookieHeader) {
|
|
88
|
+
const cookies = cookieHeader.split(';').reduce(
|
|
89
|
+
(acc, cookie) => {
|
|
90
|
+
const [key, value] = cookie.split('=').map((c) => c.trim());
|
|
91
|
+
if (key && value) acc[key] = value;
|
|
92
|
+
return acc;
|
|
93
|
+
},
|
|
94
|
+
{} as Record<string, string>,
|
|
95
|
+
);
|
|
96
|
+
const sessionCookieEncrypted = cookies['session_id'];
|
|
97
|
+
if (sessionCookieEncrypted) {
|
|
98
|
+
const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted);
|
|
99
|
+
if (sessionCookie && sessionCookie.includes(':')) {
|
|
100
|
+
const parts = sessionCookie.split(':');
|
|
101
|
+
staleSessionId = parts[0];
|
|
102
|
+
userIdStr = parts[1];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (userIdStr) {
|
|
109
|
+
// Verify user still exists (has a profile)
|
|
110
|
+
const userStub = env.USER.get(env.USER.idFromString(userIdStr));
|
|
111
|
+
const profileData = await userStub.getProfile();
|
|
112
|
+
if (Object.keys(profileData).length === 0) {
|
|
113
|
+
// User was deleted!
|
|
114
|
+
if (staleSessionId) {
|
|
115
|
+
try {
|
|
116
|
+
await userStub.deleteSession(staleSessionId);
|
|
117
|
+
} catch (_e) {
|
|
118
|
+
// ignore
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
userIdStr = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const isNewUser = !userIdStr;
|
|
126
|
+
const id = userIdStr ? env.USER.idFromString(userIdStr) : env.USER.newUniqueId();
|
|
127
|
+
const userStub = env.USER.get(id);
|
|
128
|
+
userIdStr = id.toString();
|
|
129
|
+
|
|
130
|
+
// Fetch and Store Avatar (Only for new users)
|
|
131
|
+
if (isNewUser && profile.picture) {
|
|
132
|
+
try {
|
|
133
|
+
const picRes = await fetch(profile.picture);
|
|
134
|
+
if (picRes.ok) {
|
|
135
|
+
const picBlob = await picRes.arrayBuffer();
|
|
136
|
+
await userStub.storeImage('avatar', picBlob, picRes.headers.get('Content-Type') || 'image/jpeg');
|
|
137
|
+
// Update profile.picture to point to our worker
|
|
138
|
+
profile.picture = usersPath + 'me/avatar';
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
console.error('Failed to fetch avatar', e);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Register credential in provider-specific CredentialDO
|
|
146
|
+
await credentialStub.put({
|
|
147
|
+
user_id: userIdStr,
|
|
148
|
+
subject_id: profile.id,
|
|
149
|
+
access_token: token.access_token,
|
|
150
|
+
refresh_token: token.refresh_token,
|
|
151
|
+
expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
|
|
152
|
+
scope: token.scope,
|
|
153
|
+
profile_data: profile,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Register credential mapping in UserDO
|
|
157
|
+
await userStub.addCredential(provider.name, profile.id);
|
|
158
|
+
|
|
159
|
+
// Register User in SystemDO index (Only for new users)
|
|
160
|
+
if (isNewUser) {
|
|
161
|
+
await userStub.updateProfile(profile);
|
|
162
|
+
await systemStub.registerUser({
|
|
163
|
+
id: userIdStr,
|
|
164
|
+
name: profile.name || userIdStr,
|
|
165
|
+
email: profile.email,
|
|
166
|
+
provider: provider.name,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Ensure user has at least one account
|
|
171
|
+
const memberships = await userStub.getMemberships();
|
|
172
|
+
|
|
173
|
+
if (memberships.length === 0) {
|
|
174
|
+
// Create a personal account
|
|
175
|
+
const accountId = env.ACCOUNT.newUniqueId();
|
|
176
|
+
const accountStub = env.ACCOUNT.get(accountId);
|
|
177
|
+
const accountIdStr = accountId.toString();
|
|
178
|
+
|
|
179
|
+
// Initialize account info
|
|
180
|
+
await accountStub.updateInfo({
|
|
181
|
+
name: `${profile.name || userIdStr}'s Account`,
|
|
182
|
+
personal: true,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Register Account in SystemDO
|
|
186
|
+
await systemStub.registerAccount({
|
|
187
|
+
id: accountIdStr,
|
|
188
|
+
name: `${profile.name || profile.id}'s Account`,
|
|
189
|
+
status: 'active',
|
|
190
|
+
plan: 'free',
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Add user as ADMIN to the account
|
|
194
|
+
await accountStub.addMember(id.toString(), 1);
|
|
195
|
+
|
|
196
|
+
// Add membership to user
|
|
197
|
+
await userStub.addMembership(accountIdStr, 1, true);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Create Session
|
|
201
|
+
const session = await userStub.createSession({ provider: provider.name });
|
|
202
|
+
|
|
203
|
+
// Set cookie and redirect
|
|
204
|
+
const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${userIdStr}`);
|
|
205
|
+
const headers = new Headers();
|
|
206
|
+
headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
207
|
+
|
|
208
|
+
let redirectUrl = !isNewUser ? usersPath + 'profile.html' : '/';
|
|
209
|
+
if (returnUrl) {
|
|
210
|
+
try {
|
|
211
|
+
const parsedReturn = new URL(returnUrl, origin);
|
|
212
|
+
if (parsedReturn.origin === origin) {
|
|
213
|
+
redirectUrl = parsedReturn.toString();
|
|
214
|
+
}
|
|
215
|
+
} catch (_e) {
|
|
216
|
+
if (returnUrl.startsWith('/')) {
|
|
217
|
+
redirectUrl = returnUrl;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
headers.set('Location', redirectUrl);
|
|
223
|
+
return new Response(null, { status: 302, headers });
|
|
224
|
+
} catch (e) {
|
|
225
|
+
return new Response(`Auth failed: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return new Response('Auth route not found', { status: 404 });
|
|
231
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export abstract class PaymentEngine {
|
|
2
|
+
abstract charge(accountId: string, amount: number, currency: string): Promise<boolean>;
|
|
3
|
+
abstract setupRecurring(accountId: string, planSlug: string, scheduleIdx: number): Promise<void>;
|
|
4
|
+
abstract cancelRecurring(accountId: string): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class MockPaymentEngine extends PaymentEngine {
|
|
8
|
+
async charge(accountId: string, amount: number, currency: string): Promise<boolean> {
|
|
9
|
+
console.log(`[MockPaymentEngine] Charging ${accountId} ${amount} ${currency}`);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async setupRecurring(accountId: string, planSlug: string, scheduleIdx: number): Promise<void> {
|
|
14
|
+
console.log(`[MockPaymentEngine] Setup recurring for ${accountId} on plan ${planSlug} schedule ${scheduleIdx}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async cancelRecurring(accountId: string): Promise<void> {
|
|
18
|
+
console.log(`[MockPaymentEngine] Cancel recurring for ${accountId}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export interface PaymentScheduleConfig {
|
|
2
|
+
charge_amount: number;
|
|
3
|
+
charge_period: number; // in days
|
|
4
|
+
is_default?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class PaymentSchedule {
|
|
8
|
+
charge_amount: number;
|
|
9
|
+
charge_period: number;
|
|
10
|
+
is_default: boolean;
|
|
11
|
+
|
|
12
|
+
constructor(config: PaymentScheduleConfig) {
|
|
13
|
+
this.charge_amount = config.charge_amount;
|
|
14
|
+
this.charge_period = config.charge_period;
|
|
15
|
+
this.is_default = config.is_default || false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PlanConfig {
|
|
20
|
+
slug: string;
|
|
21
|
+
name: string;
|
|
22
|
+
capabilities?: Record<string, boolean>;
|
|
23
|
+
downgrade_to_slug?: string;
|
|
24
|
+
grace_period?: number;
|
|
25
|
+
schedules?: PaymentScheduleConfig[];
|
|
26
|
+
account_activate_hook?: (accountId: string) => Promise<void>;
|
|
27
|
+
account_deactivate_hook?: (accountId: string) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class Plan {
|
|
31
|
+
slug: string;
|
|
32
|
+
name: string;
|
|
33
|
+
capabilities: Record<string, boolean>;
|
|
34
|
+
downgrade_to_slug?: string;
|
|
35
|
+
grace_period: number;
|
|
36
|
+
schedules: PaymentSchedule[];
|
|
37
|
+
account_activate_hook?: (accountId: string) => Promise<void>;
|
|
38
|
+
account_deactivate_hook?: (accountId: string) => Promise<void>;
|
|
39
|
+
|
|
40
|
+
constructor(config: PlanConfig) {
|
|
41
|
+
this.slug = config.slug;
|
|
42
|
+
this.name = config.name;
|
|
43
|
+
this.capabilities = config.capabilities || {};
|
|
44
|
+
this.downgrade_to_slug = config.downgrade_to_slug;
|
|
45
|
+
this.grace_period = config.grace_period || 0;
|
|
46
|
+
this.schedules = (config.schedules || []).map((s) => new PaymentSchedule(s));
|
|
47
|
+
this.account_activate_hook = config.account_activate_hook;
|
|
48
|
+
this.account_deactivate_hook = config.account_deactivate_hook;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Registry of plans
|
|
52
|
+
private static plans: Map<string, Plan> = new Map();
|
|
53
|
+
|
|
54
|
+
static init(plans: PlanConfig[]) {
|
|
55
|
+
Plan.plans.clear();
|
|
56
|
+
for (const p of plans) {
|
|
57
|
+
Plan.plans.set(p.slug, new Plan(p));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static isInitialized(): boolean {
|
|
62
|
+
return Plan.plans.size > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static clear() {
|
|
66
|
+
Plan.plans.clear();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static get(slug: string): Plan | undefined {
|
|
70
|
+
return Plan.plans.get(slug);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static getAll(): Plan[] {
|
|
74
|
+
return Array.from(Plan.plans.values());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getDefaultSchedule(): PaymentSchedule | undefined {
|
|
78
|
+
return this.schedules.find((s) => s.is_default) || this.schedules[0];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Plan, PlanConfig } from './Plan';
|
|
2
|
+
|
|
3
|
+
const plans: PlanConfig[] = [
|
|
4
|
+
{
|
|
5
|
+
slug: 'free',
|
|
6
|
+
name: 'Free',
|
|
7
|
+
capabilities: {
|
|
8
|
+
can_access_basic: true,
|
|
9
|
+
can_access_pro: false,
|
|
10
|
+
},
|
|
11
|
+
schedules: [{ charge_amount: 0, charge_period: 30, is_default: true }],
|
|
12
|
+
},
|
|
13
|
+
/*
|
|
14
|
+
{
|
|
15
|
+
slug: 'pro',
|
|
16
|
+
name: 'Pro',
|
|
17
|
+
capabilities: {
|
|
18
|
+
can_access_basic: true,
|
|
19
|
+
can_access_pro: true,
|
|
20
|
+
},
|
|
21
|
+
downgrade_to_slug: 'free',
|
|
22
|
+
grace_period: 7,
|
|
23
|
+
schedules: [
|
|
24
|
+
{ charge_amount: 2900, charge_period: 30, is_default: true }, // $29.00 / month
|
|
25
|
+
{ charge_amount: 29000, charge_period: 365 }, // $290.00 / year
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
slug: 'enterprise',
|
|
30
|
+
name: 'Enterprise',
|
|
31
|
+
capabilities: {
|
|
32
|
+
can_access_basic: true,
|
|
33
|
+
can_access_pro: true,
|
|
34
|
+
can_access_enterprise: true,
|
|
35
|
+
},
|
|
36
|
+
downgrade_to_slug: 'pro',
|
|
37
|
+
grace_period: 14,
|
|
38
|
+
schedules: [
|
|
39
|
+
{ charge_amount: 49900, charge_period: 30, is_default: true }, // $499.00 / month
|
|
40
|
+
{ charge_amount: 499000, charge_period: 365 }, // $4990.00 / year
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
*/
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function initPlans() {
|
|
47
|
+
Plan.init(plans);
|
|
48
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { StartupAPIEnv } from '../StartupAPIEnv';
|
|
2
|
+
import { CookieManager } from '../CookieManager';
|
|
3
|
+
import { getUserFromSession, checkAndClearStaleSession, isAdmin } from './utils';
|
|
4
|
+
import { AccountDO } from '../storage/AccountDO';
|
|
5
|
+
import { AccountInfoSchema, MemberSchema, SwitchAccountSchema } from '../schemas/account';
|
|
6
|
+
|
|
7
|
+
export async function handleMyAccounts(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise<Response> {
|
|
8
|
+
const user = await getUserFromSession(request, env, cookieManager);
|
|
9
|
+
if (!user) {
|
|
10
|
+
return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const id = env.USER.idFromString(user.id);
|
|
15
|
+
const userStub = env.USER.get(id);
|
|
16
|
+
|
|
17
|
+
// Fetch memberships
|
|
18
|
+
const memberships = await userStub.getMemberships();
|
|
19
|
+
|
|
20
|
+
const accounts = await Promise.all(
|
|
21
|
+
memberships.map(async (m: any) => {
|
|
22
|
+
try {
|
|
23
|
+
const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(m.account_id));
|
|
24
|
+
const info = await accountStub.getInfo();
|
|
25
|
+
return {
|
|
26
|
+
account_id: m.account_id,
|
|
27
|
+
name: info.name || 'Unknown Account',
|
|
28
|
+
role: m.role,
|
|
29
|
+
is_current: m.is_current,
|
|
30
|
+
};
|
|
31
|
+
} catch (_e) {
|
|
32
|
+
return { account_id: m.account_id, name: 'Unknown Account', role: m.role, is_current: m.is_current };
|
|
33
|
+
}
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return Response.json(accounts);
|
|
38
|
+
} catch (_e) {
|
|
39
|
+
return new Response('Unauthorized', { status: 401 });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function handleSwitchAccount(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise<Response> {
|
|
44
|
+
const user = await getUserFromSession(request, env, cookieManager);
|
|
45
|
+
if (!user) {
|
|
46
|
+
return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const data = await request.json();
|
|
51
|
+
const { account_id } = SwitchAccountSchema.parse(data);
|
|
52
|
+
|
|
53
|
+
const id = env.USER.idFromString(user.id);
|
|
54
|
+
const userStub = env.USER.get(id);
|
|
55
|
+
return Response.json(await userStub.switchAccount(account_id));
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return new Response(e instanceof Error ? e.message : String(e), { status: 400 });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function handleAccountDetails(
|
|
62
|
+
request: Request,
|
|
63
|
+
env: StartupAPIEnv,
|
|
64
|
+
accountId: string,
|
|
65
|
+
cookieManager: CookieManager,
|
|
66
|
+
): Promise<Response> {
|
|
67
|
+
const user = await getUserFromSession(request, env, cookieManager);
|
|
68
|
+
if (!user) {
|
|
69
|
+
return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const userStub = env.USER.get(env.USER.idFromString(user.id));
|
|
73
|
+
const memberships = await userStub.getMemberships();
|
|
74
|
+
const membership = memberships.find((m: any) => m.account_id === accountId);
|
|
75
|
+
|
|
76
|
+
const isAccountAdmin = membership && (membership as any).role === AccountDO.ROLE_ADMIN;
|
|
77
|
+
const isSysAdmin = isAdmin(user, env);
|
|
78
|
+
|
|
79
|
+
if (!isAccountAdmin && !isSysAdmin) {
|
|
80
|
+
return new Response('Forbidden', { status: 403 });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accountId));
|
|
84
|
+
|
|
85
|
+
if (request.method === 'GET') {
|
|
86
|
+
const info = await accountStub.getInfo();
|
|
87
|
+
const billing = await accountStub.getBillingInfo();
|
|
88
|
+
return Response.json({ ...info, billing, role: membership?.role });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (request.method === 'POST') {
|
|
92
|
+
try {
|
|
93
|
+
const data = await request.json();
|
|
94
|
+
const validatedData = AccountInfoSchema.partial().parse(data);
|
|
95
|
+
const result = await accountStub.updateInfo(validatedData);
|
|
96
|
+
|
|
97
|
+
// Sync with SystemDO index if name or plan changed
|
|
98
|
+
if (validatedData.name || validatedData.plan) {
|
|
99
|
+
try {
|
|
100
|
+
const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global'));
|
|
101
|
+
const updates: any = {};
|
|
102
|
+
if (validatedData.name) updates.name = validatedData.name;
|
|
103
|
+
if (validatedData.plan) updates.plan = validatedData.plan;
|
|
104
|
+
await systemStub.updateAccount(accountId, updates);
|
|
105
|
+
} catch (_e) {
|
|
106
|
+
console.error('Failed to sync account updates to SystemDO', _e);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return Response.json(result);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return new Response(e instanceof Error ? e.message : String(e), { status: 400 });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return new Response('Method Not Allowed', { status: 405 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function handleAccountImage(
|
|
119
|
+
request: Request,
|
|
120
|
+
env: StartupAPIEnv,
|
|
121
|
+
accountId: string,
|
|
122
|
+
type: string,
|
|
123
|
+
cookieManager: CookieManager,
|
|
124
|
+
): Promise<Response> {
|
|
125
|
+
const user = await getUserFromSession(request, env, cookieManager);
|
|
126
|
+
if (!user) return new Response('Unauthorized', { status: 401 });
|
|
127
|
+
|
|
128
|
+
const userStub = env.USER.get(env.USER.idFromString(user.id));
|
|
129
|
+
const memberships = await userStub.getMemberships();
|
|
130
|
+
const membership = memberships.find((m: any) => m.account_id === accountId);
|
|
131
|
+
|
|
132
|
+
// For viewing, we might allow any member to see account avatar
|
|
133
|
+
if (!membership && !isAdmin(user, env)) {
|
|
134
|
+
return new Response('Forbidden', { status: 403 });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accountId));
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
if (request.method === 'PUT') {
|
|
141
|
+
// Only admins can upload
|
|
142
|
+
if (membership?.role !== AccountDO.ROLE_ADMIN && !isAdmin(user, env)) {
|
|
143
|
+
return new Response('Forbidden', { status: 403 });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const contentType = request.headers.get('Content-Type');
|
|
147
|
+
if (!contentType || !contentType.startsWith('image/')) {
|
|
148
|
+
return new Response('Invalid image type', { status: 400 });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const blob = await request.arrayBuffer();
|
|
152
|
+
if (blob.byteLength > 1024 * 1024) {
|
|
153
|
+
return new Response('Image too large (max 1MB)', { status: 400 });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await accountStub.storeImage(type, blob, contentType);
|
|
157
|
+
return Response.json({ success: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (request.method === 'DELETE') {
|
|
161
|
+
// Only admins can delete
|
|
162
|
+
if (membership?.role !== AccountDO.ROLE_ADMIN && !isAdmin(user, env)) {
|
|
163
|
+
return new Response('Forbidden', { status: 403 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await accountStub.deleteImage(type);
|
|
167
|
+
return Response.json({ success: true });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const image = await accountStub.getImage(type);
|
|
171
|
+
if (!image) return new Response('Not Found', { status: 404 });
|
|
172
|
+
return new Response(image.value, { headers: { 'Content-Type': image.mime_type } });
|
|
173
|
+
} catch (e) {
|
|
174
|
+
console.error('[handleAccountImage] Error:', e instanceof Error ? e.message : String(e), e instanceof Error ? e.stack : '');
|
|
175
|
+
return new Response(`Error handling account image: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function handleAccountMembers(
|
|
180
|
+
request: Request,
|
|
181
|
+
env: StartupAPIEnv,
|
|
182
|
+
accountId: string,
|
|
183
|
+
pathParts: string[],
|
|
184
|
+
cookieManager: CookieManager,
|
|
185
|
+
): Promise<Response> {
|
|
186
|
+
const user = await getUserFromSession(request, env, cookieManager);
|
|
187
|
+
if (!user) {
|
|
188
|
+
return checkAndClearStaleSession(request, env, cookieManager, new Response('Unauthorized', { status: 401 }));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const userStub = env.USER.get(env.USER.idFromString(user.id));
|
|
192
|
+
const memberships = await userStub.getMemberships();
|
|
193
|
+
const membership = memberships.find((m: any) => m.account_id === accountId);
|
|
194
|
+
|
|
195
|
+
const isAccountAdmin = membership && (membership as any).role === AccountDO.ROLE_ADMIN;
|
|
196
|
+
const isSysAdmin = isAdmin(user, env);
|
|
197
|
+
|
|
198
|
+
if (!isAccountAdmin && !isSysAdmin) {
|
|
199
|
+
return new Response('Forbidden', { status: 403 });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accountId));
|
|
203
|
+
|
|
204
|
+
if (pathParts.length === 0) {
|
|
205
|
+
if (request.method === 'GET') {
|
|
206
|
+
return Response.json(await accountStub.getMembers());
|
|
207
|
+
}
|
|
208
|
+
if (request.method === 'POST') {
|
|
209
|
+
try {
|
|
210
|
+
const data = await request.json();
|
|
211
|
+
const { user_id, role } = MemberSchema.partial().parse(data);
|
|
212
|
+
if (!user_id || role === undefined) {
|
|
213
|
+
return new Response('Missing user_id or role', { status: 400 });
|
|
214
|
+
}
|
|
215
|
+
return Response.json(await accountStub.addMember(user_id, role));
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return new Response(e instanceof Error ? e.message : String(e), { status: 400 });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} else if (pathParts.length === 1) {
|
|
221
|
+
const targetUserId = pathParts[0];
|
|
222
|
+
if (request.method === 'DELETE') {
|
|
223
|
+
if (targetUserId === user.id) {
|
|
224
|
+
return new Response('Cannot remove yourself', { status: 400 });
|
|
225
|
+
}
|
|
226
|
+
return Response.json(await accountStub.removeMember(targetUserId));
|
|
227
|
+
}
|
|
228
|
+
if (request.method === 'PATCH') {
|
|
229
|
+
try {
|
|
230
|
+
const data = await request.json();
|
|
231
|
+
const { role } = MemberSchema.partial().parse(data);
|
|
232
|
+
if (role === undefined) {
|
|
233
|
+
return new Response('Missing role', { status: 400 });
|
|
234
|
+
}
|
|
235
|
+
if (targetUserId === user.id && role !== AccountDO.ROLE_ADMIN) {
|
|
236
|
+
return new Response('Cannot demote yourself', { status: 400 });
|
|
237
|
+
}
|
|
238
|
+
return Response.json(await accountStub.updateMemberRole(targetUserId, role));
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return new Response(e instanceof Error ? e.message : String(e), { status: 400 });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return new Response('Not Found', { status: 404 });
|
|
246
|
+
}
|