Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,9 @@ dist
.env*
!.env.example
.wrangler/
wrangler.local.jsonc

# Mac folder index

.DS_Store
.DS_Store

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
},
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"dev": "wrangler dev -c wrangler.local.jsonc",
"preview": "wrangler dev --env preview",
"test": "vitest run",
"test:coverage": "vitest run --coverage --coverage.provider=istanbul",
Expand Down
2 changes: 0 additions & 2 deletions src/StartupAPIEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,4 @@ export type StartupAPIEnv = {
ADMIN_IDS: string;
SESSION_SECRET: string;
ENVIRONMENT?: string;
SYSTEM: DurableObjectNamespace;
IMAGE_STORAGE: R2Bucket;
} & Env;
10 changes: 8 additions & 2 deletions src/SystemDO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,14 @@ export class SystemDO extends DurableObject {
const adminIds = (this.env.ADMIN_IDS || '').split(',').map((id) => id.trim());
const isAdmin =
adminIds.includes(u.id) ||
(u.email && adminIds.includes(u.email)) ||
(u.provider && u.id && adminIds.includes(`${u.provider}:${u.id}`));
(this.env.ENVIRONMENT === 'test' &&
adminIds.some((id) => {
try {
return u.id === this.env.USER.idFromName(id).toString();
} catch (e) {
return false;
}
}));

return {
...u,
Expand Down
137 changes: 41 additions & 96 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,8 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri
}
}
} else if (parts[0] === 'impersonate' && request.method === 'POST') {
const { user_id } = (await request.json()) as { user_id: string };
const data = (await request.json()) as any;
const user_id = data.user_id || data.userId;
if (!user_id) return new Response('Missing user_id', { status: 400 });

if (user_id === user.id) {
Expand Down Expand Up @@ -269,6 +270,8 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri

return Response.json({ success: true }, { headers });
}

return new Response('Not Found', { status: 404 });
}

url.pathname = '/users/admin' + path;
Expand All @@ -281,7 +284,7 @@ async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: Coo
const user = await getUserFromSession(request, env, cookieManager);
if (!user) return new Response('Unauthorized', { status: 401 });

const { id: doId, sessionId, profile: initialProfile, credential } = user;
const { id: doId, profile: initialProfile, credential } = user;

try {
const id = env.USER.idFromString(doId);
Expand Down Expand Up @@ -347,22 +350,19 @@ function isAdmin(user: any, env: StartupAPIEnv): boolean {
const adminIds = env.ADMIN_IDS.split(',')
.map((e) => e.trim())
.filter(Boolean);
const profile = user.profile || {};
const credential = user.credential || {};

const userId = user.id;

return (
adminIds.includes(user.id) ||
adminIds.includes(userId) ||
(env.ENVIRONMENT === 'test' &&
adminIds.some((id) => {
try {
return user.id === env.USER.idFromName(id).toString();
return userId === env.USER.idFromName(id).toString();
} catch (e) {
return false;
}
})) ||
(profile.email && adminIds.includes(profile.email)) ||
(credential.subject_id && adminIds.includes(credential.subject_id)) ||
(credential.provider && credential.subject_id && adminIds.includes(`${credential.provider}:${credential.subject_id}`))
}))
);
}

Expand Down Expand Up @@ -508,25 +508,11 @@ async function handleDeleteCredential(request: Request, env: StartupAPIEnv, cook
}

async function handleMeImage(request: Request, env: StartupAPIEnv, type: string, cookieManager: CookieManager): Promise<Response> {
const cookieHeader = request.headers.get('Cookie');
if (!cookieHeader) return new Response('Unauthorized', { status: 401 });

const cookies = parseCookies(cookieHeader);
const sessionCookieEncrypted = cookies['session_id'];

if (!sessionCookieEncrypted) {
return new Response('Unauthorized', { status: 401 });
}

const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted);
if (!sessionCookie || !sessionCookie.includes(':')) {
return new Response('Unauthorized', { status: 401 });
}

const [sessionId, doId] = sessionCookie.split(':');
const user = await getUserFromSession(request, env, cookieManager);
if (!user) return new Response('Unauthorized', { status: 401 });

