refactor: rename web/ to api/ to better reflect its purpose

The web/ folder contains the REST API, WebSocket server, and OAuth
routes — not a web frontend. Renaming to api/ clarifies this distinction
since the actual web frontend lives in panel/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-02-14 11:37:40 +01:00
parent 1a59c9e796
commit 04e5851387
31 changed files with 4 additions and 4 deletions

34
api/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

30
api/README.md Normal file
View File

@@ -0,0 +1,30 @@
# Aurora Web API
The web API provides a REST interface and WebSocket support for accessing Aurora bot data and configuration.
## API Endpoints
- `GET /api/stats` - Real-time bot statistics
- `GET /api/settings` - Bot configuration
- `GET /api/users` - User data
- `GET /api/items` - Item catalog
- `GET /api/quests` - Quest information
- `GET /api/transactions` - Economy data
- `GET /api/health` - Health check
## WebSocket
Connect to `/ws` for real-time updates:
- Stats broadcasts every 5 seconds
- Event notifications via system bus
- PING/PONG heartbeat support
## Development
The API runs automatically when you start the bot:
```bash
bun run dev
```
The API will be available at `http://localhost:3000`

17
api/bun-env.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}

3
api/bunfig.toml Normal file
View File

@@ -0,0 +1,3 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"

View File

@@ -0,0 +1,106 @@
/**
* @fileoverview Administrative action endpoints for Aurora API.
* Provides endpoints for system administration tasks like cache clearing
* and maintenance mode toggling.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, parseBody, withErrorHandling } from "./utils";
import { MaintenanceModeSchema } from "./schemas";
/**
* Admin actions routes handler.
*
* Endpoints:
* - POST /api/actions/reload-commands - Reload bot slash commands
* - POST /api/actions/clear-cache - Clear internal caches
* - POST /api/actions/maintenance-mode - Toggle maintenance mode
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req } = ctx;
// Only handle POST requests to /api/actions/*
if (!pathname.startsWith("/api/actions/") || method !== "POST") {
return null;
}
const { actionService } = await import("@shared/modules/admin/action.service");
/**
* @route POST /api/actions/reload-commands
* @description Triggers a reload of all Discord slash commands.
* Useful after modifying command configurations.
* @response 200 - `{ success: true, message: string }`
* @response 500 - Error reloading commands
*
* @example
* // Request
* POST /api/actions/reload-commands
*
* // Response
* { "success": true, "message": "Commands reloaded" }
*/
if (pathname === "/api/actions/reload-commands") {
return withErrorHandling(async () => {
const result = await actionService.reloadCommands();
return jsonResponse(result);
}, "reload commands");
}
/**
* @route POST /api/actions/clear-cache
* @description Clears all internal application caches.
* Useful for forcing fresh data fetches.
* @response 200 - `{ success: true, message: string }`
* @response 500 - Error clearing cache
*
* @example
* // Request
* POST /api/actions/clear-cache
*
* // Response
* { "success": true, "message": "Cache cleared" }
*/
if (pathname === "/api/actions/clear-cache") {
return withErrorHandling(async () => {
const result = await actionService.clearCache();
return jsonResponse(result);
}, "clear cache");
}
/**
* @route POST /api/actions/maintenance-mode
* @description Toggles bot maintenance mode on or off.
* When enabled, the bot will respond with a maintenance message.
*
* @body { enabled: boolean, reason?: string }
* @response 200 - `{ success: true, enabled: boolean }`
* @response 400 - Invalid payload with validation errors
* @response 500 - Error toggling maintenance mode
*
* @example
* // Request
* POST /api/actions/maintenance-mode
* Content-Type: application/json
* { "enabled": true, "reason": "Deploying updates..." }
*
* // Response
* { "success": true, "enabled": true }
*/
if (pathname === "/api/actions/maintenance-mode") {
return withErrorHandling(async () => {
const data = await parseBody(req, MaintenanceModeSchema);
if (data instanceof Response) return data;
const result = await actionService.toggleMaintenanceMode(data.enabled, data.reason);
return jsonResponse(result);
}, "toggle maintenance mode");
}
return null;
}
export const actionsRoutes: RouteModule = {
name: "actions",
handler
};

View File

@@ -0,0 +1,83 @@
/**
* @fileoverview Static asset serving for Aurora API.
* Serves item images and other assets from the local filesystem.
*/
import { join, resolve, dirname } from "path";
import type { RouteContext, RouteModule } from "./types";
// Resolve assets root directory
const currentDir = dirname(new URL(import.meta.url).pathname);
const assetsRoot = resolve(currentDir, "../../../bot/assets/graphics");
/** MIME types for supported image formats */
const MIME_TYPES: Record<string, string> = {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"webp": "image/webp",
"gif": "image/gif",
};
/**
* Assets routes handler.
*
* Endpoints:
* - GET /assets/* - Serve static files from the assets directory
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method } = ctx;
/**
* @route GET /assets/*
* @description Serves static asset files (images) with caching headers.
* Assets are served from the bot's graphics directory.
*
* Path security: Path traversal attacks are prevented by validating
* that the resolved path stays within the assets root.
*
* @response 200 - File content with appropriate MIME type
* @response 403 - Forbidden (path traversal attempt)
* @response 404 - File not found
*
* @example
* // Request
* GET /assets/items/1.png
*
* // Response Headers
* Content-Type: image/png
* Cache-Control: public, max-age=86400
*/
if (pathname.startsWith("/assets/") && method === "GET") {
const assetPath = pathname.replace("/assets/", "");
// Security: prevent path traversal attacks
const safePath = join(assetsRoot, assetPath);
if (!safePath.startsWith(assetsRoot)) {
return new Response("Forbidden", { status: 403 });
}
const file = Bun.file(safePath);
if (await file.exists()) {
// Determine MIME type based on extension
const ext = safePath.split(".").pop()?.toLowerCase();
const contentType = MIME_TYPES[ext || ""] || "application/octet-stream";
return new Response(file, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
}
});
}
return new Response("Not found", { status: 404 });
}
return null;
}
export const assetsRoutes: RouteModule = {
name: "assets",
handler
};

View File

@@ -0,0 +1,233 @@
/**
* @fileoverview Discord OAuth2 authentication routes for the admin panel.
* Handles login flow, callback, logout, and session management.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse } from "./utils";
import { logger } from "@shared/lib/logger";
// In-memory session store: token → { discordId, username, avatar, expiresAt }
export interface Session {
discordId: string;
username: string;
avatar: string | null;
expiresAt: number;
}
const sessions = new Map<string, Session>();
const redirects = new Map<string, string>(); // redirect token -> return_to URL
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
function getEnv(key: string): string {
const val = process.env[key];
if (!val) throw new Error(`Missing env: ${key}`);
return val;
}
function getAdminIds(): string[] {
const raw = process.env.ADMIN_USER_IDS ?? "";
return raw.split(",").map(s => s.trim()).filter(Boolean);
}
function generateToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
}
function getBaseUrl(): string {
return process.env.PANEL_BASE_URL ?? `http://localhost:3000`;
}
function parseCookies(header: string | null): Record<string, string> {
if (!header) return {};
const cookies: Record<string, string> = {};
for (const pair of header.split(";")) {
const [key, ...rest] = pair.trim().split("=");
if (key) cookies[key] = rest.join("=");
}
return cookies;
}
/** Get session from request cookie */
export function getSession(req: Request): Session | null {
const cookies = parseCookies(req.headers.get("cookie"));
const token = cookies["aurora_session"];
if (!token) return null;
const session = sessions.get(token);
if (!session) return null;
if (Date.now() > session.expiresAt) {
sessions.delete(token);
return null;
}
return session;
}
/** Check if request is authenticated as admin */
export function isAuthenticated(req: Request): boolean {
return getSession(req) !== null;
}
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method } = ctx;
// GET /auth/discord — redirect to Discord OAuth
if (pathname === "/auth/discord" && method === "GET") {
try {
const clientId = getEnv("DISCORD_CLIENT_ID");
const baseUrl = getBaseUrl();
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
const scope = "identify+email";
// Store return_to URL if provided
const returnTo = ctx.url.searchParams.get("return_to") || "/";
const redirectToken = generateToken();
redirects.set(redirectToken, returnTo);
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`;
// Set a temporary cookie with the redirect token
return new Response(null, {
status: 302,
headers: {
Location: url,
"Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`,
},
});
} catch (e) {
logger.error("auth", "Failed to initiate OAuth", e);
return errorResponse("OAuth not configured", 500);
}
}
// GET /auth/callback — handle Discord OAuth callback
if (pathname === "/auth/callback" && method === "GET") {
const code = ctx.url.searchParams.get("code");
if (!code) return errorResponse("Missing code parameter", 400);
try {
const clientId = getEnv("DISCORD_CLIENT_ID");
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
const baseUrl = getBaseUrl();
const redirectUri = `${baseUrl}/auth/callback`;
// Exchange code for token
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
}),
});
if (!tokenRes.ok) {
logger.error("auth", `Token exchange failed: ${tokenRes.status}`);
return errorResponse("OAuth token exchange failed", 401);
}
const tokenData = await tokenRes.json() as { access_token: string };
// Fetch user info
const userRes = await fetch("https://discord.com/api/users/@me", {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
if (!userRes.ok) {
return errorResponse("Failed to fetch Discord user", 401);
}
const user = await userRes.json() as { id: string; username: string; avatar: string | null };
// Check allowlist
const adminIds = getAdminIds();
if (adminIds.length > 0 && !adminIds.includes(user.id)) {
logger.warn("auth", `Unauthorized login attempt by ${user.username} (${user.id})`);
return new Response(
`<html><body><h1>Access Denied</h1><p>Your Discord account is not authorized.</p></body></html>`,
{ status: 403, headers: { "Content-Type": "text/html" } }
);
}
// Create session
const token = generateToken();
sessions.set(token, {
discordId: user.id,
username: user.username,
avatar: user.avatar,
expiresAt: Date.now() + SESSION_MAX_AGE,
});
logger.info("auth", `Admin login: ${user.username} (${user.id})`);
// Get return_to URL from redirect token cookie
const cookies = parseCookies(ctx.req.headers.get("cookie"));
const redirectToken = cookies["aurora_redirect"];
let returnTo = redirectToken && redirects.get(redirectToken) ? redirects.get(redirectToken)! : "/";
if (redirectToken) redirects.delete(redirectToken);
// Only allow redirects to localhost or relative paths (prevent open redirect)
try {
const parsed = new URL(returnTo, baseUrl);
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
returnTo = "/";
}
} catch {
returnTo = "/";
}
// Redirect to panel with session cookie
return new Response(null, {
status: 302,
headers: {
Location: returnTo,
"Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE / 1000}`,
},
});
} catch (e) {
logger.error("auth", "OAuth callback error", e);
return errorResponse("Authentication failed", 500);
}
}
// POST /auth/logout — clear session
if (pathname === "/auth/logout" && method === "POST") {
const cookies = parseCookies(ctx.req.headers.get("cookie"));
const token = cookies["aurora_session"];
if (token) sessions.delete(token);
return new Response(null, {
status: 200,
headers: {
"Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
"Content-Type": "application/json",
},
});
}
// GET /auth/me — return current session info
if (pathname === "/auth/me" && method === "GET") {
const session = getSession(ctx.req);
if (!session) return jsonResponse({ authenticated: false }, 401);
return jsonResponse({
authenticated: true,
user: {
discordId: session.discordId,
username: session.username,
avatar: session.avatar,
},
});
}
return null;
}
export const authRoutes: RouteModule = {
name: "auth",
handler,
};

View File

