diff --git a/.gitignore b/.gitignore index 647e4f6..8f2cf01 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,9 @@ pnpm-debug.log* # environment variables .env -.env.production +.env.* +.dev.vars* +worker-configuration.d.ts # macOS-specific files .DS_Store diff --git a/README.md b/README.md index 65b9fd6..cce4fd7 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ All commands are run from the root of the project, from a terminal: ## Cloudflare Workers deployment -This site remains fully static. `wrangler.jsonc` publishes Astro's `dist/` output through Cloudflare Workers Static Assets; it does not add a server entrypoint, API routes, authentication, or database bindings. +Public pages remain static. `wrangler.jsonc` publishes Astro's `dist/` output through Cloudflare Workers Static Assets. The only runtime paths are `/organizer-console/**` and `/api/auth/**`; all other public assets remain asset-first. - `pnpm deploy:dry-run` builds the site and validates the Worker asset deployment locally. - `pnpm deploy` builds and deploys the static site with Wrangler. @@ -53,6 +53,27 @@ Before the first production deployment, repository administrators must create th - `CLOUDFLARE_ACCOUNT_ID` β€” the Cloudflare account that owns the Worker and `devcongress.org` zone. The first deployment should be validated at its Workers preview URL. Attach `devcongress.org` only after URL/content parity checks pass. GitHub Pages continues to deploy independently during the agreed soak window and remains the rollback path. + +## Organizer console setup + +The organizer console lives at `/organizer-console/`. It uses the existing Supabase project and Google OAuth flow, but creates its own app session on `devcongress.org`. + +The current production Worker is static-assets-only, so Cloudflare will not show runtime variables yet. After this feature deploys the Worker script for `devcongress-website`, configure these Worker secrets in Cloudflare and rerun the deployment if the first production run needs them: + +- `SUPABASE_URL` β€” the Supabase project URL. +- `SUPABASE_ANON_KEY` β€” the browser-safe Supabase anon key. The Worker deliberately returns this only from its same-origin auth-config endpoint. +- `SUPABASE_SERVICE_ROLE_KEY` β€” server-only Supabase key. Never expose this key in browser code, GitHub Actions variables, or the repository. + +`keep_vars: true` preserves Cloudflare dashboard variables during GitHub Actions deployments. For local Worker testing, create an ignored `.dev.vars` file with the same three values. + +In Supabase Auth, add both callback destinations to the allowed redirect URLs: + +- `https://devcongress.org/api/auth/admin/callback` +- `https://devcongress-website.admins-a7d.workers.dev/api/auth/admin/callback` + +Also add `https://devcongress.org` to the authorized JavaScript origins for the existing Google OAuth client. + +Google authentication proves identity only. Access is granted only when the verified Google email has an active row in `public.admin_memberships`; the Worker then stores only a hash of an opaque, HTTP-only `devcon_admin` session token in `public.admin_sessions`. ## πŸ‘€ Want to learn more? Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/package.json b/package.json index bff6b4d..b12f441 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "astro": "astro" }, "dependencies": { + "@supabase/supabase-js": "2.106.2", "astro": "^6.4.2", "zod": "^4.4.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08c8d9d..de263df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@supabase/supabase-js': + specifier: 2.106.2 + version: 2.106.2 astro: specifier: ^6.4.2 version: 6.4.2(rollup@4.60.4) @@ -760,6 +763,33 @@ packages: '@speed-highlight/core@1.2.17': resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + '@supabase/auth-js@2.106.2': + resolution: {integrity: sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.106.2': + resolution: {integrity: sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==} + engines: {node: '>=20.0.0'} + + '@supabase/phoenix@0.4.4': + resolution: {integrity: sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==} + + '@supabase/postgrest-js@2.106.2': + resolution: {integrity: sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.106.2': + resolution: {integrity: sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.106.2': + resolution: {integrity: sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.106.2': + resolution: {integrity: sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==} + engines: {node: '>=20.0.0'} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -1056,6 +1086,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -2210,6 +2244,38 @@ snapshots: '@speed-highlight/core@1.2.17': {} + '@supabase/auth-js@2.106.2': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.106.2': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.4': {} + + '@supabase/postgrest-js@2.106.2': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.106.2': + dependencies: + '@supabase/phoenix': 0.4.4 + tslib: 2.8.1 + + '@supabase/storage-js@2.106.2': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.106.2': + dependencies: + '@supabase/auth-js': 2.106.2 + '@supabase/functions-js': 2.106.2 + '@supabase/postgrest-js': 2.106.2 + '@supabase/realtime-js': 2.106.2 + '@supabase/storage-js': 2.106.2 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -2658,6 +2724,8 @@ snapshots: http-cache-semantics@4.2.0: {} + iceberg-js@0.8.1: {} + iron-webcrypto@1.2.1: {} is-docker@3.0.0: {} @@ -3337,8 +3405,7 @@ snapshots: trough@2.2.0: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} ufo@1.6.4: {} diff --git a/src/components/Navbar.astro b/src/components/Navbar.astro index 68a4950..8a4d490 100644 --- a/src/components/Navbar.astro +++ b/src/components/Navbar.astro @@ -1,4 +1,6 @@ --- +import OrganizerActions from './OrganizerActions.astro'; + interface Props { slackUrl: string; paystackUrl: string; } const { slackUrl, paystackUrl } = Astro.props; --- @@ -16,6 +18,7 @@ const { slackUrl, paystackUrl } = Astro.props; @@ -368,7 +372,8 @@ const folderPhotos = photos.filter((photo) => photo.type === 'folder'); background: var(--bg); border-bottom: 2px solid var(--black); padding-block: var(--s4); } - .mnav-inner { display: flex; align-items: center; justify-content: space-between; } + .mnav-inner { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); } + .mnav-actions { display: inline-flex; align-items: center; gap: var(--s3); } .mnav-back { font-size: var(--text-sm); font-weight: 500; color: var(--text-mid); transition: color 150ms ease; } .mnav-back:hover { color: var(--pink); } diff --git a/src/pages/meetups/index.astro b/src/pages/meetups/index.astro index 469ecce..a7f9e0d 100644 --- a/src/pages/meetups/index.astro +++ b/src/pages/meetups/index.astro @@ -1,6 +1,7 @@ --- import { getCollection } from 'astro:content'; import Base from '../../layouts/Base.astro'; +import OrganizerActions from '../../components/OrganizerActions.astro'; const meetups = await getCollection('meetups'); @@ -41,7 +42,10 @@ const statusConfig = { - ← Home + @@ -98,7 +102,8 @@ const statusConfig = { background: var(--bg); border-bottom: 2px solid var(--black); padding-block: var(--s4); } - .mnav-inner { display: flex; align-items: center; justify-content: space-between; } + .mnav-inner { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); } + .mnav-actions { display: inline-flex; align-items: center; gap: var(--s3); } .mnav-back { font-size: var(--text-sm); font-weight: 500; color: var(--text-mid); transition: color 150ms; } .mnav-back:hover { color: var(--pink); } diff --git a/src/pages/organizer-console/auth/callback.astro b/src/pages/organizer-console/auth/callback.astro new file mode 100644 index 0000000..4aed2e0 --- /dev/null +++ b/src/pages/organizer-console/auth/callback.astro @@ -0,0 +1,136 @@ +--- +import Base from '../../../layouts/Base.astro'; +--- + + +
+
+ dev:congress{}; +