try {
const id = env.USER.idFromString(doId);
const id = env.USER.idFromString(user.id);
const stub = env.USER.get(id);

if (request.method === 'PUT') {
Expand All @@ -549,7 +535,7 @@ async function handleMeImage(request: Request, env: StartupAPIEnv, type: string,
return Response.json({ success: true });
}

return handleUserImage(request, env, doId, type, cookieManager);
return handleUserImage(request, env, user.id, type, cookieManager);
} catch (e: any) {
console.error('[handleMeImage] Error:', e.message, e.stack);
return new Response('Error fetching image: ' + e.message, { status: 500 });
Expand Down Expand Up @@ -680,29 +666,12 @@ function parseCookies(cookieHeader: string): Record<string, string> {
}

async function handleMyAccounts(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise<Response> {
const cookieHeader = request.headers.get('Cookie');
if (!cookieHeader) return new Response('Unauthorized', { status: 401 });

const cookies = parseCookies(cookieHeader);
const sessionCookieEncrypted = cookies['session_id'];

if (!sessionCookieEncrypted) {
return new Response('Unauthorized', { status: 401 });
}

const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted);
if (!sessionCookie || !sessionCookie.includes(':')) {
return new Response('Unauthorized', { status: 401 });
}

const [sessionId, doId] = sessionCookie.split(':');
const user = await getUserFromSession(request, env, cookieManager);
if (!user) return new Response('Unauthorized', { status: 401 });

try {
const id = env.USER.idFromString(doId);
const id = env.USER.idFromString(user.id);
const userStub = env.USER.get(id);
const data = await userStub.validateSession(sessionId);

if (!data.valid) return Response.json(data, { status: 401 });

// Fetch memberships
const memberships = await userStub.getMemberships();
Expand Down Expand Up @@ -731,35 +700,18 @@ async function handleMyAccounts(request: Request, env: StartupAPIEnv, cookieMana
}

async function handleSwitchAccount(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise<Response> {
const cookieHeader = request.headers.get('Cookie');
if (!cookieHeader) return new Response('Unauthorized', { status: 401 });

const cookies = parseCookies(cookieHeader);
const sessionCookieEncrypted = cookies['session_id'];

if (!sessionCookieEncrypted) {
return new Response('Unauthorized', { status: 401 });
}

const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted);
if (!sessionCookie || !sessionCookie.includes(':')) {
return new Response('Unauthorized', { status: 401 });
}
const user = await getUserFromSession(request, env, cookieManager);
if (!user) return new Response('Unauthorized', { status: 401 });

const [sessionId, doId] = sessionCookie.split(':');
const { account_id } = (await request.json()) as { account_id: string };

if (!account_id) {
return new Response('Missing account_id', { status: 400 });
}

try {
const id = env.USER.idFromString(doId);
const id = env.USER.idFromString(user.id);
const userStub = env.USER.get(id);
const data = await userStub.validateSession(sessionId);

if (!data.valid) return Response.json(data, { status: 401 });

return Response.json(await userStub.switchAccount(account_id));
} catch (e: any) {
return new Response(e.message, { status: 400 });
Expand All @@ -773,29 +725,16 @@ async function handleSSR(
usersPath: string,
cookieManager: CookieManager,
): Promise<Response> {
const cookieHeader = request.headers.get('Cookie');
const cookies = parseCookies(cookieHeader || '');
const sessionCookieEncrypted = cookies['session_id'];

if (!sessionCookieEncrypted) {
return Response.redirect(url.origin + '/', 302);
}

const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted);
if (!sessionCookie || !sessionCookie.includes(':')) {
const user = await getUserFromSession(request, env, cookieManager);
if (!user) {
return Response.redirect(url.origin + '/', 302);
}

const [sessionId, doId] = sessionCookie.split(':');
const { id: doId, sessionId, profile: initialProfile, credential } = user;

try {
const id = env.USER.idFromString(doId);
const userStub = env.USER.get(id);
const data = await userStub.validateSession(sessionId);

if (!data.valid) {
return Response.redirect(url.origin + '/', 302);
}

// Get HTML from assets
const assetUrl = new URL(url.toString());
Expand All @@ -821,17 +760,21 @@ async function handleSSR(

let html = await assetResponse.text();

const profile = { ...data.profile };
const data: any = {
valid: true,
profile: { ...initialProfile },
credential,
};

const image = await userStub.getImage('avatar');
if (image) {
const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/';
profile.picture = usersPathNormalized + 'me/avatar';
data.profile.picture = usersPathNormalized + 'me/avatar';
} else {
profile.picture = null;
data.profile.picture = null;
}

data.profile = profile;
data.is_admin = isAdmin({ id: doId, ...data.profile }, env);
data.is_admin = isAdmin({ id: doId, profile: data.profile, credential }, env);

// Fetch memberships to find current account
const memberships = await userStub.getMemberships();
Expand Down Expand Up @@ -864,14 +807,16 @@ async function handleSSR(
providers: getActiveProviders(env).join(','),
profile_json: JSON.stringify(data).replace(/"/g, '&quot;'),
credentials_json: JSON.stringify(credentials).replace(/"/g, '&quot;'),
profile_name: profile.name || 'Anonymous',
profile_name: data.profile.name || 'Anonymous',
profile_id: doId,
profile_email: profile.email || '',
profile_picture: profile.picture || '',
profile_picture_display: profile.picture ? 'display: block;' : 'display: none;',
profile_placeholder_display: profile.picture ? 'display: none;' : 'display: flex;',
profile_remove_btn_display: profile.picture ? 'display: flex;' : 'display: none;',
profile_provider_label: profile.provider ? `(from ${profile.provider.charAt(0).toUpperCase() + profile.provider.slice(1)})` : '',
profile_email: data.profile.email || '',
profile_picture: data.profile.picture || '',
profile_picture_display: data.profile.picture ? 'display: block;' : 'display: none;',
profile_placeholder_display: data.profile.picture ? 'display: none;' : 'display: flex;',
profile_remove_btn_display: data.profile.picture ? 'display: flex;' : 'display: none;',
profile_provider_label: data.profile.provider
? `(from ${data.profile.provider.charAt(0).toUpperCase() + data.profile.provider.slice(1)})`
: '',
nav_account_display: account && (account.role === 1 || data.is_admin) ? 'display: block;' : 'display: none;',
credentials_list_html: renderCredentialsList(credentials, data.credential?.provider),
link_credentials_html: renderLinkCredentialsList(getActiveProviders(env)),
Expand Down
65 changes: 48 additions & 17 deletions worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 1b2f4131cc4427d006995ed2932f1531)
// Generated by Wrangler by running `wrangler types` (hash: b67527d07bd06e5d88bcac89eae3c0e4)
// Runtime types generated with workerd@1.20260120.0 2025-09-27 global_fetch_strictly_public
declare namespace Cloudflare {
interface GlobalProps {
Expand All @@ -9,34 +9,65 @@ declare namespace Cloudflare {
interface PreviewEnv {
IMAGE_STORAGE: R2Bucket;
ASSETS: Fetcher;
SESSION_SECRET: string;
ADMIN_IDS: string;
ORIGIN_URL: string;
AUTH_ORIGIN: string;
TWITCH_CLIENT_ID: string;
TWITCH_CLIENT_SECRET: string;
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
ENVIRONMENT: "preview";
SESSION_SECRET: "";
ADMIN_IDS: "";
ORIGIN_URL: "https://startup-api-demo-origin.sergeychernyshev.workers.dev/";
TWITCH_CLIENT_ID: "";
TWITCH_CLIENT_SECRET: "";
GITHUB_PROJECT_ID: string;
USER: DurableObjectNamespace<import("./src/index").UserDO>;
ACCOUNT: DurableObjectNamespace<import("./src/index").AccountDO>;
SYSTEM: DurableObjectNamespace<import("./src/index").SystemDO>;
CREDENTIAL: DurableObjectNamespace<import("./src/index").CredentialDO>;
}
interface MinimalEnv {
IMAGE_STORAGE: R2Bucket;
ASSETS: Fetcher;
ENVIRONMENT: "minimal";
SESSION_SECRET: "";
ORIGIN_URL: "";
GITHUB_PROJECT_ID: string;
USER: DurableObjectNamespace<import("./src/index").UserDO>;
ACCOUNT: DurableObjectNamespace<import("./src/index").AccountDO>;
SYSTEM: DurableObjectNamespace<import("./src/index").SystemDO>;
CREDENTIAL: DurableObjectNamespace<import("./src/index").CredentialDO>;
}
interface OptionalEnv {
IMAGE_STORAGE: R2Bucket;
ASSETS: Fetcher;
ENVIRONMENT: "";
SESSION_SECRET: "";
ADMIN_IDS: "";
ORIGIN_URL: "";
AUTH_ORIGIN: "";
TWITCH_CLIENT_ID: "";
TWITCH_CLIENT_SECRET: "";
GOOGLE_CLIENT_ID: "";
GOOGLE_CLIENT_SECRET: "";
GITHUB_PROJECT_ID: string;
USER: DurableObjectNamespace<import("./src/index").UserDO>;
ACCOUNT: DurableObjectNamespace<import("./src/index").AccountDO>;
SYSTEM: DurableObjectNamespace<import("./src/index").SystemDO>;
CREDENTIAL: DurableObjectNamespace<import("./src/index").CredentialDO>;
}
interface Env {
SESSION_SECRET: string;
ADMIN_IDS: string;
ORIGIN_URL: string;
AUTH_ORIGIN: string;
TWITCH_CLIENT_ID: string;
TWITCH_CLIENT_SECRET: string;
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
GITHUB_PROJECT_ID: string;
IMAGE_STORAGE: R2Bucket;
ASSETS: Fetcher;
ENVIRONMENT?: "preview" | "minimal" | "";
SESSION_SECRET?: "";
ADMIN_IDS?: "";
ORIGIN_URL?: "https://startup-api-demo-origin.sergeychernyshev.workers.dev/" | "";
TWITCH_CLIENT_ID?: "";
TWITCH_CLIENT_SECRET?: "";
USER: DurableObjectNamespace<import("./src/index").UserDO>;
ACCOUNT: DurableObjectNamespace<import("./src/index").AccountDO>;
SYSTEM: DurableObjectNamespace<import("./src/index").SystemDO>;
CREDENTIAL: DurableObjectNamespace<import("./src/index").CredentialDO>;
AUTH_ORIGIN?: "";
GOOGLE_CLIENT_ID?: "";
GOOGLE_CLIENT_SECRET?: "";
}
}
interface Env extends Cloudflare.Env {}
Expand Down
Loading