TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import type { FastifyInstance } from "fastify";2import os from "node:os";3import { achievements, adminUsers, and, count, creditTransactions, db, desc, eq, gameRounds, gameStatistics, games, gte, ilike, missions, securityEvents, sessions, simulationRuns, sql, users, wallets, gameVersions, or } from "@spinza/database";4import { GAMES, getGame, CRASH_BY_SLUG, ARCADE_BY_SLUG } from "@spinza/games";5import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core";6import { simulateParallel } from "@spinza/simulator";7import { GAME_LIFECYCLE } from "@spinza/shared";8import { z } from "zod";9import { errors } from "../lib/errors";10import { decrypt, verifyPassword, verifyTotp } from "../lib/crypto";11import { rateLimit, redis } from "../lib/redis";12import { createAdminSession, destroyAdminSession, requireAdmin } from "../plugins/auth";13import { clientIp, logSecurity } from "../lib/security";14import { allFlags, allSettings, refreshSettings, setFlag, setSetting } from "../lib/settings";15import { grant } from "../services/wallet";16import { config } from "../config";17import { pingDb } from "@spinza/database";18import { pingRedis } from "../lib/redis";1920const startedAt = Date.now();2122export async function adminRoutes(app: FastifyInstance) {23 /* ------------------------------------------------------------- auth */24 app.post("/api/admin/auth/login", async (req, reply) => {25 if (config.adminIpAllowlist.length && !config.adminIpAllowlist.includes(clientIp(req))) throw errors.forbidden("Admin access is restricted.");26 const retry = await rateLimit(`admin-login:${clientIp(req)}`, 10, 600);27 if (retry) throw errors.rateLimited(retry);28 const body = z.object({ username: z.string().min(1), password: z.string().min(1), totp: z.string().min(6).max(8) }).parse(req.body);29 const a = await db.query.adminUsers.findFirst({ where: eq(adminUsers.username, body.username.toLowerCase()) });30 const ok = !!a && !a.disabled && (await verifyPassword(a.passwordHash, body.password)) && verifyTotp(decrypt(a.totpSecret), body.totp);31 if (!ok) {32 await logSecurity(req, "admin.login.failed", { adminId: a?.id ?? null, severity: "high", meta: { username: body.username } });33 throw errors.unauthorized("Invalid credentials.");34 }35 await db.update(adminUsers).set({ lastLoginAt: new Date() }).where(eq(adminUsers.id, a.id));36 await createAdminSession(reply, req, a.id);37 await logSecurity(req, "admin.login.success", { adminId: a.id, severity: "warn" });38 return { admin: { id: a.id, username: a.username, role: a.role } };39 });4041 app.post("/api/admin/auth/logout", async (req, reply) => {42 await destroyAdminSession(reply, req);43 return { ok: true };44 });4546 app.get("/api/admin/me", async (req) => {47 const admin = requireAdmin(req);48 return { admin };49 });5051 /* -------------------------------------------------------- dashboard */52 app.get("/api/admin/dashboard", async (req) => {53 requireAdmin(req);54 const now = new Date();55 const dayAgo = new Date(now.getTime() - 86400_000);56 const weekAgo = new Date(now.getTime() - 7 * 86400_000);57 const monthAgo = new Date(now.getTime() - 30 * 86400_000);58 const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));59 const [[regs], [dau], [wau], [mau], [activeSessions], todayAgg, topGames, highestWins, latency, errCount] = await Promise.all([60 db.select({ n: count() }).from(users),61 db.select({ n: sql<number>`count(distinct user_id)::int` }).from(gameRounds).where(gte(gameRounds.createdAt, dayAgo)),62 db.select({ n: sql<number>`count(distinct user_id)::int` }).from(gameRounds).where(gte(gameRounds.createdAt, weekAgo)),63 db.select({ n: sql<number>`count(distinct user_id)::int` }).from(gameRounds).where(gte(gameRounds.createdAt, monthAgo)),64 db.select({ n: count() }).from(sessions).where(gte(sessions.lastSeenAt, new Date(now.getTime() - 30 * 60_000))),65 db.execute(sql`select count(*)::int as spins, coalesce(sum(bet),0)::bigint as wagered, coalesce(sum(win),0)::bigint as won, coalesce(avg(duration_ms),0)::float as avg_ms,66 coalesce(percentile_cont(0.95) within group (order by duration_ms),0)::float as p95_ms from game_rounds where created_at >= ${todayStart}`),67 db.execute(sql`select game_slug, count(*)::int as spins, coalesce(sum(bet),0)::bigint as wagered, coalesce(sum(win),0)::bigint as won from game_rounds where created_at >= ${weekAgo} group by game_slug order by spins desc limit 8`),68 db.execute(sql`select r.round_id, r.game_slug, r.bet, r.win, r.multiplier, r.created_at, u.username from game_rounds r join users u on u.id = r.user_id order by r.win desc limit 10`),69 pingDb().catch(() => -1),70 db.select({ n: count() }).from(securityEvents).where(and(gte(securityEvents.createdAt, dayAgo), eq(securityEvents.severity, "high"))),71 ]);72 const t = todayAgg.rows[0] as { spins: number; wagered: number; won: number; avg_ms: number; p95_ms: number };73 const sessionsAgg = await db.execute(sql`select coalesce(avg(extract(epoch from (last_seen_at - created_at))),0)::float as avg_session from sessions where created_at >= ${weekAgo}`);74 const live = await redis().zcount("live:players", Date.now() - 5 * 60_000, "+inf").catch(() => 0);75 return {76 users: { registered: regs.n, dau: dau.n, wau: wau.n, mau: mau.n, activeSessions: activeSessions.n, livePlayers: live },77 today: { spins: t.spins, wagered: Number(t.wagered), won: Number(t.won), effectiveRtp: Number(t.wagered) ? Number(t.won) / Number(t.wagered) : null, avgSpinMs: t.avg_ms, p95SpinMs: t.p95_ms },78 averageSessionSec: Number((sessionsAgg.rows[0] as { avg_session: number }).avg_session),79 topGames: (topGames.rows as Record<string, unknown>[]).map((r) => ({ slug: r.game_slug, spins: r.spins, wagered: Number(r.wagered), won: Number(r.won), rtp: Number(r.wagered) ? Number(r.won) / Number(r.wagered) : null })),80 highestWins: (highestWins.rows as Record<string, unknown>[]).map((r) => ({ roundId: r.round_id, game: r.game_slug, bet: Number(r.bet), win: Number(r.win), multiplier: Number(r.multiplier), at: r.created_at, username: r.username })),81 health: { dbLatencyMs: latency, highSeverityEvents24h: errCount[0].n, uptimeSec: Math.round((Date.now() - startedAt) / 1000), version: config.version, node: os.hostname() },82 };83 });8485 /* ------------------------------------------------------------ users */86 app.get("/api/admin/users", async (req) => {87 requireAdmin(req);88 const q = z.object({ q: z.string().optional(), limit: z.coerce.number().int().min(1).max(200).default(50), offset: z.coerce.number().int().min(0).default(0), sort: z.enum(["created", "spins", "balance", "level"]).default("created") }).parse(req.query);89 const where = q.q ? ilike(users.username, `%${q.q}%`) : undefined;90 const order = { created: desc(users.createdAt), spins: desc(users.totalSpins), balance: desc(wallets.balance), level: desc(users.level) }[q.sort];91 const rows = await db92 .select({ id: users.id, username: users.username, level: users.level, xp: users.xp, status: users.status, createdAt: users.createdAt, lastLoginAt: users.lastLoginAt, totalSpins: users.totalSpins, biggestWin: users.biggestWin, balance: wallets.balance, wagered: wallets.lifetimeWagered, won: wallets.lifetimeWon })93 .from(users)94 .innerJoin(wallets, eq(wallets.userId, users.id))95 .where(where)96 .orderBy(order)97 .limit(q.limit)98 .offset(q.offset);99 const [{ n }] = await db.select({ n: count() }).from(users).where(where);100 return { users: rows, total: n };101 });102103 app.get("/api/admin/users/:id", async (req) => {104 requireAdmin(req);105 const { id } = req.params as { id: string };106 const u = await db.query.users.findFirst({ where: eq(users.id, id) });107 if (!u) throw errors.notFound();108 const [w, ledger, rounds, sess, events] = await Promise.all([109 db.query.wallets.findFirst({ where: eq(wallets.userId, id) }),110 db.select().from(creditTransactions).where(eq(creditTransactions.userId, id)).orderBy(desc(creditTransactions.createdAt)).limit(50),111 db.select().from(gameRounds).where(eq(gameRounds.userId, id)).orderBy(desc(gameRounds.createdAt)).limit(30),112 db.select().from(sessions).where(eq(sessions.userId, id)).orderBy(desc(sessions.lastSeenAt)),113 db.select().from(securityEvents).where(eq(securityEvents.userId, id)).orderBy(desc(securityEvents.createdAt)).limit(30),114 ]);115 const { passwordHash: _p, ...safe } = u;116 // Ledger invariant: sum(amount) must equal balance.117 const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, id));118 return { user: safe, wallet: w, ledger, rounds: rounds.map((r) => ({ ...r, result: undefined })), sessions: sess, events, invariant: { ledgerTotal: Number(total), balance: w?.balance ?? 0, ok: Number(total) === (w?.balance ?? 0) } };119 });120121 app.post("/api/admin/users/:id/adjust", async (req) => {122 const admin = requireAdmin(req);123 const { id } = req.params as { id: string };124 const body = z.object({ amount: z.number().int().min(-1_000_000_000).max(1_000_000_000).refine((n) => n !== 0), note: z.string().min(3).max(200) }).parse(req.body);125 const balance = await db.transaction((tx) => grant(tx, id, "ADMIN_ADJUSTMENT", body.amount, `admin:${admin.username}`, { note: body.note, adminId: admin.id }));126 await logSecurity(req, "admin.wallet.adjust", { adminId: admin.id, userId: id, severity: "high", meta: { amount: body.amount, note: body.note } });127 return { balance };128 });129130 app.post("/api/admin/users/:id/status", async (req) => {131 const admin = requireAdmin(req);132 const { id } = req.params as { id: string };133 const body = z.object({ status: z.enum(["active", "suspended"]) }).parse(req.body);134 await db.update(users).set({ status: body.status }).where(eq(users.id, id));135 if (body.status === "suspended") {136 const rows = await db.select({ tokenHash: sessions.tokenHash }).from(sessions).where(eq(sessions.userId, id));137 await db.delete(sessions).where(eq(sessions.userId, id));138 if (rows.length) await redis().del(...rows.map((r) => `sess:${r.tokenHash}`));139 }140 await logSecurity(req, "admin.user.status", { adminId: admin.id, userId: id, severity: "high", meta: { status: body.status } });141 return { ok: true };142 });143144 /* ------------------------------------------------------------ games */145 app.get("/api/admin/games", async (req) => {146 requireAdmin(req);147 const rows = await db148 .select({ game: games, stats: gameStatistics })149 .from(games)150 .leftJoin(gameStatistics, eq(gameStatistics.gameId, games.id))151 .orderBy(games.sortOrder);152 const flags = allFlags();153 return {154 games: rows.map(({ game, stats }) => {155 const def = getGame(game.slug);156 const other = CRASH_BY_SLUG.get(game.slug) ?? ARCADE_BY_SLUG.get(game.slug);157 return {158 ...game,159 enabled: flags[`game.${game.slug}.enabled`] ?? true,160 rtp: def?.rtp ?? other?.rtp ?? null,161 payScale: def?.payScale ?? (other && "payScale" in other ? other.payScale : null),162 stats: stats ? { ...stats, effectiveRtp: Number(stats.wagered) ? Number(stats.won) / Number(stats.wagered) : null } : null,163 validation: def ? validateDefinition(def) : other ? [] : [{ level: "error", message: "definition missing from library" }],164 };165 }),166 };167 });168169 app.get("/api/admin/games/:slug", async (req) => {170 requireAdmin(req);171 const { slug } = req.params as { slug: string };172 const g = await db.query.games.findFirst({ where: eq(games.slug, slug) });173 if (!g) throw errors.notFound();174 const versions = await db.select({ version: gameVersions.version, status: gameVersions.status, createdAt: gameVersions.createdAt, certification: gameVersions.certification, definitionHash: gameVersions.definitionHash }).from(gameVersions).where(eq(gameVersions.gameId, g.id)).orderBy(desc(gameVersions.createdAt));175 const stats = await db.query.gameStatistics.findFirst({ where: eq(gameStatistics.gameId, g.id) });176 const daily = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as spins, sum(bet)::bigint as wagered, sum(win)::bigint as won, count(distinct user_id)::int as players177 from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`);178 const runs = await db.select().from(simulationRuns).where(eq(simulationRuns.gameSlug, slug)).orderBy(desc(simulationRuns.createdAt)).limit(10);179 return { game: g, definition: getGame(slug) ?? CRASH_BY_SLUG.get(slug) ?? ARCADE_BY_SLUG.get(slug) ?? null, versions, stats, daily: daily.rows, simulationRuns: runs };180 });181182 app.post("/api/admin/games/:slug/lifecycle", async (req) => {183 const admin = requireAdmin(req);184 const { slug } = req.params as { slug: string };185 const body = z.object({ lifecycle: z.enum(GAME_LIFECYCLE) }).parse(req.body);186 const g = await db.query.games.findFirst({ where: eq(games.slug, slug) });187 if (!g) throw errors.notFound();188 if (body.lifecycle === "published") {189 const v = await db.query.gameVersions.findFirst({ where: and(eq(gameVersions.gameId, g.id), eq(gameVersions.version, g.version)) });190 const cert = v?.certification as { status?: string; version?: string } | null;191 if (!cert || cert.status !== "PASS" || cert.version !== g.version) throw errors.conflict("NOT_CERTIFIED", "A game cannot be published without a PASS certification for its current version.");192 }193 await db.update(games).set({ lifecycle: body.lifecycle, publishedAt: body.lifecycle === "published" ? sql`coalesce(${games.publishedAt}, now())` : games.publishedAt, updatedAt: new Date() }).where(eq(games.id, g.id));194 await logSecurity(req, "admin.game.lifecycle", { adminId: admin.id, severity: "warn", meta: { slug, lifecycle: body.lifecycle } });195 return { ok: true };196 });197198 app.post("/api/admin/games/:slug/flags", async (req) => {199 const admin = requireAdmin(req);200 const { slug } = req.params as { slug: string };201 const body = z.object({ isFeatured: z.boolean().optional(), isNew: z.boolean().optional(), sortOrder: z.number().int().optional() }).parse(req.body);202 await db.update(games).set({ ...body, updatedAt: new Date() }).where(eq(games.slug, slug));203 await logSecurity(req, "admin.game.flags", { adminId: admin.id, meta: { slug, ...body } });204 return { ok: true };205 });206207 /* -------------------------------------------------------- simulator */208 app.post("/api/admin/simulator/run", async (req) => {209 const admin = requireAdmin(req);210 const body = z.object({ slug: z.string(), spins: z.number().int().min(1000).max(10_000_000), bet: z.number().int().min(10).max(1000).default(100), certify: z.boolean().default(false) }).parse(req.body);211 const def = getGame(body.slug);212 if (!def) throw errors.notFound("Unknown game");213 const running = await db.select({ n: count() }).from(simulationRuns).where(eq(simulationRuns.status, "running"));214 if (running[0].n >= 2) throw errors.conflict("BUSY", "Two simulations are already running. Please wait.");215 const [run] = await db.insert(simulationRuns).values({ gameSlug: def.slug, gameVersion: def.version, spins: body.spins, requestedBy: admin.id }).returning();216 // Fire and forget: worker threads keep the event loop free.217 simulateParallel(def.slug, {218 spins: body.spins,219 bet: body.bet,220 payScale: def.payScale,221 threads: Math.max(1, Math.min(os.cpus().length - 2, 12)),222 onProgress: (done) => {223 db.update(simulationRuns).set({ progress: done }).where(eq(simulationRuns.id, run.id)).catch(() => {});224 },225 })226 .then(async (result) => {227 const report = body.certify ? certify(def, result, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, body.spins) }) : null;228 await db.update(simulationRuns).set({ status: "done", progress: body.spins, result: { simulation: result, certification: report } as unknown as Record<string, unknown>, finishedAt: new Date() }).where(eq(simulationRuns.id, run.id));229 // A full-size PASS certification from the admin simulator becomes the official certification of this version,230 // which is what unlocks the `published` lifecycle.231 if (report && report.status === "PASS" && body.spins >= DEFAULT_CERTIFICATION_RULES.minSpins) {232 const g = await db.query.games.findFirst({ where: eq(games.slug, def.slug), columns: { id: true } });233 if (g) await db.update(gameVersions).set({ certification: report as unknown as Record<string, unknown> }).where(and(eq(gameVersions.gameId, g.id), eq(gameVersions.version, def.version)));234 }235 })236 .catch(async (e) => {237 await db.update(simulationRuns).set({ status: "failed", error: String(e?.message ?? e), finishedAt: new Date() }).where(eq(simulationRuns.id, run.id));238 });239 return { runId: run.id };240 });241242 app.get("/api/admin/simulator/runs", async (req) => {243 requireAdmin(req);244 const q = z.object({ slug: z.string().optional(), limit: z.coerce.number().int().min(1).max(50).default(20) }).parse(req.query);245 const rows = await db.select({ id: simulationRuns.id, gameSlug: simulationRuns.gameSlug, gameVersion: simulationRuns.gameVersion, spins: simulationRuns.spins, status: simulationRuns.status, progress: simulationRuns.progress, createdAt: simulationRuns.createdAt, finishedAt: simulationRuns.finishedAt, error: simulationRuns.error }).from(simulationRuns).where(q.slug ? eq(simulationRuns.gameSlug, q.slug) : undefined).orderBy(desc(simulationRuns.createdAt)).limit(q.limit);246 return { runs: rows };247 });248249 app.get("/api/admin/simulator/runs/:id", async (req) => {250 requireAdmin(req);251 const { id } = req.params as { id: string };252 const run = await db.query.simulationRuns.findFirst({ where: eq(simulationRuns.id, id) });253 if (!run) throw errors.notFound();254 return { run };255 });256257 /** Library certifications shipped with the build. */258 app.get("/api/admin/certifications", async (req) => {259 requireAdmin(req);260 const rows = await db.select({ slug: games.slug, version: games.version, lifecycle: games.lifecycle, certification: gameVersions.certification }).from(games).innerJoin(gameVersions, and(eq(gameVersions.gameId, games.id), eq(gameVersions.version, games.version))).orderBy(games.sortOrder);261 return { certifications: rows };262 });263264 /* ---------------------------------------------------------- economy */265 app.get("/api/admin/economy", async (req) => {266 requireAdmin(req);267 const days = z.object({ days: z.coerce.number().int().min(1).max(90).default(30) }).parse(req.query).days;268 const byType = await db.execute(sql`select type, count(*)::int as n, sum(amount)::bigint as total from credit_transactions where created_at >= now() - (${days} || ' days')::interval group by type order by type`);269 const daily = await db.execute(sql`select date_trunc('day', created_at) as day, type, sum(amount)::bigint as total from credit_transactions where created_at >= now() - (${days} || ' days')::interval group by 1,2 order by 1`);270 const supply = await db.execute(sql`select coalesce(sum(balance),0)::bigint as circulating, coalesce(sum(lifetime_granted),0)::bigint as granted, coalesce(sum(lifetime_wagered),0)::bigint as wagered, coalesce(sum(lifetime_won),0)::bigint as won from wallets`);271 const invariant = await db.execute(sql`select count(*)::int as mismatches from (select w.user_id, w.balance, coalesce(sum(t.amount),0) as ledger from wallets w left join credit_transactions t on t.user_id = w.user_id group by w.user_id, w.balance having w.balance <> coalesce(sum(t.amount),0)) x`);272 const dist = await db.execute(sql`select width_bucket(balance, 0, 100000, 10) as bucket, count(*)::int as n from wallets group by 1 order by 1`);273 return { byType: byType.rows, daily: daily.rows, supply: supply.rows[0], invariant: invariant.rows[0], balanceDistribution: dist.rows };274 });275276 /* -------------------------------------------------------- analytics */277 app.get("/api/admin/analytics/games", async (req) => {278 requireAdmin(req);279 const days = z.object({ days: z.coerce.number().int().min(1).max(90).default(30) }).parse(req.query).days;280 const rows = await db.execute(sql`select game_slug, count(*)::int as spins, count(distinct user_id)::int as players, sum(bet)::bigint as wagered, sum(win)::bigint as won,281 avg(bet)::float as avg_bet, count(*) filter (where bonus or free_spins)::int as bonuses, max(win)::bigint as max_win, max(multiplier)::float as max_multiplier,282 count(*) filter (where multiplier >= 20)::int as big_wins, avg(duration_ms)::float as avg_ms283 from game_rounds where created_at >= now() - (${days} || ' days')::interval group by game_slug order by spins desc`);284 const launches = await db.select({ slug: games.slug, launches: gameStatistics.launches, favorites: gameStatistics.favorites }).from(gameStatistics).innerJoin(games, eq(games.id, gameStatistics.gameId));285 const l = new Map(launches.map((x) => [x.slug, x]));286 return {287 games: (rows.rows as Record<string, unknown>[]).map((r) => ({ ...r, wagered: Number(r.wagered), won: Number(r.won), max_win: Number(r.max_win), rtp: Number(r.wagered) ? Number(r.won) / Number(r.wagered) : null, launches: Number(l.get(r.game_slug as string)?.launches ?? 0), favorites: l.get(r.game_slug as string)?.favorites ?? 0 })),288 };289 });290291 app.get("/api/admin/analytics/players", async (req) => {292 requireAdmin(req);293 const days = z.object({ days: z.coerce.number().int().min(1).max(90).default(30) }).parse(req.query).days;294 const signups = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as n from users where created_at >= now() - (${days} || ' days')::interval group by 1 order by 1`);295 const activity = await db.execute(sql`select date_trunc('day', created_at) as day, count(distinct user_id)::int as players, count(*)::int as spins from game_rounds where created_at >= now() - (${days} || ' days')::interval group by 1 order by 1`);296 const levels = await db.execute(sql`select width_bucket(level, 1, 101, 10) as bucket, count(*)::int as n from users group by 1 order by 1`);297 const retention = await db.execute(sql`select count(*) filter (where last_login_at >= now() - interval '1 day')::int as d1, count(*) filter (where last_login_at >= now() - interval '7 days')::int as d7, count(*)::int as total from users`);298 return { signups: signups.rows, activity: activity.rows, levels: levels.rows, retention: retention.rows[0] };299 });300301 /* --------------------------------------------- missions / achievements */302 app.get("/api/admin/missions", async (req) => {303 requireAdmin(req);304 const rows = await db.select().from(missions).orderBy(missions.sortOrder);305 const completions = await db.execute(sql`select mission_key, count(*) filter (where completed_at is not null)::int as completed, count(*)::int as started from user_missions where expires_at >= now() - interval '7 days' group by mission_key`);306 return { missions: rows, completions: completions.rows };307 });308 app.patch("/api/admin/missions/:key", async (req) => {309 requireAdmin(req);310 const { key } = req.params as { key: string };311 const body = z.object({ enabled: z.boolean().optional(), target: z.number().int().min(1).optional(), rewardCredits: z.number().int().min(0).optional(), rewardXp: z.number().int().min(0).optional(), name: z.string().min(2).optional(), description: z.string().min(2).optional() }).parse(req.body);312 await db.update(missions).set(body).where(eq(missions.key, key));313 return { ok: true };314 });315 app.get("/api/admin/achievements", async (req) => {316 requireAdmin(req);317 const rows = await db.select().from(achievements).orderBy(achievements.sortOrder);318 const unlocks = await db.execute(sql`select achievement_key, count(*)::int as n from user_achievements group by achievement_key`);319 return { achievements: rows, unlocks: unlocks.rows };320 });321 app.patch("/api/admin/achievements/:key", async (req) => {322 requireAdmin(req);323 const { key } = req.params as { key: string };324 const body = z.object({ enabled: z.boolean().optional(), target: z.number().int().min(1).optional(), rewardCredits: z.number().int().min(0).optional(), rewardXp: z.number().int().min(0).optional(), name: z.string().min(2).optional(), description: z.string().min(2).optional() }).parse(req.body);325 await db.update(achievements).set(body).where(eq(achievements.key, key));326 return { ok: true };327 });328329 /* ------------------------------------------- flags / settings / maint */330 app.get("/api/admin/settings", async (req) => {331 requireAdmin(req);332 await refreshSettings(true);333 return { flags: allFlags(), settings: allSettings() };334 });335 app.post("/api/admin/flags/:key", async (req) => {336 const admin = requireAdmin(req);337 const { key } = req.params as { key: string };338 const body = z.object({ enabled: z.boolean() }).parse(req.body);339 await setFlag(key, body.enabled);340 await logSecurity(req, "admin.flag", { adminId: admin.id, meta: { key, enabled: body.enabled } });341 return { ok: true };342 });343 app.post("/api/admin/settings/:key", async (req) => {344 const admin = requireAdmin(req);345 const { key } = req.params as { key: string };346 const body = z.object({ value: z.unknown() }).parse(req.body);347 if (key === "rescue") z.object({ amount: z.number().int().min(0), cooldownHours: z.number().min(0), threshold: z.number().int().min(0) }).parse(body.value);348 if (key === "dailyRewards") z.object({ schedule: z.array(z.number().int().min(0)).min(1).max(30) }).parse(body.value);349 if (key === "maintenance") z.object({ enabled: z.boolean(), message: z.string().min(3) }).parse(body.value);350 if (key === "profanity") z.object({ words: z.array(z.string()) }).parse(body.value);351 await setSetting(key, body.value);352 await logSecurity(req, key === "maintenance" ? "admin.maintenance" : "admin.setting", { adminId: admin.id, severity: key === "maintenance" ? "high" : "info", meta: { key, value: body.value } });353 return { ok: true };354 });355356 /* ---------------------------------------------------------- security */357 app.get("/api/admin/security/events", async (req) => {358 requireAdmin(req);359 const q = z.object({ type: z.string().optional(), severity: z.string().optional(), limit: z.coerce.number().int().min(1).max(200).default(100) }).parse(req.query);360 const conds = [];361 if (q.type) conds.push(eq(securityEvents.type, q.type));362 if (q.severity) conds.push(eq(securityEvents.severity, q.severity));363 const rows = await db.select({ ev: securityEvents, username: users.username }).from(securityEvents).leftJoin(users, eq(users.id, securityEvents.userId)).where(conds.length ? and(...conds) : undefined).orderBy(desc(securityEvents.createdAt)).limit(q.limit);364 const summary = await db.execute(sql`select type, count(*)::int as n from security_events where created_at >= now() - interval '24 hours' group by type order by n desc`);365 return { events: rows.map((r) => ({ ...r.ev, username: r.username })), summary24h: summary.rows };366 });367368 /* ------------------------------------------------------------ system */369 app.get("/api/admin/system", async (req) => {370 requireAdmin(req);371 const [dbMs, redisMs] = await Promise.all([pingDb().catch(() => -1), pingRedis().catch(() => -1)]);372 const spinLatency = await db.execute(sql`select coalesce(avg(duration_ms),0)::float as avg, coalesce(percentile_cont(0.95) within group (order by duration_ms),0)::float as p95, count(*)::int as n from game_rounds where created_at >= now() - interval '1 hour'`);373 const mem = process.memoryUsage();374 const sizes = await db.execute(sql`select relname as table, pg_total_relation_size(relid)::bigint as bytes, n_live_tup::bigint as rows from pg_stat_user_tables order by bytes desc limit 15`);375 return {376 services: [377 { name: "spinza-api", node: os.hostname(), version: config.version, uptimeSec: Math.round((Date.now() - startedAt) / 1000), status: "ok", latencyMs: 0 },378 { name: "postgresql", node: "local", version: "17", uptimeSec: null, status: dbMs >= 0 ? "ok" : "down", latencyMs: dbMs },379 { name: "redis", node: "local", version: "7", uptimeSec: null, status: redisMs >= 0 ? "ok" : "down", latencyMs: redisMs },380 ],381 process: { rssMb: Math.round(mem.rss / 1048576), heapMb: Math.round(mem.heapUsed / 1048576), cpus: os.cpus().length, load: os.loadavg(), totalMemMb: Math.round(os.totalmem() / 1048576), freeMemMb: Math.round(os.freemem() / 1048576), platform: `${os.platform()} ${os.release()}`, nodeVersion: process.version },382 spins: spinLatency.rows[0],383 tables: sizes.rows,384 games: GAMES.length,385 };386 });387 void or;388}389