@@ -0,0 +1,155 @@
/**
* @fileoverview Class management endpoints for Aurora API.
* Provides CRUD operations for player classes/guilds.
*/
import type { RouteContext, RouteModule } from "./types";
import {
jsonResponse,
errorResponse,
parseBody,
parseStringIdFromPath,
withErrorHandling
} from "./utils";
import { CreateClassSchema, UpdateClassSchema } from "./schemas";
/**
* Classes routes handler.
*
* Endpoints:
* - GET /api/classes - List all classes
* - POST /api/classes - Create a new class
* - PUT /api/classes/:id - Update a class
* - DELETE /api/classes/:id - Delete a class
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req } = ctx;
// Only handle requests to /api/classes*
if (!pathname.startsWith("/api/classes")) {
return null;
}
const { classService } = await import("@shared/modules/class/class.service");
/**
* @route GET /api/classes
* @description Returns all classes/guilds in the system.
*
* @response 200 - `{ classes: Class[] }`
* @response 500 - Error fetching classes
*
* @example
* // Response
* {
* "classes": [
* { "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" }
* ]
* }
*/
if (pathname === "/api/classes" && method === "GET") {
return withErrorHandling(async () => {
const classes = await classService.getAllClasses();
return jsonResponse({ classes });
}, "fetch classes");
}
/**
* @route POST /api/classes
* @description Creates a new class/guild.
*
* @body {
* id: string | number (required) - Unique class identifier,
* name: string (required) - Class display name,
* balance?: string | number - Initial class balance (default: 0),
* roleId?: string - Associated Discord role ID
* }
* @response 201 - `{ success: true, class: Class }`
* @response 400 - Missing required fields
* @response 500 - Error creating class
*
* @example
* // Request
* POST /api/classes
* { "id": "2", "name": "Mage", "balance": "0", "roleId": "987654321" }
*/
if (pathname === "/api/classes" && method === "POST") {
return withErrorHandling(async () => {
const data = await req.json() as Record<string, any>;
if (!data.id || !data.name || typeof data.name !== 'string') {
return errorResponse("Missing required fields: id and name are required", 400);
}
const newClass = await classService.createClass({
id: BigInt(data.id),
name: data.name,
balance: data.balance ? BigInt(data.balance) : 0n,
roleId: data.roleId || null,
});
return jsonResponse({ success: true, class: newClass }, 201);
}, "create class");
}
/**
* @route PUT /api/classes/:id
* @description Updates an existing class.
*
* @param id - Class ID
* @body {
* name?: string - Updated class name,
* balance?: string | number - Updated balance,
* roleId?: string - Updated Discord role ID
* }
* @response 200 - `{ success: true, class: Class }`
* @response 404 - Class not found
* @response 500 - Error updating class
*/
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "PUT") {
const id = parseStringIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const data = await req.json() as Record<string, any>;
const updateData: any = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
if (data.roleId !== undefined) updateData.roleId = data.roleId;
const updatedClass = await classService.updateClass(BigInt(id), updateData);
if (!updatedClass) {
return errorResponse("Class not found", 404);
}
return jsonResponse({ success: true, class: updatedClass });
}, "update class");
}
/**
* @route DELETE /api/classes/:id
* @description Deletes a class. Users assigned to this class will need to be reassigned.
*
* @param id - Class ID
* @response 204 - Class deleted (no content)
* @response 500 - Error deleting class
*/
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "DELETE") {
const id = parseStringIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
await classService.deleteClass(BigInt(id));
return new Response(null, { status: 204 });
}, "delete class");
}
return null;
}
export const classesRoutes: RouteModule = {
name: "classes",
handler
};

View File

@@ -0,0 +1,64 @@
/**
* @fileoverview Guild settings endpoints for Aurora API.
* Provides endpoints for reading and updating per-guild configuration
* stored in the database.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { invalidateGuildConfigCache } from "@shared/lib/config";
const GUILD_SETTINGS_PATTERN = /^\/api\/guilds\/(\d+)\/settings$/;
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req } = ctx;
const match = pathname.match(GUILD_SETTINGS_PATTERN);
if (!match || !match[1]) {
return null;
}
const guildId = match[1];
if (method === "GET") {
return withErrorHandling(async () => {
const settings = await guildSettingsService.getSettings(guildId);
if (!settings) {
return jsonResponse({ guildId, configured: false });
}
return jsonResponse({ ...settings, guildId, configured: true });
}, "fetch guild settings");
}
if (method === "PUT" || method === "PATCH") {
try {
const body = await req.json() as Record<string, unknown>;
const { guildId: _, ...settings } = body;
const result = await guildSettingsService.upsertSettings({
guildId,
...settings,
} as Parameters<typeof guildSettingsService.upsertSettings>[0]);
invalidateGuildConfigCache(guildId);
return jsonResponse(result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return errorResponse("Failed to save guild settings", 400, message);
}
}
if (method === "DELETE") {
return withErrorHandling(async () => {
await guildSettingsService.deleteSettings(guildId);
invalidateGuildConfigCache(guildId);
return jsonResponse({ success: true });
}, "delete guild settings");
}
return null;
}
export const guildSettingsRoutes: RouteModule = {
name: "guild-settings",
handler
};

View File

@@ -0,0 +1,36 @@
/**
* @fileoverview Health check endpoint for Aurora API.
* Provides a simple health status endpoint for monitoring and load balancers.
*/
import type { RouteContext, RouteModule } from "./types";
/**
* Health routes handler.
*
* @route GET /api/health
* @description Returns server health status with timestamp.
* @response 200 - `{ status: "ok", timestamp: number }`
*
* @example
* // Request
* GET /api/health
*
* // Response
* { "status": "ok", "timestamp": 1707408000000 }
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
if (ctx.pathname === "/api/health" && ctx.method === "GET") {
return Response.json({
status: "ok",
timestamp: Date.now()
});
}
return null;
}
export const healthRoutes: RouteModule = {
name: "health",
handler
};

93
api/src/routes/index.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* @fileoverview Route registration module for Aurora API.
* Aggregates all route handlers and provides a unified request handler.
*/
import type { RouteContext, RouteModule } from "./types";
import { authRoutes, isAuthenticated } from "./auth.routes";
import { healthRoutes } from "./health.routes";
import { statsRoutes } from "./stats.routes";
import { actionsRoutes } from "./actions.routes";
import { questsRoutes } from "./quests.routes";
import { settingsRoutes } from "./settings.routes";
import { guildSettingsRoutes } from "./guild-settings.routes";
import { itemsRoutes } from "./items.routes";
import { usersRoutes } from "./users.routes";
import { classesRoutes } from "./classes.routes";
import { moderationRoutes } from "./moderation.routes";
import { transactionsRoutes } from "./transactions.routes";
import { lootdropsRoutes } from "./lootdrops.routes";
import { assetsRoutes } from "./assets.routes";
import { errorResponse } from "./utils";
/** Routes that do NOT require authentication */
const publicRoutes: RouteModule[] = [
authRoutes,
healthRoutes,
];
/** Routes that require an authenticated admin session */
const protectedRoutes: RouteModule[] = [
statsRoutes,
actionsRoutes,
questsRoutes,
settingsRoutes,
guildSettingsRoutes,
itemsRoutes,
usersRoutes,
classesRoutes,
moderationRoutes,
transactionsRoutes,
lootdropsRoutes,
assetsRoutes,
];
/**
* Main request handler that routes requests to appropriate handlers.
*
* @param req - The incoming HTTP request
* @param url - Parsed URL object
* @returns Response from matching route handler, or null if no match
*
* @example
* const response = await handleRequest(req, url);
* if (response) return response;
* return new Response("Not Found", { status: 404 });
*/
export async function handleRequest(req: Request, url: URL): Promise<Response | null> {
const ctx: RouteContext = {
req,
url,
method: req.method,
pathname: url.pathname,
};
// Try public routes first (auth, health)
for (const module of publicRoutes) {
const response = await module.handler(ctx);
if (response !== null) return response;
}
// For API routes, enforce authentication
if (ctx.pathname.startsWith("/api/")) {
if (!isAuthenticated(req)) {
return errorResponse("Unauthorized", 401);
}
}
// Try protected routes
for (const module of protectedRoutes) {
const response = await module.handler(ctx);
if (response !== null) return response;
}
return null;
}
/**
* Get list of all registered route module names.
* Useful for debugging and documentation.
*/
export function getRegisteredRoutes(): string[] {
return [...publicRoutes, ...protectedRoutes].map(m => m.name);
}

View File

@@ -0,0 +1,371 @@
/**
* @fileoverview Items management endpoints for Aurora API.
* Provides CRUD operations for game items with image upload support.
*/
import { join, resolve, dirname } from "path";
import type { RouteContext, RouteModule } from "./types";
import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service";
import {
jsonResponse,
errorResponse,
parseBody,
parseIdFromPath,
parseQuery,
withErrorHandling
} from "./utils";
import { CreateItemSchema, UpdateItemSchema, ItemQuerySchema } from "./schemas";
// Resolve assets directory path
const currentDir = dirname(new URL(import.meta.url).pathname);
const assetsDir = resolve(currentDir, "../../../bot/assets/graphics/items");
/**
* Validates image file by checking magic bytes.
* Supports PNG, JPEG, WebP, and GIF formats.
*/
function validateImageFormat(bytes: Uint8Array): boolean {
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
return isPNG || isJPEG || isWebP || isGIF;
}
/** Maximum image file size: 15MB */
const MAX_IMAGE_SIZE = 15 * 1024 * 1024;
/**
* Items routes handler.
*
* Endpoints:
* - GET /api/items - List items with filters
* - POST /api/items - Create item (JSON or multipart with image)
* - GET /api/items/:id - Get single item
* - PUT /api/items/:id - Update item
* - DELETE /api/items/:id - Delete item and asset
* - POST /api/items/:id/icon - Upload/replace item icon
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req, url } = ctx;
// Only handle requests to /api/items*
if (!pathname.startsWith("/api/items")) {
return null;
}
const { itemsService } = await import("@shared/modules/items/items.service");
/**
* @route GET /api/items
* @description Returns a paginated list of items with optional filtering.
*
* @query search - Filter by name/description (partial match)
* @query type - Filter by item type (CONSUMABLE, EQUIPMENT, etc.)
* @query rarity - Filter by rarity (C, R, SR, SSR)
* @query limit - Max results per page (default: 100, max: 100)
* @query offset - Pagination offset (default: 0)
*
* @response 200 - `{ items: Item[], total: number }`
* @response 500 - Error fetching items
*
* @example
* // Request
* GET /api/items?type=CONSUMABLE&rarity=R&limit=10
*
* // Response
* {
* "items": [{ "id": 1, "name": "Health Potion", ... }],
* "total": 25
* }
*/
if (pathname === "/api/items" && method === "GET") {
return withErrorHandling(async () => {
const filters = {
search: url.searchParams.get("search") || undefined,
type: url.searchParams.get("type") || undefined,
rarity: url.searchParams.get("rarity") || undefined,
limit: url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 100,
offset: url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0,
};
const result = await itemsService.getAllItems(filters);
return jsonResponse(result);
}, "fetch items");
}
/**
* @route POST /api/items
* @description Creates a new item. Supports JSON or multipart/form-data with image.
*
* @body (JSON) {
* name: string (required),
* type: string (required),
* description?: string,
* rarity?: "C" | "R" | "SR" | "SSR",
* price?: string | number,
* usageData?: object
* }
*
* @body (Multipart) {
* data: JSON string with item fields,
* image?: File (PNG, JPEG, WebP, GIF - max 15MB)
* }
*
* @response 201 - `{ success: true, item: Item }`
* @response 400 - Missing required fields or invalid image
* @response 409 - Item name already exists
* @response 500 - Error creating item
*/
if (pathname === "/api/items" && method === "POST") {
return withErrorHandling(async () => {
const contentType = req.headers.get("content-type") || "";
let itemData: CreateItemDTO | null = null;
let imageFile: File | null = null;
if (contentType.includes("multipart/form-data")) {
const formData = await req.formData();
const jsonData = formData.get("data");
imageFile = formData.get("image") as File | null;
if (typeof jsonData === "string") {
itemData = JSON.parse(jsonData) as CreateItemDTO;
} else {
return errorResponse("Missing item data", 400);
}
} else {
itemData = await req.json() as CreateItemDTO;
}
if (!itemData) {
return errorResponse("Missing item data", 400);
}
// Validate required fields
if (!itemData.name || !itemData.type) {
return errorResponse("Missing required fields: name and type are required", 400);
}
// Check for duplicate name
if (await itemsService.isNameTaken(itemData.name)) {
return errorResponse("An item with this name already exists", 409);
}
// Set placeholder URLs if image will be uploaded
const placeholderUrl = "/assets/items/placeholder.png";
const createData = {
name: itemData.name,
description: itemData.description || null,
rarity: itemData.rarity || "C",
type: itemData.type,
price: itemData.price ? BigInt(itemData.price) : null,
iconUrl: itemData.iconUrl || placeholderUrl,
imageUrl: itemData.imageUrl || placeholderUrl,
usageData: itemData.usageData || null,
};
// Create the item
const item = await itemsService.createItem(createData);
// If image was provided, save it and update the item
if (imageFile && item) {
const buffer = await imageFile.arrayBuffer();
const bytes = new Uint8Array(buffer);
if (!validateImageFormat(bytes)) {
await itemsService.deleteItem(item.id);
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
}
if (buffer.byteLength > MAX_IMAGE_SIZE) {
await itemsService.deleteItem(item.id);
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
}
const fileName = `${item.id}.png`;
const filePath = join(assetsDir, fileName);
await Bun.write(filePath, buffer);
const assetUrl = `/assets/items/${fileName}`;
await itemsService.updateItem(item.id, {
iconUrl: assetUrl,
imageUrl: assetUrl,
});
const updatedItem = await itemsService.getItemById(item.id);
return jsonResponse({ success: true, item: updatedItem }, 201);
}
return jsonResponse({ success: true, item }, 201);
}, "create item");
}
/**
* @route GET /api/items/:id
* @description Returns a single item by ID.
*
* @param id - Item ID (numeric)
* @response 200 - Full item object
* @response 404 - Item not found
* @response 500 - Error fetching item
*/
if (pathname.match(/^\/api\/items\/\d+$/) && method === "GET") {
const id = parseIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const item = await itemsService.getItemById(id);
if (!item) {
return errorResponse("Item not found", 404);
}
return jsonResponse(item);
}, "fetch item");
}
/**
* @route PUT /api/items/:id
* @description Updates an existing item.
*
* @param id - Item ID (numeric)
* @body Partial item fields to update
* @response 200 - `{ success: true, item: Item }`
* @response 404 - Item not found
* @response 409 - Name already taken by another item
* @response 500 - Error updating item
*/
if (pathname.match(/^\/api\/items\/\d+$/) && method === "PUT") {
const id = parseIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const data = await req.json() as Partial<UpdateItemDTO>;
const existing = await itemsService.getItemById(id);
if (!existing) {
return errorResponse("Item not found", 404);
}
// Check for duplicate name (if name is being changed)
if (data.name && data.name !== existing.name) {
if (await itemsService.isNameTaken(data.name, id)) {
return errorResponse("An item with this name already exists", 409);
}
}
// Build update data
const updateData: Partial<UpdateItemDTO> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.rarity !== undefined) updateData.rarity = data.rarity;
if (data.type !== undefined) updateData.type = data.type;
if (data.price !== undefined) updateData.price = data.price ? BigInt(data.price) : null;
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl;
if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl;
if (data.usageData !== undefined) updateData.usageData = data.usageData;
const updatedItem = await itemsService.updateItem(id, updateData);
return jsonResponse({ success: true, item: updatedItem });
}, "update item");
}
/**
* @route DELETE /api/items/:id
* @description Deletes an item and its associated asset file.
*
* @param id - Item ID (numeric)
* @response 204 - Item deleted (no content)
* @response 404 - Item not found
* @response 500 - Error deleting item
*/
if (pathname.match(/^\/api\/items\/\d+$/) && method === "DELETE") {
const id = parseIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const existing = await itemsService.getItemById(id);
if (!existing) {
return errorResponse("Item not found", 404);
}
await itemsService.deleteItem(id);
// Try to delete associated asset file
const assetPath = join(assetsDir, `${id}.png`);
try {
const assetFile = Bun.file(assetPath);
if (await assetFile.exists()) {
const { unlink } = await import("node:fs/promises");
await unlink(assetPath);
}
} catch (e) {
// Non-critical: log but don't fail
const { logger } = await import("@shared/lib/logger");
logger.warn("web", `Could not delete asset file for item ${id}`, e);
}
return new Response(null, { status: 204 });
}, "delete item");
}
/**
* @route POST /api/items/:id/icon
* @description Uploads or replaces an item's icon image.
*
* @param id - Item ID (numeric)
* @body (Multipart) { image: File }
* @response 200 - `{ success: true, item: Item }`
* @response 400 - No image file or invalid format
* @response 404 - Item not found
* @response 500 - Error uploading icon
*/
if (pathname.match(/^\/api\/items\/\d+\/icon$/) && method === "POST") {
const id = parseInt(pathname.split("/")[3] || "0");
if (!id) return null;
return withErrorHandling(async () => {
const existing = await itemsService.getItemById(id);
if (!existing) {
return errorResponse("Item not found", 404);
}
const formData = await req.formData();
const imageFile = formData.get("image") as File | null;
if (!imageFile) {
return errorResponse("No image file provided", 400);
}
const buffer = await imageFile.arrayBuffer();
const bytes = new Uint8Array(buffer);
if (!validateImageFormat(bytes)) {
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
}
if (buffer.byteLength > MAX_IMAGE_SIZE) {
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
}
const fileName = `${id}.png`;
const filePath = join(assetsDir, fileName);
await Bun.write(filePath, buffer);
const assetUrl = `/assets/items/${fileName}`;
const updatedItem = await itemsService.updateItem(id, {
iconUrl: assetUrl,
imageUrl: assetUrl,
});
return jsonResponse({ success: true, item: updatedItem });
}, "upload item icon");
}
return null;
}
export const itemsRoutes: RouteModule = {
name: "items",
handler
};

