SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
28.4 KB · 389 lines typescript
Raw Blame History
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