Skip to content

Commit 900b2bb

Browse files
committed
feat(organizer): isolate organizer access from the public site
Keep devcongress.org static-only by removing the public organizer navigation, console routes, and Worker runtime configuration from the public-site deployment. Add a separate organizer Astro build and devcongress-organizer Worker configuration that remains unreachable until a confirmed custom subdomain is configured. Require an exact ORGANIZER_ORIGIN before the organizer Worker serves authentication, restrict it to a minimal sign-in and OAuth callback surface, preserve server-only Supabase credentials, and keep organizer sessions in Secure, HTTP-only, SameSite=Strict cookies. Verified pnpm build, pnpm build:organizer, and pnpm exec wrangler deploy --config wrangler.organizer.jsonc --dry-run.
1 parent ae2fb42 commit 900b2bb

18 files changed

Lines changed: 234 additions & 581 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# build output
22
dist/
3+
dist-organizer/
34

45
# dependencies
56
node_modules/

README.md

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ All commands are run from the root of the project, from a terminal:
4040

4141
## Cloudflare Workers deployment
4242

43-
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.
43+
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.
4444

4545
- `pnpm deploy:dry-run` builds the site and validates the Worker asset deployment locally.
4646
- `pnpm deploy` builds and deploys the static site with Wrangler.
@@ -56,26 +56,16 @@ The first deployment should be validated at its Workers preview URL. Attach `dev
5656

5757
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.
5858

59-
## Organizer console setup
59+
## Organizer access (not deployed)
6060

61-
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`.
61+
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.
6262

63-
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:
63+
- `pnpm build:organizer` produces the private site in `dist-organizer/`.
64+
- `wrangler.organizer.jsonc` defines the separate `devcongress-organizer` Worker and intentionally has no route until the hostname is confirmed.
65+
- `ORGANIZER_ORIGIN` must be set to that exact HTTPS origin before the Worker will serve auth requests.
66+
- 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.
6467