View File

@@ -0,0 +1,130 @@
/**
* @fileoverview Lootdrop management endpoints for Aurora API.
* Provides endpoints for viewing, spawning, and canceling lootdrops.
*/
import type { RouteContext, RouteModule } from "./types";
import {
jsonResponse,
errorResponse,
parseStringIdFromPath,
withErrorHandling
} from "./utils";
/**
* Lootdrops routes handler.
*
* Endpoints:
* - GET /api/lootdrops - List lootdrops
* - POST /api/lootdrops - Spawn a lootdrop
* - DELETE /api/lootdrops/:messageId - Cancel/delete a lootdrop
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req, url } = ctx;
// Only handle requests to /api/lootdrops*
if (!pathname.startsWith("/api/lootdrops")) {
return null;
}
/**
* @route GET /api/lootdrops
* @description Returns recent lootdrops, sorted by newest first.
*
* @query limit - Max results (default: 50)
* @response 200 - `{ lootdrops: Lootdrop[] }`
* @response 500 - Error fetching lootdrops
*/
if (pathname === "/api/lootdrops" && method === "GET") {
return withErrorHandling(async () => {
const { lootdrops } = await import("@shared/db/schema");
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
const { desc } = await import("drizzle-orm");
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
const result = await DrizzleClient.select()
.from(lootdrops)
.orderBy(desc(lootdrops.createdAt))
.limit(limit);
return jsonResponse({ lootdrops: result });
}, "fetch lootdrops");
}
/**
* @route POST /api/lootdrops
* @description Spawns a new lootdrop in a Discord channel.
* Requires a valid text channel ID where the bot has permissions.
*
* @body {
* channelId: string (required) - Discord channel ID to spawn in,
* amount?: number - Reward amount (random if not specified),
* currency?: string - Currency type
* }
* @response 201 - `{ success: true }`
* @response 400 - Invalid channel or missing channelId
* @response 500 - Error spawning lootdrop
*
* @example
* // Request
* POST /api/lootdrops
* { "channelId": "1234567890", "amount": 100, "currency": "Gold" }
*/
if (pathname === "/api/lootdrops" && method === "POST") {
return withErrorHandling(async () => {
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const { AuroraClient } = await import("../../../bot/lib/BotClient");
const { TextChannel } = await import("discord.js");
const data = await req.json() as Record<string, any>;
if (!data.channelId) {
return errorResponse("Missing required field: channelId", 400);
}
const channel = await AuroraClient.channels.fetch(data.channelId);
if (!channel || !(channel instanceof TextChannel)) {
return errorResponse("Invalid channel. Must be a TextChannel.", 400);
}
await lootdropService.spawnLootdrop(channel, data.amount, data.currency);
return jsonResponse({ success: true }, 201);
}, "spawn lootdrop");
}
/**
* @route DELETE /api/lootdrops/:messageId
* @description Cancels and deletes an active lootdrop.
* The lootdrop is identified by its Discord message ID.
*
* @param messageId - Discord message ID of the lootdrop
* @response 204 - Lootdrop deleted (no content)
* @response 404 - Lootdrop not found
* @response 500 - Error deleting lootdrop
*/
if (pathname.match(/^\/api\/lootdrops\/[^\/]+$/) && method === "DELETE") {
const messageId = parseStringIdFromPath(pathname);
if (!messageId) return null;
return withErrorHandling(async () => {
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const success = await lootdropService.deleteLootdrop(messageId);
if (!success) {
return errorResponse("Lootdrop not found", 404);
}
return new Response(null, { status: 204 });
}, "delete lootdrop");
}
return null;
}
export const lootdropsRoutes: RouteModule = {
name: "lootdrops",
handler
};

View File

@@ -0,0 +1,217 @@
/**
* @fileoverview Moderation case management endpoints for Aurora API.
* Provides endpoints for viewing, creating, and resolving moderation cases.
*/
import type { RouteContext, RouteModule } from "./types";
import {
jsonResponse,
errorResponse,
parseBody,
withErrorHandling
} from "./utils";
import { CreateCaseSchema, ClearCaseSchema, CaseIdPattern } from "./schemas";
/**
* Moderation routes handler.
*
* Endpoints:
* - GET /api/moderation - List cases with filters
* - GET /api/moderation/:caseId - Get single case
* - POST /api/moderation - Create new case
* - PUT /api/moderation/:caseId/clear - Clear/resolve case
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req, url } = ctx;
// Only handle requests to /api/moderation*
if (!pathname.startsWith("/api/moderation")) {
return null;
}
const { moderationService } = await import("@shared/modules/moderation/moderation.service");
/**
* @route GET /api/moderation
* @description Returns moderation cases with optional filtering.
*
* @query userId - Filter by target user ID
* @query moderatorId - Filter by moderator ID
* @query type - Filter by case type (warn, timeout, kick, ban, note, prune)
* @query active - Filter by active status (true/false)
* @query limit - Max results (default: 50)
* @query offset - Pagination offset (default: 0)
*
* @response 200 - `{ cases: ModerationCase[] }`
* @response 500 - Error fetching cases
*
* @example
* // Request
* GET /api/moderation?type=warn&active=true&limit=10
*
* // Response
* {
* "cases": [
* {
* "id": "1",
* "caseId": "CASE-0001",
* "type": "warn",
* "userId": "123456789",
* "username": "User1",
* "moderatorId": "987654321",
* "moderatorName": "Mod1",
* "reason": "Spam",
* "active": true,
* "createdAt": "2024-01-15T12:00:00Z"
* }
* ]
* }
*/
if (pathname === "/api/moderation" && method === "GET") {
return withErrorHandling(async () => {
const filter: any = {};
if (url.searchParams.get("userId")) filter.userId = url.searchParams.get("userId");
if (url.searchParams.get("moderatorId")) filter.moderatorId = url.searchParams.get("moderatorId");
if (url.searchParams.get("type")) filter.type = url.searchParams.get("type");
const activeParam = url.searchParams.get("active");
if (activeParam !== null) filter.active = activeParam === "true";
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
const cases = await moderationService.searchCases(filter);
return jsonResponse({ cases });
}, "fetch moderation cases");
}
/**
* @route GET /api/moderation/:caseId
* @description Returns a single moderation case by case ID.
* Case IDs follow the format CASE-XXXX (e.g., CASE-0001).
*
* @param caseId - Case ID in CASE-XXXX format
* @response 200 - Full case object
* @response 404 - Case not found
* @response 500 - Error fetching case
*/
if (pathname.match(/^\/api\/moderation\/CASE-\d+$/i) && method === "GET") {
const caseId = pathname.split("/").pop()!.toUpperCase();
return withErrorHandling(async () => {
const moderationCase = await moderationService.getCaseById(caseId);
if (!moderationCase) {
return errorResponse("Case not found", 404);
}
return jsonResponse(moderationCase);
}, "fetch moderation case");
}
/**
* @route POST /api/moderation
* @description Creates a new moderation case.
*
* @body {
* type: "warn" | "timeout" | "kick" | "ban" | "note" | "prune" (required),
* userId: string (required) - Target user's Discord ID,
* username: string (required) - Target user's username,
* moderatorId: string (required) - Moderator's Discord ID,
* moderatorName: string (required) - Moderator's username,
* reason: string (required) - Reason for the action,
* metadata?: object - Additional case metadata (e.g., duration)
* }
* @response 201 - `{ success: true, case: ModerationCase }`
* @response 400 - Missing required fields
* @response 500 - Error creating case
*
* @example
* // Request
* POST /api/moderation
* {
* "type": "warn",
* "userId": "123456789",
* "username": "User1",
* "moderatorId": "987654321",
* "moderatorName": "Mod1",
* "reason": "Rule violation",
* "metadata": { "duration": "24h" }
* }
*/
if (pathname === "/api/moderation" && method === "POST") {
return withErrorHandling(async () => {
const data = await req.json() as Record<string, any>;
if (!data.type || !data.userId || !data.username || !data.moderatorId || !data.moderatorName || !data.reason) {
return errorResponse(
"Missing required fields: type, userId, username, moderatorId, moderatorName, reason",
400
);
}
const newCase = await moderationService.createCase({
type: data.type,
userId: data.userId,
username: data.username,
moderatorId: data.moderatorId,
moderatorName: data.moderatorName,
reason: data.reason,
metadata: data.metadata || {},
});
return jsonResponse({ success: true, case: newCase }, 201);
}, "create moderation case");
}
/**
* @route PUT /api/moderation/:caseId/clear
* @description Clears/resolves a moderation case.
* Sets the case as inactive and records who cleared it.
*
* @param caseId - Case ID in CASE-XXXX format
* @body {
* clearedBy: string (required) - Discord ID of user clearing the case,
* clearedByName: string (required) - Username of user clearing the case,
* reason?: string - Reason for clearing (default: "Cleared via API")
* }
* @response 200 - `{ success: true, case: ModerationCase }`
* @response 400 - Missing required fields
* @response 404 - Case not found
* @response 500 - Error clearing case
*
* @example
* // Request
* PUT /api/moderation/CASE-0001/clear
* { "clearedBy": "987654321", "clearedByName": "Admin1", "reason": "Appeal accepted" }
*/
if (pathname.match(/^\/api\/moderation\/CASE-\d+\/clear$/i) && method === "PUT") {
const caseId = (pathname.split("/")[3] || "").toUpperCase();
return withErrorHandling(async () => {
const data = await req.json() as Record<string, any>;
if (!data.clearedBy || !data.clearedByName) {
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
}
const updatedCase = await moderationService.clearCase({
caseId,
clearedBy: data.clearedBy,
clearedByName: data.clearedByName,
reason: data.reason || "Cleared via API",
});
if (!updatedCase) {
return errorResponse("Case not found", 404);
}
return jsonResponse({ success: true, case: updatedCase });
}, "clear moderation case");
}
return null;
}
export const moderationRoutes: RouteModule = {
name: "moderation",
handler
};

