From 6b5eadeb523bf13cb64bc4160bed60bec4471097 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Mon, 12 May 2025 16:07:51 +0530 Subject: [PATCH 01/23] intial commit, setup code for auth, in progress [skip ci] --- apps/leaderboard-backend/.env.example | 3 + .../1745907000000_create_all_tables.ts | 12 ++ apps/leaderboard-backend/src/db/types.ts | 53 +++++- apps/leaderboard-backend/src/env.ts | 9 +- .../src/middlewares/isValidSignature.ts | 27 ++- .../src/repositories/AuthRepository.ts | 126 +++++++++++++ .../src/repositories/index.ts | 7 +- .../src/routes/api/authRoutes.ts | 163 +++++++++++++++++ apps/leaderboard-backend/src/server.ts | 4 +- .../validation/auth/authRouteDescriptions.ts | 166 ++++++++++++++++++ .../validation/auth/authRouteValidations.ts | 8 + .../src/validation/auth/authSchemas.ts | 150 ++++++++++++++++ .../src/validation/auth/index.ts | 3 + bun.lock | 10 ++ 14 files changed, 721 insertions(+), 20 deletions(-) create mode 100644 apps/leaderboard-backend/src/repositories/AuthRepository.ts create mode 100644 apps/leaderboard-backend/src/routes/api/authRoutes.ts create mode 100644 apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts create mode 100644 apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts create mode 100644 apps/leaderboard-backend/src/validation/auth/authSchemas.ts create mode 100644 apps/leaderboard-backend/src/validation/auth/index.ts diff --git a/apps/leaderboard-backend/.env.example b/apps/leaderboard-backend/.env.example index 33415c6eb9..81a24308ba 100644 --- a/apps/leaderboard-backend/.env.example +++ b/apps/leaderboard-backend/.env.example @@ -1,3 +1,6 @@ PORT=4545 LEADERBOARD_DB_URL="leaderboard-backend.sqlite" DATABASE_MIGRATE_DIR="migrations" +SESSION_EXPIRY=1h +SIGN_MESSAGE_PREFIX="HappyChain Authentication" +RPC_URL="http://localhost:8545" diff --git a/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts b/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts index 064ef1070a..4a9d9cc71c 100644 --- a/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts +++ b/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts @@ -76,6 +76,17 @@ export async function up(db: Kysely): Promise { .addForeignKeyConstraint("user_game_scores_game_id_fk", ["game_id"], "games", ["id"]) .addUniqueConstraint("user_game_scores_unique", ["user_id", "game_id"]) .execute() + + // Auth sessions table + await db.schema + .createTable("auth_sessions") + .addColumn("id", "uuid", (col) => col.primaryKey().notNull()) + .addColumn("user_id", "integer", (col) => col.notNull()) + .addColumn("primary_wallet", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()) + .addColumn("last_used_at", "text", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()) + .addForeignKeyConstraint("auth_sessions_user_id_fk", ["user_id"], "users", ["id"]) + .execute() } // biome-ignore lint/suspicious/noExplicitAny: `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. @@ -86,4 +97,5 @@ export async function down(db: Kysely): Promise { await db.schema.dropTable("guilds").execute() await db.schema.dropTable("user_wallets").execute() await db.schema.dropTable("users").execute() + await db.schema.dropTable("auth_sessions").execute() } diff --git a/apps/leaderboard-backend/src/db/types.ts b/apps/leaderboard-backend/src/db/types.ts index ab74e4f5c5..8058d050c3 100644 --- a/apps/leaderboard-backend/src/db/types.ts +++ b/apps/leaderboard-backend/src/db/types.ts @@ -1,4 +1,4 @@ -import type { Address } from "@happy.tech/common" +import type { Address, UUID } from "@happy.tech/common" import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely" // --- Branded ID types for strong nominal typing --- @@ -7,6 +7,7 @@ export type GuildTableId = number & { _brand: "guilds_id" } export type GameTableId = number & { _brand: "games_id" } export type ScoreTableId = number & { _brand: "scores_id" } export type GuildMemberTableId = number & { _brand: "guild_members_id" } +export type AuthSessionTableId = UUID // Main Kysely database schema definition export interface Database { @@ -16,10 +17,11 @@ export interface Database { guild_members: GuildMemberTable games: GameTable user_game_scores: UserGameScoreTable + auth_sessions: AuthSessionTable } // Registered users -export interface UserTable { +interface UserTable { id: Generated primary_wallet: Address // Primary wallet for the user username: string @@ -28,7 +30,7 @@ export interface UserTable { } // User wallet addresses (allows multiple wallets per user) -export interface UserWalletTable { +interface UserWalletTable { id: Generated user_id: UserTableId // FK to users wallet_address: Address @@ -37,7 +39,7 @@ export interface UserWalletTable { } // Guilds (groups of users) -export interface GuildTable { +interface GuildTable { id: Generated name: string icon_url: string | null @@ -47,7 +49,7 @@ export interface GuildTable { } // Guild membership JOIN table (users in guilds with role) -export interface GuildMemberTable { +interface GuildMemberTable { id: Generated guild_id: GuildTableId // FK to guilds user_id: UserTableId // FK to users @@ -56,7 +58,7 @@ export interface GuildMemberTable { } // Games available on the platform -export interface GameTable { +interface GameTable { id: Generated name: string icon_url: string | null @@ -67,7 +69,7 @@ export interface GameTable { } // User scores in games -export interface UserGameScoreTable { +interface UserGameScoreTable { id: Generated user_id: UserTableId // FK to users game_id: GameTableId // FK to games @@ -77,6 +79,15 @@ export interface UserGameScoreTable { updated_at: ColumnType } +// Auth sessions +interface AuthSessionTable { + id: AuthSessionTableId + user_id: UserTableId // FK to users + primary_wallet: Address + created_at: ColumnType + last_used_at: ColumnType +} + // Kysely helper types export type User = Selectable export type NewUser = Insertable @@ -137,3 +148,31 @@ export interface GameGuildLeaderboardEntry { total_score: number member_count: number } + +export type AuthSession = Selectable +export type NewAuthSession = Insertable +export type UpdateAuthSession = Updateable + +// Auth types for API +export interface AuthChallengeRequest { + primary_wallet: Address +} + +export interface AuthChallengeResponse { + message: string + primary_wallet: Address +} + +export interface SignInRequest { + primary_wallet: Address + signature: string +} + +export interface AuthResponse { + session_id: AuthSessionTableId + user: { + id: UserTableId + username: string + primary_wallet: Address + } +} diff --git a/apps/leaderboard-backend/src/env.ts b/apps/leaderboard-backend/src/env.ts index a31f3b06ad..8cadf61005 100644 --- a/apps/leaderboard-backend/src/env.ts +++ b/apps/leaderboard-backend/src/env.ts @@ -1,9 +1,12 @@ import { z } from "zod" const envSchema = z.object({ - LEADERBOARD_DB_URL: z.string().trim().optional(), - PORT: z.string().trim().optional(), - DATABASE_MIGRATE_DIR: z.string().trim().optional(), + LEADERBOARD_DB_URL: z.string().trim(), + PORT: z.string().trim().default("4545"), + DATABASE_MIGRATE_DIR: z.string().trim().default("migrations"), + SESSION_EXPIRY: z.string().trim().default("1h"), + SIGN_MESSAGE_PREFIX: z.string().trim().default("HappyChain Authentication"), + RPC_URL: z.string().trim().default("http://localhost:8545"), }) const parsedEnv = envSchema.safeParse(process.env) diff --git a/apps/leaderboard-backend/src/middlewares/isValidSignature.ts b/apps/leaderboard-backend/src/middlewares/isValidSignature.ts index 1745787dbe..a59ffc078a 100644 --- a/apps/leaderboard-backend/src/middlewares/isValidSignature.ts +++ b/apps/leaderboard-backend/src/middlewares/isValidSignature.ts @@ -1,14 +1,27 @@ -// TODO: Reserved for another PR, this is just stub, ignore this file for now - import { createMiddleware } from "hono/factory" +import type { AuthSessionTableId } from "../db/types" const isValidSignature = createMiddleware(async (c, next) => { - console.log(c.req) - console.log("TODO: import wagmi, make call to SCA.isValidSignature()") + const authHeader = c.req.header("Authorization") + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Authentication required", ok: false }, 401) + } + + const sessionId = authHeader.substring(7) as AuthSessionTableId + + const { authRepo } = c.get("repos") + const session = await authRepo.verifySession(sessionId) + + if (!session) { + return c.json({ error: "Invalid or expired session", ok: false }, 401) + } + + c.set("userId", session.user_id) + c.set("primaryWallet", session.primary_wallet) + c.set("sessionId", session.id) + await next() - // !Optional, modify response after it comes back from the handler - // c.res = undefined - // c.res = new Response('New Response') }) export { isValidSignature } diff --git a/apps/leaderboard-backend/src/repositories/AuthRepository.ts b/apps/leaderboard-backend/src/repositories/AuthRepository.ts new file mode 100644 index 0000000000..767ef5ca46 --- /dev/null +++ b/apps/leaderboard-backend/src/repositories/AuthRepository.ts @@ -0,0 +1,126 @@ +import { createUUID } from "@happy.tech/common" +import type { Address, Hex } from "@happy.tech/common" +import { abis, deployment } from "@happy.tech/contracts/boop/anvil" + +import type { Kysely } from "kysely" +import { http, createPublicClient, hashMessage } from "viem" +import { localhost } from "viem/chains" + +import type { AuthSession, AuthSessionTableId, Database, NewAuthSession, UserTableId } from "../db/types" +import { env } from "../env" + +const EIP1271_MAGIC_VALUE = "0x1626ba7e" + +export class AuthRepository { + private publicClient: ReturnType + + constructor(private db: Kysely) { + this.publicClient = createPublicClient({ + chain: localhost, + transport: http(env.RPC_URL), + }) + } + + async generateChallenge(primary_wallet: Address): Promise { + const nonce = await this.publicClient.readContract({ + address: deployment.EntryPoint, + abi: abis.EntryPoint, + functionName: "nonceValues", + args: [primary_wallet, 0n], + }) + + const timestamp = Date.now() + + return `${nonce}${timestamp}` + } + + async verifySignature(primary_wallet: Address, message: Hex, signature: Hex): Promise { + try { + const messageHash = await hashMessage({ raw: message }) + const result = await this.publicClient.readContract({ + address: primary_wallet, + abi: abis.HappyAccountImpl, + functionName: "isValidSignature", + args: [messageHash, signature], + }) + + return result === EIP1271_MAGIC_VALUE + } catch (error) { + console.error("Error verifying signature:", error) + return false + } + } + + async createSession(userId: UserTableId, walletAddress: Address): Promise { + try { + const now = new Date() + + const newSession: NewAuthSession = { + id: createUUID(), + user_id: userId, + primary_wallet: walletAddress, + created_at: now.toISOString(), + last_used_at: now.toISOString(), + } + + this.db.insertInto("auth_sessions").values(newSession).executeTakeFirst() + + return { + ...newSession, + created_at: now, + last_used_at: now, + } + } catch (error) { + console.error("Error creating session:", error) + return undefined + } + } + + async verifySession(sessionId: AuthSessionTableId): Promise { + try { + const session = await this.db + .selectFrom("auth_sessions") + .where("id", "=", sessionId) + .selectAll() + .executeTakeFirst() + + if (!session) { + return undefined + } + + const now = new Date().toISOString() + await this.db.updateTable("auth_sessions").set({ last_used_at: now }).where("id", "=", sessionId).execute() + + return session + } catch (error) { + console.error("Error verifying session:", error) + return undefined + } + } + + async getUserSessions(userId: UserTableId): Promise { + return this.db.selectFrom("auth_sessions").where("user_id", "=", userId).selectAll().execute() + } + + async deleteSession(sessionId: AuthSessionTableId): Promise { + try { + const res = await this.db.deleteFrom("auth_sessions").where("id", "=", sessionId).executeTakeFirstOrThrow() + + return res.numDeletedRows > 0n + } catch (error) { + console.error("Error deleting session:", error) + return false + } + } + + async deleteAllUserSessions(userId: UserTableId): Promise { + try { + const result = await this.db.deleteFrom("auth_sessions").where("user_id", "=", userId).execute() + + return result.length > 0 + } catch (error) { + console.error("Error deleting user sessions:", error) + return false + } + } +} diff --git a/apps/leaderboard-backend/src/repositories/index.ts b/apps/leaderboard-backend/src/repositories/index.ts index 1dca81dfed..281a578754 100644 --- a/apps/leaderboard-backend/src/repositories/index.ts +++ b/apps/leaderboard-backend/src/repositories/index.ts @@ -1,21 +1,24 @@ import { db } from "../db/driver" +import { AuthRepository } from "./AuthRepository" import { GameRepository, GameScoreRepository } from "./GamesRepository" import { GuildRepository } from "./GuildsRepository" import { LeaderBoardRepository } from "./LeaderBoardRepository" import { UserRepository } from "./UsersRepository" export type Repositories = { + authRepo: AuthRepository userRepo: UserRepository guildRepo: GuildRepository - leaderboardRepo: LeaderBoardRepository gameRepo: GameRepository gameScoreRepo: GameScoreRepository + leaderboardRepo: LeaderBoardRepository } export const repositories: Repositories = { + authRepo: new AuthRepository(db), userRepo: new UserRepository(db), guildRepo: new GuildRepository(db), - leaderboardRepo: new LeaderBoardRepository(db), gameRepo: new GameRepository(db), gameScoreRepo: new GameScoreRepository(db), + leaderboardRepo: new LeaderBoardRepository(db), } diff --git a/apps/leaderboard-backend/src/routes/api/authRoutes.ts b/apps/leaderboard-backend/src/routes/api/authRoutes.ts new file mode 100644 index 0000000000..d3cf21708e --- /dev/null +++ b/apps/leaderboard-backend/src/routes/api/authRoutes.ts @@ -0,0 +1,163 @@ +import { Hono } from "hono" +import type { AuthSessionTableId } from "../../db/types" +import { + AuthChallengeDescription, + AuthChallengeValidation, + AuthLogoutDescription, + AuthMeDescription, + AuthSessionsDescription, + AuthVerifyDescription, + AuthVerifyValidation, + SessionIdValidation, +} from "../../validation/auth" + +export default new Hono() + /** + * Request a challenge to sign + * POST /auth/challenge + */ + .post("/challenge", AuthChallengeDescription, AuthChallengeValidation, async (c) => { + const { primary_wallet } = c.req.valid("json") + const { authRepo } = c.get("repos") + + // Generate challenge message + const message = authRepo.generateChallenge(primary_wallet) + + // Return the challenge message for the frontend to request signature + return c.json({ + message, + primary_wallet, + }) + }) + + /** + * Verify signature and create session + * POST /auth/verify + */ + .post("/verify", AuthVerifyDescription, AuthVerifyValidation, async (c) => { + const { primary_wallet, message, signature } = c.req.valid("json") + const { authRepo, userRepo } = c.get("repos") + + // Verify signature + const isValid = await authRepo.verifySignature( + primary_wallet as `0x${string}`, + message as `0x${string}`, + signature as `0x${string}`, + ) + + if (!isValid) { + return c.json({ error: "Invalid signature", ok: false }, 401) + } + + // Find or create user + const user = await userRepo.findByWalletAddress(primary_wallet, true) + + if (!user) { + return c.json({ error: "User not found", ok: false }, 404) + } + + // Create a new session + const session = await authRepo.createSession(user.id, primary_wallet) + + if (!session) { + return c.json({ error: "Failed to create session", ok: false }, 500) + } + + // Return success with session ID and user info + return c.json({ + ok: true, + session_id: session.id, + user: { + id: user.id, + username: user.username, + primary_wallet: user.primary_wallet, + }, + }) + }) + + /** + * Get user info from session + * GET /auth/me + */ + .get("/me", AuthMeDescription, async (c) => { + const authHeader = c.req.header("Authorization") + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Authentication required", ok: false }, 401) + } + + const sessionId = authHeader.substring(7) as AuthSessionTableId + + const { authRepo, userRepo } = c.get("repos") + const session = await authRepo.verifySession(sessionId) + + if (!session) { + return c.json({ error: "Invalid or expired session", ok: false }, 401) + } + + const user = await userRepo.findById(session.user_id) + + if (!user) { + return c.json({ error: "User not found", ok: false }, 404) + } + + return c.json({ + ok: true, + session_id: sessionId, + user: { + id: user.id, + username: user.username, + primary_wallet: user.primary_wallet, + }, + }) + }) + + /** + * Logout (delete session) + * POST /auth/logout + */ + .post("/logout", AuthLogoutDescription, SessionIdValidation, async (c) => { + const { session_id } = c.req.valid("json") + const { authRepo } = c.get("repos") + + const success = await authRepo.deleteSession(session_id as AuthSessionTableId) + + return c.json({ + ok: success, + message: success ? "Logged out successfully" : "Session not found", + }) + }) + + /** + * List all active sessions for a user + * GET /auth/sessions + */ + .get("/sessions", AuthSessionsDescription, async (c) => { + const authHeader = c.req.header("Authorization") + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Authentication required", ok: false }, 401) + } + + const sessionId = authHeader.substring(7) as AuthSessionTableId + + const { authRepo } = c.get("repos") + const session = await authRepo.verifySession(sessionId) + + if (!session) { + return c.json({ error: "Invalid or expired session", ok: false }, 401) + } + + const sessions = await authRepo.getUserSessions(session.user_id) + + return c.json({ + ok: true, + sessions: sessions.map((s) => ({ + id: s.id, + primary_wallet: s.primary_wallet, + created_at: s.created_at, + last_used_at: s.last_used_at, + is_current: s.id === sessionId, + })), + }) + }) diff --git a/apps/leaderboard-backend/src/server.ts b/apps/leaderboard-backend/src/server.ts index d96185801b..65828667b0 100644 --- a/apps/leaderboard-backend/src/server.ts +++ b/apps/leaderboard-backend/src/server.ts @@ -12,6 +12,7 @@ import { timing as timingMiddleware } from "hono/timing" import { ZodError } from "zod" import { env } from "./env" import { type Repositories, repositories } from "./repositories" +import authApi from "./routes/api/authRoutes" import gamesApi from "./routes/api/gamesRoutes" import guildsApi from "./routes/api/guildsRoutes" import leaderboardApi from "./routes/api/leaderboardRoutes" @@ -45,9 +46,10 @@ const app = new Hono()

Visit the Happy Docs for more information, or the Open API Spec

`, ), ) + .route("/auth", authApi) .route("/users", usersApi) - .route("/guilds", guildsApi) .route("/games", gamesApi) + .route("/guilds", guildsApi) .route("/leaderboards", leaderboardApi) // Serve OpenAPI JSON at /docs/openapi.json diff --git a/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts new file mode 100644 index 0000000000..287e74ed25 --- /dev/null +++ b/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts @@ -0,0 +1,166 @@ +import { describeRoute } from "hono-openapi" +import { + AuthChallengeResponseSchemaObj, + AuthResponseSchemaObj, + ErrorResponseSchemaObj, + SessionListResponseSchemaObj, +} from "./authSchemas" + +// ==================================================================================================== +// Authentication Routes + +export const AuthChallengeDescription = describeRoute({ + validateResponse: false, + description: "Request a challenge message to sign with a wallet.", + requestBody: { + description: "Authentication challenge request body.", + required: true, + content: { + "application/json": { + schema: {}, + }, + }, + }, + responses: { + 200: { + description: "Challenge message generated successfully.", + content: { + "application/json": { + schema: AuthChallengeResponseSchemaObj, + }, + }, + }, + 400: { + description: "Invalid request data.", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, + }, +}) + +export const AuthVerifyDescription = describeRoute({ + validateResponse: false, + description: "Verify a signature and create a new session.", + requestBody: { + description: "Authentication verification request body.", + required: true, + content: { + "application/json": { + schema: {}, + }, + }, + }, + responses: { + 200: { + description: "Signature verified and session created successfully.", + content: { + "application/json": { + schema: AuthResponseSchemaObj, + }, + }, + }, + 401: { + description: "Invalid signature.", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, + 400: { + description: "Invalid request data.", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, + }, +}) + +export const AuthMeDescription = describeRoute({ + validateResponse: false, + description: "Get current user information from session.", + responses: { + 200: { + description: "User information retrieved successfully.", + content: { + "application/json": { + schema: AuthResponseSchemaObj, + }, + }, + }, + 401: { + description: "Not authenticated or invalid session.", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, + }, +}) + +export const AuthLogoutDescription = describeRoute({ + validateResponse: false, + description: "Logout and invalidate the current session.", + requestBody: { + description: "Session ID to invalidate.", + required: true, + content: { + "application/json": { + schema: {}, + }, + }, + }, + responses: { + 200: { + description: "Session invalidated successfully.", + content: { + "application/json": { + schema: { + type: "object", + properties: { + ok: { type: "boolean" }, + message: { type: "string" }, + }, + }, + }, + }, + }, + 400: { + description: "Invalid session ID.", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, + }, +}) + +export const AuthSessionsDescription = describeRoute({ + validateResponse: false, + description: "List all active sessions for the current user.", + responses: { + 200: { + description: "Sessions retrieved successfully.", + content: { + "application/json": { + schema: SessionListResponseSchemaObj, + }, + }, + }, + 401: { + description: "Not authenticated or invalid session.", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, + }, +}) diff --git a/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts b/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts new file mode 100644 index 0000000000..9f0034f243 --- /dev/null +++ b/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts @@ -0,0 +1,8 @@ +import { validator as zValidator } from "hono-openapi/zod" +import { AuthChallengeRequestSchema, AuthVerifyRequestSchema, SessionIdRequestSchema } from "./authSchemas" + +export const AuthChallengeValidation = zValidator("json", AuthChallengeRequestSchema) + +export const AuthVerifyValidation = zValidator("json", AuthVerifyRequestSchema) + +export const SessionIdValidation = zValidator("json", SessionIdRequestSchema) diff --git a/apps/leaderboard-backend/src/validation/auth/authSchemas.ts b/apps/leaderboard-backend/src/validation/auth/authSchemas.ts new file mode 100644 index 0000000000..f6ef637256 --- /dev/null +++ b/apps/leaderboard-backend/src/validation/auth/authSchemas.ts @@ -0,0 +1,150 @@ +import { z } from "@hono/zod-openapi" +import { resolver } from "hono-openapi/zod" +import { isHex } from "viem" + +// ==================================================================================================== +// Response Schemas + +// Auth session schema for internal use +export const AuthSessionSchema = z + .object({ + id: z.string().uuid(), + user_id: z.number().int(), + primary_wallet: z.string().refine(isHex), + created_at: z.string(), + last_used_at: z.string(), + }) + .strict() + .openapi({ + example: { + id: "123e4567-e89b-12d3-a456-426614174000", + user_id: 1, + primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", + created_at: "2023-01-01T00:00:00.000Z", + last_used_at: "2023-01-01T00:00:00.000Z", + }, + }) + +const UserInfoSchema = z + .object({ + id: z.number().int(), + username: z.string(), + primary_wallet: z.string().refine(isHex), + }) + .strict() + .openapi({ + example: { + id: 1, + username: "username", + primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", + }, + }) + +const AuthResponseSchema = z + .object({ + ok: z.boolean(), + session_id: z.string().uuid(), + user: UserInfoSchema, + }) + .strict() + .openapi({ + example: { + ok: true, + session_id: "123e4567-e89b-12d3-a456-426614174000", + user: { + id: 1, + username: "username", + primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", + }, + }, + }) + +const AuthChallengeResponseSchema = z + .object({ + message: z.string(), + primary_wallet: z.string().refine(isHex), + }) + .strict() + .openapi({ + example: { + message: "HappyChain Authentication: 1a2b3c4d5e6f7890 (1620000000000)", + primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", + }, + }) + +const SessionListResponseSchema = z + .object({ + ok: z.boolean(), + sessions: z.array( + z.object({ + id: z.string().uuid(), + primary_wallet: z.string().refine(isHex), + created_at: z.string(), + last_used_at: z.string(), + is_current: z.boolean(), + }), + ), + }) + .strict() + .openapi({ + example: { + ok: true, + sessions: [ + { + id: "123e4567-e89b-12d3-a456-426614174000", + primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", + created_at: "2023-01-01T00:00:00.000Z", + last_used_at: "2023-01-01T00:00:00.000Z", + is_current: true, + }, + ], + }, + }) + +export const AuthResponseSchemaObj = resolver(AuthResponseSchema) +export const AuthChallengeResponseSchemaObj = resolver(AuthChallengeResponseSchema) +export const SessionListResponseSchemaObj = resolver(SessionListResponseSchema) + +// Generic error schema +export const ErrorResponseSchemaObj = resolver(z.object({ ok: z.literal(false), error: z.string() })) + +// ==================================================================================================== +// Request Body Schemas + +export const AuthChallengeRequestSchema = z + .object({ + primary_wallet: z.string().refine(isHex), + }) + .strict() + .openapi({ + example: { + primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", + }, + }) + +export const AuthVerifyRequestSchema = z + .object({ + primary_wallet: z.string().refine(isHex), + message: z.string(), + signature: z.string().startsWith("0x", "Signature must start with 0x"), + }) + .strict() + .openapi({ + example: { + primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", + message: "HappyChain Authentication: 1a2b3c4d5e6f7890 (1620000000000)", + signature: + "0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }, + }) + +export const SessionIdRequestSchema = z + .object({ + session_id: z.string().uuid("Invalid session ID"), + }) + .strict() + .openapi({ + example: { + session_id: "123e4567-e89b-12d3-a456-426614174000", + }, + }) diff --git a/apps/leaderboard-backend/src/validation/auth/index.ts b/apps/leaderboard-backend/src/validation/auth/index.ts new file mode 100644 index 0000000000..a8013292d4 --- /dev/null +++ b/apps/leaderboard-backend/src/validation/auth/index.ts @@ -0,0 +1,3 @@ +export * from "./authRouteDescriptions" +export * from "./authRouteValidations" +export * from "./authSchemas" diff --git a/bun.lock b/bun.lock index 689e4f2c6c..836fb04cfa 100644 --- a/bun.lock +++ b/bun.lock @@ -5884,6 +5884,16 @@ "@ethereumjs/util/ethereum-cryptography/@scure/bip39/@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="], + "@happy.tech/leaderboard-backend/viem/ox/@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.0", "", {}, "sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg=="], + + "@happy.tech/leaderboard-backend/viem/ox/@noble/curves": ["@noble/curves@1.9.0", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg=="], + + "@happy.tech/leaderboard-backend/viem/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@happy.tech/leaderboard-backend/viem/ox/@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + + "@happy.tech/leaderboard-backend/viem/ox/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + "@happy.tech/txm/vitest/@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "@happy.tech/txm/vitest/vite-node/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], From 51b88794b3f1a73b0226e0de3704c43e80d81196 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Mon, 12 May 2025 18:22:32 +0530 Subject: [PATCH 02/23] feat: implement role-based auth system with middleware and signature verification [skip ci] --- apps/leaderboard-backend/src/auth/index.ts | 6 + .../src/auth/middlewares/gameAuth.ts | 20 ++ .../src/auth/middlewares/guildAuth.ts | 34 +++ .../src/auth/middlewares/requireAuth.ts | 41 +++ .../src/auth/middlewares/userAuth.ts | 19 ++ apps/leaderboard-backend/src/auth/roles.ts | 48 +++ .../src/auth/verifySignature.ts | 39 +++ apps/leaderboard-backend/src/env.ts | 2 +- .../src/middlewares/isValidSignature.ts | 27 -- .../src/repositories/AuthRepository.ts | 23 +- .../src/routes/api/authRoutes.ts | 63 ++-- .../src/routes/api/gamesRoutes.ts | 122 ++++---- .../src/routes/api/guildsRoutes.ts | 154 ++++++---- .../src/routes/api/usersRoutes.ts | 276 +++++++++++------- apps/leaderboard-backend/src/server.ts | 14 + 15 files changed, 579 insertions(+), 309 deletions(-) create mode 100644 apps/leaderboard-backend/src/auth/index.ts create mode 100644 apps/leaderboard-backend/src/auth/middlewares/gameAuth.ts create mode 100644 apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts create mode 100644 apps/leaderboard-backend/src/auth/middlewares/requireAuth.ts create mode 100644 apps/leaderboard-backend/src/auth/middlewares/userAuth.ts create mode 100644 apps/leaderboard-backend/src/auth/roles.ts create mode 100644 apps/leaderboard-backend/src/auth/verifySignature.ts delete mode 100644 apps/leaderboard-backend/src/middlewares/isValidSignature.ts diff --git a/apps/leaderboard-backend/src/auth/index.ts b/apps/leaderboard-backend/src/auth/index.ts new file mode 100644 index 0000000000..0c8f186eb7 --- /dev/null +++ b/apps/leaderboard-backend/src/auth/index.ts @@ -0,0 +1,6 @@ +export * from "./roles" +export * from "./verifySignature" +export * from "./middlewares/userAuth" +export * from "./middlewares/gameAuth" +export * from "./middlewares/guildAuth" +export * from "./middlewares/requireAuth" diff --git a/apps/leaderboard-backend/src/auth/middlewares/gameAuth.ts b/apps/leaderboard-backend/src/auth/middlewares/gameAuth.ts new file mode 100644 index 0000000000..d1d4f8801e --- /dev/null +++ b/apps/leaderboard-backend/src/auth/middlewares/gameAuth.ts @@ -0,0 +1,20 @@ +import { createMiddleware } from "hono/factory" +import type { GameTableId, UserTableId } from "../../db/types" + +export const requireGameOwnership = createMiddleware(async (c, next) => { + const userId = c.get("userId") as UserTableId + const gameId = c.req.param("id") + const { gameRepo } = c.get("repos") + + const game = await gameRepo.findById(Number(gameId) as GameTableId) + + if (!game) { + return c.json({ error: "Game not found", ok: false }, 404) + } + + if (game.admin_id !== userId) { + return c.json({ error: "Only the game creator can perform this action", ok: false }, 403) + } + + await next() +}) diff --git a/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts b/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts new file mode 100644 index 0000000000..bd5eeb2fed --- /dev/null +++ b/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts @@ -0,0 +1,34 @@ +import { createMiddleware } from "hono/factory" +import type { GuildTableId, UserTableId } from "../../db/types" +import type { GuildRole } from "../roles" + +export const requireGuildRole = (role: keyof typeof GuildRole) => { + return createMiddleware(async (c, next) => { + const userId = c.get("userId") as UserTableId + const guildId = c.req.param("id") + const { guildRepo } = c.get("repos") + + const guild = await guildRepo.findById(Number(guildId) as GuildTableId) + if (!guild) { + return c.json({ error: "Guild not found", ok: false }, 404) + } + + if (role === "CREATOR" && guild.creator_id !== userId) { + return c.json({ error: "Only the guild creator can perform this action", ok: false }, 403) + } + + if (role === "MEMBER" || role === "ADMIN") { + const member = await guildRepo.findGuildMember(Number(guildId) as GuildTableId, userId) + + if (!member) { + return c.json({ error: "You are not a member of this guild", ok: false }, 403) + } + + if (role === "ADMIN" && !member.is_admin) { + return c.json({ error: "Admin privileges required", ok: false }, 403) + } + } + + await next() + }) +} diff --git a/apps/leaderboard-backend/src/auth/middlewares/requireAuth.ts b/apps/leaderboard-backend/src/auth/middlewares/requireAuth.ts new file mode 100644 index 0000000000..a3ff917b9e --- /dev/null +++ b/apps/leaderboard-backend/src/auth/middlewares/requireAuth.ts @@ -0,0 +1,41 @@ +import type { Address } from "@happy.tech/common" +import type { MiddlewareHandler } from "hono" +import { createMiddleware } from "hono/factory" +import type { AuthSessionTableId, UserTableId } from "../../db/types" + +// Define the context variables we'll set in the middleware +declare module "hono" { + interface ContextVariableMap { + userId: UserTableId + primaryWallet: Address + sessionId: AuthSessionTableId + } +} + +/** + * Middleware that verifies if a user is authenticated via session + * Requires Authorization header with Bearer token containing the session ID + */ +export const requireAuth: MiddlewareHandler = createMiddleware(async (c, next) => { + const authHeader = c.req.header("Authorization") + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Authentication required", ok: false }, 401) + } + + const sessionId = authHeader.substring(7) as AuthSessionTableId + + const { authRepo } = c.get("repos") + const session = await authRepo.verifySession(sessionId) + + if (!session) { + return c.json({ error: "Invalid or expired session", ok: false }, 401) + } + + // Set user context for downstream middleware and handlers + c.set("userId", session.user_id) + c.set("primaryWallet", session.primary_wallet) + c.set("sessionId", session.id) + + await next() +}) diff --git a/apps/leaderboard-backend/src/auth/middlewares/userAuth.ts b/apps/leaderboard-backend/src/auth/middlewares/userAuth.ts new file mode 100644 index 0000000000..97e8191f0f --- /dev/null +++ b/apps/leaderboard-backend/src/auth/middlewares/userAuth.ts @@ -0,0 +1,19 @@ +import { createMiddleware } from "hono/factory" +import type { UserTableId } from "../../db/types" + +export const requireOwnership = (paramName: string, idType: "id" | "primary_wallet") => { + return createMiddleware(async (c, next) => { + const userId = c.get("userId") as UserTableId + const resourceId = c.req.param(paramName) + + if (idType === "id" && userId !== (Number(resourceId) as UserTableId)) { + return c.json({ error: "You can only access your own resources", ok: false }, 403) + } + + if (idType === "primary_wallet" && c.get("primaryWallet") !== resourceId) { + return c.json({ error: "You can only access your own resources", ok: false }, 403) + } + + await next() + }) +} diff --git a/apps/leaderboard-backend/src/auth/roles.ts b/apps/leaderboard-backend/src/auth/roles.ts new file mode 100644 index 0000000000..aabf21e482 --- /dev/null +++ b/apps/leaderboard-backend/src/auth/roles.ts @@ -0,0 +1,48 @@ +export enum UserRole { + AUTHENTICATED = "authenticated", // Any authenticated user + SELF = "self", // The user themselves (for self-management) +} + +export enum GuildRole { + MEMBER = "member", // Regular guild member + ADMIN = "admin", // Guild admin (can manage members) + CREATOR = "creator", // Guild creator (can delete guild) +} + +export enum GameRole { + PLAYER = "player", // Regular player (can submit scores) + CREATOR = "creator", // Game creator (can manage game) +} + +export const Permissions = { + User: { + READ: [UserRole.AUTHENTICATED, UserRole.SELF], // Anyone can read user profiles + CREATE: [UserRole.AUTHENTICATED], // Any authenticated user can create a profile + UPDATE: [UserRole.SELF], // Only the user can update their profile + DELETE: [UserRole.SELF], // Only the user can delete their profile + MANAGE_WALLETS: [UserRole.SELF], // Only the user can manage their wallets + }, + + Guild: { + READ: [UserRole.AUTHENTICATED], // Anyone can read guild info + CREATE: [UserRole.AUTHENTICATED], // Any authenticated user can create a guild + UPDATE: [GuildRole.ADMIN, GuildRole.CREATOR], // Admins and creators can update guild + DELETE: [GuildRole.CREATOR], // Only creator can delete guild + ADD_MEMBER: [GuildRole.ADMIN, GuildRole.CREATOR], // Admins and creators can add members + REMOVE_MEMBER: [GuildRole.ADMIN, GuildRole.CREATOR], // Admins and creators can remove members + PROMOTE_MEMBER: [GuildRole.CREATOR], // Only creator can promote members to admin + }, + + Game: { + READ: [UserRole.AUTHENTICATED], // Anyone can read game info + CREATE: [UserRole.AUTHENTICATED], // Any authenticated user can create a game + UPDATE: [GameRole.CREATOR], // Only creator can update game + DELETE: [GameRole.CREATOR], // Only creator can delete game + SUBMIT_SCORE: [UserRole.AUTHENTICATED], // Any authenticated user can submit scores + MANAGE_SCORES: [GameRole.CREATOR], // Only creator can manage/delete scores + }, +} + +export type RoleType = UserRole | GuildRole | GameRole +export type ResourceType = "user" | "guild" | "game" | "score" +export type ActionType = "read" | "create" | "update" | "delete" | "manage" diff --git a/apps/leaderboard-backend/src/auth/verifySignature.ts b/apps/leaderboard-backend/src/auth/verifySignature.ts new file mode 100644 index 0000000000..b70ba7dcd0 --- /dev/null +++ b/apps/leaderboard-backend/src/auth/verifySignature.ts @@ -0,0 +1,39 @@ +import type { Address, Hex } from "@happy.tech/common" +import { abis } from "@happy.tech/contracts/boop/anvil" +import { http, createPublicClient, hashMessage } from "viem" +import { localhost } from "viem/chains" +import { env } from "../env" + +// ERC-1271 magic value constant +const EIP1271_MAGIC_VALUE = "0x1626ba7e" + +/** + * Verifies a signature against a wallet address using ERC-1271 standard + * Makes an RPC call to the smart contract account for signature verification + * + * @param walletAddress - The wallet address to verify against + * @param message - The message that was signed + * @param signature - The signature to verify + * @returns Promise - Whether the signature is valid + */ +export async function verifySignature(walletAddress: Address, message: Hex, signature: Hex): Promise { + try { + const publicClient = createPublicClient({ + chain: localhost, + transport: http(env.RPC_URL), + }) + + const messageHash = hashMessage({ raw: message }) + const result = await publicClient.readContract({ + address: walletAddress, + abi: abis.HappyAccountImpl, + functionName: "isValidSignature", + args: [messageHash, signature], + }) + + return result === EIP1271_MAGIC_VALUE + } catch (error) { + console.error("Error verifying signature:", error) + return false + } +} diff --git a/apps/leaderboard-backend/src/env.ts b/apps/leaderboard-backend/src/env.ts index 8cadf61005..3d600e6fbb 100644 --- a/apps/leaderboard-backend/src/env.ts +++ b/apps/leaderboard-backend/src/env.ts @@ -4,7 +4,7 @@ const envSchema = z.object({ LEADERBOARD_DB_URL: z.string().trim(), PORT: z.string().trim().default("4545"), DATABASE_MIGRATE_DIR: z.string().trim().default("migrations"), - SESSION_EXPIRY: z.string().trim().default("1h"), + SESSION_EXPIRY: z.string().trim().default("1d"), SIGN_MESSAGE_PREFIX: z.string().trim().default("HappyChain Authentication"), RPC_URL: z.string().trim().default("http://localhost:8545"), }) diff --git a/apps/leaderboard-backend/src/middlewares/isValidSignature.ts b/apps/leaderboard-backend/src/middlewares/isValidSignature.ts deleted file mode 100644 index a59ffc078a..0000000000 --- a/apps/leaderboard-backend/src/middlewares/isValidSignature.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createMiddleware } from "hono/factory" -import type { AuthSessionTableId } from "../db/types" - -const isValidSignature = createMiddleware(async (c, next) => { - const authHeader = c.req.header("Authorization") - - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return c.json({ error: "Authentication required", ok: false }, 401) - } - - const sessionId = authHeader.substring(7) as AuthSessionTableId - - const { authRepo } = c.get("repos") - const session = await authRepo.verifySession(sessionId) - - if (!session) { - return c.json({ error: "Invalid or expired session", ok: false }, 401) - } - - c.set("userId", session.user_id) - c.set("primaryWallet", session.primary_wallet) - c.set("sessionId", session.id) - - await next() -}) - -export { isValidSignature } diff --git a/apps/leaderboard-backend/src/repositories/AuthRepository.ts b/apps/leaderboard-backend/src/repositories/AuthRepository.ts index 767ef5ca46..36a3a80334 100644 --- a/apps/leaderboard-backend/src/repositories/AuthRepository.ts +++ b/apps/leaderboard-backend/src/repositories/AuthRepository.ts @@ -1,16 +1,14 @@ import { createUUID } from "@happy.tech/common" -import type { Address, Hex } from "@happy.tech/common" +import type { Address } from "@happy.tech/common" import { abis, deployment } from "@happy.tech/contracts/boop/anvil" import type { Kysely } from "kysely" -import { http, createPublicClient, hashMessage } from "viem" +import { http, createPublicClient } from "viem" import { localhost } from "viem/chains" import type { AuthSession, AuthSessionTableId, Database, NewAuthSession, UserTableId } from "../db/types" import { env } from "../env" -const EIP1271_MAGIC_VALUE = "0x1626ba7e" - export class AuthRepository { private publicClient: ReturnType @@ -34,23 +32,6 @@ export class AuthRepository { return `${nonce}${timestamp}` } - async verifySignature(primary_wallet: Address, message: Hex, signature: Hex): Promise { - try { - const messageHash = await hashMessage({ raw: message }) - const result = await this.publicClient.readContract({ - address: primary_wallet, - abi: abis.HappyAccountImpl, - functionName: "isValidSignature", - args: [messageHash, signature], - }) - - return result === EIP1271_MAGIC_VALUE - } catch (error) { - console.error("Error verifying signature:", error) - return false - } - } - async createSession(userId: UserTableId, walletAddress: Address): Promise { try { const now = new Date() diff --git a/apps/leaderboard-backend/src/routes/api/authRoutes.ts b/apps/leaderboard-backend/src/routes/api/authRoutes.ts index d3cf21708e..8abca24866 100644 --- a/apps/leaderboard-backend/src/routes/api/authRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/authRoutes.ts @@ -1,4 +1,6 @@ +import type { Address, Hex } from "@happy.tech/common" import { Hono } from "hono" +import { requireAuth, verifySignature } from "../../auth" import type { AuthSessionTableId } from "../../db/types" import { AuthChallengeDescription, @@ -38,12 +40,8 @@ export default new Hono() const { primary_wallet, message, signature } = c.req.valid("json") const { authRepo, userRepo } = c.get("repos") - // Verify signature - const isValid = await authRepo.verifySignature( - primary_wallet as `0x${string}`, - message as `0x${string}`, - signature as `0x${string}`, - ) + // Verify signature directly using the utility function + const isValid = await verifySignature(primary_wallet as Address, message as Hex, signature as Hex) if (!isValid) { return c.json({ error: "Invalid signature", ok: false }, 401) @@ -66,11 +64,12 @@ export default new Hono() // Return success with session ID and user info return c.json({ ok: true, - session_id: session.id, user: { id: user.id, username: user.username, primary_wallet: user.primary_wallet, + wallets: user.wallets, + sessionId: c.get("sessionId"), }, }) }) @@ -78,24 +77,13 @@ export default new Hono() /** * Get user info from session * GET /auth/me + * @security BearerAuth */ - .get("/me", AuthMeDescription, async (c) => { - const authHeader = c.req.header("Authorization") + .get("/me", AuthMeDescription, requireAuth, async (c) => { + const { userRepo } = c.get("repos") + const userId = c.get("userId") - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return c.json({ error: "Authentication required", ok: false }, 401) - } - - const sessionId = authHeader.substring(7) as AuthSessionTableId - - const { authRepo, userRepo } = c.get("repos") - const session = await authRepo.verifySession(sessionId) - - if (!session) { - return c.json({ error: "Invalid or expired session", ok: false }, 401) - } - - const user = await userRepo.findById(session.user_id) + const user = await userRepo.findById(userId) if (!user) { return c.json({ error: "User not found", ok: false }, 404) @@ -103,11 +91,12 @@ export default new Hono() return c.json({ ok: true, - session_id: sessionId, user: { id: user.id, username: user.username, primary_wallet: user.primary_wallet, + wallets: user.wallets, + sessionId: c.get("sessionId"), }, }) }) @@ -131,33 +120,19 @@ export default new Hono() /** * List all active sessions for a user * GET /auth/sessions + * @security BearerAuth */ - .get("/sessions", AuthSessionsDescription, async (c) => { - const authHeader = c.req.header("Authorization") - - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return c.json({ error: "Authentication required", ok: false }, 401) - } - - const sessionId = authHeader.substring(7) as AuthSessionTableId - + .get("/sessions", AuthSessionsDescription, requireAuth, async (c) => { const { authRepo } = c.get("repos") - const session = await authRepo.verifySession(sessionId) - - if (!session) { - return c.json({ error: "Invalid or expired session", ok: false }, 401) - } + const userId = c.get("userId") - const sessions = await authRepo.getUserSessions(session.user_id) + const sessions = await authRepo.getUserSessions(userId) return c.json({ ok: true, sessions: sessions.map((s) => ({ - id: s.id, - primary_wallet: s.primary_wallet, - created_at: s.created_at, - last_used_at: s.last_used_at, - is_current: s.id === sessionId, + ...s, + current: s.id === c.get("sessionId"), })), }) }) diff --git a/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts b/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts index 75825aeb69..7bd993bfed 100644 --- a/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts @@ -1,5 +1,6 @@ import type { Address } from "@happy.tech/common" import { Hono } from "hono" +import { requireAuth, requireGameOwnership } from "../../auth" import type { GameTableId, UserTableId } from "../../db/types" import { AdminWalletParamValidation, @@ -96,8 +97,9 @@ export default new Hono() /** * Create a new game (admin required) * POST /games + * @security BearerAuth */ - .post("/", GameCreateDescription, GameCreateValidation, async (c) => { + .post("/", requireAuth, GameCreateDescription, GameCreateValidation, async (c) => { try { const gameData = c.req.valid("json") const { gameRepo, userRepo } = c.get("repos") @@ -134,35 +136,45 @@ export default new Hono() /** * Update game details (admin only) * PATCH /games/:id + * Requires game ownership - only the game creator can update it + * @security BearerAuth */ - .patch("/:id", GameUpdateDescription, GameIdParamValidation, GameUpdateValidation, async (c) => { - try { - const { id } = c.req.valid("param") - const updateData = c.req.valid("json") - const { gameRepo } = c.get("repos") + .patch( + "/:id", + requireAuth, + requireGameOwnership, + GameUpdateDescription, + GameIdParamValidation, + GameUpdateValidation, + async (c) => { + try { + const { id } = c.req.valid("param") + const updateData = c.req.valid("json") + const { gameRepo } = c.get("repos") - // Check if game exists - const gameId = Number.parseInt(id, 10) as GameTableId - const game = await gameRepo.findById(gameId) - if (!game) { - return c.json({ ok: false, error: "Game not found" }, 404) - } + // Check if game exists + const gameId = Number.parseInt(id, 10) as GameTableId + const game = await gameRepo.findById(gameId) + if (!game) { + return c.json({ ok: false, error: "Game not found" }, 404) + } - // Check if name is being changed and is unique - if (updateData.name && updateData.name !== game.name) { - const existingGame = await gameRepo.findByExactName(updateData.name) - if (existingGame) { - return c.json({ ok: false, error: "Game name already exists" }, 409) + // Check if name is being changed and is unique + if (updateData.name && updateData.name !== game.name) { + const existingGame = await gameRepo.findByExactName(updateData.name) + if (existingGame) { + return c.json({ ok: false, error: "Game name already exists" }, 409) + } } - } - const updatedGame = await gameRepo.update(gameId, updateData) - return c.json({ ok: true, data: updatedGame }, 200) - } catch (err) { - console.error(`Error updating game ${c.req.param("id")}:`, err) - return c.json({ ok: false, error: "Internal Server Error" }, 500) - } - }) + const updatedGame = await gameRepo.update(gameId, updateData) + return c.json({ ok: true, data: updatedGame }, 200) + } catch (err) { + console.error(`Error updating game ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }, + ) // ==================================================================================================== // Game Scores Routes @@ -170,37 +182,45 @@ export default new Hono() /** * Submit a new score for a game * POST /games/:id/scores + * @security BearerAuth */ - .post("/:id/scores", ScoreSubmitDescription, GameIdParamValidation, ScoreSubmitValidation, async (c) => { - try { - const { id } = c.req.valid("param") - const { user_wallet, score, metadata } = c.req.valid("json") - const { gameRepo, userRepo } = c.get("repos") + .post( + "/:id/scores", + requireAuth, + ScoreSubmitDescription, + GameIdParamValidation, + ScoreSubmitValidation, + async (c) => { + try { + const { id } = c.req.valid("param") + const { user_wallet, score, metadata } = c.req.valid("json") + const { gameRepo, userRepo } = c.get("repos") - // Check if game exists - const gameId = Number.parseInt(id, 10) as GameTableId - const game = await gameRepo.findById(gameId) - if (!game) { - return c.json({ ok: false, error: "Game not found" }, 404) - } + // Check if game exists + const gameId = Number.parseInt(id, 10) as GameTableId + const game = await gameRepo.findById(gameId) + if (!game) { + return c.json({ ok: false, error: "Game not found" }, 404) + } - // Check if user exists - const user = await userRepo.findByWalletAddress(user_wallet) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + // Check if user exists + const user = await userRepo.findByWalletAddress(user_wallet) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Submit score - const { gameScoreRepo } = c.get("repos") - const userId = user.id as UserTableId - const newScore = await gameScoreRepo.submitScore(userId, gameId, score, metadata) + // Submit score + const { gameScoreRepo } = c.get("repos") + const userId = user.id as UserTableId + const newScore = await gameScoreRepo.submitScore(userId, gameId, score, metadata) - return c.json({ ok: true, data: newScore }, 201) - } catch (err) { - console.error(`Error submitting score for game ${c.req.param("id")}:`, err) - return c.json({ ok: false, error: "Internal Server Error" }, 500) - } - }) + return c.json({ ok: true, data: newScore }, 201) + } catch (err) { + console.error(`Error submitting score for game ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }, + ) /** * Get all scores for a game diff --git a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts index 766e1fb565..98ee0660a3 100644 --- a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts @@ -1,4 +1,5 @@ import { Hono } from "hono" +import { requireAuth, requireGuildRole } from "../../auth" import type { GuildTableId, UserTableId } from "../../db/types" import { GuildCreateDescription, @@ -48,8 +49,9 @@ export default new Hono() /** * Create a new guild (creator becomes admin). * POST /guilds + * @security BearerAuth */ - .post("/", GuildCreateDescription, GuildCreateValidation, async (c) => { + .post("/", requireAuth, GuildCreateDescription, GuildCreateValidation, async (c) => { try { const guildData = c.req.valid("json") const { guildRepo } = c.get("repos") @@ -100,36 +102,46 @@ export default new Hono() /** * Update guild details (admin only). * PATCH /guilds/:id + * Requires ADMIN role in the guild + * @security BearerAuth */ - .patch("/:id", GuildUpdateDescription, GuildIdParamValidation, GuildUpdateValidation, async (c) => { - try { - const { id } = c.req.valid("param") + .patch( + "/:id", + requireAuth, + requireGuildRole("ADMIN"), + GuildUpdateDescription, + GuildIdParamValidation, + GuildUpdateValidation, + async (c) => { + try { + const { id } = c.req.valid("param") - const updateData = c.req.valid("json") - const { guildRepo } = c.get("repos") + const updateData = c.req.valid("json") + const { guildRepo } = c.get("repos") - // Check if guild exists - const guildId = Number.parseInt(id, 10) as GuildTableId - const guild = await guildRepo.findById(guildId) - if (!guild) { - return c.json({ ok: false, error: "Guild not found" }, 404) - } + // Check if guild exists + const guildId = Number.parseInt(id, 10) as GuildTableId + const guild = await guildRepo.findById(guildId) + if (!guild) { + return c.json({ ok: false, error: "Guild not found" }, 404) + } - // Check if name is being changed and is unique - if (updateData.name && updateData.name !== guild.name) { - const existingGuilds = await guildRepo.findByName(updateData.name) - if (existingGuilds.length > 0) { - return c.json({ ok: false, error: "Guild name already exists" }, 409) + // Check if name is being changed and is unique + if (updateData.name && updateData.name !== guild.name) { + const existingGuilds = await guildRepo.findByName(updateData.name) + if (existingGuilds.length > 0) { + return c.json({ ok: false, error: "Guild name already exists" }, 409) + } } - } - const updatedGuild = await guildRepo.update(guildId, updateData) - return c.json(updatedGuild, 200) - } catch (err) { - console.error(`Error updating guild ${c.req.param("id")}:`, err) - return c.json({ ok: false, error: "Internal Server Error" }, 500) - } - }) + const updatedGuild = await guildRepo.update(guildId, updateData) + return c.json(updatedGuild, 200) + } catch (err) { + console.error(`Error updating guild ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }, + ) // ==================================================================================================== // Guild Member Routes @@ -162,58 +174,72 @@ export default new Hono() /** * Add a member to a guild (admin only). * POST /guilds/:id/members + * Requires ADMIN role in the guild + * @security BearerAuth */ - .post("/:id/members", GuildMemberAddDescription, GuildIdParamValidation, GuildMemberAddValidation, async (c) => { - try { - const { id } = c.req.valid("param") - let { user_id, username, is_admin } = c.req.valid("json") - const { guildRepo, userRepo } = c.get("repos") + .post( + "/:id/members", + requireAuth, + requireGuildRole("ADMIN"), + GuildMemberAddDescription, + GuildIdParamValidation, + GuildMemberAddValidation, + async (c) => { + try { + const { id } = c.req.valid("param") + let { user_id, username, is_admin } = c.req.valid("json") + const { guildRepo, userRepo } = c.get("repos") - // Ensure guild exists - const guildId = Number.parseInt(id, 10) as GuildTableId - const guild = await guildRepo.findById(guildId) - if (!guild) { - return c.json({ ok: false, error: "Guild not found" }, 404) - } + // Ensure guild exists + const guildId = Number.parseInt(id, 10) as GuildTableId + const guild = await guildRepo.findById(guildId) + if (!guild) { + return c.json({ ok: false, error: "Guild not found" }, 404) + } - // Resolve user_id from username if needed - if (!user_id && username) { - const userByName = await userRepo.findByUsername(username) - if (!userByName) { - return c.json({ ok: false, error: "User not found by username" }, 404) + // Resolve user_id from username if needed + if (!user_id && username) { + const userByName = await userRepo.findByUsername(username) + if (!userByName) { + return c.json({ ok: false, error: "User not found by username" }, 404) + } + user_id = userByName.id } - user_id = userByName.id - } - if (!user_id) { - return c.json({ ok: false, error: "User ID or username required" }, 400) - } + if (!user_id) { + return c.json({ ok: false, error: "User ID or username required" }, 400) + } - // Ensure user exists - const user = await userRepo.findById(user_id as UserTableId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + // Ensure user exists + const user = await userRepo.findById(user_id as UserTableId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Add member to guild - const member = await guildRepo.addMember(guildId, user_id as UserTableId, is_admin || false) - if (!member) { - return c.json({ ok: false, error: "User is already a member of this guild" }, 409) - } + // Add member to guild + const member = await guildRepo.addMember(guildId, user_id as UserTableId, is_admin || false) + if (!member) { + return c.json({ ok: false, error: "User is already a member of this guild" }, 409) + } - return c.json(member, 201) - } catch (err) { - console.error(`Error adding member to guild ${c.req.param("id")}:`, err) - return c.json({ ok: false, error: "Internal Server Error" }, 500) - } - }) + return c.json(member, 201) + } catch (err) { + console.error(`Error adding member to guild ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }, + ) /** * Update a guild member's role (admin only). * PATCH /guilds/:id/members/:member_id + * Requires ADMIN role in the guild + * @security BearerAuth */ .patch( "/:id/members/:member_id", + requireAuth, + requireGuildRole("ADMIN"), GuildMemberUpdateDescription, GuildIdParamValidation, GuildMemberIdParamValidation, @@ -250,9 +276,13 @@ export default new Hono() /** * Remove a member from a guild (admin only). * DELETE /guilds/:id/members/:member_id + * Requires ADMIN role in the guild + * @security BearerAuth */ .delete( "/:id/members/:member_id", + requireAuth, + requireGuildRole("ADMIN"), GuildMemberDeleteDescription, GuildIdParamValidation, GuildMemberIdParamValidation, diff --git a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts index 12800a027b..f6e3aa8a1b 100644 --- a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts @@ -1,4 +1,5 @@ import { Hono } from "hono" +import { requireAuth, requireOwnership } from "../../auth" import type { UserTableId } from "../../db/types" import { PrimaryWalletParamValidation, @@ -51,8 +52,9 @@ export default new Hono() /** * Create a new user. * POST /users + * @security BearerAuth */ - .post("/", UserCreateDescription, UserCreateValidation, async (c) => { + .post("/", requireAuth, UserCreateDescription, UserCreateValidation, async (c) => { const userData = c.req.valid("json") const { userRepo } = c.get("repos") @@ -98,53 +100,72 @@ export default new Hono() /** * Update user details by user ID. * PATCH /users/:id + * Requires ownership - only the user can update their own profile + * @security BearerAuth */ - .patch("/:id", UserUpdateByIdDescription, UserIdParamValidation, UserUpdateValidation, async (c) => { - const { id } = c.req.valid("param") - const updateData = c.req.valid("json") - const { userRepo } = c.get("repos") + .patch( + "/:id", + requireAuth, + requireOwnership("id", "id"), + UserUpdateByIdDescription, + UserIdParamValidation, + UserUpdateValidation, + async (c) => { + const { id } = c.req.valid("param") + const updateData = c.req.valid("json") + const { userRepo } = c.get("repos") - // Check if user exists - const userId = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + // Check if user exists + const userId = Number.parseInt(id, 10) as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Check if username is being changed and is unique - if (updateData.username && updateData.username !== user.username) { - const existingUser = await userRepo.findByUsername(updateData.username) - if (existingUser) { - return c.json({ ok: false, error: "Username already taken" }, 409) + // Check if username is being changed and is unique + if (updateData.username && updateData.username !== user.username) { + const existingUser = await userRepo.findByUsername(updateData.username) + if (existingUser) { + return c.json({ ok: false, error: "Username already taken" }, 409) + } } - } - const updatedUser = await userRepo.update(userId, updateData) - return c.json(updatedUser, 200) - }) + const updatedUser = await userRepo.update(userId, updateData) + return c.json(updatedUser, 200) + }, + ) /** * Delete a user by user ID and all associated data. * DELETE /users/:id + * Requires ownership - only the user can delete their own profile + * @security BearerAuth */ - .delete("/:id", UserDeleteByIdDescription, UserIdParamValidation, async (c) => { - const { id } = c.req.valid("param") - const { userRepo } = c.get("repos") + .delete( + "/:id", + requireAuth, + requireOwnership("id", "id"), + UserDeleteByIdDescription, + UserIdParamValidation, + async (c) => { + const { id } = c.req.valid("param") + const { userRepo } = c.get("repos") - // Check if user exists - const userId = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + // Check if user exists + const userId = Number.parseInt(id, 10) as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - const success = await userRepo.delete(userId) - if (!success) { - return c.json({ ok: false, error: "User not found" }, 404) - } + const success = await userRepo.delete(userId) + if (!success) { + return c.json({ ok: false, error: "User not found" }, 404) + } - return c.json(user, 200) - }) + return c.json(user, 200) + }, + ) // ==================================================================================================== // User Resource Routes (by Primary Wallet Address) @@ -168,9 +189,13 @@ export default new Hono() /** * Update user details by primary wallet address. * PATCH /users/pw/:primary_wallet + * Requires ownership - only the user can update their own profile + * @security BearerAuth */ .patch( "/pw/:primary_wallet", + requireAuth, + requireOwnership("primary_wallet", "primary_wallet"), UserUpdateByPrimaryWalletDescription, PrimaryWalletParamValidation, UserUpdateValidation, @@ -201,24 +226,33 @@ export default new Hono() /** * Delete a user by primary wallet address and all associated wallets, guild memberships, and scores. * DELETE /users/pw/:primary_wallet + * Requires ownership - only the user can delete their own profile + * @security BearerAuth */ - .delete("/pw/:primary_wallet", UserDeleteByPrimaryWalletDescription, PrimaryWalletParamValidation, async (c) => { - const { primary_wallet } = c.req.valid("param") - const { userRepo } = c.get("repos") + .delete( + "/pw/:primary_wallet", + requireAuth, + requireOwnership("primary_wallet", "primary_wallet"), + UserDeleteByPrimaryWalletDescription, + PrimaryWalletParamValidation, + async (c) => { + const { primary_wallet } = c.req.valid("param") + const { userRepo } = c.get("repos") - // Check if user exists - const user = await userRepo.findByWalletAddress(primary_wallet) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + // Check if user exists + const user = await userRepo.findByWalletAddress(primary_wallet) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - const success = await userRepo.delete(user.id) - if (!success) { - return c.json({ ok: false, error: "User not found" }, 404) - } + const success = await userRepo.delete(user.id) + if (!success) { + return c.json({ ok: false, error: "User not found" }, 404) + } - return c.json(user, 200) - }) + return c.json(user, 200) + }, + ) // ==================================================================================================== // User Wallets Collection Routes @@ -268,41 +302,55 @@ export default new Hono() /** * Add a wallet to a user by user ID. * POST /users/:id/wallets + * Requires ownership - only the user can manage their own wallets + * @security BearerAuth */ - .post("/:id/wallets", UserWalletAddByIdDescription, UserIdParamValidation, UserWalletValidation, async (c) => { - const { id } = c.req.valid("param") - const { wallet_address } = c.req.valid("json") - const { userRepo } = c.get("repos") + .post( + "/:id/wallets", + requireAuth, + requireOwnership("id", "id"), + UserWalletAddByIdDescription, + UserIdParamValidation, + UserWalletValidation, + async (c) => { + const { id } = c.req.valid("param") + const { wallet_address } = c.req.valid("json") + const { userRepo } = c.get("repos") - // Check if user exists - const userId = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + // Check if user exists + const userId = Number.parseInt(id, 10) as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Check if wallet already belongs to another user - const existingUser = await userRepo.findByWalletAddress(wallet_address) - if (existingUser && existingUser.id !== userId) { - return c.json({ ok: false, error: "Wallet already registered to another user" }, 409) - } + // Check if wallet already belongs to another user + const existingUser = await userRepo.findByWalletAddress(wallet_address) + if (existingUser && existingUser.id !== userId) { + return c.json({ ok: false, error: "Wallet already registered to another user" }, 409) + } - const success = await userRepo.addWallet(userId, wallet_address) - if (!success) { - return c.json({ ok: false, error: "Wallet already exists for this user" }, 409) - } + const success = await userRepo.addWallet(userId, wallet_address) + if (!success) { + return c.json({ ok: false, error: "Wallet already exists for this user" }, 409) + } - // Get updated user with wallets - const updatedUser = await userRepo.findById(userId, true) - return c.json(updatedUser, 200) - }) + // Get updated user with wallets + const updatedUser = await userRepo.findById(userId, true) + return c.json(updatedUser, 200) + }, + ) /** * Add a wallet to a user by primary wallet address. * POST /users/pw/:primary_wallet/wallets + * Requires ownership - only the user can manage their own wallets + * @security BearerAuth */ .post( "/pw/:primary_wallet/wallets", + requireAuth, + requireOwnership("primary_wallet", "primary_wallet"), UserWalletAddByPrimaryWalletDescription, PrimaryWalletParamValidation, UserWalletValidation, @@ -343,10 +391,14 @@ export default new Hono() /** * Set a wallet as primary for a user by user ID. - * PATCH /users/:id/wallets + * PATCH /users/:id/wallets/primary + * Requires ownership - only the user can manage their own wallets + * @security BearerAuth */ .patch( - "/:id/wallets", + "/:id/wallets/primary", + requireAuth, + requireOwnership("id", "id"), UserWalletSetPrimaryByIdDescription, UserIdParamValidation, UserWalletValidation, @@ -378,10 +430,14 @@ export default new Hono() /** * Set a wallet as primary for a user by primary wallet address. - * PATCH /users/pw/:primary_wallet/wallets + * PATCH /users/pw/:primary_wallet/wallets/primary + * Requires ownership - only the user can manage their own wallets + * @security BearerAuth */ .patch( - "/pw/:primary_wallet/wallets", + "/pw/:primary_wallet/wallets/primary", + requireAuth, + requireOwnership("primary_wallet", "primary_wallet"), UserWalletSetPrimaryByPrimaryWalletDescription, PrimaryWalletParamValidation, UserWalletValidation, @@ -417,45 +473,59 @@ export default new Hono() /** * Remove a wallet from a user by user ID. * DELETE /users/:id/wallets + * Requires ownership - only the user can manage their own wallets + * @security BearerAuth */ - .delete("/:id/wallets", UserWalletRemoveByIdDescription, UserIdParamValidation, UserWalletValidation, async (c) => { - const { id } = c.req.valid("param") - const { wallet_address } = c.req.valid("json") - const { userRepo } = c.get("repos") + .delete( + "/:id/wallets", + requireAuth, + requireOwnership("id", "id"), + UserWalletRemoveByIdDescription, + UserIdParamValidation, + UserWalletValidation, + async (c) => { + const { id } = c.req.valid("param") + const { wallet_address } = c.req.valid("json") + const { userRepo } = c.get("repos") - // Check if user exists - const userId = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + // Check if user exists + const userId = Number.parseInt(id, 10) as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - if (user.primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) - } + if (user.primary_wallet === wallet_address) { + return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) + } - const success = await userRepo.removeWallet(userId, wallet_address) - if (!success) { - return c.json( - { - ok: false, - error: "Cannot remove wallet: it may be the primary wallet or not found", - }, - 400, - ) - } + const success = await userRepo.removeWallet(userId, wallet_address) + if (!success) { + return c.json( + { + ok: false, + error: "Cannot remove wallet: it may be the primary wallet or not found", + }, + 400, + ) + } - // Get updated user with wallets - const updatedUser = await userRepo.findById(userId, true) - return c.json(updatedUser, 200) - }) + // Get updated user with wallets + const updatedUser = await userRepo.findById(userId, true) + return c.json(updatedUser, 200) + }, + ) /** * Remove a wallet from a user by primary wallet address. * DELETE /users/pw/:primary_wallet/wallets + * Requires ownership - only the user can manage their own wallets + * @security BearerAuth */ .delete( "/pw/:primary_wallet/wallets", + requireAuth, + requireOwnership("primary_wallet", "primary_wallet"), UserWalletRemoveByPrimaryWalletDescription, PrimaryWalletParamValidation, UserWalletValidation, diff --git a/apps/leaderboard-backend/src/server.ts b/apps/leaderboard-backend/src/server.ts index 65828667b0..3cbf6b7af7 100644 --- a/apps/leaderboard-backend/src/server.ts +++ b/apps/leaderboard-backend/src/server.ts @@ -62,6 +62,20 @@ app.get( version: "0.1.0", description: "Leaderboard backend for HappyChain", }, + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "UUID", + }, + }, + }, + security: [ + { + bearerAuth: [], + }, + ], servers: [ { url: `http://localhost:${env.PORT || 4545}`, From 05765bbe8aa58210cc6cc9fd9e017e5bf8cd7c1e Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Wed, 14 May 2025 17:56:06 +0530 Subject: [PATCH 03/23] refactor: switch to cookie-based auth and simplify signature verification [skip ci] --- .../src/auth/middlewares/requireAuth.ts | 22 +++------ .../src/auth/verifySignature.ts | 11 ++--- apps/leaderboard-backend/src/env.ts | 2 +- .../src/repositories/AuthRepository.ts | 28 +----------- .../src/routes/api/authRoutes.ts | 45 +++++++++++-------- .../src/routes/api/usersRoutes.ts | 3 +- apps/leaderboard-backend/src/server.ts | 23 ++++------ .../src/validation/auth/authSchemas.ts | 2 +- 8 files changed, 50 insertions(+), 86 deletions(-) diff --git a/apps/leaderboard-backend/src/auth/middlewares/requireAuth.ts b/apps/leaderboard-backend/src/auth/middlewares/requireAuth.ts index a3ff917b9e..339bd48cb6 100644 --- a/apps/leaderboard-backend/src/auth/middlewares/requireAuth.ts +++ b/apps/leaderboard-backend/src/auth/middlewares/requireAuth.ts @@ -1,30 +1,18 @@ -import type { Address } from "@happy.tech/common" import type { MiddlewareHandler } from "hono" +import { getCookie } from "hono/cookie" import { createMiddleware } from "hono/factory" -import type { AuthSessionTableId, UserTableId } from "../../db/types" - -// Define the context variables we'll set in the middleware -declare module "hono" { - interface ContextVariableMap { - userId: UserTableId - primaryWallet: Address - sessionId: AuthSessionTableId - } -} +import type { AuthSessionTableId } from "../../db/types" /** * Middleware that verifies if a user is authenticated via session - * Requires Authorization header with Bearer token containing the session ID + * Checks for session ID in cookie */ export const requireAuth: MiddlewareHandler = createMiddleware(async (c, next) => { - const authHeader = c.req.header("Authorization") - - if (!authHeader || !authHeader.startsWith("Bearer ")) { + const sessionId = getCookie(c, "session_id") as AuthSessionTableId | undefined + if (!sessionId) { return c.json({ error: "Authentication required", ok: false }, 401) } - const sessionId = authHeader.substring(7) as AuthSessionTableId - const { authRepo } = c.get("repos") const session = await authRepo.verifySession(sessionId) diff --git a/apps/leaderboard-backend/src/auth/verifySignature.ts b/apps/leaderboard-backend/src/auth/verifySignature.ts index b70ba7dcd0..ecd7610945 100644 --- a/apps/leaderboard-backend/src/auth/verifySignature.ts +++ b/apps/leaderboard-backend/src/auth/verifySignature.ts @@ -7,6 +7,12 @@ import { env } from "../env" // ERC-1271 magic value constant const EIP1271_MAGIC_VALUE = "0x1626ba7e" +// Singleton publicClient for all signature verifications +export const publicClient = createPublicClient({ + chain: localhost, + transport: http(env.RPC_URL), +}) + /** * Verifies a signature against a wallet address using ERC-1271 standard * Makes an RPC call to the smart contract account for signature verification @@ -18,11 +24,6 @@ const EIP1271_MAGIC_VALUE = "0x1626ba7e" */ export async function verifySignature(walletAddress: Address, message: Hex, signature: Hex): Promise { try { - const publicClient = createPublicClient({ - chain: localhost, - transport: http(env.RPC_URL), - }) - const messageHash = hashMessage({ raw: message }) const result = await publicClient.readContract({ address: walletAddress, diff --git a/apps/leaderboard-backend/src/env.ts b/apps/leaderboard-backend/src/env.ts index 3d600e6fbb..608491be26 100644 --- a/apps/leaderboard-backend/src/env.ts +++ b/apps/leaderboard-backend/src/env.ts @@ -4,7 +4,7 @@ const envSchema = z.object({ LEADERBOARD_DB_URL: z.string().trim(), PORT: z.string().trim().default("4545"), DATABASE_MIGRATE_DIR: z.string().trim().default("migrations"), - SESSION_EXPIRY: z.string().trim().default("1d"), + SESSION_EXPIRY: z.number().default(60 * 60 * 24), // 1 day SIGN_MESSAGE_PREFIX: z.string().trim().default("HappyChain Authentication"), RPC_URL: z.string().trim().default("http://localhost:8545"), }) diff --git a/apps/leaderboard-backend/src/repositories/AuthRepository.ts b/apps/leaderboard-backend/src/repositories/AuthRepository.ts index 36a3a80334..9c8e85d290 100644 --- a/apps/leaderboard-backend/src/repositories/AuthRepository.ts +++ b/apps/leaderboard-backend/src/repositories/AuthRepository.ts @@ -1,36 +1,10 @@ import { createUUID } from "@happy.tech/common" import type { Address } from "@happy.tech/common" -import { abis, deployment } from "@happy.tech/contracts/boop/anvil" - import type { Kysely } from "kysely" -import { http, createPublicClient } from "viem" -import { localhost } from "viem/chains" - import type { AuthSession, AuthSessionTableId, Database, NewAuthSession, UserTableId } from "../db/types" -import { env } from "../env" export class AuthRepository { - private publicClient: ReturnType - - constructor(private db: Kysely) { - this.publicClient = createPublicClient({ - chain: localhost, - transport: http(env.RPC_URL), - }) - } - - async generateChallenge(primary_wallet: Address): Promise { - const nonce = await this.publicClient.readContract({ - address: deployment.EntryPoint, - abi: abis.EntryPoint, - functionName: "nonceValues", - args: [primary_wallet, 0n], - }) - - const timestamp = Date.now() - - return `${nonce}${timestamp}` - } + constructor(private db: Kysely) {} async createSession(userId: UserTableId, walletAddress: Address): Promise { try { diff --git a/apps/leaderboard-backend/src/routes/api/authRoutes.ts b/apps/leaderboard-backend/src/routes/api/authRoutes.ts index 8abca24866..0784d47603 100644 --- a/apps/leaderboard-backend/src/routes/api/authRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/authRoutes.ts @@ -1,7 +1,9 @@ import type { Address, Hex } from "@happy.tech/common" import { Hono } from "hono" +import { deleteCookie, setCookie } from "hono/cookie" import { requireAuth, verifySignature } from "../../auth" import type { AuthSessionTableId } from "../../db/types" +import { env } from "../../env" import { AuthChallengeDescription, AuthChallengeValidation, @@ -10,7 +12,6 @@ import { AuthSessionsDescription, AuthVerifyDescription, AuthVerifyValidation, - SessionIdValidation, } from "../../validation/auth" export default new Hono() @@ -20,12 +21,8 @@ export default new Hono() */ .post("/challenge", AuthChallengeDescription, AuthChallengeValidation, async (c) => { const { primary_wallet } = c.req.valid("json") - const { authRepo } = c.get("repos") - - // Generate challenge message - const message = authRepo.generateChallenge(primary_wallet) - - // Return the challenge message for the frontend to request signature + // Use a hardcoded, Ethereum-style message for leaderboard authentication + const message = `\x19Leaderboard Signed Message:\nHappyChain Leaderboard Authentication Request for ${primary_wallet}` return c.json({ message, primary_wallet, @@ -47,20 +44,24 @@ export default new Hono() return c.json({ error: "Invalid signature", ok: false }, 401) } - // Find or create user const user = await userRepo.findByWalletAddress(primary_wallet, true) - if (!user) { return c.json({ error: "User not found", ok: false }, 404) } - // Create a new session const session = await authRepo.createSession(user.id, primary_wallet) - if (!session) { return c.json({ error: "Failed to create session", ok: false }, 500) } + setCookie(c, "session_id", session.id, { + httpOnly: true, + secure: false, + sameSite: "Lax", + path: "/", + maxAge: env.SESSION_EXPIRY, + }) + // Return success with session ID and user info return c.json({ ok: true, @@ -69,7 +70,7 @@ export default new Hono() username: user.username, primary_wallet: user.primary_wallet, wallets: user.wallets, - sessionId: c.get("sessionId"), + sessionId: session.id, }, }) }) @@ -104,17 +105,25 @@ export default new Hono() /** * Logout (delete session) * POST /auth/logout + * @security BearerAuth */ - .post("/logout", AuthLogoutDescription, SessionIdValidation, async (c) => { - const { session_id } = c.req.valid("json") + .post("/logout", AuthLogoutDescription, requireAuth, async (c) => { + const sessionId = c.get("sessionId") const { authRepo } = c.get("repos") - const success = await authRepo.deleteSession(session_id as AuthSessionTableId) + const success = await authRepo.deleteSession(sessionId as AuthSessionTableId) - return c.json({ - ok: success, - message: success ? "Logged out successfully" : "Session not found", + if (!success) { + return c.json({ error: "Failed to delete session", ok: false }, 500) + } + + // Delete the session cookie + deleteCookie(c, "session_id", { + path: "/", + secure: false, }) + + return c.json({ ok: true, message: "Logged out successfully" }) }) /** diff --git a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts index f6e3aa8a1b..91cf4d54cd 100644 --- a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts @@ -52,9 +52,8 @@ export default new Hono() /** * Create a new user. * POST /users - * @security BearerAuth */ - .post("/", requireAuth, UserCreateDescription, UserCreateValidation, async (c) => { + .post("/", UserCreateDescription, UserCreateValidation, async (c) => { const userData = c.req.valid("json") const { userRepo } = c.get("repos") diff --git a/apps/leaderboard-backend/src/server.ts b/apps/leaderboard-backend/src/server.ts index 3cbf6b7af7..b965f88a39 100644 --- a/apps/leaderboard-backend/src/server.ts +++ b/apps/leaderboard-backend/src/server.ts @@ -1,3 +1,4 @@ +import type { Address } from "@happy.tech/common" import { Scalar } from "@scalar/hono-api-reference" import { Hono } from "hono" import { openAPISpecs } from "hono-openapi" @@ -10,6 +11,8 @@ import { requestId as requestIdMiddleware } from "hono/request-id" import { timeout as timeoutMiddleware } from "hono/timeout" import { timing as timingMiddleware } from "hono/timing" import { ZodError } from "zod" + +import type { AuthSessionTableId, UserTableId } from "./db/types" import { env } from "./env" import { type Repositories, repositories } from "./repositories" import authApi from "./routes/api/authRoutes" @@ -18,9 +21,13 @@ import guildsApi from "./routes/api/guildsRoutes" import leaderboardApi from "./routes/api/leaderboardRoutes" import usersApi from "./routes/api/usersRoutes" +// Extend Hono's ContextVariableMap to include all custom context fields used across the app declare module "hono" { interface ContextVariableMap { - repos: Repositories + repos: Repositories // Database repositories, set globally in middleware + userId: UserTableId // Authenticated user's ID, set by requireAuth + primaryWallet: Address // Authenticated user's wallet address, set by requireAuth + sessionId: AuthSessionTableId // Current session ID, set by requireAuth } } @@ -62,20 +69,6 @@ app.get( version: "0.1.0", description: "Leaderboard backend for HappyChain", }, - components: { - securitySchemes: { - bearerAuth: { - type: "http", - scheme: "bearer", - bearerFormat: "UUID", - }, - }, - }, - security: [ - { - bearerAuth: [], - }, - ], servers: [ { url: `http://localhost:${env.PORT || 4545}`, diff --git a/apps/leaderboard-backend/src/validation/auth/authSchemas.ts b/apps/leaderboard-backend/src/validation/auth/authSchemas.ts index f6ef637256..f020b024a6 100644 --- a/apps/leaderboard-backend/src/validation/auth/authSchemas.ts +++ b/apps/leaderboard-backend/src/validation/auth/authSchemas.ts @@ -126,7 +126,7 @@ export const AuthVerifyRequestSchema = z .object({ primary_wallet: z.string().refine(isHex), message: z.string(), - signature: z.string().startsWith("0x", "Signature must start with 0x"), + signature: z.string().refine(isHex), }) .strict() .openapi({ From 36e436d2fecf9638a9414d81dc7b9836defb0555 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Thu, 15 May 2025 15:18:58 +0530 Subject: [PATCH 04/23] feat: implement role-based access control with enum-based permissions system [skip ci] --- apps/leaderboard-backend/.env.example | 2 +- .../src/auth/middlewares/permissions.ts | 66 ++++++++++++++++++ apps/leaderboard-backend/src/auth/roles.ts | 69 ++++++++++++------- .../1745907000000_create_all_tables.ts | 3 +- apps/leaderboard-backend/src/db/types.ts | 5 +- 5 files changed, 115 insertions(+), 30 deletions(-) create mode 100644 apps/leaderboard-backend/src/auth/middlewares/permissions.ts diff --git a/apps/leaderboard-backend/.env.example b/apps/leaderboard-backend/.env.example index 81a24308ba..a72ec518b1 100644 --- a/apps/leaderboard-backend/.env.example +++ b/apps/leaderboard-backend/.env.example @@ -1,6 +1,6 @@ PORT=4545 LEADERBOARD_DB_URL="leaderboard-backend.sqlite" DATABASE_MIGRATE_DIR="migrations" -SESSION_EXPIRY=1h +SESSION_EXPIRY=86400 SIGN_MESSAGE_PREFIX="HappyChain Authentication" RPC_URL="http://localhost:8545" diff --git a/apps/leaderboard-backend/src/auth/middlewares/permissions.ts b/apps/leaderboard-backend/src/auth/middlewares/permissions.ts new file mode 100644 index 0000000000..8ab85427f5 --- /dev/null +++ b/apps/leaderboard-backend/src/auth/middlewares/permissions.ts @@ -0,0 +1,66 @@ +import type { Context, MiddlewareHandler } from "hono" +import { createMiddleware } from "hono/factory" + +import type { GuildTableId, UserTableId } from "../../db/types" +import { type ActionType, Permissions, type ResourceType, type RoleType, UserRole } from "../roles" + +/** + * Generic permissions middleware for resource-based RBAC. + * + * Usage: + * permissions({ resource: "guild", action: "add_member" }) + * + * Looks up the user's role for the resource and checks if it's allowed for the action. + * + * @param opts.resource: ResourceType - The resource type (e.g., "guild") + * @param opts.action - The action type (e.g., "add_member") + * @param opts.getUserRole - Optional: custom function to get the user's role for the resource + */ +export function requirePermission({ + resource, + action, + getUserRole, +}: { + resource: ResourceType + action: ActionType + getUserRole?: (c: Context) => Promise +}): MiddlewareHandler { + return createMiddleware(async (c, next) => { + // Determine the user's role for this resource + let role: RoleType | undefined + if (getUserRole) { + role = await getUserRole(c) + } else { + // Default: look for c.get("guildRole"), c.get("gameRole"), etc. + role = c.get("role") || c.get("guildRole") || c.get("gameRole") || c.get("userRole") + // If nothing found, fallback to UserRole.AUTHENTICATED + if (!role) role = UserRole.AUTHENTICATED + } + + // Find allowed roles from the Permissions object (now enums) + const allowed = Permissions?.[resource]?.[action] + if (!allowed) { + return c.json({ ok: false, error: `Permission config not found for ${resource}.${action}` }, 500) + } + + // Allow if user's role is in allowed list + if (role && allowed.includes(role)) { + await next() + return + } + + return c.json({ ok: false, error: "Forbidden: insufficient role/permissions" }, 403) + }) +} + +/** + * Example getUserRole implementation for guilds. + * Looks up the user's role in the guild_members table. + */ +export async function getGuildUserRole(c: Context): Promise { + const userId = c.get("userId") as UserTableId + const guildId = c.req.param("id") + const { guildRepo } = c.get("repos") + const member = await guildRepo.findGuildMember(Number.parseInt(guildId, 10) as GuildTableId, userId) + return member?.role +} diff --git a/apps/leaderboard-backend/src/auth/roles.ts b/apps/leaderboard-backend/src/auth/roles.ts index aabf21e482..63fcef46a6 100644 --- a/apps/leaderboard-backend/src/auth/roles.ts +++ b/apps/leaderboard-backend/src/auth/roles.ts @@ -14,35 +14,52 @@ export enum GameRole { CREATOR = "creator", // Game creator (can manage game) } -export const Permissions = { - User: { - READ: [UserRole.AUTHENTICATED, UserRole.SELF], // Anyone can read user profiles - CREATE: [UserRole.AUTHENTICATED], // Any authenticated user can create a profile - UPDATE: [UserRole.SELF], // Only the user can update their profile - DELETE: [UserRole.SELF], // Only the user can delete their profile - MANAGE_WALLETS: [UserRole.SELF], // Only the user can manage their wallets - }, +export enum ResourceType { + USER = "user", + GUILD = "guild", + GAME = "game", + SCORE = "score", +} - Guild: { - READ: [UserRole.AUTHENTICATED], // Anyone can read guild info - CREATE: [UserRole.AUTHENTICATED], // Any authenticated user can create a guild - UPDATE: [GuildRole.ADMIN, GuildRole.CREATOR], // Admins and creators can update guild - DELETE: [GuildRole.CREATOR], // Only creator can delete guild - ADD_MEMBER: [GuildRole.ADMIN, GuildRole.CREATOR], // Admins and creators can add members - REMOVE_MEMBER: [GuildRole.ADMIN, GuildRole.CREATOR], // Admins and creators can remove members - PROMOTE_MEMBER: [GuildRole.CREATOR], // Only creator can promote members to admin - }, +export enum ActionType { + READ = "read", + CREATE = "create", + UPDATE = "update", + DELETE = "delete", + MANAGE = "manage", + ADD_MEMBER = "add_member", + REMOVE_MEMBER = "remove_member", + PROMOTE_MEMBER = "promote_member", + SUBMIT_SCORE = "submit_score", + MANAGE_SCORES = "manage_scores", +} - Game: { - READ: [UserRole.AUTHENTICATED], // Anyone can read game info - CREATE: [UserRole.AUTHENTICATED], // Any authenticated user can create a game - UPDATE: [GameRole.CREATOR], // Only creator can update game - DELETE: [GameRole.CREATOR], // Only creator can delete game - SUBMIT_SCORE: [UserRole.AUTHENTICATED], // Any authenticated user can submit scores - MANAGE_SCORES: [GameRole.CREATOR], // Only creator can manage/delete scores +export const Permissions: Record>> = { + [ResourceType.USER]: { + [ActionType.READ]: [UserRole.AUTHENTICATED, UserRole.SELF], + [ActionType.CREATE]: [UserRole.AUTHENTICATED], + [ActionType.UPDATE]: [UserRole.SELF], + [ActionType.DELETE]: [UserRole.SELF], + [ActionType.MANAGE]: [UserRole.SELF], + }, + [ResourceType.GUILD]: { + [ActionType.READ]: [UserRole.AUTHENTICATED], + [ActionType.CREATE]: [UserRole.AUTHENTICATED], + [ActionType.UPDATE]: [GuildRole.ADMIN, GuildRole.CREATOR], + [ActionType.DELETE]: [GuildRole.CREATOR], + [ActionType.ADD_MEMBER]: [GuildRole.ADMIN, GuildRole.CREATOR], + [ActionType.REMOVE_MEMBER]: [GuildRole.ADMIN, GuildRole.CREATOR], + [ActionType.PROMOTE_MEMBER]: [GuildRole.CREATOR], + }, + [ResourceType.GAME]: { + [ActionType.READ]: [UserRole.AUTHENTICATED], + [ActionType.CREATE]: [UserRole.AUTHENTICATED], + [ActionType.UPDATE]: [GameRole.CREATOR], + [ActionType.DELETE]: [GameRole.CREATOR], + [ActionType.SUBMIT_SCORE]: [UserRole.AUTHENTICATED], + [ActionType.MANAGE_SCORES]: [GameRole.CREATOR], }, + [ResourceType.SCORE]: {}, } export type RoleType = UserRole | GuildRole | GameRole -export type ResourceType = "user" | "guild" | "game" | "score" -export type ActionType = "read" | "create" | "update" | "delete" | "manage" diff --git a/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts b/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts index 4a9d9cc71c..9b0f2580ca 100644 --- a/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts +++ b/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts @@ -42,7 +42,7 @@ export async function up(db: Kysely): Promise { .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement()) .addColumn("guild_id", "integer", (col) => col.notNull()) .addColumn("user_id", "integer", (col) => col.notNull()) - .addColumn("is_admin", "boolean", (col) => col.notNull().defaultTo(false)) + .addColumn("role", "text", (col) => col.notNull().defaultTo("member")) // Only values from GuildRole enum allowed; enforced in code .addColumn("joined_at", "text", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()) .addForeignKeyConstraint("guild_members_guild_id_fk", ["guild_id"], "guilds", ["id"]) .addForeignKeyConstraint("guild_members_user_id_fk", ["user_id"], "users", ["id"]) @@ -68,6 +68,7 @@ export async function up(db: Kysely): Promise { .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement()) .addColumn("user_id", "integer", (col) => col.notNull()) .addColumn("game_id", "integer", (col) => col.notNull()) + .addColumn("role", "text", (col) => col.notNull().defaultTo("player")) // Only values from GameRole enum allowed; enforced in code .addColumn("score", "integer", (col) => col.notNull()) .addColumn("metadata", "text", (col) => col) .addColumn("created_at", "text", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()) diff --git a/apps/leaderboard-backend/src/db/types.ts b/apps/leaderboard-backend/src/db/types.ts index 8058d050c3..850a2c0593 100644 --- a/apps/leaderboard-backend/src/db/types.ts +++ b/apps/leaderboard-backend/src/db/types.ts @@ -1,5 +1,6 @@ import type { Address, UUID } from "@happy.tech/common" import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely" +import type { GameRole, GuildRole } from "../auth/roles" // --- Branded ID types for strong nominal typing --- export type UserTableId = number & { _brand: "users_id" } @@ -48,12 +49,11 @@ interface GuildTable { updated_at: ColumnType } -// Guild membership JOIN table (users in guilds with role) interface GuildMemberTable { id: Generated guild_id: GuildTableId // FK to guilds user_id: UserTableId // FK to users - is_admin: boolean // Whether user is an admin of this guild + role: GuildRole // Enum-based role in guild joined_at: ColumnType } @@ -73,6 +73,7 @@ interface UserGameScoreTable { id: Generated user_id: UserTableId // FK to users game_id: GameTableId // FK to games + role: GameRole // Enum-based role in game score: number // The actual score metadata: string | null // JSON string for any additional game-specific data created_at: ColumnType From 6c3afbc1bab695fdaa6c9107c8f366c64870c7ee Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Thu, 15 May 2025 16:10:49 +0530 Subject: [PATCH 05/23] refactor: replace boolean admin flags with role enums for guilds and games [skip ci] --- apps/leaderboard-backend/src/auth/index.ts | 1 + .../src/auth/middlewares/guildAuth.ts | 43 +++++++++- .../src/auth/middlewares/permissions.ts | 16 +--- apps/leaderboard-backend/src/env.ts | 2 +- .../src/repositories/GamesRepository.ts | 4 + .../src/repositories/GuildsRepository.ts | 17 ++-- .../src/repositories/utils.ts | 21 ----- .../src/routes/api/authRoutes.ts | 2 +- .../src/routes/api/guildsRoutes.ts | 79 ++++++++++++------- .../src/validation/guilds/guildSchemas.ts | 13 +-- 10 files changed, 118 insertions(+), 80 deletions(-) delete mode 100644 apps/leaderboard-backend/src/repositories/utils.ts diff --git a/apps/leaderboard-backend/src/auth/index.ts b/apps/leaderboard-backend/src/auth/index.ts index 0c8f186eb7..20d2eb1bf5 100644 --- a/apps/leaderboard-backend/src/auth/index.ts +++ b/apps/leaderboard-backend/src/auth/index.ts @@ -4,3 +4,4 @@ export * from "./middlewares/userAuth" export * from "./middlewares/gameAuth" export * from "./middlewares/guildAuth" export * from "./middlewares/requireAuth" +export * from "./middlewares/permissions" diff --git a/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts b/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts index bd5eeb2fed..dab273927d 100644 --- a/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts +++ b/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts @@ -1,7 +1,40 @@ +import type { Context } from "hono" import { createMiddleware } from "hono/factory" import type { GuildTableId, UserTableId } from "../../db/types" -import type { GuildRole } from "../roles" +import { GuildRole, type RoleType } from "../roles" +/** + * Get the user's role in a guild based on their membership status and guild relationship + * Returns GuildRole.CREATOR, GuildRole.ADMIN, GuildRole.MEMBER, or undefined if not a member + */ +export async function getGuildUserRole(c: Context): Promise { + const userId = c.get("userId") as UserTableId + const guildId = c.req.param("id") + if (!guildId || !userId) return undefined + + const { guildRepo } = c.get("repos") + const guildIdNum = Number.parseInt(guildId, 10) as GuildTableId + + // First check if user is the creator + const guild = await guildRepo.findById(guildIdNum) + if (!guild) return undefined + + if (guild.creator_id === userId) { + return GuildRole.CREATOR + } + + // Then check member status + const member = await guildRepo.findGuildMember(guildIdNum, userId) + if (!member) return undefined + + // Return the role from the member record + return member.role +} + +/** + * Middleware that requires a specific guild role to access a route + * @param role The minimum required role (MEMBER, ADMIN, or CREATOR) + */ export const requireGuildRole = (role: keyof typeof GuildRole) => { return createMiddleware(async (c, next) => { const userId = c.get("userId") as UserTableId @@ -24,11 +57,17 @@ export const requireGuildRole = (role: keyof typeof GuildRole) => { return c.json({ error: "You are not a member of this guild", ok: false }, 403) } - if (role === "ADMIN" && !member.is_admin) { + // Check if the member's role matches the required role + // For ADMIN, the member must have ADMIN or CREATOR role + // For MEMBER, any role is sufficient (MEMBER, ADMIN, or CREATOR) + if (role === "ADMIN" && member.role !== GuildRole.ADMIN && member.role !== GuildRole.CREATOR) { return c.json({ error: "Admin privileges required", ok: false }, 403) } } + // Store the role in context for potential use by other middlewares + c.set("guildRole", GuildRole[role]) + await next() }) } diff --git a/apps/leaderboard-backend/src/auth/middlewares/permissions.ts b/apps/leaderboard-backend/src/auth/middlewares/permissions.ts index 8ab85427f5..ef1ad27459 100644 --- a/apps/leaderboard-backend/src/auth/middlewares/permissions.ts +++ b/apps/leaderboard-backend/src/auth/middlewares/permissions.ts @@ -1,7 +1,5 @@ import type { Context, MiddlewareHandler } from "hono" import { createMiddleware } from "hono/factory" - -import type { GuildTableId, UserTableId } from "../../db/types" import { type ActionType, Permissions, type ResourceType, type RoleType, UserRole } from "../roles" /** @@ -37,7 +35,7 @@ export function requirePermission({ if (!role) role = UserRole.AUTHENTICATED } - // Find allowed roles from the Permissions object (now enums) + // Find allowed roles from the Permissions object const allowed = Permissions?.[resource]?.[action] if (!allowed) { return c.json({ ok: false, error: `Permission config not found for ${resource}.${action}` }, 500) @@ -52,15 +50,3 @@ export function requirePermission({ return c.json({ ok: false, error: "Forbidden: insufficient role/permissions" }, 403) }) } - -/** - * Example getUserRole implementation for guilds. - * Looks up the user's role in the guild_members table. - */ -export async function getGuildUserRole(c: Context): Promise { - const userId = c.get("userId") as UserTableId - const guildId = c.req.param("id") - const { guildRepo } = c.get("repos") - const member = await guildRepo.findGuildMember(Number.parseInt(guildId, 10) as GuildTableId, userId) - return member?.role -} diff --git a/apps/leaderboard-backend/src/env.ts b/apps/leaderboard-backend/src/env.ts index 608491be26..e6d18dbf63 100644 --- a/apps/leaderboard-backend/src/env.ts +++ b/apps/leaderboard-backend/src/env.ts @@ -4,7 +4,7 @@ const envSchema = z.object({ LEADERBOARD_DB_URL: z.string().trim(), PORT: z.string().trim().default("4545"), DATABASE_MIGRATE_DIR: z.string().trim().default("migrations"), - SESSION_EXPIRY: z.number().default(60 * 60 * 24), // 1 day + SESSION_EXPIRY: z.string().trim().default("86400"), SIGN_MESSAGE_PREFIX: z.string().trim().default("HappyChain Authentication"), RPC_URL: z.string().trim().default("http://localhost:8545"), }) diff --git a/apps/leaderboard-backend/src/repositories/GamesRepository.ts b/apps/leaderboard-backend/src/repositories/GamesRepository.ts index 86de3a24e4..a9300eb31b 100644 --- a/apps/leaderboard-backend/src/repositories/GamesRepository.ts +++ b/apps/leaderboard-backend/src/repositories/GamesRepository.ts @@ -1,4 +1,5 @@ import type { Kysely } from "kysely" +import { GameRole } from "../auth/roles" import type { Database, Game, GameTableId, NewGame, UpdateGame, UserGameScore, UserTableId } from "../db/types" export class GameRepository { @@ -99,6 +100,7 @@ export class GameScoreRepository { "user_game_scores.id", "user_game_scores.game_id", "user_game_scores.user_id", + "user_game_scores.role", "user_game_scores.score", "user_game_scores.metadata", "user_game_scores.created_at", @@ -117,6 +119,7 @@ export class GameScoreRepository { "user_game_scores.id", "user_game_scores.game_id", "user_game_scores.user_id", + "user_game_scores.role", "user_game_scores.score", "user_game_scores.metadata", "user_game_scores.created_at", @@ -155,6 +158,7 @@ export class GameScoreRepository { .values({ user_id: userId, game_id: gameId, + role: GameRole.PLAYER, // Default role for new score submissions score, metadata, created_at: now, diff --git a/apps/leaderboard-backend/src/repositories/GuildsRepository.ts b/apps/leaderboard-backend/src/repositories/GuildsRepository.ts index 668087665c..5c962e97e3 100644 --- a/apps/leaderboard-backend/src/repositories/GuildsRepository.ts +++ b/apps/leaderboard-backend/src/repositories/GuildsRepository.ts @@ -1,4 +1,5 @@ import type { Kysely } from "kysely" +import { GuildRole } from "../auth/roles" import type { Database, Guild, @@ -107,7 +108,7 @@ export class GuildRepository { .values({ guild_id: newGuild.id, user_id: newGuild.creator_id, - is_admin: true, + role: GuildRole.CREATOR, joined_at: now, }) .execute() @@ -153,7 +154,7 @@ export class GuildRepository { "guild_members.id", "guild_members.guild_id", "guild_members.user_id", - "guild_members.is_admin", + "guild_members.role", "guild_members.joined_at", "users.username", "users.primary_wallet", @@ -198,12 +199,15 @@ export class GuildRepository { return undefined } + // Determine role based on isAdmin parameter + const role = isAdmin ? GuildRole.ADMIN : GuildRole.MEMBER + await this.db .insertInto("guild_members") .values({ guild_id: guildId, user_id: userId, - is_admin: isAdmin, + role: role, joined_at: new Date().toISOString(), }) .execute() @@ -212,7 +216,7 @@ export class GuildRepository { .selectFrom("guild_members") .where("guild_id", "=", guildId) .where("user_id", "=", userId) - .select(["id", "guild_id", "user_id", "is_admin", "joined_at"]) + .select(["id", "guild_id", "user_id", "role", "joined_at"]) .executeTakeFirst() } @@ -221,9 +225,12 @@ export class GuildRepository { userId: UserTableId, isAdmin: boolean, ): Promise { + // Convert boolean isAdmin to appropriate GuildRole enum value + const role = isAdmin ? GuildRole.ADMIN : GuildRole.MEMBER + return await this.db .updateTable("guild_members") - .set({ is_admin: isAdmin }) + .set({ role: role }) .where("guild_id", "=", guildId) .where("user_id", "=", userId) .returningAll() diff --git a/apps/leaderboard-backend/src/repositories/utils.ts b/apps/leaderboard-backend/src/repositories/utils.ts deleted file mode 100644 index 4e2854ce70..0000000000 --- a/apps/leaderboard-backend/src/repositories/utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { GameTableId, GuildTableId, UserTableId } from "../db/types" -import type { GameRepository } from "./GamesRepository" -import type { GuildRepository } from "./GuildsRepository" - -export async function isUserGameAdminById( - gameRepo: GameRepository, - userId: UserTableId, - gameId: GameTableId, -): Promise { - const game = await gameRepo.findById(gameId) - return game ? game.admin_id === userId : false -} - -export async function isUserGuildAdminById( - guildRepo: GuildRepository, - userId: UserTableId, - guildId: GuildTableId, -): Promise { - const member = await guildRepo.findGuildMember(guildId, userId) - return !!member && member.is_admin === true -} diff --git a/apps/leaderboard-backend/src/routes/api/authRoutes.ts b/apps/leaderboard-backend/src/routes/api/authRoutes.ts index 0784d47603..f710a32ade 100644 --- a/apps/leaderboard-backend/src/routes/api/authRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/authRoutes.ts @@ -59,7 +59,7 @@ export default new Hono() secure: false, sameSite: "Lax", path: "/", - maxAge: env.SESSION_EXPIRY, + maxAge: Number.parseInt(env.SESSION_EXPIRY, 10), }) // Return success with session ID and user info diff --git a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts index 98ee0660a3..243108070b 100644 --- a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts @@ -1,5 +1,5 @@ import { Hono } from "hono" -import { requireAuth, requireGuildRole } from "../../auth" +import { ActionType, GuildRole, ResourceType, getGuildUserRole, requireAuth, requirePermission } from "../../auth" import type { GuildTableId, UserTableId } from "../../db/types" import { GuildCreateDescription, @@ -51,29 +51,36 @@ export default new Hono() * POST /guilds * @security BearerAuth */ - .post("/", requireAuth, GuildCreateDescription, GuildCreateValidation, async (c) => { - try { - const guildData = c.req.valid("json") - const { guildRepo } = c.get("repos") + .post( + "/", + requireAuth, + requirePermission({ resource: ResourceType.GUILD, action: ActionType.CREATE }), + GuildCreateDescription, + GuildCreateValidation, + async (c) => { + try { + const guildData = c.req.valid("json") + const { guildRepo } = c.get("repos") - // Check if guild name already exists - const existingGuilds = await guildRepo.findByName(guildData.name) - if (existingGuilds.length > 0) { - return c.json({ ok: false, error: "Guild name already exists" }, 409) - } + // Check if guild name already exists + const existingGuilds = await guildRepo.findByName(guildData.name) + if (existingGuilds.length > 0) { + return c.json({ ok: false, error: "Guild name already exists" }, 409) + } - const newGuild = await guildRepo.create({ - name: guildData.name, - icon_url: guildData.icon_url || null, - creator_id: guildData.creator_id as UserTableId, - }) + const newGuild = await guildRepo.create({ + name: guildData.name, + icon_url: guildData.icon_url || null, + creator_id: guildData.creator_id as UserTableId, + }) - return c.json(newGuild, 201) - } catch (err) { - console.error("Error creating guild:", err) - return c.json({ ok: false, error: "Internal Server Error" }, 500) - } - }) + return c.json(newGuild, 201) + } catch (err) { + console.error("Error creating guild:", err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }, + ) /** * Get a guild by ID (optionally include members). @@ -108,7 +115,7 @@ export default new Hono() .patch( "/:id", requireAuth, - requireGuildRole("ADMIN"), + requirePermission({ resource: ResourceType.GUILD, action: ActionType.UPDATE, getUserRole: getGuildUserRole }), GuildUpdateDescription, GuildIdParamValidation, GuildUpdateValidation, @@ -180,14 +187,18 @@ export default new Hono() .post( "/:id/members", requireAuth, - requireGuildRole("ADMIN"), + requirePermission({ + resource: ResourceType.GUILD, + action: ActionType.ADD_MEMBER, + getUserRole: getGuildUserRole, + }), GuildMemberAddDescription, GuildIdParamValidation, GuildMemberAddValidation, async (c) => { try { const { id } = c.req.valid("param") - let { user_id, username, is_admin } = c.req.valid("json") + let { user_id, username, role } = c.req.valid("json") const { guildRepo, userRepo } = c.get("repos") // Ensure guild exists @@ -217,7 +228,7 @@ export default new Hono() } // Add member to guild - const member = await guildRepo.addMember(guildId, user_id as UserTableId, is_admin || false) + const member = await guildRepo.addMember(guildId, user_id as UserTableId, role === GuildRole.ADMIN) if (!member) { return c.json({ ok: false, error: "User is already a member of this guild" }, 409) } @@ -239,7 +250,11 @@ export default new Hono() .patch( "/:id/members/:member_id", requireAuth, - requireGuildRole("ADMIN"), + requirePermission({ + resource: ResourceType.GUILD, + action: ActionType.PROMOTE_MEMBER, + getUserRole: getGuildUserRole, + }), GuildMemberUpdateDescription, GuildIdParamValidation, GuildMemberIdParamValidation, @@ -248,7 +263,7 @@ export default new Hono() try { const { id, member_id } = c.req.valid("param") - const { is_admin } = c.req.valid("json") + const { role } = c.req.valid("json") const { guildRepo } = c.get("repos") // Check if guild exists @@ -260,7 +275,9 @@ export default new Hono() } // Update member role - const updatedMember = await guildRepo.updateMemberRole(guildId, userId, is_admin) + // Convert role enum to boolean for backward compatibility with repository + const isAdmin = role === GuildRole.ADMIN + const updatedMember = await guildRepo.updateMemberRole(guildId, userId, isAdmin) if (!updatedMember) { return c.json({ ok: false, error: "Member not found in guild" }, 404) } @@ -282,7 +299,11 @@ export default new Hono() .delete( "/:id/members/:member_id", requireAuth, - requireGuildRole("ADMIN"), + requirePermission({ + resource: ResourceType.GUILD, + action: ActionType.REMOVE_MEMBER, + getUserRole: getGuildUserRole, + }), GuildMemberDeleteDescription, GuildIdParamValidation, GuildMemberIdParamValidation, diff --git a/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts b/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts index 59d051f022..99ff39951e 100644 --- a/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts +++ b/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts @@ -1,6 +1,7 @@ import { z } from "@hono/zod-openapi" import { resolver } from "hono-openapi/zod" import { type Address, isHex } from "viem" +import { GuildRole } from "../../auth/roles" // ==================================================================================================== // Response Schemas @@ -31,7 +32,7 @@ const GuildMemberResponseSchema = z id: z.number().int(), guild_id: z.number().int(), user_id: z.number().int(), - is_admin: z.boolean(), + role: z.nativeEnum(GuildRole), joined_at: z.string(), // Extended properties when including user details username: z.string().optional(), @@ -47,7 +48,7 @@ const GuildMemberResponseSchema = z id: 1, guild_id: 1, user_id: 1, - is_admin: true, + role: GuildRole.ADMIN, joined_at: "2023-01-01T00:00:00.000Z", username: "player1", primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", @@ -118,7 +119,7 @@ export const GuildMemberAddRequestSchema = z .object({ user_id: z.number().int().optional(), username: z.string().min(1).optional(), - is_admin: z.boolean().default(false).optional(), + role: z.nativeEnum(GuildRole).default(GuildRole.MEMBER).optional(), }) .strict() .refine((data) => data.user_id !== undefined || data.username !== undefined, { @@ -127,19 +128,19 @@ export const GuildMemberAddRequestSchema = z .openapi({ example: { username: "aryan", - is_admin: false, + role: GuildRole.MEMBER, }, }) // Guild member update request schema (for PATCH /guilds/:id/members/:userId) export const GuildMemberUpdateRequestSchema = z .object({ - is_admin: z.boolean(), + role: z.nativeEnum(GuildRole), }) .strict() .openapi({ example: { - is_admin: true, + role: GuildRole.ADMIN, }, }) From 6d13cbdbeadf80d3ae986a3ed94d4c98caa6fb3d Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Mon, 19 May 2025 17:50:13 +0530 Subject: [PATCH 06/23] update bun.lock after rebasing [skip ci] --- bun.lock | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bun.lock b/bun.lock index 836fb04cfa..3d84878ff8 100644 --- a/bun.lock +++ b/bun.lock @@ -5894,6 +5894,16 @@ "@happy.tech/leaderboard-backend/viem/ox/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + "@happy.tech/submitter/viem/ox/@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.0", "", {}, "sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg=="], + + "@happy.tech/submitter/viem/ox/@noble/curves": ["@noble/curves@1.9.0", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg=="], + + "@happy.tech/submitter/viem/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@happy.tech/submitter/viem/ox/@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + + "@happy.tech/submitter/viem/ox/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + "@happy.tech/txm/vitest/@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "@happy.tech/txm/vitest/vite-node/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], From 4b35736230ae8bad9785abe18e0afb64f290833d Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Tue, 20 May 2025 19:42:04 +0530 Subject: [PATCH 07/23] refactor: standardize API response format with ok/data wrapper [skip ci] --- apps/leaderboard-backend/.env.example | 1 - apps/leaderboard-backend/src/env.ts | 1 - .../src/routes/api/authRoutes.ts | 62 +++++++---- .../src/routes/api/guildsRoutes.ts | 16 +-- .../src/routes/api/usersRoutes.ts | 34 +++--- .../validation/auth/authRouteDescriptions.ts | 8 +- .../src/validation/auth/authSchemas.ts | 105 ++++++------------ .../src/validation/common/index.ts | 1 + .../src/validation/common/responseSchemas.ts | 34 ++++++ .../validation/games/gameRouteDescriptions.ts | 8 +- .../src/validation/games/gameSchemas.ts | 3 - .../validation/users/userRouteDescriptions.ts | 8 +- .../src/validation/users/userSchemas.ts | 18 +-- 13 files changed, 150 insertions(+), 149 deletions(-) create mode 100644 apps/leaderboard-backend/src/validation/common/index.ts create mode 100644 apps/leaderboard-backend/src/validation/common/responseSchemas.ts diff --git a/apps/leaderboard-backend/.env.example b/apps/leaderboard-backend/.env.example index a72ec518b1..f01bfae19e 100644 --- a/apps/leaderboard-backend/.env.example +++ b/apps/leaderboard-backend/.env.example @@ -2,5 +2,4 @@ PORT=4545 LEADERBOARD_DB_URL="leaderboard-backend.sqlite" DATABASE_MIGRATE_DIR="migrations" SESSION_EXPIRY=86400 -SIGN_MESSAGE_PREFIX="HappyChain Authentication" RPC_URL="http://localhost:8545" diff --git a/apps/leaderboard-backend/src/env.ts b/apps/leaderboard-backend/src/env.ts index e6d18dbf63..8a5600cb1b 100644 --- a/apps/leaderboard-backend/src/env.ts +++ b/apps/leaderboard-backend/src/env.ts @@ -5,7 +5,6 @@ const envSchema = z.object({ PORT: z.string().trim().default("4545"), DATABASE_MIGRATE_DIR: z.string().trim().default("migrations"), SESSION_EXPIRY: z.string().trim().default("86400"), - SIGN_MESSAGE_PREFIX: z.string().trim().default("HappyChain Authentication"), RPC_URL: z.string().trim().default("http://localhost:8545"), }) diff --git a/apps/leaderboard-backend/src/routes/api/authRoutes.ts b/apps/leaderboard-backend/src/routes/api/authRoutes.ts index f710a32ade..f73a3e5f73 100644 --- a/apps/leaderboard-backend/src/routes/api/authRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/authRoutes.ts @@ -24,8 +24,11 @@ export default new Hono() // Use a hardcoded, Ethereum-style message for leaderboard authentication const message = `\x19Leaderboard Signed Message:\nHappyChain Leaderboard Authentication Request for ${primary_wallet}` return c.json({ - message, - primary_wallet, + ok: true, + data: { + message, + primary_wallet, + }, }) }) @@ -41,22 +44,23 @@ export default new Hono() const isValid = await verifySignature(primary_wallet as Address, message as Hex, signature as Hex) if (!isValid) { - return c.json({ error: "Invalid signature", ok: false }, 401) + return c.json({ ok: false, error: "Invalid signature" }, 401) } const user = await userRepo.findByWalletAddress(primary_wallet, true) if (!user) { - return c.json({ error: "User not found", ok: false }, 404) + return c.json({ ok: false, error: "User not found" }, 404) } const session = await authRepo.createSession(user.id, primary_wallet) if (!session) { - return c.json({ error: "Failed to create session", ok: false }, 500) + return c.json({ ok: false, error: "Failed to create session" }, 500) } setCookie(c, "session_id", session.id, { httpOnly: true, - secure: false, + secure: true, + domain: "localhost", sameSite: "Lax", path: "/", maxAge: Number.parseInt(env.SESSION_EXPIRY, 10), @@ -65,12 +69,14 @@ export default new Hono() // Return success with session ID and user info return c.json({ ok: true, - user: { - id: user.id, - username: user.username, - primary_wallet: user.primary_wallet, - wallets: user.wallets, - sessionId: session.id, + data: { + session_id: session.id, + user: { + id: user.id, + username: user.username, + primary_wallet: user.primary_wallet, + wallets: user.wallets, + }, }, }) }) @@ -87,17 +93,19 @@ export default new Hono() const user = await userRepo.findById(userId) if (!user) { - return c.json({ error: "User not found", ok: false }, 404) + return c.json({ ok: false, error: "User not found" }, 404) } return c.json({ ok: true, - user: { - id: user.id, - username: user.username, - primary_wallet: user.primary_wallet, - wallets: user.wallets, - sessionId: c.get("sessionId"), + data: { + session_id: c.get("sessionId") as string, + user: { + id: user.id, + username: user.username, + primary_wallet: user.primary_wallet, + wallets: user.wallets, + }, }, }) }) @@ -114,16 +122,22 @@ export default new Hono() const success = await authRepo.deleteSession(sessionId as AuthSessionTableId) if (!success) { - return c.json({ error: "Failed to delete session", ok: false }, 500) + return c.json({ ok: false, error: "Failed to delete session" }, 500) } // Delete the session cookie deleteCookie(c, "session_id", { path: "/", - secure: false, + secure: true, + domain: "localhost", }) - return c.json({ ok: true, message: "Logged out successfully" }) + return c.json({ + ok: true, + data: { + message: "Logged out successfully", + }, + }) }) /** @@ -139,9 +153,9 @@ export default new Hono() return c.json({ ok: true, - sessions: sessions.map((s) => ({ + data: sessions.map((s) => ({ ...s, - current: s.id === c.get("sessionId"), + is_current: s.id === c.get("sessionId"), })), }) }) diff --git a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts index 243108070b..398c2afd09 100644 --- a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts @@ -39,7 +39,7 @@ export default new Hono() includeMembers: include_members ? include_members : false, }) - return c.json(guilds, 200) + return c.json({ ok: true, data: guilds }, 200) } catch (err) { console.error("Error listing guilds:", err) return c.json({ ok: false, error: "Internal Server Error" }, 500) @@ -74,7 +74,7 @@ export default new Hono() creator_id: guildData.creator_id as UserTableId, }) - return c.json(newGuild, 201) + return c.json({ ok: true, data: newGuild }, 201) } catch (err) { console.error("Error creating guild:", err) return c.json({ ok: false, error: "Internal Server Error" }, 500) @@ -99,7 +99,7 @@ export default new Hono() return c.json({ ok: false, error: "Guild not found" }, 404) } - return c.json(guild, 200) + return c.json({ ok: true, data: guild }, 200) } catch (err) { console.error(`Error fetching guild ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) @@ -142,7 +142,7 @@ export default new Hono() } const updatedGuild = await guildRepo.update(guildId, updateData) - return c.json(updatedGuild, 200) + return c.json({ ok: true, data: updatedGuild }, 200) } catch (err) { console.error(`Error updating guild ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) @@ -171,7 +171,7 @@ export default new Hono() } const members = await guildRepo.getGuildMembersWithUserDetails(guildId) - return c.json(members, 200) + return c.json({ ok: true, data: members }, 200) } catch (err) { console.error(`Error fetching members for guild ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) @@ -233,7 +233,7 @@ export default new Hono() return c.json({ ok: false, error: "User is already a member of this guild" }, 409) } - return c.json(member, 201) + return c.json({ ok: true, data: member }, 201) } catch (err) { console.error(`Error adding member to guild ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) @@ -282,7 +282,7 @@ export default new Hono() return c.json({ ok: false, error: "Member not found in guild" }, 404) } - return c.json(updatedMember, 200) + return c.json({ ok: true, data: updatedMember }, 200) } catch (err) { console.error(`Error updating member role in guild ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) @@ -333,7 +333,7 @@ export default new Hono() ) } - return c.json({ removed: true }, 200) + return c.json({ ok: true, data: { removed: true } }, 200) } catch (err) { console.error(`Error removing member from guild ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) diff --git a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts index 91cf4d54cd..c6a9f7fa2e 100644 --- a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts @@ -46,7 +46,7 @@ export default new Hono() includeWallets: query.include_wallets, }) - return c.json(users, 200) + return c.json({ ok: true, data: users }, 200) }) /** @@ -73,7 +73,7 @@ export default new Hono() username: userData.username, }) - return c.json(newUser, 201) + return c.json({ ok: true, data: newUser }, 201) }) // ==================================================================================================== @@ -93,7 +93,7 @@ export default new Hono() return c.json({ ok: false, error: "User not found" }, 404) } - return c.json(user, 200) + return c.json({ ok: true, data: user }, 200) }) /** @@ -130,7 +130,7 @@ export default new Hono() } const updatedUser = await userRepo.update(userId, updateData) - return c.json(updatedUser, 200) + return c.json({ ok: true, data: updatedUser }, 200) }, ) @@ -162,7 +162,7 @@ export default new Hono() return c.json({ ok: false, error: "User not found" }, 404) } - return c.json(user, 200) + return c.json({ ok: true, data: user }, 200) }, ) @@ -182,7 +182,7 @@ export default new Hono() return c.json({ ok: false, error: "User not found" }, 404) } - return c.json(user, 200) + return c.json({ ok: true, data: user }, 200) }) /** @@ -218,7 +218,7 @@ export default new Hono() } const updatedUser = await userRepo.update(user.id, updateData) - return c.json(updatedUser, 200) + return c.json({ ok: true, data: updatedUser }, 200) }, ) @@ -249,7 +249,7 @@ export default new Hono() return c.json({ ok: false, error: "User not found" }, 404) } - return c.json(user, 200) + return c.json({ ok: true, data: user }, 200) }, ) @@ -272,7 +272,7 @@ export default new Hono() } const wallets = await userRepo.getUserWallets(userId) - return c.json(wallets, 200) + return c.json({ ok: true, data: wallets }, 200) }) /** @@ -294,7 +294,7 @@ export default new Hono() } const wallets = await userRepo.getUserWallets(user.id) - return c.json(wallets, 200) + return c.json({ ok: true, data: wallets }, 200) }, ) @@ -336,7 +336,7 @@ export default new Hono() // Get updated user with wallets const updatedUser = await userRepo.findById(userId, true) - return c.json(updatedUser, 200) + return c.json({ ok: true, data: updatedUser }, 200) }, ) @@ -381,7 +381,7 @@ export default new Hono() // Get updated user with wallets const updatedUser = await userRepo.findById(user.id, true) - return c.json(updatedUser, 200) + return c.json({ ok: true, data: updatedUser }, 200) }, ) @@ -423,7 +423,7 @@ export default new Hono() // Get updated user with wallets const updatedUser = await userRepo.findById(userId, true) - return c.json(updatedUser, 200) + return c.json({ ok: true, data: updatedUser }, 200) }, ) @@ -465,7 +465,7 @@ export default new Hono() // Get updated user with wallets const updatedUser = await userRepo.findById(user.id, true) - return c.json(updatedUser, 200) + return c.json({ ok: true, data: updatedUser }, 200) }, ) @@ -511,7 +511,7 @@ export default new Hono() // Get updated user with wallets const updatedUser = await userRepo.findById(userId, true) - return c.json(updatedUser, 200) + return c.json({ ok: true, data: updatedUser }, 200) }, ) @@ -560,7 +560,7 @@ export default new Hono() // Get updated user with wallets const updatedUser = await userRepo.findById(user.id, true) - return c.json(updatedUser, 200) + return c.json({ ok: true, data: updatedUser }, 200) }, ) @@ -581,7 +581,7 @@ export default new Hono() } const guilds = await guildRepo.getUserGuilds(userId) - return c.json(guilds, 200) + return c.json({ ok: true, data: guilds }, 200) } catch (err) { console.error(`Error fetching guilds for user ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) diff --git a/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts index 287e74ed25..1e9e6934f2 100644 --- a/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts @@ -1,10 +1,6 @@ import { describeRoute } from "hono-openapi" -import { - AuthChallengeResponseSchemaObj, - AuthResponseSchemaObj, - ErrorResponseSchemaObj, - SessionListResponseSchemaObj, -} from "./authSchemas" +import { ErrorResponseSchemaObj } from "../common/responseSchemas" +import { AuthChallengeResponseSchemaObj, AuthResponseSchemaObj, SessionListResponseSchemaObj } from "./authSchemas" // ==================================================================================================== // Authentication Routes diff --git a/apps/leaderboard-backend/src/validation/auth/authSchemas.ts b/apps/leaderboard-backend/src/validation/auth/authSchemas.ts index f020b024a6..35de553702 100644 --- a/apps/leaderboard-backend/src/validation/auth/authSchemas.ts +++ b/apps/leaderboard-backend/src/validation/auth/authSchemas.ts @@ -1,65 +1,35 @@ import { z } from "@hono/zod-openapi" import { resolver } from "hono-openapi/zod" import { isHex } from "viem" +import { createSuccessResponseSchema } from "../common" +import { UserResponseSchema } from "../users" // ==================================================================================================== // Response Schemas -// Auth session schema for internal use -export const AuthSessionSchema = z +// Auth response data schema (without the wrapper) +const AuthResponseDataSchema = z .object({ - id: z.string().uuid(), - user_id: z.number().int(), - primary_wallet: z.string().refine(isHex), - created_at: z.string(), - last_used_at: z.string(), - }) - .strict() - .openapi({ - example: { - id: "123e4567-e89b-12d3-a456-426614174000", - user_id: 1, - primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", - created_at: "2023-01-01T00:00:00.000Z", - last_used_at: "2023-01-01T00:00:00.000Z", - }, - }) - -const UserInfoSchema = z - .object({ - id: z.number().int(), - username: z.string(), - primary_wallet: z.string().refine(isHex), - }) - .strict() - .openapi({ - example: { - id: 1, - username: "username", - primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", - }, - }) - -const AuthResponseSchema = z - .object({ - ok: z.boolean(), session_id: z.string().uuid(), - user: UserInfoSchema, + user: UserResponseSchema, }) .strict() .openapi({ example: { - ok: true, session_id: "123e4567-e89b-12d3-a456-426614174000", user: { id: 1, username: "username", primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", + created_at: "2023-01-01T00:00:00.000Z", + updated_at: "2023-01-01T00:00:00.000Z", + wallets: [], }, }, }) -const AuthChallengeResponseSchema = z +// Auth challenge data schema (without the wrapper) +const AuthChallengeDataSchema = z .object({ message: z.string(), primary_wallet: z.string().refine(isHex), @@ -72,42 +42,39 @@ const AuthChallengeResponseSchema = z }, }) -const SessionListResponseSchema = z - .object({ - ok: z.boolean(), - sessions: z.array( - z.object({ - id: z.string().uuid(), - primary_wallet: z.string().refine(isHex), - created_at: z.string(), - last_used_at: z.string(), - is_current: z.boolean(), - }), - ), - }) - .strict() +// Session list data schema (without the wrapper) +const SessionListDataSchema = z + .array( + z.object({ + id: z.string().uuid(), + primary_wallet: z.string().refine(isHex), + created_at: z.string(), + last_used_at: z.string(), + is_current: z.boolean(), + }), + ) .openapi({ - example: { - ok: true, - sessions: [ - { - id: "123e4567-e89b-12d3-a456-426614174000", - primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", - created_at: "2023-01-01T00:00:00.000Z", - last_used_at: "2023-01-01T00:00:00.000Z", - is_current: true, - }, - ], - }, + example: [ + { + id: "123e4567-e89b-12d3-a456-426614174000", + primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", + created_at: "2023-01-01T00:00:00.000Z", + last_used_at: "2023-01-01T00:00:00.000Z", + is_current: true, + }, + ], }) +// Create the wrapped schemas with the standard format +const AuthResponseSchema = createSuccessResponseSchema(AuthResponseDataSchema) +const AuthChallengeResponseSchema = createSuccessResponseSchema(AuthChallengeDataSchema) +const SessionListResponseSchema = createSuccessResponseSchema(SessionListDataSchema) + +// Export the resolved schemas for OpenAPI export const AuthResponseSchemaObj = resolver(AuthResponseSchema) export const AuthChallengeResponseSchemaObj = resolver(AuthChallengeResponseSchema) export const SessionListResponseSchemaObj = resolver(SessionListResponseSchema) -// Generic error schema -export const ErrorResponseSchemaObj = resolver(z.object({ ok: z.literal(false), error: z.string() })) - // ==================================================================================================== // Request Body Schemas diff --git a/apps/leaderboard-backend/src/validation/common/index.ts b/apps/leaderboard-backend/src/validation/common/index.ts new file mode 100644 index 0000000000..0e825adb5d --- /dev/null +++ b/apps/leaderboard-backend/src/validation/common/index.ts @@ -0,0 +1 @@ +export * from "./responseSchemas" diff --git a/apps/leaderboard-backend/src/validation/common/responseSchemas.ts b/apps/leaderboard-backend/src/validation/common/responseSchemas.ts new file mode 100644 index 0000000000..59ccbaedd3 --- /dev/null +++ b/apps/leaderboard-backend/src/validation/common/responseSchemas.ts @@ -0,0 +1,34 @@ +import { z } from "@hono/zod-openapi" +import { resolver } from "hono-openapi/zod" + +/** + * Standard success response wrapper + * All successful API responses should use this wrapper with their specific data schema + */ +export const createSuccessResponseSchema = (dataSchema: T) => { + return z + .object({ + ok: z.literal(true), + data: dataSchema, + }) + .strict() +} + +/** + * Standard error response schema + * All API error responses should use this schema + */ +export const ErrorResponseSchema = z + .object({ + ok: z.literal(false), + error: z.string(), + }) + .strict() + .openapi({ + example: { + ok: false, + error: "An error occurred", + }, + }) + +export const ErrorResponseSchemaObj = resolver(ErrorResponseSchema) diff --git a/apps/leaderboard-backend/src/validation/games/gameRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/games/gameRouteDescriptions.ts index 9a0e42d360..bbb1c0f772 100644 --- a/apps/leaderboard-backend/src/validation/games/gameRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/games/gameRouteDescriptions.ts @@ -1,10 +1,6 @@ import { describeRoute } from "hono-openapi" -import { - ErrorResponseSchemaObj, - GameListResponseSchemaObj, - GameResponseSchemaObj, - UserGameScoreResponseSchemaObj, -} from "./gameSchemas" +import { ErrorResponseSchemaObj } from "../common" +import { GameListResponseSchemaObj, GameResponseSchemaObj, UserGameScoreResponseSchemaObj } from "./gameSchemas" // ==================================================================================================== // Game Collection diff --git a/apps/leaderboard-backend/src/validation/games/gameSchemas.ts b/apps/leaderboard-backend/src/validation/games/gameSchemas.ts index 95c70dc82c..2a975ab3ff 100644 --- a/apps/leaderboard-backend/src/validation/games/gameSchemas.ts +++ b/apps/leaderboard-backend/src/validation/games/gameSchemas.ts @@ -61,9 +61,6 @@ export const GameListResponseSchemaObj = resolver(z.array(GameResponseSchema)) export const UserGameScoreResponseSchemaObj = resolver(UserGameScoreResponseSchema) -// Generic error schema -export const ErrorResponseSchemaObj = resolver(z.object({ ok: z.literal(false), error: z.string() })) - // ==================================================================================================== // Request Body Schemas diff --git a/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts index ce7711c0ea..f6bfe97a30 100644 --- a/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts @@ -1,10 +1,6 @@ import { describeRoute } from "hono-openapi" -import { - ErrorResponseSchemaObj, - UserListResponseSchemaObj, - UserResponseSchemaObj, - UserWalletListResponseSchemaObj, -} from "./userSchemas" +import { ErrorResponseSchemaObj } from "../common/responseSchemas" +import { UserListResponseSchemaObj, UserResponseSchemaObj, UserWalletListResponseSchemaObj } from "./userSchemas" // ==================================================================================================== // User Collection diff --git a/apps/leaderboard-backend/src/validation/users/userSchemas.ts b/apps/leaderboard-backend/src/validation/users/userSchemas.ts index c5e770cd94..5fa977db90 100644 --- a/apps/leaderboard-backend/src/validation/users/userSchemas.ts +++ b/apps/leaderboard-backend/src/validation/users/userSchemas.ts @@ -1,6 +1,7 @@ import { z } from "@hono/zod-openapi" import { resolver } from "hono-openapi/zod" import { isHex } from "viem" +import { createSuccessResponseSchema } from "../common/responseSchemas" // ==================================================================================================== // Response Schemas @@ -24,7 +25,7 @@ const UserWalletSchema = z }, }) -const UserResponseSchema = z +export const UserResponseSchema = z .object({ id: z.number().int(), primary_wallet: z.string().refine(isHex), @@ -53,14 +54,15 @@ const UserResponseSchema = z }, }) -export const UserResponseSchemaObj = resolver(UserResponseSchema) +// Create wrapped schemas with standard format +const WrappedUserResponseSchema = createSuccessResponseSchema(UserResponseSchema) +const WrappedUserListResponseSchema = createSuccessResponseSchema(z.array(UserResponseSchema)) +const WrappedUserWalletListResponseSchema = createSuccessResponseSchema(z.array(UserWalletSchema)) -export const UserListResponseSchemaObj = resolver(z.array(UserResponseSchema)) - -export const UserWalletListResponseSchemaObj = resolver(z.array(UserWalletSchema)) - -// Generic error schema -export const ErrorResponseSchemaObj = resolver(z.object({ ok: z.literal(false), error: z.string() })) +// Export resolved schemas for OpenAPI +export const UserResponseSchemaObj = resolver(WrappedUserResponseSchema) +export const UserListResponseSchemaObj = resolver(WrappedUserListResponseSchema) +export const UserWalletListResponseSchemaObj = resolver(WrappedUserWalletListResponseSchema) // ==================================================================================================== // Request Body Schemas From 8701b3503ff9b20220ab67c1fec2dfa5b497f127 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Tue, 20 May 2025 20:23:01 +0530 Subject: [PATCH 08/23] refactor: move OpenAPI schema resolvers from schema files to route validation files [skip ci] --- .../validation/auth/authRouteDescriptions.ts | 8 +- .../validation/auth/authRouteValidations.ts | 20 ++- .../src/validation/auth/authSchemas.ts | 18 +- .../validation/games/gameRouteDescriptions.ts | 6 +- .../validation/games/gameRouteValidations.ts | 10 +- .../src/validation/games/gameSchemas.ts | 9 +- .../guilds/guildRouteDescriptions.ts | 3 +- .../guilds/guildRouteValidations.ts | 18 +- .../src/validation/guilds/guildSchemas.ts | 16 +- .../leaderBoardRouteDescriptions.ts | 35 +++- .../leaderBoardRouteValidations.ts | 17 +- .../leaderboard/leaderBoardSchema.ts | 167 +++++++++--------- .../validation/users/userRouteDescriptions.ts | 6 +- .../validation/users/userRouteValidations.ts | 14 +- .../src/validation/users/userSchemas.ts | 16 +- 15 files changed, 209 insertions(+), 154 deletions(-) diff --git a/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts index 1e9e6934f2..fdfe1598eb 100644 --- a/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts @@ -1,6 +1,10 @@ import { describeRoute } from "hono-openapi" -import { ErrorResponseSchemaObj } from "../common/responseSchemas" -import { AuthChallengeResponseSchemaObj, AuthResponseSchemaObj, SessionListResponseSchemaObj } from "./authSchemas" +import { ErrorResponseSchemaObj } from "../common" +import { + AuthChallengeResponseSchemaObj, + AuthResponseSchemaObj, + SessionListResponseSchemaObj, +} from "./authRouteValidations" // ==================================================================================================== // Authentication Routes diff --git a/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts b/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts index 9f0034f243..ca302be5e0 100644 --- a/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts +++ b/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts @@ -1,8 +1,20 @@ -import { validator as zValidator } from "hono-openapi/zod" -import { AuthChallengeRequestSchema, AuthVerifyRequestSchema, SessionIdRequestSchema } from "./authSchemas" +import { resolver, validator as zValidator } from "hono-openapi/zod" +import { createSuccessResponseSchema } from "../common" +import { + AuthChallengeDataSchema, + AuthChallengeRequestSchema, + AuthResponseDataSchema, + AuthVerifyRequestSchema, + SessionIdRequestSchema, + SessionListDataSchema, +} from "./authSchemas" -export const AuthChallengeValidation = zValidator("json", AuthChallengeRequestSchema) +// Export the resolved schemas for Hono +export const AuthResponseSchemaObj = resolver(createSuccessResponseSchema(AuthResponseDataSchema)) +export const AuthChallengeResponseSchemaObj = resolver(createSuccessResponseSchema(AuthChallengeDataSchema)) +export const SessionListResponseSchemaObj = resolver(createSuccessResponseSchema(SessionListDataSchema)) +// Export the validators for Hono +export const AuthChallengeValidation = zValidator("json", AuthChallengeRequestSchema) export const AuthVerifyValidation = zValidator("json", AuthVerifyRequestSchema) - export const SessionIdValidation = zValidator("json", SessionIdRequestSchema) diff --git a/apps/leaderboard-backend/src/validation/auth/authSchemas.ts b/apps/leaderboard-backend/src/validation/auth/authSchemas.ts index 35de553702..02e57eb383 100644 --- a/apps/leaderboard-backend/src/validation/auth/authSchemas.ts +++ b/apps/leaderboard-backend/src/validation/auth/authSchemas.ts @@ -1,14 +1,12 @@ import { z } from "@hono/zod-openapi" -import { resolver } from "hono-openapi/zod" import { isHex } from "viem" -import { createSuccessResponseSchema } from "../common" import { UserResponseSchema } from "../users" // ==================================================================================================== // Response Schemas // Auth response data schema (without the wrapper) -const AuthResponseDataSchema = z +export const AuthResponseDataSchema = z .object({ session_id: z.string().uuid(), user: UserResponseSchema, @@ -29,7 +27,7 @@ const AuthResponseDataSchema = z }) // Auth challenge data schema (without the wrapper) -const AuthChallengeDataSchema = z +export const AuthChallengeDataSchema = z .object({ message: z.string(), primary_wallet: z.string().refine(isHex), @@ -43,7 +41,7 @@ const AuthChallengeDataSchema = z }) // Session list data schema (without the wrapper) -const SessionListDataSchema = z +export const SessionListDataSchema = z .array( z.object({ id: z.string().uuid(), @@ -65,16 +63,6 @@ const SessionListDataSchema = z ], }) -// Create the wrapped schemas with the standard format -const AuthResponseSchema = createSuccessResponseSchema(AuthResponseDataSchema) -const AuthChallengeResponseSchema = createSuccessResponseSchema(AuthChallengeDataSchema) -const SessionListResponseSchema = createSuccessResponseSchema(SessionListDataSchema) - -// Export the resolved schemas for OpenAPI -export const AuthResponseSchemaObj = resolver(AuthResponseSchema) -export const AuthChallengeResponseSchemaObj = resolver(AuthChallengeResponseSchema) -export const SessionListResponseSchemaObj = resolver(SessionListResponseSchema) - // ==================================================================================================== // Request Body Schemas diff --git a/apps/leaderboard-backend/src/validation/games/gameRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/games/gameRouteDescriptions.ts index bbb1c0f772..7853ed3023 100644 --- a/apps/leaderboard-backend/src/validation/games/gameRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/games/gameRouteDescriptions.ts @@ -1,6 +1,10 @@ import { describeRoute } from "hono-openapi" import { ErrorResponseSchemaObj } from "../common" -import { GameListResponseSchemaObj, GameResponseSchemaObj, UserGameScoreResponseSchemaObj } from "./gameSchemas" +import { + GameListResponseSchemaObj, + GameResponseSchemaObj, + UserGameScoreResponseSchemaObj, +} from "./gameRouteValidations" // ==================================================================================================== // Game Collection diff --git a/apps/leaderboard-backend/src/validation/games/gameRouteValidations.ts b/apps/leaderboard-backend/src/validation/games/gameRouteValidations.ts index 5caada879e..56eb9c0ede 100644 --- a/apps/leaderboard-backend/src/validation/games/gameRouteValidations.ts +++ b/apps/leaderboard-backend/src/validation/games/gameRouteValidations.ts @@ -1,15 +1,23 @@ -import { validator as zValidator } from "hono-openapi/zod" +import { resolver, validator as zValidator } from "hono-openapi/zod" +import { createSuccessResponseSchema } from "../common" import { AdminWalletParamSchema, GameCreateRequestSchema, GameIdParamSchema, + GameListResponseSchema, GameQuerySchema, + GameResponseSchema, GameScoresQuerySchema, GameUpdateRequestSchema, ScoreSubmitRequestSchema, + UserGameScoreResponseSchema, UserWalletParamSchema, } from "./gameSchemas" +export const GameResponseSchemaObj = resolver(createSuccessResponseSchema(GameResponseSchema)) +export const GameListResponseSchemaObj = resolver(createSuccessResponseSchema(GameListResponseSchema)) +export const UserGameScoreResponseSchemaObj = resolver(createSuccessResponseSchema(UserGameScoreResponseSchema)) + export const GameQueryValidation = zValidator("query", GameQuerySchema) export const GameCreateValidation = zValidator("json", GameCreateRequestSchema) export const GameIdParamValidation = zValidator("param", GameIdParamSchema) diff --git a/apps/leaderboard-backend/src/validation/games/gameSchemas.ts b/apps/leaderboard-backend/src/validation/games/gameSchemas.ts index 2a975ab3ff..bdb8acb60c 100644 --- a/apps/leaderboard-backend/src/validation/games/gameSchemas.ts +++ b/apps/leaderboard-backend/src/validation/games/gameSchemas.ts @@ -1,5 +1,4 @@ import { z } from "@hono/zod-openapi" -import { resolver } from "hono-openapi/zod" import { isHex } from "viem" // ==================================================================================================== @@ -28,6 +27,8 @@ export const GameResponseSchema = z }, }) +export const GameListResponseSchema = z.array(GameResponseSchema) + export const UserGameScoreResponseSchema = z .object({ id: z.number().int(), @@ -55,12 +56,6 @@ export const UserGameScoreResponseSchema = z }, }) -export const GameResponseSchemaObj = resolver(GameResponseSchema) - -export const GameListResponseSchemaObj = resolver(z.array(GameResponseSchema)) - -export const UserGameScoreResponseSchemaObj = resolver(UserGameScoreResponseSchema) - // ==================================================================================================== // Request Body Schemas diff --git a/apps/leaderboard-backend/src/validation/guilds/guildRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/guilds/guildRouteDescriptions.ts index 6ed6681229..e16a306ee8 100644 --- a/apps/leaderboard-backend/src/validation/guilds/guildRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/guilds/guildRouteDescriptions.ts @@ -1,5 +1,6 @@ import { describeRoute } from "hono-openapi" -import { ErrorResponseSchemaObj, GuildListResponseSchemaObj, GuildResponseSchemaObj } from "./guildSchemas" +import { ErrorResponseSchemaObj } from "../common" +import { GuildListResponseSchemaObj, GuildResponseSchemaObj } from "./guildRouteValidations" // ==================================================================================================== // Guild Collection diff --git a/apps/leaderboard-backend/src/validation/guilds/guildRouteValidations.ts b/apps/leaderboard-backend/src/validation/guilds/guildRouteValidations.ts index 592473e514..d5b81a348b 100644 --- a/apps/leaderboard-backend/src/validation/guilds/guildRouteValidations.ts +++ b/apps/leaderboard-backend/src/validation/guilds/guildRouteValidations.ts @@ -1,24 +1,28 @@ -import { validator as zValidator } from "hono-openapi/zod" +import { resolver, validator as zValidator } from "hono-openapi/zod" +import { createSuccessResponseSchema } from "../common" import { GuildCreateRequestSchema, GuildIdParamSchema, GuildMemberAddRequestSchema, GuildMemberIdParamSchema, + GuildMemberResponseSchema, + GuildMemberResponseSchemaArray, GuildMemberUpdateRequestSchema, GuildQuerySchema, + GuildResponseSchema, + GuildResponseSchemaArray, GuildUpdateRequestSchema, } from "./guildSchemas" -export const GuildQueryValidation = zValidator("query", GuildQuerySchema) +export const GuildResponseSchemaObj = resolver(createSuccessResponseSchema(GuildResponseSchema)) +export const GuildListResponseSchemaObj = resolver(createSuccessResponseSchema(GuildResponseSchemaArray)) +export const GuildMemberResponseSchemaObj = resolver(createSuccessResponseSchema(GuildMemberResponseSchema)) +export const GuildMemberListResponseSchemaObj = resolver(createSuccessResponseSchema(GuildMemberResponseSchemaArray)) +export const GuildQueryValidation = zValidator("query", GuildQuerySchema) export const GuildCreateValidation = zValidator("json", GuildCreateRequestSchema) - export const GuildUpdateValidation = zValidator("json", GuildUpdateRequestSchema) - export const GuildMemberAddValidation = zValidator("json", GuildMemberAddRequestSchema) - export const GuildMemberUpdateValidation = zValidator("json", GuildMemberUpdateRequestSchema) - export const GuildIdParamValidation = zValidator("param", GuildIdParamSchema) - export const GuildMemberIdParamValidation = zValidator("param", GuildMemberIdParamSchema) diff --git a/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts b/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts index 99ff39951e..0bb62c6bb1 100644 --- a/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts +++ b/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts @@ -1,12 +1,11 @@ import { z } from "@hono/zod-openapi" -import { resolver } from "hono-openapi/zod" import { type Address, isHex } from "viem" import { GuildRole } from "../../auth/roles" // ==================================================================================================== // Response Schemas -const GuildResponseSchema = z +export const GuildResponseSchema = z .object({ id: z.number().int(), name: z.string(), @@ -27,7 +26,7 @@ const GuildResponseSchema = z }, }) -const GuildMemberResponseSchema = z +export const GuildMemberResponseSchema = z .object({ id: z.number().int(), guild_id: z.number().int(), @@ -55,16 +54,9 @@ const GuildMemberResponseSchema = z }, }) -export const GuildResponseSchemaObj = resolver(GuildResponseSchema) +export const GuildResponseSchemaArray = z.array(GuildResponseSchema) -export const GuildListResponseSchemaObj = resolver(z.array(GuildResponseSchema)) - -export const GuildMemberResponseSchemaObj = resolver(GuildMemberResponseSchema) - -export const GuildMemberListResponseSchemaObj = resolver(z.array(GuildMemberResponseSchema)) - -// Generic error schema -export const ErrorResponseSchemaObj = resolver(z.object({ ok: z.literal(false), error: z.string() })) +export const GuildMemberResponseSchemaArray = z.array(GuildMemberResponseSchema) // ==================================================================================================== // Request Body Schemas diff --git a/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardRouteDescriptions.ts index d10a09adf2..d1a6140a3e 100644 --- a/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardRouteDescriptions.ts @@ -1,10 +1,11 @@ import { describeRoute } from "hono-openapi" +import { ErrorResponseSchemaObj } from "../common" import { GameGuildLeaderBoardResponseObj, GameLeaderBoardResponseObj, GlobalLeaderBoardResponseObj, GuildLeaderBoardResponseObj, -} from "./leaderBoardSchema" +} from "./leaderBoardRouteValidations" export const GlobalLeaderboardDescription = describeRoute({ validateResponse: false, @@ -18,6 +19,14 @@ export const GlobalLeaderboardDescription = describeRoute({ }, }, }, + 500: { + description: "Internal Server Error", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, }, }) @@ -33,6 +42,14 @@ export const GuildLeaderboardDescription = describeRoute({ }, }, }, + 500: { + description: "Internal Server Error", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, }, }) @@ -48,6 +65,14 @@ export const GameLeaderboardDescription = describeRoute({ }, }, }, + 500: { + description: "Internal Server Error", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, }, }) @@ -63,5 +88,13 @@ export const GameGuildLeaderboardDescription = describeRoute({ }, }, }, + 500: { + description: "Internal Server Error", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, }, }) diff --git a/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardRouteValidations.ts b/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardRouteValidations.ts index 1e9dc7b300..b16dfbc4eb 100644 --- a/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardRouteValidations.ts +++ b/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardRouteValidations.ts @@ -1,5 +1,18 @@ -import { validator as zValidator } from "hono-openapi/zod" -import { LeaderboardGameIdParamSchema, LeaderboardLimitQuerySchema } from "./leaderBoardSchema" +import { resolver, validator as zValidator } from "hono-openapi/zod" +import { createSuccessResponseSchema } from "../common" +import { + GameGuildLeaderboardEntrySchema, + GameLeaderboardEntrySchema, + GlobalLeaderboardEntrySchema, + GuildLeaderboardEntrySchema, + LeaderboardGameIdParamSchema, + LeaderboardLimitQuerySchema, +} from "./leaderBoardSchema" + +export const GlobalLeaderBoardResponseObj = resolver(createSuccessResponseSchema(GlobalLeaderboardEntrySchema)) +export const GuildLeaderBoardResponseObj = resolver(createSuccessResponseSchema(GuildLeaderboardEntrySchema)) +export const GameLeaderBoardResponseObj = resolver(createSuccessResponseSchema(GameLeaderboardEntrySchema)) +export const GameGuildLeaderBoardResponseObj = resolver(createSuccessResponseSchema(GameGuildLeaderboardEntrySchema)) export const GlobalLeaderboardValidation = zValidator("query", LeaderboardLimitQuerySchema) export const GuildLeaderboardValidation = zValidator("query", LeaderboardLimitQuerySchema) diff --git a/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardSchema.ts b/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardSchema.ts index b3e5bd870e..546acdfff4 100644 --- a/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardSchema.ts +++ b/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardSchema.ts @@ -1,99 +1,102 @@ import { z } from "@hono/zod-openapi" -import { resolver } from "hono-openapi/zod" import { isHex } from "viem" // ==================================================================================================== // Response Schemas -const GlobalLeaderboardEntrySchema = z - .object({ - user_id: z.number().int(), - username: z.string(), // is unique (enforced in db) - primary_wallet: z.string().refine(isHex), - total_score: z.number().int(), - }) - .strict() - .openapi({ - example: { - user_id: 1, - username: "player1", - primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", - total_score: 5000, - }, - }) +export const GlobalLeaderboardEntrySchema = z.array( + z + .object({ + user_id: z.number().int(), + username: z.string(), // is unique (enforced in db) + primary_wallet: z.string().refine(isHex), + total_score: z.number().int(), + }) + .strict() + .openapi({ + example: { + user_id: 1, + username: "player1", + primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", + total_score: 5000, + }, + }), +) -const GuildLeaderboardEntrySchema = z - .object({ - guild_id: z.number().int(), - guild_name: z.string(), - icon_url: z.string().nullable(), - total_score: z.number().int(), - member_count: z.number().int(), - }) - .strict() - .openapi({ - example: { - guild_id: 1, - guild_name: "Alpha Guild", - icon_url: "https://example.com/icon.png", - total_score: 12000, - member_count: 5, - }, - }) +export const GuildLeaderboardEntrySchema = z.array( + z + .object({ + guild_id: z.number().int(), + guild_name: z.string(), + icon_url: z.string().nullable(), + total_score: z.number().int(), + member_count: z.number().int(), + }) + .strict() + .openapi({ + example: { + guild_id: 1, + guild_name: "Alpha Guild", + icon_url: "https://example.com/icon.png", + total_score: 12000, + member_count: 5, + }, + }), +) -const GameLeaderboardEntrySchema = z - .object({ - game_id: z.number().int(), - user_id: z.number().int(), - username: z.string(), - primary_wallet: z.string().refine(isHex), - score: z.number().int(), - }) - .strict() - .openapi({ - example: { - game_id: 42, - user_id: 2, - username: "player2", - primary_wallet: "0x1234567890abcdef1234567890abcdef12345678", - score: 3000, - }, - }) +export const GameLeaderboardEntrySchema = z.array( + z + .object({ + game_id: z.number().int(), + user_id: z.number().int(), + username: z.string(), + primary_wallet: z.string().refine(isHex), + score: z.number().int(), + }) + .strict() + .openapi({ + example: { + game_id: 42, + user_id: 2, + username: "player2", + primary_wallet: "0x1234567890abcdef1234567890abcdef12345678", + score: 3000, + }, + }), +) -const GameGuildLeaderboardEntrySchema = z - .object({ - game_id: z.number().int(), - guild_id: z.number().int(), - guild_name: z.string(), - icon_url: z.string().nullable(), - total_score: z.number().int(), - member_count: z.number().int(), - }) - .strict() - .openapi({ - example: { - game_id: 42, - guild_id: 2, - guild_name: "Beta Guild", - icon_url: "https://example.com/beta-icon.png", - total_score: 8000, - member_count: 3, - }, - }) - -export const GlobalLeaderBoardResponseObj = resolver(z.array(GlobalLeaderboardEntrySchema)) - -export const GuildLeaderBoardResponseObj = resolver(z.array(GuildLeaderboardEntrySchema)) - -export const GameLeaderBoardResponseObj = resolver(z.array(GameLeaderboardEntrySchema)) - -export const GameGuildLeaderBoardResponseObj = resolver(z.array(GameGuildLeaderboardEntrySchema)) +export const GameGuildLeaderboardEntrySchema = z.array( + z + .object({ + game_id: z.number().int(), + guild_id: z.number().int(), + guild_name: z.string(), + icon_url: z.string().nullable(), + total_score: z.number().int(), + member_count: z.number().int(), + }) + .strict() + .openapi({ + example: { + game_id: 42, + guild_id: 2, + guild_name: "Beta Guild", + icon_url: "https://example.com/beta-icon.png", + total_score: 8000, + member_count: 3, + }, + }), +) // ==================================================================================================== // Request Param/Query Schemas export const LeaderboardLimitQuerySchema = z.object({ - limit: z.string().regex(/^\d+$/, { message: "Must be a positive integer string" }).default("50"), + limit: z + .string() + .transform((val) => Number.parseInt(val)) + .default("50"), + // regex(/^\d+$/, { message: "Must be a positive integer string" }).default("50"), }) export const LeaderboardGameIdParamSchema = z.object({ diff --git a/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts index f6bfe97a30..2356271996 100644 --- a/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts @@ -1,6 +1,10 @@ import { describeRoute } from "hono-openapi" import { ErrorResponseSchemaObj } from "../common/responseSchemas" -import { UserListResponseSchemaObj, UserResponseSchemaObj, UserWalletListResponseSchemaObj } from "./userSchemas" +import { + UserListResponseSchemaObj, + UserResponseSchemaObj, + UserWalletListResponseSchemaObj, +} from "./userRouteValidations" // ==================================================================================================== // User Collection diff --git a/apps/leaderboard-backend/src/validation/users/userRouteValidations.ts b/apps/leaderboard-backend/src/validation/users/userRouteValidations.ts index 2720656a6d..ba4a78bb28 100644 --- a/apps/leaderboard-backend/src/validation/users/userRouteValidations.ts +++ b/apps/leaderboard-backend/src/validation/users/userRouteValidations.ts @@ -1,21 +1,23 @@ -import { validator as zValidator } from "hono-openapi/zod" +import { resolver, validator as zValidator } from "hono-openapi/zod" +import { createSuccessResponseSchema } from "../common" import { PrimaryWalletParamSchema, UserCreateRequestSchema, UserIdParamSchema, UserQuerySchema, + UserResponseSchema, UserUpdateRequestSchema, + UserWalletListSchema, UserWalletRequestSchema, } from "./userSchemas" -export const UserQueryValidation = zValidator("query", UserQuerySchema) +export const UserResponseSchemaObj = resolver(createSuccessResponseSchema(UserResponseSchema)) +export const UserListResponseSchemaObj = resolver(createSuccessResponseSchema(UserResponseSchema)) +export const UserWalletListResponseSchemaObj = resolver(createSuccessResponseSchema(UserWalletListSchema)) +export const UserQueryValidation = zValidator("query", UserQuerySchema) export const UserCreateValidation = zValidator("json", UserCreateRequestSchema) - export const UserUpdateValidation = zValidator("json", UserUpdateRequestSchema) - export const UserWalletValidation = zValidator("json", UserWalletRequestSchema) - export const UserIdParamValidation = zValidator("param", UserIdParamSchema) - export const PrimaryWalletParamValidation = zValidator("param", PrimaryWalletParamSchema) diff --git a/apps/leaderboard-backend/src/validation/users/userSchemas.ts b/apps/leaderboard-backend/src/validation/users/userSchemas.ts index 5fa977db90..4bfd88d289 100644 --- a/apps/leaderboard-backend/src/validation/users/userSchemas.ts +++ b/apps/leaderboard-backend/src/validation/users/userSchemas.ts @@ -1,7 +1,5 @@ import { z } from "@hono/zod-openapi" -import { resolver } from "hono-openapi/zod" import { isHex } from "viem" -import { createSuccessResponseSchema } from "../common/responseSchemas" // ==================================================================================================== // Response Schemas @@ -25,6 +23,8 @@ const UserWalletSchema = z }, }) +export const UserWalletListSchema = z.array(UserWalletSchema) + export const UserResponseSchema = z .object({ id: z.number().int(), @@ -32,7 +32,7 @@ export const UserResponseSchema = z username: z.string(), created_at: z.string(), updated_at: z.string(), - wallets: z.array(UserWalletSchema).optional(), + wallets: UserWalletListSchema.optional(), }) .strict() .openapi({ @@ -54,15 +54,7 @@ export const UserResponseSchema = z }, }) -// Create wrapped schemas with standard format -const WrappedUserResponseSchema = createSuccessResponseSchema(UserResponseSchema) -const WrappedUserListResponseSchema = createSuccessResponseSchema(z.array(UserResponseSchema)) -const WrappedUserWalletListResponseSchema = createSuccessResponseSchema(z.array(UserWalletSchema)) - -// Export resolved schemas for OpenAPI -export const UserResponseSchemaObj = resolver(WrappedUserResponseSchema) -export const UserListResponseSchemaObj = resolver(WrappedUserListResponseSchema) -export const UserWalletListResponseSchemaObj = resolver(WrappedUserWalletListResponseSchema) +export const UserListSchema = z.array(UserResponseSchema) // ==================================================================================================== // Request Body Schemas From f579d2ecad3de7c36b8f1fea13ad2f16fce842a6 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Tue, 20 May 2025 20:55:31 +0530 Subject: [PATCH 09/23] refactor: move ID parsing from routes to validation schemas and add error handling [skip ci] --- .../src/routes/api/authRoutes.ts | 233 ++++--- .../src/routes/api/gamesRoutes.ts | 10 +- .../src/routes/api/guildsRoutes.ts | 16 +- .../src/routes/api/leaderboardRoutes.ts | 12 +- .../src/routes/api/usersRoutes.ts | 595 ++++++++++-------- .../src/validation/games/gameSchemas.ts | 4 +- .../src/validation/guilds/guildSchemas.ts | 4 +- .../leaderboard/leaderBoardSchema.ts | 3 +- .../src/validation/users/userSchemas.ts | 2 +- 9 files changed, 493 insertions(+), 386 deletions(-) diff --git a/apps/leaderboard-backend/src/routes/api/authRoutes.ts b/apps/leaderboard-backend/src/routes/api/authRoutes.ts index f73a3e5f73..38fd7d2cc4 100644 --- a/apps/leaderboard-backend/src/routes/api/authRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/authRoutes.ts @@ -20,16 +20,21 @@ export default new Hono() * POST /auth/challenge */ .post("/challenge", AuthChallengeDescription, AuthChallengeValidation, async (c) => { - const { primary_wallet } = c.req.valid("json") - // Use a hardcoded, Ethereum-style message for leaderboard authentication - const message = `\x19Leaderboard Signed Message:\nHappyChain Leaderboard Authentication Request for ${primary_wallet}` - return c.json({ - ok: true, - data: { - message, - primary_wallet, - }, - }) + try { + const { primary_wallet } = c.req.valid("json") + // Use a hardcoded, Ethereum-style message for leaderboard authentication + const message = `\x19Leaderboard Signed Message:\nHappyChain Leaderboard Authentication Request for ${primary_wallet}` + return c.json({ + ok: true, + data: { + message, + primary_wallet, + }, + }) + } catch (err) { + console.error("Error generating auth challenge:", err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }) /** @@ -37,48 +42,53 @@ export default new Hono() * POST /auth/verify */ .post("/verify", AuthVerifyDescription, AuthVerifyValidation, async (c) => { - const { primary_wallet, message, signature } = c.req.valid("json") - const { authRepo, userRepo } = c.get("repos") - - // Verify signature directly using the utility function - const isValid = await verifySignature(primary_wallet as Address, message as Hex, signature as Hex) - - if (!isValid) { - return c.json({ ok: false, error: "Invalid signature" }, 401) - } - - const user = await userRepo.findByWalletAddress(primary_wallet, true) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } - - const session = await authRepo.createSession(user.id, primary_wallet) - if (!session) { - return c.json({ ok: false, error: "Failed to create session" }, 500) - } - - setCookie(c, "session_id", session.id, { - httpOnly: true, - secure: true, - domain: "localhost", - sameSite: "Lax", - path: "/", - maxAge: Number.parseInt(env.SESSION_EXPIRY, 10), - }) - - // Return success with session ID and user info - return c.json({ - ok: true, - data: { - session_id: session.id, - user: { - id: user.id, - username: user.username, - primary_wallet: user.primary_wallet, - wallets: user.wallets, + try { + const { primary_wallet, message, signature } = c.req.valid("json") + const { authRepo, userRepo } = c.get("repos") + + // Verify signature directly using the utility function + const isValid = await verifySignature(primary_wallet as Address, message as Hex, signature as Hex) + + if (!isValid) { + return c.json({ ok: false, error: "Invalid signature" }, 401) + } + + const user = await userRepo.findByWalletAddress(primary_wallet, true) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } + + const session = await authRepo.createSession(user.id, primary_wallet) + if (!session) { + return c.json({ ok: false, error: "Failed to create session" }, 500) + } + + setCookie(c, "session_id", session.id, { + httpOnly: true, + secure: true, + domain: "localhost", + sameSite: "Lax", + path: "/", + maxAge: Number.parseInt(env.SESSION_EXPIRY, 10), + }) + + // Return success with session ID and user info + return c.json({ + ok: true, + data: { + session_id: session.id, + user: { + id: user.id, + username: user.username, + primary_wallet: user.primary_wallet, + wallets: user.wallets, + }, }, - }, - }) + }) + } catch (err) { + console.error("Error verifying auth signature:", err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }) /** @@ -87,27 +97,32 @@ export default new Hono() * @security BearerAuth */ .get("/me", AuthMeDescription, requireAuth, async (c) => { - const { userRepo } = c.get("repos") - const userId = c.get("userId") - - const user = await userRepo.findById(userId) - - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } - - return c.json({ - ok: true, - data: { - session_id: c.get("sessionId") as string, - user: { - id: user.id, - username: user.username, - primary_wallet: user.primary_wallet, - wallets: user.wallets, + try { + const { userRepo } = c.get("repos") + const userId = c.get("userId") + + const user = await userRepo.findById(userId) + + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } + + return c.json({ + ok: true, + data: { + session_id: c.get("sessionId") as string, + user: { + id: user.id, + username: user.username, + primary_wallet: user.primary_wallet, + wallets: user.wallets, + }, }, - }, - }) + }) + } catch (err) { + console.error("Error retrieving user session info:", err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }) /** @@ -116,28 +131,33 @@ export default new Hono() * @security BearerAuth */ .post("/logout", AuthLogoutDescription, requireAuth, async (c) => { - const sessionId = c.get("sessionId") - const { authRepo } = c.get("repos") - - const success = await authRepo.deleteSession(sessionId as AuthSessionTableId) - - if (!success) { - return c.json({ ok: false, error: "Failed to delete session" }, 500) + try { + const sessionId = c.get("sessionId") + const { authRepo } = c.get("repos") + + const success = await authRepo.deleteSession(sessionId as AuthSessionTableId) + + if (!success) { + return c.json({ ok: false, error: "Failed to delete session" }, 500) + } + + // Delete the session cookie + deleteCookie(c, "session_id", { + path: "/", + secure: true, + domain: "localhost", + }) + + return c.json({ + ok: true, + data: { + message: "Logged out successfully", + }, + }) + } catch (err) { + console.error("Error logging out user:", err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) } - - // Delete the session cookie - deleteCookie(c, "session_id", { - path: "/", - secure: true, - domain: "localhost", - }) - - return c.json({ - ok: true, - data: { - message: "Logged out successfully", - }, - }) }) /** @@ -146,16 +166,21 @@ export default new Hono() * @security BearerAuth */ .get("/sessions", AuthSessionsDescription, requireAuth, async (c) => { - const { authRepo } = c.get("repos") - const userId = c.get("userId") - - const sessions = await authRepo.getUserSessions(userId) - - return c.json({ - ok: true, - data: sessions.map((s) => ({ - ...s, - is_current: s.id === c.get("sessionId"), - })), - }) + try { + const { authRepo } = c.get("repos") + const userId = c.get("userId") + + const sessions = await authRepo.getUserSessions(userId) + + return c.json({ + ok: true, + data: sessions.map((s) => ({ + ...s, + is_current: s.id === c.get("sessionId"), + })), + }) + } catch (err) { + console.error("Error retrieving user sessions:", err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }) diff --git a/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts b/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts index 7bd993bfed..9813e41661 100644 --- a/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts @@ -56,7 +56,7 @@ export default new Hono() const { id } = c.req.valid("param") const { gameRepo } = c.get("repos") - const gameId = Number.parseInt(id, 10) as GameTableId + const gameId = id as GameTableId const game = await gameRepo.findById(gameId) if (!game) { return c.json({ ok: false, error: "Game not found" }, 404) @@ -153,7 +153,7 @@ export default new Hono() const { gameRepo } = c.get("repos") // Check if game exists - const gameId = Number.parseInt(id, 10) as GameTableId + const gameId = id as GameTableId const game = await gameRepo.findById(gameId) if (!game) { return c.json({ ok: false, error: "Game not found" }, 404) @@ -197,7 +197,7 @@ export default new Hono() const { gameRepo, userRepo } = c.get("repos") // Check if game exists - const gameId = Number.parseInt(id, 10) as GameTableId + const gameId = id as GameTableId const game = await gameRepo.findById(gameId) if (!game) { return c.json({ ok: false, error: "Game not found" }, 404) @@ -233,7 +233,7 @@ export default new Hono() const { gameRepo } = c.get("repos") // Check if game exists - const gameId = Number.parseInt(id, 10) as GameTableId + const gameId = id as GameTableId const game = await gameRepo.findById(gameId) if (!game) { return c.json({ ok: false, error: "Game not found" }, 404) @@ -264,7 +264,7 @@ export default new Hono() const { gameRepo, userRepo } = c.get("repos") // Check if game exists - const gameId = Number.parseInt(id, 10) as GameTableId + const gameId = id as GameTableId const game = await gameRepo.findById(gameId) if (!game) { return c.json({ ok: false, error: "Game not found" }, 404) diff --git a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts index 398c2afd09..4ac1c10cd1 100644 --- a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts @@ -93,7 +93,7 @@ export default new Hono() const { guildRepo } = c.get("repos") const includeMembers = c.req.query("include_members") === "true" - const guildId = Number.parseInt(id, 10) as GuildTableId + const guildId = id as GuildTableId const guild = await guildRepo.findById(guildId, includeMembers) if (!guild) { return c.json({ ok: false, error: "Guild not found" }, 404) @@ -127,7 +127,7 @@ export default new Hono() const { guildRepo } = c.get("repos") // Check if guild exists - const guildId = Number.parseInt(id, 10) as GuildTableId + const guildId = id as GuildTableId const guild = await guildRepo.findById(guildId) if (!guild) { return c.json({ ok: false, error: "Guild not found" }, 404) @@ -164,7 +164,7 @@ export default new Hono() const { guildRepo } = c.get("repos") // Check if guild exists - const guildId = Number.parseInt(id, 10) as GuildTableId + const guildId = id as GuildTableId const guild = await guildRepo.findById(guildId) if (!guild) { return c.json({ ok: false, error: "Guild not found" }, 404) @@ -202,7 +202,7 @@ export default new Hono() const { guildRepo, userRepo } = c.get("repos") // Ensure guild exists - const guildId = Number.parseInt(id, 10) as GuildTableId + const guildId = id as GuildTableId const guild = await guildRepo.findById(guildId) if (!guild) { return c.json({ ok: false, error: "Guild not found" }, 404) @@ -267,8 +267,8 @@ export default new Hono() const { guildRepo } = c.get("repos") // Check if guild exists - const guildId = Number.parseInt(id, 10) as GuildTableId - const userId = Number.parseInt(member_id, 10) as UserTableId + const guildId = id as GuildTableId + const userId = member_id as UserTableId const guild = await guildRepo.findById(guildId) if (!guild) { return c.json({ ok: false, error: "Guild not found" }, 404) @@ -314,8 +314,8 @@ export default new Hono() const { guildRepo } = c.get("repos") // Check if guild exists - const guildId = Number.parseInt(id, 10) as GuildTableId - const userId = Number.parseInt(member_id, 10) as UserTableId + const guildId = id as GuildTableId + const userId = member_id as UserTableId const guild = await guildRepo.findById(guildId) if (!guild) { return c.json({ ok: false, error: "Guild not found" }, 404) diff --git a/apps/leaderboard-backend/src/routes/api/leaderboardRoutes.ts b/apps/leaderboard-backend/src/routes/api/leaderboardRoutes.ts index 3be4f01cf1..2e06ae7d23 100644 --- a/apps/leaderboard-backend/src/routes/api/leaderboardRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/leaderboardRoutes.ts @@ -24,7 +24,7 @@ export default new Hono() const { limit } = c.req.valid("query") const { leaderboardRepo } = c.get("repos") - const leaderboard = await leaderboardRepo.getGlobalLeaderboard(Number.parseInt(limit, 10)) + const leaderboard = await leaderboardRepo.getGlobalLeaderboard(limit) return c.json({ ok: true, data: leaderboard, @@ -44,7 +44,7 @@ export default new Hono() const { limit } = c.req.valid("query") const { leaderboardRepo } = c.get("repos") - const leaderboard = await leaderboardRepo.getGuildLeaderboard(Number.parseInt(limit, 10)) + const leaderboard = await leaderboardRepo.getGuildLeaderboard(limit) return c.json({ ok: true, data: leaderboard, @@ -71,13 +71,13 @@ export default new Hono() const { leaderboardRepo, gameRepo } = c.get("repos") // Check if game exists - const gameId = Number.parseInt(id, 10) as GameTableId + const gameId = id as GameTableId const game = await gameRepo.findById(gameId) if (!game) { return c.json({ ok: false, error: "Game not found" }, 404) } - const leaderboard = await leaderboardRepo.getGameLeaderboard(gameId, Number.parseInt(limit, 10)) + const leaderboard = await leaderboardRepo.getGameLeaderboard(gameId, limit) return c.json({ ok: true, data: leaderboard, @@ -105,13 +105,13 @@ export default new Hono() const { leaderboardRepo, gameRepo } = c.get("repos") // Check if game exists - const gameId = Number.parseInt(id, 10) as GameTableId + const gameId = id as GameTableId const game = await gameRepo.findById(gameId) if (!game) { return c.json({ ok: false, error: "Game not found" }, 404) } - const leaderboard = await leaderboardRepo.getGameGuildLeaderboard(gameId, Number.parseInt(limit, 10)) + const leaderboard = await leaderboardRepo.getGameGuildLeaderboard(gameId, limit) return c.json({ ok: true, data: leaderboard, diff --git a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts index c6a9f7fa2e..5bb0a77aa7 100644 --- a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts @@ -37,16 +37,21 @@ export default new Hono() * GET /users */ .get("/", UserQueryDescription, UserQueryValidation, async (c) => { - const query = c.req.valid("query") - const { userRepo } = c.get("repos") + try { + const query = c.req.valid("query") + const { userRepo } = c.get("repos") - const users = await userRepo.find({ - primary_wallet: query.primary_wallet, - username: query.username, - includeWallets: query.include_wallets, - }) + const users = await userRepo.find({ + primary_wallet: query.primary_wallet, + username: query.username, + includeWallets: query.include_wallets, + }) - return c.json({ ok: true, data: users }, 200) + return c.json({ ok: true, data: users }, 200) + } catch (err) { + console.error("Error listing users:", err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }) /** @@ -54,26 +59,31 @@ export default new Hono() * POST /users */ .post("/", UserCreateDescription, UserCreateValidation, async (c) => { - const userData = c.req.valid("json") - const { userRepo } = c.get("repos") + try { + const userData = c.req.valid("json") + const { userRepo } = c.get("repos") - // Check if user with this wallet or username already exists - const existingByWallet = await userRepo.findByWalletAddress(userData.primary_wallet) - if (existingByWallet) { - return c.json({ ok: false, error: "Wallet address already registered" }, 409) - } + // Check if user with this wallet or username already exists + const existingByWallet = await userRepo.findByWalletAddress(userData.primary_wallet) + if (existingByWallet) { + return c.json({ ok: false, error: "Wallet address already registered" }, 409) + } - const existingByUsername = await userRepo.findByUsername(userData.username) - if (existingByUsername) { - return c.json({ ok: false, error: "Username already taken" }, 409) - } + const existingByUsername = await userRepo.findByUsername(userData.username) + if (existingByUsername) { + return c.json({ ok: false, error: "Username already taken" }, 409) + } - const newUser = await userRepo.create({ - primary_wallet: userData.primary_wallet, - username: userData.username, - }) + const newUser = await userRepo.create({ + primary_wallet: userData.primary_wallet, + username: userData.username, + }) - return c.json({ ok: true, data: newUser }, 201) + return c.json({ ok: true, data: newUser }, 201) + } catch (err) { + console.error("Error creating user:", err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }) // ==================================================================================================== @@ -84,16 +94,21 @@ export default new Hono() * GET /users/:id */ .get("/:id", UserGetByIdDescription, UserIdParamValidation, async (c) => { - const { id } = c.req.valid("param") - const { userRepo } = c.get("repos") + try { + const { id } = c.req.valid("param") + const { userRepo } = c.get("repos") - const userTableId = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userTableId, true) // Include wallets - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + const userTableId = id as UserTableId + const user = await userRepo.findById(userTableId, true) // Include wallets + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - return c.json({ ok: true, data: user }, 200) + return c.json({ ok: true, data: user }, 200) + } catch (err) { + console.error(`Error fetching user ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }) /** @@ -110,27 +125,32 @@ export default new Hono() UserIdParamValidation, UserUpdateValidation, async (c) => { - const { id } = c.req.valid("param") - const updateData = c.req.valid("json") - const { userRepo } = c.get("repos") - - // Check if user exists - const userId = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + try { + const { id } = c.req.valid("param") + const updateData = c.req.valid("json") + const { userRepo } = c.get("repos") + + // Check if user exists + const userId = id as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Check if username is being changed and is unique - if (updateData.username && updateData.username !== user.username) { - const existingUser = await userRepo.findByUsername(updateData.username) - if (existingUser) { - return c.json({ ok: false, error: "Username already taken" }, 409) + // Check if username is being changed and is unique + if (updateData.username && updateData.username !== user.username) { + const existingUser = await userRepo.findByUsername(updateData.username) + if (existingUser) { + return c.json({ ok: false, error: "Username already taken" }, 409) + } } - } - const updatedUser = await userRepo.update(userId, updateData) - return c.json({ ok: true, data: updatedUser }, 200) + const updatedUser = await userRepo.update(userId, updateData) + return c.json({ ok: true, data: updatedUser }, 200) + } catch (err) { + console.error(`Error updating user ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }, ) @@ -147,22 +167,27 @@ export default new Hono() UserDeleteByIdDescription, UserIdParamValidation, async (c) => { - const { id } = c.req.valid("param") - const { userRepo } = c.get("repos") + try { + const { id } = c.req.valid("param") + const { userRepo } = c.get("repos") + + // Check if user exists + const userId = id as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Check if user exists - const userId = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + const success = await userRepo.delete(userId) + if (!success) { + return c.json({ ok: false, error: "User not found" }, 404) + } - const success = await userRepo.delete(userId) - if (!success) { - return c.json({ ok: false, error: "User not found" }, 404) + return c.json({ ok: true, data: user }, 200) + } catch (err) { + console.error(`Error deleting user ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) } - - return c.json({ ok: true, data: user }, 200) }, ) @@ -174,15 +199,20 @@ export default new Hono() * GET /users/pw/:primary_wallet */ .get("/pw/:primary_wallet", UserGetByPrimaryWalletDescription, PrimaryWalletParamValidation, async (c) => { - const { primary_wallet } = c.req.valid("param") - const { userRepo } = c.get("repos") + try { + const { primary_wallet } = c.req.valid("param") + const { userRepo } = c.get("repos") - const user = await userRepo.findByWalletAddress(primary_wallet, true) // Include wallets - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + const user = await userRepo.findByWalletAddress(primary_wallet, true) // Include wallets + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - return c.json({ ok: true, data: user }, 200) + return c.json({ ok: true, data: user }, 200) + } catch (err) { + console.error(`Error fetching user by wallet ${c.req.param("primary_wallet")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }) /** @@ -199,26 +229,31 @@ export default new Hono() PrimaryWalletParamValidation, UserUpdateValidation, async (c) => { - const { primary_wallet } = c.req.valid("param") - const updateData = c.req.valid("json") - const { userRepo } = c.get("repos") - - // Check if user exists - const user = await userRepo.findByWalletAddress(primary_wallet) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + try { + const { primary_wallet } = c.req.valid("param") + const updateData = c.req.valid("json") + const { userRepo } = c.get("repos") + + // Check if user exists + const user = await userRepo.findByWalletAddress(primary_wallet) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Check if username is being changed and is unique - if (updateData.username && updateData.username !== user.username) { - const existingUser = await userRepo.findByUsername(updateData.username) - if (existingUser) { - return c.json({ ok: false, error: "Username already taken" }, 409) + // Check if username is being changed and is unique + if (updateData.username && updateData.username !== user.username) { + const existingUser = await userRepo.findByUsername(updateData.username) + if (existingUser) { + return c.json({ ok: false, error: "Username already taken" }, 409) + } } - } - const updatedUser = await userRepo.update(user.id, updateData) - return c.json({ ok: true, data: updatedUser }, 200) + const updatedUser = await userRepo.update(user.id, updateData) + return c.json({ ok: true, data: updatedUser }, 200) + } catch (err) { + console.error(`Error updating user with wallet ${c.req.param("primary_wallet")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }, ) @@ -235,21 +270,26 @@ export default new Hono() UserDeleteByPrimaryWalletDescription, PrimaryWalletParamValidation, async (c) => { - const { primary_wallet } = c.req.valid("param") - const { userRepo } = c.get("repos") + try { + const { primary_wallet } = c.req.valid("param") + const { userRepo } = c.get("repos") + + // Check if user exists + const user = await userRepo.findByWalletAddress(primary_wallet) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Check if user exists - const user = await userRepo.findByWalletAddress(primary_wallet) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + const success = await userRepo.delete(user.id) + if (!success) { + return c.json({ ok: false, error: "User not found" }, 404) + } - const success = await userRepo.delete(user.id) - if (!success) { - return c.json({ ok: false, error: "User not found" }, 404) + return c.json({ ok: true, data: user }, 200) + } catch (err) { + console.error(`Error deleting user with wallet ${c.req.param("primary_wallet")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) } - - return c.json({ ok: true, data: user }, 200) }, ) @@ -261,18 +301,23 @@ export default new Hono() * GET /users/:id/wallets */ .get("/:id/wallets", UserWalletsGetByIdDescription, UserIdParamValidation, async (c) => { - const { id } = c.req.valid("param") - const { userRepo } = c.get("repos") - - // Check if user exists - const userId = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + try { + const { id } = c.req.valid("param") + const { userRepo } = c.get("repos") - const wallets = await userRepo.getUserWallets(userId) - return c.json({ ok: true, data: wallets }, 200) + // Check if user exists + const userId = id as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } + + const wallets = await userRepo.getUserWallets(userId) + return c.json({ ok: true, data: wallets }, 200) + } catch (err) { + console.error(`Error fetching wallets for user ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }) /** @@ -284,17 +329,22 @@ export default new Hono() UserWalletsGetByPrimaryWalletDescription, PrimaryWalletParamValidation, async (c) => { - const { primary_wallet } = c.req.valid("param") - const { userRepo } = c.get("repos") + try { + const { primary_wallet } = c.req.valid("param") + const { userRepo } = c.get("repos") + + // Check if user exists + const user = await userRepo.findByWalletAddress(primary_wallet) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Check if user exists - const user = await userRepo.findByWalletAddress(primary_wallet) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) + const wallets = await userRepo.getUserWallets(user.id) + return c.json({ ok: true, data: wallets }, 200) + } catch (err) { + console.error(`Error fetching wallets for user with wallet ${c.req.param("primary_wallet")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) } - - const wallets = await userRepo.getUserWallets(user.id) - return c.json({ ok: true, data: wallets }, 200) }, ) @@ -312,31 +362,36 @@ export default new Hono() UserIdParamValidation, UserWalletValidation, async (c) => { - const { id } = c.req.valid("param") - const { wallet_address } = c.req.valid("json") - const { userRepo } = c.get("repos") + try { + const { id } = c.req.valid("param") + const { wallet_address } = c.req.valid("json") + const { userRepo } = c.get("repos") + + // Check if user exists + const userId = id as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Check if user exists - const userId = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + // Check if wallet already belongs to another user + const existingUser = await userRepo.findByWalletAddress(wallet_address) + if (existingUser && existingUser.id !== userId) { + return c.json({ ok: false, error: "Wallet already registered to another user" }, 409) + } - // Check if wallet already belongs to another user - const existingUser = await userRepo.findByWalletAddress(wallet_address) - if (existingUser && existingUser.id !== userId) { - return c.json({ ok: false, error: "Wallet already registered to another user" }, 409) - } + const success = await userRepo.addWallet(userId, wallet_address) + if (!success) { + return c.json({ ok: false, error: "Wallet already exists for this user" }, 409) + } - const success = await userRepo.addWallet(userId, wallet_address) - if (!success) { - return c.json({ ok: false, error: "Wallet already exists for this user" }, 409) + // Get updated user with wallets + const updatedUser = await userRepo.findById(userId, true) + return c.json({ ok: true, data: updatedUser }, 200) + } catch (err) { + console.error(`Error adding wallet to user ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) } - - // Get updated user with wallets - const updatedUser = await userRepo.findById(userId, true) - return c.json({ ok: true, data: updatedUser }, 200) }, ) @@ -354,34 +409,39 @@ export default new Hono() PrimaryWalletParamValidation, UserWalletValidation, async (c) => { - const { primary_wallet } = c.req.valid("param") - const { wallet_address } = c.req.valid("json") - const { userRepo } = c.get("repos") + try { + const { primary_wallet } = c.req.valid("param") + const { wallet_address } = c.req.valid("json") + const { userRepo } = c.get("repos") - if (primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Primary wallet already exists for this user" }, 400) - } + if (primary_wallet === wallet_address) { + return c.json({ ok: false, error: "Primary wallet already exists for this user" }, 400) + } - // Check if user exists - const user = await userRepo.findByWalletAddress(primary_wallet) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + // Check if user exists + const user = await userRepo.findByWalletAddress(primary_wallet) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Check if wallet already belongs to another user - const existingUser = await userRepo.findByWalletAddress(wallet_address) - if (existingUser && existingUser.id !== user.id) { - return c.json({ ok: false, error: "Wallet already registered to another user" }, 409) - } + // Check if wallet already belongs to another user + const existingUser = await userRepo.findByWalletAddress(wallet_address) + if (existingUser && existingUser.id !== user.id) { + return c.json({ ok: false, error: "Wallet already registered to another user" }, 409) + } - const success = await userRepo.addWallet(user.id, wallet_address) - if (!success) { - return c.json({ ok: false, error: "Wallet already exists for this user" }, 409) - } + const success = await userRepo.addWallet(user.id, wallet_address) + if (!success) { + return c.json({ ok: false, error: "Wallet already exists for this user" }, 409) + } - // Get updated user with wallets - const updatedUser = await userRepo.findById(user.id, true) - return c.json({ ok: true, data: updatedUser }, 200) + // Get updated user with wallets + const updatedUser = await userRepo.findById(user.id, true) + return c.json({ ok: true, data: updatedUser }, 200) + } catch (err) { + console.error(`Error adding wallet to user with wallet ${c.req.param("primary_wallet")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }, ) @@ -402,28 +462,33 @@ export default new Hono() UserIdParamValidation, UserWalletValidation, async (c) => { - const { id } = c.req.valid("param") - const { wallet_address } = c.req.valid("json") - const { userRepo } = c.get("repos") + try { + const { id } = c.req.valid("param") + const { wallet_address } = c.req.valid("json") + const { userRepo } = c.get("repos") + + // Check if user exists + const userId = id as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } + if (user.primary_wallet === wallet_address) { + return c.json({ ok: false, error: "Wallet is already primary" }, 400) + } - // Check if user exists - const userId = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } - if (user.primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Wallet is already primary" }, 400) - } + const success = await userRepo.setWalletAsPrimary(userId, wallet_address) + if (!success) { + return c.json({ ok: false, error: "Wallet not found for this user" }, 404) + } - const success = await userRepo.setWalletAsPrimary(userId, wallet_address) - if (!success) { - return c.json({ ok: false, error: "Wallet not found for this user" }, 404) + // Get updated user with wallets + const updatedUser = await userRepo.findById(userId, true) + return c.json({ ok: true, data: updatedUser }, 200) + } catch (err) { + console.error(`Error setting primary wallet for user ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) } - - // Get updated user with wallets - const updatedUser = await userRepo.findById(userId, true) - return c.json({ ok: true, data: updatedUser }, 200) }, ) @@ -441,31 +506,39 @@ export default new Hono() PrimaryWalletParamValidation, UserWalletValidation, async (c) => { - const { primary_wallet } = c.req.valid("param") - const { wallet_address } = c.req.valid("json") - const { userRepo } = c.get("repos") + try { + const { primary_wallet } = c.req.valid("param") + const { wallet_address } = c.req.valid("json") + const { userRepo } = c.get("repos") - if (primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Primary wallet cannot be set as primary" }, 400) - } + if (primary_wallet === wallet_address) { + return c.json({ ok: false, error: "Primary wallet cannot be set as primary" }, 400) + } - // Check if user exists - const user = await userRepo.findByWalletAddress(primary_wallet) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } - if (user.primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Wallet is already primary" }, 400) - } + // Check if user exists + const user = await userRepo.findByWalletAddress(primary_wallet) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } + if (user.primary_wallet === wallet_address) { + return c.json({ ok: false, error: "Wallet is already primary" }, 400) + } - const success = await userRepo.setWalletAsPrimary(user.id, wallet_address) - if (!success) { - return c.json({ ok: false, error: "Wallet not found for this user" }, 404) - } + const success = await userRepo.setWalletAsPrimary(user.id, wallet_address) + if (!success) { + return c.json({ ok: false, error: "Wallet not found for this user" }, 404) + } - // Get updated user with wallets - const updatedUser = await userRepo.findById(user.id, true) - return c.json({ ok: true, data: updatedUser }, 200) + // Get updated user with wallets + const updatedUser = await userRepo.findById(user.id, true) + return c.json({ ok: true, data: updatedUser }, 200) + } catch (err) { + console.error( + `Error setting primary wallet for user with wallet ${c.req.param("primary_wallet")}:`, + err, + ) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }, ) @@ -483,35 +556,40 @@ export default new Hono() UserIdParamValidation, UserWalletValidation, async (c) => { - const { id } = c.req.valid("param") - const { wallet_address } = c.req.valid("json") - const { userRepo } = c.get("repos") + try { + const { id } = c.req.valid("param") + const { wallet_address } = c.req.valid("json") + const { userRepo } = c.get("repos") + + // Check if user exists + const userId = id as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - // Check if user exists - const userId = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + if (user.primary_wallet === wallet_address) { + return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) + } - if (user.primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) - } + const success = await userRepo.removeWallet(userId, wallet_address) + if (!success) { + return c.json( + { + ok: false, + error: "Cannot remove wallet: it may be the primary wallet or not found", + }, + 400, + ) + } - const success = await userRepo.removeWallet(userId, wallet_address) - if (!success) { - return c.json( - { - ok: false, - error: "Cannot remove wallet: it may be the primary wallet or not found", - }, - 400, - ) + // Get updated user with wallets + const updatedUser = await userRepo.findById(userId, true) + return c.json({ ok: true, data: updatedUser }, 200) + } catch (err) { + console.error(`Error removing wallet from user ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) } - - // Get updated user with wallets - const updatedUser = await userRepo.findById(userId, true) - return c.json({ ok: true, data: updatedUser }, 200) }, ) @@ -529,38 +607,43 @@ export default new Hono() PrimaryWalletParamValidation, UserWalletValidation, async (c) => { - const { primary_wallet } = c.req.valid("param") - const { wallet_address } = c.req.valid("json") - const { userRepo } = c.get("repos") + try { + const { primary_wallet } = c.req.valid("param") + const { wallet_address } = c.req.valid("json") + const { userRepo } = c.get("repos") - if (primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) - } + if (primary_wallet === wallet_address) { + return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) + } - // Check if user exists - const user = await userRepo.findByWalletAddress(primary_wallet) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } + // Check if user exists + const user = await userRepo.findByWalletAddress(primary_wallet) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - if (user.primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) - } + if (user.primary_wallet === wallet_address) { + return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) + } - const success = await userRepo.removeWallet(user.id, wallet_address) - if (!success) { - return c.json( - { - ok: false, - error: "Cannot remove wallet: it may be the primary wallet or not found", - }, - 400, - ) - } + const success = await userRepo.removeWallet(user.id, wallet_address) + if (!success) { + return c.json( + { + ok: false, + error: "Cannot remove wallet: it may be the primary wallet or not found", + }, + 400, + ) + } - // Get updated user with wallets - const updatedUser = await userRepo.findById(user.id, true) - return c.json({ ok: true, data: updatedUser }, 200) + // Get updated user with wallets + const updatedUser = await userRepo.findById(user.id, true) + return c.json({ ok: true, data: updatedUser }, 200) + } catch (err) { + console.error(`Error removing wallet from user with wallet ${c.req.param("primary_wallet")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }, ) @@ -574,7 +657,7 @@ export default new Hono() const { guildRepo, userRepo } = c.get("repos") // Check if user exists - const userId = Number.parseInt(id, 10) as UserTableId + const userId = id as UserTableId const user = await userRepo.findById(userId) if (!user) { return c.json({ ok: false, error: "User not found" }, 404) diff --git a/apps/leaderboard-backend/src/validation/games/gameSchemas.ts b/apps/leaderboard-backend/src/validation/games/gameSchemas.ts index bdb8acb60c..4ea45b2b93 100644 --- a/apps/leaderboard-backend/src/validation/games/gameSchemas.ts +++ b/apps/leaderboard-backend/src/validation/games/gameSchemas.ts @@ -141,7 +141,7 @@ export const GameScoresQuerySchema = z export const GameIdParamSchema = z .object({ - id: z.string().regex(/^\d+$/, { message: "Must be a positive integer string" }), + id: z.string().transform((val) => Number.parseInt(val)), }) .strict() .openapi({ @@ -150,7 +150,7 @@ export const GameIdParamSchema = z export const AdminIdParamSchema = z .object({ - admin_id: z.string().regex(/^\d+$/, { message: "Must be a positive integer string" }), + admin_id: z.string().transform((val) => Number.parseInt(val)), }) .strict() .openapi({ diff --git a/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts b/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts index 0bb62c6bb1..3a1396fec1 100644 --- a/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts +++ b/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts @@ -141,7 +141,7 @@ export const GuildMemberUpdateRequestSchema = z export const GuildIdParamSchema = z .object({ - id: z.string().regex(/^\d+$/, { message: "Must be a positive integer string" }), + id: z.string().transform((val) => Number.parseInt(val)), }) .strict() .openapi({ @@ -152,7 +152,7 @@ export const GuildIdParamSchema = z export const GuildMemberIdParamSchema = z .object({ - member_id: z.string().regex(/^\d+$/, { message: "Must be a positive integer string" }), + member_id: z.string().transform((val) => Number.parseInt(val)), }) .strict() .openapi({ diff --git a/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardSchema.ts b/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardSchema.ts index 546acdfff4..f14f1406f2 100644 --- a/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardSchema.ts +++ b/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardSchema.ts @@ -96,9 +96,8 @@ export const LeaderboardLimitQuerySchema = z.object({ .string() .transform((val) => Number.parseInt(val)) .default("50"), - // regex(/^\d+$/, { message: "Must be a positive integer string" }).default("50"), }) export const LeaderboardGameIdParamSchema = z.object({ - id: z.string().regex(/^\d+$/, { message: "Must be a positive integer string" }), + id: z.string().transform((val) => Number.parseInt(val)), }) diff --git a/apps/leaderboard-backend/src/validation/users/userSchemas.ts b/apps/leaderboard-backend/src/validation/users/userSchemas.ts index 4bfd88d289..7508afc23e 100644 --- a/apps/leaderboard-backend/src/validation/users/userSchemas.ts +++ b/apps/leaderboard-backend/src/validation/users/userSchemas.ts @@ -117,7 +117,7 @@ export const UserWalletRequestSchema = z export const UserIdParamSchema = z .object({ - id: z.string().regex(/^\d+$/, { message: "Must be a positive integer string" }), + id: z.string().transform((val) => Number.parseInt(val)), }) .strict() .openapi({ From f8f885352f53bcd89f0b434e54204cc4ba458562 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Wed, 21 May 2025 16:53:07 +0530 Subject: [PATCH 10/23] refactor: add error handling and improve type safety in repository methods [skip ci] --- apps/leaderboard-backend/src/auth/index.ts | 6 +- .../src/auth/middlewares/index.ts | 5 + apps/leaderboard-backend/src/db/types.ts | 24 - .../src/repositories/AuthRepository.ts | 20 +- .../src/repositories/GamesRepository.ts | 297 +++++++----- .../src/repositories/GuildsRepository.ts | 436 +++++++++++------- .../src/repositories/LeaderBoardRepository.ts | 168 ++++--- .../src/repositories/UsersRepository.ts | 340 ++++++++------ .../src/routes/api/guildsRoutes.ts | 4 +- bun.lock | 22 - 10 files changed, 748 insertions(+), 574 deletions(-) create mode 100644 apps/leaderboard-backend/src/auth/middlewares/index.ts diff --git a/apps/leaderboard-backend/src/auth/index.ts b/apps/leaderboard-backend/src/auth/index.ts index 20d2eb1bf5..9c94bc8d9c 100644 --- a/apps/leaderboard-backend/src/auth/index.ts +++ b/apps/leaderboard-backend/src/auth/index.ts @@ -1,7 +1,3 @@ export * from "./roles" +export * from "./middlewares" export * from "./verifySignature" -export * from "./middlewares/userAuth" -export * from "./middlewares/gameAuth" -export * from "./middlewares/guildAuth" -export * from "./middlewares/requireAuth" -export * from "./middlewares/permissions" diff --git a/apps/leaderboard-backend/src/auth/middlewares/index.ts b/apps/leaderboard-backend/src/auth/middlewares/index.ts new file mode 100644 index 0000000000..4023ed726e --- /dev/null +++ b/apps/leaderboard-backend/src/auth/middlewares/index.ts @@ -0,0 +1,5 @@ +export * from "./userAuth" +export * from "./gameAuth" +export * from "./guildAuth" +export * from "./requireAuth" +export * from "./permissions" diff --git a/apps/leaderboard-backend/src/db/types.ts b/apps/leaderboard-backend/src/db/types.ts index 850a2c0593..9be74c5d7c 100644 --- a/apps/leaderboard-backend/src/db/types.ts +++ b/apps/leaderboard-backend/src/db/types.ts @@ -153,27 +153,3 @@ export interface GameGuildLeaderboardEntry { export type AuthSession = Selectable export type NewAuthSession = Insertable export type UpdateAuthSession = Updateable - -// Auth types for API -export interface AuthChallengeRequest { - primary_wallet: Address -} - -export interface AuthChallengeResponse { - message: string - primary_wallet: Address -} - -export interface SignInRequest { - primary_wallet: Address - signature: string -} - -export interface AuthResponse { - session_id: AuthSessionTableId - user: { - id: UserTableId - username: string - primary_wallet: Address - } -} diff --git a/apps/leaderboard-backend/src/repositories/AuthRepository.ts b/apps/leaderboard-backend/src/repositories/AuthRepository.ts index 9c8e85d290..29db5f1d76 100644 --- a/apps/leaderboard-backend/src/repositories/AuthRepository.ts +++ b/apps/leaderboard-backend/src/repositories/AuthRepository.ts @@ -18,7 +18,7 @@ export class AuthRepository { last_used_at: now.toISOString(), } - this.db.insertInto("auth_sessions").values(newSession).executeTakeFirst() + await this.db.insertInto("auth_sessions").values(newSession).executeTakeFirstOrThrow() return { ...newSession, @@ -43,8 +43,11 @@ export class AuthRepository { return undefined } - const now = new Date().toISOString() - await this.db.updateTable("auth_sessions").set({ last_used_at: now }).where("id", "=", sessionId).execute() + await this.db + .updateTable("auth_sessions") + .set({ last_used_at: new Date().toISOString() }) + .where("id", "=", sessionId) + .executeTakeFirstOrThrow() return session } catch (error) { @@ -54,14 +57,19 @@ export class AuthRepository { } async getUserSessions(userId: UserTableId): Promise { - return this.db.selectFrom("auth_sessions").where("user_id", "=", userId).selectAll().execute() + try { + return await this.db.selectFrom("auth_sessions").where("user_id", "=", userId).selectAll().execute() + } catch (error) { + console.error("Error getting user sessions:", error) + return [] + } } async deleteSession(sessionId: AuthSessionTableId): Promise { try { - const res = await this.db.deleteFrom("auth_sessions").where("id", "=", sessionId).executeTakeFirstOrThrow() + await this.db.deleteFrom("auth_sessions").where("id", "=", sessionId).executeTakeFirstOrThrow() - return res.numDeletedRows > 0n + return true } catch (error) { console.error("Error deleting session:", error) return false diff --git a/apps/leaderboard-backend/src/repositories/GamesRepository.ts b/apps/leaderboard-backend/src/repositories/GamesRepository.ts index a9300eb31b..c9df971b98 100644 --- a/apps/leaderboard-backend/src/repositories/GamesRepository.ts +++ b/apps/leaderboard-backend/src/repositories/GamesRepository.ts @@ -6,72 +6,110 @@ export class GameRepository { constructor(private db: Kysely) {} async findById(id: GameTableId): Promise { - return await this.db.selectFrom("games").where("id", "=", id).selectAll().executeTakeFirst() + try { + return await this.db.selectFrom("games").where("id", "=", id).selectAll().executeTakeFirstOrThrow() + } catch (error) { + console.error("Error finding game by ID:", error) + return undefined + } } async findByNameLike(name: string): Promise { - return await this.db.selectFrom("games").where("name", "like", `%${name}%`).selectAll().execute() + try { + return await this.db.selectFrom("games").where("name", "like", `%${name}%`).selectAll().execute() + } catch (error) { + console.error("Error finding games by name like:", error) + return [] + } } async findByExactName(name: string): Promise { - return await this.db.selectFrom("games").where("name", "=", name).selectAll().executeTakeFirst() + try { + return await this.db.selectFrom("games").where("name", "=", name).selectAll().executeTakeFirstOrThrow() + } catch (error) { + console.error("Error finding game by exact name:", error) + return undefined + } } async findByAdmin(adminId: UserTableId): Promise { - return await this.db.selectFrom("games").where("admin_id", "=", adminId).selectAll().execute() + try { + return await this.db.selectFrom("games").where("admin_id", "=", adminId).selectAll().execute() + } catch (error) { + console.error("Error finding games by admin:", error) + return [] + } } async find(criteria: { name?: string admin_id?: UserTableId }): Promise { - const { name, admin_id } = criteria - - return await this.db - .selectFrom("games") - .$if(typeof name === "string" && name.length > 0, (qb) => qb.where("name", "like", `%${name}%`)) - .$if(typeof admin_id === "number", (qb) => qb.where("admin_id", "=", admin_id as UserTableId)) - .selectAll() - .execute() + try { + const { name, admin_id } = criteria + + return await this.db + .selectFrom("games") + .$if(typeof name === "string" && name.length > 0, (qb) => qb.where("name", "like", `%${name}%`)) + .$if(typeof admin_id === "number", (qb) => qb.where("admin_id", "=", admin_id as UserTableId)) + .selectAll() + .execute() + } catch (error) { + console.error("Error finding games by criteria:", error) + return [] + } } - async create(game: NewGame): Promise { - const now = new Date().toISOString() - return await this.db - .insertInto("games") - .values({ - ...game, - created_at: now, - updated_at: now, - }) - .returningAll() - .executeTakeFirstOrThrow() + async create(game: NewGame): Promise { + try { + const now = new Date().toISOString() + return await this.db + .insertInto("games") + .values({ + ...game, + created_at: now, + updated_at: now, + }) + .returningAll() + .executeTakeFirstOrThrow() + } catch (error) { + console.error("Error creating game:", error) + return undefined + } } async update(id: GameTableId, updateWith: UpdateGame): Promise { - await this.db - .updateTable("games") - .set({ ...updateWith, updated_at: new Date().toISOString() }) - .where("id", "=", id) - .execute() + try { + await this.db + .updateTable("games") + .set({ ...updateWith, updated_at: new Date().toISOString() }) + .where("id", "=", id) + .executeTakeFirstOrThrow() - return this.findById(id) + return this.findById(id) + } catch (error) { + console.error("Error updating game:", error) + return undefined + } } async delete(id: GameTableId): Promise { - return await this.db.transaction().execute(async (trx) => { - // Get game before deletion - const game = await trx.selectFrom("games").where("id", "=", id).selectAll().executeTakeFirst() + try { + return await this.db.transaction().execute(async (trx) => { + const game = await trx.selectFrom("games").where("id", "=", id).selectAll().executeTakeFirst() + if (!game) { + return undefined + } - if (!game) { - return undefined - } - - await trx.deleteFrom("user_game_scores").where("game_id", "=", id).execute() - await trx.deleteFrom("games").where("id", "=", id).execute() + await trx.deleteFrom("user_game_scores").where("game_id", "=", id).executeTakeFirstOrThrow() + await trx.deleteFrom("games").where("id", "=", id).executeTakeFirstOrThrow() - return game - }) + return game + }) + } catch (error) { + console.error("Error deleting game:", error) + return undefined + } } } @@ -79,56 +117,73 @@ export class GameScoreRepository { constructor(private db: Kysely) {} async findUserGameScore(userId: UserTableId, gameId: GameTableId): Promise { - return await this.db - .selectFrom("user_game_scores") - .where("user_id", "=", userId) - .where("game_id", "=", gameId) - .selectAll() - .executeTakeFirst() + try { + return await this.db + .selectFrom("user_game_scores") + .where("user_id", "=", userId) + .where("game_id", "=", gameId) + .selectAll() + .executeTakeFirstOrThrow() + } catch (error) { + console.error("Error finding user game score:", error) + return undefined + } } async findUserScores( userId: UserTableId, gameId?: GameTableId, ): Promise<(UserGameScore & { game_name: string })[]> { - return await this.db - .selectFrom("user_game_scores") - .innerJoin("games", "games.id", "user_game_scores.game_id") - .where("user_game_scores.user_id", "=", userId) - .$if(typeof gameId === "number", (qb) => qb.where("user_game_scores.game_id", "=", gameId as GameTableId)) - .select([ - "user_game_scores.id", - "user_game_scores.game_id", - "user_game_scores.user_id", - "user_game_scores.role", - "user_game_scores.score", - "user_game_scores.metadata", - "user_game_scores.created_at", - "user_game_scores.updated_at", - "games.name as game_name", - ]) - .execute() + try { + return await this.db + .selectFrom("user_game_scores") + .innerJoin("games", "games.id", "user_game_scores.game_id") + .where("user_game_scores.user_id", "=", userId) + .$if(gameId !== undefined && typeof gameId === "number", (qb) => + qb.where("user_game_scores.game_id", "=", gameId!), + ) + .select([ + "user_game_scores.id", + "user_game_scores.game_id", + "user_game_scores.user_id", + "user_game_scores.role", + "user_game_scores.score", + "user_game_scores.metadata", + "user_game_scores.created_at", + "user_game_scores.updated_at", + "games.name as game_name", + ]) + .execute() + } catch (error) { + console.error("Error finding user scores:", error) + return [] + } } async findGameScores(gameId: GameTableId, limit = 50): Promise<(UserGameScore & { username: string })[]> { - return await this.db - .selectFrom("user_game_scores") - .innerJoin("users", "users.id", "user_game_scores.user_id") - .where("user_game_scores.game_id", "=", gameId) - .select([ - "user_game_scores.id", - "user_game_scores.game_id", - "user_game_scores.user_id", - "user_game_scores.role", - "user_game_scores.score", - "user_game_scores.metadata", - "user_game_scores.created_at", - "user_game_scores.updated_at", - "users.username", - ]) - .orderBy("user_game_scores.score", "desc") - .limit(limit) - .execute() + try { + return await this.db + .selectFrom("user_game_scores") + .innerJoin("users", "users.id", "user_game_scores.user_id") + .where("user_game_scores.game_id", "=", gameId) + .select([ + "user_game_scores.id", + "user_game_scores.game_id", + "user_game_scores.user_id", + "user_game_scores.role", + "user_game_scores.score", + "user_game_scores.metadata", + "user_game_scores.created_at", + "user_game_scores.updated_at", + "users.username", + ]) + .orderBy("user_game_scores.score", "desc") + .limit(limit) + .execute() + } catch (error) { + console.error("Error finding game scores:", error) + return [] + } } async submitScore( @@ -136,45 +191,55 @@ export class GameScoreRepository { gameId: GameTableId, score: number, metadata?: string, - ): Promise { - const existingScore = await this.findUserGameScore(userId, gameId) - const now = new Date().toISOString() + ): Promise { + try { + const existingScore = await this.findUserGameScore(userId, gameId) + const now = new Date().toISOString() + + if (existingScore) { + const newScore = existingScore.score + score + return await this.db + .updateTable("user_game_scores") + .set({ + score: newScore, + metadata: metadata !== undefined ? metadata : existingScore.metadata, + updated_at: now, + }) + .where("id", "=", existingScore.id) + .returningAll() + .executeTakeFirstOrThrow() + } else { + return await this.db + .insertInto("user_game_scores") + .values({ + user_id: userId, + game_id: gameId, + role: GameRole.PLAYER, // Default role for new score submissions + score, + metadata, + created_at: now, + updated_at: now, + }) + .returningAll() + .executeTakeFirstOrThrow() + } + } catch (error) { + console.error("Error submitting score:", error) + return undefined + } + } - if (existingScore) { - const newScore = existingScore.score + score - return await this.db - .updateTable("user_game_scores") - .set({ - score: newScore, - metadata: metadata !== undefined ? metadata : existingScore.metadata, - updated_at: now, - }) - .where("id", "=", existingScore.id) - .returningAll() - .executeTakeFirstOrThrow() - } else { + async deleteScore(userId: UserTableId, gameId: GameTableId): Promise { + try { return await this.db - .insertInto("user_game_scores") - .values({ - user_id: userId, - game_id: gameId, - role: GameRole.PLAYER, // Default role for new score submissions - score, - metadata, - created_at: now, - updated_at: now, - }) + .deleteFrom("user_game_scores") + .where("user_id", "=", userId) + .where("game_id", "=", gameId) .returningAll() .executeTakeFirstOrThrow() + } catch (error) { + console.error("Error deleting score:", error) + return undefined } } - - async deleteScore(userId: UserTableId, gameId: GameTableId): Promise { - return await this.db - .deleteFrom("user_game_scores") - .where("user_id", "=", userId) - .where("game_id", "=", gameId) - .returningAll() - .executeTakeFirst() - } } diff --git a/apps/leaderboard-backend/src/repositories/GuildsRepository.ts b/apps/leaderboard-backend/src/repositories/GuildsRepository.ts index 5c962e97e3..88ba49a447 100644 --- a/apps/leaderboard-backend/src/repositories/GuildsRepository.ts +++ b/apps/leaderboard-backend/src/repositories/GuildsRepository.ts @@ -18,49 +18,70 @@ export class GuildRepository { id: GuildTableId, includeMembers = false, ): Promise<(Guild & { members: GuildMember[] }) | undefined> { - const guild = await this.db.selectFrom("guilds").where("id", "=", id).selectAll().executeTakeFirst() + try { + const guild = await this.db.selectFrom("guilds").where("id", "=", id).selectAll().executeTakeFirstOrThrow() - if (guild && includeMembers) { - const members = await this.getGuildMembersWithUserDetails(id) - return { ...guild, members } - } + if (includeMembers) { + const members = await this.getGuildMembersWithUserDetails(id) + return { ...guild, members } + } - return guild as Guild & { members: GuildMember[] } + return guild as Guild & { members: GuildMember[] } + } catch (error) { + console.error("Error finding guild by ID:", error) + return undefined + } } async findByName(name: string, includeMembers = false): Promise<(Guild & { members: GuildMember[] })[]> { - const guilds = await this.db.selectFrom("guilds").where("name", "like", `%${name}%`).selectAll().execute() - - if (includeMembers && guilds.length > 0) { - const guildsWithMembers = await Promise.all( - guilds.map(async (guild) => { - const members = await this.getGuildMembersWithUserDetails(guild.id) - return { ...guild, members } - }), - ) - return guildsWithMembers - } + try { + const guilds = await this.db.selectFrom("guilds").where("name", "like", `%${name}%`).selectAll().execute() + if (guilds.length === 0) { + return [] + } - return guilds as (Guild & { members: GuildMember[] })[] + if (includeMembers) { + const guildsWithMembers = await Promise.all( + guilds.map(async (guild) => { + const members = await this.getGuildMembersWithUserDetails(guild.id) + return { ...guild, members } + }), + ) + return guildsWithMembers + } + + return guilds as (Guild & { members: GuildMember[] })[] + } catch (error) { + console.error("Error finding guilds by name:", error) + return [] + } } async findByCreator( creatorId: UserTableId, includeMembers = false, ): Promise<(Guild & { members: GuildMember[] })[]> { - const guilds = await this.db.selectFrom("guilds").where("creator_id", "=", creatorId).selectAll().execute() - - if (includeMembers && guilds.length > 0) { - const guildsWithMembers = await Promise.all( - guilds.map(async (guild) => { - const members = await this.getGuildMembersWithUserDetails(guild.id) - return { ...guild, members } - }), - ) - return guildsWithMembers - } + try { + const guilds = await this.db.selectFrom("guilds").where("creator_id", "=", creatorId).selectAll().execute() + if (guilds.length === 0) { + return [] + } - return guilds as (Guild & { members: GuildMember[] })[] + if (includeMembers) { + const guildsWithMembers = await Promise.all( + guilds.map(async (guild) => { + const members = await this.getGuildMembersWithUserDetails(guild.id) + return { ...guild, members } + }), + ) + return guildsWithMembers + } + + return guilds as (Guild & { members: GuildMember[] })[] + } catch (error) { + console.error("Error finding guilds by creator:", error) + return [] + } } async find(criteria: { @@ -68,190 +89,253 @@ export class GuildRepository { creator_id?: UserTableId includeMembers?: boolean }): Promise<(Guild & { members: GuildMember[] })[]> { - const { name, creator_id, includeMembers = false } = criteria - - const guilds = await this.db - .selectFrom("guilds") - .$if(typeof name === "string" && name.length > 0, (qb) => qb.where("name", "like", `%${name}%`)) - .$if(typeof creator_id === "number", (qb) => qb.where("creator_id", "=", creator_id as UserTableId)) - .selectAll() - .execute() - - if (includeMembers && guilds.length > 0) { - const guildsWithMembers = await Promise.all( - guilds.map(async (guild) => { - const members = await this.getGuildMembersWithUserDetails(guild.id) - return { ...guild, members } - }), - ) - return guildsWithMembers - } + try { + const { name, creator_id, includeMembers = false } = criteria + + const guilds = await this.db + .selectFrom("guilds") + .$if(typeof name === "string" && name.length > 0, (qb) => qb.where("name", "like", `%${name}%`)) + .$if(typeof creator_id === "number", (qb) => qb.where("creator_id", "=", creator_id as UserTableId)) + .selectAll() + .execute() - return guilds as (Guild & { members: GuildMember[] })[] - } + if (guilds.length === 0) { + return [] + } - async create(guild: NewGuild): Promise { - const now = new Date().toISOString() - return await this.db.transaction().execute(async (trx) => { - const newGuild = await trx - .insertInto("guilds") - .values({ - ...guild, - created_at: now, - updated_at: now, - }) - .returningAll() - .executeTakeFirstOrThrow() + if (includeMembers) { + const guildsWithMembers = await Promise.all( + guilds.map(async (guild) => { + const members = await this.getGuildMembersWithUserDetails(guild.id) + return { ...guild, members } + }), + ) + return guildsWithMembers + } - await trx - .insertInto("guild_members") - .values({ - guild_id: newGuild.id, - user_id: newGuild.creator_id, - role: GuildRole.CREATOR, - joined_at: now, - }) - .execute() + return guilds as (Guild & { members: GuildMember[] })[] + } catch (error) { + console.error("Error finding guilds by criteria:", error) + return [] + } + } - return newGuild - }) + async create(guild: NewGuild): Promise { + try { + const now = new Date().toISOString() + return await this.db.transaction().execute(async (trx) => { + const newGuild = await trx + .insertInto("guilds") + .values({ + ...guild, + created_at: now, + updated_at: now, + }) + .returningAll() + .executeTakeFirstOrThrow() + + await trx + .insertInto("guild_members") + .values({ + guild_id: newGuild.id, + user_id: newGuild.creator_id, + role: GuildRole.CREATOR, + joined_at: now, + }) + .execute() + + return newGuild + }) + } catch (error) { + console.error("Error creating guild:", error) + return undefined + } } async update(id: GuildTableId, updateWith: UpdateGuild): Promise { - await this.db - .updateTable("guilds") - .set({ - ...updateWith, - updated_at: new Date().toISOString(), - }) - .where("id", "=", id) - .execute() + try { + await this.db + .updateTable("guilds") + .set({ + ...updateWith, + updated_at: new Date().toISOString(), + }) + .where("id", "=", id) + .executeTakeFirstOrThrow() - return this.findById(id) + return this.findById(id) + } catch (error) { + console.error("Error updating guild:", error) + return undefined + } } async delete(id: GuildTableId): Promise { - return await this.db.transaction().execute(async (trx) => { - const guild = await trx.selectFrom("guilds").where("id", "=", id).selectAll().executeTakeFirst() - - if (!guild) { - return undefined - } + try { + return await this.db.transaction().execute(async (trx) => { + const guild = await trx.selectFrom("guilds").where("id", "=", id).selectAll().executeTakeFirstOrThrow() - await trx.deleteFrom("guild_members").where("guild_id", "=", id).execute() - await trx.deleteFrom("guilds").where("id", "=", id).execute() + await trx.deleteFrom("guild_members").where("guild_id", "=", id).execute() + await trx.deleteFrom("guilds").where("id", "=", id).execute() - return guild - }) + return guild + }) + } catch (error) { + console.error("Error deleting guild:", error) + return undefined + } } async getGuildMembersWithUserDetails(guildId: GuildTableId): Promise { - return await this.db - .selectFrom("guild_members") - .innerJoin("users", "users.id", "guild_members.user_id") - .where("guild_members.guild_id", "=", guildId) - .select([ - "guild_members.id", - "guild_members.guild_id", - "guild_members.user_id", - "guild_members.role", - "guild_members.joined_at", - "users.username", - "users.primary_wallet", - ]) - .execute() + try { + return await this.db + .selectFrom("guild_members") + .innerJoin("users", "users.id", "guild_members.user_id") + .where("guild_members.guild_id", "=", guildId) + .select([ + "guild_members.id", + "guild_members.guild_id", + "guild_members.user_id", + "guild_members.role", + "guild_members.joined_at", + "users.username", + "users.primary_wallet", + ]) + .execute() + } catch (error) { + console.error("Error getting guild members with user details:", error) + return [] + } } async getUserGuilds(userId: UserTableId): Promise { - return await this.db - .selectFrom("guild_members") - .innerJoin("guilds", "guilds.id", "guild_members.guild_id") - .where("guild_members.user_id", "=", userId) - .select([ - "guilds.id", - "guilds.name", - "guilds.icon_url", - "guilds.creator_id", - "guilds.created_at", - "guilds.updated_at", - ]) - .execute() + try { + return await this.db + .selectFrom("guild_members") + .innerJoin("guilds", "guilds.id", "guild_members.guild_id") + .where("guild_members.user_id", "=", userId) + .select([ + "guilds.id", + "guilds.name", + "guilds.icon_url", + "guilds.creator_id", + "guilds.created_at", + "guilds.updated_at", + ]) + .execute() + } catch (error) { + console.error("Error getting user guilds:", error) + return [] + } } async findGuildMember(guildId: GuildTableId, userId: UserTableId): Promise { - return await this.db - .selectFrom("guild_members") - .where("guild_id", "=", guildId) - .where("user_id", "=", userId) - .selectAll() - .executeTakeFirst() + try { + return await this.db + .selectFrom("guild_members") + .where("guild_id", "=", guildId) + .where("user_id", "=", userId) + .selectAll() + .executeTakeFirstOrThrow() + } catch (error) { + console.error("Error finding guild member:", error) + return undefined + } } async addMember(guildId: GuildTableId, userId: UserTableId, isAdmin = false): Promise { - const existing = await this.db - .selectFrom("guild_members") - .where("guild_id", "=", guildId) - .where("user_id", "=", userId) - .select(["id"]) - .executeTakeFirst() - - if (existing) { - return undefined - } + try { + const existing = await this.db + .selectFrom("guild_members") + .where("guild_id", "=", guildId) + .where("user_id", "=", userId) + .select(["id"]) + .executeTakeFirst() + + if (existing) { + return undefined + } - // Determine role based on isAdmin parameter - const role = isAdmin ? GuildRole.ADMIN : GuildRole.MEMBER + // Determine role based on isAdmin parameter + const role = isAdmin ? GuildRole.ADMIN : GuildRole.MEMBER - await this.db - .insertInto("guild_members") - .values({ - guild_id: guildId, - user_id: userId, - role: role, - joined_at: new Date().toISOString(), - }) - .execute() - - return await this.db - .selectFrom("guild_members") - .where("guild_id", "=", guildId) - .where("user_id", "=", userId) - .select(["id", "guild_id", "user_id", "role", "joined_at"]) - .executeTakeFirst() + await this.db + .insertInto("guild_members") + .values({ + guild_id: guildId, + user_id: userId, + role: role, + joined_at: new Date().toISOString(), + }) + .execute() + + return await this.db + .selectFrom("guild_members") + .where("guild_id", "=", guildId) + .where("user_id", "=", userId) + .select(["id", "guild_id", "user_id", "role", "joined_at"]) + .executeTakeFirstOrThrow() + } catch (error) { + console.error("Error adding member to guild:", error) + return undefined + } } async updateMemberRole( guildId: GuildTableId, userId: UserTableId, - isAdmin: boolean, + role: GuildRole, ): Promise { - // Convert boolean isAdmin to appropriate GuildRole enum value - const role = isAdmin ? GuildRole.ADMIN : GuildRole.MEMBER - - return await this.db - .updateTable("guild_members") - .set({ role: role }) - .where("guild_id", "=", guildId) - .where("user_id", "=", userId) - .returningAll() - .executeTakeFirst() - } + try { + // Ensure we're not changing a creator's role + const member = await this.findGuildMember(guildId, userId) + if (!member) { + throw new Error("Member not found in guild") + } - async removeMember(guildId: GuildTableId, userId: UserTableId): Promise { - const guild = await this.findById(guildId) - if (!guild) { + // Don't allow changing the role of the creator + if (member.role === GuildRole.CREATOR) { + throw new Error("Cannot change the role of the guild creator") + } + + // Only allow setting valid non-creator roles + if (role !== GuildRole.ADMIN && role !== GuildRole.MEMBER) { + throw new Error("Invalid role specified") + } + + return await this.db + .updateTable("guild_members") + .set({ role }) + .where("guild_id", "=", guildId) + .where("user_id", "=", userId) + .returningAll() + .executeTakeFirstOrThrow() + } catch (error) { + console.error("Error updating member role:", error) return undefined } + } - if (guild.creator_id === userId) { + async removeMember(guildId: GuildTableId, userId: UserTableId): Promise { + try { + const guild = await this.findById(guildId) + if (!guild) { + return undefined + } + + if (guild.creator_id === userId) { + return undefined + } + + return await this.db + .deleteFrom("guild_members") + .where("guild_id", "=", guildId) + .where("user_id", "=", userId) + .returningAll() + .executeTakeFirstOrThrow() + } catch (error) { + console.error("Error removing member from guild:", error) return undefined } - - return await this.db - .deleteFrom("guild_members") - .where("guild_id", "=", guildId) - .where("user_id", "=", userId) - .returningAll() - .executeTakeFirst() } } diff --git a/apps/leaderboard-backend/src/repositories/LeaderBoardRepository.ts b/apps/leaderboard-backend/src/repositories/LeaderBoardRepository.ts index 43f7697113..e7fe497b82 100644 --- a/apps/leaderboard-backend/src/repositories/LeaderBoardRepository.ts +++ b/apps/leaderboard-backend/src/repositories/LeaderBoardRepository.ts @@ -13,93 +13,113 @@ export class LeaderBoardRepository { // Global leaderboard: top users by total score across all games async getGlobalLeaderboard(limit = 50): Promise { - return await this.db - .selectFrom("users") - .innerJoin("user_game_scores", "users.id", "user_game_scores.user_id") - .select([ - "users.id as user_id", - "users.username", - "users.primary_wallet", - this.db.fn.sum("user_game_scores.score").as("total_score"), - ]) - .groupBy(["users.id", "users.username", "users.primary_wallet"]) - .orderBy("total_score", "desc") - .limit(limit) - .execute() + try { + return await this.db + .selectFrom("users") + .innerJoin("user_game_scores", "users.id", "user_game_scores.user_id") + .select([ + "users.id as user_id", + "users.username", + "users.primary_wallet", + this.db.fn.sum("user_game_scores.score").as("total_score"), + ]) + .groupBy(["users.id", "users.username", "users.primary_wallet"]) + .orderBy("total_score", "desc") + .limit(limit) + .execute() + } catch (error) { + console.error("Error getting global leaderboard:", error) + return [] + } } // Guild leaderboard: top guilds by total score of all members async getGuildLeaderboard(limit = 50): Promise { - const leaderboardRows = await this.db - .selectFrom("guilds") - .innerJoin("guild_members", "guilds.id", "guild_members.guild_id") - .innerJoin("user_game_scores", "guild_members.user_id", "user_game_scores.user_id") - .select([ - "guilds.id as guild_id", - "guilds.name as guild_name", - "guilds.icon_url", - this.db.fn.sum("user_game_scores.score").as("total_score"), - ]) - .groupBy(["guilds.id", "guilds.name", "guilds.icon_url"]) - .orderBy("total_score", "desc") - .limit(limit) - .execute() - - // Get member counts for all guilds in the leaderboard - const guildIds = leaderboardRows.map((row) => row.guild_id) - let memberCounts: Record = {} - if (guildIds.length > 0) { - const memberCountRows = await this.db - .selectFrom("guild_members") - .select(["guild_id", this.db.fn.count("user_id").as("member_count")]) - .where("guild_id", "in", guildIds) - .groupBy(["guild_id"]) + try { + const leaderboardRows = await this.db + .selectFrom("guilds") + .innerJoin("guild_members", "guilds.id", "guild_members.guild_id") + .innerJoin("user_game_scores", "guild_members.user_id", "user_game_scores.user_id") + .select([ + "guilds.id as guild_id", + "guilds.name as guild_name", + "guilds.icon_url", + this.db.fn.sum("user_game_scores.score").as("total_score"), + ]) + .groupBy(["guilds.id", "guilds.name", "guilds.icon_url"]) + .orderBy("total_score", "desc") + .limit(limit) .execute() - memberCounts = Object.fromEntries(memberCountRows.map((row) => [row.guild_id, row.member_count])) - } - return leaderboardRows.map((row) => ({ - ...row, - member_count: memberCounts[row.guild_id] ?? 0, - })) + // Get member counts for all guilds in the leaderboard + const guildIds = leaderboardRows.map((row) => row.guild_id) + let memberCounts: Record = {} + if (guildIds.length > 0) { + const memberCountRows = await this.db + .selectFrom("guild_members") + .select(["guild_id", this.db.fn.count("user_id").as("member_count")]) + .where("guild_id", "in", guildIds) + .groupBy(["guild_id"]) + .execute() + memberCounts = Object.fromEntries(memberCountRows.map((row) => [row.guild_id, row.member_count])) + } + + return leaderboardRows.map((row) => ({ + ...row, + member_count: memberCounts[row.guild_id] ?? 0, + })) + } catch (error) { + console.error("Error getting guild leaderboard:", error) + return [] + } } // Game-specific leaderboard: top users by score in a game async getGameLeaderboard(gameId: GameTableId, limit = 50): Promise { - return await this.db - .selectFrom("user_game_scores") - .innerJoin("users", "users.id", "user_game_scores.user_id") - .where("user_game_scores.game_id", "=", gameId) - .select([ - "user_game_scores.game_id", - "users.id as user_id", - "users.username", - "users.primary_wallet", - "user_game_scores.score", - ]) - .orderBy("user_game_scores.score", "desc") - .limit(limit) - .execute() + try { + return await this.db + .selectFrom("user_game_scores") + .innerJoin("users", "users.id", "user_game_scores.user_id") + .where("user_game_scores.game_id", "=", gameId) + .select([ + "user_game_scores.game_id", + "users.id as user_id", + "users.username", + "users.primary_wallet", + "user_game_scores.score", + ]) + .orderBy("user_game_scores.score", "desc") + .limit(limit) + .execute() + } catch (error) { + console.error("Error getting game leaderboard:", error) + return [] + } } // Game-specific guild leaderboard: top guilds by total score of members in a game async getGameGuildLeaderboard(gameId: GameTableId, limit = 50): Promise { - return await this.db - .selectFrom("guilds") - .innerJoin("guild_members", "guilds.id", "guild_members.guild_id") - .innerJoin("user_game_scores", "guild_members.user_id", "user_game_scores.user_id") - .where("user_game_scores.game_id", "=", gameId) - .select([ - "user_game_scores.game_id", - "guilds.id as guild_id", - "guilds.name as guild_name", - "guilds.icon_url", - this.db.fn.sum("user_game_scores.score").as("total_score"), - this.db.fn.count("guild_members.id").as("member_count"), - ]) - .groupBy(["user_game_scores.game_id", "guilds.id", "guilds.name", "guilds.icon_url"]) - .orderBy("total_score", "desc") - .limit(limit) - .execute() + try { + return await this.db + .selectFrom("guilds") + .innerJoin("guild_members", "guilds.id", "guild_members.guild_id") + .innerJoin("user_game_scores", "guild_members.user_id", "user_game_scores.user_id") + .where("user_game_scores.game_id", "=", gameId) + .select([ + "user_game_scores.game_id", + "guilds.id as guild_id", + "guilds.name as guild_name", + "guilds.icon_url", + this.db.fn.sum("user_game_scores.score").as("total_score"), + this.db.fn.count("guild_members.id").as("member_count"), + ]) + .groupBy(["user_game_scores.game_id", "guilds.id", "guilds.name", "guilds.icon_url"]) + .orderBy("total_score", "desc") + .limit(limit) + .execute() + } catch (error) { + console.error("Error getting game guild leaderboard:", error) + return [] + } } } diff --git a/apps/leaderboard-backend/src/repositories/UsersRepository.ts b/apps/leaderboard-backend/src/repositories/UsersRepository.ts index 75aff47178..c17ef96e35 100644 --- a/apps/leaderboard-backend/src/repositories/UsersRepository.ts +++ b/apps/leaderboard-backend/src/repositories/UsersRepository.ts @@ -6,62 +6,74 @@ export class UserRepository { constructor(private db: Kysely) {} async findById(id: UserTableId, includeWallets = false): Promise<(User & { wallets: UserWallet[] }) | undefined> { - const user = await this.db.selectFrom("users").where("id", "=", id).selectAll().executeTakeFirst() + try { + const user = await this.db.selectFrom("users").where("id", "=", id).selectAll().executeTakeFirstOrThrow() - if (user && includeWallets) { - const wallets = await this.getUserWallets(id) - return { ...user, wallets } - } + if (includeWallets) { + const wallets = await this.getUserWallets(id) + return { ...user, wallets } + } - return user as User & { wallets: UserWallet[] } + return user as User & { wallets: UserWallet[] } + } catch (error) { + console.error("Error finding user by ID:", error) + return undefined + } } async findByWalletAddress( walletAddress: Address, includeWallets = false, ): Promise<(User & { wallets: UserWallet[] }) | undefined> { - let user = await this.db - .selectFrom("users") - .where("primary_wallet", "=", walletAddress) - .selectAll() - .executeTakeFirst() - - if (!user) { - const walletEntry = await this.db - .selectFrom("user_wallets") - .where("wallet_address", "=", walletAddress) + try { + const user = await this.db + .selectFrom("users") + .where("primary_wallet", "=", walletAddress) .selectAll() - .executeTakeFirst() + .executeTakeFirstOrThrow() - if (walletEntry) { - user = await this.findById(walletEntry.user_id) + if (includeWallets) { + const wallets = await this.getUserWallets(user.id) + return { ...user, wallets } } - } - if (user && includeWallets) { - const wallets = await this.getUserWallets(user.id) - return { ...user, wallets } + return user as User & { wallets: UserWallet[] } + } catch (error) { + console.error("Error finding user by wallet address:", error) + return undefined } - - return user as User & { wallets: UserWallet[] } } async findByUsername( username: string, includeWallets = false, ): Promise<(User & { wallets: UserWallet[] }) | undefined> { - const user = await this.db.selectFrom("users").where("username", "=", username).selectAll().executeTakeFirst() + try { + const user = await this.db + .selectFrom("users") + .where("username", "=", username) + .selectAll() + .executeTakeFirstOrThrow() - if (user && includeWallets) { - const wallets = await this.getUserWallets(user.id) - return { ...user, wallets } - } + if (includeWallets) { + const wallets = await this.getUserWallets(user.id) + return { ...user, wallets } + } - return user as User & { wallets: UserWallet[] } + return user as User & { wallets: UserWallet[] } + } catch (error) { + console.error("Error finding user by username:", error) + return undefined + } } async getUserWallets(userId: UserTableId): Promise { - return await this.db.selectFrom("user_wallets").where("user_id", "=", userId).selectAll().execute() + try { + return await this.db.selectFrom("user_wallets").where("user_id", "=", userId).selectAll().execute() + } catch (error) { + console.error("Error getting user wallets:", error) + return [] + } } async find(criteria: { @@ -69,158 +81,190 @@ export class UserRepository { username?: string includeWallets?: boolean }): Promise<(User & { wallets: UserWallet[] })[]> { - const { primary_wallet, username, includeWallets = false } = criteria + try { + const { primary_wallet, username, includeWallets = false } = criteria - if (primary_wallet) { - const user = await this.findByWalletAddress(primary_wallet, includeWallets) - return user ? [user] : [] - } + if (primary_wallet) { + const user = await this.findByWalletAddress(primary_wallet, includeWallets) + return user ? [user] : [] + } + + if (username) { + const user = await this.db + .selectFrom("users") + .where("username", "=", username) + .selectAll() + .executeTakeFirstOrThrow() - if (username) { - const users = await this.db.selectFrom("users").where("username", "=", username).selectAll().execute() - - if (includeWallets && users.length > 0) { - const usersWithWallets = await Promise.all( - users.map(async (user) => { - const wallets = await this.getUserWallets(user.id) - return { ...user, wallets } - }), - ) - return usersWithWallets + if (includeWallets) { + const wallets = await this.getUserWallets(user.id) + return [{ ...user, wallets }] + } + + return [user as User & { wallets: UserWallet[] }] } - return users as (User & { wallets: UserWallet[] })[] + return [] + } catch (error) { + console.error("Error finding users by criteria:", error) + return [] } + } - return [] + async create(user: NewUser): Promise { + try { + const now = new Date().toISOString() + return await this.db.transaction().execute(async (trx) => { + const createdUser = await trx + .insertInto("users") + .values({ + ...user, + created_at: now, + updated_at: now, + }) + .returningAll() + .executeTakeFirstOrThrow() + + await trx + .insertInto("user_wallets") + .values({ + user_id: createdUser.id, + wallet_address: createdUser.primary_wallet, + is_primary: true, + created_at: now, + }) + .execute() + + return createdUser + }) + } catch (error) { + console.error("Error creating user:", error) + return undefined + } } - async create(user: NewUser): Promise { - const now = new Date().toISOString() - return await this.db.transaction().execute(async (trx) => { - const createdUser = await trx - .insertInto("users") - .values({ - ...user, - created_at: now, - updated_at: now, + async update(id: UserTableId, updateWith: UpdateUser): Promise { + try { + await this.db + .updateTable("users") + .set({ + ...updateWith, + updated_at: new Date().toISOString(), }) - .returningAll() - .executeTakeFirstOrThrow() + .where("id", "=", id) + .execute() + + return this.findById(id) + } catch (error) { + console.error("Error updating user:", error) + return undefined + } + } - await trx + async addWallet(userId: UserTableId, walletAddress: Address): Promise { + try { + // Check if wallet already exists for any user + const existingWallet = await this.db + .selectFrom("user_wallets") + .select("id") + .where("wallet_address", "=", walletAddress) + .executeTakeFirst() + if (existingWallet) { + return false + } + // Insert as secondary wallet + await this.db .insertInto("user_wallets") .values({ - user_id: createdUser.id, - wallet_address: createdUser.primary_wallet, - is_primary: true, - created_at: now, + user_id: userId, + wallet_address: walletAddress, + is_primary: false, + created_at: new Date().toISOString(), }) .execute() - return createdUser - }) - } - - async update(id: UserTableId, updateWith: UpdateUser): Promise { - await this.db - .updateTable("users") - .set({ - ...updateWith, - updated_at: new Date().toISOString(), - }) - .where("id", "=", id) - .execute() - - return this.findById(id) - } - - async addWallet(userId: UserTableId, walletAddress: Address): Promise { - // Check if wallet already exists for any user - const existingWallet = await this.db - .selectFrom("user_wallets") - .select("id") - .where("wallet_address", "=", walletAddress) - .executeTakeFirst() - if (existingWallet) { + return true + } catch (error) { + console.error("Error adding wallet:", error) return false } - // Insert as secondary wallet - await this.db - .insertInto("user_wallets") - .values({ - user_id: userId, - wallet_address: walletAddress, - is_primary: false, - created_at: new Date().toISOString(), - }) - .execute() - return true } async setWalletAsPrimary(userId: UserTableId, walletAddress: Address): Promise { - const wallet = await this.db - .selectFrom("user_wallets") - .where("user_id", "=", userId) - .where("wallet_address", "=", walletAddress) - .executeTakeFirst() + try { + // Make sure the wallet exists first + await this.db + .selectFrom("user_wallets") + .where("user_id", "=", userId) + .where("wallet_address", "=", walletAddress) + .executeTakeFirstOrThrow() - if (!wallet) { + return await this.db.transaction().execute(async (trx) => { + await trx + .updateTable("users") + .set({ + primary_wallet: walletAddress, + updated_at: new Date().toISOString(), + }) + .where("id", "=", userId) + .execute() + + await trx.updateTable("user_wallets").set({ is_primary: false }).where("user_id", "=", userId).execute() + + await trx + .updateTable("user_wallets") + .set({ is_primary: true }) + .where("user_id", "=", userId) + .where("wallet_address", "=", walletAddress) + .execute() + + return true + }) + } catch (error) { + console.error("Error setting wallet as primary:", error) return false } + } - return await this.db.transaction().execute(async (trx) => { - await trx - .updateTable("users") - .set({ - primary_wallet: walletAddress, - updated_at: new Date().toISOString(), - }) - .where("id", "=", userId) - .execute() - - await trx.updateTable("user_wallets").set({ is_primary: false }).where("user_id", "=", userId).execute() + async removeWallet(userId: UserTableId, walletAddress: Address): Promise { + try { + const user = await this.findById(userId) + if (!user || user.primary_wallet === walletAddress) { + return false + } - await trx - .updateTable("user_wallets") - .set({ is_primary: true }) + const result = await this.db + .deleteFrom("user_wallets") .where("user_id", "=", userId) .where("wallet_address", "=", walletAddress) .execute() - return true - }) - } - - async removeWallet(userId: UserTableId, walletAddress: Address): Promise { - const user = await this.findById(userId) - if (!user || user.primary_wallet === walletAddress) { + return result.length > 0 + } catch (error) { + console.error("Error removing wallet:", error) return false } - - const result = await this.db - .deleteFrom("user_wallets") - .where("user_id", "=", userId) - .where("wallet_address", "=", walletAddress) - .execute() - - return result.length > 0 } async delete(id: UserTableId): Promise { - return await this.db.transaction().execute(async (trx) => { - const user = await trx.selectFrom("users").where("id", "=", id).selectAll().executeTakeFirst() + try { + return await this.db.transaction().execute(async (trx) => { + const user = await trx.selectFrom("users").where("id", "=", id).selectAll().executeTakeFirst() - if (!user) { - return undefined - } + if (!user) { + return undefined + } - await trx.deleteFrom("user_wallets").where("user_id", "=", id).execute() - await trx.deleteFrom("guild_members").where("user_id", "=", id).execute() - await trx.deleteFrom("user_game_scores").where("user_id", "=", id).execute() - await trx.deleteFrom("users").where("id", "=", id).execute() + await trx.deleteFrom("user_wallets").where("user_id", "=", id).execute() + await trx.deleteFrom("guild_members").where("user_id", "=", id).execute() + await trx.deleteFrom("user_game_scores").where("user_id", "=", id).execute() + await trx.deleteFrom("users").where("id", "=", id).execute() - return user - }) + return user + }) + } catch (error) { + console.error("Error deleting user:", error) + return undefined + } } } diff --git a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts index 4ac1c10cd1..830f553e12 100644 --- a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts @@ -275,9 +275,7 @@ export default new Hono() } // Update member role - // Convert role enum to boolean for backward compatibility with repository - const isAdmin = role === GuildRole.ADMIN - const updatedMember = await guildRepo.updateMemberRole(guildId, userId, isAdmin) + const updatedMember = await guildRepo.updateMemberRole(guildId, userId, role) if (!updatedMember) { return c.json({ ok: false, error: "Member not found in guild" }, 404) } diff --git a/bun.lock b/bun.lock index 3d84878ff8..5913ee7389 100644 --- a/bun.lock +++ b/bun.lock @@ -5884,26 +5884,6 @@ "@ethereumjs/util/ethereum-cryptography/@scure/bip39/@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="], - "@happy.tech/leaderboard-backend/viem/ox/@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.0", "", {}, "sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg=="], - - "@happy.tech/leaderboard-backend/viem/ox/@noble/curves": ["@noble/curves@1.9.0", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg=="], - - "@happy.tech/leaderboard-backend/viem/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - - "@happy.tech/leaderboard-backend/viem/ox/@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], - - "@happy.tech/leaderboard-backend/viem/ox/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], - - "@happy.tech/submitter/viem/ox/@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.0", "", {}, "sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg=="], - - "@happy.tech/submitter/viem/ox/@noble/curves": ["@noble/curves@1.9.0", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg=="], - - "@happy.tech/submitter/viem/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - - "@happy.tech/submitter/viem/ox/@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], - - "@happy.tech/submitter/viem/ox/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], - "@happy.tech/txm/vitest/@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "@happy.tech/txm/vitest/vite-node/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], @@ -5978,8 +5958,6 @@ "@toruslabs/torus.js/ethereum-cryptography/@scure/bip39/@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="], - "@toruslabs/tss-client/ethereum-cryptography/@scure/bip32/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], - "@vanilla-extract/compiler/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "@vanilla-extract/compiler/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], From 29f27bb035591f23666e2cbe68c2e0aab9febfa5 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Wed, 21 May 2025 17:21:08 +0530 Subject: [PATCH 11/23] refactor: remove redundant @security BearerAuth annotations from API route handlers [skip ci] --- apps/leaderboard-backend/src/routes/api/authRoutes.ts | 3 --- apps/leaderboard-backend/src/routes/api/gamesRoutes.ts | 3 --- .../leaderboard-backend/src/routes/api/guildsRoutes.ts | 5 ----- apps/leaderboard-backend/src/routes/api/usersRoutes.ts | 10 ---------- 4 files changed, 21 deletions(-) diff --git a/apps/leaderboard-backend/src/routes/api/authRoutes.ts b/apps/leaderboard-backend/src/routes/api/authRoutes.ts index 38fd7d2cc4..760643fa69 100644 --- a/apps/leaderboard-backend/src/routes/api/authRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/authRoutes.ts @@ -94,7 +94,6 @@ export default new Hono() /** * Get user info from session * GET /auth/me - * @security BearerAuth */ .get("/me", AuthMeDescription, requireAuth, async (c) => { try { @@ -128,7 +127,6 @@ export default new Hono() /** * Logout (delete session) * POST /auth/logout - * @security BearerAuth */ .post("/logout", AuthLogoutDescription, requireAuth, async (c) => { try { @@ -163,7 +161,6 @@ export default new Hono() /** * List all active sessions for a user * GET /auth/sessions - * @security BearerAuth */ .get("/sessions", AuthSessionsDescription, requireAuth, async (c) => { try { diff --git a/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts b/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts index 9813e41661..e2a69e521b 100644 --- a/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/gamesRoutes.ts @@ -97,7 +97,6 @@ export default new Hono() /** * Create a new game (admin required) * POST /games - * @security BearerAuth */ .post("/", requireAuth, GameCreateDescription, GameCreateValidation, async (c) => { try { @@ -137,7 +136,6 @@ export default new Hono() * Update game details (admin only) * PATCH /games/:id * Requires game ownership - only the game creator can update it - * @security BearerAuth */ .patch( "/:id", @@ -182,7 +180,6 @@ export default new Hono() /** * Submit a new score for a game * POST /games/:id/scores - * @security BearerAuth */ .post( "/:id/scores", diff --git a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts index 830f553e12..deed71fcb9 100644 --- a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts @@ -49,7 +49,6 @@ export default new Hono() /** * Create a new guild (creator becomes admin). * POST /guilds - * @security BearerAuth */ .post( "/", @@ -110,7 +109,6 @@ export default new Hono() * Update guild details (admin only). * PATCH /guilds/:id * Requires ADMIN role in the guild - * @security BearerAuth */ .patch( "/:id", @@ -182,7 +180,6 @@ export default new Hono() * Add a member to a guild (admin only). * POST /guilds/:id/members * Requires ADMIN role in the guild - * @security BearerAuth */ .post( "/:id/members", @@ -245,7 +242,6 @@ export default new Hono() * Update a guild member's role (admin only). * PATCH /guilds/:id/members/:member_id * Requires ADMIN role in the guild - * @security BearerAuth */ .patch( "/:id/members/:member_id", @@ -292,7 +288,6 @@ export default new Hono() * Remove a member from a guild (admin only). * DELETE /guilds/:id/members/:member_id * Requires ADMIN role in the guild - * @security BearerAuth */ .delete( "/:id/members/:member_id", diff --git a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts index 5bb0a77aa7..7ff3b3b12d 100644 --- a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts @@ -115,7 +115,6 @@ export default new Hono() * Update user details by user ID. * PATCH /users/:id * Requires ownership - only the user can update their own profile - * @security BearerAuth */ .patch( "/:id", @@ -158,7 +157,6 @@ export default new Hono() * Delete a user by user ID and all associated data. * DELETE /users/:id * Requires ownership - only the user can delete their own profile - * @security BearerAuth */ .delete( "/:id", @@ -219,7 +217,6 @@ export default new Hono() * Update user details by primary wallet address. * PATCH /users/pw/:primary_wallet * Requires ownership - only the user can update their own profile - * @security BearerAuth */ .patch( "/pw/:primary_wallet", @@ -261,7 +258,6 @@ export default new Hono() * Delete a user by primary wallet address and all associated wallets, guild memberships, and scores. * DELETE /users/pw/:primary_wallet * Requires ownership - only the user can delete their own profile - * @security BearerAuth */ .delete( "/pw/:primary_wallet", @@ -352,7 +348,6 @@ export default new Hono() * Add a wallet to a user by user ID. * POST /users/:id/wallets * Requires ownership - only the user can manage their own wallets - * @security BearerAuth */ .post( "/:id/wallets", @@ -399,7 +394,6 @@ export default new Hono() * Add a wallet to a user by primary wallet address. * POST /users/pw/:primary_wallet/wallets * Requires ownership - only the user can manage their own wallets - * @security BearerAuth */ .post( "/pw/:primary_wallet/wallets", @@ -452,7 +446,6 @@ export default new Hono() * Set a wallet as primary for a user by user ID. * PATCH /users/:id/wallets/primary * Requires ownership - only the user can manage their own wallets - * @security BearerAuth */ .patch( "/:id/wallets/primary", @@ -496,7 +489,6 @@ export default new Hono() * Set a wallet as primary for a user by primary wallet address. * PATCH /users/pw/:primary_wallet/wallets/primary * Requires ownership - only the user can manage their own wallets - * @security BearerAuth */ .patch( "/pw/:primary_wallet/wallets/primary", @@ -546,7 +538,6 @@ export default new Hono() * Remove a wallet from a user by user ID. * DELETE /users/:id/wallets * Requires ownership - only the user can manage their own wallets - * @security BearerAuth */ .delete( "/:id/wallets", @@ -597,7 +588,6 @@ export default new Hono() * Remove a wallet from a user by primary wallet address. * DELETE /users/pw/:primary_wallet/wallets * Requires ownership - only the user can manage their own wallets - * @security BearerAuth */ .delete( "/pw/:primary_wallet/wallets", From 5e1fecbf65add5dbe2e3db430dde933a66141380 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Thu, 22 May 2025 17:18:28 +0530 Subject: [PATCH 12/23] feat: add guild leave endpoint and refactor permission middleware [skip ci] --- apps/leaderboard-backend/.env.example | 6 +- .../src/auth/middlewares/guildAuth.ts | 69 +++++-------- .../src/auth/middlewares/permissions.ts | 16 +-- apps/leaderboard-backend/src/auth/roles.ts | 2 + .../src/routes/api/guildsRoutes.ts | 98 ++++++++++++++----- apps/leaderboard-backend/src/server.ts | 1 + .../validation/auth/authRouteValidations.ts | 2 - .../src/validation/games/gameSchemas.ts | 4 - .../guilds/guildRouteDescriptions.ts | 66 ++++++++++--- .../src/validation/guilds/guildSchemas.ts | 10 +- .../src/validation/users/userSchemas.ts | 2 - 11 files changed, 167 insertions(+), 109 deletions(-) diff --git a/apps/leaderboard-backend/.env.example b/apps/leaderboard-backend/.env.example index f01bfae19e..3b4cf3d32e 100644 --- a/apps/leaderboard-backend/.env.example +++ b/apps/leaderboard-backend/.env.example @@ -1,5 +1,5 @@ PORT=4545 -LEADERBOARD_DB_URL="leaderboard-backend.sqlite" -DATABASE_MIGRATE_DIR="migrations" +LEADERBOARD_DB_URL=leaderboard-backend.sqlite +DATABASE_MIGRATE_DIR=migrations SESSION_EXPIRY=86400 -RPC_URL="http://localhost:8545" +RPC_URL=http://localhost:8545 diff --git a/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts b/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts index dab273927d..237e8d00ce 100644 --- a/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts +++ b/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts @@ -1,73 +1,50 @@ import type { Context } from "hono" -import { createMiddleware } from "hono/factory" -import type { GuildTableId, UserTableId } from "../../db/types" -import { GuildRole, type RoleType } from "../roles" +import type { GuildTableId } from "../../db/types" +import { GuildRole, ResourceType } from "../roles" +import type { ActionType, RoleType } from "../roles" +import { requirePermission } from "./permissions" /** * Get the user's role in a guild based on their membership status and guild relationship * Returns GuildRole.CREATOR, GuildRole.ADMIN, GuildRole.MEMBER, or undefined if not a member */ -export async function getGuildUserRole(c: Context): Promise { - const userId = c.get("userId") as UserTableId - const guildId = c.req.param("id") - if (!guildId || !userId) return undefined +async function getGuildUserRole(c: Context): Promise { + const userId = c.get("userId") + const guild_id = c.req.param("id") + if (!guild_id || !userId) return undefined const { guildRepo } = c.get("repos") - const guildIdNum = Number.parseInt(guildId, 10) as GuildTableId + const guildId = Number.parseInt(guild_id, 10) as GuildTableId // First check if user is the creator - const guild = await guildRepo.findById(guildIdNum) + const guild = await guildRepo.findById(guildId) if (!guild) return undefined if (guild.creator_id === userId) { + c.set("guildRole", GuildRole.CREATOR) // Store role in context for potential use in handlers return GuildRole.CREATOR } // Then check member status - const member = await guildRepo.findGuildMember(guildIdNum, userId) + const member = await guildRepo.findGuildMember(guildId, userId) if (!member) return undefined + // Store role in context for potential use in handlers + c.set("guildRole", member.role) + // Return the role from the member record return member.role } /** - * Middleware that requires a specific guild role to access a route - * @param role The minimum required role (MEMBER, ADMIN, or CREATOR) + * Middleware factory for guild operations that combines permission checking + * @param action The action being performed on the guild + * @returns A middleware that checks if the user has permission to perform the action */ -export const requireGuildRole = (role: keyof typeof GuildRole) => { - return createMiddleware(async (c, next) => { - const userId = c.get("userId") as UserTableId - const guildId = c.req.param("id") - const { guildRepo } = c.get("repos") - - const guild = await guildRepo.findById(Number(guildId) as GuildTableId) - if (!guild) { - return c.json({ error: "Guild not found", ok: false }, 404) - } - - if (role === "CREATOR" && guild.creator_id !== userId) { - return c.json({ error: "Only the guild creator can perform this action", ok: false }, 403) - } - - if (role === "MEMBER" || role === "ADMIN") { - const member = await guildRepo.findGuildMember(Number(guildId) as GuildTableId, userId) - - if (!member) { - return c.json({ error: "You are not a member of this guild", ok: false }, 403) - } - - // Check if the member's role matches the required role - // For ADMIN, the member must have ADMIN or CREATOR role - // For MEMBER, any role is sufficient (MEMBER, ADMIN, or CREATOR) - if (role === "ADMIN" && member.role !== GuildRole.ADMIN && member.role !== GuildRole.CREATOR) { - return c.json({ error: "Admin privileges required", ok: false }, 403) - } - } - - // Store the role in context for potential use by other middlewares - c.set("guildRole", GuildRole[role]) - - await next() +export const guildAction = (action: ActionType) => { + return requirePermission({ + resource: ResourceType.GUILD, + action, + getUserRole: getGuildUserRole, }) } diff --git a/apps/leaderboard-backend/src/auth/middlewares/permissions.ts b/apps/leaderboard-backend/src/auth/middlewares/permissions.ts index ef1ad27459..812f82a8fe 100644 --- a/apps/leaderboard-backend/src/auth/middlewares/permissions.ts +++ b/apps/leaderboard-backend/src/auth/middlewares/permissions.ts @@ -1,6 +1,6 @@ import type { Context, MiddlewareHandler } from "hono" import { createMiddleware } from "hono/factory" -import { type ActionType, Permissions, type ResourceType, type RoleType, UserRole } from "../roles" +import { type ActionType, Permissions, type ResourceType, type RoleType } from "../roles" /** * Generic permissions middleware for resource-based RBAC. @@ -12,7 +12,7 @@ import { type ActionType, Permissions, type ResourceType, type RoleType, UserRol * * @param opts.resource: ResourceType - The resource type (e.g., "guild") * @param opts.action - The action type (e.g., "add_member") - * @param opts.getUserRole - Optional: custom function to get the user's role for the resource + * @param opts.getUserRole - Custom function to get the user's role for the resource */ export function requirePermission({ resource, @@ -21,19 +21,11 @@ export function requirePermission({ }: { resource: ResourceType action: ActionType - getUserRole?: (c: Context) => Promise + getUserRole: (c: Context) => Promise }): MiddlewareHandler { return createMiddleware(async (c, next) => { // Determine the user's role for this resource - let role: RoleType | undefined - if (getUserRole) { - role = await getUserRole(c) - } else { - // Default: look for c.get("guildRole"), c.get("gameRole"), etc. - role = c.get("role") || c.get("guildRole") || c.get("gameRole") || c.get("userRole") - // If nothing found, fallback to UserRole.AUTHENTICATED - if (!role) role = UserRole.AUTHENTICATED - } + const role = await getUserRole(c) // Find allowed roles from the Permissions object const allowed = Permissions?.[resource]?.[action] diff --git a/apps/leaderboard-backend/src/auth/roles.ts b/apps/leaderboard-backend/src/auth/roles.ts index 63fcef46a6..31d41d1a94 100644 --- a/apps/leaderboard-backend/src/auth/roles.ts +++ b/apps/leaderboard-backend/src/auth/roles.ts @@ -30,6 +30,7 @@ export enum ActionType { ADD_MEMBER = "add_member", REMOVE_MEMBER = "remove_member", PROMOTE_MEMBER = "promote_member", + LEAVE = "leave", SUBMIT_SCORE = "submit_score", MANAGE_SCORES = "manage_scores", } @@ -50,6 +51,7 @@ export const Permissions: Record { @@ -113,7 +114,7 @@ export default new Hono() .patch( "/:id", requireAuth, - requirePermission({ resource: ResourceType.GUILD, action: ActionType.UPDATE, getUserRole: getGuildUserRole }), + guildAction(ActionType.UPDATE), GuildUpdateDescription, GuildIdParamValidation, GuildUpdateValidation, @@ -184,11 +185,7 @@ export default new Hono() .post( "/:id/members", requireAuth, - requirePermission({ - resource: ResourceType.GUILD, - action: ActionType.ADD_MEMBER, - getUserRole: getGuildUserRole, - }), + guildAction(ActionType.ADD_MEMBER), GuildMemberAddDescription, GuildIdParamValidation, GuildMemberAddValidation, @@ -246,11 +243,7 @@ export default new Hono() .patch( "/:id/members/:member_id", requireAuth, - requirePermission({ - resource: ResourceType.GUILD, - action: ActionType.PROMOTE_MEMBER, - getUserRole: getGuildUserRole, - }), + guildAction(ActionType.PROMOTE_MEMBER), GuildMemberUpdateDescription, GuildIdParamValidation, GuildMemberIdParamValidation, @@ -292,18 +285,13 @@ export default new Hono() .delete( "/:id/members/:member_id", requireAuth, - requirePermission({ - resource: ResourceType.GUILD, - action: ActionType.REMOVE_MEMBER, - getUserRole: getGuildUserRole, - }), + guildAction(ActionType.REMOVE_MEMBER), GuildMemberDeleteDescription, GuildIdParamValidation, GuildMemberIdParamValidation, async (c) => { try { const { id, member_id } = c.req.valid("param") - const { guildRepo } = c.get("repos") // Check if guild exists @@ -314,22 +302,88 @@ export default new Hono() return c.json({ ok: false, error: "Guild not found" }, 404) } + // Check if the user is a member of the guild + const member = await guildRepo.findGuildMember(guildId, userId) + if (!member) { + return c.json({ ok: false, error: "User is not a member of this guild" }, 404) + } + + // Check if trying to remove the guild creator + if (member.role === GuildRole.CREATOR) { + return c.json({ ok: false, error: "Cannot remove the guild creator" }, 400) + } + // Remove member from guild const removedMember = await guildRepo.removeMember(guildId, userId) if (!removedMember) { return c.json( { ok: false, - error: "Cannot remove member: user may be the guild creator or not a member", + error: "Failed to remove member", }, - 400, + 500, ) } - return c.json({ ok: true, data: { removed: true } }, 200) + return c.json({ ok: true, data: removedMember }, 200) } catch (err) { console.error(`Error removing member from guild ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) } }, ) + + /** + * Leave a guild (user can leave a guild they are a member of) + * POST /guilds/:id/leave + */ + .post( + "/:id/leave", + requireAuth, + guildAction(ActionType.LEAVE), + GuildLeaveDescription, + GuildIdParamValidation, + async (c) => { + try { + const { id } = c.req.valid("param") + const userId = c.get("userId") + const userRole = c.get("guildRole") + const { guildRepo } = c.get("repos") + + // Check if guild exists + const guildId = id as GuildTableId + const guild = await guildRepo.findById(guildId) + if (!guild) { + return c.json({ ok: false, error: "Guild not found" }, 404) + } + + // Check if the user is a member of the guild + const member = await guildRepo.findGuildMember(guildId, userId) + if (!member) { + return c.json({ ok: false, error: "You are not a member of this guild" }, 403) + } + + // Prevent creator from leaving their own guild + if (userRole === GuildRole.CREATOR) { + return c.json( + { + ok: false, + error: "Guild creators cannot leave their own guild. Transfer ownership first or delete the guild.", + }, + 400, + ) + } + + // Remove the user from the guild + const removedMember = await guildRepo.removeMember(guildId, userId) + if (!removedMember) { + return c.json({ ok: false, error: "Failed to leave guild" }, 500) + } + + return c.json({ ok: true, data: removedMember }, 200) + } catch (err) { + console.error(`Error leaving guild ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }, + ) diff --git a/apps/leaderboard-backend/src/server.ts b/apps/leaderboard-backend/src/server.ts index b965f88a39..6987cc731d 100644 --- a/apps/leaderboard-backend/src/server.ts +++ b/apps/leaderboard-backend/src/server.ts @@ -28,6 +28,7 @@ declare module "hono" { userId: UserTableId // Authenticated user's ID, set by requireAuth primaryWallet: Address // Authenticated user's wallet address, set by requireAuth sessionId: AuthSessionTableId // Current session ID, set by requireAuth + guildRole: string // User's role in a guild, set by guild middleware } } diff --git a/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts b/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts index ca302be5e0..82bb0abba1 100644 --- a/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts +++ b/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts @@ -9,12 +9,10 @@ import { SessionListDataSchema, } from "./authSchemas" -// Export the resolved schemas for Hono export const AuthResponseSchemaObj = resolver(createSuccessResponseSchema(AuthResponseDataSchema)) export const AuthChallengeResponseSchemaObj = resolver(createSuccessResponseSchema(AuthChallengeDataSchema)) export const SessionListResponseSchemaObj = resolver(createSuccessResponseSchema(SessionListDataSchema)) -// Export the validators for Hono export const AuthChallengeValidation = zValidator("json", AuthChallengeRequestSchema) export const AuthVerifyValidation = zValidator("json", AuthVerifyRequestSchema) export const SessionIdValidation = zValidator("json", SessionIdRequestSchema) diff --git a/apps/leaderboard-backend/src/validation/games/gameSchemas.ts b/apps/leaderboard-backend/src/validation/games/gameSchemas.ts index 4ea45b2b93..9e43b4bc6f 100644 --- a/apps/leaderboard-backend/src/validation/games/gameSchemas.ts +++ b/apps/leaderboard-backend/src/validation/games/gameSchemas.ts @@ -143,7 +143,6 @@ export const GameIdParamSchema = z .object({ id: z.string().transform((val) => Number.parseInt(val)), }) - .strict() .openapi({ example: { id: "1" }, }) @@ -152,7 +151,6 @@ export const AdminIdParamSchema = z .object({ admin_id: z.string().transform((val) => Number.parseInt(val)), }) - .strict() .openapi({ example: { admin_id: "1" }, }) @@ -161,7 +159,6 @@ export const AdminWalletParamSchema = z .object({ admin_wallet: z.string().refine(isHex, { message: "Admin wallet must be a valid hex string" }), }) - .strict() .openapi({ example: { admin_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa" }, }) @@ -170,7 +167,6 @@ export const UserWalletParamSchema = z .object({ user_wallet: z.string().refine(isHex, { message: "User wallet must be a valid hex string" }), }) - .strict() .openapi({ example: { user_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa" }, }) diff --git a/apps/leaderboard-backend/src/validation/guilds/guildRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/guilds/guildRouteDescriptions.ts index e16a306ee8..9ea4754caa 100644 --- a/apps/leaderboard-backend/src/validation/guilds/guildRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/guilds/guildRouteDescriptions.ts @@ -1,6 +1,11 @@ import { describeRoute } from "hono-openapi" import { ErrorResponseSchemaObj } from "../common" -import { GuildListResponseSchemaObj, GuildResponseSchemaObj } from "./guildRouteValidations" +import { + GuildListResponseSchemaObj, + GuildMemberListResponseSchemaObj, + GuildMemberResponseSchemaObj, + GuildResponseSchemaObj, +} from "./guildRouteValidations" // ==================================================================================================== // Guild Collection @@ -137,7 +142,7 @@ export const GuildListMembersDescription = describeRoute({ description: "Successfully retrieved guild members.", content: { "application/json": { - schema: {}, + schema: GuildMemberListResponseSchemaObj, }, }, }, @@ -162,7 +167,7 @@ export const GuildListMembersDescription = describeRoute({ export const GuildMemberAddDescription = describeRoute({ validateResponse: false, - description: "Add a member to a guild (admin only).", + description: "Add a member to a guild.", requestBody: { required: true, content: { @@ -176,7 +181,7 @@ export const GuildMemberAddDescription = describeRoute({ description: "Member added successfully.", content: { "application/json": { - schema: {}, + schema: GuildMemberResponseSchemaObj, }, }, }, @@ -209,7 +214,7 @@ export const GuildMemberAddDescription = describeRoute({ export const GuildMemberUpdateDescription = describeRoute({ validateResponse: false, - description: "Update a guild member's role (admin only).", + description: "Update a guild member's role.", requestBody: { required: true, content: { @@ -220,10 +225,10 @@ export const GuildMemberUpdateDescription = describeRoute({ }, responses: { 200: { - description: "Member updated successfully.", + description: "Member role updated successfully.", content: { "application/json": { - schema: {}, + schema: GuildMemberResponseSchemaObj, }, }, }, @@ -248,18 +253,18 @@ export const GuildMemberUpdateDescription = describeRoute({ export const GuildMemberDeleteDescription = describeRoute({ validateResponse: false, - description: "Remove a member from a guild (admin only).", + description: "Remove a member from a guild.", responses: { 200: { description: "Member removed successfully.", content: { "application/json": { - schema: {}, + schema: GuildMemberResponseSchemaObj, }, }, }, 404: { - description: "Guild or member not found, or user is the creator.", + description: "Guild or member not found.", content: { "application/json": { schema: ErrorResponseSchemaObj, @@ -267,7 +272,46 @@ export const GuildMemberDeleteDescription = describeRoute({ }, }, 400: { - description: "Invalid parameters or removal not allowed.", + description: "Invalid request parameters or cannot remove guild creator.", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, + }, +}) + +export const GuildLeaveDescription = describeRoute({ + validateResponse: false, + description: "Leave a guild.", + responses: { + 200: { + description: "Successfully left the guild.", + content: { + "application/json": { + schema: GuildMemberResponseSchemaObj, + }, + }, + }, + 404: { + description: "Guild not found.", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, + 400: { + description: "Invalid request parameters or user is the guild creator.", + content: { + "application/json": { + schema: ErrorResponseSchemaObj, + }, + }, + }, + 403: { + description: "Not a member of this guild.", content: { "application/json": { schema: ErrorResponseSchemaObj, diff --git a/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts b/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts index 3a1396fec1..de3251e347 100644 --- a/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts +++ b/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts @@ -35,11 +35,9 @@ export const GuildMemberResponseSchema = z joined_at: z.string(), // Extended properties when including user details username: z.string().optional(), - primary_wallet: z - .string() - .refine(isHex) - .transform((val) => val as Address) - .optional(), + primary_wallet: z.string().refine(isHex).optional().openapi({ + type: "string", + }), }) .strict() .openapi({ @@ -143,7 +141,6 @@ export const GuildIdParamSchema = z .object({ id: z.string().transform((val) => Number.parseInt(val)), }) - .strict() .openapi({ example: { id: "1", @@ -154,7 +151,6 @@ export const GuildMemberIdParamSchema = z .object({ member_id: z.string().transform((val) => Number.parseInt(val)), }) - .strict() .openapi({ example: { member_id: "1", diff --git a/apps/leaderboard-backend/src/validation/users/userSchemas.ts b/apps/leaderboard-backend/src/validation/users/userSchemas.ts index 7508afc23e..164fcf13a5 100644 --- a/apps/leaderboard-backend/src/validation/users/userSchemas.ts +++ b/apps/leaderboard-backend/src/validation/users/userSchemas.ts @@ -119,7 +119,6 @@ export const UserIdParamSchema = z .object({ id: z.string().transform((val) => Number.parseInt(val)), }) - .strict() .openapi({ param: { name: "id", @@ -134,7 +133,6 @@ export const PrimaryWalletParamSchema = z .object({ primary_wallet: z.string().refine(isHex, { message: "Primary wallet address must be a valid hex string" }), }) - .strict() .openapi({ param: { name: "primary_wallet", From cc19dcfac857940811b4142d99c1814476dc5737 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Thu, 22 May 2025 17:49:29 +0530 Subject: [PATCH 13/23] feat: restrict game score submission to game creators only [skip ci] --- .../src/auth/middlewares/gameAuth.ts | 50 ++++++++++++++----- apps/leaderboard-backend/src/auth/roles.ts | 2 +- .../src/routes/api/gamesRoutes.ts | 5 +- .../validation/games/gameRouteDescriptions.ts | 2 +- .../src/validation/guilds/guildSchemas.ts | 2 +- 5 files changed, 43 insertions(+), 18 deletions(-) diff --git a/apps/leaderboard-backend/src/auth/middlewares/gameAuth.ts b/apps/leaderboard-backend/src/auth/middlewares/gameAuth.ts index d1d4f8801e..3ae63fa0f5 100644 --- a/apps/leaderboard-backend/src/auth/middlewares/gameAuth.ts +++ b/apps/leaderboard-backend/src/auth/middlewares/gameAuth.ts @@ -1,20 +1,44 @@ -import { createMiddleware } from "hono/factory" -import type { GameTableId, UserTableId } from "../../db/types" +import type { Context } from "hono" +import type { GameTableId } from "../../db/types" +import { GameRole, ResourceType } from "../roles" +import type { ActionType, RoleType } from "../roles" +import { requirePermission } from "./permissions" + +/** + * Get the user's role in a game based on their relationship to the game + * Returns GameRole.CREATOR or GameRole.PLAYER + */ +async function getGameUserRole(c: Context): Promise { + const userId = c.get("userId") + const game_id = c.req.param("id") + if (!userId || !game_id) return undefined -export const requireGameOwnership = createMiddleware(async (c, next) => { - const userId = c.get("userId") as UserTableId - const gameId = c.req.param("id") const { gameRepo } = c.get("repos") + const gameId = Number.parseInt(game_id, 10) as GameTableId - const game = await gameRepo.findById(Number(gameId) as GameTableId) + // Check if game exists + const game = await gameRepo.findById(gameId) + if (!game) return undefined - if (!game) { - return c.json({ error: "Game not found", ok: false }, 404) + // Check if user is the creator + if (game.admin_id === userId) { + c.set("gameRole", GameRole.CREATOR) + return GameRole.CREATOR } - if (game.admin_id !== userId) { - return c.json({ error: "Only the game creator can perform this action", ok: false }, 403) - } + // For games, all authenticated users are considered players + return GameRole.PLAYER +} - await next() -}) +/** + * Middleware factory for game operations that combines permission checking + * @param action The action being performed on the game + * @returns A middleware that checks if the user has permission to perform the action + */ +export const gameAction = (action: ActionType) => { + return requirePermission({ + resource: ResourceType.GAME, + action, + getUserRole: getGameUserRole, + }) +} diff --git a/apps/leaderboard-backend/src/auth/roles.ts b/apps/leaderboard-backend/src/auth/roles.ts index 31d41d1a94..5778ebdfbd 100644 --- a/apps/leaderboard-backend/src/auth/roles.ts +++ b/apps/leaderboard-backend/src/auth/roles.ts @@ -58,7 +58,7 @@ export const Permissions: Record Date: Thu, 22 May 2025 19:33:54 +0530 Subject: [PATCH 14/23] refactor: replace requireOwnership with role-based userAction middleware [skip ci] --- .../src/auth/middlewares/userAuth.ts | 65 ++- .../src/routes/api/usersRoutes.ts | 528 +++++++++--------- .../validation/users/userRouteDescriptions.ts | 3 +- 3 files changed, 319 insertions(+), 277 deletions(-) diff --git a/apps/leaderboard-backend/src/auth/middlewares/userAuth.ts b/apps/leaderboard-backend/src/auth/middlewares/userAuth.ts index 97e8191f0f..bc14979c21 100644 --- a/apps/leaderboard-backend/src/auth/middlewares/userAuth.ts +++ b/apps/leaderboard-backend/src/auth/middlewares/userAuth.ts @@ -1,19 +1,64 @@ -import { createMiddleware } from "hono/factory" +import type { Context } from "hono" import type { UserTableId } from "../../db/types" +import { ActionType, ResourceType, UserRole } from "../roles" +import type { RoleType } from "../roles" +import { requirePermission } from "./permissions" -export const requireOwnership = (paramName: string, idType: "id" | "primary_wallet") => { - return createMiddleware(async (c, next) => { - const userId = c.get("userId") as UserTableId - const resourceId = c.req.param(paramName) +/** + * Get the user's role in relation to the request context + * For actions that require SELF permission (UPDATE, DELETE, MANAGE), + * we check if the authenticated user is accessing their own resources. + * + * @param c - The context from the request + * @param action - The action being performed (optional) + * @returns The role of the user (SELF or AUTHENTICATED) + */ +async function getUserRole(c: Context, action?: ActionType): Promise { + const authenticatedUserId = c.get("userId") + if (!authenticatedUserId) return undefined - if (idType === "id" && userId !== (Number(resourceId) as UserTableId)) { - return c.json({ error: "You can only access your own resources", ok: false }, 403) + // Get target identifiers from route params + const targetIdParam = c.req.param("id") + const targetWalletParam = c.req.param("primary_wallet") + + // For sensitive operations (UPDATE, DELETE, MANAGE) we need to verify identity + const requiresSelfCheck = + action === ActionType.UPDATE || action === ActionType.DELETE || action === ActionType.MANAGE + + // If we're checking a route that operates on a specific user + if (requiresSelfCheck) { + // Check if accessing by user ID + if (targetIdParam) { + const targetUserId = Number.parseInt(targetIdParam, 10) as UserTableId + if (authenticatedUserId === targetUserId) { + c.set("userRole", UserRole.SELF) + return UserRole.SELF + } } - if (idType === "primary_wallet" && c.get("primaryWallet") !== resourceId) { - return c.json({ error: "You can only access your own resources", ok: false }, 403) + // Check if accessing by primary wallet + if (targetWalletParam) { + const authenticatedWallet = c.get("primaryWallet") + if (authenticatedWallet === targetWalletParam) { + c.set("userRole", UserRole.SELF) + return UserRole.SELF + } } + } + + // Otherwise the user is just an authenticated user accessing someone else's resources + return UserRole.AUTHENTICATED +} - await next() +/** + * Middleware factory for user operations that combines permission checking + * @param action The action being performed on the user resource + * @returns A middleware that checks if the user has permission to perform the action + */ +export const userAction = (action: ActionType) => { + return requirePermission({ + resource: ResourceType.USER, + action, + getUserRole: (c) => getUserRole(c, action), }) } diff --git a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts index 7ff3b3b12d..403b0ad11e 100644 --- a/apps/leaderboard-backend/src/routes/api/usersRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/usersRoutes.ts @@ -1,5 +1,5 @@ import { Hono } from "hono" -import { requireAuth, requireOwnership } from "../../auth" +import { ActionType, requireAuth, userAction } from "../../auth" import type { UserTableId } from "../../db/types" import { PrimaryWalletParamValidation, @@ -86,109 +86,6 @@ export default new Hono() } }) - // ==================================================================================================== - // User Resource Routes (by ID) - - /** - * Get user details by user ID. - * GET /users/:id - */ - .get("/:id", UserGetByIdDescription, UserIdParamValidation, async (c) => { - try { - const { id } = c.req.valid("param") - const { userRepo } = c.get("repos") - - const userTableId = id as UserTableId - const user = await userRepo.findById(userTableId, true) // Include wallets - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } - - return c.json({ ok: true, data: user }, 200) - } catch (err) { - console.error(`Error fetching user ${c.req.param("id")}:`, err) - return c.json({ ok: false, error: "Internal Server Error" }, 500) - } - }) - - /** - * Update user details by user ID. - * PATCH /users/:id - * Requires ownership - only the user can update their own profile - */ - .patch( - "/:id", - requireAuth, - requireOwnership("id", "id"), - UserUpdateByIdDescription, - UserIdParamValidation, - UserUpdateValidation, - async (c) => { - try { - const { id } = c.req.valid("param") - const updateData = c.req.valid("json") - const { userRepo } = c.get("repos") - - // Check if user exists - const userId = id as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } - - // Check if username is being changed and is unique - if (updateData.username && updateData.username !== user.username) { - const existingUser = await userRepo.findByUsername(updateData.username) - if (existingUser) { - return c.json({ ok: false, error: "Username already taken" }, 409) - } - } - - const updatedUser = await userRepo.update(userId, updateData) - return c.json({ ok: true, data: updatedUser }, 200) - } catch (err) { - console.error(`Error updating user ${c.req.param("id")}:`, err) - return c.json({ ok: false, error: "Internal Server Error" }, 500) - } - }, - ) - - /** - * Delete a user by user ID and all associated data. - * DELETE /users/:id - * Requires ownership - only the user can delete their own profile - */ - .delete( - "/:id", - requireAuth, - requireOwnership("id", "id"), - UserDeleteByIdDescription, - UserIdParamValidation, - async (c) => { - try { - const { id } = c.req.valid("param") - const { userRepo } = c.get("repos") - - // Check if user exists - const userId = id as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } - - const success = await userRepo.delete(userId) - if (!success) { - return c.json({ ok: false, error: "User not found" }, 404) - } - - return c.json({ ok: true, data: user }, 200) - } catch (err) { - console.error(`Error deleting user ${c.req.param("id")}:`, err) - return c.json({ ok: false, error: "Internal Server Error" }, 500) - } - }, - ) - // ==================================================================================================== // User Resource Routes (by Primary Wallet Address) @@ -208,7 +105,7 @@ export default new Hono() return c.json({ ok: true, data: user }, 200) } catch (err) { - console.error(`Error fetching user by wallet ${c.req.param("primary_wallet")}:`, err) + console.error(`Error fetching user with wallet ${c.req.param("primary_wallet")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) } }) @@ -221,7 +118,7 @@ export default new Hono() .patch( "/pw/:primary_wallet", requireAuth, - requireOwnership("primary_wallet", "primary_wallet"), + userAction(ActionType.UPDATE), UserUpdateByPrimaryWalletDescription, PrimaryWalletParamValidation, UserUpdateValidation, @@ -255,14 +152,14 @@ export default new Hono() ) /** - * Delete a user by primary wallet address and all associated wallets, guild memberships, and scores. + * Delete a user by primary wallet address and all associated data. * DELETE /users/pw/:primary_wallet * Requires ownership - only the user can delete their own profile */ .delete( "/pw/:primary_wallet", requireAuth, - requireOwnership("primary_wallet", "primary_wallet"), + userAction(ActionType.DELETE), UserDeleteByPrimaryWalletDescription, PrimaryWalletParamValidation, async (c) => { @@ -290,34 +187,10 @@ export default new Hono() ) // ==================================================================================================== - // User Wallets Collection Routes + // User Wallet Routes (by Primary Wallet Address) /** - * Get all wallets for a user by user ID. - * GET /users/:id/wallets - */ - .get("/:id/wallets", UserWalletsGetByIdDescription, UserIdParamValidation, async (c) => { - try { - const { id } = c.req.valid("param") - const { userRepo } = c.get("repos") - - // Check if user exists - const userId = id as UserTableId - const user = await userRepo.findById(userId) - if (!user) { - return c.json({ ok: false, error: "User not found" }, 404) - } - - const wallets = await userRepo.getUserWallets(userId) - return c.json({ ok: true, data: wallets }, 200) - } catch (err) { - console.error(`Error fetching wallets for user ${c.req.param("id")}:`, err) - return c.json({ ok: false, error: "Internal Server Error" }, 500) - } - }) - - /** - * Get all wallets for a user by primary wallet address. + * Get wallets for a user by primary wallet address. * GET /users/pw/:primary_wallet/wallets */ .get( @@ -329,7 +202,6 @@ export default new Hono() const { primary_wallet } = c.req.valid("param") const { userRepo } = c.get("repos") - // Check if user exists const user = await userRepo.findByWalletAddress(primary_wallet) if (!user) { return c.json({ ok: false, error: "User not found" }, 404) @@ -345,61 +217,59 @@ export default new Hono() ) /** - * Add a wallet to a user by user ID. - * POST /users/:id/wallets - * Requires ownership - only the user can manage their own wallets + * Add a wallet address to a user by primary wallet address. + * POST /users/pw/:primary_wallet/wallets + * Requires ownership - only the user can add wallets to their own profile */ .post( - "/:id/wallets", + "/pw/:primary_wallet/wallets", requireAuth, - requireOwnership("id", "id"), - UserWalletAddByIdDescription, - UserIdParamValidation, + userAction(ActionType.UPDATE), + UserWalletAddByPrimaryWalletDescription, + PrimaryWalletParamValidation, UserWalletValidation, async (c) => { try { - const { id } = c.req.valid("param") + const { primary_wallet } = c.req.valid("param") const { wallet_address } = c.req.valid("json") const { userRepo } = c.get("repos") // Check if user exists - const userId = id as UserTableId - const user = await userRepo.findById(userId) + const user = await userRepo.findByWalletAddress(primary_wallet) if (!user) { return c.json({ ok: false, error: "User not found" }, 404) } - // Check if wallet already belongs to another user + // Check if wallet address is already registered to any user const existingUser = await userRepo.findByWalletAddress(wallet_address) - if (existingUser && existingUser.id !== userId) { - return c.json({ ok: false, error: "Wallet already registered to another user" }, 409) + if (existingUser) { + return c.json({ ok: false, error: "Wallet address already registered to another user" }, 409) } - const success = await userRepo.addWallet(userId, wallet_address) - if (!success) { - return c.json({ ok: false, error: "Wallet already exists for this user" }, 409) + const walletAdded = await userRepo.addWallet(user.id, wallet_address) + if (!walletAdded) { + return c.json({ ok: false, error: "Failed to add wallet" }, 500) } - // Get updated user with wallets - const updatedUser = await userRepo.findById(userId, true) - return c.json({ ok: true, data: updatedUser }, 200) + const updatedUser = await userRepo.findById(user.id) + return c.json({ ok: true, data: updatedUser }, 201) } catch (err) { - console.error(`Error adding wallet to user ${c.req.param("id")}:`, err) + console.error(`Error adding wallet for user with wallet ${c.req.param("primary_wallet")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) } }, ) /** - * Add a wallet to a user by primary wallet address. - * POST /users/pw/:primary_wallet/wallets - * Requires ownership - only the user can manage their own wallets + * Set a wallet as primary for a user by primary wallet address. + * PATCH /users/pw/:primary_wallet/wallets/primary + * Requires ownership - only the user can update their own wallets */ - .post( - "/pw/:primary_wallet/wallets", + .patch( + "/pw/:primary_wallet/wallets/primary", requireAuth, - requireOwnership("primary_wallet", "primary_wallet"), - UserWalletAddByPrimaryWalletDescription, + userAction(ActionType.UPDATE), + UserWalletSetPrimaryByPrimaryWalletDescription, PrimaryWalletParamValidation, UserWalletValidation, async (c) => { @@ -408,56 +278,128 @@ export default new Hono() const { wallet_address } = c.req.valid("json") const { userRepo } = c.get("repos") - if (primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Primary wallet already exists for this user" }, 400) + // Check if user exists + const user = await userRepo.findByWalletAddress(primary_wallet) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) } + // Check if the wallet address belongs to the user + const wallets = await userRepo.getUserWallets(user.id) + if (!wallets.some((wallet) => wallet.wallet_address === wallet_address)) { + return c.json({ ok: false, error: "Wallet address does not belong to this user" }, 404) + } + + const walletSetToPrimary = await userRepo.setWalletAsPrimary(user.id, wallet_address) + if (!walletSetToPrimary) { + return c.json({ ok: false, error: "Failed to set wallet as primary" }, 500) + } + + const updatedUser = await userRepo.findById(user.id) + return c.json({ ok: true, data: updatedUser }, 201) + } catch (err) { + console.error( + `Error setting primary wallet for user with wallet ${c.req.param("primary_wallet")}:`, + err, + ) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }, + ) + + /** + * Remove a wallet address from a user by primary wallet address. + * DELETE /users/pw/:primary_wallet/wallets + * Requires ownership - only the user can remove wallets from their own profile + */ + .delete( + "/pw/:primary_wallet/wallets", + requireAuth, + userAction(ActionType.UPDATE), + UserWalletRemoveByPrimaryWalletDescription, + PrimaryWalletParamValidation, + UserWalletValidation, + async (c) => { + try { + const { primary_wallet } = c.req.valid("param") + const { wallet_address } = c.req.valid("json") + const { userRepo } = c.get("repos") + // Check if user exists const user = await userRepo.findByWalletAddress(primary_wallet) if (!user) { return c.json({ ok: false, error: "User not found" }, 404) } - // Check if wallet already belongs to another user - const existingUser = await userRepo.findByWalletAddress(wallet_address) - if (existingUser && existingUser.id !== user.id) { - return c.json({ ok: false, error: "Wallet already registered to another user" }, 409) + // Cannot remove primary wallet + if (user.primary_wallet === wallet_address) { + return c.json( + { ok: false, error: "Cannot remove primary wallet, set another wallet as primary first" }, + 400, + ) } - const success = await userRepo.addWallet(user.id, wallet_address) - if (!success) { - return c.json({ ok: false, error: "Wallet already exists for this user" }, 409) + // Check if the wallet address belongs to the user + const wallets = await userRepo.getUserWallets(user.id) + if (!wallets.some((wallet) => wallet.wallet_address === wallet_address)) { + return c.json({ ok: false, error: "Wallet address does not belong to this user" }, 404) + } + + const walletRemoved = await userRepo.removeWallet(user.id, wallet_address) + if (!walletRemoved) { + return c.json({ ok: false, error: "Failed to remove wallet" }, 500) } - // Get updated user with wallets - const updatedUser = await userRepo.findById(user.id, true) + const updatedUser = await userRepo.findById(user.id) return c.json({ ok: true, data: updatedUser }, 200) } catch (err) { - console.error(`Error adding wallet to user with wallet ${c.req.param("primary_wallet")}:`, err) + console.error(`Error removing wallet for user with wallet ${c.req.param("primary_wallet")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) } }, ) // ==================================================================================================== - // User Wallet Resource Routes (Set Primary, Remove) + // User Resource Routes (by ID) /** - * Set a wallet as primary for a user by user ID. - * PATCH /users/:id/wallets/primary - * Requires ownership - only the user can manage their own wallets + * Get user details by user ID. + * GET /users/:id + */ + .get("/:id", UserGetByIdDescription, UserIdParamValidation, async (c) => { + try { + const { id } = c.req.valid("param") + const { userRepo } = c.get("repos") + + const userTableId = id as UserTableId + const user = await userRepo.findById(userTableId, true) // Include wallets + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } + + return c.json({ ok: true, data: user }, 200) + } catch (err) { + console.error(`Error fetching user ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }) + + /** + * Update user details by user ID. + * PATCH /users/:id + * Requires ownership - only the user can update their own profile */ .patch( - "/:id/wallets/primary", + "/:id", requireAuth, - requireOwnership("id", "id"), - UserWalletSetPrimaryByIdDescription, + userAction(ActionType.UPDATE), + UserUpdateByIdDescription, UserIdParamValidation, - UserWalletValidation, + UserUpdateValidation, async (c) => { try { const { id } = c.req.valid("param") - const { wallet_address } = c.req.valid("json") + const updateData = c.req.valid("json") const { userRepo } = c.get("repos") // Check if user exists @@ -466,84 +408,141 @@ export default new Hono() if (!user) { return c.json({ ok: false, error: "User not found" }, 404) } - if (user.primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Wallet is already primary" }, 400) + + // Check if username is being changed and is unique + if (updateData.username && updateData.username !== user.username) { + const existingUser = await userRepo.findByUsername(updateData.username) + if (existingUser) { + return c.json({ ok: false, error: "Username already taken" }, 409) + } } - const success = await userRepo.setWalletAsPrimary(userId, wallet_address) + const updatedUser = await userRepo.update(userId, updateData) + return c.json({ ok: true, data: updatedUser }, 200) + } catch (err) { + console.error(`Error updating user ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }, + ) + + /** + * Delete a user by user ID and all associated data. + * DELETE /users/:id + * Requires ownership - only the user can delete their own profile + */ + .delete( + "/:id", + requireAuth, + userAction(ActionType.DELETE), + UserDeleteByIdDescription, + UserIdParamValidation, + async (c) => { + try { + const { id } = c.req.valid("param") + const { userRepo } = c.get("repos") + + // Check if user exists + const userId = id as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } + + const success = await userRepo.delete(userId) if (!success) { - return c.json({ ok: false, error: "Wallet not found for this user" }, 404) + return c.json({ ok: false, error: "User not found" }, 404) } - // Get updated user with wallets - const updatedUser = await userRepo.findById(userId, true) - return c.json({ ok: true, data: updatedUser }, 200) + return c.json({ ok: true, data: user }, 200) } catch (err) { - console.error(`Error setting primary wallet for user ${c.req.param("id")}:`, err) + console.error(`Error deleting user ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) } }, ) + // ==================================================================================================== + // User Wallet Routes (by ID) + /** - * Set a wallet as primary for a user by primary wallet address. - * PATCH /users/pw/:primary_wallet/wallets/primary - * Requires ownership - only the user can manage their own wallets + * Get wallets for a user by ID. + * GET /users/:id/wallets */ - .patch( - "/pw/:primary_wallet/wallets/primary", + .get("/:id/wallets", UserWalletsGetByIdDescription, UserIdParamValidation, async (c) => { + try { + const { id } = c.req.valid("param") + const { userRepo } = c.get("repos") + + const userId = id as UserTableId + const user = await userRepo.findById(userId) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } + + const wallets = await userRepo.getUserWallets(userId) + return c.json({ ok: true, data: wallets }, 200) + } catch (err) { + console.error(`Error fetching wallets for user ${c.req.param("id")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }) + + /** + * Add a wallet address to a user by ID. + * POST /users/:id/wallets + * Requires ownership - only the user can add wallets to their own profile + */ + .post( + "/:id/wallets", requireAuth, - requireOwnership("primary_wallet", "primary_wallet"), - UserWalletSetPrimaryByPrimaryWalletDescription, - PrimaryWalletParamValidation, + userAction(ActionType.UPDATE), + UserWalletAddByIdDescription, + UserIdParamValidation, UserWalletValidation, async (c) => { try { - const { primary_wallet } = c.req.valid("param") + const { id } = c.req.valid("param") const { wallet_address } = c.req.valid("json") const { userRepo } = c.get("repos") - if (primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Primary wallet cannot be set as primary" }, 400) - } - // Check if user exists - const user = await userRepo.findByWalletAddress(primary_wallet) + const userId = id as UserTableId + const user = await userRepo.findById(userId) if (!user) { return c.json({ ok: false, error: "User not found" }, 404) } - if (user.primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Wallet is already primary" }, 400) + + // Check if wallet address is already registered to any user + const existingUser = await userRepo.findByWalletAddress(wallet_address) + if (existingUser) { + return c.json({ ok: false, error: "Wallet address already registered to another user" }, 409) } - const success = await userRepo.setWalletAsPrimary(user.id, wallet_address) - if (!success) { - return c.json({ ok: false, error: "Wallet not found for this user" }, 404) + const walletAdded = await userRepo.addWallet(userId, wallet_address) + if (!walletAdded) { + return c.json({ ok: false, error: "Failed to add wallet" }, 500) } - // Get updated user with wallets - const updatedUser = await userRepo.findById(user.id, true) - return c.json({ ok: true, data: updatedUser }, 200) + const updatedUser = await userRepo.findById(userId) + return c.json({ ok: true, data: updatedUser }, 201) } catch (err) { - console.error( - `Error setting primary wallet for user with wallet ${c.req.param("primary_wallet")}:`, - err, - ) + console.error(`Error adding wallet for user ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) } }, ) /** - * Remove a wallet from a user by user ID. - * DELETE /users/:id/wallets - * Requires ownership - only the user can manage their own wallets + * Set a wallet as primary for a user by ID. + * PATCH /users/:id/wallets/primary + * Requires ownership - only the user can update their own wallets */ - .delete( - "/:id/wallets", + .patch( + "/:id/wallets/primary", requireAuth, - requireOwnership("id", "id"), - UserWalletRemoveByIdDescription, + userAction(ActionType.UPDATE), + UserWalletSetPrimaryByIdDescription, UserIdParamValidation, UserWalletValidation, async (c) => { @@ -559,86 +558,84 @@ export default new Hono() return c.json({ ok: false, error: "User not found" }, 404) } - if (user.primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) + // Check if the wallet address belongs to the user + const wallets = await userRepo.getUserWallets(userId) + if (!wallets.some((wallet) => wallet.wallet_address === wallet_address)) { + return c.json({ ok: false, error: "Wallet address does not belong to this user" }, 404) } - const success = await userRepo.removeWallet(userId, wallet_address) - if (!success) { - return c.json( - { - ok: false, - error: "Cannot remove wallet: it may be the primary wallet or not found", - }, - 400, - ) + const walletSetToPrimary = await userRepo.setWalletAsPrimary(userId, wallet_address) + if (!walletSetToPrimary) { + return c.json({ ok: false, error: "Failed to set wallet as primary" }, 500) } - // Get updated user with wallets - const updatedUser = await userRepo.findById(userId, true) - return c.json({ ok: true, data: updatedUser }, 200) + const updatedUser = await userRepo.findById(userId) + return c.json({ ok: true, data: updatedUser }, 201) } catch (err) { - console.error(`Error removing wallet from user ${c.req.param("id")}:`, err) + console.error(`Error setting primary wallet for user ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) } }, ) /** - * Remove a wallet from a user by primary wallet address. - * DELETE /users/pw/:primary_wallet/wallets - * Requires ownership - only the user can manage their own wallets + * Remove a wallet address from a user by ID. + * DELETE /users/:id/wallets + * Requires ownership - only the user can remove wallets from their own profile */ .delete( - "/pw/:primary_wallet/wallets", + "/:id/wallets", requireAuth, - requireOwnership("primary_wallet", "primary_wallet"), - UserWalletRemoveByPrimaryWalletDescription, - PrimaryWalletParamValidation, + userAction(ActionType.UPDATE), + UserWalletRemoveByIdDescription, + UserIdParamValidation, UserWalletValidation, async (c) => { try { - const { primary_wallet } = c.req.valid("param") + const { id } = c.req.valid("param") const { wallet_address } = c.req.valid("json") const { userRepo } = c.get("repos") - if (primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) - } - // Check if user exists - const user = await userRepo.findByWalletAddress(primary_wallet) + const userId = id as UserTableId + const user = await userRepo.findById(userId) if (!user) { return c.json({ ok: false, error: "User not found" }, 404) } + // Cannot remove primary wallet if (user.primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) - } - - const success = await userRepo.removeWallet(user.id, wallet_address) - if (!success) { return c.json( - { - ok: false, - error: "Cannot remove wallet: it may be the primary wallet or not found", - }, + { ok: false, error: "Cannot remove primary wallet, set another wallet as primary first" }, 400, ) } - // Get updated user with wallets - const updatedUser = await userRepo.findById(user.id, true) + // Check if the wallet address belongs to the user + const wallets = await userRepo.getUserWallets(userId) + if (!wallets.some((wallet) => wallet.wallet_address === wallet_address)) { + return c.json({ ok: false, error: "Wallet address does not belong to this user" }, 404) + } + + const walletRemoved = await userRepo.removeWallet(userId, wallet_address) + if (!walletRemoved) { + return c.json({ ok: false, error: "Failed to remove wallet" }, 500) + } + + const updatedUser = await userRepo.findById(userId) return c.json({ ok: true, data: updatedUser }, 200) } catch (err) { - console.error(`Error removing wallet from user with wallet ${c.req.param("primary_wallet")}:`, err) + console.error(`Error removing wallet for user ${c.req.param("id")}:`, err) return c.json({ ok: false, error: "Internal Server Error" }, 500) } }, ) + // ==================================================================================================== + // Special User Routes + /** - * Get all guilds a user belongs to. + * Get all guilds for a user by ID. * GET /users/:id/guilds */ .get("/:id/guilds", UserGuildsListDescription, UserIdParamValidation, async (c) => { @@ -646,7 +643,6 @@ export default new Hono() const { id } = c.req.valid("param") const { guildRepo, userRepo } = c.get("repos") - // Check if user exists const userId = id as UserTableId const user = await userRepo.findById(userId) if (!user) { diff --git a/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts index 2356271996..4ec9dd819c 100644 --- a/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts @@ -1,5 +1,6 @@ import { describeRoute } from "hono-openapi" import { ErrorResponseSchemaObj } from "../common/responseSchemas" +import { GuildListResponseSchemaObj } from "../guilds" import { UserListResponseSchemaObj, UserResponseSchemaObj, @@ -542,7 +543,7 @@ export const UserGuildsListDescription = describeRoute({ description: "Successfully retrieved user guilds.", content: { "application/json": { - schema: {}, + schema: GuildListResponseSchemaObj, }, }, }, From a2f39e11499a148fe93cc2014fddf96521d71a73 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Thu, 22 May 2025 19:55:46 +0530 Subject: [PATCH 15/23] refactor: centralize session cookie configuration and remove unused game icon [skip ci] --- apps/leaderboard-backend/public/gameicon.png | Bin 190134 -> 0 bytes apps/leaderboard-backend/src/env.ts | 42 +++++++++++++++++- .../src/routes/api/authRoutes.ts | 21 +++------ 3 files changed, 47 insertions(+), 16 deletions(-) delete mode 100644 apps/leaderboard-backend/public/gameicon.png diff --git a/apps/leaderboard-backend/public/gameicon.png b/apps/leaderboard-backend/public/gameicon.png deleted file mode 100644 index 6c1db4808749e0bbd8510b98b21d94aa55f3bae6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 190134 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 4(mcIH zaSJIIr5z| zjQ#nuV$O|IFngX%9(W|P>pH5F_eN>dsj&B`l-w9Xw>IIVJ7<~b+|i8+F_Fxryb z+Ic3vc=4d>oNvq_DFF}Chvcx;v(YcnukIR|xpR*Mh$C>$^Xma@ z2-cD%RQ?H#N7A?C3^WhNNycEEjA74stTP}+=H$w7@ij}dZDW8_+ytZum7FkR45Vek z<~2Iymj!|LGe3IC}$QV7jdW$UXyqrs%IncX=Br9c+5z9mSlSDrnn-g@*vUd8y zl^4=VWHLieGcf)p^bzbo){eo#P9JGEqVUC6%4|*quDPYFFj4L0A!OU14Ze&YVOM!_ zKw01Fzv>e=KiaXtM^|+UYqj6I8iXnxQH5cAQ}+2cV#8LMX@HpasfZ&@*$u?zZrS;O zXOVvoW(AAhDvR36c)1XZZ{Nrf-apvq?e-NM_7dqNyA#b^uG3O=04%Xogx0?badA<8>RT|InF3EQ%WU_FHbgj2J!48n10 zZi<09XOJ`_PHpXRoH`b#ju|-1xFZe*O*9LKAI_M5GnH}NC6zfvDe7lmcA0ZZXHjzp zl!Mvui7!UM%YoO60QkrhXDsCv$U!kM?2ABTfUDYz>W95X!IpjRg1lzDT*MA74E7Rk zqQ1B4h|xW)-_}$Xa!=bJ2fQab;p%lA%SWb@ptR0=D5<%;Y^2i&Z<8KW_Dq|Ie>Rb* zdSv9x?ZMb5hIjhRWAz%FDPE2T=_x(|h!$4HdUh^L#{klX6)|hbH1NS1){HlP5#N>A zBAiI*2LGdbkvJmOU54FjT_gB-uBWwG1}6j?ZR6x1RPHAlUW1qWsaScDw(V@B2k-B} zS-^36&wF9MkAcYscbhCxtST^)#@kT_=kFdwUr#{5S0CPUuo$n_29PW!TVl#4PX>MS zy+EJz6TZl~1**(Ixy2;%n^b7oiDkO>qE;Fj(lk{ZHJ?gVD2pR5GdS8ZkpOUGdl<#p zBaIWe0)d#lTfFo$Xn=`hL=0=?x-`PLTpGdqS70buPnFU8s65EuHc>pif}VY97Ohz~ zpI-acbEsnuBF!aDIky|9ck$h_U!0F%bVt$vl#G>OmiU>sAMwMEokFhxVQT>}*NZ%i zUp{bE2w>NC>zlT-shizBWBCFsORBi?Q}tE`a5JrqWTXc?M~plZ zV}mEp{`*^-w*8+s4o~?d#tUGtB;Y}Muf%s~-t79N*RI_dD~ye;NCS}>5cpKSI98wx ze5N+*QVpW@svQqN)~v~DMdS}IA~*}Pxh@3f(3l$Dp#TEoFjytdXC7KZu|Y84(i)sv z)kBqr3VgarTK^3jVTiXBljBa$f@HR?mPUF$2{|ShoXpx=l=3zA)p+gFmZT z-uk%3?`{0u6+Rl1f8(=07)!CQJ%+jxB`86qQCJ2ZrX`CK^z@@G^q23wmHzSbAEpyd zhbbST5U}K6I4(=f8n$lHGy*x9O~Bf3!!o5k&KO3b3j$?^-?;V?Q>^{n$iTw9lxgay z62gA|7^)zwxxg?Zy&cP!5HEl&Sa1OelV-EG5t+;z*`~;HbHX3_yZ-K`%D@YAaK*a) z+!0^hVI~0&(udiA?pb#(uY7ykFVcnKVL7Z`5Bg!2z~%T*+VM2pE? zJFm;M0ayWcZj=5}{fGq1NulOmqfGGiQY@R?^-WN^bqxp@n)IX67C!JpqVpT50XfM3 z;BCXy+Ek)X{M8rf^H={Jop$z8^pTI=)Z;nFrSgNux}tNpMxlsO#`TnHHfVXWez-)z3@hL3C#Hb z@*=Xzb#oYk!btN zOZHu3eaTZr0v@EN%1GDj=T+3VdEKgMg|S9TapbTP-UKIc0Fw@ueM=DcP*y=?IHH)? zR4ZU4Yq>7y;Vx+9BFdUU4%wW&<6)|dJc2eM8oDJ<@fl#viK4K51zaw`_-unUKTfSU zhO#Y=#3cfhwm!T1@^`joM11Gg1DNGVER@9nZIlJGO)CJUYAwb1Y;EdNt7-(sV9Thg ziK8^f0Sc%2FbYrFlj-QcqL75zqmw? zuI{1leDzlP^#A%}VSkZ8xQMb*dyw@KXWXoX#QD~$T@Lm-g+;WEs!Ap~!S--I?*4KU z0A?*8+-Jy=51dKxlau>ExSHRGg^aNawXZ-DA}3@8iVgPo;IZ8c-CwaA3HW3tQ*?^x zxza0RvC+{p<0V8i^PW&%*m<8cK%=RVxAg?Dv*%JW5wf~)s|i_P*+fk1E3;uq?;(I% z9_yr1*Y#B1^l!NIXB3;cp5k*xDbWl7O#)O6Ks$J0%(j-kFVBd`C1A#zE^E;1ZX1oqBjRYQKm3+SMa^!x~(XzM!IT1p9y?v7(Q%k6<2|)k5=>v z`4A{2)k=ek)b$dZ0<1h?ZHPC41tLK0y_aO@wwv#zty^}=IoDQ}+D084#}-h9v!Tg7 zm=Piq?RBWbgi;Aruuv6Kp&G_5BC&UxSC@*a$PU26X^XTchQnqM2ncgG`=k?`d5o<> z>Kig~8|vp57m9IPh&`wOtUji4vU23z5$nbY;L*K+6Px z85R@Zg}d%6ei`5~Ox;`|>(89C#+Jv%@T z-2EI1H-GlhQ8Tt|KDX^sxUm?lV`Q&mrd~CWF1QDBlrC6{fYowrqyd9k2-wx!=f`n)KmzQU8FT`^x8|m-94eqDSOX|~Dkolq<$q;TvZw_sn`LeV zDi1t>y~SU|0{1J5%?3EP!i1Pa#;-V=p%sZVs#$1@0aZp{^AuX^nuaZqYW?9Ogp%Ov zfC)uArx0aZ9iY>=Z!9yEB|9k8BCG>|2C!@aDh6KI(IsYPMgg%=T8Q_81Mq z1rpnQ{!R`$Mqdj^HM$OoPS(Ts4ub)<*wMT>8M^b=4^uv;fn%;FVig@3HY;9-`C27O zm~)^N*^sFSYRg@r)U03(@JHa-9U#6VDziPEa`n1CXfb3|MBtcgy)wRNiSU+rXj!z5 zePFeb_uBjTKzFKP{bNVgt6jdFsVM;u(o=KXnIXhBzg%ihmh!WCKPX47CXuj#Qy{jr zAyp*@#|T!q+-MJkWvQrP7Gg|w*5GnwfJ)nbM6rP%Q+)asO0>Z=n_^(r>|h~Xvj99E z2rQOWgIcu3F3=f-2v*^0Aa~Hkp|lP>N>xEW3{VOzi;3D%99&TaQDC~D+dyswJnfg( zVfwf$#Yld(b@2OeTbB)BlJ>ERfrKf^wj_eTVTOetm7jT}XzL zNvRa<6Z^bz_=hAK=Y7<2)~2yyNYxy$XPC4RikRj3dwx~vgguW8nJP&EfE!Gc#lBY){S|9QwxGh-EyZEbemYA=%|$awQwP7mrIUILSrfDas-wCE1~ z8+)F5bZ)XR+9;=9;b_Vf*fWE4duyX)Te7GA>{U-gB4x~}Sk%hfsn~rz;;Ww~YUiBU zMXYZoTH90>tl@6uir&r@=5t`1wjdW?W`RQ@#+|ZkkXlt3wYkDvDdj4(INbTJXJ~o>8P?b&Zu+N@;-*XkN)^lj*myNaO3b``00=T~ zVc#$u-B9G<5`h`GVy`h7>HFubu$OpR=)Nu~8qQnJ9Ot!al7nA4@smwA`Ge|QN@S^^%VCoQ@|{>E5wa9~-gTxg8N zGXUv?oc5DNRCoLIW;Ju$h~vq0HDHf+g#!p zktBWhJ7a_+qUpQ~Q}nJYGSmo@y|7S2FKOGqA$onMfnoIOddO$C37{GGhTwMkM0+uM+Zs z1Zoih)JAAsc^tCN`PIcDVa^Aa`C}Cij7Mp~2{iVhf2QO$|451F?l#sZ8JJjnWB^%Y z{KHFN@)GbMJ$cz3vUln!Sl7p5g^}YDSbS0ljN#nG4AKqQU)wefzD&!C7)V?u^lH5} zU}uAZG-H_o61?o_?xUYya}B-!PqE*b_hBdCdlrD^_1i4sx||!r>{M*QGboV3&4phq zq{wZ(bz2tJfD*ENF*7&Yt1OU-=~l}OFhG3X`h^}!VScIE=O~Nk`<{9>O@H;_UK-75 z`mYBc9fOIqi9Y-nyzv~7+om=f^=lEe%J>^fHONe6TWMiY+bQfhVD~mk+ja||M^aRH zp@|YhY0k|i$xixmY5Mmcb|EtvSTG9t$un_!a`6(H*HECP4LO>d9-y{l4!OlE)WEsL zkrahlV2XqG%A8LWU@UviF&9LJybD-mSt+r39I*wgYmFemj3ig#c5zxw)Q7Eoah&Xx zY@SBRER;XALF3k}a^wm+RI1G2s$5=;eFtM!uRljq8OkjdN;EQ(r$Pa?h+>IKa0Bo` zD3VdtjH~4BhGk(88S7e(zko{5{@!$t%p7~7q} z+7Q_+`%HyxnlzP$r8%KyxVmyT{6l=9`UCw|B6GeD)iQAJMEhZ-e_eYM2`mGCSAyz@QV0oX{d znQf#*QwyH06vr{095`na&e#CDP#+7opXnf0|FMRE>IT70`cK*fJBlI#IpuN%8XOv> zu~7gu=5Gl=U16ZcZvm_3K0}cy<5^sK5>?PI8bLmJ+uTC#xW!j)$ZoiDG26lY!%JX~ zCE!7Nk41Jk+;COwF}fsH8R}iY)=1=C(DRG|(yh}P@CsiegJ1BUtjsk!ZodnCw!u|~ zRVy@AX#LtPbpQQpsN?(+$a^G+O(0V^;jjIwMA#@W`H&dNT4i;E!7ysEij6l9MF=0C8G@SwWDCpF9D#3 zk%UIru8QdHCrfnG-8f_<1@pIn5VjNz^y0XVJmr$O7Kvv1i;dJ%g7t*4x4@@5snSsF z&e4Otuh6$n$^pn>_7#)0g0amNBZ?eXCjYP;SW(f|%hv+jdO;?VLZ(bP-{ye1oQs+XMG%uHRP+wHIb03ZNKL_t&v(Ox@xjV)6PXZe~Gr*dhnIa*@? ztG{wBKw{Q5AdRC%`}>AzbQIPR1lZ-FT$cD$F%bhRmO42roM!-5lU&;jYM8Tx66>A_ z6wBC1Uvr)wo`nlBBmBclV9zDsL3+<+c39l;KfkxVEjc=HR2-&mIW&NiHGoaGf=SC! zeAe7;!Q$0=Eo?SilzH)r@qrLnh=uV2EH-y`)7V&^o_c1C#@>L%cuRpYi^1klnBCT4*CU8|mEU1LNZNPi5xW{yITgV zo!usJX}@xNk*4us7r;n|`bXg31>5B4@c`9vjyE6mC_##_h6l$0 z)UXP`{LM#@vX9#6o;s{WWW+<}x*~k>mH9_eZ0%!^fm#>h`4Lz*URr_6JV@{H*!UY* z0v@EN*gQ~w^i|aP`05$Sk$z+?(db=0mJ$aYHZY5}LfHdmqR1%=liXzvv{1{pGDBXV zpLwZBk3LhTGgjeyCdkSZB1x>Pez3@408$=d4@R6jn7xoj+~f%za|N zw7FL-6Q=E^9YJjjZss!U7mt{Jty04Bk<$5JW9o)4y!bMd9+?!!#o?MAI3-aZG5oG(`Q~y)Ye{HuDq_ zz0HQGK|L$7f(8HwFy_XFXs8b{3w$)B>^Oju)zC;80Cu{$8Em_OQduaHu==o>S%r@= zu$xJ(tEGC#cKKN)YAq}>LUOKWiTeA8X#h-mWSD~jb70dj-2 z9mNGp)ylwAN-RAE8tvH%n+n}F;U@VDy#yQyc#xi=(_y4@-O&wsm_gwq419uE;4(;o zs7nlkHVen~g6>9GvN)L)$^jl;_h!wRE-VBVMEd^E`e@Zs`0h621Fp*~U=)!9kL960 zTI)a7Ro&ui;Db;8t!J>0x{O z1h^BK4BQ&nk-)4QPi}t#+XtoXFqf->XXWOqTf3gWYG_gGg#b1O-1S0X0E-?O0+U7n zSrK{G*)J{al=kVk$I(UsW!#(ZSWMXjg5}J9|3tYQi}&=j?dC7X|Li5OO9^<8o|2Q} zvj}c^-$G_0*1XAEOiExfDP~mabbIxOr5?^zdW&U+4(I_N7c82I6PTK4?O=``f4)T5 z{A7qe_P#Xbx;U^4XF)IEldsrlX#vUbPR3|chv7W30J}@?#tkq*;w)v}#w#t5=9eWA zMWJ99Yh6%n$1JbagFnLWrTXBifCfipfeQ)c7Xg?CDl5@B`99Q6`G0)4H* z4*lpV3Zc1Me~H=_Dx(b0FjWuoK_~_-0D5Ej1by*`y>#2-kShYk7@Vcl7>?z9_lK#_ z+(wlb*HL2rQY!T9K=2rRz%VUW*qm(op)bl#Wt$WZuI5PX3cE&lUo-?A!--HzUu`>; zedl34VDtKLIZNAOEwnl@m&z?C6Sn$MYpI{+;P8~iw3U`uy6H&TP8~FWptVs-%Qi4R za#R^8cZq`vMY#~81Rqxl7RyoAcoyL!PK=ELyuq{?uwfGC*Hi-l5^WUagwb9k=LkDA z6NO_c-VL=<6M%VVJy!N5G;HIF@IShN4-L7>Do=gcTfHN4XtGNMqkL4a4s) zmL;@5w{v=Q0{Kr~0uz^j2kD8+>ae=9Ff{x+_2seq4SPLd#w&ucBzVCR@YVntIfpsG zj>sym3snGop~+C!L<2-vVJlc%TasnE;GCoB`PFyhr#M}I`!F55d>UPJR*LeQLB()+ zdOk1G9P!P!^VTc_bP>rrP;EePYZsrRY$J^IV$Prj5`(Vj^;)KBuV5v=H^67t@SuBg zvC{|JfY4mc<&KP)8^lbn*I50grI~}D)C6uz8?X16r9GkxSh<-dXsN=|wE5`gaDs{( zz#Q>v4Af$-&qGo9?#*M6EwY#}=-RWU;j>R&@@^{4UO=TEAO-8HbEpiE=Iv8rU$Ff* z!1XWD)p54Z zBWgTRf@=nIHiakeXHI3tsDJ=EX4RaWhZPErx!}{QxUJG|GiSzWNx=|vC9D{fX=m>+ z4GtiNVl)SHJtDj%T3r>}aN6Tq$Uw#iWfYrRsMLbU^`RXqSXGivJ**ZF-ClerGnf%0la|JA| zE$}u6a+=G&c0GQv^dcR&<2pl2dNFgSy>d26omohVcs$j%X|e8 z7qK5)4vFFIgz}1d<(Ns%j>cOIVJgRW32v99y{%FLxeq9-#93*OHnp#hnKTEDAtGJ& zO6xvpD}o@(Aep5S61uP$%5iLiOSdrb*O|d0$&WNuPb2@?OJJfB@E|==NgYPleSU03 zHc`l(1Og3az#t8P9s{6afl6cTDkD5OmgBQ?0e%2l_mB-;`z>%7aJE1#tCymZT!9E; z&JV5oWlJ!3x6y0QT|vLPZH<)KjZ~GN|7PF+WA8iQB`vGF&(u3}@15K4-CpnRvb2Sz zvw$E31r$Mz-9-I@Vq!FCG@mU7Qv6Izj8S9k6&pseB7z_ag1`a`yY%gS@7`Xg&-eeI zbDno*K@*ang>naGXYR~9&-;|~od4;MeDEFJvU~yjyZ~zcHVvRwP-VLfC>NMw_)M_` z|1iSZ{LmKdZ9j>_zYgsj&w8#mnQ{;)92}!Q|C$B%_Uh(l`+)i7*=9zsV}1g7In$?d z*}sDG$MFQ=AZXO?^9S{b8x7@xsBou`S zl?kSHuVd~ReMT@`mvoC%DJ(!xs!%vV;g;~F3T4G<+6!7=KqsLYiA#^HY8r{Ik}g{u>yysf+qq2*HZf za}^d;U5q9mlPSuoRT#^-3OEM0cyya#ca^8nrjVt&^7tHPaH(Qsa+dr+sr^aO=o5N%O6pV zH-7g2k@BLW@Dm_nR|jfN13RpPitAQT%=)Umq$z@u9Dzvl%q=j_%(xbvZ2(T1v9rPX zzeb8f_|tyibIp1N$myXTV<_~&b`YT*lNKY3c$T5Ej9E=fziT@=Ru0hAO9)KFlIacd!K<>kp8_d!SB8Ul|5S*$7jZciWt*L z_+J{D&dMd-$Ww);ranA8jmsJ*uH(}a>Tns%c^E-V+a_{y|71oUoNSRT(>d8uLg9+% z9FXcaKdHe=gsw++$K^dA8n|T;?_}Y4)W?iOuJ@2A6x#~a1UE6h^ zB>M6E=Qm4hGa~0ht!D>9b}lNFs@O}jx$6Hqs2GlytnP*Ce~>`M;}Kyx-9iZmUn5YA zn4{X9nvLlb7P;z%s{G?;hSZc?FRkj>%NZIUfVPM-p=ZBN3M-G}*&hhC@041O0B!5f zVm4-mcVG!Q>r{a?e4X91_RSEX$Q%huRy@UA04n4tU=zel;EJmT9tY4{`}8T-X5csH zlR;=Sc`z3mnFhLu&`K+sXKeQ)$~F;cX)>oqiyZjra#Hd*JY;XJOYSN!ftkE4xtST{ zb5F{W#E7f_a4%1eAfkL+vWXJR&qQ|?qd=tw#+D9vXUhHb3HgzYz$9Qc~|BQ@y0@toW`)*&0Ga&oYgk?9%^eSl5{-YVJW z{E;MgZI;*_KT!=ThKQg$9}s!pM@QxF-qI;O9g|Yr1uYWyPcAS3AV?@!C`!0?t&ZV} zoN;DN=6)(A!{esSu}g7)`wK5dz3AkWId~PI8v$97=P?6o7)%&@xWt(08YVNx&r_(4$z`5 zuVK!(7fks!a8+JS3q?Io0`ym|Ey_QAegc7i47j78*g!%3rhJP9sb)GQ_JTJ{X-+@S z(2!bh7XUizau0+Ji#>C#*TkMkkZ|E5nzk?(!56M~AsXpGRO@a8a{8?G3VdG;^Y1sK zxm^JbT5)X}9fNtfCeX53+Z!~4zXcT6ml&7DsbM(+0cnRN4#-@YkX&j;Ix;o+<<|Xb`c`28?LUhC-*oc> z5CqCHZ%$6mJf&aGe%5OFgERW&^ph9Kcdof#zVx;AvVUMw=UH>T@tYz_f$G$7j4*lE zn9`Zc zGFoiHHOv~tQ%#VF)J>!%edKWx+ju*CmPl2hwgslQ`#@DTKAe|RkLf^B739bgbnye6 zT@c{_&A7ooY`7Vd&hDzLT+uG;akxc(2DPZc4llX1Q!acV@5-E9M;8YZ8$$Tcfkqi> zBO&Ww3M9p+?<^K>}Gk*>vHnqm&Z`OEARI-l}ka-v8=9zSz8L^ zd8ShV*l{?9`eoH}!B>83{h+l&$IcgMf1kg)ApiR12^7!>5dyf*!P6!gO51|fQhm{7 zQtVuyk?yfM-6Cx`RuO`c;kmUP=0p4AL4xYz*YB;}FgGeAsb=ioxwWM}MyzI(qC|}w zKHy%M*7MMs18BR+Tvw82Xp>gdOkRaX9Ai~7_@Qf)@C{E*K~RCf!~A4w;fx}uHp|Fu z&6#F2H&0Wum8SHMZ4BNHcK!YzOimtk8cs8{IoQ|u~B^Dd~*|NcIS z@7t`A#-_N{awEj^SE_f&0pT6F@_d^%V z{n)%7*T6(>)&h%1NVm1ZpNn-EpK)=+$K@TVyHspBbc%f}!OoyQbL|j%3k^Gls$(_Q z`G$#{->FYr#h41poq|pgtVpouTBs}$#-????F_Ft(gJHJ*(uW~kTSAgYCE^WlJS7l zwm%@L;l0)uOcY0>M^)mPYfZ`Om2>3Tr>>G^i|5Eqw{DUfuG=KTD9c*wf*{leHv;|X zLt8~qIZe~mK<{N_ffrx(!6Wy5{Er`eN-&7oPClt-U{;fUQZ2{trZX+=^Iy?4J-Al2 z9B9&A9nJEbv(`voFH%RSm5?8Ui2+hIJ?jA6t9OrOhVP7PvSEt#+;=}4))S5k8qOZEQss6P#+ zrVLtS00(5O-!LO9=C{baK4?f&bfm)c-Ab*_{LrR`5=mW2z;(%ntNx`r9s z_rV4sEqDwf7JnAf3K!@(>fb2HegYlFav1gcj4Z8NfKkk z5t}s#HL*cx)P4^gEd?0-o)(D@e|cK2yaq))0MJo46ci2okn1GL6V8|7nSUsiR1WhE zjdMN$8i#MV6-4h#ce85V2E2Nbl|PtcZgy{5s}NMC0ueu^M(YC!LP>po7~##U%okn7 z2~vX0fuNM@a17hs#BS>sG;y2OFjwI|CKxE-Qq!hs92LlL5)9%REcMAAj1a@j@fd3! zdg20?#&=ObQ@=g)=c&{~<3_FU5`w%+o%5wMcR9|5J_>S7yHp;!Lu+^IlDZ(ndiEs* zQtsV9F1M}UA-CVVS(Yy8k>ieDE+ZqTOomgwl4CHRPYguCmPRFJ5t&(f)R$&9-T?HU zeaz0l|7-?kebWE4`FdJh4b8Jt85lSaHH0M0j2Q&wFeg;CGMYod-1m$o;aBdII}3AU zAl@eBI8uNZ=)>Qdi&&X?g*A)8hzihREL{a96k#%CdKWbd;$(svFc@kqm(zeT^L-{H zkHQ++Kl&@Fe&z~^=SK;v+VCU$V)Bo%V--x#Pz;Gkc6aMB^5>*iO3Bhya#)1ZE8n{`;QKG-K_`6c=UGVwPW zb>2Wa+Li%X5D+jah~CD$?_dPC1FoC)*?|dICEow(G5P+_3z%;Y^x~~B1RWzy&`DGk zTlXR<9(y*fp;;L9yyx~vjT)zR&4Hc3Y$~)ZU_c{&;-+vjar+!<0GFA+qiS?2J(&AK zTXHSlklj79u5#PdIpX=`n33M*C9*L1`*68Q9rjS6z^7K1m(|AxMGcCf(* z9}1!j4;STI-`%7xbefKoJKzI&+f|0o?R7Ly)=s`UF6Inttyr_HBXy0i16hhKm?7@ z06-yEP3h*#j!yRqCSBJq?P707wP6D^%Mmw#(hs~?w`s*>_Dug`J!_7~%$t!W7(nq5 z;%Vq>9f70I+WW)QDHgt;p-WtDPLIGc^LEG2MOf1qUaSA7`Fn#-{afVq>!;g)C zGWxNZ=g$gytZ-Hv9g-wYVoUHl-j6|c8rg1vZJgRN%6g_xdXB`7MOC)ePN|FxA|-Cz zX3y!;|A=b*P%sU>vXS{j=!HCFqZTXY<~}&Nd*e+{Cq6Pemfy_`H0<2p%?)So^81~E zuD`jmt@*S6xH&Viuh&4jCf$7~CUV4KGTgaPsz;tEwS_BXI*Fh!_(@9$3M1~N;lC@x zcUR4}Nv$2spUl%selo9Bi%^kK(yXltM9?>bUzW_)N@Z4%S6YE!V9GB{ns_FrCM9{x zkEHsYkHTcQ7i}==)v5FN}cA4cb*ER^Nb8W@}6hyKwV3a~5#1bFOZzCa-a2Uoy_IXPJt@N5*h zsLCWFx+ifq_5{L#M(}xWUrgTp?yPiop%@HD7mh=6z8whxra%XE*_6ylrduI+Y{t%} zR;?fXe|Tq1{`M0ia`(dk78R+jpFTM%D8zbKh@AUMDRnJTOEb;52?U8%8L1zH3!80+ z4I8Iw>;zB;Wi!c#Ym7F6WkAP+wkn)UMCLI)jLz>WYFpQk^G*|n=0>vxJ?hacCKa}L&Ef%;I$yxR$aulf5 z#pcb{Fx7GmKm1r7!gd6ttg{e=e~d+Dv`IHRYo0U;fgE;<)K0$$YXV;?`;Ouu$-W){bnt82 zx`cG|AONrn76fJ{;Q%T+laW?y_v1Oy<{S6>Qv=mPEgo5>RYl#j%swbUN0Nx)Jk9cs znHtb#5nVcAENeyp03ZNKL_t(N_1twOs<5cq4F%2UGfumi$%CjJ1a30gyiJw$dVpn217U1b$C0ya~q;3}ZYr_i++dHt`2pZ^?* z{p{OP{^_^$jA*m4u!BY~(P9U=MzK1KB`(>q-OH<6HvE0(8$S8^kxyL;oBHfyb_Sl< z49tS`6Pu@}#Z?^_U)~YF=X>u;g1Kr$F(v-qeo4Igty1YfQl=Oz2ELmD^U7|!Po}T? zs+1o2rKC$Jd_v9h^p{C_&Qd##$h8_Zk&&OwJBp}T8o-YSCNnmo}Akv zXtmsAJIYiZe-2#N87Y7BA0#>LhZOkiQ~OP$x|sTswHBm(p{yQlmk?cEfF{ZuS!K?l z`c^;(;*a_vp%E$quse-N+Td-DG$9>;0iFBdZCn6;K!LyZxfzP_HxP2|nzZ!vI7pj~ z-#h4SG=L3n&Rc8Y4~^4;CYt6=q3J_Ayl-n#-uw@HWOzbD+I1d@YoT^@74@Q%XX4O| z!%qb`BZZ6iLlv*Y9D-Fyp>rUlz~n(<7NBMQ3AMkYpf-+!An1|y>ejb^B6))#SDTnJ z0fv32WMIpPm^DVJt8^2MTYnwlvCW5d!LG6CC+J5L9$7Xt7+dGZZeXr+6RywQrAuyi>M98({>t-T2&6hiPBOyhCXudkwRH?25jx?`xFXR=a; zMWWQPK$0)GRGL>DE`=}sv&6;`h95}B=gEfTC>@qO#700GY}&Kw53B1t@5TM!Z_?`Q zV|E7q`x%(^N&okA^t8F6rK|5P&D-yNB_mb&sI<+K^d;|=@`|IC$umwiyX78{Z+=*c zKlp?+9e5O)rWHx5%sm=D&o`p(GOH_*5n41&1X#v$>!v^j0_CR46xHuZUQ5PGpESx< zD#*D;t2~n>9J64G={9LhX$C49SB#3;=l@Va8jOYCDcYJnv7W zxcoQ;6s#9t<#>Y6xbFW|>Y%2s^U8Cktq*3(bzoI+ zwiKr@4wnAJNiAB@i=V01>s<-B9n!n(3O8-DDZeg!u2ut`VxQ**NQ*q?_{svx&_~qm zP9QZ)1Ke<^i!GUtK>-u*aexd1&g{7juWOPE|B%UBOvwuE^2f%uw?$g*vVC#Gy_hUY2)+es)^gc&c>k?zd~fPw!>k%D?mZYE;UWp-ssO=GgK`z?2n)s zQW51;hWlth9^fz!!3ZFz{%uzhbyd&@13m{`3I|FDwk~?d5EQ2G{ZpFY{obZA2MwC8 z3Z_xn`qQnU#XhOoBOok^ynhWO&~exdM=j}rFAWwLUx1LR#bzwE%zDhVl4+IFRLjx;Y{Ddqca#pVEzM-yp3oj&FQIr#iPRku*n=sro{MtI)?FawB`ej`s=PchSG(RBu^Mu6ur-)llQ4a1 z#H@mZMq)*UJ~W!v!62||3$h}!MLsID!}PKRQmmdW3E}l7 zv>soXflWxI0-X@Or(Vh+Gt#wA96zv|1WIC?`W>5BD}e{HsX&VR@CBgE=XB31NuK)} zsr>L$m=6k#?2-vJscm;l7QvUbW6zQ4p}o?w<<=;Lj2R8-!Oh1%wBg4Kaq0Gm0A}CJ z&cLskfmx7#W{^fKHoW6F3-n<$>Cu4D2*Hf3vKSc~Yb^8Lu3<9Q+JlwqK1O4vAuy73rJaB5O|sWY=I~bcU#YN@I_a)}=}uG$xi6J>m$F!;g&1i~bO` z$I3aBherAsBD9f43uTK(bXp|0738pBGDdzU0MKdphtnAzJVK2bf(p)4OkhB9Ru+qy zNt*#x>RT;2v>2ASc6caZMU~84TRa5FRhUtOtj-Zam{7kQIq6kc5LA^7(1j})v`6s3 zeCugb?QMv-hnd$<_ILN{B9S@G=EXzc$ji-Q7ac z(sAJ@Ft@9qm4!=11LI6k(5O1loz{xiMYmjWmp9BJq1aFBgb5J#uL%c=>ppkdQP6aC zE~4s@>P7*@E;V5wJHB7CKlnEYI#@{+PHZKtHPBdLvb8ojwhn)8kG`0FGdlynW(HI-OTiCK1_bbO z95D{AT-7E}O^+RYs)M3{C3hn6|;+F=E87xp#nvVc9p0JdlWkW3(ieShu2l(iTRCo{b zcD23L&D9R5I(DmhuK%f~7#O$JPukKcp~maNf}w9%a9>;XdpiN?^+rp25C?#| zMoR#y=P7*z|GK+x*gjAG(&IM6Jv{i$C&cE7PXu zL-W6NOn+h*;!%#prYBCro3GaCWcJybM=At#@fMqP29jODCQ&7E~knyXibp8%bX zxDj9kcrvX?0l)D{Q-LY`&QUeoN1r+$JfuRkJB}qhoo>{0>K`kR`ybi`10nBC_<=#E zA{D*?NY@g<1YASia+te4=S#Iy1!}ZJ|1(X%6g@iH^!P?y18vCWm}Xj?odAkKBMrUQ z%N(G!Zw|)%Tm`BCJCO)tdC&M*XM^qW^r|UTs6z@9i;9@Q#W`{`(s4CTQ%JkA_Jhz; ze`l=7$(W^> z_M}#?FHUbJi&&&R>INjL@NrzZa zYu0b`&3ZRcMXi>EshNIh2m_1eG6<~hFEz|Je>JtmD%}b>6ok>C`#24K>@zpzvKA(b zp!nGmHmo4K=&-#nx{fxFp=VLWFSS%#gH>wIu$F{!uMr4pyJjshYqryZ&rv9^YF#xw zT21JUnmg~!pSeI1q(9D+9be*tfCr`>vgS?_nzF@UGSHKqV{N}v@sZ^~8E^n!zmjVt z%rGU9WmGM#EbfJL0(EJD6(1;tNEIje&p49zxgoSovsVOK3ZxUwual3t?6D8?lfONX zwgryK8}CZST7I7F9xW$J`Qc<~ywHs4=)ee@5oDLeVx|(T9~(>6Du|9t#j})b%E+)N zR?3M&yn^VBcy1)oQLLnzd&@0-N0hhk%~j-+$D6b~`}m#Az${4rPNw|hwcMYSCVUBx=rc0FJ;oLcITOz8imQ zpka?gfw z6nPItbz4HqBnW67NY2|*ki|a~5^?OL)$|U+n~ht2;NGU4-OV|)kr2G~d#1c`j@j=S z#0-Qsc)iA1gg$tC(b7kq)OO-koSS+1S5xz2maP0f2MiT0fx%Z{Ab{h^nbG(G$Hjm*F-NdHEr@k!puc1s?5SSelBVoh+$J$emd{*Hnm_59&rZL-E>6*C}g??I-yYM;8+*N=x zHO7R}iTjTuf3b^A5lyh4?5Its7CcerZ|ynqI$*n@*#VnHXpADg${E_w_y*of@62W` zTHVan)}F()u3_YT=ej8lp1%1}Im1#TD`HEz7gxyMEpoFU;Us z=s*{Ii+GySh6y1yOY9TI!@@)uZ&geCV*b4zx_zcjnE8Akmd-|mzg37cV8_AUFecy# zw{-Jh;oi=JJu6PXtPoI}lOGYk3w3IU*1`@yyz2dLwm-PjSQDvKd2jZDs7p~OId zT-g|vnzEH&yIGqSLD#tB&_HS}?WUQ~ipumE=Dy;m=9$E?l!(wS-Fp)_PeRT)2Zu!n zgWCcZbZ}{0lS6r;^?`i~T-^DmnOBX`81T7(;B7~y-T+_?j8?}(pRIFz`+1}G$q61)y`!zgWnI$bY~OQ(W{watiiexS z!`pY9ric))PG_cf?_ZNU@2&GHYya^0)cF3@#q#tKO_LMzVx`Fr=2o+y3Nf6(nSkJx zMq+*k0tM%$~!Iy5{c1B1gdGB_lIIDKqn zbWHMyNhs^N-IO!(iCAS~j?4_qLAQ^p?coGq9#d`QTJybIdscjRL-LGQ-dtXC;BP z@s~f6#LDAQWF)I+GwF6`9xfRlqk9_wK%1i`O@``eIu)7-3GmcJSd}U^A=MUW<_yMC zPE7PO<%i5Ujl&G4VCwGeO~}#5@e2!1a<$QRxNB37-|gf^#@OvLXUYz!5#lxAv;mp@ zPT6jQHt4rL-saE%xHfqtOGVsc~KTAB746w$72Sp*Llcg)KV4L_yu> z!epif6sGp8pzmwLahgOI(Vi}uld=hoq*3-6^TfaPKA>J2k&u38)8@Ln3SR_a6H+vG zBWRE+V8D~>eN+oO+N;8{6xKh`nQ7&L{y07tlB}ybCelJ`d~Z+o8}Ga=1EC1>wXx!p)1JWP%`?pQf$%~7&w$r9=7>y_r#X4U2+i8!F?y@2`|f#Z@}3`kXl~bn&6RA&oyng5>!*)A^@n3$eHUu1&OUzK z3_RmN`l_p7{TW)~V z??NOMx3pA~P1GsMIGcAbB<-0>Q%feOrZ$%4 zQ!?4=6BzKdd$;u zHP}XW)OU7r-utKyURcmBQkHIvp&a^%Y*0aBZ=~#2W72AE1)cWp)J=Egi zmI!LR6r5BLATS#PH}{9p6#d#E?G8GXy2&Ft@JVzT#{iUb>y{V8?l2y`gCelq&HAs! zMohM|`)T5{t-X>4$Kv1fBCR^d8E|(5J5bF9hS4Eq}zxT}&4M=CD;kQolxGW8!FWuBC9i6Sx zzhbehdlpl^naoj^A!yp0AKfnZZg@cM->^|O-Ma~FV;9`_ma2w&dZ|Qd3WpQMu*(voc?~=-8I~PPp_gt3^Ef&>48zf^_YtXC=St#Sgat6h0xrZc!PSXcQ^$C$ zxwBZxv=owki)WIVN^x}g=`-@?6|Ca$IDz2u%WHDQ&^1j>-+Rw7Dt+@39L`YfU8>AX z3yzSfICG-UEgaXUIsw0+7FU^)@`mdqwiK0=Fn(>ynSE6Jf=y*me;VdC`ZBeE3D52& zr3JsD@RBZJcZSbzC29_XMPy)Y7xHWN)lg zC!?i<1wqa3w5o7vR5-BJh{neEioBZbWnfG-+$}Ak`d2ht3eXP7PN$L3=9HoSYzB&{ zP-7atp|BN#DZ`|1ny_0E7~PF4Dva3XDBJ}D1=l)6p#uX*qX4uY1cUc zev1g}fID1 z_(z@A9)E54uHl+Pny|I3yHCQce${>I^s4<*;vlA)NxrYIL;B_%E9*`_4xc4Zns-Vy z?OSiTO>VjAR(W9KLo$dXLo1leBoqX=u`=oDeJAc8lM}!6wb)y4{7GiRE6!T*#idJA z*ETJE?IV|8e))eTjr@vvc=~>&$Is+TYbGYE zxk*HFmRJ#rIk(^>n$|*z-}$&w05`4g1PyFbMnlOvctMSUt2C}##Epu|GT(!~Cqhfn{tFsm*oF-@Qq9vOW8%iu6cOkq5D0|DEl}YLg26y6(X>C- znj5LLc0P&&_UuPO#F|I46Jv7|*MIuf)Z|D9nNn%(c{20jH%jU%9HO!70i=>3 z-xJ60nRV1{h<9FXF>|3FZ|TfTZ_ryPGWqi79eqH59()M54& z{~E#1*VmUVntvm3XFh@ZLBra!JuSE1RF#Jwt%703m@vc9olp5Og2f^f!Iu%spr?@q|e-aQCmGHAM}fOh(; zW#+^SXcRZV?C!+rPq_v^r~!@!Y-MriMkl_5@3A^Of%a)uH6)Co&_}!(YsGx$X@rzB zdfSDNi1|{WpqZSfL(#mhPGB2L&kQmpHfY0)-T|J$0aY|US*jlsI|E8ayP9RkjcqxCUHEts1^d!{Zf|6Avo<|_(M@PM12u64*Fpwz-%T_UbrCh%bt$JzhC^E+%>Phd^4jno{^8I3`o~5zg*sS|Bq$& z?p?9hUDTbrXM1JM1CNZKaA2tT{K3J(@fH(#oDR^eAQNHyXkVI`p9W)O7IhALa_ zda|&Y001BWNkl-^iwxj^`oObswPGQeqi%fdfNk7JPN;(>caUvz^SAL43W6{Isg_8WO7ZlbWJ|6R zn>^4TM?bjf{gT}GC_v(X7CB+9W45hZQa5-- zN=r@30$lpf~zndD+Ce6yAYs99t)6;P1BAZ1V$5Fsog28Xm^}7N62HEu#y;sSI}w=Hk|<0 zb-~2V;T~0|W^n#T%~UmErBDM0e(p4_0&)Y$Y0QFxFom~nAh zfy9IYQcJuUrBxBttiVao*Zbn4u?b12`PBeKDMX{t!zN?D9Rx|U=xDN%mznEPq3`bN zqzz_j2C?by8jM%EN(#&{p5aK<{#DE5l+#X@Q=WC29Dei>GG}g|@)Wz(Lk@-Hz!`3qdI-m z|FFGi{W1YP^qgsG#(Yhgus{L68XZ`$B@96mGqQc_E@;{7<(8k`F88e8h#D_g8@5O* zhBaDkYfbD~wj}d~BM+FAlwiHg%P<(o5D0Y$KEx3|$0lWLdtu%%5H;Aua}=}}1x?2_b; z`y{?&lL+3&kbcEzYnmSmS=91=(?8?r%4NnJ)Xis%IOMJdb9rMw0^c`tATH*_hu={! z!P@YcN_M&Y+4o5Dj_=EPr)TA(pHE|LaRQM_{>DTj5DuW?EF#*e&d^M}u&Yg5HE@;+ z7{Fjvwr*^a&wOr1ZoIc32S<^{0+)qG=8_BGHHAFoH@&f0Ui#9c^vuD>Y0iPodwAI8 z^=+i<9|2ppONN_hpEw*|dY@K{3b7i861kkigO4Sw3w;y~8G)OD-qMmJs&x)jXm{D{C_!k|k4^T~-4KzvH}4~)U0Ff4oa z4aq(@a}FSx_Tb2bOhY^1bv8P)C`4ONwFw4WQ_ElqO-f3+IZ|7CtW^7t#3@&+rJU<> z*au+Yil!>eP@_X4`?eunYl|dtu5NABiBdY|6yO|~zx&%FZOf-r=g-I0Sa;g^SqMII zM&UfNLenh#*@?+9Dg48$BssKS-gHSu-u_Osm3A~9Y!;b6dhta~@)vK)$-E^9A3#wi zR;&sXm zXTDmdk3S#UnmbcSn=}LWVJ%spoPeh7(=FoKArIpSJ&d$jz0`#g7iBas%P6K^6)s@6 zT@%vUbX^J5cv8bQ;l^l~79n$E1QzU-)vyLvr#?>1x;uB*h?%wXHf*G7Fd1Z{*7oEx%J@AiV~U zn$u^*G3tCVu*!zE!pPhF|GuCaw8y=m#ZvT@3lwOU3txm1vVF@w zx&E4)(2#V8d8Lz6Lsh_JXjl|IAfGKOPOxk?$vxe3+?sG(F_8X3VW ze0>axQ9|T(bs}eL+CDxHfim@yPj+3M{zICLG2cdGm|nx%ho?u_(riQk&%^vaIgyva z(Yy@oAC+AI@SS_`+;yL9%s`tDT+82cO-dNSUbKoc5$@R# zldXG_a>nsFX>F;=EgK8+H~%pJ0f;F&IsjXn&_(>Y?xd8w`A?g1&__)&9pS^$*)YFn6;IEwy?cVW}o1qz}bWi#`D#yNy0ji>-MCJ|2|D&n%D(qAi5h z_G(_1j@~23q-MZ}j+GT4J3@DU>A;b=`T=I!R<&qdqj+o+5JP{^xjg zt|QiG?|h4fZKOg$xY$=V8ltYJwNSqgBj}@6qf3>X8a|HZTKhoW>ZWn$wd8NZL(_8q z#;x+B?_4K8{?RRRa6g0>{E#k{Y$jFcYEAs&@DG!Cw|L<{Z zS6%fI*>Kn1)Z~^u{X2H%U$%W;{?88%mwPfe7q$;RwUtLKgkR?vn2?T<{*_CxA$al+ znLaljxq}5m`0jU-b4DF8C2oFaOUe`)htBtbGL4bwsXmN!R&Cj@C&0O%R?Thrt~^Ds z2I0{B#Ykpts$pC3UVO57ZN~)%2lJrtMbNJP8mDG|4a_Yt%b?9PZeYEwo&oG;LGmM* zHz$$vI&g4O_U;~(Z94{J$F3pSwRcSR9~_h6(HSW)C`flInL3{7W@y$YN$LF8N(GEx z{f7W|JvE4?de@%ybGi!%*p%vV?{L(m=Aqh*+Wu8WYVF(1`1}N^gMZ_F0`UQf+rKZx zFa3>V5e)a)k2hn@5dzn2;9}La>ViR7!A4E~5SAMRrpbgJYZEf2k5WC3TY;dZT>BksLdpj%y8JQXaSP;B|7KqVq88mCXhvHwD0wd6>iRf#M(0x#Xf^FB2+ao4I*oiWE3V3VS^>;0Glu0J ztftli?k~4e=^k#FXGX76ljam~uBqX}tt8Z>>30fSJ|b3U9bx^@30jo85RPRT(rpx@Le*@T*KXPa%yZlKA!3>Q)fC%Kg-5D+;!AeCuY;>xH?@6qM9 z&8_cB53~|+j5?=vx(Yt?5J7MEt<#sT4f@o6%n6e1U0`$^@j0CY3kGFyA0y3qVjV1k z6b@=AXhKV&otMp{Wm%*oSY<#5)RHKm)AyZ504bgcq;kg7*jJ=YmOP`--Y9_ToP=4i zSCjjEgXt$>=FCzOD(OW^5d}5OO$jyacWm7+Km6_ua`iW^gD-oJx(rn(N7=OneNDeu zvLy47Kfbu-nx~s)9r#={UBAmW1m~+ieoO44XD{gf`n9{>y?J}#&xXd!O)W52AAfwm zT=>F2kacISk@<7m;4g(Q7I~qpa6^BXov_4(cQ#7UWUv)XSY?p`Jyd`JBe)0K^Pnze>kJNqMqL^j0B9pXhnoz< zn{Mkuf8o!bL^LTjg1TWfki(d|{_9$T>ScVd+5~-Y4(1_MC2VAHyRfP2AZ_hLTCE1A z_(pIBrVLM;1i=%$kqy2F<*XihXph`;-%i>1&<@!N@Xq6$$s8a9B=Cz%d^qlYiVrMs;xZ@n}pXFn$n!Qh+~)_I~a z#CnU)sGRm18IvIeBghpxQ5F;t*AIfe(DxWl#k%sxW%=t*k72IBAaPyO`F|$Ut4}wO zRvv5sp+JRv+jw_3wBp8HZ1ZEmJhorV4{gB4M-pb+INC4aplvPL%5`*2D~yBlp!%tg zi=SJ*JNk}*NG<58pAlS02#gV||=&rP`#H9Bluq3pWv|9r7 zR*eDMpg4T54=YRyri241=JU|#pRd<5E55( z%882*=K?F9S~FCT3oEWO-!R#@H^8?D?KnTO3;bXY-VdqSI!+Ba2qYeh@i@5BrE;ET zBxhCx1#C{dXG7?sxCEo5Wr~2l#XWL|ua)fF#cD>c7H7blW~2;Xc4eB+Y2~9hI|Dif zTy_Lx-T;ziwLJxC@@j?|&~K&494&H+?~#|Gd7r-}v6HD>gnf^{UC4Sj*w7dgO%{ zo-F5|cd~SMWTk+*&83NP1WrKuW%`Jg4&>r`ej{1G?muVHBsSZR^{qo9QlJ2n16af% z0mgAqdjeWwGSdvR5S}Qgk;ovjI1RHouB)2@1Aqr&veSQpkF&0g8&Ij#kCK-~1esOF zhPA|Evb`IUUJ_<4{Ec&)l|ia`+@)0SZ{1QCfSa#;8pAV&Wg3NTWD_!!T?K$9k=NC~ z=<|p`gJ!88xJgr)G5oZUN{0uhWc#)u*|cj+Hg>L)hck<0w3L*oGDOuH53q+uil4DC z1^b}K1F|Q9DeAnNHmW+8?KbU`;R1o0W^!uZl*lPW8FHV^8 zFRn9}m@z%)xuKM1&?HQQ3~k2|?@*Bo&rQi2U)L)0R+Xix4Qt6%En}BH<~mLK4P0>Y z)v&J(qg6{quervfu1RY&b5*|mv#R{%M^TUo!XrVk=>?a`bpOe9O_=RC17$U>#N1w3 zL(HAw9SY)_Ma)u#CT~?>wEbcr?%i>4Hq)_vX#Q2aHyrli*g9+VF$m371T|V!I1>T_ ztttTOeOwIkaT63H7Z8~*bEg#m*eKE7=%06FzgfbPAk#zBw`{LHo>3aT zF?g#yg*3T=od_)50fBr7``pV^d;blXI4<^=H|88Fp&D@ezE;bE0y4&`RwJAHV2-L| znt!I>d%n1~Wl8`wTME;5)G6z(J44hL;~y@!n3d0h0~&+~+he+KgQb#m5qN6@3vx~r z2wBIXf-U%N9Q-uh3M)-phh%zsC6#MO+zEJWa49Jtv>jZJ0rJ23xosuqC`PA7tNbp3 z%$S(mdD}+$IH1x+`-_Qgy7?Dg-z_feINp2CmE)821+FmHXy zsnWf;&~fjb`~LiaN2cE}HB(-6!qJQ5rO#h0>yBS0X>5!Fq&+CTyoz71v{2L>NQkhZn;pi8dpf|1jdgW9)EtsRnR?~rsymn4y9L;z>K zUnSy0ZdzwXN=acq`*yCvXN@{$QW*z>Wge-T#<(HT7K$0fa4!OSj0GdOx!9Q|n40N3 zg9*wur)z{7Y(ev!U3iGOTNzWGaRm@FE@YcceR&MjP%S)YpFVc}n%`3ZqWKGg1Q}`> zKHTYITJ{!`vMryH4aJ4BVK6CMp?Od4cvzZfsX$r8atoqKQHL8R;=r_+RiCx%ICcYV zpL>`SR5wd115j?uxu+=&)56q|E8i!wYopA;0TWk$tqmZE`AM@N3ONIX6*lX;*SBvl z#Ol?nG0f$=SiYEEZ zjIkg{SLEuO2+~NC!@RP|)WvU;sikY}NXuZB_D1=VK$JIf4RyYm5@_czhxsB&S^rH) zBwdI3QSmoo;tkZU^X+1af{YzH;Y+H3N}vWj5U4e{%7h=SBF3A!2F(5hVZdgGD`)#M z%&QKiVOe-=q<*jT!Y|B1qq*76$hEHyJhl}aMPTk$kmhe_AwU^REsu&6svN#r(UX=F z7PZPr%W|@2enxtjVPG(Te}gGItQbdZ@gnpe7m+7j7}z0&q1`ZN52JnVW9Kp69~+a= zQB>1h?_hkWaiB_GDsTjAn)SkJI@laXf~w9D2TCBeSGF1I(rb=+gEhmZmviDYsWeUt z1NsrU5lk3jprrYS-MXQ)5=3F2SDkkXL)t@I8wwU0H^6q33~aA9SSVts>yT{CLDL3* z?e2x8u@f3M(=O|#pU7J7{jKwM-+RXGY$(P)u$SX-sNBjOpq?atR~m&=9E{$y-vcgu5byZhk(J1|gOd(zPhRP&F0WZ zGfNjkx@^a0FG%6QeyL6(Xav`$d*?~I9RXa3a4rp^20e=1t0UMo9%V$jKLdlc++lY! zx`t!3;@hV#sRxNY>{Z`*HUy3V0E1%sl{`vK>U1gFP{5`!I?>)M`Bbaiefvha?q{3i zj(c~@?g3a0%1Hl!6(XKN9q!gnsddhiME3%K#e7t|>Op<(988WVR03-PJ$z;f2}{B# zbKVQUirb}YI8@+keoZQl z&UE@f6Ax}rs`h-(!!VyCAWMH55spdix{hp%S|&O)-Md&S5JqwXTTu`ezB>?$7P&v$+H$pMKXsVwvH_m~w{6=04-#LT}JoS6f z=9jN6%6mUO0nmpg4Zupg_?vy{hjWg zh~j)r*?qfb&@`1yUGU@N)GbRv8>@qY3bFv`7);)(*~8@93d`IPJt=wiiZ(fOMThhQ zq??fiVn9Gpb{9ezRJ4NlJF%uvH3!J7G443rnp;4opg$4ps{0nyIYNeqe!P8VACD zU{NagPs{YGaBn4kRZw#aie4K+)}1z;?4z~Sb@#coC3iY-9%_3}oq($NFwb7;OzZda ztqC8dIN$>helS;+?=OGlK1w#XB5kZkGIQoh6VufYm*S=CeCya98#%r8IoiB_FN?vs z^~Ss9Up{ap%8_nUGk6KY*_^KU&f|{le8=HuroY#|wIhG&r|xpXeQLafk^a_t%RP1Z z>z2xbBNlaj_o_$#_5LkWFI&C1EAxhzoFpf&S)fO(k&x;O5q5oG67HW3sG|W)#%OHe zt?Ln^R)DdMZ=X9K(G8DpSd)T`$x9THnNj*P@C%%$F3p-IV%#YKpG&%Tu4Lvd#vv75 zW*%3@5$-|LEuSV)bJJLp&`=S`NYuJQni+?;Q1XGb=Np)L9;T*kdne_VyLZY>w{MdjyN5JL%@QmWk@R|L0@^~$mbN|s z_dxMS{r5{bmXW)MxpMl4p0Ik$!(`iaW9nfj1$hY*$ z@To75!ot-WDA>H~QR%qz2a*}uWk1v=jPX)4jiV~ci=LB}H@r9_9XSYFU8u*r7~j*E z9<+7R*$@~h4*yH%jGm0|Q6jK$s1@KLXme9ESK@oL|Dmr<$w$8h%L-jJF<38N@ot&v z>Br^f^R3s5Ho+rK3&$KpdDA+fG;RHK3iWY2(dy2RSDAJ)<+ociB z>%NPMVB2qNG&>@STGO)(StH+O__0}ctj|uuItAhG`jxIR>Ci1;Vc{b1v}v<`8GX2< ztsoN1C8RZzkRwsg<@}{Ba>k;pEP}5&!*S}Qn03n+G)$8It;L865JE?H%QOz6DWl*| z6=_mYsu`~iW@G{*6$}j~wSG|OHX+7Fq*Lue2jgmrsCO`hvaX}UlQIoIIqM(r+9nh` zY00&qfDeli`1C*-N@stt)RUaE!o|iC;Gi@xnPY@7Hbn61Z9h7Ads?azIM3K z>a*J%IH}upfdC>qv@Mb z{y?jvXS&+1@p0%{*XLB-fi@73UR7@R;cfEI@BfNyeH81aQe{O}ENc4k3CFa(ZSy0s z2adh?wK&TA@`g2k^1gM5`|Wg)lfUt<*4W3c=zG?!cMW|c9ZRmckDb0u^@t(E7cdpjY@{&gu+*?8O<$Wbfz0L(w+k`Qi`T9i|=?0-OLnxNRX}y zNE<1jWGIP|npG=u8{68D0>r8)Aqv)k!8w%!8Zj6$DonkZzJ-#SKwrfz001BWNklgReyBjPgvT;^dw4lLsRcS5efh@%V(B`4oG2aM5WOL z3hO6na>r{Crt>mDyv73+X#R4o!^RpUU)y}PQ@J4=dN96lYLP3VeqGpvZn{*nIX5Ny zhc)|bzNZ`KJ zVbBNy!vxj2OklF$BhtphyW{e~FAm7d(byDjJDMO7&@YR z15Yru1XHmnAgMHJLWuG<-*38w__;G~+fy#!Igl9o86z|e$jssqjfEBiNJb3Y=awX| zK#eCRpJzpfeE?lRqQ7qL6}06R-$S@h!}s9=vbBUtQ6 zL}H(_yjjjz(k#m$6t%*Q7OIie34k34#9(8r%#6Z}jZ&2dw@C&0$_R{cjn%=#g#k+k z3!q3}xmmR=6_l+~!!>JQ0VPPMaIE7vY8*^WqG};5Xw(*)U}0))%^9F3~irq)) z1MZzYW4T40B@uk$4j*6TWVqV`BVHfspEa zB@e&oD9zviYm_IYcFp1lRhXO$LxV7dBO;WH7k+96Uc?bSS%QDNIy$Vmme!6>o-^Sf z!CIjQS_l|{S@*%$IeO^52{7unrg=;mf@xaL=wq8!(=d>h zQUUOemoxI{&QaNL?{?X=ahq&=bPs~k;KyU}3$#rSl>OA&4FImf1lz+26D-T|{>wV# z!V92zE=4Di24h?X7qQUCIP;#N*^d(R7@chZ636=j{OLcsJue^r%CH<5W_u1&$^cA| zi!>}b~*P{1j1!XFyS$95eyQ7V4??QKPOQTr5SV8L`H;h;i)~E0HfcGv=&66t49r{ zbZ{dOXjvNAOy5NaIC=Q_%h)$mDtJUBOAD7+mKg6V zc(2z(GiMdT3Ob;*!hIiGnPSt>Ii56JPCS#E_iq8uX+^|(GtA2w(03BG+!Kr^35tm- z9PSF|Ec-eIi$$bZaWARa+l$NS`YR&4RmONleWHRmWi$|i1`Wx?lR6hRU|CAfAU61)=uyIWmElA(7dPVEo zx(?reTXXK+)hDx7{aXQPF5pN0-$1S=3IkBO6-&Bg`NA$)g7mIAU9HlB?_y~;%b3ci%(BkV+?=e$YIhC5 z6$%){)2IOsux?(s1inE8Q~7vm1ZyyVs5#D9*)Gl3*otC%w5#+fYCDOg4WYAbFy_anjjJSp?!*&kWI4oUyl{yjx&Z3Iv)kIc*J}FtNjg%%dD@MbHL{ zj>M4rOt4nTTUjaTrY8G`GxG0O4awC%g`XdQeTWvw`3jn^PL$4`G6uF{*BVQ+F_RJS zX_CLvlYWKI$iyhvP@oL6j^@Z~GXUk6J-1a}^TKB7#Hpo}{*&!!wG{#ij*TqSQUNBM zSx7A#v`|1?`&KkOPSJ$S&46s<)|mY9-yW1{n()y^W$s~8dht7PXavsm)`0g$ld9{T zl(fKzE1E83nj&dOr&p~j&UyKsY(l)%(=O;X-W@s6Ey6GadPZzP{}F?cx;28 z8R)7uL?Kh>3UrQ=x6H{^kY*62uABYd=9c~P{jawU;=8ScY0UhBrd2kAoN)*^tLLQT z+?5^Bm~+zK35!_LPF=H|5y;&>G59cWP+%V1CHeiErG#4bg8g?0GjwbTU5n^5vMP9! z+N>RKak~FGm+YnQd>?8Vyd?$eL0emk^z?L~EHVoBII z)(142VR>de)2K9PiiI7MYn^Ib4O1qxmI4CJT9&N@V9$rO(cO*F@WUJsOMC&<3IJ;c zN-tmi)f?sCKKOMxum^eSXe*U0Ppv&V_n~!1bbm0JDNVfPqnnjxo@|dp=<+wwIReMc zmmHt^m-}|V|A8&Vx1D$T%J^IU=oHjlr8(Guj~h)3`zwPBTL-m@!O(Z^AD8tTcgr0c zcgdC=BQk-b44D?qs}#rUJI~*T#UPb6ERrr<9a#03STKuYFv*Y)=IFOTs-2Je-b)vD z$npi<(!Z!n7R~RFPKf7eNSJn#jJ2oDC_x*QTmShm;xgY02(c5A-{Pe(iO+M%kKZ$* z7+M)muhL(d=23$$x-`01D#Lp5zoHZwEEyRz0ko@pjibYF;*`4E zyI?(lU!6rTkkZB@JEMN-JcV_(X|n(un%xZ~3aFht{LrH^1Ew^A6PU(Om;B(s1j?I^ zz(h4JKe+KO)GRN{k{*%I{oQ;7kr`}~F(5k&C+&bC!dEGL28SVHDHQpaUsusu(N@UznCJ|JV;Y0pa$qJ5h4y zzEXxNEiwZpKT%16DMCBfpdkk!T17z5dW@>xktzw@MFw2TZ+#brtO=bL+>qu-g+bpp z1X;>&mXZycMqpoh3b7Lq-WWtzi*xNWIJ^sj2c9sV8qL|A6r|1Dfp+Pqwi}tOqu@@# zz!|b0Xyeup3V+%@0muX}ttu0+3{LFwtw;u0;{%eU*08SFmgqo?jh6FGC ziuZ$1LhIh7*5022olVOW%8|}YO+zLq`Jp;~PtFUcNYj(tm8T>}(=zYJ8}nM@JK>nl zK$bzQ#ID7Wmsy-gy$EL(Kydc6+Eu{TX)(paso8m^(Kxl?GBtJb6FoSH0yMO905D6U zvIV{;AET>Set!m4nEXX*(k#LyV#p?Y6e7 z{gu(S&@xrKUa$y7@DahHfjI<8YS(NpFbDdAQN0WW5Gpi-V^gJ&W1R#Ta_?49GrWk( zW)KO{2CD^GjqW&v2pt!Ty+A52;mKOR37CWuI9!2%twmjU5ji!FErj_|PwFAK0(5b{ zwT(-ekD1&oy#*#Pq*WzgYEM8jpvj%7V+z*JGXm3i>?+l(2l?q$VZL<@5F4;}uat+7 zQ;Pp6teMtl()lMof1`Z%E5E=|fC+ik`7QF+SHRql-x$RELc4#G%zWoyLjL2M@ZH`r z1%b}AL0blAfDOySDQH5qF`TF)&p-PpS##I|x#xl1vVOxJ(B6O?K(2T`k2&J3tK4OA z3kEKHwfhHV9j&)dfC}&`lo->&35D|V=eEhq&x7Bw304u7W+iXS;uS$;@dExRI>9K% z1wi4x`$m#-@jDL4$c!g)qvIcV$2oG`+J2cr&>k^wx|)_E&HE782-GE9lfWS*DF9go zm9-F%uIHsw9N{4JIn%VY=h!NATdi5rD$iQpDyJ=OljU7WJ@Pt;_=nOS=hEhr zd!zzj&I6c>@G+MVxLIQ@Y(?8ZM+Um~8`D~&AVvMu_z>4DNxJ93JPOPdBv>&30wMry z0{-SP9E>xL8v2tEI%)3CV=q=%29#jOm>TvW*SJ+ybJsioZRIUNu&~zqx2Q zQ4X;D+W}2~qrO=4ZY=JfC(BpD^#;D60=)7Y=P~+fpJ`Dj7FvSKk8UWP4R!lqtPE1r zYJwa$i#OR!(>%`ytJ|p=TE`R2+{SB-$C{`Vxdf=pgneOBqV_E)Q95+(rV@km@~}Q8 zl+=TSZT|SsA~&GD6zbNuEj8L|sk7~|OJlzjiHpUOY~{Z}A3!NP;C zw6?@XmMu(u;sxh)e;_uQ8-FrIfcs1wO6LFDzdrfNqB9C+>ednP}9M1ODc18+Z1 zTC*fZpDbMoTfiyQl749GkX(1mBXZk@ozR?M7Ie_ExpfG2$uXL5CFJ_o^}ae$D?uCz z&UHApe%SU8Y}~Gm9=2C>eND1vZDT>zvz(lH;!;_+W});i?u6;wB)w<@>3!=FtJVlR zizj&kO6avLUMeliSAc=FX&$)x3X{$H8WAx8czloKccYRHP2JRtk=m4I`WNfp0j$gP zOV8j`92_;Nn;)Ah2~dvfX*9%G#LwL97{M3c z@m?(SLMb%?@YZYH+R?CkbX>kV4qQG~UnG-|!$gL8ZU9ba%Tqg0kqPzBRqG07WSXq^ zP0GtI`8O1@g4qy&^S(Fq%GoEvJf>O(TPJHw%Ftw0Zo=Uc|MATOvI~k)(4LKaq8?g* z@pngCT8>;bM=m;djhuSy5-=SLx-p+T013j&;P8}egIi_Ko>AF5Fp0mxc%l7F($5If zJbi+!B1ivo@ZXzw<8#1aW6p~R=pn=495ZO@W ze@iX;YuCei0u6<2bisdkC+e**NpSP{mSyMjJT%|Fat211bELNK4EnK4jD#34uzS$NWnXZ^0I@7 zP@b45$n*@%_+a?d#8adjuf@!AA(|NgT92XjKr9DZ&Ov%<#epsi79_BP4r{18O@QZq zKn$m>n3I)thjq)jN6nL?`ndiOHMq|#szK?-9^n=5sKMf(isCT#5K`w#IGdc(3fHQ! z@P{&f_lX+6`2Wnk2b`T(c_w}|eKZ>NQSVlZj+{TXfMu^d zo;&w`-}%mY&)eHU-V42Zk-y+!019Tal3i#~unhwbPCyu+z(F_1kM*X$6R6IB#cUBQ z;~khA$22#D3tn!T3*eYb@DAG+-&A^q>^N6^NCU=p#x9`ngo_sFLZNAb(}WilcHDs9 zGvM03;CbtiA+FQhtmnAJxs?l|!+O-S-1;`)rv-_!r1KEQ(FKrm2zC$u< z`=Z5Z#;VoG4MO5%AbBwJAkQjfb=TJ)N$-94U!^A=*_)N1mCIW{chx1c-qqjJ^7xe`xR;N-02ptux1b@?{JzE7|?@H98I72 z{QdZiKDM-cTu7Rs62P_CR--Tu*=@u|_YJPe_vWpaaEOEi+Z$6^je3M&=2qhu1zp00 zr9`Uo+yWq3{q%lsSUo4b^6CxgMVGCDmQSV`!deP|vL9XW_`FOlpw@{pu2~x{P8~~E zIS~W#kqtf#NX8g1-PmKpy=h?2qd4ywnWZE!j!(2l$x;hy3Ynlab_%uBy+_hC#HV>e z3|-l@PI!)OEBZIH$1WE#28(JO?zIt?RlzK2odq{F;>V1CH<8HbuC^1yL>{(+&~^*W zIk`!THAPncD}>fSkPchdpoh( zdg%*iq#L%(22Qo6zQOTy&m#lri{ChrcI<^%aZTTp4x3`^FHvD4&6)xC=!?!vuX)Ah zv}Or}1MJH1fEcoP0+X%CwRv;pnUGb>Q0703psnN};%>umH4ii9#-<4tEaPv-0nB{} zF#q^x?@V{yv)hspEkc1gb$3pro35Fj-tx-XY0*3+Ir_f3+_brmO}4>hetLDIBR&tTl-|(c=!kH6? zFd0F@hyoVBcBi zGx9%yLI$FQnVtA#yi-O;8-(ec`Q51#zZuD)M{*d}FqJT_u?z9$F3>$=;H_X%7PMjS z1vUzy(m)rqx7Q=)2!h}bR64*vn3y(ziVnrZS^2^wHF*IDUn4CH+6qN}HtO)_E}oO- zAeSc;q&Sf*>*fwi^UAs66ZmA0=A4LqgdxM99xehTr3P?Za$Si&ZhrvN42!kaC?>`$mJmaq?jBZLUXR670Od)IjN zh_J53sGVLk1ODd^R_uo%r^z%2NkErhv>1%{f^;D)G=u{E#GKG1C5*!T6R|EXvlZEj zGuLlS9rG5uhRyjh3)g#0b24k0d#-p%Yx#($=f!{X&}cwlO5vM>fAJTXYC znaL*Xquh*cKJ1nTp$_*a>$|71UYQ4GP-t+qu9;?}m7g}CrUe^9yD%_DQVZq+mvGnx z)774EFBP*YyU6jCP!yl@l5y0F_V3)Ir$Mn_Po-|JY1(h{UAcMDa ziWRpkv*MWAB;s8wH2t;kc)aR{i_$e$Zh$g{c%g+f3eu_Ns`lC}6JrQI4j&N-R4)9@ z9Y~zvb2*F)aw(RMsq7()ujp3(+?O9tfBCW7(y?QMmRZZWj$y&m+hAE=+?HOpr8`}) zvK3(zT#q?#+cS|q{5c4@Ctxv>`P)WKL-JWu`oM4ea9WNeM{7Up*|cP`P4JD=%{c>! zG%aYsC=Z5&uyrj%yq1Z1%r6)^iVS=F7DBu5Ghsqna`z?o*}kKKzyo8nhh}iQHW7(s z#vtuW?KiDJx`99QFHffreE6GiqiX(!Lxw-KsMcS^S0*#9-?T3M=bwB_y8aa}#i2S( z2!aqa?WXERWxMMyCS9Um{>V|3OC5y8e2RSrc;E>${DsHzYD3sT(N&i+4I30zhd<4hAo`TgpPWp2ua5jGs!AChCT*bL4 z1Wn$k>!UkfB;T2kHiLq2P!g7Ucqg1wjbnPxTL^*Dg(NJN+tDGyeW&LJ3I?~&tZXWm zJSlG^4Izwp8cAwH;1vV-8Ad*c9c^pM2JWFCM|g~7sBPGjcC_%Efhw5}gt%Iv{CA*+ zyM=YjE<6cNTa$%8f?e>v^AZYoSM0ccRr3fp4YeJ=9XOt!MtmyDiP0AES!iVMGs+P* z*#HxtRR}^}%8tcLQ}_Dy)=yl@Y*_ak(|HGA#-z6o{lO>GpMT&VvF{2awzW;3xN39v zyU@()FTUS`IyhF1{?GY+-`~8t`R4cE_Q4&y(>vbzwyV+)U%$a>kt6AYuCY&j>7n$w zf8P$~tN2cAjJC=*jj&TlstC4xp~pK68-1gk@FV-MjKonx;5c(I#1hZQLbLGeLi>JA zAVLtfIE-&>Ttg;<3AX?#H=yV5E3Q7@+~JFQ&>oW+j=V`eoUF;YEObPjmZmRUnz~l5 zwXAMhCYr6RICJaa2A-NThC~-O>u0TWlgGD=fAKUL_9Z7!;VxvW&S@6=V-_dJF=^tW zDQu#eS;k%j;RHjOiUuKs{v-4d^U$ktC$gY!?`R@Uwz2prq-p&EO{w%@-XN{Mn~da(lb3>{`}?Xr7zf!Hea{``2|#{*iMo?-vPv&o1EPYP@PP_SDZ&$o??z^w+(i!Raw2m8`rfBX)t;bAaK zZmY)%u?7myj#eCfi5NLOOaqKhyIj(!%m$uFZ}`DY>FsaW;=)fi-UcCnzs;O0s$B>$t4zSZo`pT>s%x)IH{JZobmfb#NZoAX$=0w=mk!y2 zPk6>S`V0&sZ)5oIE@Wq;Dq@6@)J))Ki9$C}6R|$1e+U75qnbxjdjIvTzi$qN{Pl=i|>vtGloB`}{UmrS`!73g> z3D>A)iOU*q?Ue9sM;Tx{1uqDMQiWHe;G=}E zHcx_FWo$y9Dg4*JxA2uK*Rl(O9wqf1^YmD<7!OH=rER$FG;4g_We0Dtzu`Tc0jqlY zd28$dACUs^*W&o$QIMDDe}Ceu>39D7N76Bbxj5gJmWjR>T`~KYQ`6{Qe*7y4n|z;M zA*Rmx@dU)rzkbCvU%Tzh7cbecsOxur61{mDnap0@2eWiFmzs)m@E zsk_w6e@~fCVE8l5(wUEL6#_z*&0GGp94X;>G8s8zHclEIabY7DQZOc8x4DgC z+ueGqpix%i9|o*yObIzx|89V*12edrs8$1(O?P^>g=tHu)t)+?h7aO2F}V7z=?AFp z)^BV}pZ%w;>B*-KTaCPh+RQ4Jd$Jp~@SD~xOfS3U{B+rcE7M}^0RwxHe4|IY74O<~ z5deaJc7Hop2?bN|Wu_{a7ZAINXKy>Brj=yE&u~ESHPgWzNC!Jv`@%jyJ9JnKc>J)2yZ$6m=ph7sWvul*U~A}g8LV~@3;fkjid7(&wjt~l8q*qUvk6sY4OrUX7!npkcrb&Djx$Qq4s$A z)B$wM-Wkp}D(5fNN`m^O#bJf3`OUTK0Tk`%cdUx1qh# zMGuq3XUiwRRk6*_Xgyza(Hh{_B=Q58F+PYce`t_G7d0&7QB=oJV_|MaClWooPy;<3 z?(YsH!BQBr)QRqD&V%!z1<8g*L$Iiakx$T%Mo+`|dl0T~cK_)>Th%42mZn+r+-1yfe+*#a}O=S44oWjKmQ`o%RJvyM`rgJMmXo2w?35K{quj4cJBm##n{bFPvByQB?k5EGcVI(mnN(6#Er~)Wg{C)>f&$4_b?`6y3NSQZ$hT>1l|uLGkpXn zw6T?{@ddh}jTwufs{4^^tC$uU>jcWgzK&S!NB-uv^ys#O7SFY#PaOd2Rwv893%x7f z_=6XvH@@mp=JQz?q?P4w2y-ygzQsQDW+lW6M|GJSnh)`Y){xwMj%%hc5|6MZxIp_* zug*}3)$RM{-ueEorpI@%W1jGZ|H!c0;38df!RqwG zue&L|?8cX*6{}WSeJmoPj(N@qGOjSBfwRux!X7-Z6A56uQL2Xi<>tmD!HS<`B@EAr zu%~*qi^wcbzyvIlreGCg-&s=GkCLc8sM}{(Ji2@-(JkBBcmVf6uJWSg5dOt`V~T;V zFVFkg`B)soRWiPt*SyV_l4aL)Fd0P%36TW~CGub5()c!&K2)Q zP4#?qtz!b3S1B1REcWh3Q1I&rQD#L>Ov83kzc{{1^9)~{&sf-~`dfX)<<{1EML+z_ zW>8e{f}Y|&Ws_4OV_1gPzD9S(<)2A$HAM-u+`V>fnz3PnDr<}=LJ--EP3hSAruD{M6R;_y6)Z+}?!=+rx~RNV6=c7>{=1w=Jz)Q-zC*IXKeBdmWQU9V3Y*W$P~ z#7fIt9TDv&-E6l5VOvov-MM&0>R5!@irA%P<~nCIvcht2+Cm_<4pbS45{HE@mYRI zY*E=0W3qNS7vk+yL`bw$J}m2&)-99n3S*M1F(ycuRajzg#Y}@;WM!h7zV42V6m#b3 zVB-+VIx8F*`GDyd4n`S1it~b@Fhz{t9%l?qHSKCRQVDA?>V70yef{pI(!byKM0#xd zk<<$}E1N^Go^<}~u5{V+mLbt?9Wv2@FVo?gL^4<_PGg&9u}67T;vDpRIECY`F)+fw zWI)*j!!WzX*X4@x1Q>SzVH~a6kG|BI!D-C~NV8-_ud1V#i`@8`;~{m0kN*9`=`TKc zKbW{&=gnBZN-AYJ$J>(a*OZA|k~w>yoEm~Cj5{WTyGm@NcHU#}heID}a9 zINIl0oZ0!V=`gNb>-=eF?2Z#QaJaP@hQOaJqF~bBC@LXNoH&_!51&e>j>8*e37Mj~ zD0>o&T*&+VCS!4#PM8wIHy5o?$=4#_$H5e_u(;l!=I|74xu$O8>g)hJoQFJ7rMotS z#i}WypyYI+jq=P{Gt#VC>{^C{L0}a(qhf=~B$K;_*mwx;?m<|@gLHWhutr;g*eXfD zC)zCL%tj&*#z-1%hgZXX5QpY7tnQm`4NOwWT-+{lyepit<%dh#0HYlZk700S zT_+x#UE3MIoYrdm<#pz^_Wj`;)Wg<*WAN0V{$AF$Cn4D9*#i{g;(965(^3Z#EW0;e zXlGsv7Yio32AG~er^H*{`Jr^zH=Z#1?d+I5_QEZ5-mqmw&)5EMmuJV#W4;z_z(n_eCp^Qw`p1kj6eP$>jPoSM2UZ@o08GYTLw zkNCZ8(~@-aE1sV&0rPFL1`I@|vIUB>5IcFRVrguF*RfzJ`aqwTT6-9`24UI*?OUyZ zOq8Rc29!0{BS%qdjS?*i6lVLjqM-^qf*B&4YA(zTQ5e9Mm$L%2&o4vC9u*8H-_@|< z_%#1^Vk)1{VKe<|7qqQ>@A#LwsrIDd;?pbB2x~!SAGwB#p^rOThQhvB8Zzyni`4hx_H*+JNl_Dq#S#{|Av_KS?iw zTQH4uQSG}aZ;kXZ073s#w|qYB-hU=7Tec)^x%#qn-A&h~=U=fo%|o{<;=0emp~}D9 z9;cFt7P!MuGSBSap2m97BY;XzX2Ryf)EW$R96z1z!m17s4597g9>kS*>^cGxX-0e6 zV`=-&gXv`#Ey3}W-VCVHq;+~-fK6kEHzaeZn2Q}B>M+L`I{Vo@CMJjpCn>-tU(8)A z9-z4!M{p&odakn=#?;45@W@TiJ)}V1eARjr%H&~#5WXyR>+c_d_kz8^htUgs!YpR< z0O4UwB%@HDF_XQ)JLU35r2xGMEYsxaTHz_M>MhG6$0WHtQ!vF@WGIQp#JTqT&2UaJ zvI@s7)^9Ng)a5QXtp9c;N7$$TD}&qj!LUT17PqQUM|jQTHF;{}&EX6+v;`GAjA+f1LB{1e~bOKkR5`QA;MeeZ{xUf5ytw!5X zBthUHh?)Te3$O$e#$$01{TLE#*miUhl-Purb=!RO7DuovaGY2PJv78Supfq zhE6hPt+QxavDrph1+hYa26GGyj7u8+_CJiDl`&$T2IXq#!foSmet+R3PUyb>MxMo+ z(73kp%nUD^JH#04(;=)UQDW4D){ksYIF3~2VOXT2@M>5FK3|5r&K#=e#GEC1+~v)K zG3XUw4|HbW^PxQHEtr4MN=DpRTL6rBq~{eJ4yHB*`!kaShNCoZ@CdvCJQkJrJ73h} zLI6?lxD^>f$Grpe0313qmL5GiBW(iHTYusCX(qe@K68&M1)~;R%rn76!Q+TCAKZ~f z@H;`lj6^M~6VAG^h&=XRlqr}}ps>vA@bP|Bj2ukgynk5v5#8E=TdSFJ8aNWWg zP3gyP*l7K|^^zA)CylwV^m&Dq7W^Yz#}q)!sUd;LH7<{iye3+g9d2PB2k%4eN}^oz z<(YQB=Z1vKl|xxxL=c=SLo{sXEkLh!cy30lM>iEI|+RYSxVV5eOM;1{i4X!XcFVQ1) zZ}E<}EO76I;4CIL$+H@se+l|mtgyfJY^_`F8{{mY4fWe!e@ohW_ja?W7cWeYUwh@; z8!wx;!Dby4a2e|q`B0rgc}8J z2a?1(m!ok3&Tm%PRv;+fI5NZX8y`Mxb)%!K9UVsNP&AD&i`=q-eZ@6VoP`PB#_gRY z<$r)m`C}IzF@Aow@0nX?`SG>zbr5L2&wxXrzcQ+pMId%-$(`Wi|r@SSxH0>?-2LCZ8ju?3}m@NhKBolCKK2cNa z^EJjN!N#s~O!TsnA=LF+X1NXSOvg;PlC>J>7|)j#NB+YYEf_Wu$A)@)EiS2tyKlvh zr;-5i{WqZ@X8{@VF#8+KTnGlYKFwIPGEJWi?~w7hIMgEM5ARO*K6)@cz3)^yiF^gigl3H2 z@9##|{IWUi>52;$r&VZ-qGL0)PQirUjBsU#hrq2a6A1dQoc&Cp9CNGZi238X5uO^S z9gZ!L66hPC#}_M8WUA*yZMD^DTk@641KW}OHFx3c)I9@-VW0%66PeiUu!`B2+opmA z4B0u3CN5OD}|NHkIPrvl`_rvv#nrVE$ zdR6o1-~75IKlb+bJk~pvZDn@xU3?QP>NyYjxeslg{`-Hh^D7gR?JxZJ@4X5L)RF$@ z-`$%&{*P$FVdD*WJ6WUvOYAvKC zTixa49J<<)dY>Dax;;A_!~&ayZy;14rdv)`a!9tkYbjycpw_f*<*f9omuyNeKo_hz zvsKS)#9>;Wo|N76R*pep0`Z|cSo{1XY5GcJh4-xV5-wkN#vZq6V5y7^^uisExHZnS zZbHnO{l$x0!>F@q zLJan;70j`TebtRNFdwrP#lW%v544SE`n6_tTmNZ~^d%iSV}u)dp^gvGPX@mvc3 zQ1EQsL{ZCbmK&CH8AE)15Q%RStgpvMcKly3Lz|JZi&XgbH@ z4Rc`%jo_K7(!R&dProZoR-n0HU zX5M&*BfsDFJ@|FbLi#stbUpu65Y}o(UOQm39q5XR!unawQlkRP*gzN$;G%a0glh=YmFy}VY01`2q(}WWtn2adQ`XZvC)zk5w4Bg9 z3&9C=hWSPkai(=8C96#@1RMw-M3iBbuMPr>z;Y0X{GAQ>;R;w$x}@afBqy6(?4rii zJ~}dFtcYhg!TSCMiWbX(ds;1v1I1nkJUBG1Y9y77orYD-zu8`uZg5D4aR|gQ)V8Bx zH3TVK+4OF};!)hw;psS?2?N)JYtRI|nUOjWC9YxocpQD)86O`(($_f4W>`at>srvX zhDU4CN?^j!DB8q^^~|`X)hSyNLk=7{K_cH8!|X@f&-)%bfS4Uu}^_rb-e&bDO zG;%{)whC=+DfL*(KFLHrx;dTN49?<^T=x)41qUFQX+6U)ZOXef*`Tt)CK_bLu2e-M z4{N9C|M=Mf%;nz@6a3dZpH6$xE*44n5*Qu|3eSY?D8Y&SX+v`6>(F6t{St(D0J|}R zCoiCcP*V_>!;To*2eX>lo15p@Tlt^Uwb6t;8n#kr_#$};U5nGQvVaPgRcZ)#^jwck z+us_bB#b$F;yd+RVAm^_Xu)+2Z`%bIuS&hg&!pqW`z#I}M^Um@0d8}p<-}U$BETmt5&Ej9W_kp25{I$1lN!M>#lYa8QeH@Ni(ZD19kIaMPvWz>jKrI&RLNXIhJ*~(&a3PyJE@kHF^ERXtQgfa$ zW*(~A*#?{6cz{#5Q+Z@NN(g8hR4p==RxjyJZ+PV;>7`e#fv{$FrjW?d(2(CWT`TK~ zdo{OK)Jb=*y9gPK>tI>Y#TVmKU~wEF>f{7!LdS4q8#+5h3`!$aC{ZaWfXHq3QvNcYn=r<{uuT^a zPcez3Zed}e*;!+Dm<4;RQwS>+*xa~O6kzR+4)#L{7)+x$8qzwmaYxaWj39VUXhKrOv-#I6i$+0D- z3ob6kV2d9f)^Msf^`oA72*<4QlqtqY+0=ze-J|dT(9NC?XW($Q0iYza=J<$d2%8P0 zJMP<)zIx|&E9t_pHcn>n((pBhYA1rm_?y+pGoVcL%;`w0dvJ{9QnX^lneuj)d?-F_ z*=dFXOR(7dC&CBXcUziekjw3A(Bz{jcuYBo4~5B8D$HGvYs}I!%5vQBjpo!Tw0KyB zj(xNN4F+<1M`;+kfOeXc2o7y$dEzzUsyWSO4DnAANWl z!oT0ESH+Q^{UZpJmp^aO=MNm7eB}rK+e_2-y{FRe|HU`445q{|{~yf-5Xh zZHj&fTlLfpcZA_>{=ODw5?J5)OZyTVAt9Gv`dBZ+SDz&RtmbVoGZL~}0NL`(i_79n zL)A!=EmJ)#$_^xGCeo(!=A|Ed&1LD*jf zLJ)Q(1%0Fjs;UQl#`k3&eqTr-YT3j^6LzteL5Um+WNpoL_&Y`s?kTVldeZ{ z*5d+-aPLxBQ@U7NNag+cJQ_#I$ut2+p23SF4tYeN`Dd%CYb`FcSwduAs}D3yRGwZ$ zg}{Uj1P05O#^4TRD^Q)pL>xwjFiL|D9qvPt6_TxRxXmLwj;1~+4@*}sPA_=zW$D_N zz9?P3(icNJI!(A zLh!6%t+j4;A&ZBvUI^x`j~z^R-nR=b?L#BvjKcWhpA0w*}J zD&Pz6(O?Fx+)uiRqN6;q>s_ImImC>!L2O-o82t#DHU+)6ge(8K~>L`}wQll&pUYRjRC4g7KeN$afDio0gLcR2Jn(()Soip*wJ~LAMnncn<^I2>Z-Olv`E-Hxm*Fhoiqt`m##5;ov~ z#*S?6nd_dP+83{dyA{G3ZdXw)sgxw7y4J~w0bCEu8p2x1M@5(fIL`iLDpbx|lrzCt zUQw%9`u)GYEp6R~v&*se4I5Xd>#n~t zU47kEsAXPv zPNmLAY#DZ|;4&8n07*naRL?RioMYJ>I3GQOb81I?dCi7p zY2m_IRz}r#>Wp>YW6~k(oToGP3^ze5^Af_dVSA48%&qHv>S+a=aw>evee@lyQ6Fu6 z@hebM5E=%_4O9Sa3P>b}ap@eJ?s_R221mlx=Hi0Rggs}*c||C|VU26eLzxA2P<7!Q zDgonsd_0b3#vSMEJb=6G@ORQJdlH1+z=Yhq^7Tz??@Dtoy*#xoTHw_hMe?wL&)^JA8jmGVde5cln~<`-2@1uU#^G{(FD% z`t;U!e;!BqX%nlZ_+C2ToB;-%84f!32QlcgGJ?_N6hNQTgr6&rf-e1G|IsrW_t-VI zI@VkfX$WuAWP{=6dFct}rgKk19dBXsE!xwimt46vz4?{TODmSL2%lFAA&o1T7U+Lb z$uTBi;d{#oXr3`Mb)9!H%C|Pag@zvAY+Yn7-4NEnS(qhz28@=%1tym>so1ZwS6oMl zn|tPwt)qxFQn_z@P4T|RR=aLrPa%ARQN^G+sI>tld>{m3Ljt}rwmm%(AglmX5XsEh zIH!bjymG7&_{#7Ira7qrh6XV zpT4?vm-YI7!F5-rt6(*+SiJ<52GKL#=VWd$r%y7&kzrFdM$qAIxc8_ff>DsSvU?kn z<=BOaOnekE@=-jK*0YG4Og;9O?qBw+PB>zz;9PS*6wxq1J1 zdj^9*9)G8i$gw><5!56&*cVi#vr$Y6ibr(o`tFF>*+PtJ2Mg2g#? zD&Ft@=7-Zq{`^Zw)=PaayKc^_=U(vi*S;5C?{kuQ?|k!`8$bWgr@y%6!Uf1mUY~yD z_iwk-2f{dSeCArxmQ0g&O);Po#F7&$$`<)1yNn1vpTGZJzNrL8B@F)mo+zQ~t{C4; z=iO6o3*;5^$-=_1#e`~ToE3qJV5Aw&MhCN(y=ZNE&C52Wr8rDN?mQBn;ZY`3xwM>7 zIP>NlDXeF#-UMO29&T{NrzUlzr4X8e+x%b{ig8C+*Au5@UE_o;2?a74F%9R$T5*Pz z4^^!^AZqtl%qw@x3L#wuiNj4^a3tOd8Ej0O54CRl!=FeXh#*ObnlnBKMjDow?zC&| zJYF)bAVj)zS+mTzuca5~75L&r^!BO2qFy{1r2e)U`ByA|8$AtAg>{7UZ=K-F+R!xF(0h;!eub$9yO-8&Fx?uCn%tsI@HRu*QEMWZ5H&s2L>l-`1c0(ZSv zz6q68XhJ0L%qPib5?flZmptTS>AbGA8t2Ea0DUchi=5ptv*L-1mGq?(!bE>w2W^5@ z_3CjVmO9P_KIqv})LhV+fC@KqQB>lzD?^d2xVyDUF@j9(IrC?w<;#20Y#h45qg8`< zn1W&ofkcbpL1cD2 z8{hks^U0O4HwZTu@pz_0`edk|!b!tIgfPvKR#WS$4Jc#2RZ@MKtf{@YdS!4I!{JOU5-}MJLul2k9 z5=VZHuMG>kwmiLO^fk|4KQHz5kK$bDUPB}ijx)?J{D%V~ER+qwI=t zV9Ri|9?CAv@w*9qzneR|WGf({N-l(8UV=vkh(knMQP>S*d(05$ae}O|wjU~DnE%kp zzBGUg;6WUcFp4G(O~_R3T(UgPUb``^yZrLBar4D#*-D(Vi*qq~oTP9J%m%LO(NiaI zc*XA2x8w2D_tc}QZ_iU{_z2wL)-M|tGp=vtafPhu#%-f$K{*ObeF(yQj7f6VV$xHa#Z|`YOCV6;`y%bp`R~djD_o6R<9L5qb6T#>&19`S^EBedA*)AxF ze8hRbER(AlFd`ax4a74t)3WP}Jc=Vg#}|(ewha8)+pf6d zW4G>d;C**~RkMEf1HSf!i+=XL2S)z%^)FwSb{$~tB80TLnmC4t)!tXcAkZgKKo^TSFQ}rF$gYe;1%9$eNF>4f@UqznRi}r4KXnu12354l0{wV zW}KOP!wWZ{R-GSGllM58WuK6M9GOE9I2CiGmads;`YKx18z4l`*~mKh6B*+ttZdpE zw3bkIGl@nW6a8jkQ$U18YNS?glJkRF+ZL@g5_=YA`FdVcVTHXd_`^j7Tr<)7<&j*) z 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 kq5t>#-{lZg8pIdsJ)Tun#T`vc1o-nw`mn+a diff --git a/apps/leaderboard-backend/src/env.ts b/apps/leaderboard-backend/src/env.ts index 8a5600cb1b..4ea3cc89e1 100644 --- a/apps/leaderboard-backend/src/env.ts +++ b/apps/leaderboard-backend/src/env.ts @@ -1,10 +1,33 @@ import { z } from "zod" const envSchema = z.object({ + // Database settings LEADERBOARD_DB_URL: z.string().trim(), - PORT: z.string().trim().default("4545"), DATABASE_MIGRATE_DIR: z.string().trim().default("migrations"), - SESSION_EXPIRY: z.string().trim().default("86400"), + + // Server settings + PORT: z.string().trim().default("4545"), + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + + // Session settings + SESSION_EXPIRY: z.string().trim().default("86400"), // Default to 24 hours in seconds + SESSION_COOKIE_NAME: z.string().trim().default("session_id"), + SESSION_COOKIE_HTTP_ONLY: z + .string() + .trim() + .transform((val) => val.toLowerCase() === "true") + .default("true"), + SESSION_COOKIE_SECURE: z + .string() + .trim() + // In development, secure cookies may not work on localhost unless using HTTPS + .transform((val) => val.toLowerCase() === "true") + .default("true"), + SESSION_COOKIE_DOMAIN: z.string().trim().default(""), // Empty string means browser sets domain to current domain + SESSION_COOKIE_SAME_SITE: z.enum(["Strict", "Lax", "None"]).default("Lax"), + SESSION_COOKIE_PATH: z.string().trim().default("/"), + + // External services RPC_URL: z.string().trim().default("http://localhost:8545"), }) @@ -16,3 +39,18 @@ if (!parsedEnv.success) { } export const env = parsedEnv.data + +/** + * Cookie configuration for sessions + * Uses environment variables with appropriate defaults + * Centralizes configuration for easier management and security enhancements + */ +export const sessionCookieConfig = { + name: env.SESSION_COOKIE_NAME, + httpOnly: env.SESSION_COOKIE_HTTP_ONLY, + secure: env.SESSION_COOKIE_SECURE, + domain: env.SESSION_COOKIE_DOMAIN || undefined, // If empty string, set to undefined to use current domain + sameSite: env.SESSION_COOKIE_SAME_SITE, + path: env.SESSION_COOKIE_PATH, + maxAge: Number.parseInt(env.SESSION_EXPIRY, 10), +} diff --git a/apps/leaderboard-backend/src/routes/api/authRoutes.ts b/apps/leaderboard-backend/src/routes/api/authRoutes.ts index 760643fa69..0e1c48dd98 100644 --- a/apps/leaderboard-backend/src/routes/api/authRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/authRoutes.ts @@ -3,7 +3,7 @@ import { Hono } from "hono" import { deleteCookie, setCookie } from "hono/cookie" import { requireAuth, verifySignature } from "../../auth" import type { AuthSessionTableId } from "../../db/types" -import { env } from "../../env" +import { sessionCookieConfig } from "../../env" import { AuthChallengeDescription, AuthChallengeValidation, @@ -63,14 +63,7 @@ export default new Hono() return c.json({ ok: false, error: "Failed to create session" }, 500) } - setCookie(c, "session_id", session.id, { - httpOnly: true, - secure: true, - domain: "localhost", - sameSite: "Lax", - path: "/", - maxAge: Number.parseInt(env.SESSION_EXPIRY, 10), - }) + setCookie(c, sessionCookieConfig.name, session.id, sessionCookieConfig) // Return success with session ID and user info return c.json({ @@ -101,7 +94,6 @@ export default new Hono() const userId = c.get("userId") const user = await userRepo.findById(userId) - if (!user) { return c.json({ ok: false, error: "User not found" }, 404) } @@ -140,10 +132,11 @@ export default new Hono() } // Delete the session cookie - deleteCookie(c, "session_id", { - path: "/", - secure: true, - domain: "localhost", + deleteCookie(c, sessionCookieConfig.name, { + path: sessionCookieConfig.path, + secure: sessionCookieConfig.secure, + domain: sessionCookieConfig.domain, + sameSite: sessionCookieConfig.sameSite, }) return c.json({ From 0eabfb34792d0ed0d3e3db14593bb44058962dfa Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Thu, 22 May 2025 20:39:13 +0530 Subject: [PATCH 16/23] refactor: optimize database queries for session verification and user wallet retrieval [skip ci] --- .../src/repositories/AuthRepository.ts | 34 +++++++++-- .../src/repositories/UsersRepository.ts | 56 ++++++++++++++++--- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/apps/leaderboard-backend/src/repositories/AuthRepository.ts b/apps/leaderboard-backend/src/repositories/AuthRepository.ts index 29db5f1d76..5177eb6579 100644 --- a/apps/leaderboard-backend/src/repositories/AuthRepository.ts +++ b/apps/leaderboard-backend/src/repositories/AuthRepository.ts @@ -33,23 +33,43 @@ export class AuthRepository { async verifySession(sessionId: AuthSessionTableId): Promise { try { + // First check if the session exists and belongs to an existing user const session = await this.db .selectFrom("auth_sessions") - .where("id", "=", sessionId) - .selectAll() + .innerJoin("users", "users.id", "auth_sessions.user_id") + .where("auth_sessions.id", "=", sessionId) + .select([ + "auth_sessions.id", + "auth_sessions.user_id", + "auth_sessions.primary_wallet", + "auth_sessions.created_at", + "auth_sessions.last_used_at", + ]) .executeTakeFirst() if (!session) { return undefined } + // Update the last_used_at timestamp + const currentTime = new Date().toISOString() + + // We don't need the result of this update since we already have the session data + // and the only thing that changed is the last_used_at timestamp, which isn't usually + // needed by the application logic await this.db .updateTable("auth_sessions") - .set({ last_used_at: new Date().toISOString() }) + .set({ last_used_at: currentTime }) .where("id", "=", sessionId) .executeTakeFirstOrThrow() - return session + // Return the session with the updated timestamp + // The session object already has the correct types from the database schema + // so we need to preserve those types + return { + ...session, + last_used_at: new Date(currentTime), // Convert string to Date to match expected type + } } catch (error) { console.error("Error verifying session:", error) return undefined @@ -67,9 +87,11 @@ export class AuthRepository { async deleteSession(sessionId: AuthSessionTableId): Promise { try { - await this.db.deleteFrom("auth_sessions").where("id", "=", sessionId).executeTakeFirstOrThrow() + // Use execute() which returns the number of affected rows + const result = await this.db.deleteFrom("auth_sessions").where("id", "=", sessionId).execute() - return true + // If at least one row was affected, the deletion was successful + return result.length > 0 } catch (error) { console.error("Error deleting session:", error) return false diff --git a/apps/leaderboard-backend/src/repositories/UsersRepository.ts b/apps/leaderboard-backend/src/repositories/UsersRepository.ts index c17ef96e35..650fc5fb76 100644 --- a/apps/leaderboard-backend/src/repositories/UsersRepository.ts +++ b/apps/leaderboard-backend/src/repositories/UsersRepository.ts @@ -26,18 +26,58 @@ export class UserRepository { includeWallets = false, ): Promise<(User & { wallets: UserWallet[] }) | undefined> { try { - const user = await this.db + if (!includeWallets) { + // Simple query without wallets + const user = await this.db + .selectFrom("users") + .where("primary_wallet", "=", walletAddress) + .selectAll() + .executeTakeFirst() + + return user ? { ...user, wallets: [] } : undefined + } + + // Query with wallets using a join + const results = await this.db .selectFrom("users") - .where("primary_wallet", "=", walletAddress) - .selectAll() - .executeTakeFirstOrThrow() + .leftJoin("user_wallets", "users.id", "user_wallets.user_id") + .where("users.primary_wallet", "=", walletAddress) + .select([ + "users.id", + "users.primary_wallet", + "users.username", + "users.created_at", + "users.updated_at", + "user_wallets.id as wallet_id", + "user_wallets.wallet_address", + "user_wallets.is_primary", + "user_wallets.created_at as wallet_created_at", + ]) + .execute() - if (includeWallets) { - const wallets = await this.getUserWallets(user.id) - return { ...user, wallets } + if (results.length === 0) { + return undefined } - return user as User & { wallets: UserWallet[] } + // Process the joined results to form the user with wallets + const userData = { + id: results[0].id, + primary_wallet: results[0].primary_wallet, + username: results[0].username, + created_at: results[0].created_at, + updated_at: results[0].updated_at, + wallets: results + .filter((row) => row.wallet_id !== null) + .map((row) => ({ + id: row.wallet_id, + user_id: results[0].id, + wallet_address: row.wallet_address, + is_primary: row.is_primary, + created_at: row.wallet_created_at, + })), + } + + return userData as User & { wallets: UserWallet[] } } catch (error) { console.error("Error finding user by wallet address:", error) return undefined From d9b69a1545e65eb51240df1dfa9ccdf467829ca3 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Fri, 23 May 2025 13:06:43 +0530 Subject: [PATCH 17/23] refactor: optimize database queries for session verification and user wallet retrieval [skip ci] --- .../src/repositories/AuthRepository.ts | 52 ++++++++----------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/apps/leaderboard-backend/src/repositories/AuthRepository.ts b/apps/leaderboard-backend/src/repositories/AuthRepository.ts index 5177eb6579..fe39a438c9 100644 --- a/apps/leaderboard-backend/src/repositories/AuthRepository.ts +++ b/apps/leaderboard-backend/src/repositories/AuthRepository.ts @@ -33,43 +33,35 @@ export class AuthRepository { async verifySession(sessionId: AuthSessionTableId): Promise { try { - // First check if the session exists and belongs to an existing user + // Single-query approach: update timestamp, check user exists, return updated session const session = await this.db - .selectFrom("auth_sessions") - .innerJoin("users", "users.id", "auth_sessions.user_id") - .where("auth_sessions.id", "=", sessionId) - .select([ - "auth_sessions.id", - "auth_sessions.user_id", - "auth_sessions.primary_wallet", + .updateTable("auth_sessions") + .set({ last_used_at: new Date().toISOString() }) + .where("id", "=", sessionId) + .where(({ exists, selectFrom }) => + exists( + selectFrom("users") + .select("users.id") + .whereRef("users.id", "=", "auth_sessions.user_id") + ) + ) + .returning([ + "auth_sessions.id", + "auth_sessions.user_id", + "auth_sessions.primary_wallet", "auth_sessions.created_at", - "auth_sessions.last_used_at", + "auth_sessions.last_used_at" ]) .executeTakeFirst() - + + // If no rows matched our criteria (session not found or user doesn't exist) if (!session) { return undefined } - - // Update the last_used_at timestamp - const currentTime = new Date().toISOString() - - // We don't need the result of this update since we already have the session data - // and the only thing that changed is the last_used_at timestamp, which isn't usually - // needed by the application logic - await this.db - .updateTable("auth_sessions") - .set({ last_used_at: currentTime }) - .where("id", "=", sessionId) - .executeTakeFirstOrThrow() - - // Return the session with the updated timestamp - // The session object already has the correct types from the database schema - // so we need to preserve those types - return { - ...session, - last_used_at: new Date(currentTime), // Convert string to Date to match expected type - } + + // The session object already has the correct types, and last_used_at is + // already up-to-date with the new timestamp + return session } catch (error) { console.error("Error verifying session:", error) return undefined From fd86edc6e83443cda62a4bdb95c5657dd5da3fff Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Fri, 23 May 2025 13:37:42 +0530 Subject: [PATCH 18/23] feat: implement secure auth challenge system with message signing [skip ci] --- apps/leaderboard-backend/src/auth/index.ts | 1 + apps/leaderboard-backend/src/db/types.ts | 25 ++++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/leaderboard-backend/src/auth/index.ts b/apps/leaderboard-backend/src/auth/index.ts index 9c94bc8d9c..1b86cb200a 100644 --- a/apps/leaderboard-backend/src/auth/index.ts +++ b/apps/leaderboard-backend/src/auth/index.ts @@ -1,3 +1,4 @@ export * from "./roles" export * from "./middlewares" export * from "./verifySignature" +export * from "./challengeGenerator" diff --git a/apps/leaderboard-backend/src/db/types.ts b/apps/leaderboard-backend/src/db/types.ts index 9be74c5d7c..f0162eaf92 100644 --- a/apps/leaderboard-backend/src/db/types.ts +++ b/apps/leaderboard-backend/src/db/types.ts @@ -19,6 +19,7 @@ export interface Database { games: GameTable user_game_scores: UserGameScoreTable auth_sessions: AuthSessionTable + auth_challenges: AuthChallengeTable } // Registered users @@ -89,6 +90,17 @@ interface AuthSessionTable { last_used_at: ColumnType } +// Auth challenges +interface AuthChallengeTable { + id: Generated + primary_wallet: Address + nonce: string + message_hash: string // Hash of the challenge message for verification + expires_at: ColumnType + created_at: ColumnType + used: boolean +} + // Kysely helper types export type User = Selectable export type NewUser = Insertable @@ -118,6 +130,15 @@ export type UserGameScore = Selectable export type NewUserGameScore = Insertable export type UpdateUserGameScore = Updateable +export type AuthChallenge = Selectable +export type NewAuthChallenge = Insertable +export type UpdateAuthChallenge = Updateable + +export type AuthSession = Selectable +export type NewAuthSession = Insertable +export type UpdateAuthSession = Updateable + +// Leaderboard entries export interface GlobalLeaderboardEntry { user_id: UserTableId username: string @@ -149,7 +170,3 @@ export interface GameGuildLeaderboardEntry { total_score: number member_count: number } - -export type AuthSession = Selectable -export type NewAuthSession = Insertable -export type UpdateAuthSession = Updateable From 2ac03781b44d6803b511ea18b447b035f15deba3 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Fri, 23 May 2025 14:07:00 +0530 Subject: [PATCH 19/23] feat: add auth challenge table and add migrations, add stub methods in authrepository [skip ci] --- .../1745907000000_create_all_tables.ts | 50 +++++++++- apps/leaderboard-backend/src/db/types.ts | 2 +- .../src/repositories/AuthRepository.ts | 93 ++++++++++++++++--- 3 files changed, 128 insertions(+), 17 deletions(-) diff --git a/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts b/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts index 9b0f2580ca..bea5578bfc 100644 --- a/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts +++ b/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts @@ -88,15 +88,59 @@ export async function up(db: Kysely): Promise { .addColumn("last_used_at", "text", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()) .addForeignKeyConstraint("auth_sessions_user_id_fk", ["user_id"], "users", ["id"]) .execute() + + // Auth challenges table for secure authentication + await db.schema + .createTable("auth_challenges") + .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement()) + .addColumn("primary_wallet", "text", (col) => col.notNull()) + .addColumn("nonce", "text", (col) => col.notNull()) + .addColumn("message_hash", "text", (col) => col.notNull()) + .addColumn("expires_at", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()) + .addColumn("used", "boolean", (col) => col.defaultTo(false).notNull()) + .execute() + + // Create indexes for better query performance + + // Auth challenges indexes + await db.schema.createIndex("auth_challenges_wallet_idx").on("auth_challenges").column("primary_wallet").execute() + + await db.schema.createIndex("auth_challenges_nonce_idx").on("auth_challenges").column("nonce").execute() + + // User wallets index for faster wallet lookup by user + await db.schema.createIndex("user_wallets_user_id_idx").on("user_wallets").column("user_id").execute() + + // Guild members indexes for faster lookup by guild or user + await db.schema.createIndex("guild_members_guild_id_idx").on("guild_members").column("guild_id").execute() + + await db.schema.createIndex("guild_members_user_id_idx").on("guild_members").column("user_id").execute() + + // User game scores index for efficient leaderboard queries + await db.schema + .createIndex("user_game_scores_game_id_score_idx") + .on("user_game_scores") + .columns(["game_id", "score"]) + .execute() + + // Auth sessions index for faster session lookup by user + await db.schema.createIndex("auth_sessions_user_id_idx").on("auth_sessions").column("user_id").execute() } // biome-ignore lint/suspicious/noExplicitAny: `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. export async function down(db: Kysely): Promise { + // Drop tables in reverse order to avoid foreign key constraint issues + // First drop tables that depend on other tables await db.schema.dropTable("user_game_scores").execute() - await db.schema.dropTable("games").execute() await db.schema.dropTable("guild_members").execute() - await db.schema.dropTable("guilds").execute() await db.schema.dropTable("user_wallets").execute() - await db.schema.dropTable("users").execute() await db.schema.dropTable("auth_sessions").execute() + await db.schema.dropTable("auth_challenges").execute() + + // Then drop tables that are referenced by the above tables + await db.schema.dropTable("games").execute() + await db.schema.dropTable("guilds").execute() + + // Finally drop the core users table + await db.schema.dropTable("users").execute() } diff --git a/apps/leaderboard-backend/src/db/types.ts b/apps/leaderboard-backend/src/db/types.ts index f0162eaf92..507d6e7636 100644 --- a/apps/leaderboard-backend/src/db/types.ts +++ b/apps/leaderboard-backend/src/db/types.ts @@ -95,7 +95,7 @@ interface AuthChallengeTable { id: Generated primary_wallet: Address nonce: string - message_hash: string // Hash of the challenge message for verification + message_hash: string // Hash of the challenge message for verification expires_at: ColumnType created_at: ColumnType used: boolean diff --git a/apps/leaderboard-backend/src/repositories/AuthRepository.ts b/apps/leaderboard-backend/src/repositories/AuthRepository.ts index fe39a438c9..bbb98781f7 100644 --- a/apps/leaderboard-backend/src/repositories/AuthRepository.ts +++ b/apps/leaderboard-backend/src/repositories/AuthRepository.ts @@ -1,7 +1,14 @@ import { createUUID } from "@happy.tech/common" import type { Address } from "@happy.tech/common" import type { Kysely } from "kysely" -import type { AuthSession, AuthSessionTableId, Database, NewAuthSession, UserTableId } from "../db/types" +import type { + AuthChallenge, + AuthSession, + AuthSessionTableId, + Database, + NewAuthSession, + UserTableId, +} from "../db/types" export class AuthRepository { constructor(private db: Kysely) {} @@ -38,27 +45,23 @@ export class AuthRepository { .updateTable("auth_sessions") .set({ last_used_at: new Date().toISOString() }) .where("id", "=", sessionId) - .where(({ exists, selectFrom }) => - exists( - selectFrom("users") - .select("users.id") - .whereRef("users.id", "=", "auth_sessions.user_id") - ) + .where(({ exists, selectFrom }) => + exists(selectFrom("users").select("users.id").whereRef("users.id", "=", "auth_sessions.user_id")), ) .returning([ - "auth_sessions.id", - "auth_sessions.user_id", - "auth_sessions.primary_wallet", + "auth_sessions.id", + "auth_sessions.user_id", + "auth_sessions.primary_wallet", "auth_sessions.created_at", - "auth_sessions.last_used_at" + "auth_sessions.last_used_at", ]) .executeTakeFirst() - + // If no rows matched our criteria (session not found or user doesn't exist) if (!session) { return undefined } - + // The session object already has the correct types, and last_used_at is // already up-to-date with the new timestamp return session @@ -77,6 +80,70 @@ export class AuthRepository { } } + /** + * Create a new authentication challenge for a wallet address + * @param walletAddress - The wallet address to create a challenge for + * @param message - The challenge message to be signed + * @param expiresIn - Number of seconds until the challenge expires + * @returns The created challenge with nonce + */ + async createChallenge( + walletAddress: Address, + message: string, + expiresIn = 300, + ): Promise { + return undefined + } + + /** + * Validate a challenge during authentication + * @param walletAddress - The wallet address that's authenticating + * @param nonce - The nonce from the challenge + * @param signedMessage - The message that was signed + * @returns The challenge if valid, undefined if invalid or expired + */ + async validateChallenge( + walletAddress: Address, + nonce: string, + signedMessage: string, + ): Promise { + return undefined + } + + /** + * Mark a challenge as used to prevent replay attacks + * @param challengeId - The ID of the challenge to mark as used + */ + async markChallengeAsUsed(challengeId: number): Promise { + const res = await this.db + .updateTable("auth_challenges") + .set({ used: true }) + .where("id", "=", challengeId) + .execute() + return res.length > 0 + } + + /** + * Clean up old challenges for a wallet address + * @private + */ + private async _cleanupExpiredChallenges(walletAddress: Address): Promise { + // For now, just limit the number of challenges per wallet to prevent database bloat + // This is a simpler approach than dealing with date comparisons that can have type issues + const oldChallenges = await this.db + .selectFrom("auth_challenges") + .select("id") + .where("primary_wallet", "=", walletAddress) + .orderBy("created_at", "desc") + .offset(10) // Keep only the 10 most recent challenges + .execute() + + if (oldChallenges.length > 0) { + const oldIds = oldChallenges.map((c) => c.id) + await this.db.deleteFrom("auth_challenges").where("id", "in", oldIds).execute() + } + } + async deleteSession(sessionId: AuthSessionTableId): Promise { try { // Use execute() which returns the number of affected rows From 2769e6461391ec458ed3ec6a8c0c5d00a0cb37c4 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Fri, 23 May 2025 14:45:38 +0530 Subject: [PATCH 20/23] feat: implement SIWE challenge generation and validation for auth system [skip ci] --- .../src/auth/challengeGenerator.ts | 151 ++++++++++++++ apps/leaderboard-backend/src/env.ts | 24 +++ .../src/repositories/AuthRepository.ts | 184 ++++++++++++++---- 3 files changed, 321 insertions(+), 38 deletions(-) create mode 100644 apps/leaderboard-backend/src/auth/challengeGenerator.ts diff --git a/apps/leaderboard-backend/src/auth/challengeGenerator.ts b/apps/leaderboard-backend/src/auth/challengeGenerator.ts new file mode 100644 index 0000000000..86f897e22f --- /dev/null +++ b/apps/leaderboard-backend/src/auth/challengeGenerator.ts @@ -0,0 +1,151 @@ +import { type Address, type Hex, createUUID } from "@happy.tech/common" +import { hashMessage, toHex } from "viem" +import { authConfig } from "../env" + +/** + * Always a hex string of exactly 32 characters (16 bytes) + */ +export type AuthNonce = string & { readonly __brand: "auth_nonce" } + +/** + * Validates if a string is a valid authentication nonce + * @param value The string to validate + * @returns True if the string is a valid hex nonce of correct length + */ +export function isValidNonce(value: string): boolean { + // Must be exactly 32 characters (16 bytes represented as hex) + if (value.length !== 32) return false + + // Must be a valid hex string (each character is 0-9, a-f, A-F) + return /^[0-9a-fA-F]{32}$/.test(value) +} + +/** + * Asserts that a string is a valid authentication nonce + * @param value The string to validate + * @throws Error if the string is not a valid nonce + * @returns The validated nonce as an AuthNonce type + */ +export function assertNonce(value: string): AuthNonce { + if (!isValidNonce(value)) { + throw new Error(`Invalid nonce format: ${value}. Expected a 32-character hex string.`) + } + return value as AuthNonce +} + +/** + * Generates a cryptographically secure random nonce for authentication + * @returns A 32-character hex string as an AuthNonce + */ +export function generateNonce(): AuthNonce { + // Generate a UUID and convert to hex, taking only first 32 characters + const nonce = toHex(createUUID(), { size: 32 }).slice(2) + return assertNonce(nonce) +} + +/** + * Interface for challenge message options + * Following EIP-4361 Sign-In with Ethereum (SIWE) standard + */ +export interface ChallengeMessageOptions { + /** The wallet address requesting authentication */ + walletAddress: Address + /** Optional request ID for tracking */ + requestId?: string + /** Optional resources to include */ + resources?: string[] +} + +/** + * Interface for challenge message response + * Contains all data needed for verification and database storage + */ +export interface ChallengeMessage { + /** The formatted message to be signed */ + message: string + /** The nonce used in the message (for verification), always a 32-char hex string */ + nonce: AuthNonce + /** The timestamp when the challenge was issued */ + issuedAt: string + /** The timestamp when the challenge will expire */ + expiresAt: string + /** The wallet address that requested the challenge */ + walletAddress: Address + /** Message hash for verification */ + messageHash: Hex +} + +/** + * Generates a secure challenge message for authentication + * Following EIP-4361 (Sign-In with Ethereum) standard + * Includes nonce and timestamps to prevent replay attacks + */ +export function generateChallengeMessage(options: ChallengeMessageOptions): ChallengeMessage { + const { walletAddress, requestId, resources } = options + + // Use configuration from environment + const domain = authConfig.domain + const expiresIn = authConfig.challengeExpirySeconds + const uri = authConfig.uri + const statement = authConfig.statement + const chainId = authConfig.chainId + + // Generate a cryptographically secure random nonce + const nonce = generateNonce() + + // Generate timestamps for issuance and expiration + const now = new Date() + const issuedAt = now.toISOString() + const expiresAt = new Date(now.getTime() + expiresIn * 1000).toISOString() + + // Build the message parts according to EIP-4361 standard + const messageParts = [ + `${domain} wants you to sign in with your Ethereum account:`, + walletAddress, + "", // Empty line + ] + + // Add statement if provided + if (statement) { + messageParts.push(statement) + messageParts.push("") // Empty line after statement + } + + // Add the required structured data parts + messageParts.push( + `URI: ${uri}`, + "Version: 1", + `Chain ID: ${chainId}`, + `Nonce: ${nonce}`, + `Issued At: ${issuedAt}`, + `Expiration Time: ${expiresAt}`, + ) + + // Add optional request ID if provided + if (requestId) { + messageParts.push(`Request ID: ${requestId}`) + } + + // Add optional resources if provided + if (resources && resources.length > 0) { + messageParts.push("Resources:") + for (const resource of resources) { + messageParts.push(`- ${resource}`) + } + } + + // Format the final message (no EIP-191 prefix, wallet will add that) + const message = messageParts.join("\n") + + // Generate a hash of the message for verification + const messageHash = hashMessage(message) + + return { + message, + nonce, + issuedAt, + expiresAt, + walletAddress, + messageHash, + } +} diff --git a/apps/leaderboard-backend/src/env.ts b/apps/leaderboard-backend/src/env.ts index 4ea3cc89e1..be7ddeda08 100644 --- a/apps/leaderboard-backend/src/env.ts +++ b/apps/leaderboard-backend/src/env.ts @@ -27,6 +27,18 @@ const envSchema = z.object({ SESSION_COOKIE_SAME_SITE: z.enum(["Strict", "Lax", "None"]).default("Lax"), SESSION_COOKIE_PATH: z.string().trim().default("/"), + // Authentication Challenge Settings (SIWE - Sign In With Ethereum) + AUTH_DOMAIN: z.string().trim().default("happychain.app"), + AUTH_CHAIN_ID: z.string().trim().default("216"), // Default to Happy Chain mainnet + AUTH_STATEMENT: z + .string() + .trim() + .default( + "Sign this message to authenticate with HappyChain Leaderboard. This will not trigger a blockchain transaction or cost any gas fees.", + ), + AUTH_URI: z.string().trim().default("https://happychain.app/login"), + AUTH_CHALLENGE_EXPIRY: z.string().trim().default("300"), // Default to 5 minutes in seconds + // External services RPC_URL: z.string().trim().default("http://localhost:8545"), }) @@ -54,3 +66,15 @@ export const sessionCookieConfig = { path: env.SESSION_COOKIE_PATH, maxAge: Number.parseInt(env.SESSION_EXPIRY, 10), } + +/** + * SIWE (Sign-In with Ethereum) configuration + * Follows EIP-4361 standard for Ethereum-based authentication + */ +export const authConfig = { + domain: env.AUTH_DOMAIN, + chainId: Number.parseInt(env.AUTH_CHAIN_ID, 10), + statement: env.AUTH_STATEMENT, + uri: env.AUTH_URI, + challengeExpirySeconds: Number.parseInt(env.AUTH_CHALLENGE_EXPIRY, 10), +} diff --git a/apps/leaderboard-backend/src/repositories/AuthRepository.ts b/apps/leaderboard-backend/src/repositories/AuthRepository.ts index bbb98781f7..636da80ca2 100644 --- a/apps/leaderboard-backend/src/repositories/AuthRepository.ts +++ b/apps/leaderboard-backend/src/repositories/AuthRepository.ts @@ -1,11 +1,14 @@ import { createUUID } from "@happy.tech/common" import type { Address } from "@happy.tech/common" import type { Kysely } from "kysely" +import { hashMessage } from "viem" +import { assertNonce, generateChallengeMessage } from "../auth/challengeGenerator" import type { AuthChallenge, AuthSession, AuthSessionTableId, Database, + NewAuthChallenge, NewAuthSession, UserTableId, } from "../db/types" @@ -81,67 +84,172 @@ export class AuthRepository { } /** - * Create a new authentication challenge for a wallet address - * @param walletAddress - The wallet address to create a challenge for - * @param message - The challenge message to be signed - * @param expiresIn - Number of seconds until the challenge expires - * @returns The created challenge with nonce + * Generates a new authentication challenge for a wallet address + * @param walletAddress Ethereum wallet address requesting authentication + * @param resources Optional resources to include in the challenge message + * @param requestId Optional request ID for tracking + * @returns The created challenge with message to be signed */ async createChallenge( walletAddress: Address, - message: string, - expiresIn = 300, - ): Promise { - return undefined + resources?: string[], + requestId?: string, + ): Promise { + // Generate a challenge message using the EIP-4361 compliant generator + const challenge = generateChallengeMessage({ + walletAddress, + resources, + requestId, + }) + + // Clean up expired challenges first to prevent DB bloat + await this._cleanupExpiredChallenges(walletAddress) + + // Create the new challenge record in the database + const newChallenge: NewAuthChallenge = { + primary_wallet: walletAddress, + nonce: challenge.nonce, + message_hash: challenge.messageHash, + expires_at: challenge.expiresAt, + created_at: challenge.issuedAt, + used: false, + } + + // Store the challenge in the database + const dbChallenge = await this.db + .insertInto("auth_challenges") + .values(newChallenge) + .returning(["id", "primary_wallet", "nonce", "message_hash", "expires_at", "created_at", "used"]) + .executeTakeFirstOrThrow() + + return { + ...dbChallenge, + message: challenge.message, + } } /** - * Validate a challenge during authentication - * @param walletAddress - The wallet address that's authenticating - * @param nonce - The nonce from the challenge - * @param signedMessage - The message that was signed - * @returns The challenge if valid, undefined if invalid or expired + * Validate an authentication challenge using the original signed message + * @param walletAddress The wallet address that requested the challenge + * @param nonce The nonce from the challenge (must be a valid 32-char hex string) + * @param signedMessage The message that was signed by the wallet + * @returns True if the challenge is valid, false otherwise */ - async validateChallenge( - walletAddress: Address, - nonce: string, - signedMessage: string, - ): Promise { - return undefined + async validateChallenge(walletAddress: Address, nonce: string, signedMessage: string): Promise { + try { + // Validate nonce format first + const validNonce = assertNonce(nonce) + + // Calculate hash of the signed message for verification + const messageHash = hashMessage(signedMessage) + + // Fetch the challenge from the database + const challenge = await this.db + .selectFrom("auth_challenges") + .where("primary_wallet", "=", walletAddress) + .where("nonce", "=", validNonce) + .where("message_hash", "=", messageHash) + .where("used", "=", false) + .where("expires_at", ">", new Date()) + .select("id") + .executeTakeFirst() + + return !!challenge + } catch (error) { + // If the nonce format is invalid, the challenge cannot be valid + if (error instanceof Error && error.message.includes("Invalid nonce format")) { + return false + } + throw error + } + } + + /** + * Find a specific authentication challenge by wallet and nonce + * @param walletAddress The wallet address that requested the challenge + * @param nonce The nonce from the challenge (must be a valid 32-char hex string) + * @returns The challenge if found, undefined otherwise + */ + async findChallenge(walletAddress: Address, nonce: string): Promise { + try { + // Validate nonce format first + const validNonce = assertNonce(nonce) + + const challenge = await this.db + .selectFrom("auth_challenges") + .where("primary_wallet", "=", walletAddress) + .where("nonce", "=", validNonce) + .select(["id", "primary_wallet", "nonce", "message_hash", "expires_at", "created_at", "used"]) + .executeTakeFirst() + + if (!challenge) return undefined + + return challenge + } catch (error) { + // If the nonce format is invalid, no challenge can be found + if (error instanceof Error && error.message.includes("Invalid nonce format")) { + return undefined + } + throw error + } } /** * Mark a challenge as used to prevent replay attacks - * @param challengeId - The ID of the challenge to mark as used + * @param walletAddress The wallet address that requested the challenge + * @param nonce The nonce from the challenge (must be a valid 32-char hex string) + * @returns True if the challenge was found and marked as used */ - async markChallengeAsUsed(challengeId: number): Promise { - const res = await this.db - .updateTable("auth_challenges") - .set({ used: true }) - .where("id", "=", challengeId) - .execute() - return res.length > 0 + async markChallengeAsUsed(walletAddress: Address, nonce: string): Promise { + try { + // Validate nonce format first + const validNonce = assertNonce(nonce) + + const result = await this.db + .updateTable("auth_challenges") + .set({ used: true }) + .where("primary_wallet", "=", walletAddress) + .where("nonce", "=", validNonce) + .where("used", "=", false) + .executeTakeFirst() + + return result.numUpdatedRows > 0 + } catch (error) { + // If the nonce format is invalid, no challenge can be marked as used + if (error instanceof Error && error.message.includes("Invalid nonce format")) { + return false + } + throw error + } } /** - * Clean up old challenges for a wallet address - * @private + * Clean up expired challenges for a wallet address + * Keeps the database tidy by removing old challenges + * @param walletAddress The wallet address to clean challenges for */ private async _cleanupExpiredChallenges(walletAddress: Address): Promise { - // For now, just limit the number of challenges per wallet to prevent database bloat - // This is a simpler approach than dealing with date comparisons that can have type issues - const oldChallenges = await this.db + // Keep only the most recent challenges (max 5 per wallet) to prevent database bloat + const challengesToKeep = await this.db .selectFrom("auth_challenges") - .select("id") .where("primary_wallet", "=", walletAddress) .orderBy("created_at", "desc") - .offset(10) // Keep only the 10 most recent challenges + .limit(5) + .select("id") .execute() - if (oldChallenges.length > 0) { - const oldIds = oldChallenges.map((c) => c.id) - await this.db.deleteFrom("auth_challenges").where("id", "in", oldIds).execute() + const idsToKeep = challengesToKeep.map((c) => c.id) + + if (idsToKeep.length > 0) { + await this.db + .deleteFrom("auth_challenges") + .where("primary_wallet", "=", walletAddress) + .where("id", "not in", idsToKeep) + .execute() } + + // Also delete expired challenges + await this.db.deleteFrom("auth_challenges").where("expires_at", "<", new Date()).execute() } async deleteSession(sessionId: AuthSessionTableId): Promise { From a3ea1369faed86877651b481f30d5d05add55ea3 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Fri, 23 May 2025 16:23:49 +0530 Subject: [PATCH 21/23] feat: implement EIP-4361 SIWE authentication flow with viem/siwe [skip ci] --- .../src/auth/challengeGenerator.ts | 118 ++++-------------- .../src/auth/verifySignature.ts | 4 +- .../src/repositories/AuthRepository.ts | 89 ++++++------- .../src/routes/api/authRoutes.ts | 74 ++++++++--- .../src/validation/auth/authSchemas.ts | 8 +- 5 files changed, 125 insertions(+), 168 deletions(-) diff --git a/apps/leaderboard-backend/src/auth/challengeGenerator.ts b/apps/leaderboard-backend/src/auth/challengeGenerator.ts index 86f897e22f..eea6b8b2a4 100644 --- a/apps/leaderboard-backend/src/auth/challengeGenerator.ts +++ b/apps/leaderboard-backend/src/auth/challengeGenerator.ts @@ -1,48 +1,8 @@ -import { type Address, type Hex, createUUID } from "@happy.tech/common" -import { hashMessage, toHex } from "viem" +import type { Address, Hex } from "@happy.tech/common" +import { hashMessage } from "viem" +import { createSiweMessage, generateSiweNonce } from "viem/siwe" import { authConfig } from "../env" -/** - * Always a hex string of exactly 32 characters (16 bytes) - */ -export type AuthNonce = string & { readonly __brand: "auth_nonce" } - -/** - * Validates if a string is a valid authentication nonce - * @param value The string to validate - * @returns True if the string is a valid hex nonce of correct length - */ -export function isValidNonce(value: string): boolean { - // Must be exactly 32 characters (16 bytes represented as hex) - if (value.length !== 32) return false - - // Must be a valid hex string (each character is 0-9, a-f, A-F) - return /^[0-9a-fA-F]{32}$/.test(value) -} - -/** - * Asserts that a string is a valid authentication nonce - * @param value The string to validate - * @throws Error if the string is not a valid nonce - * @returns The validated nonce as an AuthNonce type - */ -export function assertNonce(value: string): AuthNonce { - if (!isValidNonce(value)) { - throw new Error(`Invalid nonce format: ${value}. Expected a 32-character hex string.`) - } - return value as AuthNonce -} - -/** - * Generates a cryptographically secure random nonce for authentication - * @returns A 32-character hex string as an AuthNonce - */ -export function generateNonce(): AuthNonce { - // Generate a UUID and convert to hex, taking only first 32 characters - const nonce = toHex(createUUID(), { size: 32 }).slice(2) - return assertNonce(nonce) -} - /** * Interface for challenge message options * Following EIP-4361 Sign-In with Ethereum (SIWE) standard @@ -63,8 +23,8 @@ export interface ChallengeMessageOptions { export interface ChallengeMessage { /** The formatted message to be signed */ message: string - /** The nonce used in the message (for verification), always a 32-char hex string */ - nonce: AuthNonce + /** The nonce used in the message (for verification) */ + nonce: string /** The timestamp when the challenge was issued */ issuedAt: string /** The timestamp when the challenge will expire */ @@ -83,68 +43,38 @@ export interface ChallengeMessage { export function generateChallengeMessage(options: ChallengeMessageOptions): ChallengeMessage { const { walletAddress, requestId, resources } = options - // Use configuration from environment const domain = authConfig.domain - const expiresIn = authConfig.challengeExpirySeconds const uri = authConfig.uri const statement = authConfig.statement const chainId = authConfig.chainId - // Generate a cryptographically secure random nonce - const nonce = generateNonce() + const nonce = generateSiweNonce() - // Generate timestamps for issuance and expiration const now = new Date() - const issuedAt = now.toISOString() - const expiresAt = new Date(now.getTime() + expiresIn * 1000).toISOString() - - // Build the message parts according to EIP-4361 standard - const messageParts = [ - `${domain} wants you to sign in with your Ethereum account:`, - walletAddress, - "", // Empty line - ] - - // Add statement if provided - if (statement) { - messageParts.push(statement) - messageParts.push("") // Empty line after statement - } - - // Add the required structured data parts - messageParts.push( - `URI: ${uri}`, - "Version: 1", - `Chain ID: ${chainId}`, - `Nonce: ${nonce}`, - `Issued At: ${issuedAt}`, - `Expiration Time: ${expiresAt}`, - ) - - // Add optional request ID if provided - if (requestId) { - messageParts.push(`Request ID: ${requestId}`) - } - - // Add optional resources if provided - if (resources && resources.length > 0) { - messageParts.push("Resources:") - for (const resource of resources) { - messageParts.push(`- ${resource}`) - } - } - - // Format the final message (no EIP-191 prefix, wallet will add that) - const message = messageParts.join("\n") + const issuedAt = now + const expiresAt = new Date(now.getTime() + authConfig.challengeExpirySeconds * 1000) + + const message = createSiweMessage({ + domain, + address: walletAddress, + statement, + uri, + version: "1", + chainId, + nonce, + issuedAt, + expirationTime: expiresAt, + requestId, + resources, + }) - // Generate a hash of the message for verification const messageHash = hashMessage(message) return { message, nonce, - issuedAt, - expiresAt, + issuedAt: issuedAt.toISOString(), + expiresAt: expiresAt.toISOString(), walletAddress, messageHash, } diff --git a/apps/leaderboard-backend/src/auth/verifySignature.ts b/apps/leaderboard-backend/src/auth/verifySignature.ts index ecd7610945..67d040c9e2 100644 --- a/apps/leaderboard-backend/src/auth/verifySignature.ts +++ b/apps/leaderboard-backend/src/auth/verifySignature.ts @@ -22,9 +22,9 @@ export const publicClient = createPublicClient({ * @param signature - The signature to verify * @returns Promise - Whether the signature is valid */ -export async function verifySignature(walletAddress: Address, message: Hex, signature: Hex): Promise { +export async function verifySignature(walletAddress: Address, message: string, signature: Hex): Promise { try { - const messageHash = hashMessage({ raw: message }) + const messageHash = hashMessage(message) const result = await publicClient.readContract({ address: walletAddress, abi: abis.HappyAccountImpl, diff --git a/apps/leaderboard-backend/src/repositories/AuthRepository.ts b/apps/leaderboard-backend/src/repositories/AuthRepository.ts index 636da80ca2..243360d90a 100644 --- a/apps/leaderboard-backend/src/repositories/AuthRepository.ts +++ b/apps/leaderboard-backend/src/repositories/AuthRepository.ts @@ -2,7 +2,7 @@ import { createUUID } from "@happy.tech/common" import type { Address } from "@happy.tech/common" import type { Kysely } from "kysely" import { hashMessage } from "viem" -import { assertNonce, generateChallengeMessage } from "../auth/challengeGenerator" +import { generateChallengeMessage } from "../auth/challengeGenerator" import type { AuthChallenge, AuthSession, @@ -16,6 +16,12 @@ import type { export class AuthRepository { constructor(private db: Kysely) {} + /** + * Creates a new authentication session for a user + * @param userId The ID of the user to create a session for + * @param walletAddress The primary wallet address for the session + * @returns The created session, or undefined if an error occurred + */ async createSession(userId: UserTableId, walletAddress: Address): Promise { try { const now = new Date() @@ -41,6 +47,11 @@ export class AuthRepository { } } + /** + * Verifies an authentication session + * @param sessionId The ID of the session to verify + * @returns The updated session, or undefined if the session doesn't exist + */ async verifySession(sessionId: AuthSessionTableId): Promise { try { // Single-query approach: update timestamp, check user exists, return updated session @@ -74,6 +85,11 @@ export class AuthRepository { } } + /** + * Gets all active sessions for a user + * @param userId The ID of the user to get sessions for + * @returns An array of sessions, or an empty array if none exist + */ async getUserSessions(userId: UserTableId): Promise { try { return await this.db.selectFrom("auth_sessions").where("user_id", "=", userId).selectAll().execute() @@ -131,15 +147,11 @@ export class AuthRepository { /** * Validate an authentication challenge using the original signed message * @param walletAddress The wallet address that requested the challenge - * @param nonce The nonce from the challenge (must be a valid 32-char hex string) * @param signedMessage The message that was signed by the wallet * @returns True if the challenge is valid, false otherwise */ - async validateChallenge(walletAddress: Address, nonce: string, signedMessage: string): Promise { + async validateChallenge(walletAddress: Address, signedMessage: string): Promise { try { - // Validate nonce format first - const validNonce = assertNonce(nonce) - // Calculate hash of the signed message for verification const messageHash = hashMessage(signedMessage) @@ -147,7 +159,6 @@ export class AuthRepository { const challenge = await this.db .selectFrom("auth_challenges") .where("primary_wallet", "=", walletAddress) - .where("nonce", "=", validNonce) .where("message_hash", "=", messageHash) .where("used", "=", false) .where("expires_at", ">", new Date()) @@ -156,70 +167,34 @@ export class AuthRepository { return !!challenge } catch (error) { - // If the nonce format is invalid, the challenge cannot be valid - if (error instanceof Error && error.message.includes("Invalid nonce format")) { - return false - } - throw error - } - } - - /** - * Find a specific authentication challenge by wallet and nonce - * @param walletAddress The wallet address that requested the challenge - * @param nonce The nonce from the challenge (must be a valid 32-char hex string) - * @returns The challenge if found, undefined otherwise - */ - async findChallenge(walletAddress: Address, nonce: string): Promise { - try { - // Validate nonce format first - const validNonce = assertNonce(nonce) - - const challenge = await this.db - .selectFrom("auth_challenges") - .where("primary_wallet", "=", walletAddress) - .where("nonce", "=", validNonce) - .select(["id", "primary_wallet", "nonce", "message_hash", "expires_at", "created_at", "used"]) - .executeTakeFirst() - - if (!challenge) return undefined - - return challenge - } catch (error) { - // If the nonce format is invalid, no challenge can be found - if (error instanceof Error && error.message.includes("Invalid nonce format")) { - return undefined - } - throw error + console.error("Error validating challenge:", error) + return false } } /** * Mark a challenge as used to prevent replay attacks * @param walletAddress The wallet address that requested the challenge - * @param nonce The nonce from the challenge (must be a valid 32-char hex string) + * @param message The message that was signed * @returns True if the challenge was found and marked as used */ - async markChallengeAsUsed(walletAddress: Address, nonce: string): Promise { + async markChallengeAsUsed(walletAddress: Address, message: string): Promise { try { - // Validate nonce format first - const validNonce = assertNonce(nonce) + // Calculate hash of the message for identification + const messageHash = hashMessage(message) const result = await this.db .updateTable("auth_challenges") .set({ used: true }) .where("primary_wallet", "=", walletAddress) - .where("nonce", "=", validNonce) + .where("message_hash", "=", messageHash) .where("used", "=", false) .executeTakeFirst() return result.numUpdatedRows > 0 } catch (error) { - // If the nonce format is invalid, no challenge can be marked as used - if (error instanceof Error && error.message.includes("Invalid nonce format")) { - return false - } - throw error + console.error("Error marking challenge as used:", error) + return false } } @@ -252,6 +227,11 @@ export class AuthRepository { await this.db.deleteFrom("auth_challenges").where("expires_at", "<", new Date()).execute() } + /** + * Deletes a specific session by ID + * @param sessionId The ID of the session to delete + * @returns True if the session was deleted, false otherwise + */ async deleteSession(sessionId: AuthSessionTableId): Promise { try { // Use execute() which returns the number of affected rows @@ -265,6 +245,11 @@ export class AuthRepository { } } + /** + * Deletes all sessions for a specific user + * @param userId The ID of the user to delete sessions for + * @returns True if the sessions were deleted, false otherwise + */ async deleteAllUserSessions(userId: UserTableId): Promise { try { const result = await this.db.deleteFrom("auth_sessions").where("user_id", "=", userId).execute() diff --git a/apps/leaderboard-backend/src/routes/api/authRoutes.ts b/apps/leaderboard-backend/src/routes/api/authRoutes.ts index 0e1c48dd98..ef5a00fc31 100644 --- a/apps/leaderboard-backend/src/routes/api/authRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/authRoutes.ts @@ -1,6 +1,6 @@ -import type { Address, Hex } from "@happy.tech/common" import { Hono } from "hono" import { deleteCookie, setCookie } from "hono/cookie" +import { parseSiweMessage, validateSiweMessage } from "viem/siwe" import { requireAuth, verifySignature } from "../../auth" import type { AuthSessionTableId } from "../../db/types" import { sessionCookieConfig } from "../../env" @@ -18,17 +18,35 @@ export default new Hono() /** * Request a challenge to sign * POST /auth/challenge + * + * Generates an EIP-4361 (Sign-In with Ethereum) compliant challenge message + * for the user to sign with their wallet. */ .post("/challenge", AuthChallengeDescription, AuthChallengeValidation, async (c) => { try { const { primary_wallet } = c.req.valid("json") - // Use a hardcoded, Ethereum-style message for leaderboard authentication - const message = `\x19Leaderboard Signed Message:\nHappyChain Leaderboard Authentication Request for ${primary_wallet}` + const { authRepo } = c.get("repos") + + // Generate resources list for this authentication + // These are URIs the user will be able to access after authentication (template) + const resources = [ + "https://happychain.app/profile", + "https://happychain.app/leaderboard", + "https://happychain.app/games", + ] + + // Create a challenge with the wallet address and resources + const challenge = await authRepo.createChallenge(primary_wallet, resources) + + if (!challenge) { + return c.json({ ok: false, error: "Failed to generate challenge" }, 500) + } + + // Return just the message to the client - everything they need is contained within it return c.json({ ok: true, data: { - message, - primary_wallet, + message: challenge.message, }, }) } catch (err) { @@ -46,13 +64,43 @@ export default new Hono() const { primary_wallet, message, signature } = c.req.valid("json") const { authRepo, userRepo } = c.get("repos") - // Verify signature directly using the utility function - const isValid = await verifySignature(primary_wallet as Address, message as Hex, signature as Hex) + try { + const parsedMessage = parseSiweMessage(message) + + const isValidFormat = validateSiweMessage({ + message: parsedMessage, + address: primary_wallet, + }) + + if (!isValidFormat) { + return c.json({ ok: false, error: "Invalid message format" }, 400) + } + + if (parsedMessage.address && parsedMessage.address.toLowerCase() !== primary_wallet.toLowerCase()) { + return c.json({ ok: false, error: "Address mismatch in message" }, 400) + } + } catch (error) { + console.error("Error parsing SIWE message:", error) + return c.json({ ok: false, error: "Invalid message format" }, 400) + } + + // Step 1: Validate the challenge from db + const isChallengeValid = await authRepo.validateChallenge(primary_wallet, message) + if (!isChallengeValid) { + return c.json({ ok: false, error: "Invalid or expired challenge" }, 401) + } - if (!isValid) { + // Step 2: Verify the signature on-chain with the SCA + const isSignatureValid = await verifySignature(primary_wallet, message, signature) + if (!isSignatureValid) { return c.json({ ok: false, error: "Invalid signature" }, 401) } + const markChallengeAsUsed = await authRepo.markChallengeAsUsed(primary_wallet, message) + if (!markChallengeAsUsed) { + return c.json({ ok: false, error: "Failed to mark challenge as used" }, 500) + } + const user = await userRepo.findByWalletAddress(primary_wallet, true) if (!user) { return c.json({ ok: false, error: "User not found" }, 404) @@ -60,22 +108,16 @@ export default new Hono() const session = await authRepo.createSession(user.id, primary_wallet) if (!session) { - return c.json({ ok: false, error: "Failed to create session" }, 500) + return c.json({ ok: false, error: "Error creating session" }, 500) } setCookie(c, sessionCookieConfig.name, session.id, sessionCookieConfig) - // Return success with session ID and user info return c.json({ ok: true, data: { session_id: session.id, - user: { - id: user.id, - username: user.username, - primary_wallet: user.primary_wallet, - wallets: user.wallets, - }, + user, }, }) } catch (err) { diff --git a/apps/leaderboard-backend/src/validation/auth/authSchemas.ts b/apps/leaderboard-backend/src/validation/auth/authSchemas.ts index 02e57eb383..196c41b280 100644 --- a/apps/leaderboard-backend/src/validation/auth/authSchemas.ts +++ b/apps/leaderboard-backend/src/validation/auth/authSchemas.ts @@ -30,13 +30,12 @@ export const AuthResponseDataSchema = z export const AuthChallengeDataSchema = z .object({ message: z.string(), - primary_wallet: z.string().refine(isHex), }) .strict() .openapi({ example: { - message: "HappyChain Authentication: 1a2b3c4d5e6f7890 (1620000000000)", - primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", + message: + "happychain.app wants you to sign in with your Ethereum account:\n0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa\n\nSign this message to authenticate with HappyChain Leaderboard. This will not trigger a blockchain transaction or cost any gas fees.\n\nURI: https://happychain.app/login\nVersion: 1\nChain ID: 216\nNonce: 7f8e9d1c6b3a2e5f4d7c8b9a1e3f5d7c\nIssued At: 2025-05-23T09:30:00.000Z\nExpiration Time: 2025-05-23T09:35:00.000Z", }, }) @@ -87,7 +86,8 @@ export const AuthVerifyRequestSchema = z .openapi({ example: { primary_wallet: "0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa", - message: "HappyChain Authentication: 1a2b3c4d5e6f7890 (1620000000000)", + message: + "happychain.app wants you to sign in with your Ethereum account:\n0xBC5F85819B9b970c956f80c1Ab5EfbE73c818eaa\n\nSign this message to authenticate with HappyChain Leaderboard. This will not trigger a blockchain transaction or cost any gas fees.\n\nURI: https://happychain.app/login\nVersion: 1\nChain ID: 216\nNonce: 7f8e9d1c6b3a2e5f4d7c8b9a1e3f5d7c\nIssued At: 2025-05-23T09:30:00.000Z\nExpiration Time: 2025-05-23T09:35:00.000Z", signature: "0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", }, From cb3c1907853e2015e86e85c95d81fe2ddc1318c9 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Fri, 23 May 2025 16:35:29 +0530 Subject: [PATCH 22/23] docs: add basic doc comments for SIWE authentication flow [skip ci] --- .../src/auth/challengeGenerator.ts | 32 +++++++++++++++++-- .../src/auth/verifySignature.ts | 21 +++++++++--- .../src/repositories/AuthRepository.ts | 4 +-- .../src/routes/api/authRoutes.ts | 24 ++++++++++++-- 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/apps/leaderboard-backend/src/auth/challengeGenerator.ts b/apps/leaderboard-backend/src/auth/challengeGenerator.ts index eea6b8b2a4..fe6ef050c2 100644 --- a/apps/leaderboard-backend/src/auth/challengeGenerator.ts +++ b/apps/leaderboard-backend/src/auth/challengeGenerator.ts @@ -1,3 +1,17 @@ +/** + * Authentication Challenge Generator + * + * This module handles the creation of EIP-4361 (Sign-In with Ethereum) compliant + * authentication challenges using Viem's SIWE utilities. + * + * The flow is as follows: + * 1. Client requests a challenge with their wallet address + * 2. Server generates a challenge message using createSiweMessage + * 3. Server stores the challenge in the database + * 4. Client signs the message with their wallet + * 5. Client sends the signature back for verification + */ + import type { Address, Hex } from "@happy.tech/common" import { hashMessage } from "viem" import { createSiweMessage, generateSiweNonce } from "viem/siwe" @@ -36,9 +50,21 @@ export interface ChallengeMessage { } /** - * Generates a secure challenge message for authentication - * Following EIP-4361 (Sign-In with Ethereum) standard - * Includes nonce and timestamps to prevent replay attacks + * Generates a secure challenge message for authentication following EIP-4361 (SIWE) standard + * + * This function creates a standardized message for wallet-based authentication that includes: + * - Domain and URI information to identify the application + * - Wallet address of the user attempting to authenticate + * - A cryptographically secure nonce to prevent replay attacks + * - Timestamps for issuance and expiration to limit the validity period + * - Optional resources that the user will be able to access + * - Optional request ID for tracking purposes + * + * The message is formatted according to the EIP-4361 standard using Viem's SIWE utilities. + * A hash of the message is also generated for database storage and verification. + * + * @param options Configuration options including wallet address and optional parameters + * @returns A challenge message object with all data needed for storage and verification */ export function generateChallengeMessage(options: ChallengeMessageOptions): ChallengeMessage { const { walletAddress, requestId, resources } = options diff --git a/apps/leaderboard-backend/src/auth/verifySignature.ts b/apps/leaderboard-backend/src/auth/verifySignature.ts index 67d040c9e2..c468fb46b9 100644 --- a/apps/leaderboard-backend/src/auth/verifySignature.ts +++ b/apps/leaderboard-backend/src/auth/verifySignature.ts @@ -1,3 +1,16 @@ +/** + * Signature Verification Module + * + * This module handles cryptographic verification of signatures against wallet addresses + * using the ERC-1271 standard for Smart Contract Accounts. + * + * The verification process works as follows: + * 1. The message is hashed to create a message hash + * 2. An RPC call is made to the wallet's isValidSignature method + * 3. The wallet implementation validates the signature according to ERC-1271 + * 4. The result is interpreted as a boolean indicating validity + */ + import type { Address, Hex } from "@happy.tech/common" import { abis } from "@happy.tech/contracts/boop/anvil" import { http, createPublicClient, hashMessage } from "viem" @@ -17,10 +30,10 @@ export const publicClient = createPublicClient({ * Verifies a signature against a wallet address using ERC-1271 standard * Makes an RPC call to the smart contract account for signature verification * - * @param walletAddress - The wallet address to verify against - * @param message - The message that was signed - * @param signature - The signature to verify - * @returns Promise - Whether the signature is valid + * @param walletAddress - The wallet address that allegedly signed the message + * @param message - The original message that was signed (plain text) + * @param signature - The signature produced by the wallet (hex string) + * @returns Promise - True if the signature is valid, false otherwise */ export async function verifySignature(walletAddress: Address, message: string, signature: Hex): Promise { try { diff --git a/apps/leaderboard-backend/src/repositories/AuthRepository.ts b/apps/leaderboard-backend/src/repositories/AuthRepository.ts index 243360d90a..0516da8490 100644 --- a/apps/leaderboard-backend/src/repositories/AuthRepository.ts +++ b/apps/leaderboard-backend/src/repositories/AuthRepository.ts @@ -175,8 +175,8 @@ export class AuthRepository { /** * Mark a challenge as used to prevent replay attacks * @param walletAddress The wallet address that requested the challenge - * @param message The message that was signed - * @returns True if the challenge was found and marked as used + * @param message The EIP-4361 compliant message that was signed + * @returns True if the challenge was found and marked as used, false otherwise */ async markChallengeAsUsed(walletAddress: Address, message: string): Promise { try { diff --git a/apps/leaderboard-backend/src/routes/api/authRoutes.ts b/apps/leaderboard-backend/src/routes/api/authRoutes.ts index ef5a00fc31..9b208c6764 100644 --- a/apps/leaderboard-backend/src/routes/api/authRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/authRoutes.ts @@ -14,13 +14,31 @@ import { AuthVerifyValidation, } from "../../validation/auth" +/** + * The authentication flow consists of these main steps: + * + * 1. Challenge Generation (/auth/challenge): + * - Client sends their wallet address + * - Server creates a unique challenge message + * - Server returns the message to be signed + * + * 2. Signature Verification (/auth/verify): + * - Client signs the message and sends back signature + * - Server validates message format using SIWE utilities + * - Server checks database for valid challenge + * - Server verifies signature on-chain + * - Server creates session and returns token + * + * 3. Session Management: + * - /auth/me - Get current user info + * - /auth/sessions - List all active sessions + * - /auth/logout - End the current session + */ + export default new Hono() /** * Request a challenge to sign * POST /auth/challenge - * - * Generates an EIP-4361 (Sign-In with Ethereum) compliant challenge message - * for the user to sign with their wallet. */ .post("/challenge", AuthChallengeDescription, AuthChallengeValidation, async (c) => { try { From 4f6348911960a8c2bf62501cb0075478aff89f48 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Fri, 23 May 2025 17:06:14 +0530 Subject: [PATCH 23/23] chore: add detailed session, auth, and environment configuration options to .env.example --- apps/leaderboard-backend/.env.example | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/leaderboard-backend/.env.example b/apps/leaderboard-backend/.env.example index 3b4cf3d32e..d44be05b47 100644 --- a/apps/leaderboard-backend/.env.example +++ b/apps/leaderboard-backend/.env.example @@ -1,5 +1,26 @@ -PORT=4545 +# Database settings LEADERBOARD_DB_URL=leaderboard-backend.sqlite DATABASE_MIGRATE_DIR=migrations + +# Server settings +PORT=4545 +NODE_ENV=development + +# Session settings SESSION_EXPIRY=86400 +SESSION_COOKIE_NAME=session_id +SESSION_COOKIE_HTTP_ONLY=true +SESSION_COOKIE_SECURE=true +SESSION_COOKIE_DOMAIN= +SESSION_COOKIE_SAME_SITE=Lax +SESSION_COOKIE_PATH=/ + +# Authentication Challenge Settings (SIWE - Sign In With Ethereum) +AUTH_DOMAIN=happychain.app +AUTH_CHAIN_ID=216 +AUTH_STATEMENT="Sign this message to authenticate with HappyChain Leaderboard. This will not trigger a blockchain transaction or cost any gas fees." +AUTH_URI=https://happychain.app/login +AUTH_CHALLENGE_EXPIRY=300 + +# External services RPC_URL=http://localhost:8545