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;
+
Support Us
@@ -36,6 +39,7 @@ const { slackUrl, paystackUrl } = Astro.props;
Meetups
diff --git a/src/components/OrganizerActions.astro b/src/components/OrganizerActions.astro
new file mode 100644
index 0000000..fd63e3e
--- /dev/null
+++ b/src/components/OrganizerActions.astro
@@ -0,0 +1,93 @@
+
+
+
+
+
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index 97400b2..2f3bcbd 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -5,10 +5,12 @@ import FooterSection from '../components/FooterSection.astro';
interface Props {
title?: string;
description?: string;
+ robots?: string;
}
const {
title = "DevCongress | Africa's home for builders",
description = "DevCongress is a tech community where developers, designers, founders, and makers across Africa connect, grow, and build together.",
+ robots = 'index, follow',
} = Astro.props;
const siteEntries = await getCollection('site');
@@ -22,7 +24,7 @@ const site = siteEntries[0]!.data;
{title}
-
+
diff --git a/src/pages/meetups/[slug].astro b/src/pages/meetups/[slug].astro
index d9849a9..f580ab9 100644
--- a/src/pages/meetups/[slug].astro
+++ b/src/pages/meetups/[slug].astro
@@ -1,6 +1,7 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
+import OrganizerActions from '../../components/OrganizerActions.astro';
export async function getStaticPaths() {
const meetups = await getCollection('meetups');
@@ -76,7 +77,10 @@ const folderPhotos = photos.filter((photo) => photo.type === 'folder');
-
β All meetups
+
@@ -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';
+---
+
+
+
+
+
+ Organizer access
+ Checking your account
+
+ Finishing Google sign-in securely.
+
+
+ Back to sign in
+
+
+
+
+
+
+
+
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
+
+ Only Google accounts already added as DevCongress organizers can continue.
+
+
+
+ Continue with Google β
+
+ β Back to website
+
+
+
+
+
+
+
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/*"
+ ]
}
}