View File

@@ -0,0 +1,207 @@
/**
* @fileoverview Quest management endpoints for Aurora API.
* Provides CRUD operations for game quests.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, parseIdFromPath, withErrorHandling } from "./utils";
import { CreateQuestSchema, UpdateQuestSchema } from "@shared/modules/quest/quest.types";
/**
* Quest routes handler.
*
* Endpoints:
* - GET /api/quests - List all quests
* - POST /api/quests - Create a new quest
* - PUT /api/quests/:id - Update an existing quest
* - DELETE /api/quests/:id - Delete a quest
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req } = ctx;
// Only handle requests to /api/quests*
if (!pathname.startsWith("/api/quests")) {
return null;
}
const { questService } = await import("@shared/modules/quest/quest.service");
/**
* @route GET /api/quests
* @description Returns all quests in the system.
* @response 200 - `{ success: true, data: Quest[] }`
* @response 500 - Error fetching quests
*
* @example
* // Response
* {
* "success": true,
* "data": [
* {
* "id": 1,
* "name": "Daily Login",
* "description": "Login once to claim",
* "triggerEvent": "login",
* "requirements": { "target": 1 },
* "rewards": { "xp": 50, "balance": 100 }
* }
* ]
* }
*/
if (pathname === "/api/quests" && method === "GET") {
return withErrorHandling(async () => {
const quests = await questService.getAllQuests();
return jsonResponse({
success: true,
data: quests.map(q => ({
id: q.id,
name: q.name,
description: q.description,
triggerEvent: q.triggerEvent,
requirements: q.requirements,
rewards: q.rewards,
})),
});
}, "fetch quests");
}
/**
* @route POST /api/quests
* @description Creates a new quest.
*
* @body {
* name: string,
* description?: string,
* triggerEvent: string,
* target: number,
* xpReward: number,
* balanceReward: number
* }
* @response 200 - `{ success: true, quest: Quest }`
* @response 400 - Validation error
* @response 500 - Error creating quest
*
* @example
* // Request
* POST /api/quests
* {
* "name": "Win 5 Battles",
* "description": "Defeat 5 enemies in combat",
* "triggerEvent": "battle_win",
* "target": 5,
* "xpReward": 200,
* "balanceReward": 500
* }
*/
if (pathname === "/api/quests" && method === "POST") {
return withErrorHandling(async () => {
const rawData = await req.json();
const parseResult = CreateQuestSchema.safeParse(rawData);
if (!parseResult.success) {
return Response.json({
error: "Invalid payload",
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
}, { status: 400 });
}
const data = parseResult.data;
const result = await questService.createQuest({
name: data.name,
description: data.description || "",
triggerEvent: data.triggerEvent,
requirements: { target: data.target },
rewards: {
xp: data.xpReward,
balance: data.balanceReward
}
});
return jsonResponse({ success: true, quest: result[0] });
}, "create quest");
}
/**
* @route PUT /api/quests/:id
* @description Updates an existing quest by ID.
*
* @param id - Quest ID (numeric)
* @body Partial quest fields to update
* @response 200 - `{ success: true, quest: Quest }`
* @response 400 - Invalid quest ID or validation error
* @response 404 - Quest not found
* @response 500 - Error updating quest
*/
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "PUT") {
const id = parseIdFromPath(pathname);
if (!id) {
return errorResponse("Invalid quest ID", 400);
}
return withErrorHandling(async () => {
const rawData = await req.json();
const parseResult = UpdateQuestSchema.safeParse(rawData);
if (!parseResult.success) {
return Response.json({
error: "Invalid payload",
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
}, { status: 400 });
}
const data = parseResult.data;
const result = await questService.updateQuest(id, {
...(data.name !== undefined && { name: data.name }),
...(data.description !== undefined && { description: data.description }),
...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }),
...(data.target !== undefined && { requirements: { target: data.target } }),
...((data.xpReward !== undefined || data.balanceReward !== undefined) && {
rewards: {
xp: data.xpReward ?? 0,
balance: data.balanceReward ?? 0
}
})
});
if (!result || result.length === 0) {
return errorResponse("Quest not found", 404);
}
return jsonResponse({ success: true, quest: result[0] });
}, "update quest");
}
/**
* @route DELETE /api/quests/:id
* @description Deletes a quest by ID.
*
* @param id - Quest ID (numeric)
* @response 200 - `{ success: true, deleted: number }`
* @response 400 - Invalid quest ID
* @response 404 - Quest not found
* @response 500 - Error deleting quest
*/
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "DELETE") {
const id = parseIdFromPath(pathname);
if (!id) {
return errorResponse("Invalid quest ID", 400);
}
return withErrorHandling(async () => {
const result = await questService.deleteQuest(id);
if (!result || result.length === 0) {
return errorResponse("Quest not found", 404);
}
return jsonResponse({ success: true, deleted: (result[0] as { id: number }).id });
}, "delete quest");
}
return null;
}
export const questsRoutes: RouteModule = {
name: "quests",
handler
};

274
api/src/routes/schemas.ts Normal file
View File

@@ -0,0 +1,274 @@
/**
* @fileoverview Centralized Zod validation schemas for all Aurora API endpoints.
* Provides type-safe request/response validation for every entity in the system.
*/
import { z } from "zod";
// ============================================================================
// Common Schemas
// ============================================================================
/**
* Standard pagination query parameters.
*/
export const PaginationSchema = z.object({
limit: z.coerce.number().min(1).max(100).optional().default(50),
offset: z.coerce.number().min(0).optional().default(0),
});
/**
* Numeric ID parameter validation.
*/
export const NumericIdSchema = z.coerce.number().int().positive();
/**
* Discord snowflake ID validation (string of digits).
*/
export const SnowflakeIdSchema = z.string().regex(/^\d{17,20}$/, "Invalid Discord ID format");
// ============================================================================
// Items Schemas
// ============================================================================
/**
* Valid item types in the system.
*/
export const ItemTypeEnum = z.enum([
"CONSUMABLE",
"EQUIPMENT",
"MATERIAL",
"LOOTBOX",
"COLLECTIBLE",
"KEY",
"TOOL"
]);
/**
* Valid item rarities.
*/
export const ItemRarityEnum = z.enum(["C", "R", "SR", "SSR"]);
/**
* Query parameters for listing items.
*/
export const ItemQuerySchema = PaginationSchema.extend({
search: z.string().optional(),
type: z.string().optional(),
rarity: z.string().optional(),
});
/**
* Schema for creating a new item.
*/
export const CreateItemSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
description: z.string().max(500).nullable().optional(),
rarity: ItemRarityEnum.optional().default("C"),
type: ItemTypeEnum,
price: z.union([z.string(), z.number()]).nullable().optional(),
iconUrl: z.string().optional(),
imageUrl: z.string().optional(),
usageData: z.any().nullable().optional(),
});
/**
* Schema for updating an existing item.
*/
export const UpdateItemSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
rarity: ItemRarityEnum.optional(),
type: ItemTypeEnum.optional(),
price: z.union([z.string(), z.number()]).nullable().optional(),
iconUrl: z.string().optional(),
imageUrl: z.string().optional(),
usageData: z.any().nullable().optional(),
});
// ============================================================================
// Users Schemas
// ============================================================================
/**
* Query parameters for listing users.
*/
export const UserQuerySchema = PaginationSchema.extend({
search: z.string().optional(),
sortBy: z.enum(["balance", "level", "xp", "username"]).optional().default("balance"),
sortOrder: z.enum(["asc", "desc"]).optional().default("desc"),
});
/**
* Schema for updating a user.
*/
export const UpdateUserSchema = z.object({
username: z.string().min(1).max(32).optional(),
balance: z.union([z.string(), z.number()]).optional(),
xp: z.union([z.string(), z.number()]).optional(),
level: z.coerce.number().int().min(0).optional(),
dailyStreak: z.coerce.number().int().min(0).optional(),
isActive: z.boolean().optional(),
settings: z.record(z.string(), z.any()).optional(),
classId: z.union([z.string(), z.number()]).optional(),
});
/**
* Schema for adding an item to user inventory.
*/
export const InventoryAddSchema = z.object({
itemId: z.coerce.number().int().positive("Item ID is required"),
quantity: z.union([z.string(), z.number()]).refine(
(val) => BigInt(val) > 0n,
"Quantity must be positive"
),
});
/**
* Query params for removing inventory items.
*/
export const InventoryRemoveQuerySchema = z.object({
amount: z.coerce.number().int().min(1).optional().default(1),
});
// ============================================================================
// Classes Schemas
// ============================================================================
/**
* Schema for creating a new class.
*/
export const CreateClassSchema = z.object({
id: z.union([z.string(), z.number()]),
name: z.string().min(1, "Name is required").max(50),
balance: z.union([z.string(), z.number()]).optional().default("0"),
roleId: z.string().nullable().optional(),
});
/**
* Schema for updating a class.
*/
export const UpdateClassSchema = z.object({
name: z.string().min(1).max(50).optional(),
balance: z.union([z.string(), z.number()]).optional(),
roleId: z.string().nullable().optional(),
});
// ============================================================================
// Moderation Schemas
// ============================================================================
/**
* Valid moderation case types.
*/
export const ModerationTypeEnum = z.enum([
"warn",
"timeout",
"kick",
"ban",
"note",
"prune"
]);
/**
* Query parameters for searching moderation cases.
*/
export const CaseQuerySchema = PaginationSchema.extend({
userId: z.string().optional(),
moderatorId: z.string().optional(),
type: ModerationTypeEnum.optional(),
active: z.preprocess(
(val) => val === "true" ? true : val === "false" ? false : undefined,
z.boolean().optional()
),
});
/**
* Schema for creating a moderation case.
*/
export const CreateCaseSchema = z.object({
type: ModerationTypeEnum,
userId: z.string().min(1, "User ID is required"),
username: z.string().min(1, "Username is required"),
moderatorId: z.string().min(1, "Moderator ID is required"),
moderatorName: z.string().min(1, "Moderator name is required"),
reason: z.string().min(1, "Reason is required").max(1000),
metadata: z.record(z.string(), z.any()).optional().default({}),
});
/**
* Schema for clearing/resolving a moderation case.
*/
export const ClearCaseSchema = z.object({
clearedBy: z.string().min(1, "Cleared by ID is required"),
clearedByName: z.string().min(1, "Cleared by name is required"),
reason: z.string().max(500).optional().default("Cleared via API"),
});
/**
* Case ID pattern validation (CASE-XXXX format).
*/
export const CaseIdPattern = /^CASE-\d+$/i;
// ============================================================================
// Transactions Schemas
// ============================================================================
/**
* Query parameters for listing transactions.
*/
export const TransactionQuerySchema = PaginationSchema.extend({
userId: z.string().optional(),
type: z.string().optional(),
});
// ============================================================================
// Lootdrops Schemas
// ============================================================================
/**
* Query parameters for listing lootdrops.
*/
export const LootdropQuerySchema = z.object({
limit: z.coerce.number().min(1).max(100).optional().default(50),
});
/**
* Schema for spawning a lootdrop.
*/
export const CreateLootdropSchema = z.object({
channelId: z.string().min(1, "Channel ID is required"),
amount: z.coerce.number().int().positive().optional(),
currency: z.string().optional(),
});
// ============================================================================
// Admin Actions Schemas
// ============================================================================
/**
* Schema for toggling maintenance mode.
*/
export const MaintenanceModeSchema = z.object({
enabled: z.boolean(),
reason: z.string().max(200).optional(),
});
// ============================================================================
// Type Exports
// ============================================================================
export type ItemQuery = z.infer<typeof ItemQuerySchema>;
export type CreateItem = z.infer<typeof CreateItemSchema>;
export type UpdateItem = z.infer<typeof UpdateItemSchema>;
export type UserQuery = z.infer<typeof UserQuerySchema>;
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
export type InventoryAdd = z.infer<typeof InventoryAddSchema>;
export type CreateClass = z.infer<typeof CreateClassSchema>;
export type UpdateClass = z.infer<typeof UpdateClassSchema>;
export type CaseQuery = z.infer<typeof CaseQuerySchema>;
export type CreateCase = z.infer<typeof CreateCaseSchema>;
export type ClearCase = z.infer<typeof ClearCaseSchema>;
export type TransactionQuery = z.infer<typeof TransactionQuerySchema>;
export type CreateLootdrop = z.infer<typeof CreateLootdropSchema>;
export type MaintenanceMode = z.infer<typeof MaintenanceModeSchema>;

