diff --git a/.gitignore b/.gitignore
index 647e4f6..0d8086a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# build output
dist/
+dist-organizer/
# dependencies
node_modules/
@@ -13,7 +14,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 6552a21..a32fb0d 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,17 @@ Before the first production deployment, repository administrators must create th
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.
Forks and fork-origin pull requests run the build jobs only. GitHub Pages artifacts, Workers dry-run validation, protected environments, and production deployments run only from `devcongress/website` itself.
+
+## Organizer access (not deployed)
+
+The public website deliberately contains no organizer route or navigation link. A separate organizer-only Astro build and Worker configuration are prepared for the future confirmed subdomain.
+
+- `pnpm build:organizer` produces the private site in `dist-organizer/`.
+- `wrangler.organizer.jsonc` defines the separate `devcongress-organizer` Worker and intentionally has no route until the hostname is confirmed.
+- `ORGANIZER_ORIGIN` must be set to that exact HTTPS origin before the Worker will serve auth requests.
+- After the initial script deployment creates the Worker, set `SUPABASE_URL`, `SUPABASE_ANON_KEY`, and `SUPABASE_SERVICE_ROLE_KEY` as Cloudflare secrets. Do not put them in this repository.
+
+When the hostname is confirmed, add it as a Cloudflare custom domain, then add the matching Supabase redirect URL and Google OAuth authorized JavaScript origin.
## 👀 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/astro.organizer.config.mjs b/astro.organizer.config.mjs
new file mode 100644
index 0000000..2dacd60
--- /dev/null
+++ b/astro.organizer.config.mjs
@@ -0,0 +1,9 @@
+// @ts-check
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ srcDir: './src-organizer',
+ publicDir: './public',
+ outDir: './dist-organizer',
+ output: 'static',
+});
diff --git a/organizer.dev.vars.example b/organizer.dev.vars.example
new file mode 100644
index 0000000..662f3d3
--- /dev/null
+++ b/organizer.dev.vars.example
@@ -0,0 +1,5 @@
+# Set these locally only after the organizer hostname is confirmed.
+ORGANIZER_ORIGIN=https://organizer.example.devcongress.org
+SUPABASE_URL=
+SUPABASE_ANON_KEY=
+SUPABASE_SERVICE_ROLE_KEY=
diff --git a/package.json b/package.json
index bff6b4d..8058c4e 100644
--- a/package.json
+++ b/package.json
@@ -9,11 +9,14 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
+ "build:organizer": "astro build --config astro.organizer.config.mjs",
+ "deploy:organizer:dry-run": "pnpm build:organizer && wrangler deploy --config wrangler.organizer.jsonc --dry-run",
"deploy": "pnpm build && wrangler deploy",
"deploy:dry-run": "pnpm build && wrangler deploy --dry-run",
"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-organizer/pages/auth/callback.astro b/src-organizer/pages/auth/callback.astro
new file mode 100644
index 0000000..1ed0293
--- /dev/null
+++ b/src-organizer/pages/auth/callback.astro
@@ -0,0 +1,55 @@
+---
+---
+
+
+
+
+
+
+
+ Completing sign in | DevCongress
+
+
+
+
+
+
+
+
+
diff --git a/src-organizer/pages/index.astro b/src-organizer/pages/index.astro
new file mode 100644
index 0000000..7e8eead
--- /dev/null
+++ b/src-organizer/pages/index.astro
@@ -0,0 +1,95 @@
+---
+---
+
+
+
+
+
+
+
+
+ Organizer sign in | DevCongress
+
+
+
+
+
+ Organizer access
+ Sign in with Google
+ Only approved DevCongress organizers can continue.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/worker/index.ts b/src/worker/index.ts
new file mode 100644
index 0000000..f9da75b
--- /dev/null
+++ b/src/worker/index.ts
@@ -0,0 +1,407 @@
+import { createClient } from '@supabase/supabase-js';
+
+type AssetsBinding = {
+ fetch: (request: Request) => Promise;
+};
+
+type Env = {
+ ASSETS: AssetsBinding;
+ ORGANIZER_ORIGIN?: string;
+ 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 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 organizerOrigin(env: Env): string | null {
+ if (!env.ORGANIZER_ORIGIN) return null;
+
+ try {
+ const origin = new URL(env.ORGANIZER_ORIGIN).origin;
+ return origin === env.ORGANIZER_ORIGIN.replace(/\/$/, '') ? origin : null;
+ } catch {
+ return null;
+ }
+}
+
+function authIsConfigured(env: Env): env is Env & Required> {
+ return Boolean(organizerOrigin(env) && env.SUPABASE_URL && env.SUPABASE_ANON_KEY && env.SUPABASE_SERVICE_ROLE_KEY);
+}
+
+function requestOriginIsValid(request: Request, env: Env): boolean {
+ const expectedOrigin = organizerOrigin(env);
+ return Boolean(expectedOrigin && expectedOrigin === new URL(request.url).origin && request.headers.get('Origin') === expectedOrigin);
+}
+
+function sessionCookie(request: Request, token: string): string {
+ const attributes = [
+ `${ADMIN_SESSION_COOKIE}=${token}`,
+ 'Path=/',
+ `Max-Age=${ADMIN_SESSION_MAX_AGE_SECONDS}`,
+ 'HttpOnly',
+ 'SameSite=Strict',
+ ];
+
+ 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=Strict',
+ ];
+
+ 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('/auth/callback', requestUrl.origin);
+ const code = requestUrl.searchParams.get('code');
+ const error = requestUrl.searchParams.get('error_description') ?? requestUrl.searchParams.get('error');
+
+ 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, env)) {
+ 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, env)) {
+ 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);
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise {
+ const requestUrl = new URL(request.url);
+ const expectedOrigin = organizerOrigin(env);
+
+ if (!expectedOrigin) {
+ return json({ error: 'Organizer access is not configured.' }, 503);
+ }
+
+ if (requestUrl.origin !== expectedOrigin) {
+ return json({ error: 'Not found.' }, 404);
+ }
+
+ const pathname = requestUrl.pathname;
+
+ if (pathname.startsWith('/api/auth/')) {
+ return handleAuthRequest(request, env);
+ }
+
+ return env.ASSETS.fetch(request);
+ },
+};
diff --git a/wrangler.organizer.jsonc b/wrangler.organizer.jsonc
new file mode 100644
index 0000000..c8b52ee
--- /dev/null
+++ b/wrangler.organizer.jsonc
@@ -0,0 +1,22 @@
+{
+ "$schema": "./node_modules/wrangler/config-schema.json",
+ "name": "devcongress-organizer",
+ "compatibility_date": "2026-07-14",
+ "main": "./src/worker/index.ts",
+ "workers_dev": false,
+ "keep_vars": true,
+ "observability": {
+ "enabled": true
+ },
+ "assets": {
+ "directory": "./dist-organizer",
+ "html_handling": "auto-trailing-slash",
+ "binding": "ASSETS",
+ "run_worker_first": [
+ "/",
+ "/auth/callback",
+ "/auth/callback/",
+ "/api/auth/*"
+ ]
+ }
+}