65-
- `SUPABASE_URL` — the Supabase project URL.
66-
- `SUPABASE_ANON_KEY` — the browser-safe Supabase anon key. The Worker deliberately returns this only from its same-origin auth-config endpoint.
67-
- `SUPABASE_SERVICE_ROLE_KEY` — server-only Supabase key. Never expose this key in browser code, GitHub Actions variables, or the repository.
68-
69-
`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.
70-
71-
In Supabase Auth, add both callback destinations to the allowed redirect URLs:
72-
73-
- `https://devcongress.org/api/auth/admin/callback`
74-
- `https://devcongress-website.admins-a7d.workers.dev/api/auth/admin/callback`
75-
76-
Also add `https://devcongress.org` to the authorized JavaScript origins for the existing Google OAuth client.
77-
78-
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`.
68+
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.
7969
## 👀 Want to learn more?
8070

8171
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

astro.organizer.config.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// @ts-check
2+
import { defineConfig } from 'astro/config';
3+
4+
export default defineConfig({
5+
srcDir: './src-organizer',
6+
publicDir: './public',
7+
outDir: './dist-organizer',
8+
output: 'static',
9+
});

organizer.dev.vars.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Set these locally only after the organizer hostname is confirmed.
2+
ORGANIZER_ORIGIN=https://organizer.example.devcongress.org
3+
SUPABASE_URL=
4+
SUPABASE_ANON_KEY=
5+
SUPABASE_SERVICE_ROLE_KEY=

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
"dev": "astro dev",
1010
"build": "astro build",
1111
"preview": "astro preview",
12+
"build:organizer": "astro build --config astro.organizer.config.mjs",
13+
"deploy:organizer:dry-run": "pnpm build:organizer && wrangler deploy --config wrangler.organizer.jsonc --dry-run",
1214
"deploy": "pnpm build && wrangler deploy",
1315
"deploy:dry-run": "pnpm build && wrangler deploy --dry-run",
1416
"astro": "astro"
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
---
3+
4+
<!doctype html>
5+
<html lang="en">
6+
<head>
7+
<meta charset="utf-8" />
8+
<meta name="viewport" content="width=device-width" />
9+
<meta name="robots" content="noindex, nofollow" />
10+
<title>Completing sign in | DevCongress</title>
11+
</head>
12+
<body>
13+
<main><section><h1 data-title>Checking your account</h1><p data-message>Finishing Google sign-in securely.</p><a href="/" data-back hidden>Back to sign in</a></section></main>
14+
</body>
15+
</html>
16+
17+
<style>
18+
:root { color: #e5e5e5; background: #1c1c1c; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
19+
body { margin: 0; } main { display: grid; min-height: 100vh; place-items: center; padding: 24px; }
20+
section { width: min(100%, 460px); padding: 40px; border: 1px solid #3a3a3a; border-radius: 12px; background: #242424; }
21+
h1 { margin: 0; letter-spacing: -0.15px; font-size: clamp(32px, 8vw, 44px); line-height: 1.05; } p, a { color: #a1a1a1; line-height: 1.6; }
22+
</style>
23+
24+
<script>
25+
import { createClient } from '@supabase/supabase-js';
26+
27+
type AuthConfig = { supabaseUrl: string; supabaseAnonKey: string };
28+
const title = document.querySelector<HTMLElement>('[data-title]');
29+
const message = document.querySelector<HTMLElement>('[data-message]');
30+
const back = document.querySelector<HTMLAnchorElement>('[data-back]');
31+
function showError(value: string) { if (title) title.textContent = 'Sign-in unavailable'; if (message) message.textContent = value; if (back) back.hidden = false; }
32+
33+
async function completeSignIn() {
34+
const parameters = new URLSearchParams(window.location.search);
35+
const code = parameters.get('code');
36+
const callbackError = parameters.get('error');
37+
if (callbackError || !code) return showError(callbackError ?? 'Google did not return a sign-in code. Please try again.');
38+
try {
39+
const response = await fetch('/api/auth/config', { credentials: 'same-origin' });
40+
const config = await response.json() as AuthConfig & { error?: string };
41+
if (!response.ok || !config.supabaseUrl || !config.supabaseAnonKey) throw new Error(config.error ?? 'Organizer sign-in is not configured.');
42+
const supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
43+
auth: { persistSession: true, autoRefreshToken: false, detectSessionInUrl: false, flowType: 'pkce', storage: window.sessionStorage, storageKey: 'devcon-organizer-auth' },
44+
});
45+
const { data, error } = await supabase.auth.exchangeCodeForSession(code);
46+
if (error || !data.session?.access_token) throw new Error('Google organizer sign-in could not be completed. Please try again.');
47+
const exchange = await fetch('/api/auth/admin/exchange', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ access_token: data.session.access_token }) });
48+
const payload = await exchange.json().catch(() => null) as { error?: string } | null;
49+
await supabase.auth.signOut();
50+
if (!exchange.ok) throw new Error(payload?.error ?? 'Google organizer sign-in could not be completed. Please try again.');
51+
window.location.replace('/');
52+
} catch (error) { showError(error instanceof Error ? error.message : 'Google organizer sign-in could not be completed. Please try again.'); }
53+
}
54+
void completeSignIn();
55+
</script>

src-organizer/pages/index.astro

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
---
2+
---
3+
4+
<!doctype html>
5+
<html lang="en">
6+
<head>
7+
<meta charset="utf-8" />
8+
<meta name="viewport" content="width=device-width" />
9+
<meta name="robots" content="noindex, nofollow" />
10+
<meta name="theme-color" content="#1C1C1C" />
11+
<title>Organizer sign in | DevCongress</title>
12+
</head>
13+
<body>
14+
<main>
15+
<section class="card" aria-labelledby="title">
16+
<img src="/images/logo.png" alt="dev:congress{};" height="42" />
17+
<p class="eyebrow">Organizer access</p>
18+
<h1 id="title">Sign in with Google</h1>
19+
<p>Only approved DevCongress organizers can continue.</p>
20+
<p class="message" data-message role="status" aria-live="polite"></p>
21+
<button type="button" data-sign-in>Continue with Google</button>
22+
<button type="button" class="secondary" data-sign-out hidden>Sign out</button>
23+
</section>
24+
</main>
25+
</body>
26+
</html>
27+
28+
<style>
29+
:root { color: #e5e5e5; background: #1c1c1c; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
30+
* { box-sizing: border-box; }
31+
body { margin: 0; min-width: 320px; }
32+
main { display: grid; min-height: 100vh; place-items: center; padding: 24px; }
33+
.card { width: min(100%, 460px); padding: 40px; border: 1px solid #3a3a3a; border-radius: 12px; background: #242424; }
34+
img { display: block; width: auto; margin-bottom: 36px; }
35+
.eyebrow { margin: 0 0 10px; color: #a1a1a1; font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
36+
h1 { margin: 0; letter-spacing: -0.15px; font-size: clamp(32px, 8vw, 44px); line-height: 1.05; }
37+
p:not(.eyebrow) { color: #a1a1a1; line-height: 1.6; }
38+
.message { min-height: 24px; color: #e5e5e5 !important; font-size: 14px; }
39+
button { width: 100%; min-height: 46px; border: 0; border-radius: 8px; background: #e5e5e5; color: #1c1c1c; font: inherit; font-weight: 700; cursor: pointer; }
40+
button:disabled { cursor: wait; opacity: .65; }
41+
button.secondary { margin-top: 12px; border: 1px solid #4a4a4a; background: transparent; color: #e5e5e5; }
42+
</style>
43+
44+
<script>
45+
import { createClient } from '@supabase/supabase-js';
46+
47+
type AuthConfig = { supabaseUrl: string; supabaseAnonKey: string };
48+
const message = document.querySelector<HTMLElement>('[data-message]');
49+
const signIn = document.querySelector<HTMLButtonElement>('[data-sign-in]');
50+
const signOut = document.querySelector<HTMLButtonElement>('[data-sign-out]');
51+
52+
function setMessage(value: string) { if (message) message.textContent = value; }
53+
54+
async function authConfig(): Promise<AuthConfig> {
55+
const response = await fetch('/api/auth/config', { credentials: 'same-origin' });
56+
const payload = await response.json() as AuthConfig & { error?: string };
57+
if (!response.ok || !payload.supabaseUrl || !payload.supabaseAnonKey) throw new Error(payload.error ?? 'Organizer sign-in is not configured.');
58+
return payload;
59+
}
60+
61+
async function updateSession() {
62+
const response = await fetch('/api/auth/session', { credentials: 'same-origin' });
63+
const session = await response.json() as { authenticated?: boolean; email?: string };
64+
if (session.authenticated) {
65+
setMessage(`Signed in as ${session.email ?? 'an organizer'}.`);
66+
signIn?.setAttribute('hidden', '');
67+
if (signOut) signOut.hidden = false;
68+
}
69+
}
70+
71+
signIn?.addEventListener('click', async () => {
72+
signIn.disabled = true;
73+
setMessage('Opening Google sign-in…');
74+
try {
75+
const config = await authConfig();
76+
const supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
77+
auth: { persistSession: true, autoRefreshToken: false, detectSessionInUrl: false, flowType: 'pkce', storage: window.sessionStorage, storageKey: 'devcon-organizer-auth' },
78+
});
79+
const callbackUrl = new URL('/api/auth/admin/callback', window.location.origin);
80+
const { error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: callbackUrl.toString(), scopes: 'email profile', queryParams: { prompt: 'select_account' } } });
81+
if (error) throw error;
82+
} catch (error) {
83+
setMessage(error instanceof Error ? error.message : 'Unable to start Google sign-in. Please try again.');
84+
signIn.disabled = false;
85+
}
86+
});
87+
88+
signOut?.addEventListener('click', async () => {
89+
signOut.disabled = true;
90+
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
91+
window.location.replace('/');
92+
});
93+
94+
void updateSession();
95+
</script>

src/components/Navbar.astro

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
---
2-
import OrganizerActions from './OrganizerActions.astro';
3-
42
interface Props { slackUrl: string; paystackUrl: string; }
53
const { slackUrl, paystackUrl } = Astro.props;
64
---
@@ -18,7 +16,6 @@ const { slackUrl, paystackUrl } = Astro.props;
1816
</nav>
1917

2018
<div class="navbar-actions">
21-
<OrganizerActions />
2219
<a href={paystackUrl} target="_blank" rel="noopener noreferrer" class="btn btn-outline">
2320
Support Us
2421
</a>
@@ -39,7 +36,6 @@ const { slackUrl, paystackUrl } = Astro.props;
3936
<a href="#meetups">Meetups</a>
4037
</nav>
4138
<div class="mobile-actions">
42-
<OrganizerActions />
4339
<a href={paystackUrl} target="_blank" rel="noopener noreferrer" class="btn btn-outline">Support Us</a>
4440
<a href={slackUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary">Join Slack</a>
4541
</div>

src/components/OrganizerActions.astro

Lines changed: 0 additions & 93 deletions
This file was deleted.

src/layouts/Base.astro

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@ import FooterSection from '../components/FooterSection.astro';
55
interface Props {
66
title?: string;
77
description?: string;
8-
robots?: string;
98
}
109
const {
1110
title = "DevCongress | Africa's home for builders",
1211
description = "DevCongress is a tech community where developers, designers, founders, and makers across Africa connect, grow, and build together.",
13-
robots = 'index, follow',
1412
} = Astro.props;
1513
1614
const siteEntries = await getCollection('site');
@@ -24,7 +22,7 @@ const site = siteEntries[0]!.data;
2422
<meta name="viewport" content="width=device-width, initial-scale=1" />
2523
<title>{title}</title>
2624
<meta name="description" content={description} />
27-
<meta name="robots" content={robots} />
25+
<meta name="robots" content="index, follow" />
2826
<meta name="theme-color" content="#F5F2E8" />
2927
<meta property="og:title" content={title} />
3028
<meta property="og:description" content={description} />

0 commit comments

Comments
 (0)