// src/lib/server/auth.ts import { log } from '$lib/server/logger'; import { betterAuth } from 'better-auth/minimal'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { sveltekitCookies } from 'better-auth/svelte-kit'; import { env } from '$env/dynamic/private'; import { getRequestEvent } from '$app/server'; import { db } from '$lib/server/db'; import { genericOAuth } from 'better-auth/plugins'; import { team, teamMember, playerRating } from './db/schema'; import { proxyImage } from './storage/r2'; // Stash the osu! profile between mapProfileToUser and user.create.after // (both run in the same request, so this is safe) let pendingOsuProfile: { rank: number | null } | null = null; function eloFromRank(rank: number | null): { elo: number; osuRank: number | null } { // Unranked → treat as rank 10,000,000 (yields 0 ELO) const effectiveRank = rank && rank > 0 ? rank : 10_000_000; const rawElo = 3500 - Math.log10(effectiveRank) * 500; return { elo: Math.round(Math.max(0, Math.min(3500, rawElo))), osuRank: rank }; } export const auth = betterAuth({ baseURL: env.ORIGIN, secret: env.BETTER_AUTH_SECRET, database: drizzleAdapter(db, { provider: 'pg' }), emailAndPassword: { enabled: true }, databaseHooks: { user: { create: { after: async (user) => { const [personalTeam] = await db .insert(team) .values({ name: user.name, isPersonal: true, ownerId: user.id, avatarUrl: user.image }) .returning(); await db.insert(teamMember).values({ teamId: personalTeam.id, userId: user.id, role: 'owner' }); const { elo, osuRank } = eloFromRank(pendingOsuProfile?.rank ?? null); pendingOsuProfile = null; await db.insert(playerRating).values({ userId: user.id, elo, initialElo: elo, osuRankAtSeed: osuRank }); log.auth.info({ name: user.name, elo, osuRank }, 'seeded new user rating'); } } } }, plugins: [ genericOAuth({ config: [ { providerId: 'osu', clientId: env.OSU_CLIENT_ID!, clientSecret: env.OSU_CLIENT_SECRET!, authorizationUrl: 'https://osu.ppy.sh/oauth/authorize', tokenUrl: 'https://osu.ppy.sh/oauth/token', userInfoUrl: 'https://osu.ppy.sh/api/v2/me', scopes: ['identify', 'public'], pkce: false, authentication: 'post', // ── FIX: Use ORIGIN env var instead of hardcoded localhost ── redirectURI: `${env.ORIGIN}/api/auth/oauth2/callback/osu`, getToken: async ({ code, redirectURI }) => { const res = await fetch('https://osu.ppy.sh/oauth/token', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: env.OSU_CLIENT_ID!, client_secret: env.OSU_CLIENT_SECRET!, code, grant_type: 'authorization_code', redirect_uri: redirectURI }) }); const data = await res.json(); if (!res.ok) { log.auth.error({ data }, 'osu! token exchange failed'); throw new Error('Token exchange failed'); } return { accessToken: data.access_token, refreshToken: data.refresh_token, accessTokenExpiresAt: new Date(Date.now() + data.expires_in * 1000), scopes: ['identify', 'public'], raw: data }; }, async mapProfileToUser(profile) { pendingOsuProfile = { rank: profile.statistics?.global_rank ?? null }; return { name: profile.username, image: await proxyImage(profile.avatar_url), email: `${profile.id}@osu.local` }; } } ] }), sveltekitCookies(getRequestEvent) // must be last ] });