diff --git a/apps/leaderboard-backend/.env.example b/apps/leaderboard-backend/.env.example index 33415c6eb9..d44be05b47 100644 --- a/apps/leaderboard-backend/.env.example +++ b/apps/leaderboard-backend/.env.example @@ -1,3 +1,26 @@ +# Database settings +LEADERBOARD_DB_URL=leaderboard-backend.sqlite +DATABASE_MIGRATE_DIR=migrations + +# Server settings PORT=4545 -LEADERBOARD_DB_URL="leaderboard-backend.sqlite" -DATABASE_MIGRATE_DIR="migrations" +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 diff --git a/apps/leaderboard-backend/public/gameicon.png b/apps/leaderboard-backend/public/gameicon.png deleted file mode 100644 index 6c1db48087..0000000000 Binary files a/apps/leaderboard-backend/public/gameicon.png and /dev/null differ diff --git a/apps/leaderboard-backend/src/auth/challengeGenerator.ts b/apps/leaderboard-backend/src/auth/challengeGenerator.ts new file mode 100644 index 0000000000..fe6ef050c2 --- /dev/null +++ b/apps/leaderboard-backend/src/auth/challengeGenerator.ts @@ -0,0 +1,107 @@ +/** + * 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" +import { authConfig } from "../env" + +/** + * 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) */ + nonce: string + /** 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 (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 + + const domain = authConfig.domain + const uri = authConfig.uri + const statement = authConfig.statement + const chainId = authConfig.chainId + + const nonce = generateSiweNonce() + + const now = new Date() + 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, + }) + + const messageHash = hashMessage(message) + + return { + message, + nonce, + issuedAt: issuedAt.toISOString(), + expiresAt: expiresAt.toISOString(), + walletAddress, + messageHash, + } +} diff --git a/apps/leaderboard-backend/src/auth/index.ts b/apps/leaderboard-backend/src/auth/index.ts new file mode 100644 index 0000000000..1b86cb200a --- /dev/null +++ b/apps/leaderboard-backend/src/auth/index.ts @@ -0,0 +1,4 @@ +export * from "./roles" +export * from "./middlewares" +export * from "./verifySignature" +export * from "./challengeGenerator" 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..3ae63fa0f5 --- /dev/null +++ b/apps/leaderboard-backend/src/auth/middlewares/gameAuth.ts @@ -0,0 +1,44 @@ +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 + + const { gameRepo } = c.get("repos") + const gameId = Number.parseInt(game_id, 10) as GameTableId + + // Check if game exists + const game = await gameRepo.findById(gameId) + if (!game) return undefined + + // Check if user is the creator + if (game.admin_id === userId) { + c.set("gameRole", GameRole.CREATOR) + return GameRole.CREATOR + } + + // For games, all authenticated users are considered players + return GameRole.PLAYER +} + +/** + * 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/middlewares/guildAuth.ts b/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts new file mode 100644 index 0000000000..237e8d00ce --- /dev/null +++ b/apps/leaderboard-backend/src/auth/middlewares/guildAuth.ts @@ -0,0 +1,50 @@ +import type { Context } from "hono" +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 + */ +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 guildId = Number.parseInt(guild_id, 10) as GuildTableId + + // First check if user is the creator + 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(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 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 guildAction = (action: ActionType) => { + return requirePermission({ + resource: ResourceType.GUILD, + action, + getUserRole: getGuildUserRole, + }) +} 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/auth/middlewares/permissions.ts b/apps/leaderboard-backend/src/auth/middlewares/permissions.ts new file mode 100644 index 0000000000..812f82a8fe --- /dev/null +++ b/apps/leaderboard-backend/src/auth/middlewares/permissions.ts @@ -0,0 +1,44 @@ +import type { Context, MiddlewareHandler } from "hono" +import { createMiddleware } from "hono/factory" +import { type ActionType, Permissions, type ResourceType, type RoleType } 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 - 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 + const role = await getUserRole(c) + + // 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) + } + + // 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) + }) +} 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..339bd48cb6 --- /dev/null +++ b/apps/leaderboard-backend/src/auth/middlewares/requireAuth.ts @@ -0,0 +1,29 @@ +import type { MiddlewareHandler } from "hono" +import { getCookie } from "hono/cookie" +import { createMiddleware } from "hono/factory" +import type { AuthSessionTableId } from "../../db/types" + +/** + * Middleware that verifies if a user is authenticated via session + * Checks for session ID in cookie + */ +export const requireAuth: MiddlewareHandler = createMiddleware(async (c, next) => { + const sessionId = getCookie(c, "session_id") as AuthSessionTableId | undefined + if (!sessionId) { + return c.json({ error: "Authentication required", ok: false }, 401) + } + + 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..bc14979c21 --- /dev/null +++ b/apps/leaderboard-backend/src/auth/middlewares/userAuth.ts @@ -0,0 +1,64 @@ +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" + +/** + * 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 + + // 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 + } + } + + // 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 +} + +/** + * 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/auth/roles.ts b/apps/leaderboard-backend/src/auth/roles.ts new file mode 100644 index 0000000000..5778ebdfbd --- /dev/null +++ b/apps/leaderboard-backend/src/auth/roles.ts @@ -0,0 +1,67 @@ +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 enum ResourceType { + USER = "user", + GUILD = "guild", + GAME = "game", + SCORE = "score", +} + +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", + LEAVE = "leave", + SUBMIT_SCORE = "submit_score", + MANAGE_SCORES = "manage_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], + [ActionType.LEAVE]: [GuildRole.MEMBER, GuildRole.ADMIN], + }, + [ResourceType.GAME]: { + [ActionType.READ]: [UserRole.AUTHENTICATED], + [ActionType.CREATE]: [UserRole.AUTHENTICATED], + [ActionType.UPDATE]: [GameRole.CREATOR], + [ActionType.DELETE]: [GameRole.CREATOR], + [ActionType.SUBMIT_SCORE]: [GameRole.CREATOR], + [ActionType.MANAGE_SCORES]: [GameRole.CREATOR], + }, + [ResourceType.SCORE]: {}, +} + +export type RoleType = UserRole | GuildRole | GameRole diff --git a/apps/leaderboard-backend/src/auth/verifySignature.ts b/apps/leaderboard-backend/src/auth/verifySignature.ts new file mode 100644 index 0000000000..c468fb46b9 --- /dev/null +++ b/apps/leaderboard-backend/src/auth/verifySignature.ts @@ -0,0 +1,53 @@ +/** + * 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" +import { localhost } from "viem/chains" +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 + * + * @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 { + const messageHash = hashMessage(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/db/migrations/1745907000000_create_all_tables.ts b/apps/leaderboard-backend/src/db/migrations/1745907000000_create_all_tables.ts index 064ef1070a..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 @@ -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()) @@ -76,14 +77,70 @@ 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() + + // 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("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 ab74e4f5c5..507d6e7636 100644 --- a/apps/leaderboard-backend/src/db/types.ts +++ b/apps/leaderboard-backend/src/db/types.ts @@ -1,5 +1,6 @@ -import type { Address } from "@happy.tech/common" +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" } @@ -7,6 +8,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 +18,12 @@ export interface Database { guild_members: GuildMemberTable games: GameTable user_game_scores: UserGameScoreTable + auth_sessions: AuthSessionTable + auth_challenges: AuthChallengeTable } // Registered users -export interface UserTable { +interface UserTable { id: Generated primary_wallet: Address // Primary wallet for the user username: string @@ -28,7 +32,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 +41,7 @@ export interface UserWalletTable { } // Guilds (groups of users) -export interface GuildTable { +interface GuildTable { id: Generated name: string icon_url: string | null @@ -46,17 +50,16 @@ export interface GuildTable { updated_at: ColumnType } -// 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 - is_admin: boolean // Whether user is an admin of this guild + role: GuildRole // Enum-based role in guild joined_at: ColumnType } // Games available on the platform -export interface GameTable { +interface GameTable { id: Generated name: string icon_url: string | null @@ -67,16 +70,37 @@ 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 + 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 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 +} + +// 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 @@ -106,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 diff --git a/apps/leaderboard-backend/src/env.ts b/apps/leaderboard-backend/src/env.ts index a31f3b06ad..be7ddeda08 100644 --- a/apps/leaderboard-backend/src/env.ts +++ b/apps/leaderboard-backend/src/env.ts @@ -1,9 +1,46 @@ 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(), + // Database settings + LEADERBOARD_DB_URL: z.string().trim(), + DATABASE_MIGRATE_DIR: z.string().trim().default("migrations"), + + // 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("/"), + + // 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"), }) const parsedEnv = envSchema.safeParse(process.env) @@ -14,3 +51,30 @@ 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), +} + +/** + * 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/middlewares/isValidSignature.ts b/apps/leaderboard-backend/src/middlewares/isValidSignature.ts deleted file mode 100644 index 1745787dbe..0000000000 --- a/apps/leaderboard-backend/src/middlewares/isValidSignature.ts +++ /dev/null @@ -1,14 +0,0 @@ -// TODO: Reserved for another PR, this is just stub, ignore this file for now - -import { createMiddleware } from "hono/factory" - -const isValidSignature = createMiddleware(async (c, next) => { - console.log(c.req) - console.log("TODO: import wagmi, make call to SCA.isValidSignature()") - 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..0516da8490 --- /dev/null +++ b/apps/leaderboard-backend/src/repositories/AuthRepository.ts @@ -0,0 +1,263 @@ +import { createUUID } from "@happy.tech/common" +import type { Address } from "@happy.tech/common" +import type { Kysely } from "kysely" +import { hashMessage } from "viem" +import { generateChallengeMessage } from "../auth/challengeGenerator" +import type { + AuthChallenge, + AuthSession, + AuthSessionTableId, + Database, + NewAuthChallenge, + NewAuthSession, + UserTableId, +} from "../db/types" + +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() + + const newSession: NewAuthSession = { + id: createUUID(), + user_id: userId, + primary_wallet: walletAddress, + created_at: now.toISOString(), + last_used_at: now.toISOString(), + } + + await this.db.insertInto("auth_sessions").values(newSession).executeTakeFirstOrThrow() + + return { + ...newSession, + created_at: now, + last_used_at: now, + } + } catch (error) { + console.error("Error creating session:", error) + return undefined + } + } + + /** + * 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 + const session = await this.db + .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", + ]) + .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 + } catch (error) { + console.error("Error verifying session:", error) + return undefined + } + } + + /** + * 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() + } catch (error) { + console.error("Error getting user sessions:", error) + return [] + } + } + + /** + * 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, + 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 an authentication challenge using the original signed message + * @param walletAddress The wallet address that requested the challenge + * @param signedMessage The message that was signed by the wallet + * @returns True if the challenge is valid, false otherwise + */ + async validateChallenge(walletAddress: Address, signedMessage: string): Promise { + try { + // 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("message_hash", "=", messageHash) + .where("used", "=", false) + .where("expires_at", ">", new Date()) + .select("id") + .executeTakeFirst() + + return !!challenge + } catch (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 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 { + // 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("message_hash", "=", messageHash) + .where("used", "=", false) + .executeTakeFirst() + + return result.numUpdatedRows > 0 + } catch (error) { + console.error("Error marking challenge as used:", error) + return false + } + } + + /** + * 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 { + // Keep only the most recent challenges (max 5 per wallet) to prevent database bloat + const challengesToKeep = await this.db + .selectFrom("auth_challenges") + .where("primary_wallet", "=", walletAddress) + .orderBy("created_at", "desc") + .limit(5) + .select("id") + .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() + } + + /** + * 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 + const result = await this.db.deleteFrom("auth_sessions").where("id", "=", sessionId).execute() + + // 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 + } + } + + /** + * 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() + + return result.length > 0 + } catch (error) { + console.error("Error deleting user sessions:", error) + return false + } + } +} diff --git a/apps/leaderboard-backend/src/repositories/GamesRepository.ts b/apps/leaderboard-backend/src/repositories/GamesRepository.ts index 86de3a24e4..c9df971b98 100644 --- a/apps/leaderboard-backend/src/repositories/GamesRepository.ts +++ b/apps/leaderboard-backend/src/repositories/GamesRepository.ts @@ -1,76 +1,115 @@ 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 { 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 + } } } @@ -78,54 +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.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.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( @@ -133,44 +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, - 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 668087665c..88ba49a447 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, @@ -17,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: { @@ -67,184 +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, - is_admin: true, - 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() + try { + return await this.db.transaction().execute(async (trx) => { + const guild = await trx.selectFrom("guilds").where("id", "=", id).selectAll().executeTakeFirstOrThrow() - if (!guild) { - return undefined - } + 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.is_admin", - "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) { + 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 + + 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 } - - await this.db - .insertInto("guild_members") - .values({ - guild_id: guildId, - user_id: userId, - is_admin: isAdmin, - 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", "is_admin", "joined_at"]) - .executeTakeFirst() } async updateMemberRole( guildId: GuildTableId, userId: UserTableId, - isAdmin: boolean, + role: GuildRole, ): Promise { - return await this.db - .updateTable("guild_members") - .set({ is_admin: isAdmin }) - .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..650fc5fb76 100644 --- a/apps/leaderboard-backend/src/repositories/UsersRepository.ts +++ b/apps/leaderboard-backend/src/repositories/UsersRepository.ts @@ -6,62 +6,114 @@ 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) - .selectAll() - .executeTakeFirst() + try { + 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") + .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 (walletEntry) { - user = await this.findById(walletEntry.user_id) + if (results.length === 0) { + return undefined } - } - if (user && includeWallets) { - const wallets = await this.getUserWallets(user.id) - return { ...user, wallets } - } + // 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 user as User & { wallets: UserWallet[] } + return userData as User & { wallets: UserWallet[] } + } catch (error) { + console.error("Error finding user by wallet address:", error) + return undefined + } } 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 +121,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 (includeWallets) { + const wallets = await this.getUserWallets(user.id) + return [{ ...user, wallets }] + } - 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 + 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() - await trx + return this.findById(id) + } catch (error) { + console.error("Error updating user:", error) + return undefined + } + } + + 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/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/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 new file mode 100644 index 0000000000..9b208c6764 --- /dev/null +++ b/apps/leaderboard-backend/src/routes/api/authRoutes.ts @@ -0,0 +1,236 @@ +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" +import { + AuthChallengeDescription, + AuthChallengeValidation, + AuthLogoutDescription, + AuthMeDescription, + AuthSessionsDescription, + AuthVerifyDescription, + 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 + */ + .post("/challenge", AuthChallengeDescription, AuthChallengeValidation, async (c) => { + try { + const { primary_wallet } = c.req.valid("json") + 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: challenge.message, + }, + }) + } catch (err) { + console.error("Error generating auth challenge:", err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }) + + /** + * Verify signature and create session + * POST /auth/verify + */ + .post("/verify", AuthVerifyDescription, AuthVerifyValidation, async (c) => { + try { + const { primary_wallet, message, signature } = c.req.valid("json") + const { authRepo, userRepo } = c.get("repos") + + 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) + } + + // 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) + } + + const session = await authRepo.createSession(user.id, primary_wallet) + if (!session) { + return c.json({ ok: false, error: "Error creating session" }, 500) + } + + setCookie(c, sessionCookieConfig.name, session.id, sessionCookieConfig) + + return c.json({ + ok: true, + data: { + session_id: session.id, + user, + }, + }) + } catch (err) { + console.error("Error verifying auth signature:", err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } + }) + + /** + * Get user info from session + * GET /auth/me + */ + .get("/me", AuthMeDescription, requireAuth, async (c) => { + 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) + } + }) + + /** + * Logout (delete session) + * POST /auth/logout + */ + .post("/logout", AuthLogoutDescription, requireAuth, async (c) => { + 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, sessionCookieConfig.name, { + path: sessionCookieConfig.path, + secure: sessionCookieConfig.secure, + domain: sessionCookieConfig.domain, + sameSite: sessionCookieConfig.sameSite, + }) + + 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) + } + }) + + /** + * List all active sessions for a user + * GET /auth/sessions + */ + .get("/sessions", AuthSessionsDescription, requireAuth, async (c) => { + 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 75825aeb69..2218e9d929 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 { ActionType, gameAction, requireAuth } from "../../auth" import type { GameTableId, UserTableId } from "../../db/types" import { AdminWalletParamValidation, @@ -55,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) @@ -97,7 +98,7 @@ export default new Hono() * Create a new game (admin required) * POST /games */ - .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 +135,44 @@ export default new Hono() /** * Update game details (admin only) * PATCH /games/:id + * Requires game ownership - only the game creator can update it */ - .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, + gameAction(ActionType.UPDATE), + 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 = id 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 @@ -171,36 +181,44 @@ export default new Hono() * Submit a new score for a game * POST /games/:id/scores */ - .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, + gameAction(ActionType.SUBMIT_SCORE), + 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 = id 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 @@ -213,7 +231,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) @@ -244,7 +262,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 766e1fb565..f4095e4408 100644 --- a/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts +++ b/apps/leaderboard-backend/src/routes/api/guildsRoutes.ts @@ -1,10 +1,12 @@ import { Hono } from "hono" +import { ActionType, GuildRole, guildAction, requireAuth } from "../../auth" import type { GuildTableId, UserTableId } from "../../db/types" import { GuildCreateDescription, GuildCreateValidation, GuildGetByIdDescription, GuildIdParamValidation, + GuildLeaveDescription, GuildListMembersDescription, GuildMemberAddDescription, GuildMemberAddValidation, @@ -38,7 +40,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) @@ -49,29 +51,36 @@ export default new Hono() * Create a new guild (creator becomes admin). * POST /guilds */ - .post("/", GuildCreateDescription, GuildCreateValidation, async (c) => { - try { - const guildData = c.req.valid("json") - const { guildRepo } = c.get("repos") + .post( + "/", + requireAuth, + guildAction(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({ ok: true, data: 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). @@ -84,13 +93,13 @@ 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) } - 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) @@ -100,36 +109,45 @@ export default new Hono() /** * Update guild details (admin only). * PATCH /guilds/:id + * Requires ADMIN role in the guild */ - .patch("/:id", GuildUpdateDescription, GuildIdParamValidation, GuildUpdateValidation, async (c) => { - try { - const { id } = c.req.valid("param") + .patch( + "/:id", + requireAuth, + guildAction(ActionType.UPDATE), + 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 = id 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({ 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) + } + }, + ) // ==================================================================================================== // Guild Member Routes @@ -145,14 +163,14 @@ 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) } 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) @@ -162,58 +180,70 @@ export default new Hono() /** * Add a member to a guild (admin only). * POST /guilds/:id/members + * Requires ADMIN role in the guild */ - .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, + guildAction(ActionType.ADD_MEMBER), + GuildMemberAddDescription, + GuildIdParamValidation, + GuildMemberAddValidation, + async (c) => { + try { + const { id } = c.req.valid("param") + let { user_id, username, role } = 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 = id 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, role === GuildRole.ADMIN) + 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({ 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) + } + }, + ) /** * Update a guild member's role (admin only). * PATCH /guilds/:id/members/:member_id + * Requires ADMIN role in the guild */ .patch( "/:id/members/:member_id", + requireAuth, + guildAction(ActionType.PROMOTE_MEMBER), GuildMemberUpdateDescription, GuildIdParamValidation, GuildMemberIdParamValidation, @@ -222,24 +252,24 @@ 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 - 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) } // Update member role - const updatedMember = await guildRepo.updateMemberRole(guildId, userId, is_admin) + const updatedMember = await guildRepo.updateMemberRole(guildId, userId, role) if (!updatedMember) { 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) @@ -250,42 +280,110 @@ export default new Hono() /** * Remove a member from a guild (admin only). * DELETE /guilds/:id/members/:member_id + * Requires ADMIN role in the guild */ .delete( "/:id/members/:member_id", + requireAuth, + 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 - 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) } + // 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({ 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/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 12800a027b..403b0ad11e 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 { ActionType, requireAuth, userAction } from "../../auth" import type { UserTableId } from "../../db/types" import { PrimaryWalletParamValidation, @@ -36,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(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) + } }) /** @@ -53,97 +59,31 @@ export default new Hono() * POST /users */ .post("/", UserCreateDescription, UserCreateValidation, async (c) => { - 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) - } - - 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, - }) - - return c.json(newUser, 201) - }) - - // ==================================================================================================== - // User Resource Routes (by ID) - - /** - * Get user details by user ID. - * GET /users/:id - */ - .get("/:id", UserGetByIdDescription, UserIdParamValidation, async (c) => { - 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) - } - - return c.json(user, 200) - }) + try { + const userData = c.req.valid("json") + const { userRepo } = c.get("repos") - /** - * Update user details by user ID. - * PATCH /users/:id - */ - .patch("/: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 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 username is being changed and is unique - if (updateData.username && updateData.username !== user.username) { - const existingUser = await userRepo.findByUsername(updateData.username) - if (existingUser) { + const existingByUsername = await userRepo.findByUsername(userData.username) + if (existingByUsername) { return c.json({ ok: false, error: "Username already taken" }, 409) } - } - const updatedUser = await userRepo.update(userId, updateData) - return c.json(updatedUser, 200) - }) + const newUser = await userRepo.create({ + primary_wallet: userData.primary_wallet, + username: userData.username, + }) - /** - * Delete a user by user ID and all associated data. - * DELETE /users/:id - */ - .delete("/: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) - } - - const success = await userRepo.delete(userId) - if (!success) { - return c.json({ ok: false, error: "User not found" }, 404) + 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) } - - return c.json(user, 200) }) // ==================================================================================================== @@ -154,96 +94,103 @@ 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(user, 200) + return c.json({ ok: true, data: user }, 200) + } catch (err) { + console.error(`Error fetching user with wallet ${c.req.param("primary_wallet")}:`, err) + return c.json({ ok: false, error: "Internal Server Error" }, 500) + } }) /** * Update user details by primary wallet address. * PATCH /users/pw/:primary_wallet + * Requires ownership - only the user can update their own profile */ .patch( "/pw/:primary_wallet", + requireAuth, + userAction(ActionType.UPDATE), UserUpdateByPrimaryWalletDescription, 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(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) + } }, ) /** - * 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", 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) - } + .delete( + "/pw/:primary_wallet", + requireAuth, + userAction(ActionType.DELETE), + UserDeleteByPrimaryWalletDescription, + PrimaryWalletParamValidation, + async (c) => { + 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) + } - 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({ 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) + } + }, + ) // ==================================================================================================== - // 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) => { - 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) - } - - const wallets = await userRepo.getUserWallets(userId) - return c.json(wallets, 200) - }) - - /** - * 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( @@ -251,252 +198,444 @@ 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) + 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) } + }, + ) + + /** + * 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( + "/pw/:primary_wallet/wallets", + requireAuth, + userAction(ActionType.UPDATE), + UserWalletAddByPrimaryWalletDescription, + 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 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 walletAdded = await userRepo.addWallet(user.id, wallet_address) + if (!walletAdded) { + return c.json({ ok: false, error: "Failed to add wallet" }, 500) + } - const wallets = await userRepo.getUserWallets(user.id) - return c.json(wallets, 200) + const updatedUser = await userRepo.findById(user.id) + return c.json({ ok: true, data: updatedUser }, 201) + } catch (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 user ID. - * POST /users/:id/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("/: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") - - // 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) - } + .patch( + "/pw/:primary_wallet/wallets/primary", + requireAuth, + userAction(ActionType.UPDATE), + UserWalletSetPrimaryByPrimaryWalletDescription, + 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 !== userId) { - return c.json({ ok: false, error: "Wallet already registered to another 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 success = await userRepo.addWallet(userId, wallet_address) - if (!success) { - return c.json({ ok: false, error: "Wallet already exists for this user" }, 409) - } + const walletSetToPrimary = await userRepo.setWalletAsPrimary(user.id, 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(updatedUser, 200) - }) + 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) + } + }, + ) /** - * Add a wallet to a user by primary wallet address. - * POST /users/pw/:primary_wallet/wallets + * 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 */ - .post( + .delete( "/pw/:primary_wallet/wallets", - UserWalletAddByPrimaryWalletDescription, + requireAuth, + userAction(ActionType.UPDATE), + UserWalletRemoveByPrimaryWalletDescription, 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") + + // Check if user exists + const user = await userRepo.findByWalletAddress(primary_wallet) + if (!user) { + return c.json({ ok: false, error: "User not found" }, 404) + } - if (primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Primary wallet already exists for this user" }, 400) - } + // 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, + ) + } - // 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) + } - // 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 walletRemoved = await userRepo.removeWallet(user.id, wallet_address) + if (!walletRemoved) { + return c.json({ ok: false, error: "Failed to remove wallet" }, 500) + } - 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 updatedUser = await userRepo.findById(user.id) + return c.json({ ok: true, data: updatedUser }, 200) + } catch (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) } - - // Get updated user with wallets - const updatedUser = await userRepo.findById(user.id, true) - return c.json(updatedUser, 200) }, ) // ==================================================================================================== - // 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 + * Get user details by user ID. + * GET /users/:id */ - .patch( - "/:id/wallets", - UserWalletSetPrimaryByIdDescription, - UserIdParamValidation, - UserWalletValidation, - async (c) => { + .get("/:id", UserGetByIdDescription, UserIdParamValidation, async (c) => { + 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 = Number.parseInt(id, 10) as UserTableId - const user = await userRepo.findById(userId) + 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) } - 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) - } + 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, + userAction(ActionType.UPDATE), + 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) + } - // Get updated user with wallets - const updatedUser = await userRepo.findById(userId, true) - return c.json(updatedUser, 200) + // 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) + } }, ) /** - * Set a wallet as primary for a user by primary wallet address. - * PATCH /users/pw/:primary_wallet/wallets + * Delete a user by user ID and all associated data. + * DELETE /users/:id + * Requires ownership - only the user can delete their own profile */ - .patch( - "/pw/:primary_wallet/wallets", - UserWalletSetPrimaryByPrimaryWalletDescription, - PrimaryWalletParamValidation, - UserWalletValidation, + .delete( + "/:id", + requireAuth, + userAction(ActionType.DELETE), + UserDeleteByIdDescription, + UserIdParamValidation, async (c) => { - const { primary_wallet } = c.req.valid("param") - const { wallet_address } = c.req.valid("json") - 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) + } + + const success = await userRepo.delete(userId) + if (!success) { + return c.json({ ok: false, error: "User not found" }, 404) + } - if (primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Primary wallet cannot be set as primary" }, 400) + 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 Wallet Routes (by ID) + + /** + * Get wallets for a user by 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 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) - } - 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 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 updated user with wallets - const updatedUser = await userRepo.findById(user.id, true) - return c.json(updatedUser, 200) + /** + * 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, + userAction(ActionType.UPDATE), + UserWalletAddByIdDescription, + UserIdParamValidation, + UserWalletValidation, + async (c) => { + 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 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 walletAdded = await userRepo.addWallet(userId, wallet_address) + if (!walletAdded) { + return c.json({ ok: false, error: "Failed to add wallet" }, 500) + } + + const updatedUser = await userRepo.findById(userId) + return c.json({ ok: true, data: updatedUser }, 201) + } catch (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 + * 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", 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) - } + .patch( + "/:id/wallets/primary", + requireAuth, + userAction(ActionType.UPDATE), + UserWalletSetPrimaryByIdDescription, + UserIdParamValidation, + UserWalletValidation, + async (c) => { + 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: "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(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 ${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 + * 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", - UserWalletRemoveByPrimaryWalletDescription, - PrimaryWalletParamValidation, + "/:id/wallets", + requireAuth, + userAction(ActionType.UPDATE), + UserWalletRemoveByIdDescription, + UserIdParamValidation, UserWalletValidation, async (c) => { - const { primary_wallet } = 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 (primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) - } + // 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, + ) + } - // 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(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) + } - if (user.primary_wallet === wallet_address) { - return c.json({ ok: false, error: "Cannot remove primary wallet" }, 400) - } + const walletRemoved = await userRepo.removeWallet(userId, wallet_address) + if (!walletRemoved) { + return c.json({ ok: false, error: "Failed to remove wallet" }, 500) + } - 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 updatedUser = await userRepo.findById(userId) + return c.json({ ok: true, data: updatedUser }, 200) + } catch (err) { + console.error(`Error removing 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(user.id, true) - return c.json(updatedUser, 200) }, ) + // ==================================================================================================== + // 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) => { @@ -504,15 +643,14 @@ export default new Hono() const { id } = c.req.valid("param") 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) } 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/server.ts b/apps/leaderboard-backend/src/server.ts index d96185801b..6987cc731d 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,16 +11,24 @@ 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" import gamesApi from "./routes/api/gamesRoutes" 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 + guildRole: string // User's role in a guild, set by guild middleware } } @@ -45,9 +54,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..fdfe1598eb --- /dev/null +++ b/apps/leaderboard-backend/src/validation/auth/authRouteDescriptions.ts @@ -0,0 +1,166 @@ +import { describeRoute } from "hono-openapi" +import { ErrorResponseSchemaObj } from "../common" +import { + AuthChallengeResponseSchemaObj, + AuthResponseSchemaObj, + SessionListResponseSchemaObj, +} from "./authRouteValidations" + +// ==================================================================================================== +// 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..82bb0abba1 --- /dev/null +++ b/apps/leaderboard-backend/src/validation/auth/authRouteValidations.ts @@ -0,0 +1,18 @@ +import { resolver, validator as zValidator } from "hono-openapi/zod" +import { createSuccessResponseSchema } from "../common" +import { + AuthChallengeDataSchema, + AuthChallengeRequestSchema, + AuthResponseDataSchema, + AuthVerifyRequestSchema, + SessionIdRequestSchema, + SessionListDataSchema, +} from "./authSchemas" + +export const AuthResponseSchemaObj = resolver(createSuccessResponseSchema(AuthResponseDataSchema)) +export const AuthChallengeResponseSchemaObj = resolver(createSuccessResponseSchema(AuthChallengeDataSchema)) +export const SessionListResponseSchemaObj = resolver(createSuccessResponseSchema(SessionListDataSchema)) + +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..196c41b280 --- /dev/null +++ b/apps/leaderboard-backend/src/validation/auth/authSchemas.ts @@ -0,0 +1,105 @@ +import { z } from "@hono/zod-openapi" +import { isHex } from "viem" +import { UserResponseSchema } from "../users" + +// ==================================================================================================== +// Response Schemas + +// Auth response data schema (without the wrapper) +export const AuthResponseDataSchema = z + .object({ + session_id: z.string().uuid(), + user: UserResponseSchema, + }) + .strict() + .openapi({ + example: { + 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: [], + }, + }, + }) + +// Auth challenge data schema (without the wrapper) +export const AuthChallengeDataSchema = z + .object({ + message: z.string(), + }) + .strict() + .openapi({ + example: { + 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", + }, + }) + +// Session list data schema (without the wrapper) +export 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: [ + { + 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, + }, + ], + }) + +// ==================================================================================================== +// 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().refine(isHex), + }) + .strict() + .openapi({ + example: { + 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", + 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/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..aac2c3f8f0 100644 --- a/apps/leaderboard-backend/src/validation/games/gameRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/games/gameRouteDescriptions.ts @@ -1,10 +1,10 @@ import { describeRoute } from "hono-openapi" +import { ErrorResponseSchemaObj } from "../common" import { - ErrorResponseSchemaObj, GameListResponseSchemaObj, GameResponseSchemaObj, UserGameScoreResponseSchemaObj, -} from "./gameSchemas" +} from "./gameRouteValidations" // ==================================================================================================== // Game Collection @@ -191,7 +191,7 @@ export const GameListByAdminDescription = describeRoute({ export const ScoreSubmitDescription = describeRoute({ validateResponse: false, - description: "Submit a new score for a game.", + description: "Submit a new score for a game (creator only).", requestBody: { required: true, content: { 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 95c70dc82c..9e43b4bc6f 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,15 +56,6 @@ export const UserGameScoreResponseSchema = z }, }) -export const GameResponseSchemaObj = resolver(GameResponseSchema) - -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 @@ -149,18 +141,16 @@ 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({ example: { id: "1" }, }) 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({ example: { admin_id: "1" }, }) @@ -169,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" }, }) @@ -178,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 6ed6681229..9ea4754caa 100644 --- a/apps/leaderboard-backend/src/validation/guilds/guildRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/guilds/guildRouteDescriptions.ts @@ -1,5 +1,11 @@ import { describeRoute } from "hono-openapi" -import { ErrorResponseSchemaObj, GuildListResponseSchemaObj, GuildResponseSchemaObj } from "./guildSchemas" +import { ErrorResponseSchemaObj } from "../common" +import { + GuildListResponseSchemaObj, + GuildMemberListResponseSchemaObj, + GuildMemberResponseSchemaObj, + GuildResponseSchemaObj, +} from "./guildRouteValidations" // ==================================================================================================== // Guild Collection @@ -136,7 +142,7 @@ export const GuildListMembersDescription = describeRoute({ description: "Successfully retrieved guild members.", content: { "application/json": { - schema: {}, + schema: GuildMemberListResponseSchemaObj, }, }, }, @@ -161,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: { @@ -175,7 +181,7 @@ export const GuildMemberAddDescription = describeRoute({ description: "Member added successfully.", content: { "application/json": { - schema: {}, + schema: GuildMemberResponseSchemaObj, }, }, }, @@ -208,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: { @@ -219,10 +225,10 @@ export const GuildMemberUpdateDescription = describeRoute({ }, responses: { 200: { - description: "Member updated successfully.", + description: "Member role updated successfully.", content: { "application/json": { - schema: {}, + schema: GuildMemberResponseSchemaObj, }, }, }, @@ -247,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, @@ -266,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/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 59d051f022..5e17767bda 100644 --- a/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts +++ b/apps/leaderboard-backend/src/validation/guilds/guildSchemas.ts @@ -1,11 +1,11 @@ import { z } from "@hono/zod-openapi" -import { resolver } from "hono-openapi/zod" -import { type Address, isHex } from "viem" +import { 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(), @@ -26,20 +26,18 @@ const GuildResponseSchema = z }, }) -const GuildMemberResponseSchema = z +export const GuildMemberResponseSchema = z .object({ 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(), - primary_wallet: z - .string() - .refine(isHex) - .transform((val) => val as Address) - .optional(), + primary_wallet: z.string().refine(isHex).optional().openapi({ + type: "string", + }), }) .strict() .openapi({ @@ -47,23 +45,16 @@ 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", }, }) -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 @@ -118,7 +109,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 +118,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, }, }) @@ -148,9 +139,8 @@ 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({ example: { id: "1", @@ -159,9 +149,8 @@ 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({ example: { member_id: "1", 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..f14f1406f2 100644 --- a/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardSchema.ts +++ b/apps/leaderboard-backend/src/validation/leaderboard/leaderBoardSchema.ts @@ -1,101 +1,103 @@ 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"), }) 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/userRouteDescriptions.ts b/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts index ce7711c0ea..4ec9dd819c 100644 --- a/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts +++ b/apps/leaderboard-backend/src/validation/users/userRouteDescriptions.ts @@ -1,10 +1,11 @@ import { describeRoute } from "hono-openapi" +import { ErrorResponseSchemaObj } from "../common/responseSchemas" +import { GuildListResponseSchemaObj } from "../guilds" import { - ErrorResponseSchemaObj, UserListResponseSchemaObj, UserResponseSchemaObj, UserWalletListResponseSchemaObj, -} from "./userSchemas" +} from "./userRouteValidations" // ==================================================================================================== // User Collection @@ -542,7 +543,7 @@ export const UserGuildsListDescription = describeRoute({ description: "Successfully retrieved user guilds.", content: { "application/json": { - schema: {}, + schema: GuildListResponseSchemaObj, }, }, }, 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 c5e770cd94..164fcf13a5 100644 --- a/apps/leaderboard-backend/src/validation/users/userSchemas.ts +++ b/apps/leaderboard-backend/src/validation/users/userSchemas.ts @@ -1,5 +1,4 @@ import { z } from "@hono/zod-openapi" -import { resolver } from "hono-openapi/zod" import { isHex } from "viem" // ==================================================================================================== @@ -24,14 +23,16 @@ const UserWalletSchema = z }, }) -const UserResponseSchema = z +export const UserWalletListSchema = z.array(UserWalletSchema) + +export const UserResponseSchema = z .object({ id: z.number().int(), primary_wallet: z.string().refine(isHex), username: z.string(), created_at: z.string(), updated_at: z.string(), - wallets: z.array(UserWalletSchema).optional(), + wallets: UserWalletListSchema.optional(), }) .strict() .openapi({ @@ -53,14 +54,7 @@ const UserResponseSchema = z }, }) -export const UserResponseSchemaObj = resolver(UserResponseSchema) - -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 const UserListSchema = z.array(UserResponseSchema) // ==================================================================================================== // Request Body Schemas @@ -123,9 +117,8 @@ 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({ param: { name: "id", @@ -140,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", diff --git a/bun.lock b/bun.lock index 689e4f2c6c..5913ee7389 100644 --- a/bun.lock +++ b/bun.lock @@ -5958,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=="],