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>
This commit is contained in:
EJClaw
2026-10-04 04:52:38 +09:00
parent b2f998fa95
commit 242ab3ad71
31 changed files with 2009 additions and 68 deletions

View File

@@ -0,0 +1,208 @@
/** Admin site tests (docs/14-admin.md §9). */
import { afterEach, describe, expect, test } from 'bun:test';
import { Client, api, boot, guest, sleep } from './test-utils';
const SUPER = '293719842274541579';
type Booted = ReturnType<typeof boot>;
const running: Booted[] = [];
const clients: Client[] = [];
afterEach(async () => {
for (const c of clients.splice(0)) c.close();
for (const s of running.splice(0)) await s.stop();
});
function start(): Booted {
const s = boot(undefined, {
discordExchange: async (code) => ({ id: code, username: `user${code.slice(-4)}`, global_name: `디코${code.slice(-4)}`, avatar: null }),
});
s.config.discord = { clientId: 'cid', clientSecret: 'sec' };
running.push(s);
return s;
}
/** Logs in through the (mocked) Discord flow; returns the session cookie and user id. */
async function discordLogin(s: Booted, discordId: string): Promise<{ cookie: string; id: string }> {
const st = await api(s.base, '/api/auth/discord/start?next=/');
const state = new URL(st.res.headers.get('location')!).searchParams.get('state')!;
const oauth = st.setCookie!.split(';')[0]!;
const cb = await api(s.base, `/api/auth/discord/callback?code=${discordId}&state=${state}`, { cookie: oauth });
const loc = cb.res.headers.get('location');
if (loc !== '/') throw new Error(`login redirect ${loc}`);
const sid = cb.setCookie!.split(/,(?=\s*\w+=)/).find((c) => c.trim().startsWith('sid='))!.split(';')[0]!.trim();
const me = await api(s.base, '/api/me', { cookie: sid });
return { cookie: sid, id: me.body.me.id };
}
describe('roles', () => {
test('guest and plain discord users get 401/403; superadmin by fixed Discord ID', async () => {
const s = start();
const g = await guest(s.base, '손님');
expect((await api(s.base, '/api/admin/me', { cookie: g.cookie })).res.status).toBe(403);
expect((await api(s.base, '/api/admin/me')).res.status).toBe(401);
const plain = await discordLogin(s, '111111111111111111');
expect((await api(s.base, '/api/admin/overview', { cookie: plain.cookie })).res.status).toBe(403);
expect((await api(s.base, '/api/me', { cookie: plain.cookie })).body.role).toBe('user');
const sup = await discordLogin(s, SUPER);
const me = await api(s.base, '/api/admin/me', { cookie: sup.cookie });
expect(me.body.role).toBe('superadmin');
expect((await api(s.base, '/api/me', { cookie: sup.cookie })).body.role).toBe('superadmin');
});
test('superadmin adds/removes an admin by Discord ID; takes effect immediately; admin cannot manage users', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const adminDiscord = '222222222222222222';
const bad = await api(s.base, '/api/admin/admins', { method: 'POST', cookie: sup.cookie, body: JSON.stringify({ discordId: '12ab' }) });
expect(bad.res.status).toBe(400);
// Pre-register before the person ever logged in.
const add = await api(s.base, '/api/admin/admins', { method: 'POST', cookie: sup.cookie, body: JSON.stringify({ discordId: adminDiscord, note: '친구1' }) });
expect(add.res.status).toBe(200);
expect(add.body.admins.map((a: any) => a.discordId)).toEqual([SUPER, adminDiscord]);
const adm = await discordLogin(s, adminDiscord);
expect((await api(s.base, '/api/admin/me', { cookie: adm.cookie })).body.role).toBe('admin');
expect((await api(s.base, '/api/admin/users', { cookie: adm.cookie })).res.status).toBe(403);
expect((await api(s.base, '/api/admin/admins', { cookie: adm.cookie })).res.status).toBe(403);
expect((await api(s.base, '/api/admin/games', { cookie: adm.cookie })).res.status).toBe(200);
// Fixed superadmin cannot be removed.
expect((await api(s.base, `/api/admin/admins/${SUPER}`, { method: 'DELETE', cookie: sup.cookie })).res.status).toBe(400);
expect((await api(s.base, `/api/admin/admins/${adminDiscord}`, { method: 'DELETE', cookie: sup.cookie })).res.status).toBe(200);
expect((await api(s.base, '/api/admin/me', { cookie: adm.cookie })).res.status).toBe(403);
const audit = await api(s.base, '/api/admin/audit', { cookie: sup.cookie });
expect(audit.body.entries.map((e: any) => e.action)).toEqual(['admin.remove', 'admin.add']);
});
test('admin API rejects foreign-origin writes', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const res = await fetch(s.base + '/api/admin/site', {
method: 'PUT',
headers: { origin: 'https://evil.example', cookie: sup.cookie, 'content-type': 'application/json' },
body: JSON.stringify({ maintenance: true }),
});
expect(res.status).toBe(403);
});
});
describe('game settings', () => {
test('schema exposed with Korean titles; defaults apply to new rooms; locked options win; invalid values rejected', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const games = await api(s.base, '/api/admin/games', { cookie: sup.cookie });
const omok = games.body.games.find((g: any) => g.id === 'omok');
expect(omok.schema.properties.ruleSet.title).toBe('규칙');
expect(omok.schema.properties.autoMoveLimit.title).toContain('자동 착수');
const bad = await api(s.base, '/api/admin/games/omok', {
method: 'PUT',
cookie: sup.cookie,
body: JSON.stringify({ defaultOptions: { ruleSet: 'nope' } }),
});
expect(bad.res.status).toBe(400);
const tooMany = await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ maxPlayers: 3 }) });
expect(tooMany.res.status).toBe(400);
const put = await api(s.base, '/api/admin/games/omok', {
method: 'PUT',
cookie: sup.cookie,
body: JSON.stringify({
nameKo: '오목(친구룰)',
notice: '렌주룰로만 해요',
defaultOptions: { ruleSet: 'renju', timeControl: { kind: 'perMove', perMoveSec: 45 } },
lockedOptions: ['ruleSet'],
}),
});
expect(put.res.status).toBe(200);
expect(put.body.effective.defaultOptions.timeControl.perMoveSec).toBe(45);
const cfg = await api(s.base, '/api/config');
expect(cfg.body.catalog.find((g: any) => g.id === 'omok')).toMatchObject({ nameKo: '오목(친구룰)', notice: '렌주룰로만 해요' });
// A host creates a room → admin defaults; trying to change the locked rule is overridden.
const host = await guest(s.base, '방장');
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) });
const c = await new Client(s.base, host.cookie, host.id).connect();
clients.push(c);
c.send({ t: 'join', code: room.body.code, as: 'player' });
const first = await c.next('room');
expect((first.room.options as any).ruleSet).toBe('renju');
expect(first.room.lockedOptions).toEqual(['ruleSet']);
c.send({ t: 'config', options: { ...(first.room.options as any), ruleSet: 'free', boardSize: 19 } });
const after = await c.next('room', (m) => (m.room.options as any).boardSize === 19);
expect((after.room.options as any).ruleSet).toBe('renju');
// Disable the game → creation refused and hidden from catalog.
await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ enabled: false }) });
const refused = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) });
expect(refused.res.status).toBe(400);
expect((await api(s.base, '/api/config')).body.catalog.find((g: any) => g.id === 'omok').available).toBe(false);
// Reset restores code defaults.
await api(s.base, '/api/admin/games/omok/reset', { method: 'POST', cookie: sup.cookie });
expect((await api(s.base, '/api/config')).body.catalog.find((g: any) => g.id === 'omok')).toMatchObject({ nameKo: '오목', available: true });
});
});
describe('site settings, rooms, users', () => {
test('maintenance blocks room creation; banned words masked in chat and rejected in nicknames', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const site = (await api(s.base, '/api/admin/site', { cookie: sup.cookie })).body.site;
await api(s.base, '/api/admin/site', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ ...site, maintenance: true, announcement: '점검 중이에요' }) });
const g = await guest(s.base, '방장님');
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
expect(r.res.status).toBe(429);
expect(r.body.error).toBe('점검 중이에요');
expect((await api(s.base, '/api/config')).body).toMatchObject({ maintenance: true, announcement: '점검 중이에요' });
await api(s.base, '/api/admin/site', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ ...site, bannedWords: ['바보'] }) });
expect((await api(s.base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: '왕바보' }) })).res.status).toBe(400);
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
const c = await new Client(s.base, g.cookie, g.id).connect();
clients.push(c);
c.send({ t: 'join', code: room.body.code, as: 'player' });
await c.next('room');
const chat = c.next('chat');
c.send({ t: 'chat', text: '너 바보야' });
expect((await chat).text).toBe('너 **야');
});
test('admin closes a room: everyone is told and disconnected from it', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const g = await guest(s.base, '방주인');
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
const c = await new Client(s.base, g.cookie, g.id).connect();
clients.push(c);
c.send({ t: 'join', code: room.body.code, as: 'player' });
await c.next('room');
const list = await api(s.base, '/api/admin/rooms', { cookie: sup.cookie });
expect(list.body.rooms.map((r: any) => r.code)).toContain(room.body.code);
const bye = c.next('bye');
expect((await api(s.base, `/api/admin/rooms/${room.body.code}/close`, { method: 'POST', cookie: sup.cookie })).res.status).toBe(200);
expect((await bye).reason).toBe('closed');
expect((await api(s.base, `/api/rooms/${room.body.code}`)).res.status).toBe(404);
});
test('superadmin: search, rename, ban (sessions revoked, socket closed, discord re-login refused), unban', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const victim = await discordLogin(s, '333333333333333333');
const c = await new Client(s.base, victim.cookie, victim.id).connect();
clients.push(c);
const found = await api(s.base, '/api/admin/users?q=333333333333333333', { cookie: sup.cookie });
expect(found.body.users.map((u: any) => u.id)).toEqual([victim.id]);
const ren = await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ nickname: '바뀐 이름' }) });
expect(ren.res.status).toBe(200);
expect((await api(s.base, '/api/me', { cookie: victim.cookie })).body.me.nickname).toBe('바뀐 이름');
const ban = await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: { reason: '도배' } }) });
expect(ban.res.status).toBe(200);
for (let i = 0; i < 20 && !c.closed; i++) await sleep(25);
expect(c.closed?.code).toBe(4403);
expect((await api(s.base, '/api/me', { cookie: victim.cookie })).body.me).toBeNull();
await expect(discordLogin(s, '333333333333333333')).rejects.toThrow('/?login=banned');
// Superadmin cannot ban themselves.
const self = await api(s.base, `/api/admin/users/${sup.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: { reason: 'x' } }) });
expect(self.res.status).toBe(400);
await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: null }) });
const again = await discordLogin(s, '333333333333333333');
expect(again.id).toBe(victim.id);
const actions = (await api(s.base, '/api/admin/audit', { cookie: sup.cookie })).body.entries.map((e: any) => e.action);
expect(actions).toEqual(['user.unban', 'user.ban', 'user.rename']);
});
});

View File

@@ -0,0 +1,90 @@
/** Role resolution by linked Discord ID (docs/14-admin.md §2). Computed per request. */
import type { Database } from 'bun:sqlite';
export type Role = 'user' | 'admin' | 'superadmin';
export class Roles {
constructor(
private db: Database,
private superadminDiscordIds: string[],
) {}
discordIdOf(userId: string): string | null {
return (
this.db
.query<{ provider_user_id: string }, [string]>("SELECT provider_user_id FROM oauth_accounts WHERE user_id = ? AND provider = 'discord'")
.get(userId)?.provider_user_id ?? null
);
}
roleOfDiscord(discordId: string | null): Role {
if (!discordId) return 'user';
if (this.superadminDiscordIds.includes(discordId)) return 'superadmin';
const row = this.db.query<{ n: number }, [string]>('SELECT COUNT(*) AS n FROM admins WHERE discord_id = ?').get(discordId);
return (row?.n ?? 0) > 0 ? 'admin' : 'user';
}
roleOf(userId: string | null): Role {
return userId ? this.roleOfDiscord(this.discordIdOf(userId)) : 'user';
}
isSuperadminDiscord(discordId: string): boolean {
return this.superadminDiscordIds.includes(discordId);
}
listAdmins(): { discordId: string; note: string | null; addedBy: string | null; addedAt: number; userId: string | null; nickname: string | null; fixed: boolean }[] {
const rows = this.db
.query<{ discord_id: string; note: string | null; added_by: string | null; added_at: number; user_id: string | null; nickname: string | null }, []>(
`SELECT a.discord_id, a.note, a.added_by, a.added_at, o.user_id, u.nickname FROM admins a
LEFT JOIN oauth_accounts o ON o.provider = 'discord' AND o.provider_user_id = a.discord_id
LEFT JOIN users u ON u.id = o.user_id ORDER BY a.added_at`,
)
.all()
.map((r) => ({ discordId: r.discord_id, note: r.note, addedBy: r.added_by, addedAt: r.added_at, userId: r.user_id, nickname: r.nickname, fixed: false }));
const fixed = this.superadminDiscordIds.map((d) => {
const u = this.db
.query<{ user_id: string; nickname: string }, [string]>(
"SELECT o.user_id, u.nickname FROM oauth_accounts o JOIN users u ON u.id = o.user_id WHERE o.provider = 'discord' AND o.provider_user_id = ?",
)
.get(d);
return { discordId: d, note: '슈퍼어드민(설정 고정)', addedBy: null, addedAt: 0, userId: u?.user_id ?? null, nickname: u?.nickname ?? null, fixed: true };
});
return [...fixed, ...rows];
}
addAdmin(discordId: string, note: string | null, by: string, now = Date.now()): boolean {
return this.db.query('INSERT OR IGNORE INTO admins (discord_id, note, added_by, added_at) VALUES (?, ?, ?, ?)').run(discordId, note, by, now).changes > 0;
}
removeAdmin(discordId: string): boolean {
return this.db.query('DELETE FROM admins WHERE discord_id = ?').run(discordId).changes > 0;
}
}
export class Audit {
constructor(private db: Database) {}
log(actorId: string, action: string, target: string | null, before: unknown, after: unknown, now = Date.now()): void {
this.db
.query('INSERT INTO admin_audit (actor_id, action, target, before_json, after_json, at) VALUES (?, ?, ?, ?, ?, ?)')
.run(actorId, action, target, before === undefined ? null : JSON.stringify(before), after === undefined ? null : JSON.stringify(after), now);
}
list(limit: number, offset: number) {
return this.db
.query<{ id: number; actor_id: string; nickname: string | null; action: string; target: string | null; before_json: string | null; after_json: string | null; at: number }, [number, number]>(
'SELECT a.*, u.nickname FROM admin_audit a LEFT JOIN users u ON u.id = a.actor_id ORDER BY a.id DESC LIMIT ? OFFSET ?',
)
.all(limit, offset)
.map((r) => ({
id: r.id,
actorId: r.actor_id,
actor: r.nickname,
action: r.action,
target: r.target,
before: r.before_json ? JSON.parse(r.before_json) : null,
after: r.after_json ? JSON.parse(r.after_json) : null,
at: r.at,
}));
}
}

View File

@@ -0,0 +1,159 @@
/**
* Admin-editable site and game settings with an in-memory cache (docs/14-admin.md §4–§5).
* Changes apply to new rooms / new games only.
*/
import type { Database } from 'bun:sqlite';
import { z } from 'zod';
import type { AnyGameDefinition } from '@bg/engine';
import { CATALOG, type CatalogEntry } from '@bg/shared';
export const SiteSettingsSchema = z.object({
siteName: z.string().trim().min(1).max(40).default('같이 놀자 보드게임'),
announcement: z.string().trim().max(300).default(''),
maintenance: z.boolean().default(false),
bannedWords: z.array(z.string().trim().min(1).max(30)).max(500).default([]),
maxRoomsPerUser: z.number().int().min(1).max(50).default(3),
maxRooms: z.number().int().min(10).max(20000).default(2000),
graceSec: z.number().int().min(10).max(600).default(60),
chatEnabledDefault: z.boolean().default(true),
chatFilterDefault: z.boolean().default(true),
});
export type SiteSettings = z.infer<typeof SiteSettingsSchema>;
export const GameSettingsSchema = z.object({
enabled: z.boolean().optional(),
nameKo: z.string().trim().min(1).max(30).optional(),
blurb: z.string().trim().max(80).optional(),
notice: z.string().trim().max(500).optional(),
minPlayers: z.number().int().min(1).max(32).optional(),
maxPlayers: z.number().int().min(1).max(32).optional(),
defaultOptions: z.unknown().optional(),
lockedOptions: z.array(z.string().max(60)).max(100).optional(),
});
export type GameSettings = z.infer<typeof GameSettingsSchema>;
export interface EffectiveGame {
id: string;
enabled: boolean;
nameKo: string;
blurb: string;
notice: string;
minPlayers: number;
maxPlayers: number;
defaultOptions: unknown;
lockedOptions: string[];
}
export class SettingsStore {
private site: SiteSettings;
private games = new Map<string, GameSettings>();
constructor(
private db: Database,
private defs: Record<string, AnyGameDefinition>,
) {
const row = db.query<{ json: string }, [string]>("SELECT json FROM site_settings WHERE key = ?").get('site');
this.site = SiteSettingsSchema.parse(row ? JSON.parse(row.json) : {});
for (const r of db.query<{ game_id: string; json: string }, []>('SELECT game_id, json FROM game_settings').all()) {
const parsed = GameSettingsSchema.safeParse(JSON.parse(r.json));
if (parsed.success) this.games.set(r.game_id, parsed.data);
}
}
getSite(): SiteSettings {
return this.site;
}
setSite(next: SiteSettings, by: string, now = Date.now()): void {
this.db
.query('INSERT INTO site_settings (key, json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_by = excluded.updated_by, updated_at = excluded.updated_at')
.run('site', JSON.stringify(next), by, now);
this.site = next;
}
rawGame(id: string): GameSettings {
return this.games.get(id) ?? {};
}
/** Validates against the game's own schemas. Returns an error message or null. */
validateGame(id: string, s: GameSettings): string | null {
const def = this.defs[id];
if (!def) return '없는 게임이에요.';
if (s.defaultOptions !== undefined) {
const r = def.optionsSchema.safeParse(s.defaultOptions);
if (!r.success) return `기본 규칙 값이 올바르지 않아요: ${r.error.issues.map((i) => i.path.join('.') + ' ' + i.message).join(', ')}`;
}
const min = s.minPlayers ?? def.minPlayers;
const max = s.maxPlayers ?? def.maxPlayers;
if (min < def.minPlayers || max > def.maxPlayers || min > max) {
return `인원은 원작 범위(${def.minPlayers}~${def.maxPlayers}명) 안에서만 정할 수 있어요.`;
}
return null;
}
setGame(id: string, s: GameSettings, by: string, now = Date.now()): void {
const def = this.defs[id]!;
const clean: GameSettings = { ...s };
if (clean.defaultOptions !== undefined) clean.defaultOptions = def.optionsSchema.parse(clean.defaultOptions);
this.db
.query('INSERT INTO game_settings (game_id, json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(game_id) DO UPDATE SET json = excluded.json, updated_by = excluded.updated_by, updated_at = excluded.updated_at')
.run(id, JSON.stringify(clean), by, now);
this.games.set(id, clean);
}
resetGame(id: string): void {
this.db.query('DELETE FROM game_settings WHERE game_id = ?').run(id);
this.games.delete(id);
}
game(id: string): EffectiveGame | null {
const def = this.defs[id];
if (!def) return null;
const meta = CATALOG.find((c) => c.id === id);
const s = this.rawGame(id);
let defaults: unknown = def.defaultOptions;
if (s.defaultOptions !== undefined) {
const r = def.optionsSchema.safeParse(s.defaultOptions);
if (r.success) defaults = r.data;
}
return {
id,
enabled: s.enabled ?? meta?.available ?? true,
nameKo: s.nameKo ?? meta?.nameKo ?? def.nameKo,
blurb: s.blurb ?? meta?.blurb ?? '',
notice: s.notice ?? '',
minPlayers: s.minPlayers ?? def.minPlayers,
maxPlayers: s.maxPlayers ?? def.maxPlayers,
defaultOptions: defaults,
lockedOptions: s.lockedOptions ?? [],
};
}
/** Catalog for /api/config: code metadata merged with admin overrides. */
catalog(): (CatalogEntry & { notice: string })[] {
return CATALOG.map((c) => {
const g = this.game(c.id);
if (!g) return { ...c, available: false, notice: '' };
return { ...c, nameKo: g.nameKo, blurb: g.blurb, minPlayers: g.minPlayers, maxPlayers: g.maxPlayers, available: g.enabled, notice: g.notice };
});
}
}
/** Forces locked option paths (dot-separated) to the admin default values. */
export function applyLocks(options: unknown, defaults: unknown, locked: string[]): unknown {
if (!locked.length || typeof options !== 'object' || options === null) return options;
const out = structuredClone(options) as Record<string, unknown>;
for (const path of locked) {
const keys = path.split('.');
let src: unknown = defaults;
for (const k of keys) src = (src as Record<string, unknown> | undefined)?.[k];
if (src === undefined) continue;
let dst = out;
for (const k of keys.slice(0, -1)) {
if (typeof dst[k] !== 'object' || dst[k] === null) dst[k] = {};
dst = dst[k] as Record<string, unknown>;
}
dst[keys[keys.length - 1]!] = structuredClone(src);
}
return out;
}

View File

@@ -32,7 +32,7 @@ export class Sessions {
const row = this.db
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>(
`SELECT s.user_id, s.expires_at, s.last_used_at, u.kind FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND u.deleted_at IS NULL`,
WHERE s.token_hash = ? AND u.deleted_at IS NULL AND u.banned_at IS NULL`,
)
.get(hash);
if (!row) return null;

