From f897e585b937c2e7f9f5396a3a6dcca4ea7f73fa Mon Sep 17 00:00:00 2001 From: Elvis020 Date: Tue, 14 Jul 2026 11:41:04 +0000 Subject: [PATCH] feat(organizer): prepare isolated organizer access Keep devcongress.org static-only by removing organizer navigation, console routes, and authentication runtime from the public-site Worker. Add a separate organizer Astro build and devcongress-organizer Worker configuration for the future organizer subdomain, proposed as em.devcongress.org. The organizer surface provides a minimal Google sign-in and OAuth callback flow, verifies active Supabase organizer membership, records audit events, and issues only Secure, HTTP-only, SameSite=Strict sessions. It is disabled until an exact ORGANIZER_ORIGIN and custom domain are configured, and it has no workers.dev exposure. Verified pnpm build, pnpm build:organizer, and pnpm exec wrangler deploy --config wrangler.organizer.jsonc --dry-run. --- .gitignore | 5 +- README.md | 11 + astro.organizer.config.mjs | 9 + organizer.dev.vars.example | 5 + package.json | 3 + pnpm-lock.yaml | 71 ++++- src-organizer/pages/auth/callback.astro | 55 ++++ src-organizer/pages/index.astro | 95 ++++++ src/worker/index.ts | 407 ++++++++++++++++++++++++ wrangler.organizer.jsonc | 22 ++ 10 files changed, 680 insertions(+), 3 deletions(-) create mode 100644 astro.organizer.config.mjs create mode 100644 organizer.dev.vars.example create mode 100644 src-organizer/pages/auth/callback.astro create mode 100644 src-organizer/pages/index.astro create mode 100644 src/worker/index.ts create mode 100644 wrangler.organizer.jsonc 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 + + +

Checking your account

Finishing Google sign-in securely.

+ + + + + + 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 + + +
+
+ dev:congress{}; +

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/*" + ] + } +}