View File

@@ -0,0 +1,152 @@
/**
* @fileoverview Bot settings endpoints for Aurora API.
* Provides endpoints for reading and updating bot configuration,
* as well as fetching Discord metadata.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
/**
* Settings routes handler.
*
* Endpoints:
* - GET /api/settings - Get current bot configuration
* - POST /api/settings - Update bot configuration (partial merge)
* - GET /api/settings/meta - Get Discord metadata (roles, channels, commands)
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req } = ctx;
// Only handle requests to /api/settings*
if (!pathname.startsWith("/api/settings")) {
return null;
}
/**
* @route GET /api/settings
* @description Returns the current bot configuration from database.
* Configuration includes economy settings, leveling settings,
* command toggles, and other system settings.
* @response 200 - Full configuration object (DB format with strings for BigInts)
* @response 500 - Error fetching settings
*
* @example
* // Response
* {
* "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
* "leveling": { "base": 100, "exponent": 1.5 },
* "commands": { "disabled": [], "channelLocks": {} }
* }
*/
if (pathname === "/api/settings" && method === "GET") {
return withErrorHandling(async () => {
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
const settings = await gameSettingsService.getSettings();
if (!settings) {
// Return defaults if no settings in DB yet
return jsonResponse(gameSettingsService.getDefaults());
}
return jsonResponse(settings);
}, "fetch settings");
}
/**
* @route POST /api/settings
* @description Updates bot configuration with partial merge.
* Only the provided fields will be updated; other settings remain unchanged.
* After updating, commands are automatically reloaded.
*
* @body Partial configuration object (DB format with strings for BigInts)
* @response 200 - `{ success: true }`
* @response 400 - Validation error
* @response 500 - Error saving settings
*
* @example
* // Request - Only update economy daily reward
* POST /api/settings
* { "economy": { "daily": { "amount": "150" } } }
*/
if (pathname === "/api/settings" && method === "POST") {
try {
const partialConfig = await req.json() as Record<string, unknown>;
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
// Use upsertSettings to merge partial update
await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
return jsonResponse({ success: true });
} catch (error) {
// Return 400 for validation errors
const message = error instanceof Error ? error.message : String(error);
return errorResponse("Failed to save settings", 400, message);
}
}
/**
* @route GET /api/settings/meta
* @description Returns Discord server metadata for settings UI.
* Provides lists of roles, channels, and registered commands.
*
* @response 200 - `{ roles: Role[], channels: Channel[], commands: Command[] }`
* @response 500 - Error fetching metadata
*
* @example
* // Response
* {
* "roles": [
* { "id": "123456789", "name": "Admin", "color": "#FF0000" }
* ],
* "channels": [
* { "id": "987654321", "name": "general", "type": 0 }
* ],
* "commands": [
* { "name": "daily", "category": "economy" }
* ]
* }
*/
if (pathname === "/api/settings/meta" && method === "GET") {
return withErrorHandling(async () => {
const { AuroraClient } = await import("../../../bot/lib/BotClient");
const { env } = await import("@shared/lib/env");
if (!env.DISCORD_GUILD_ID) {
return jsonResponse({ roles: [], channels: [], commands: [] });
}
const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID);
if (!guild) {
return jsonResponse({ roles: [], channels: [], commands: [] });
}
// Map roles and channels to a simplified format
const roles = guild.roles.cache
.sort((a, b) => b.position - a.position)
.map(r => ({ id: r.id, name: r.name, color: r.hexColor }));
const channels = guild.channels.cache
.map(c => ({ id: c.id, name: c.name, type: c.type }));
const commands = Array.from(AuroraClient.knownCommands.entries())
.map(([name, category]) => ({ name, category }))
.sort((a, b) => {
if (a.category !== b.category) return a.category.localeCompare(b.category);
return a.name.localeCompare(b.name);
});
return jsonResponse({ roles, channels, commands });
}, "fetch settings meta");
}
return null;
}
export const settingsRoutes: RouteModule = {
name: "settings",
handler
};

View File

@@ -0,0 +1,94 @@
/**
* @fileoverview Dashboard stats helper for Aurora API.
* Provides the getFullDashboardStats function used by stats routes.
*/
import { logger } from "@shared/lib/logger";
/**
* Fetches comprehensive dashboard statistics.
* Aggregates data from multiple services with error isolation.
*
* @returns Full dashboard stats object including bot info, user counts,
* economy data, leaderboards, and system status.
*/
export async function getFullDashboardStats() {
// Import services (dynamic to avoid circular deps)
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const { getClientStats } = await import("../../../bot/lib/clientStats");
// Fetch all data in parallel with error isolation
const results = await Promise.allSettled([
Promise.resolve(getClientStats()),
dashboardService.getActiveUserCount(),
dashboardService.getTotalUserCount(),
dashboardService.getEconomyStats(),
dashboardService.getRecentEvents(10),
dashboardService.getTotalItems(),
dashboardService.getActiveLootdrops(),
dashboardService.getLeaderboards(),
Promise.resolve(lootdropService.getLootdropState()),
]);
// Helper to unwrap result or return default
const unwrap = <T>(result: PromiseSettledResult<T>, defaultValue: T, name: string): T => {
if (result.status === 'fulfilled') return result.value;
logger.error("web", `Failed to fetch ${name}`, result.reason);
return defaultValue;
};
const clientStats = unwrap(results[0], {
bot: { name: 'Aurora', avatarUrl: null, status: null },
guilds: 0,
commandsRegistered: 0,
commandsKnown: 0,
cachedUsers: 0,
ping: 0,
uptime: 0,
lastCommandTimestamp: null
}, 'clientStats');
const activeUsers = unwrap(results[1], 0, 'activeUsers');
const totalUsers = unwrap(results[2], 0, 'totalUsers');
const economyStats = unwrap(results[3], { totalWealth: 0n, avgLevel: 0, topStreak: 0 }, 'economyStats');
const recentEvents = unwrap(results[4], [], 'recentEvents');
const totalItems = unwrap(results[5], 0, 'totalItems');
const activeLootdrops = unwrap(results[6], [], 'activeLootdrops');
const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [], topNetWorth: [] }, 'leaderboards');
const lootdropState = unwrap(results[8], undefined, 'lootdropState');
return {
bot: clientStats.bot,
guilds: { count: clientStats.guilds },
users: { active: activeUsers, total: totalUsers },
commands: {
total: clientStats.commandsKnown,
active: clientStats.commandsRegistered,
disabled: clientStats.commandsKnown - clientStats.commandsRegistered
},
ping: { avg: clientStats.ping },
economy: {
totalWealth: economyStats.totalWealth.toString(),
avgLevel: economyStats.avgLevel,
topStreak: economyStats.topStreak,
totalItems,
},
recentEvents: recentEvents.map(event => ({
...event,
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
})),
activeLootdrops: activeLootdrops.map(drop => ({
rewardAmount: drop.rewardAmount,
currency: drop.currency,
createdAt: drop.createdAt.toISOString(),
expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null,
// Explicitly excluding channelId/messageId to prevent sniping
})),
lootdropState,
leaderboards,
uptime: clientStats.uptime,
lastCommandTimestamp: clientStats.lastCommandTimestamp,
maintenanceMode: (await import("../../../bot/lib/BotClient")).AuroraClient.maintenanceMode,
};
}

View File

@@ -0,0 +1,85 @@
/**
* @fileoverview Statistics endpoints for Aurora API.
* Provides dashboard statistics and activity aggregation data.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
// Cache for activity stats (heavy aggregation)
let activityPromise: Promise<import("@shared/modules/dashboard/dashboard.types").ActivityData[]> | null = null;
let lastActivityFetch: number = 0;
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Stats routes handler.
*
* Endpoints:
* - GET /api/stats - Full dashboard statistics
* - GET /api/stats/activity - Activity aggregation with caching
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method } = ctx;
/**
* @route GET /api/stats
* @description Returns comprehensive dashboard statistics including
* bot info, user counts, economy data, and leaderboards.
* @response 200 - Full dashboard stats object
* @response 500 - Error fetching statistics
*/
if (pathname === "/api/stats" && method === "GET") {
return withErrorHandling(async () => {
// Import the stats function from wherever it's defined
// This will be passed in during initialization
const { getFullDashboardStats } = await import("./stats.helper.ts");
const stats = await getFullDashboardStats();
return jsonResponse(stats);
}, "fetch dashboard stats");
}
/**
* @route GET /api/stats/activity
* @description Returns activity aggregation data with 5-minute caching.
* Heavy query, results are cached to reduce database load.
* @response 200 - Array of activity data points
* @response 500 - Error fetching activity statistics
*
* @example
* // Response
* [
* { "date": "2024-02-08", "commands": 150, "users": 25 },
* { "date": "2024-02-07", "commands": 200, "users": 30 }
* ]
*/
if (pathname === "/api/stats/activity" && method === "GET") {
return withErrorHandling(async () => {
const now = Date.now();
// If we have a valid cache, return it
if (activityPromise && (now - lastActivityFetch < ACTIVITY_CACHE_TTL)) {
const data = await activityPromise;
return jsonResponse(data);
}
// Otherwise, trigger a new fetch (deduplicated by the promise)
if (!activityPromise || (now - lastActivityFetch >= ACTIVITY_CACHE_TTL)) {
activityPromise = (async () => {
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
return await dashboardService.getActivityAggregation();
})();
lastActivityFetch = now;
}
const activity = await activityPromise;
return jsonResponse(activity);
}, "fetch activity stats");
}
return null;
}
export const statsRoutes: RouteModule = {
name: "stats",
handler
};

View File

@@ -0,0 +1,91 @@
/**
* @fileoverview Transaction listing endpoints for Aurora API.
* Provides read access to economy transaction history.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, withErrorHandling } from "./utils";
/**
* Transactions routes handler.
*
* Endpoints:
* - GET /api/transactions - List transactions with filters
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, url } = ctx;
/**
* @route GET /api/transactions
* @description Returns economy transactions with optional filtering.
*
* @query userId - Filter by user ID (Discord snowflake)
* @query type - Filter by transaction type
* @query limit - Max results (default: 50)
* @query offset - Pagination offset (default: 0)
*
* @response 200 - `{ transactions: Transaction[] }`
* @response 500 - Error fetching transactions
*
* Transaction Types:
* - DAILY_REWARD - Daily claim reward
* - TRANSFER_IN - Received from another user
* - TRANSFER_OUT - Sent to another user
* - LOOTDROP_CLAIM - Claimed lootdrop
* - SHOP_BUY - Item purchase
* - QUEST_REWARD - Quest completion reward
*
* @example
* // Request
* GET /api/transactions?userId=123456789&type=DAILY_REWARD&limit=10
*
* // Response
* {
* "transactions": [
* {
* "id": "1",
* "userId": "123456789",
* "amount": "100",
* "type": "DAILY_REWARD",
* "description": "Daily reward (Streak: 3)",
* "createdAt": "2024-01-15T12:00:00Z"
* }
* ]
* }
*/
if (pathname === "/api/transactions" && method === "GET") {
return withErrorHandling(async () => {
const { transactions } = await import("@shared/db/schema");
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
const { eq, desc } = await import("drizzle-orm");
const userId = url.searchParams.get("userId");
const type = url.searchParams.get("type");
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
let query = DrizzleClient.select().from(transactions);
if (userId) {
query = query.where(eq(transactions.userId, BigInt(userId))) as typeof query;
}
if (type) {
query = query.where(eq(transactions.type, type)) as typeof query;
}
const result = await query
.orderBy(desc(transactions.createdAt))
.limit(limit)
.offset(offset);
return jsonResponse({ transactions: result });
}, "fetch transactions");
}
return null;
}
export const transactionsRoutes: RouteModule = {
name: "transactions",
handler
};

