Files
joke-app/apps/server/src/http/admin.ts
EJClaw 242ab3ad71 M1.5 관리자 사이트: 슈퍼어드민/어드민 권한, 게임·사이트 설정, 방·사용자 관리, 기록
- 권한: 디스코드 ID로 요청마다 계산. 슈퍼어드민 293719842274541579(SUPERADMIN_DISCORD_IDS),
  어드민은 슈퍼어드민이 디스코드 ID로 추가(로그인 전 미리 등록 가능), 고정 슈퍼어드민은 삭제 불가
- 게임 설정: 사용 여부, 표시 이름·설명·안내문, 원작 범위 안 인원, 모든 규칙 옵션 기본값,
  옵션별 방장 변경 금지(잠금), 초기화. 입력 화면은 옵션 스키마(zod→JSON Schema)로 자동 생성
- 오목 옵션 전체에 한국어 제목·선택지 메타, 엔진 고정값(자동 착수 허용 횟수, 무승부 재제안 간격,
  한 수 제한 초)을 옵션으로 꺼냄
- 사이트 설정: 이름, 공지, 점검 모드, 금지어, 방 수 제한, 연결 유예, 채팅 기본값
- 방 관리(목록·닫기), 사용자 관리(검색·닉네임 변경·이용 제한·강제 로그아웃·삭제, 슈퍼어드민 전용),
  관리자 작업 기록(전/후 값)
- 이용 제한: 세션 삭제 + 접속 종료 + 디스코드 재로그인 거부
- 테스트: 관리자 API 7개(권한·설정 반영·잠금·점검·금지어·방 닫기·이용 제한·기록), 오목 옵션 2개,
  e2e/admin.e2e.ts(슈퍼어드민 로그인→규칙 변경·잠금→어드민 추가→게스트 새 방 반영)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:52:38 +09:00

240 lines
10 KiB
TypeScript

/** 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<string, AnyGameDefinition>;
disconnectUser: (userId: string) => void;
stats: () => Record<string, unknown>;
}
type Env = { Variables: { userId: string | null; role: Role } };
const DISCORD_ID = /^\d{17,20}$/;
export function createAdminApp(d: AdminDeps) {
const app = new Hono<Env>();
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<Env>, next: () => Promise<void>) => {
if (c.get('role') !== 'superadmin') return c.json({ error: '슈퍼어드민만 할 수 있어요.' }, 403);
await next();
};
const actor = (c: Context<Env>) => c.get('userId')!;
const body = async (c: Context<Env>) => 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;
}