The four
The Hearth
The Stage & Studio
The Library
The Bazaar
The Bridge
The Observatory
The Council
The Forge
The Nexus
The Realms
Authentication Flow
A password, or a link if a password is too much today. Two doors, one home.
Overview
We use Supabase Auth, and the door has two ways through it. A password is the first — the form asks for it, and a leaked-password check runs before it is ever used. Beside it, on the same page, stands a magic link: an email that opens the door once, for a day when a password is more than a nervous system has to spare. Neither is a lesser door. Both land in the same place.
The Journey
Step-by-Step Process
A Vessel Signs Up
Visits /signup and gives four things: a username, an email, a password, and the same password again. The Terms and the Privacy Policy are accepted here, by a checkbox that starts unticked.
📍 Code location: components/asgard/auth/SignupForm.tsx
The Password Is Checked Before It Is Used
A k-anonymous HaveIBeenPwned range check runs in the browser: the password is hashed there, only the first five characters of the hash are sent, and the comparison happens locally. The password never leaves the device. If HIBP is unreachable the check fails open — the Sanctuary does not lock its own door because a third party is napping.
📍 Code location: lib/auth/pwned.ts
Supabase Creates the Vessel
An auth record is created in auth.users. The house's birth chain, handle_new_user(), fills the three-table identity beside it — community_profiles (the public face), user_private (the sovereign shell, own-only by RLS), and vessel_config (presentation defaults, every ceremony off).
📍 Code location: docs/sql/007-the-vessel-arrives.sql
The Acid Test Is Offered, Not Imposed
Signup ends with an offer, and 'Not now' is a real answer that leads straight to /vessel. Taking it leads to /questionaire. It is offered again whenever it is wanted — nothing is lost by waiting, and nothing anywhere is gated behind it.
The Door Lands at /vessel
Whichever way through — a password, a magic link, or a returning login — the callback exchanges the code for a session and redirects to /vessel, the one room where the Velkomin greeting fires, so no arrival crosses in silence. A failed or spent link returns to /login and says so plainly.
📍 Code location: app/(auth)/callback/route.ts
Client-Side Auth
Browser client for user-facing components. A singleton, so React Strict Mode cannot build two.
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@/lib/generated/supabase/database.types'
let clientInstance: ReturnType<typeof createBrowserClient<Database>> | null = null
export function createClient() {
if (clientInstance) return clientInstance
clientInstance = createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
return clientInstance
}Server-Side Auth
Server client for route handlers and server components. It reads and writes the cookie store, which is how a session survives a redirect.
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { Database } from '@/lib/generated/supabase/database.types'
export async function createServerSupabase() {
const cookieStore = await cookies()
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) { return cookieStore.get(name)?.value },
set(name: string, value: string, options: any) {
cookieStore.set({ name, value, ...options })
},
remove(name: string, options: any) {
cookieStore.set({ name, value: '', ...options })
},
},
}
)
}How Routes Are Guarded
There is no middleware.ts at the repo root — nothing intercepts a request before it reaches a page. Guarding is a component: AuthGuard wraps the pages that need it and decides once, when auth first settles.
export default function AuthGuard({
children,
requireAuth = true,
redirectTo = AUTH_ROUTES.LOGIN,
}: AuthGuardProps) {
const { user, loading } = useAuth()
const signedInOnArrival = useRef<boolean | null>(null)
useEffect(() => {
if (loading) return
// The bounce is an ENTRY check, not a live leash. Read once, when auth
// first settles — signing in ON this page must leave the door's own
// landing in charge.
if (signedInOnArrival.current === null) signedInOnArrival.current = !!user
if (requireAuth && !user) {
// A spent recovery link says so, rather than failing in silence.
router.push(buildRedirectUrl(redirectTo, pathname))
} else if (!requireAuth && user && signedInOnArrival.current) {
router.push(AUTH_ROUTES.DASHBOARD) // '/vessel'
}
}, [user, loading, requireAuth, redirectTo, router, pathname])
if (loading) return <LoadingSanctuary />
if (requireAuth && !user) return null
return <>{children}</>
}Protected Routes
Every route below exists under src/app. These are all of them — the Sanctuary gates very little.
/reset-passwordAuthGuard requires a live recovery session. Without one it returns you to /login and says the link has been spent — no one transitions unaccompanied.
/login · /signup · /forgot-passwordAuthGuard, inverted: a vessel already signed in on arrival is sent to /vessel rather than shown the door twice.
/council/applications/creator · /council/applications/vendorThe form renders only for a signed-in vessel; a visitor is asked to sign in first, and is turned away from nothing else.
✨ Why Both?
- ✓A password is the fastest way back in for anyone who has one to hand
- ✓Some days a vessel has not the capacity to reset a password — the link is for those days
- ✓The password is checked against known breaches without ever leaving the device
- ✓Neither door tells a stranger whether an address has a home here