brackt/docs/agents/auth.md
Chris Parsons 5268e07365
Migrate auth from Clerk to BetterAuth (#354)
* Fix BetterAuth field mapping and add owner-prefixed team names

- Fix auth.server.ts: use camelCase Drizzle field names for BetterAuth
  adapter (was snake_case, causing user inserts to fail); add
  generateId: "uuid" so PostgreSQL UUID columns accept generated IDs
- Add prependOwnerToTeamName / stripOwnerFromTeamName utilities
- Invite join, league creation, and settings assign/remove all now
  manage the owner-prefix on team names atomically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Complete BetterAuth migration: auth flows, rename, and cleanup

- Add /forgot-password and /reset-password pages (full password reset flow)
- Add 'Forgot password?' link on login page
- Fix register.tsx: add explicit window.location fallback after signUp
- Rename commissionerAuditLog.actorClerkId → actorUserId (migration 0084)
  and update all references in model, routes, components, and tests
- Rewrite docs/agents/auth.md to document BetterAuth (removes all Clerk refs)
- Update test fixtures and schema comments to remove Clerk ID references;
  use UUID-format IDs throughout test data
- Update docs/agents/domain-models.md ownerId description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix BetterAuth ID generation, clean up review issues

- Set generateId: false so BetterAuth omits id from inserts; add
  $defaultFn(crypto.randomUUID) to accounts/sessions/verifications
  so Drizzle fills it in (fixes null id constraint violation on signup)
- Move renameTeam to static import; rename before removeTeamOwner so
  a failed rename leaves owner intact rather than leaving a stale prefix
- Clarify stripOwnerFromTeamName toTitleCase assumption in comment
- Annotate reset-password token fallback to explain loader guarantee

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 10:03:50 -07:00

3.1 KiB

Authentication

Auth is handled by BetterAuth (better-auth package).

Key Files

File Purpose
app/lib/auth.server.ts BetterAuth instance — providers, DB adapter, field mapping
app/lib/auth-client.ts Browser-side auth client (exported as authClient)
app/routes/api.auth.$.ts Catches all /api/auth/* requests and delegates to BetterAuth
app/lib/auth.ts Server helper: requireLeagueAccess()

Reading the Session

In any route loader or action:

import { auth } from "~/lib/auth.server";

const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user.id ?? null; // UUID string

session is null when the user is not logged in.

User Fields

BetterAuth's user object on the session exposes these fields:

Session field DB column
session.user.id users.id (UUID)
session.user.name users.display_name (mapped as displayName)
session.user.email users.email
session.user.image users.image_url (mapped as imageUrl)

Additional fields (firstName, lastName, username, isAdmin) are stored in users and accessible via DB lookup but are NOT on the session object by default.

Admin Access

isAdmin is a boolean on the users table. All /admin routes check it:

const isAdmin = session ? await isUserAdmin(session.user.id) : false;

Do not rely on session.user.isAdmin — always re-read from the DB to pick up changes without requiring a new login.

Ownership Validation

Ownership is stored as a user UUID in FK columns (teams.owner_id, leagues.created_by, etc.). Compare against session.user.id:

if (team.ownerId !== userId) throw new Response("Forbidden", { status: 403 });

League Commissioner Access

Commissioners are stored in the commissioners table. Use requireLeagueAccess() from app/lib/auth.ts to enforce that the current user is either a commissioner or a team owner:

await requireLeagueAccess(args, { leagueId, seasonId, db });

Auth Routes

Route Purpose
/login Email/password sign-in + Google/Discord OAuth
/register Email/password sign-up + OAuth
/forgot-password Request a password reset email
/reset-password Enter new password via reset token (link sent by email via Resend)

Password reset emails are sent via Resend from noreply@brackt.com. The reset link points to /reset-password?token=… using BETTER_AUTH_URL as the base.

OAuth Providers

Google and Discord are conditionally enabled when env vars are present:

GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET

Account linking is enabled — users who sign in with OAuth get their accounts linked if the email matches an existing email/password account (trustedProviders: ["google", "discord"]).

Environment Variables

BETTER_AUTH_SECRET   # required — random secret for signing sessions
BETTER_AUTH_URL      # required — app base URL, e.g. https://brackt.com
RESEND_API_KEY       # required for password reset emails