diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..62b42a4
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ env:
+ # Placeholders so the build can run; CI never talks to Supabase.
+ NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: sb_publishable_placeholder
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+ - run: npm ci
+ - run: npm run lint
+ # Build first: it generates the route types that tsc checks against.
+ - run: npm run build
+ - run: npx tsc --noEmit
+ - run: npm test --if-present
diff --git a/next.config.ts b/next.config.ts
index a0cf0c7..ae5d58f 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,8 +1,24 @@
import type { NextConfig } from "next";
+// No script-src: a nonce-based CSP would force every page to render
+// dynamically. These directives still block clickjacking, hijacking,
+// form exfiltration, and plugins.
+const securityHeaders = [
+ {
+ key: "Content-Security-Policy",
+ value: "frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'",
+ },
+ { key: "X-Content-Type-Options", value: "nosniff" },
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
+ { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
+ { key: "Strict-Transport-Security", value: "max-age=63072000" },
+];
+
const nextConfig: NextConfig = {
- /* config options here */
cacheComponents: true,
+ async headers() {
+ return [{ source: "/:path*", headers: securityHeaders }];
+ },
partialPrefetching: true,
turbopack: {
rules: {
diff --git a/src/lib/data.ts b/src/lib/data.ts
index 7890534..fb23f83 100644
--- a/src/lib/data.ts
+++ b/src/lib/data.ts
@@ -13,7 +13,8 @@ export type ScheduleResult = { events: EventRow[]; error: string | null };
export async function getSchedule(): Promise {
"use cache";
cacheTag("events");
- cacheLife("minutes");
+ // Organizers edit events in the dashboard; show changes within ~30s.
+ cacheLife({ stale: 30, revalidate: 30, expire: 3600 });
if (!isSupabaseConfigured) return { events: [], error: null };
@@ -73,6 +74,6 @@ export async function searchProfiles(filters: PeopleFilters) {
query = query.order("created_at", { ascending: false });
}
- const { data, error } = await query.limit(200);
+ const { data, error } = await query.limit(1000);
return { profiles: data ?? [], error: error?.message ?? null };
}
diff --git a/supabase/config.toml b/supabase/config.toml
index 21ac97d..0b1617e 100644
--- a/supabase/config.toml
+++ b/supabase/config.toml
@@ -277,9 +277,9 @@ max_frequency = "5s"
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
-# [auth.hook.before_user_created]
-# enabled = true
-# uri = "pg-functions://postgres/auth/before-user-created-hook"
+[auth.hook.before_user_created]
+enabled = true
+uri = "pg-functions://postgres/public/hook_before_user_created"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
diff --git a/supabase/migrations/20261103000000_usc_signup_gate.sql b/supabase/migrations/20261103000000_usc_signup_gate.sql
new file mode 100644
index 0000000..d3e4c34
--- /dev/null
+++ b/supabase/migrations/20261103000000_usc_signup_gate.sql
@@ -0,0 +1,32 @@
+-- Only @usc.edu addresses can create accounts. Runs as the Supabase Auth
+-- "before user created" hook, so it also covers sign-ups made straight
+-- against the Auth API (bypassing the app's own form check).
+-- Existing accounts are untouched; this only gates new users.
+--
+-- Enable on the hosted project: Dashboard -> Authentication -> Hooks ->
+-- "Before User Created" -> Postgres function public.hook_before_user_created.
+
+create or replace function public.hook_before_user_created(event jsonb)
+returns jsonb
+language plpgsql
+stable
+set search_path = ''
+as $$
+declare
+ email text := lower(trim(coalesce(event->'user'->>'email', '')));
+begin
+ if email ~ '^[^@[:space:]]+@usc[.]edu$' then
+ return '{}'::jsonb;
+ end if;
+
+ return jsonb_build_object(
+ 'error', jsonb_build_object(
+ 'http_code', 403,
+ 'message', 'Sign up with your @usc.edu email.'
+ )
+ );
+end;
+$$;
+
+grant execute on function public.hook_before_user_created(jsonb) to supabase_auth_admin;
+revoke execute on function public.hook_before_user_created(jsonb) from public, anon, authenticated;