94
api/src/routes/types.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* @fileoverview Shared types for the Aurora API routing system.
* Provides type definitions for route handlers, responses, and errors.
*/
/**
* Standard API error response structure.
*/
export interface ApiErrorResponse {
error: string;
details?: string;
issues?: Array<{ path: (string | number)[]; message: string }>;
}
/**
* Standard API success response with optional data wrapper.
*/
export interface ApiSuccessResponse<T = unknown> {
success: true;
[key: string]: T | true;
}
/**
* Route context passed to all route handlers.
* Contains parsed URL information and the original request.
*/
export interface RouteContext {
/** The original HTTP request */
req: Request;
/** Parsed URL object */
url: URL;
/** HTTP method (GET, POST, PUT, DELETE, etc.) */
method: string;
/** URL pathname without query string */
pathname: string;
}
/**
* A route handler function that processes a request and returns a response.
* Returns null if the route doesn't match, allowing the next handler to try.
*/
export type RouteHandler = (ctx: RouteContext) => Promise<Response | null> | Response | null;
/**
* A route module that exports a handler function.
*/
export interface RouteModule {
/** Human-readable name for debugging */
name: string;
/** The route handler function */
handler: RouteHandler;
}
/**
* Custom API error class with HTTP status code support.
*/
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number = 500,
public readonly details?: string
) {
super(message);
this.name = 'ApiError';
}
/**
* Creates a 400 Bad Request error.
*/
static badRequest(message: string, details?: string): ApiError {
return new ApiError(message, 400, details);
}
/**
* Creates a 404 Not Found error.
*/
static notFound(resource: string): ApiError {
return new ApiError(`${resource} not found`, 404);
}
/**
* Creates a 409 Conflict error.
*/
static conflict(message: string): ApiError {
return new ApiError(message, 409);
}
/**
* Creates a 500 Internal Server Error.
*/
static internal(message: string, details?: string): ApiError {
return new ApiError(message, 500, details);
}
}

View File

@@ -0,0 +1,263 @@
/**
* @fileoverview User management endpoints for Aurora API.
* Provides CRUD operations for users and user inventory.
*/
import type { RouteContext, RouteModule } from "./types";
import {
jsonResponse,
errorResponse,
parseBody,
parseIdFromPath,
parseStringIdFromPath,
withErrorHandling
} from "./utils";
import { UpdateUserSchema, InventoryAddSchema } from "./schemas";
/**
* Users routes handler.
*
* Endpoints:
* - GET /api/users - List users with filters
* - GET /api/users/:id - Get single user
* - PUT /api/users/:id - Update user
* - GET /api/users/:id/inventory - Get user inventory
* - POST /api/users/:id/inventory - Add item to inventory
* - DELETE /api/users/:id/inventory/:itemId - Remove item from inventory
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req, url } = ctx;
// Only handle requests to /api/users*
if (!pathname.startsWith("/api/users")) {
return null;
}
/**
* @route GET /api/users
* @description Returns a paginated list of users with optional filtering and sorting.
*
* @query search - Filter by username (partial match)
* @query sortBy - Sort field: balance, level, xp, username (default: balance)
* @query sortOrder - Sort direction: asc, desc (default: desc)
* @query limit - Max results (default: 50)
* @query offset - Pagination offset (default: 0)
*
* @response 200 - `{ users: User[], total: number }`
* @response 500 - Error fetching users
*
* @example
* // Request
* GET /api/users?sortBy=level&sortOrder=desc&limit=10
*/
if (pathname === "/api/users" && method === "GET") {
return withErrorHandling(async () => {
const { users } = await import("@shared/db/schema");
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
const { ilike, desc, asc, sql } = await import("drizzle-orm");
const search = url.searchParams.get("search") || undefined;
const sortBy = url.searchParams.get("sortBy") || "balance";
const sortOrder = url.searchParams.get("sortOrder") || "desc";
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
let query = DrizzleClient.select().from(users);
if (search) {
query = query.where(ilike(users.username, `%${search}%`)) as typeof query;
}
const sortColumn = sortBy === "level" ? users.level :
sortBy === "xp" ? users.xp :
sortBy === "username" ? users.username : users.balance;
const orderFn = sortOrder === "asc" ? asc : desc;
const result = await query
.orderBy(orderFn(sortColumn))
.limit(limit)
.offset(offset);
const countResult = await DrizzleClient.select({ count: sql<number>`count(*)` }).from(users);
const total = Number(countResult[0]?.count || 0);
return jsonResponse({ users: result, total });
}, "fetch users");
}
/**
* @route GET /api/users/:id
* @description Returns a single user by Discord ID.
* Includes related class information if the user has a class assigned.
*
* @param id - Discord User ID (snowflake)
* @response 200 - Full user object with class relation
* @response 404 - User not found
* @response 500 - Error fetching user
*
* @example
* // Response
* {
* "id": "123456789012345678",
* "username": "Player1",
* "balance": "1000",
* "level": 5,
* "class": { "id": "1", "name": "Warrior" }
* }
*/
if (pathname.match(/^\/api\/users\/\d+$/) && method === "GET") {
const id = parseStringIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const { userService } = await import("@shared/modules/user/user.service");
const user = await userService.getUserById(id);
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse(user);
}, "fetch user");
}
/**
* @route PUT /api/users/:id
* @description Updates user fields. Only provided fields will be updated.
*
* @param id - Discord User ID (snowflake)
* @body {
* username?: string,
* balance?: string | number,
* xp?: string | number,
* level?: number,
* dailyStreak?: number,
* isActive?: boolean,
* settings?: object,
* classId?: string | number
* }
* @response 200 - `{ success: true, user: User }`
* @response 404 - User not found
* @response 500 - Error updating user
*/
if (pathname.match(/^\/api\/users\/\d+$/) && method === "PUT") {
const id = parseStringIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const { userService } = await import("@shared/modules/user/user.service");
const data = await req.json() as Record<string, any>;
const existing = await userService.getUserById(id);
if (!existing) {
return errorResponse("User not found", 404);
}
// Build update data (only allow safe fields)
const updateData: any = {};
if (data.username !== undefined) updateData.username = data.username;
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
if (data.xp !== undefined) updateData.xp = BigInt(data.xp);
if (data.level !== undefined) updateData.level = parseInt(data.level);
if (data.dailyStreak !== undefined) updateData.dailyStreak = parseInt(data.dailyStreak);
if (data.isActive !== undefined) updateData.isActive = Boolean(data.isActive);
if (data.settings !== undefined) updateData.settings = data.settings;
if (data.classId !== undefined) updateData.classId = BigInt(data.classId);
const updatedUser = await userService.updateUser(id, updateData);
return jsonResponse({ success: true, user: updatedUser });
}, "update user");
}
/**
* @route GET /api/users/:id/inventory
* @description Returns user's inventory with item details.
*
* @param id - Discord User ID (snowflake)
* @response 200 - `{ inventory: InventoryEntry[] }`
* @response 500 - Error fetching inventory
*
* @example
* // Response
* {
* "inventory": [
* {
* "userId": "123456789",
* "itemId": 1,
* "quantity": "5",
* "item": { "id": 1, "name": "Health Potion", ... }
* }
* ]
* }
*/
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "GET") {
const id = pathname.split("/")[3] || "0";
return withErrorHandling(async () => {
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
const inventory = await inventoryService.getInventory(id);
return jsonResponse({ inventory });
}, "fetch inventory");
}
/**
* @route POST /api/users/:id/inventory
* @description Adds an item to user's inventory.
*
* @param id - Discord User ID (snowflake)
* @body { itemId: number, quantity: string | number }
* @response 201 - `{ success: true, entry: InventoryEntry }`
* @response 400 - Missing required fields
* @response 500 - Error adding item
*/
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "POST") {
const id = pathname.split("/")[3] || "0";
return withErrorHandling(async () => {
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
const data = await req.json() as Record<string, any>;
if (!data.itemId || !data.quantity) {
return errorResponse("Missing required fields: itemId, quantity", 400);
}
const entry = await inventoryService.addItem(id, data.itemId, BigInt(data.quantity));
return jsonResponse({ success: true, entry }, 201);
}, "add item to inventory");
}
/**
* @route DELETE /api/users/:id/inventory/:itemId
* @description Removes an item from user's inventory.
*
* @param id - Discord User ID (snowflake)
* @param itemId - Item ID to remove
* @query amount - Quantity to remove (default: 1)
* @response 204 - Item removed (no content)
* @response 500 - Error removing item
*/
if (pathname.match(/^\/api\/users\/\d+\/inventory\/\d+$/) && method === "DELETE") {
const parts = pathname.split("/");
const userId = parts[3] || "";
const itemId = parseInt(parts[5] || "0");
if (!userId) return null;
return withErrorHandling(async () => {
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
const amount = url.searchParams.get("amount");
const quantity = amount ? BigInt(amount) : 1n;
await inventoryService.removeItem(userId, itemId, quantity);
return new Response(null, { status: 204 });
}, "remove item from inventory");
}
return null;
}
export const usersRoutes: RouteModule = {
name: "users",
handler
};

213
api/src/routes/utils.ts Normal file
View File

@@ -0,0 +1,213 @@
/**
* @fileoverview Utility functions for Aurora API route handlers.
* Provides helpers for response formatting, parameter parsing, and validation.
*/
import { z, ZodError, type ZodSchema } from "zod";
import type { ApiErrorResponse } from "./types";
/**
* JSON replacer function that handles BigInt serialization.
* Converts BigInt values to strings for JSON compatibility.
*/
export function jsonReplacer(_key: string, value: unknown): unknown {
return typeof value === "bigint" ? value.toString() : value;
}
/**
* Creates a JSON response with proper content-type header and BigInt handling.
*
* @param data - The data to serialize as JSON
* @param status - HTTP status code (default: 200)
* @returns A Response object with JSON content
*
* @example
* return jsonResponse({ items: [...], total: 10 });
* return jsonResponse({ success: true, item }, 201);
*/
export function jsonResponse<T>(data: T, status: number = 200): Response {
return new Response(JSON.stringify(data, jsonReplacer), {
status,
headers: { "Content-Type": "application/json" }
});
}
/**
* Creates a standardized error response.
*
* @param error - Error message
* @param status - HTTP status code (default: 500)
* @param details - Optional additional error details
* @returns A Response object with error JSON
*
* @example
* return errorResponse("Item not found", 404);
* return errorResponse("Validation failed", 400, "Name is required");
*/
export function errorResponse(
error: string,
status: number = 500,
details?: string
): Response {
const body: ApiErrorResponse = { error };
if (details) body.details = details;
return Response.json(body, { status });
}
/**
* Creates a validation error response from a ZodError.
*
* @param zodError - The ZodError from a failed parse
* @returns A 400 Response with validation issue details
*/
export function validationErrorResponse(zodError: ZodError): Response {
return Response.json(
{
error: "Invalid payload",
issues: zodError.issues.map(issue => ({
path: issue.path,
message: issue.message
}))
},
{ status: 400 }
);
}
/**
* Parses and validates a request body against a Zod schema.
*
* @param req - The HTTP request
* @param schema - Zod schema to validate against
* @returns Validated data or an error Response
*
* @example
* const result = await parseBody(req, CreateItemSchema);
* if (result instanceof Response) return result; // Validation failed
* const data = result; // Type-safe validated data
*/
export async function parseBody<T extends ZodSchema>(
req: Request,
schema: T
): Promise<z.infer<T> | Response> {
try {
const rawBody = await req.json();
const parsed = schema.safeParse(rawBody);
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
return parsed.data;
} catch (e) {
return errorResponse("Invalid JSON body", 400);
}
}
/**
* Parses query parameters against a Zod schema.
*
* @param url - The URL containing query parameters
* @param schema - Zod schema to validate against
* @returns Validated query params or an error Response
*/
export function parseQuery<T extends ZodSchema>(
url: URL,
schema: T
): z.infer<T> | Response {
const params: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
params[key] = value;
});
const parsed = schema.safeParse(params);
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
return parsed.data;
}
/**
* Extracts a numeric ID from a URL path segment.
*
* @param pathname - The URL pathname
* @param position - Position from the end (0 = last segment, 1 = second-to-last, etc.)
* @returns The parsed integer ID or null if invalid
*
* @example
* parseIdFromPath("/api/items/123") // returns 123
* parseIdFromPath("/api/items/abc") // returns null
* parseIdFromPath("/api/users/456/inventory", 1) // returns 456
*/
export function parseIdFromPath(pathname: string, position: number = 0): number | null {
const segments = pathname.split("/").filter(Boolean);
const segment = segments[segments.length - 1 - position];
if (!segment) return null;
const id = parseInt(segment, 10);
return isNaN(id) ? null : id;
}
/**
* Extracts a string ID (like Discord snowflake) from a URL path segment.
*
* @param pathname - The URL pathname
* @param position - Position from the end (0 = last segment)
* @returns The string ID or null if segment doesn't exist
*/
export function parseStringIdFromPath(pathname: string, position: number = 0): string | null {
const segments = pathname.split("/").filter(Boolean);
const segment = segments[segments.length - 1 - position];
return segment || null;
}
/**
* Checks if a pathname matches a pattern with optional parameter placeholders.
*
* @param pathname - The actual URL pathname
* @param pattern - The pattern to match (use :id for numeric params, :param for string params)
* @returns True if the pattern matches
*
* @example
* matchPath("/api/items/123", "/api/items/:id") // true
* matchPath("/api/items", "/api/items/:id") // false
*/
export function matchPath(pathname: string, pattern: string): boolean {
const pathParts = pathname.split("/").filter(Boolean);
const patternParts = pattern.split("/").filter(Boolean);
if (pathParts.length !== patternParts.length) return false;
return patternParts.every((part, i) => {
if (part.startsWith(":")) return true; // Matches any value
return part === pathParts[i];
});
}
/**
* Wraps an async route handler with consistent error handling.
* Catches all errors and returns appropriate error responses.
*
* @param handler - The async handler function
* @param logContext - Context string for error logging
* @returns A wrapped handler with error handling
*/
export function withErrorHandling(
handler: () => Promise<Response>,
logContext: string
): Promise<Response> {
return handler().catch((error: unknown) => {
// Dynamic import to avoid circular dependencies
return import("@shared/lib/logger").then(({ logger }) => {
logger.error("web", `Error in ${logContext}`, error);
return errorResponse(
`Failed to ${logContext.toLowerCase()}`,
500,
error instanceof Error ? error.message : String(error)
);
});
});
}