Organizer access

+

Checking your account

+

+ Finishing Google sign-in securely. +

+ +
+
+ + + + + diff --git a/src/pages/organizer-console/index.astro b/src/pages/organizer-console/index.astro new file mode 100644 index 0000000..f506b64 --- /dev/null +++ b/src/pages/organizer-console/index.astro @@ -0,0 +1,94 @@ +--- +import Base from '../../layouts/Base.astro'; +import OrganizerActions from '../../components/OrganizerActions.astro'; +--- + + +
+
+ + Organizer Console + +
+
+ +
+
+

Organizer access

+

You’re in.

+

+ Your approved Google account has an active DevCongress organizer session. +

+
+ +
+
+ Access ready +

Organizer tools are moving here next.

+

+ This console is intentionally limited to access control for now. Event, talk, attendance, feedback, and community tools will move here one capability at a time. +

+ ← Back to website +
+
+
+ + + diff --git a/src/pages/organizer-console/login.astro b/src/pages/organizer-console/login.astro new file mode 100644 index 0000000..5faa1f1 --- /dev/null +++ b/src/pages/organizer-console/login.astro @@ -0,0 +1,150 @@ +--- +import Base from '../../layouts/Base.astro'; +--- + + +
+
+ +

Organizer access

+

Sign in with Google

