Skip to content

First-run setup

A fresh deployment has no schema, no users, and no organisations. First-run setup bridges that gap — automatically for the database, then interactively for the first admin account — via scripts/supa-first-run.sh, the /setup route, and three server functions in apps/app/src/server/functions/setup.ts.

scripts/supa-first-run.sh runs on every pnpm dev start (root predev hook) and whenever scripts/supa <runner> is invoked on a protected branch. It’s a no-op once migrations exist:

  1. Calls the Supabase Management API to check whether any migrations have been applied to SUPABASE_PROJECT_REF.
  2. If none have, applies every local migration with SUPABASE_FIRST_RUN=1 scripts/supa db push (the one exception to the protected-branch migration guard — see Migration workflow).
  3. Runs scripts/supa-configure-project.sh to set the PostgREST exposed-schema list and the Auth redirect allow-list — project-level settings a migration can’t reach, without which every app/network query would 42501 even on a freshly-migrated schema.

Both steps are idempotent and safe to run on every pnpm dev start; they exit immediately once done.

Once the schema exists but no app.system_admin row exists yet, the app is “uninitialised”. The root route’s beforeLoad (apps/app/src/routes/__root.tsx) calls getAppStatus() on every request and redirects any route outside SETUP_EXEMPT (/setup, /auth/confirm, /auth/callback) to /setup.

getAppStatus() uses the service-role client — it must be able to answer “is this app initialised?” before any admin user (and therefore before any RLS-satisfying session) exists. It also reports noSchema: true if the query itself fails, so /setup can distinguish “not initialised” from “not migrated”.

The /setup route is a two-step wizard:

  1. signUpAdmin({ email, password }) — calls supabase.auth.signUp(). Three outcomes:
    • Email confirmation is disabled (or Supabase signs the user straight in): proceed immediately to step 2.
    • A genuinely new signup: Supabase sends a confirmation email; the UI shows a “check your inbox” state. Confirming (/auth/confirm) redirects to /dashboard, which redirects back to /setup for step 2 since the app still isn’t initialised.
    • Already-registered-email recovery: Supabase’s anti-enumeration behaviour means signUp() never errors and never sends an email for an address that already has an account — it silently returns identities: []. signUpAdmin detects this and tries signing in with the password just supplied, so a setup that was interrupted after the auth user was created (but before initializeApp ran) can be resumed without creating a duplicate account or leaking whether the email exists.
  2. initializeApp({ orgName }) — one service-role write that creates the caller’s app.profile, the first app.organisation, the app.system_admin row, and an org_admin membership row, all in sequence. Re-verifies no system admin exists first (defence-in-depth — /setup’s beforeLoad also checks) and requires a valid signed-in session (getClaims()), so it’s safe to retry if a step fails partway.

After initializeApp succeeds, getAppStatus() reports initialized: true and the app behaves normally for every subsequent request.