View File

@@ -0,0 +1,436 @@
import { describe, test, expect, afterAll, beforeAll, mock } from "bun:test";
import type { WebServerInstance } from "./server";
import { createWebServer } from "./server";
/**
* Items API Integration Tests
*
* Tests the full CRUD functionality for the Items management API.
* Uses mocked database and service layers.
*/
// --- Mock Types ---
interface MockItem {
id: number;
name: string;
description: string | null;
rarity: string;
type: string;
price: bigint | null;
iconUrl: string;
imageUrl: string;
usageData: { consume: boolean; effects: any[] } | null;
}
// --- Mock Data ---
let mockItems: MockItem[] = [
{
id: 1,
name: "Health Potion",
description: "Restores health",
rarity: "C",
type: "CONSUMABLE",
price: 100n,
iconUrl: "/assets/items/1.png",
imageUrl: "/assets/items/1.png",
usageData: { consume: true, effects: [] },
},
{
id: 2,
name: "Iron Sword",
description: "A basic sword",
rarity: "R",
type: "EQUIPMENT",
price: 500n,
iconUrl: "/assets/items/2.png",
imageUrl: "/assets/items/2.png",
usageData: null,
},
];
let mockIdCounter = 3;
// --- Mock Items Service ---
mock.module("@shared/modules/items/items.service", () => ({
itemsService: {
getAllItems: mock(async (filters: any = {}) => {
let filtered = [...mockItems];
if (filters.search) {
const search = filters.search.toLowerCase();
filtered = filtered.filter(
(item) =>
item.name.toLowerCase().includes(search) ||
(item.description?.toLowerCase().includes(search) ?? false)
);
}
if (filters.type) {
filtered = filtered.filter((item) => item.type === filters.type);
}
if (filters.rarity) {
filtered = filtered.filter((item) => item.rarity === filters.rarity);
}
return {
items: filtered,
total: filtered.length,
};
}),
getItemById: mock(async (id: number) => {
return mockItems.find((item) => item.id === id) ?? null;
}),
isNameTaken: mock(async (name: string, excludeId?: number) => {
return mockItems.some(
(item) =>
item.name.toLowerCase() === name.toLowerCase() &&
item.id !== excludeId
);
}),
createItem: mock(async (data: any) => {
const newItem: MockItem = {
id: mockIdCounter++,
name: data.name,
description: data.description ?? null,
rarity: data.rarity ?? "C",
type: data.type,
price: data.price ?? null,
iconUrl: data.iconUrl,
imageUrl: data.imageUrl,
usageData: data.usageData ?? null,
};
mockItems.push(newItem);
return newItem;
}),
updateItem: mock(async (id: number, data: any) => {
const index = mockItems.findIndex((item) => item.id === id);
if (index === -1) return null;
mockItems[index] = { ...mockItems[index], ...data };
return mockItems[index];
}),
deleteItem: mock(async (id: number) => {
const index = mockItems.findIndex((item) => item.id === id);
if (index === -1) return null;
const [deleted] = mockItems.splice(index, 1);
return deleted;
}),
},
}));
// --- Mock Utilities ---
mock.module("@shared/lib/utils", () => ({
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
jsonReplacer: (key: string, value: any) =>
typeof value === "bigint" ? value.toString() : value,
}));
// --- Mock Logger ---
mock.module("@shared/lib/logger", () => ({
logger: {
info: () => { },
warn: () => { },
error: () => { },
debug: () => { },
},
}));
describe("Items API", () => {
const port = 3002;
const hostname = "127.0.0.1";
const baseUrl = `http://${hostname}:${port}`;
let serverInstance: WebServerInstance | null = null;
beforeAll(async () => {
// Reset mock data before all tests
mockItems = [
{
id: 1,
name: "Health Potion",
description: "Restores health",
rarity: "C",
type: "CONSUMABLE",
price: 100n,
iconUrl: "/assets/items/1.png",
imageUrl: "/assets/items/1.png",
usageData: { consume: true, effects: [] },
},
{
id: 2,
name: "Iron Sword",
description: "A basic sword",
rarity: "R",
type: "EQUIPMENT",
price: 500n,
iconUrl: "/assets/items/2.png",
imageUrl: "/assets/items/2.png",
usageData: null,
},
];
mockIdCounter = 3;
serverInstance = await createWebServer({ port, hostname });
});
afterAll(async () => {
if (serverInstance) {
await serverInstance.stop();
}
});
// ===========================================
// GET /api/items Tests
// ===========================================
describe("GET /api/items", () => {
test("should return all items", async () => {
const response = await fetch(`${baseUrl}/api/items`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items).toBeInstanceOf(Array);
expect(data.total).toBeGreaterThanOrEqual(0);
});
test("should filter items by search query", async () => {
const response = await fetch(`${baseUrl}/api/items?search=potion`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items.every((item) =>
item.name.toLowerCase().includes("potion") ||
(item.description?.toLowerCase().includes("potion") ?? false)
)).toBe(true);
});
test("should filter items by type", async () => {
const response = await fetch(`${baseUrl}/api/items?type=CONSUMABLE`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items.every((item) => item.type === "CONSUMABLE")).toBe(true);
});
test("should filter items by rarity", async () => {
const response = await fetch(`${baseUrl}/api/items?rarity=C`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items.every((item) => item.rarity === "C")).toBe(true);
});
});
// ===========================================
// GET /api/items/:id Tests
// ===========================================
describe("GET /api/items/:id", () => {
test("should return a single item by ID", async () => {
const response = await fetch(`${baseUrl}/api/items/1`);
expect(response.status).toBe(200);
const data = (await response.json()) as MockItem;
expect(data.id).toBe(1);
expect(data.name).toBe("Health Potion");
});
test("should return 404 for non-existent item", async () => {
const response = await fetch(`${baseUrl}/api/items/9999`);
expect(response.status).toBe(404);
const data = (await response.json()) as { error: string };
expect(data.error).toBe("Item not found");
});
});
// ===========================================
// POST /api/items Tests
// ===========================================
describe("POST /api/items", () => {
test("should create a new item", async () => {
const newItem = {
name: "Magic Staff",
description: "A powerful staff",
rarity: "SR",
type: "EQUIPMENT",
price: "1000",
iconUrl: "/assets/items/placeholder.png",
imageUrl: "/assets/items/placeholder.png",
};
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newItem),
});
expect(response.status).toBe(201);
const data = (await response.json()) as { success: boolean; item: MockItem };
expect(data.success).toBe(true);
expect(data.item.name).toBe("Magic Staff");
expect(data.item.id).toBeGreaterThan(0);
});
test("should reject item without required fields", async () => {
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: "No name or type" }),
});
expect(response.status).toBe(400);
const data = (await response.json()) as { error: string };
expect(data.error).toContain("required");
});
test("should reject duplicate item name", async () => {
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Health Potion", // Already exists
type: "CONSUMABLE",
iconUrl: "/assets/items/placeholder.png",
imageUrl: "/assets/items/placeholder.png",
}),
});
expect(response.status).toBe(409);
const data = (await response.json()) as { error: string };
expect(data.error).toContain("already exists");
});
});
// ===========================================
// PUT /api/items/:id Tests
// ===========================================
describe("PUT /api/items/:id", () => {
test("should update an existing item", async () => {
const response = await fetch(`${baseUrl}/api/items/1`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
description: "Updated description",
price: "200",
}),
});
expect(response.status).toBe(200);
const data = (await response.json()) as { success: boolean; item: MockItem };
expect(data.success).toBe(true);
expect(data.item.description).toBe("Updated description");
});
test("should return 404 for updating non-existent item", async () => {
const response = await fetch(`${baseUrl}/api/items/9999`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "New Name" }),
});
expect(response.status).toBe(404);
});
test("should reject duplicate name when updating", async () => {
const response = await fetch(`${baseUrl}/api/items/2`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Health Potion", // ID 1 has this name
}),
});
expect(response.status).toBe(409);
});
});
// ===========================================
// DELETE /api/items/:id Tests
// ===========================================
describe("DELETE /api/items/:id", () => {
test("should delete an existing item", async () => {
// First, create an item to delete
const createResponse = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Item to Delete",
type: "MATERIAL",
iconUrl: "/assets/items/placeholder.png",
imageUrl: "/assets/items/placeholder.png",
}),
});
const { item } = (await createResponse.json()) as { item: MockItem };
// Now delete it
const deleteResponse = await fetch(`${baseUrl}/api/items/${item.id}`, {
method: "DELETE",
});
expect(deleteResponse.status).toBe(204);
// Verify it's gone
const getResponse = await fetch(`${baseUrl}/api/items/${item.id}`);
expect(getResponse.status).toBe(404);
});
test("should return 404 for deleting non-existent item", async () => {
const response = await fetch(`${baseUrl}/api/items/9999`, {
method: "DELETE",
});
expect(response.status).toBe(404);
});
});
// ===========================================
// Static Asset Serving Tests
// ===========================================
describe("Static Asset Serving (/assets/*)", () => {
test("should return 404 for non-existent asset", async () => {
const response = await fetch(`${baseUrl}/assets/items/nonexistent.png`);
expect(response.status).toBe(404);
});
test("should prevent path traversal attacks", async () => {
const response = await fetch(`${baseUrl}/assets/../../../etc/passwd`);
// Should either return 403 (Forbidden) or 404 (Not found after sanitization)
expect([403, 404]).toContain(response.status);
});
});
// ===========================================
// Validation Edge Cases
// ===========================================
describe("Validation Edge Cases", () => {
test("should handle empty search query gracefully", async () => {
const response = await fetch(`${baseUrl}/api/items?search=`);
expect(response.status).toBe(200);
});
test("should handle invalid pagination values", async () => {
const response = await fetch(`${baseUrl}/api/items?limit=abc&offset=xyz`);
// Should not crash, may use defaults
expect(response.status).toBe(200);
});
test("should handle missing content-type header", async () => {
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
body: JSON.stringify({ name: "Test", type: "MATERIAL" }),
});
// May fail due to no content-type, but shouldn't crash
expect([200, 201, 400, 415]).toContain(response.status);
});
});
});

View File

