add basic db schema + auth + oauth with osu
This commit is contained in:
@@ -4,17 +4,65 @@ import { sveltekitCookies } from 'better-auth/svelte-kit';
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { getRequestEvent } from '$app/server';
|
import { getRequestEvent } from '$app/server';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
|
import { genericOAuth } from 'better-auth/plugins';
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
baseURL: env.ORIGIN,
|
baseURL: env.ORIGIN,
|
||||||
secret: env.BETTER_AUTH_SECRET,
|
secret: env.BETTER_AUTH_SECRET,
|
||||||
database: drizzleAdapter(db, { provider: 'pg' }),
|
database: drizzleAdapter(db, { provider: 'pg' }),
|
||||||
emailAndPassword: { enabled: true },
|
emailAndPassword: { enabled: true },
|
||||||
socialProviders: {
|
plugins: [
|
||||||
github: {
|
genericOAuth({
|
||||||
clientId: env.GITHUB_CLIENT_ID,
|
config: [
|
||||||
clientSecret: env.GITHUB_CLIENT_SECRET
|
{
|
||||||
}
|
providerId: 'osu',
|
||||||
},
|
clientId: env.OSU_CLIENT_ID!,
|
||||||
plugins: [sveltekitCookies(getRequestEvent)] // make sure this is the last plugin in the array
|
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',
|
||||||
|
redirectURI: 'http://localhost:5173/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) {
|
||||||
|
console.error('osu! token exchange failed:', data);
|
||||||
|
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
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mapProfileToUser(profile) {
|
||||||
|
return {
|
||||||
|
name: profile.username,
|
||||||
|
image: profile.avatar_url,
|
||||||
|
email: `${profile.id}@osu.local`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,159 @@
|
|||||||
import { pgTable, serial, integer, text } from 'drizzle-orm/pg-core';
|
import {
|
||||||
|
pgTable,
|
||||||
export const task = pgTable('task', {
|
integer,
|
||||||
id: serial('id').primaryKey(),
|
text,
|
||||||
title: text('title').notNull(),
|
uuid,
|
||||||
priority: integer('priority').notNull().default(1)
|
boolean,
|
||||||
});
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
real
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
export * from './auth.schema';
|
export * from './auth.schema';
|
||||||
|
|
||||||
|
// ── Teams ───────────────────────────────────────────────────────────────
|
||||||
|
export const team = pgTable('team', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
isPersonal: boolean('is_personal').default(false).notNull(),
|
||||||
|
ownerId: text('owner_id'),
|
||||||
|
avatarUrl: text('avatar_url'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Team Members ────────────────────────────────────────────────────────
|
||||||
|
export const teamMember = pgTable('team_member', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
teamId: uuid('team_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => team.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
role: text('role').default('member'),
|
||||||
|
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mappools ────────────────────────────────────────────────────────────
|
||||||
|
export const mappool = pgTable('mappool', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
createdBy: text('created_by'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mappool Slots ───────────────────────────────────────────────────────
|
||||||
|
export const mappoolSlot = pgTable('mappool_slot', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
mappoolId: uuid('mappool_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => mappool.id, { onDelete: 'cascade' }),
|
||||||
|
category: text('category').notNull(),
|
||||||
|
orderInCategory: integer('order_in_category').notNull(),
|
||||||
|
beatmapId: text('beatmap_id').notNull(),
|
||||||
|
mods: text('mods').array().default([]).notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Matches ─────────────────────────────────────────────────────────────
|
||||||
|
export const match = pgTable(
|
||||||
|
'match',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name'),
|
||||||
|
state: text('state').default('CREATED').notNull(),
|
||||||
|
config: jsonb('config').notNull(),
|
||||||
|
mappoolId: uuid('mappool_id').references(() => mappool.id),
|
||||||
|
osuLobbyId: integer('osu_lobby_id'),
|
||||||
|
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||||
|
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||||
|
winnerId: uuid('winner_id').references(() => team.id),
|
||||||
|
createdBy: text('created_by'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(t) => [index('idx_match_state').on(t.state)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Match Participants (a team in a match) ──────────────────────────────
|
||||||
|
export const matchParticipant = pgTable(
|
||||||
|
'match_participant',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
matchId: uuid('match_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => match.id, { onDelete: 'cascade' }),
|
||||||
|
teamId: uuid('team_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => team.id),
|
||||||
|
slot: integer('slot').notNull(),
|
||||||
|
score: integer('score').default(0).notNull(),
|
||||||
|
rollValue: integer('roll_value'),
|
||||||
|
pickOrder: integer('pick_order')
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('idx_mp_match_team').on(t.matchId, t.teamId),
|
||||||
|
uniqueIndex('idx_mp_match_slot').on(t.matchId, t.slot),
|
||||||
|
index('idx_mp_match').on(t.matchId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Match Participant Players (a player fielded for this match) ─────────
|
||||||
|
export const matchParticipantPlayer = pgTable(
|
||||||
|
'match_participant_player',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
participantId: uuid('participant_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => matchParticipant.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id').notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('idx_mpp_participant_user').on(t.participantId, t.userId),
|
||||||
|
index('idx_mpp_participant').on(t.participantId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Match Games ─────────────────────────────────────────────────────────
|
||||||
|
export const matchGame = pgTable(
|
||||||
|
'match_game',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
matchId: uuid('match_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => match.id, { onDelete: 'cascade' }),
|
||||||
|
gameNumber: integer('game_number').notNull(),
|
||||||
|
mappoolSlotId: uuid('mappool_slot_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => mappoolSlot.id),
|
||||||
|
pickedByParticipantId: uuid('picked_by_participant_id').references(() => matchParticipant.id),
|
||||||
|
winnerParticipantId: uuid('winner_participant_id').references(() => matchParticipant.id),
|
||||||
|
state: text('state').default('PENDING').notNull(),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||||
|
finishedAt: timestamp('finished_at', { withTimezone: true })
|
||||||
|
},
|
||||||
|
(t) => [index('idx_match_game_match').on(t.matchId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Match Game Scores (per-player per-map) ──────────────────────────────
|
||||||
|
export const matchGameScore = pgTable(
|
||||||
|
'match_game_score',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
matchGameId: uuid('match_game_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => matchGame.id, { onDelete: 'cascade' }),
|
||||||
|
playerId: uuid('player_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => matchParticipantPlayer.id),
|
||||||
|
score: integer('score').default(0).notNull(),
|
||||||
|
accuracy: real('accuracy').default(0).notNull(),
|
||||||
|
maxCombo: integer('max_combo').default(0).notNull(),
|
||||||
|
count300: integer('count_300').default(0).notNull(),
|
||||||
|
count100: integer('count_100').default(0).notNull(),
|
||||||
|
count50: integer('count_50').default(0).notNull(),
|
||||||
|
countMiss: integer('count_miss').default(0).notNull(),
|
||||||
|
mods: text('mods').array().default([]).notNull(),
|
||||||
|
passed: boolean('passed').default(false).notNull()
|
||||||
|
},
|
||||||
|
(t) => [index('idx_mgs_game').on(t.matchGameId)]
|
||||||
|
);
|
||||||
|
|||||||
@@ -60,12 +60,12 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
signInSocial: async (event) => {
|
signInSocial: async (event) => {
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const provider = formData.get('provider')?.toString() ?? 'github';
|
const provider = formData.get('provider')?.toString() ?? 'osu';
|
||||||
const callbackURL = formData.get('callbackURL')?.toString() ?? '/demo/better-auth';
|
const callbackURL = formData.get('callbackURL')?.toString() ?? '/demo/better-auth';
|
||||||
|
|
||||||
const result = await auth.api.signInSocial({
|
const result = await auth.api.signInSocial({
|
||||||
body: {
|
body: {
|
||||||
provider: provider as 'github',
|
provider: provider as 'osu',
|
||||||
callbackURL
|
callbackURL
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,9 +43,9 @@
|
|||||||
|
|
||||||
<hr class="my-4" />
|
<hr class="my-4" />
|
||||||
<form method="post" action="?/signInSocial" use:enhance>
|
<form method="post" action="?/signInSocial" use:enhance>
|
||||||
<input type="hidden" name="provider" value="github" />
|
<input type="hidden" name="provider" value="osu" />
|
||||||
<input type="hidden" name="callbackURL" value="/demo/better-auth" />
|
<input type="hidden" name="callbackURL" value="/demo/better-auth" />
|
||||||
<button class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
|
<button class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
|
||||||
>Sign in with GitHub</button
|
>Sign in with osu!</button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user