/** Admin API (docs/14-admin.md §8). Mounted under /api/admin by createHttpApp. */ import { Hono, type Context } from 'hono'; import { z } from 'zod'; import type { AnyGameDefinition } from '@bg/engine'; import { nicknameError, normalizeNickname } from '@bg/shared'; import type { Audit, Role, Roles } from '../admin/roles'; import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../admin/settings'; import type { Sessions } from '../auth/sessions'; import { toPublicUser, type Users } from '../auth/users'; import type { RoomManager } from '../rooms/manager'; export interface AdminDeps { users: Users; sessions: Sessions; rooms: RoomManager; roles: Roles; audit: Audit; settings: SettingsStore; games: Record; disconnectUser: (userId: string) => void; stats: () => Record; } type Env = { Variables: { userId: string | null; role: Role } }; const DISCORD_ID = /^\d{17,20}$/; export function createAdminApp(d: AdminDeps) { const app = new Hono(); app.use('*', async (c, next) => { const role = d.roles.roleOf(c.get('userId')); if (role === 'user') return c.json({ error: '권한이 없어요.' }, c.get('userId') ? 403 : 401); c.set('role', role); await next(); }); const superOnly = async (c: Context, next: () => Promise) => { if (c.get('role') !== 'superadmin') return c.json({ error: '슈퍼어드민만 할 수 있어요.' }, 403); await next(); }; const actor = (c: Context) => c.get('userId')!; const body = async (c: Context) => c.req.json().catch(() => null); app.get('/me', (c) => c.json({ role: c.get('role'), me: toPublicUser(d.users.get(actor(c))!) })); app.get('/overview', (c) => c.json({ users: d.users.counts(), ...d.stats() })); // ------------------------------------------------------------ games app.get('/games', (c) => c.json({ games: Object.values(d.games).map((def) => ({ id: def.id, codeDefaults: { nameKo: def.nameKo, minPlayers: def.minPlayers, maxPlayers: def.maxPlayers, defaultOptions: def.defaultOptions }, raw: d.settings.rawGame(def.id), effective: d.settings.game(def.id), schema: z.toJSONSchema(def.optionsSchema, { io: 'input', unrepresentable: 'any' }), })), }), ); app.put('/games/:id', async (c) => { const id = c.req.param('id'); if (!d.games[id]) return c.json({ error: '없는 게임이에요.' }, 404); const parsed = GameSettingsSchema.safeParse(await body(c)); if (!parsed.success) return c.json({ error: '입력 값이 올바르지 않아요.' }, 400); const err = d.settings.validateGame(id, parsed.data); if (err) return c.json({ error: err }, 400); const before = d.settings.rawGame(id); d.settings.setGame(id, parsed.data, actor(c)); d.audit.log(actor(c), 'game.update', id, before, d.settings.rawGame(id)); return c.json({ effective: d.settings.game(id), raw: d.settings.rawGame(id) }); }); app.post('/games/:id/reset', (c) => { const id = c.req.param('id'); if (!d.games[id]) return c.json({ error: '없는 게임이에요.' }, 404); const before = d.settings.rawGame(id); d.settings.resetGame(id); d.audit.log(actor(c), 'game.reset', id, before, null); return c.json({ effective: d.settings.game(id), raw: {} }); }); // ------------------------------------------------------------ site app.get('/site', (c) => c.json({ site: d.settings.getSite() })); app.put('/site', async (c) => { const parsed = SiteSettingsSchema.safeParse(await body(c)); if (!parsed.success) return c.json({ error: `입력 값이 올바르지 않아요: ${parsed.error.issues.map((i) => i.path.join('.')).join(', ')}` }, 400); const before = d.settings.getSite(); d.settings.setSite(parsed.data, actor(c)); d.audit.log(actor(c), 'site.update', null, before, parsed.data); return c.json({ site: parsed.data }); }); // ------------------------------------------------------------ rooms app.get('/rooms', (c) => c.json({ rooms: d.rooms.all().map((r) => ({ code: r.code, gameId: r.config.gameId, status: r.status, host: d.users.get(r.hostId)?.nickname ?? null, seats: r.seats.map((s) => (s ? { id: s.userId, nickname: d.users.get(s.userId)?.nickname ?? '?', connected: !!r.presence.get(s.userId)?.connected } : null)), spectators: r.spectators.size, connections: r.conns.size, visibility: r.config.visibility, gameNo: r.gameNo, lastActiveAt: r.lastActiveAt, })), }), ); app.post('/rooms/:code/close', (c) => { const code = c.req.param('code'); if (!d.rooms.closeByAdmin(code)) return c.json({ error: '방을 찾을 수 없어요.' }, 404); d.audit.log(actor(c), 'room.close', code, null, null); return c.json({ ok: true }); }); // ------------------------------------------------------------ audit app.get('/audit', (c) => { const page = Math.max(0, Number(c.req.query('page') ?? 0) || 0); return c.json({ entries: d.audit.list(50, page * 50) }); }); // ------------------------------------------------------------ users (superadmin) app.use('/users/*', superOnly); app.use('/users', superOnly); app.get('/users', (c) => { const q = (c.req.query('q') ?? '').trim().slice(0, 60); const page = Math.max(0, Number(c.req.query('page') ?? 0) || 0); const { rows, total } = d.users.search(q, 30, page * 30); return c.json({ total, users: rows.map((u) => ({ id: u.id, nickname: u.nickname, kind: u.kind, discordId: u.discord_id, discordName: u.discord_name, role: d.roles.roleOfDiscord(u.discord_id), banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null, createdAt: u.created_at, lastSeenAt: u.last_seen_at, })), }); }); app.get('/users/:id', (c) => { const u = d.users.get(c.req.param('id')); if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404); const discordId = d.roles.discordIdOf(u.id); return c.json({ user: { ...toPublicUser(u), discordId, role: d.roles.roleOfDiscord(discordId), banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null, createdAt: u.created_at, lastSeenAt: u.last_seen_at, activeRoom: d.rooms.activeRoomFor(u.id), }, stats: d.users.stats(u.id), }); }); app.patch('/users/:id', async (c) => { const id = c.req.param('id'); const u = d.users.get(id); if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404); const parsed = z .object({ nickname: z.string().max(100).optional(), ban: z.object({ reason: z.string().trim().min(1).max(200) }).nullable().optional() }) .safeParse(await body(c)); if (!parsed.success) return c.json({ error: '입력 값이 올바르지 않아요.' }, 400); const discordId = d.roles.discordIdOf(id); if (parsed.data.ban && discordId && d.roles.isSuperadminDiscord(discordId)) return c.json({ error: '슈퍼어드민은 제한할 수 없어요.' }, 400); if (parsed.data.nickname !== undefined) { const nick = normalizeNickname(parsed.data.nickname); const e = nicknameError(nick, d.settings.getSite().bannedWords); if (e) return c.json({ error: e }, 400); d.users.forceNickname(id, nick); d.audit.log(actor(c), 'user.rename', id, u.nickname, nick); } if (parsed.data.ban !== undefined) { if (parsed.data.ban) { d.users.setBan(id, parsed.data.ban.reason); d.sessions.revokeAll(id); d.disconnectUser(id); d.audit.log(actor(c), 'user.ban', id, null, parsed.data.ban); } else { d.users.setBan(id, null); d.audit.log(actor(c), 'user.unban', id, { reason: u.banned_reason }, null); } } return c.json({ ok: true }); }); app.post('/users/:id/logout', (c) => { const id = c.req.param('id'); if (!d.users.get(id)) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404); d.sessions.revokeAll(id); d.disconnectUser(id); d.audit.log(actor(c), 'user.logout', id, null, null); return c.json({ ok: true }); }); app.delete('/users/:id', (c) => { const id = c.req.param('id'); const u = d.users.get(id); if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404); const discordId = d.roles.discordIdOf(id); if (discordId && d.roles.isSuperadminDiscord(discordId)) return c.json({ error: '슈퍼어드민은 삭제할 수 없어요.' }, 400); d.disconnectUser(id); d.users.softDelete(id); d.audit.log(actor(c), 'user.delete', id, { nickname: u.nickname, discordId }, null); return c.json({ ok: true }); }); // ------------------------------------------------------------ admins (superadmin) app.use('/admins/*', superOnly); app.use('/admins', superOnly); app.get('/admins', (c) => c.json({ admins: d.roles.listAdmins() })); app.post('/admins', async (c) => { const parsed = z.object({ discordId: z.string().trim(), note: z.string().trim().max(100).optional() }).safeParse(await body(c)); if (!parsed.success || !DISCORD_ID.test(parsed.data.discordId)) return c.json({ error: '디스코드 ID는 숫자 17~20자리예요.' }, 400); if (d.roles.isSuperadminDiscord(parsed.data.discordId)) return c.json({ error: '이미 슈퍼어드민이에요.' }, 400); if (!d.roles.addAdmin(parsed.data.discordId, parsed.data.note ?? null, actor(c))) return c.json({ error: '이미 등록된 어드민이에요.' }, 400); d.audit.log(actor(c), 'admin.add', parsed.data.discordId, null, { note: parsed.data.note ?? null }); return c.json({ admins: d.roles.listAdmins() }); }); app.delete('/admins/:discordId', (c) => { const id = c.req.param('discordId'); if (d.roles.isSuperadminDiscord(id)) return c.json({ error: '설정 파일의 슈퍼어드민은 지울 수 없어요.' }, 400); if (!d.roles.removeAdmin(id)) return c.json({ error: '등록되지 않은 디스코드 ID예요.' }, 404); d.audit.log(actor(c), 'admin.remove', id, null, null); return c.json({ admins: d.roles.listAdmins() }); }); return app; }