@@ -0,0 +1,194 @@
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
import { type WebServerInstance } from "./server";
// Mock gameSettingsService — the route now uses this instead of config/saveConfig
const mockSettings = {
leveling: {
base: 100,
exponent: 1.5,
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
},
economy: {
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
transfers: { allowSelfTransfer: false, minAmount: "1" },
exam: { multMin: 1.5, multMax: 2.5 }
},
inventory: { maxStackSize: "99", maxSlots: 20 },
lootdrop: {
spawnChance: 0.1,
cooldownMs: 3600000,
minMessages: 10,
activityWindowMs: 300000,
reward: { min: 100, max: 500, currency: "gold" }
},
commands: { "help": true },
system: {},
moderation: {
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
},
trivia: {
entryFee: "50",
rewardMultiplier: 1.5,
timeoutSeconds: 30,
cooldownMs: 60000,
categories: [],
difficulty: "random"
}
};
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
const mockGetDefaults = jest.fn(() => mockSettings);
mock.module("@shared/modules/game-settings/game-settings.service", () => ({
gameSettingsService: {
getSettings: mockGetSettings,
upsertSettings: mockUpsertSettings,
getDefaults: mockGetDefaults,
invalidateCache: jest.fn(),
}
}));
// Mock DrizzleClient (dependency potentially imported transitively)
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {}
}));
// Mock @shared/lib/utils (deepMerge is used by settings API)
mock.module("@shared/lib/utils", () => ({
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
jsonReplacer: (key: string, value: any) =>
typeof value === "bigint" ? value.toString() : value,
}));
// Mock BotClient
const mockGuild = {
roles: {
cache: [
{ id: "role1", name: "Admin", hexColor: "#ffffff", position: 1 },
{ id: "role2", name: "User", hexColor: "#000000", position: 0 }
]
},
channels: {
cache: [
{ id: "chan1", name: "general", type: 0 }
]
}
};
mock.module("../../bot/lib/BotClient", () => ({
AuroraClient: {
guilds: {
cache: {
get: () => mockGuild
}
},
commands: [
{ data: { name: "ping" } }
],
knownCommands: new Map([
["ping", "utility"],
["help", "utility"],
["disabled-cmd", "admin"]
])
}
}));
mock.module("@shared/lib/env", () => ({
env: {
DISCORD_GUILD_ID: "123456789"
}
}));
// Mock spawn
mock.module("bun", () => {
return {
spawn: jest.fn(() => ({
unref: () => { }
})),
serve: Bun.serve
};
});
// Import createWebServer after mocks
import { createWebServer } from "./server";
describe("Settings API", () => {
let serverInstance: WebServerInstance;
const PORT = 3009;
const HOSTNAME = "127.0.0.1";
const BASE_URL = `http://${HOSTNAME}:${PORT}`;
beforeEach(async () => {
jest.clearAllMocks();
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
});
afterEach(async () => {
if (serverInstance) {
await serverInstance.stop();
}
});
it("GET /api/settings should return current configuration", async () => {
const res = await fetch(`${BASE_URL}/api/settings`);
expect(res.status).toBe(200);
const data = await res.json() as any;
// Check values come through correctly
expect(data.economy.daily.amount).toBe("100");
expect(data.leveling.base).toBe(100);
});
it("POST /api/settings should save valid configuration via merge", async () => {
const partialConfig = { economy: { daily: { amount: "200" } } };
const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(partialConfig)
});
expect(res.status).toBe(200);
// upsertSettings should be called with the partial config
expect(mockUpsertSettings).toHaveBeenCalledWith(
expect.objectContaining({
economy: { daily: { amount: "200" } }
})
);
});
it("POST /api/settings should return 400 when save fails", async () => {
mockUpsertSettings.mockImplementationOnce(() => {
throw new Error("Validation failed");
});
const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
expect(res.status).toBe(400);
const data = await res.json() as any;
expect(data.details).toBe("Validation failed");
});
it("GET /api/settings/meta should return simplified metadata", async () => {
const res = await fetch(`${BASE_URL}/api/settings/meta`);
expect(res.status).toBe(200);
const data = await res.json() as any;
expect(data.roles).toHaveLength(2);
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
// Check new commands structure
expect(data.commands).toBeArray();
expect(data.commands.length).toBeGreaterThan(0);
expect(data.commands[0]).toHaveProperty("name");
expect(data.commands[0]).toHaveProperty("category");
});
});

153
api/src/server.test.ts Normal file
View File

@@ -0,0 +1,153 @@
import { describe, test, expect, afterAll, mock } from "bun:test";
import type { WebServerInstance } from "./server";
interface MockBotStats {
bot: { name: string; avatarUrl: string | null };
guilds: number;
ping: number;
cachedUsers: number;
commandsRegistered: number;
uptime: number;
lastCommandTimestamp: number | null;
}
// 1. Mock DrizzleClient (dependency of dashboardService)
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
mock.module("@shared/db/DrizzleClient", () => {
const mockBuilder: Record<string, any> = {};
// Every chainable method returns mock builder; terminal calls return resolved promise
mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
mockBuilder.orderBy = mock(() => mockBuilder);
mockBuilder.limit = mock(() => Promise.resolve([]));
mockBuilder.leftJoin = mock(() => mockBuilder);
mockBuilder.groupBy = mock(() => mockBuilder);
mockBuilder.from = mock(() => mockBuilder);
return {
DrizzleClient: {
select: mock(() => mockBuilder),
query: {
transactions: { findMany: mock(() => Promise.resolve([])) },
moderationCases: { findMany: mock(() => Promise.resolve([])) },
users: {
findFirst: mock(() => Promise.resolve({ username: "test" })),
findMany: mock(() => Promise.resolve([])),
},
lootdrops: { findMany: mock(() => Promise.resolve([])) },
}
},
};
});
// 2. Mock Bot Stats Provider
mock.module("../../bot/lib/clientStats", () => ({
getClientStats: mock((): MockBotStats => ({
bot: { name: "TestBot", avatarUrl: null },
guilds: 5,
ping: 42,
cachedUsers: 100,
commandsRegistered: 10,
uptime: 3600,
lastCommandTimestamp: Date.now(),
})),
}));
// 3. Mock config (used by lootdrop.service.getLootdropState)
mock.module("@shared/lib/config", () => ({
config: {
lootdrop: {
activityWindowMs: 120000,
minMessages: 1,
spawnChance: 1,
cooldownMs: 3000,
reward: { min: 40, max: 150, currency: "Astral Units" }
}
}
}));
// 4. Mock BotClient (used by stats helper for maintenanceMode)
mock.module("../../bot/lib/BotClient", () => ({
AuroraClient: {
maintenanceMode: false,
guilds: { cache: { get: () => null } },
commands: [],
knownCommands: new Map(),
}
}));
// Import after all mocks are set up
import { createWebServer } from "./server";
describe("WebServer Security & Limits", () => {
const port = 3001;
const hostname = "127.0.0.1";
let serverInstance: WebServerInstance | null = null;
afterAll(async () => {
if (serverInstance) {
await serverInstance.stop();
}
});
test("should reject more than 10 concurrent WebSocket connections", async () => {
serverInstance = await createWebServer({ port, hostname });
const wsUrl = `ws://${hostname}:${port}/ws`;
const sockets: WebSocket[] = [];
try {
// Attempt to open 12 connections (limit is 10)
for (let i = 0; i < 12; i++) {
const ws = new WebSocket(wsUrl);
sockets.push(ws);
await new Promise(resolve => setTimeout(resolve, 5));
}
// Give connections time to settle
await new Promise(resolve => setTimeout(resolve, 800));
const pendingCount = serverInstance.server.pendingWebSockets;
expect(pendingCount).toBeLessThanOrEqual(10);
} finally {
sockets.forEach(s => {
if (s.readyState === WebSocket.OPEN || s.readyState === WebSocket.CONNECTING) {
s.close();
}
});
}
});
test("should return 200 for health check", async () => {
if (!serverInstance) {
serverInstance = await createWebServer({ port, hostname });
}
const response = await fetch(`http://${hostname}:${port}/api/health`);
expect(response.status).toBe(200);
const data = (await response.json()) as { status: string };
expect(data.status).toBe("ok");
});
describe("Administrative Actions", () => {
test("should allow administrative actions without token", async () => {
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
method: "POST"
});
// Should be 200 (OK) or 500 (if underlying service fails, but NOT 401)
expect(response.status).not.toBe(401);
expect(response.status).toBe(200);
});
test("should reject maintenance mode with invalid payload", async () => {
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ not_enabled: true }) // Wrong field
});
expect(response.status).toBe(400);
const data = await response.json() as { error: string };
expect(data.error).toBe("Invalid payload");
});
});
});

243
api/src/server.ts Normal file
View File

@@ -0,0 +1,243 @@
/**
* @fileoverview API server factory module.
* Exports a function to create and start the API server.
* This allows the server to be started in-process from the main application.
*
* Routes are organized into modular files in the ./routes directory.
* Each route module handles its own validation, business logic, and responses.
*/
import { serve, file } from "bun";
import { logger } from "@shared/lib/logger";
import { handleRequest } from "./routes";
import { getFullDashboardStats } from "./routes/stats.helper";
import { join } from "path";
export interface WebServerConfig {
port?: number;
hostname?: string;
}
export interface WebServerInstance {
server: ReturnType<typeof serve>;
stop: () => Promise<void>;
url: string;
}
/**
* Creates and starts the API server.
*
* @param config - Server configuration options
* @param config.port - Port to listen on (default: 3000)
* @param config.hostname - Hostname to bind to (default: "localhost")
* @returns Promise resolving to server instance with stop() method
*
* @example
* const server = await createWebServer({ port: 3000, hostname: "0.0.0.0" });
* console.log(`Server running at ${server.url}`);
*
* // To stop the server:
* await server.stop();
*/
const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
};
/**
* Serve static files from the panel dist directory.
* Falls back to index.html for SPA routing.
*/
async function servePanelStatic(pathname: string, distDir: string): Promise<Response | null> {
// Don't serve panel for API/auth/ws/assets routes
if (pathname.startsWith("/api/") || pathname.startsWith("/auth/") || pathname === "/ws" || pathname.startsWith("/assets/")) {
return null;
}
// Try to serve the exact file
const filePath = join(distDir, pathname);
const bunFile = file(filePath);
if (await bunFile.exists()) {
const ext = pathname.substring(pathname.lastIndexOf("."));
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
return new Response(bunFile, {
headers: {
"Content-Type": contentType,
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
},
});
}
// SPA fallback: serve index.html for all non-file routes
const indexFile = file(join(distDir, "index.html"));
if (await indexFile.exists()) {
return new Response(indexFile, {
headers: { "Content-Type": "text/html", "Cache-Control": "no-cache" },
});
}
return null;
}
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
const { port = 3000, hostname = "localhost" } = config;
// Configuration constants
const MAX_CONNECTIONS = 10;
const MAX_PAYLOAD_BYTES = 16384; // 16KB
const IDLE_TIMEOUT_SECONDS = 60;
// Interval for broadcasting stats to all connected WS clients
let statsBroadcastInterval: Timer | undefined;
const server = serve({
port,
hostname,
async fetch(req, server) {
const url = new URL(req.url);
// WebSocket upgrade handling
if (url.pathname === "/ws") {
const currentConnections = server.pendingWebSockets;
if (currentConnections >= MAX_CONNECTIONS) {
logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
return new Response("Connection limit reached", { status: 429 });
}
const success = server.upgrade(req);
if (success) return undefined;
return new Response("WebSocket upgrade failed", { status: 400 });
}
// Delegate to modular route handlers
const response = await handleRequest(req, url);
if (response) return response;
// Serve panel static files (production)
const panelDistDir = join(import.meta.dir, "../../panel/dist");
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
if (staticResponse) return staticResponse;
// No matching route found
return new Response("Not Found", { status: 404 });
},
websocket: {
/**
* Called when a WebSocket client connects.
* Subscribes the client to the dashboard channel and sends initial stats.
*/
open(ws) {
ws.subscribe("dashboard");
logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);
// Send initial stats
getFullDashboardStats().then(stats => {
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
});
// Start broadcast interval if this is the first client
if (!statsBroadcastInterval) {
statsBroadcastInterval = setInterval(async () => {
try {
const stats = await getFullDashboardStats();
server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats }));
} catch (error) {
logger.error("web", "Error in stats broadcast", error);
}
}, 5000);
}
},
/**
* Called when a WebSocket message is received.
* Handles PING/PONG heartbeat messages.
*/
async message(ws, message) {
try {
const messageStr = message.toString();
// Defense-in-depth: redundant length check before parsing
if (messageStr.length > MAX_PAYLOAD_BYTES) {
logger.error("web", "Payload exceeded maximum limit");
return;
}
const rawData = JSON.parse(messageStr);
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
const parsed = WsMessageSchema.safeParse(rawData);
if (!parsed.success) {
logger.error("web", "Invalid message format", parsed.error.issues);
return;
}
if (parsed.data.type === "PING") {
ws.send(JSON.stringify({ type: "PONG" }));
}
} catch (e) {
logger.error("web", "Failed to handle message", e);
}
},
/**
* Called when a WebSocket client disconnects.
* Stops the broadcast interval if no clients remain.
*/
close(ws) {
ws.unsubscribe("dashboard");
logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);
// Stop broadcast interval if no clients left
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
clearInterval(statsBroadcastInterval);
statsBroadcastInterval = undefined;
}
},
maxPayloadLength: MAX_PAYLOAD_BYTES,
idleTimeout: IDLE_TIMEOUT_SECONDS,
},
});
// Listen for real-time events from the system bus
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event }));
});
const url = `http://${hostname}:${port}`;
return {
server,
url,
stop: async () => {
if (statsBroadcastInterval) {
clearInterval(statsBroadcastInterval);
}
server.stop(true);
},
};
}
/**
* Starts the web server from the main application root.
* Kept for backward compatibility.
*
* @param webProjectPath - Deprecated, no longer used
* @param config - Server configuration options
* @returns Promise resolving to server instance
*/
export async function startWebServerFromRoot(
webProjectPath: string,
config: WebServerConfig = {}
): Promise<WebServerInstance> {
return createWebServer(config);
}

38
api/tsconfig.json Normal file
View File

@@ -0,0 +1,38 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@shared/*": [
"../shared/*"
],
"@bot/*": [
"../bot/*"
]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["node_modules"]
}