View File

@@ -14,6 +14,8 @@ export interface UserRow {
created_at: number;
last_seen_at: number;
deleted_at: number | null;
banned_at: number | null;
banned_reason: string | null;
}
export const NICK_CHANGES_PER_DAY = 10;
@@ -111,6 +113,39 @@ export class Users {
}
}
/** Admin rename: ignores the daily limit. */
forceNickname(userId: string, nickname: string): void {
this.db.query('UPDATE users SET nickname = ? WHERE id = ?').run(nickname, userId);
}
setBan(userId: string, reason: string | null, now = Date.now()): void {
if (reason === null) this.db.query('UPDATE users SET banned_at = NULL, banned_reason = NULL WHERE id = ?').run(userId);
else this.db.query('UPDATE users SET banned_at = ?, banned_reason = ? WHERE id = ?').run(now, reason, userId);
}
search(q: string, limit: number, offset: number) {
const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
const rows = this.db
.query<UserRow & { discord_id: string | null; discord_name: string | null }, [string, string, string, string, number, number]>(
`SELECT u.*, o.provider_user_id AS discord_id, o.username AS discord_name FROM users u
LEFT JOIN oauth_accounts o ON o.user_id = u.id AND o.provider = 'discord'
WHERE u.deleted_at IS NULL AND (? = '' OR u.nickname LIKE ? ESCAPE '\\' OR u.id = ? OR o.provider_user_id = ?)
ORDER BY u.last_seen_at DESC LIMIT ? OFFSET ?`,
)
.all(q, like, q, q, limit, offset);
const total = this.db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM users WHERE deleted_at IS NULL').get()!.n;
return { rows, total };
}
counts(): { total: number; guests: number; members: number; banned: number } {
return this.db
.query<{ total: number; guests: number; members: number; banned: number }, []>(
`SELECT COUNT(*) AS total, SUM(kind = 'guest') AS guests, SUM(kind = 'member') AS members, SUM(banned_at IS NOT NULL) AS banned
FROM users WHERE deleted_at IS NULL`,
)
.get()!;
}
touch(userId: string, now = Date.now()): void {
this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId);
}