+ + + + +
+
+ + + + + diff --git a/src/worker/index.ts b/src/worker/index.ts new file mode 100644 index 0000000..f813851 --- /dev/null +++ b/src/worker/index.ts @@ -0,0 +1,429 @@ +import { createClient } from '@supabase/supabase-js'; + +type AssetsBinding = { + fetch: (request: Request) => Promise; +}; + +type Env = { + ASSETS: AssetsBinding; + SUPABASE_URL?: string; + SUPABASE_ANON_KEY?: string; + SUPABASE_SERVICE_ROLE_KEY?: string; +}; + +type OrganizerRole = 'owner' | 'organizer'; + +type OrganizerSession = { + id: string; + email: string; + displayName: string | null; + role: OrganizerRole; +}; + +const ADMIN_SESSION_COOKIE = 'devcon_admin'; +const ADMIN_SESSION_MAX_AGE_SECONDS = 60 * 60 * 12; +const ORGANIZER_BASE_PATH = '/organizer-console'; +const NO_STORE_HEADERS = { + 'Cache-Control': 'no-store, max-age=0', + 'Content-Type': 'application/json; charset=utf-8', +}; + +function json(body: unknown, status = 200, headers: HeadersInit = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + ...NO_STORE_HEADERS, + ...headers, + }, + }); +} + +function readCookie(request: Request, name: string): string | null { + const prefix = `${name}=`; + const value = request.headers.get('Cookie') + ?.split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)); + + return value ? value.slice(prefix.length) : null; +} + +function normalizeEmail(value: string): string { + return value.trim().toLowerCase(); +} + +function isOrganizerRole(value: unknown): value is OrganizerRole { + return value === 'owner' || value === 'organizer'; +} + +function authIsConfigured(env: Env): env is Env & Required> { + return Boolean(env.SUPABASE_URL && env.SUPABASE_ANON_KEY && env.SUPABASE_SERVICE_ROLE_KEY); +} + +function safeOrganizerPath(value: string | null): string { + if ( + !value + || (value !== ORGANIZER_BASE_PATH && !value.startsWith(`${ORGANIZER_BASE_PATH}/`)) + || value.startsWith('//') + ) { + return ORGANIZER_BASE_PATH; + } + + return value; +} + +function requestOriginIsValid(request: Request): boolean { + const origin = request.headers.get('Origin'); + return origin === new URL(request.url).origin; +} + +function sessionCookie(request: Request, token: string): string { + const attributes = [ + `${ADMIN_SESSION_COOKIE}=${token}`, + 'Path=/', + `Max-Age=${ADMIN_SESSION_MAX_AGE_SECONDS}`, + 'HttpOnly', + 'SameSite=Lax', + ]; + + if (new URL(request.url).protocol === 'https:') attributes.push('Secure'); + return attributes.join('; '); +} + +function expiredSessionCookie(request: Request): string { + const attributes = [ + `${ADMIN_SESSION_COOKIE}=`, + 'Path=/', + 'Max-Age=0', + 'HttpOnly', + 'SameSite=Lax', + ]; + + if (new URL(request.url).protocol === 'https:') attributes.push('Secure'); + return attributes.join('; '); +} + +function newSessionToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +async function tokenHash(token: string): Promise { + const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)); + return [...new Uint8Array(bytes)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function requestIp(request: Request): string | null { + return request.headers.get('cf-connecting-ip') + ?? request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() + ?? null; +} + +function adminClient(env: Env & Required>) { + return createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + }); +} + +function authClient(env: Env & Required>) { + return createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + }); +} + +async function getOrganizerSession(request: Request, env: Env): Promise { + const token = readCookie(request, ADMIN_SESSION_COOKIE); + if (!token || !authIsConfigured(env)) return null; + + const { data, error } = await adminClient(env) + .from('admin_sessions') + .select('id, email, expires_at, revoked_at, admin_memberships!inner(display_name, status, role)') + .eq('token_hash', await tokenHash(token)) + .is('revoked_at', null) + .maybeSingle(); + + if (error || !data || new Date(data.expires_at).getTime() <= Date.now()) { + return null; + } + + const membership = Array.isArray(data.admin_memberships) + ? data.admin_memberships[0] + : data.admin_memberships; + + if (!membership || membership.status !== 'active' || !isOrganizerRole(membership.role)) { + return null; + } + + return { + id: data.id, + email: data.email, + displayName: membership.display_name ?? null, + role: membership.role, + }; +} + +async function recordAuditEvent( + request: Request, + env: Env & Required>, + input: { + action: string; + session?: OrganizerSession; + targetId?: string | null; + }, +): Promise { + const { error } = await adminClient(env) + .from('admin_audit_log') + .insert({ + actor_email: input.session?.email ?? null, + actor_role: input.session?.role ?? null, + action: input.action, + target_type: 'admin_session', + target_id: input.targetId ?? input.session?.id ?? null, + ip_address: requestIp(request), + user_agent: request.headers.get('user-agent') ?? null, + request_method: request.method, + request_path: new URL(request.url).pathname, + }); + + if (error) { + console.warn('Unable to write organizer audit event.'); + } +} + +async function handleAuthConfig(env: Env): Promise { + if (!authIsConfigured(env)) { + return json({ error: 'Organizer sign-in is not configured.' }, 503); + } + + return json({ + supabaseUrl: env.SUPABASE_URL, + supabaseAnonKey: env.SUPABASE_ANON_KEY, + }); +} + +async function handleSession(request: Request, env: Env): Promise { + const session = await getOrganizerSession(request, env); + if (!session) return json({ authenticated: false }); + + return json({ + authenticated: true, + email: session.email, + displayName: session.displayName, + role: session.role, + }); +} + +async function handleAuthCallback(request: Request): Promise { + const requestUrl = new URL(request.url); + const callbackUrl = new URL(`${ORGANIZER_BASE_PATH}/auth/callback`, requestUrl.origin); + const next = safeOrganizerPath(requestUrl.searchParams.get('next')); + const code = requestUrl.searchParams.get('code'); + const error = requestUrl.searchParams.get('error_description') ?? requestUrl.searchParams.get('error'); + + callbackUrl.searchParams.set('next', next); + if (code) callbackUrl.searchParams.set('code', code); + if (error) callbackUrl.searchParams.set('error', error); + + return new Response(null, { + status: 302, + headers: { + Location: callbackUrl.toString(), + 'Cache-Control': 'no-store, max-age=0', + }, + }); +} + +async function handleTokenExchange(request: Request, env: Env): Promise { + if (!requestOriginIsValid(request)) { + return json({ error: 'Invalid request origin.' }, 403); + } + + if (!authIsConfigured(env)) { + return json({ error: 'Organizer sign-in is not configured.' }, 503); + } + + let accessToken = ''; + try { + const body = await request.json() as { access_token?: unknown }; + accessToken = typeof body.access_token === 'string' ? body.access_token : ''; + } catch { + return json({ error: 'Invalid sign-in response.' }, 400); + } + + if (!accessToken || accessToken.length > 16_384) { + return json({ error: 'Google organizer sign-in could not be completed. Please try again.' }, 401); + } + + const { data, error } = await authClient(env).auth.getUser(accessToken); + const email = normalizeEmail(data.user?.email ?? ''); + const provider = String(data.user?.app_metadata.provider ?? ''); + + if (error || !data.user || !email || provider !== 'google') { + return json({ error: 'Google organizer sign-in could not be completed. Please try again.' }, 401); + } + + const supabase = adminClient(env); + const { data: membership, error: membershipError } = await supabase + .from('admin_memberships') + .select('id, display_name, role, status') + .eq('email', email) + .eq('status', 'active') + .maybeSingle(); + + if (membershipError) { + return json({ error: 'Unable to verify organizer access. Please try again.' }, 500); + } + + if (!membership || !isOrganizerRole(membership.role)) { + return json({ error: 'This Google account has not been approved for the organizer console.' }, 403); + } + + const sessionToken = newSessionToken(); + const expiresAt = new Date(Date.now() + ADMIN_SESSION_MAX_AGE_SECONDS * 1000).toISOString(); + const { data: insertedSession, error: sessionError } = await supabase + .from('admin_sessions') + .insert({ + token_hash: await tokenHash(sessionToken), + user_id: data.user.id, + membership_id: membership.id, + email, + role: membership.role, + expires_at: expiresAt, + user_agent: request.headers.get('user-agent') ?? null, + ip_address: requestIp(request), + }) + .select('id') + .single(); + + if (sessionError || !insertedSession) { + return json({ error: 'Unable to create organizer session. Please try again.' }, 500); + } + + await supabase + .from('admin_memberships') + .update({ last_login_at: new Date().toISOString() }) + .eq('id', membership.id); + + await recordAuditEvent(request, env, { + action: 'admin.login', + session: { + id: insertedSession.id, + email, + displayName: membership.display_name ?? null, + role: membership.role, + }, + targetId: membership.id, + }); + + return json({ authenticated: true }, 200, { + 'Set-Cookie': sessionCookie(request, sessionToken), + }); +} + +async function handleLogout(request: Request, env: Env): Promise { + if (!requestOriginIsValid(request)) { + return json({ error: 'Invalid request origin.' }, 403); + } + + const session = await getOrganizerSession(request, env); + const token = readCookie(request, ADMIN_SESSION_COOKIE); + if (token && authIsConfigured(env)) { + await adminClient(env) + .from('admin_sessions') + .update({ revoked_at: new Date().toISOString() }) + .eq('token_hash', await tokenHash(token)); + } + + if (session && authIsConfigured(env)) { + await recordAuditEvent(request, env, { + action: 'admin.logout', + session, + }); + } + + return json({ authenticated: false }, 200, { + 'Set-Cookie': expiredSessionCookie(request), + }); +} + +async function handleAuthRequest(request: Request, env: Env): Promise { + const pathname = new URL(request.url).pathname.replace(/\/+$/, '') || '/'; + + if (request.method === 'GET' && pathname === '/api/auth/config') { + return handleAuthConfig(env); + } + + if (request.method === 'GET' && pathname === '/api/auth/session') { + return handleSession(request, env); + } + + if (request.method === 'GET' && pathname === '/api/auth/admin/callback') { + return handleAuthCallback(request); + } + + if (request.method === 'POST' && pathname === '/api/auth/admin/exchange') { + return handleTokenExchange(request, env); + } + + if (request.method === 'POST' && pathname === '/api/auth/logout') { + return handleLogout(request, env); + } + + return json({ error: 'Not found.' }, 404); +} + +async function handleOrganizerPage(request: Request, env: Env): Promise { + const requestUrl = new URL(request.url); + const pathname = requestUrl.pathname.replace(/\/+$/, '') || '/'; + const isPublicAuthPage = pathname === `${ORGANIZER_BASE_PATH}/login` + || pathname === `${ORGANIZER_BASE_PATH}/auth/callback`; + + if (isPublicAuthPage) { + return env.ASSETS.fetch(request); + } + + const session = await getOrganizerSession(request, env); + if (session) { + return env.ASSETS.fetch(request); + } + + const loginUrl = new URL(`${ORGANIZER_BASE_PATH}/login`, requestUrl.origin); + loginUrl.searchParams.set('next', safeOrganizerPath(`${requestUrl.pathname}${requestUrl.search}`)); + return new Response(null, { + status: 302, + headers: { + Location: loginUrl.toString(), + 'Cache-Control': 'no-store, max-age=0', + }, + }); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const pathname = new URL(request.url).pathname; + + if (pathname.startsWith('/api/auth/')) { + return handleAuthRequest(request, env); + } + + if (pathname === ORGANIZER_BASE_PATH || pathname.startsWith(`${ORGANIZER_BASE_PATH}/`)) { + return handleOrganizerPage(request, env); + } + + return env.ASSETS.fetch(request); + }, +}; diff --git a/wrangler.jsonc b/wrangler.jsonc index cb11ff3..b53d06e 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,12 +1,27 @@ { "$schema": "./node_modules/wrangler/config-schema.json", "name": "devcongress-website", - "compatibility_date": "2026-07-10", + "compatibility_date": "2026-07-09", + "main": "./src/worker/index.ts", + "keep_vars": true, + "secrets": { + "required": [ + "SUPABASE_URL", + "SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY" + ] + }, "observability": { "enabled": true }, "assets": { "directory": "./dist", - "html_handling": "auto-trailing-slash" + "html_handling": "auto-trailing-slash", + "binding": "ASSETS", + "run_worker_first": [ + "/api/auth/*", + "/organizer-console", + "/organizer-console/*" + ] } }