View File

@@ -9,8 +9,12 @@ export interface Config {
secureCookies: boolean;
/** Trust X-Forwarded-For (only when running behind our reverse proxy). */
trustProxy: boolean;
superadminDiscordIds: string[];
}
/** The site owner's Discord ID (docs/14-admin.md §2). */
export const DEFAULT_SUPERADMIN = '293719842274541579';
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, '');
const extra = (env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
@@ -29,5 +33,6 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me',
secureCookies: publicOrigin.startsWith('https://'),
trustProxy: env.TRUST_PROXY === '1',
superadminDiscordIds: (env.SUPERADMIN_DISCORD_IDS ?? DEFAULT_SUPERADMIN).split(',').map((s) => s.trim()).filter(Boolean),
};
}

View File

@@ -0,0 +1,34 @@
CREATE TABLE admins (
discord_id TEXT PRIMARY KEY,
note TEXT,
added_by TEXT,
added_at INTEGER NOT NULL
);
CREATE TABLE game_settings (
game_id TEXT PRIMARY KEY,
json TEXT NOT NULL,
updated_by TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE site_settings (
key TEXT PRIMARY KEY,
json TEXT NOT NULL,
updated_by TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE admin_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT,
before_json TEXT,
after_json TEXT,
at INTEGER NOT NULL
);
CREATE INDEX admin_audit_at ON admin_audit(at);
ALTER TABLE users ADD COLUMN banned_at INTEGER;
ALTER TABLE users ADD COLUMN banned_reason TEXT;

View File

@@ -0,0 +1,239 @@
/** 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;
}

View File

@@ -2,13 +2,16 @@
import { Hono, type Context } from 'hono';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { z } from 'zod';
import { CATALOG, catalogById, nicknameError, normalizeNickname, normalizeRoomCode, formatRoomCode } from '@bg/shared';
import { nicknameError, normalizeNickname, normalizeRoomCode, formatRoomCode } from '@bg/shared';
import type { Config } from '../config';
import { authorizeUrl, avatarUrl, exchangeCode, safeNext, type DiscordUser } from '../auth/discord';
import { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions';
import { toPublicUser, type Users } from '../auth/users';
import type { RoomManager } from '../rooms/manager';
import { WindowLimiter } from '../ws/rate-limit';
import { createAdminApp, type AdminDeps } from './admin';
import type { Roles } from '../admin/roles';
import type { SettingsStore } from '../admin/settings';
export interface HttpDeps {
config: Config;
@@ -19,6 +22,9 @@ export interface HttpDeps {
discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>;
ipOf: (req: Request) => string;
health: () => { ok: boolean; [k: string]: unknown };
roles: Roles;
settings: SettingsStore;
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'roles' | 'settings'>;
}
type Env = { Variables: { userId: string | null } };
@@ -68,14 +74,23 @@ export function createHttpApp(d: HttpDeps) {
return c.json(h, h.ok ? 200 : 503);
});
app.get('/api/config', (c) => c.json({ discord: d.config.discord !== null, catalog: CATALOG }));
app.get('/api/config', (c) => {
const site = d.settings.getSite();
return c.json({
discord: d.config.discord !== null,
catalog: d.settings.catalog(),
siteName: site.siteName,
announcement: site.announcement,
maintenance: site.maintenance,
});
});
app.post('/api/auth/guest', async (c) => {
if (!guestLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const body = z.object({ nickname: z.string().max(100) }).safeParse(await c.req.json().catch(() => null));
if (!body.success) return c.json({ error: '닉네임을 입력해 주세요.' }, 400);
const nick = normalizeNickname(body.data.nickname);
const e = nicknameError(nick);
const e = nicknameError(nick, d.settings.getSite().bannedWords);
if (e) return c.json({ error: e }, 400);
const current = requireUser(c);
if (current) {
@@ -119,6 +134,7 @@ export function createHttpApp(d: HttpDeps) {
}
const avatar = avatarUrl(du);
const existing = d.users.findByOauth('discord', du.id);
if (existing?.banned_at) return c.redirect('/?login=banned');
const current = requireUser(c) ? d.users.get(requireUser(c)!) : null;
let userId: string;
let merged = false;
@@ -170,6 +186,7 @@ export function createHttpApp(d: HttpDeps) {
me: toPublicUser(row),
settings: JSON.parse(row.settings_json),
useAvatar: !!row.use_avatar,
role: d.roles.roleOf(row.id),
hasDiscordAvatar: !!row.avatar_url,
activeRoom: d.rooms.activeRoomFor(row.id),
});
@@ -197,7 +214,7 @@ export function createHttpApp(d: HttpDeps) {
if (!body.success) return c.json({ error: '잘못된 요청이에요.' }, 400);
if (body.data.nickname !== undefined) {
const nick = normalizeNickname(body.data.nickname);
const e = nicknameError(nick) ?? d.users.changeNickname(u, nick);
const e = nicknameError(nick, d.settings.getSite().bannedWords) ?? d.users.changeNickname(u, nick);
if (e) return c.json({ error: e }, 400);
}
d.users.updateSettings(u, { useAvatar: body.data.useAvatar, settings: body.data.settings });
@@ -227,7 +244,7 @@ export function createHttpApp(d: HttpDeps) {
const body = z
.object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional() })
.safeParse(await c.req.json().catch(() => null));
if (!body.success || !catalogById(body.data.gameId)?.available) return c.json({ error: '없는 게임이에요.' }, 400);
if (!body.success || !d.settings.game(body.data.gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400);
const r = d.rooms.create(u, body.data.gameId, body.data.visibility);
if (typeof r === 'string') return c.json({ error: r }, 429);
return c.json({ code: r.code });
@@ -237,7 +254,7 @@ export function createHttpApp(d: HttpDeps) {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
const gameId = c.req.param('gameId');
if (!catalogById(gameId)?.available) return c.json({ error: '없는 게임이에요.' }, 400);
if (!d.settings.game(gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400);
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const r = d.rooms.quickPlay(u, gameId);
if (typeof r === 'string') return c.json({ error: r }, 429);
@@ -264,5 +281,10 @@ export function createHttpApp(d: HttpDeps) {
app.get('/api/games/:gameId/rooms', (c) => c.json({ rooms: d.rooms.publicRooms(c.req.param('gameId')) }));
app.route(
'/api/admin',
createAdminApp({ ...d.admin, users: d.users, sessions: d.sessions, rooms: d.rooms, roles: d.roles, settings: d.settings }),
);
return app;
}

View File

@@ -4,6 +4,7 @@ import type { AnyGameDefinition } from '@bg/engine';
import { isAllowedRoomCode, type PublicUser } from '@bg/shared';
import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room';
import type { RoomStore } from './store';
import type { SettingsStore } from '../admin/settings';
export const MAX_ROOMS = 2000;
export const MAX_ROOMS_PER_USER = 3;
@@ -17,6 +18,7 @@ export interface ManagerDeps {
now?: () => number;
log?: RoomDeps['log'];
random?: () => number;
settings?: SettingsStore;
}
export class RoomManager {
@@ -37,6 +39,7 @@ export class RoomManager {
users: this.deps.users,
now: this.now,
log: this.deps.log,
settings: this.deps.settings,
onSeatChange: (room, userId, seated) => {
const set = this.seatIndex.get(userId) ?? new Set<string>();
if (seated) set.add(room.code);
@@ -69,19 +72,26 @@ export class RoomManager {
/** Returns the room or a Korean error message. */
create(hostId: string, gameId: string, visibility: 'private' | 'public' = 'private'): Room | string {
const def = this.deps.games[gameId];
if (!def) return '없는 게임이에요.';
if (this.byCode.size >= MAX_ROOMS) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.';
const eff = this.deps.settings?.game(gameId);
const site = this.deps.settings?.getSite();
if (!def || (eff && !eff.enabled)) return '없는 게임이에요.';
if (site?.maintenance) return site.announcement || '지금은 점검 중이라 새 방을 만들 수 없어요.';
const maxRooms = site?.maxRooms ?? MAX_ROOMS;
const perUser = site?.maxRoomsPerUser ?? MAX_ROOMS_PER_USER;
if (this.byCode.size >= maxRooms) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.';
const mine = [...(this.createdBy.get(hostId) ?? [])].filter((c) => this.byCode.has(c));
if (mine.length >= MAX_ROOMS_PER_USER) return `방은 한 번에 ${MAX_ROOMS_PER_USER}개까지 만들 수 있어요.`;
if (mine.length >= perUser) return `방은 한 번에 ${perUser}개까지 만들 수 있어요.`;
const options = eff?.defaultOptions ?? def.defaultOptions;
const base = def.playersFor ? def.playersFor(options).max : def.maxPlayers;
const config: RoomConfig = {
gameId,
options: def.defaultOptions,
maxPlayers: def.playersFor ? def.playersFor(def.defaultOptions).max : def.maxPlayers,
options,
maxPlayers: Math.min(base, eff?.maxPlayers ?? base),
visibility,
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
chatEnabled: site?.chatEnabledDefault ?? true,
chatFilter: site?.chatFilterDefault ?? true,
graceSec: site?.graceSec ?? 60,
};
const code = this.newCode();
const id = ulid(this.now());
@@ -174,6 +184,14 @@ export class RoomManager {
}
}
closeByAdmin(code: string): boolean {
const room = this.byCode.get(code);
if (!room) return false;
room.closeByAdmin();
this.close(room);
return true;
}
close(room: Room): void {
room.shutdown();
this.deps.store.close(room.id, this.now());

View File

@@ -7,6 +7,7 @@ import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResu
import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
import { sha256Hex } from '../auth/sessions';
import type { RoomStore } from './store';
import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings';
export interface Conn {
userId: string;
@@ -76,6 +77,8 @@ export interface RoomDeps {
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
onEmpty?: (room: Room) => void;
onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
/** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */
settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings };
}
const CHAT_HISTORY = 30;
@@ -126,8 +129,23 @@ export class Room {
}
playerRange(): { min: number; max: number } {
const d = this.def;
return d.playersFor ? d.playersFor(this.config.options) : { min: d.minPlayers, max: d.maxPlayers };
return this.rangeFor(this.config.gameId, this.config.options);
}
/** Game's own range (per options) intersected with the admin-configured range. */
private rangeFor(gameId: string, options: unknown): { min: number; max: number } {
const d = this.deps.games[gameId]!;
const base = d.playersFor ? d.playersFor(options) : { min: d.minPlayers, max: d.maxPlayers };
const eff = this.deps.settings?.game(gameId);
if (!eff) return base;
const min = Math.max(base.min, eff.minPlayers);
const max = Math.min(base.max, eff.maxPlayers);
return min <= max ? { min, max } : base;
}
private locked(gameId: string): { defaults: unknown; paths: string[] } {
const eff = this.deps.settings?.game(gameId);
return { defaults: eff?.defaultOptions, paths: eff?.lockedOptions ?? [] };
}
seatOf(userId: string): number {
@@ -239,6 +257,7 @@ export class Room {
rematchVotes: [...this.rematchVotes],
sessionStats: this.sessionStats,
seedHash: this.game?.seedHash ?? null,
lockedOptions: this.locked(this.config.gameId).paths,
lastSeed: this.lastSeed,
};
}
@@ -446,10 +465,11 @@ export class Room {
const next: RoomConfig = { ...this.config };
if (patch.gameId !== undefined && patch.gameId !== this.config.gameId) {
const def = this.deps.games[patch.gameId];
if (!def) return '없는 게임이에요.';
const eff = this.deps.settings?.game(patch.gameId);
if (!def || (eff && !eff.enabled)) return '없는 게임이에요.';
next.gameId = patch.gameId;
next.options = def.defaultOptions;
next.maxPlayers = def.maxPlayers;
next.options = eff?.defaultOptions ?? def.defaultOptions;
next.maxPlayers = eff?.maxPlayers ?? def.maxPlayers;
}
const def = this.deps.games[next.gameId]!;
if (patch.options !== undefined) {
@@ -457,7 +477,10 @@ export class Room {
if (!parsed.success) return '설정 값이 올바르지 않아요.';
next.options = parsed.data;
}
const range = def.playersFor ? def.playersFor(next.options) : { min: def.minPlayers, max: def.maxPlayers };
// Admin-locked options always keep the admin value (docs/14-admin.md §4).
const lk = this.locked(next.gameId);
if (lk.paths.length) next.options = def.optionsSchema.parse(applyLocks(next.options, lk.defaults, lk.paths));
const range = this.rangeFor(next.gameId, next.options);
if (patch.maxPlayers !== undefined) next.maxPlayers = patch.maxPlayers;
next.maxPlayers = Math.min(Math.max(next.maxPlayers, range.min), range.max);
if (next.maxPlayers < this.seatedIds().length) return `이미 ${this.seatedIds().length}명이 앉아 있어요.`;
@@ -517,7 +540,7 @@ export class Room {
const msg: Extract<ServerMessage, { t: 'chat' }> = {
t: 'chat',
from: this.deps.users.publicUser(userId),
text: this.config.chatFilter ? maskProfanity(clean) : clean,
text: this.config.chatFilter ? maskProfanity(clean, this.deps.settings?.getSite().bannedWords ?? []) : clean,
at: now,
};
this.chatLog.push(msg);
@@ -570,8 +593,12 @@ export class Room {
private startGame(players: string[]): string | null {
const def = this.def;
const parsed = def.optionsSchema.safeParse(this.config.options);
const lk = this.locked(def.id);
const parsed = def.optionsSchema.safeParse(lk.paths.length ? applyLocks(this.config.options, lk.defaults, lk.paths) : this.config.options);
if (!parsed.success) return '설정 값이 올바르지 않아요.';
const eff = this.deps.settings?.game(def.id);
if (eff && !eff.enabled) return '관리자가 이 게임을 잠시 막아 두었어요.';
this.config = { ...this.config, options: parsed.data };
const seed = createSeed();
const seedHex = seedToHex(seed);
const rng = SeededRng.fromSeed(seed);
@@ -800,6 +827,20 @@ export class Room {
if (st) conn.send(st);
}
/** Admin closes the room: void any running game, tell everyone, disconnect. */
closeByAdmin(): void {
if (this.game && !this.game.finished) {
this.finishGame({ ranking: [this.game.players], summary: '관리자가 방을 닫아 무효 처리됐어요.', reason: 'abandoned' }, true);
}
this.notice('closed', '관리자가 방을 닫았어요.');
this.clearTimer();
for (const c of this.conns.values()) {
c.send({ t: 'bye', reason: 'closed' });
c.close(4002, 'closed');
}
this.conns.clear();
}
shutdown(): void {
this.clearTimer();
for (const c of this.conns.values()) {

View File

@@ -11,6 +11,8 @@ import { createHttpApp, type HttpDeps } from './http/app';
import { RoomManager } from './rooms/manager';
import { RoomStore } from './rooms/store';
import { Gateway, type WsData } from './ws/gateway';
import { Audit, Roles } from './admin/roles';
import { SettingsStore } from './admin/settings';
export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
@@ -38,7 +40,11 @@ export function startServer(opts: StartOptions) {
const u = users.get(id);
return u ? toPublicUser(u) : null;
};
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log });
const settings = new SettingsStore(db, GAMES);
const roles = new Roles(db, config.superadminDiscordIds);
const audit = new Audit(db);
const startedAt = Date.now();
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings });
const restored = rooms.restoreAll();
log('info', 'rooms restored', restored);
const gateway = new Gateway({ rooms, me: publicUser, log });
@@ -57,6 +63,24 @@ export function startServer(opts: StartOptions) {
ipOf,
discordExchange: opts.discordExchange,
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
roles,
settings,
admin: {
audit,
games: GAMES,
disconnectUser: (id) => gateway.disconnectUser(id),
stats: () => {
const all = rooms.all();
const day = Date.now() - 24 * 3600_000;
return {
connections: gateway.connectionCount,
rooms: { total: all.length, lobby: all.filter((r) => r.status === 'lobby').length, playing: all.filter((r) => r.status === 'playing').length },
games: db.query<{ total: number; today: number }, [number]>('SELECT COUNT(*) AS total, SUM(started_at > ?) AS today FROM games').get(day),
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
memoryMb: Math.round(process.memoryUsage().rss / 1048576),
};
},
},
});
const staticDir = opts.staticDir === undefined ? join(import.meta.dir, '../../web/dist') : opts.staticDir;
@@ -114,7 +138,7 @@ export function startServer(opts: StartOptions) {
db.close();
};
return { server, stop, rooms, users, sessions, db, gateway };
return { server, stop, rooms, users, sessions, db, gateway, settings, roles };
}
async function serveFile(root: string, pathname: string): Promise<Response> {

View File

@@ -195,6 +195,14 @@ export class Gateway {
if (e) err(e);
}
/** Drop every connection of a user (ban / forced logout). */
disconnectUser(userId: string): void {
for (const ws of [...(this.byUser.get(userId) ?? [])]) {
this.send(ws, { t: 'bye', reason: 'kicked' });
ws.close(4403, 'banned');
}
}
/** Graceful shutdown: tell everyone to reconnect shortly (docs/03 §10). */
shutdown(): void {
this.closing = true;