슈퍼어드민 디스코드 ID를 저장소에서 제거: 서버 deploy/.env의 SUPERADMIN_DISCORD_IDS에만 둔다

- config: 기본값 삭제(설정이 없으면 슈퍼어드민 없음)
- 테스트·E2E: 만든 ID(900000000000000001/2) 사용
- 문서·README·.env.example·관리자 화면 입력 예시에서 실제 ID 삭제

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-08 10:36:08 +09:00
parent 4e4c195c79
commit e499c79464
10 changed files with 19 additions and 18 deletions

View File

@@ -34,7 +34,7 @@ cd deploy && docker compose up -d --build
자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md).
## 관리자
`/admin`. 슈퍼어드민은 디스코드 ID `293719842274541579`, `1352267557213573160`(환경 변수 `SUPERADMIN_DISCORD_IDS`), 어드민은 관리자 화면에서 디스코드 ID로 추가한다. 자세한 내용은 [docs/14-admin.md](docs/14-admin.md).
`/admin`. 슈퍼어드민은 서버의 `deploy/.env`에 있는 환경 변수 `SUPERADMIN_DISCORD_IDS`의 디스코드 ID(저장소에는 넣지 않음), 어드민은 관리자 화면에서 디스코드 ID로 추가한다. 자세한 내용은 [docs/14-admin.md](docs/14-admin.md).
## 구조
- `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구

View File

@@ -1,8 +1,8 @@
/** 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';
import { Client, TEST_SUPERADMINS, api, boot, guest, sleep } from './test-utils';
const SUPER = '293719842274541579';
const [SUPER, SUPER2] = TEST_SUPERADMINS;
type Booted = ReturnType<typeof boot>;
const running: Booted[] = [];
const clients: Client[] = [];
@@ -47,7 +47,7 @@ describe('roles', () => {
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');
const sup2 = await discordLogin(s, '1352267557213573160');
const sup2 = await discordLogin(s, SUPER2);
expect((await api(s.base, '/api/admin/me', { cookie: sup2.cookie })).body.role).toBe('superadmin');
});
@@ -60,7 +60,7 @@ describe('roles', () => {
// 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, '1352267557213573160', adminDiscord]);
expect(add.body.admins.map((a: any) => a.discordId)).toEqual([SUPER, SUPER2, 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);

View File

@@ -14,9 +14,6 @@ export interface Config {
loadTestNoHttpLimits: boolean;
}
/** Superadmin Discord IDs, comma-separated (docs/14-admin.md §2). */
export const DEFAULT_SUPERADMIN = '293719842274541579,1352267557213573160';
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);
@@ -36,6 +33,7 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
secureCookies: publicOrigin.startsWith('https://'),
trustProxy: env.TRUST_PROXY === '1',
loadTestNoHttpLimits: env.LOADTEST_NO_HTTP_LIMITS === '1' && !publicOrigin.startsWith('https://'),
superadminDiscordIds: (env.SUPERADMIN_DISCORD_IDS ?? DEFAULT_SUPERADMIN).split(',').map((s) => s.trim()).filter(Boolean),
/** Superadmin Discord IDs, comma-separated (docs/14-admin.md §2). Only in deploy/.env, never in the repo. */
superadminDiscordIds: (env.SUPERADMIN_DISCORD_IDS ?? '').split(',').map((s) => s.trim()).filter(Boolean),
};
}

View File

@@ -7,6 +7,8 @@ import { loadConfig } from './config';
import { startServer, type StartOptions } from './server';
export const ORIGIN = 'http://localhost:5173';
/** Made-up superadmin Discord IDs for tests (the real ones live only in deploy/.env). */
export const TEST_SUPERADMINS = ['900000000000000001', '900000000000000002'] as const;
export function tempDbPath(): string {
const dir = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), 'bg-test-'));
@@ -14,7 +16,7 @@ export function tempDbPath(): string {
}
export function boot(dbPath = tempDbPath(), extra: Partial<StartOptions> = {}) {
const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN });
const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN, SUPERADMIN_DISCORD_IDS: TEST_SUPERADMINS.join(',') });
const app = startServer({ config, log: () => {}, staticDir: null, stopWaitMs: 50, ...extra });
const base = `http://localhost:${app.server.port}`;
return { ...app, base, dbPath, config };

View File

@@ -577,7 +577,7 @@ function Admins() {
<h2>어드민 추가</h2>
<label className="field">
디스코드 ID(숫자)
<input className="input" inputMode="numeric" value={discordId} onChange={(e) => setDiscordId(e.target.value)} placeholder="예: 293719842274541579" />
<input className="input" inputMode="numeric" value={discordId} onChange={(e) => setDiscordId(e.target.value)} placeholder="디스코드 ID (숫자 17~20자리)" />
</label>
<label className="field">
메모(선택)

View File

@@ -7,5 +7,5 @@ SESSION_SECRET=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
LOG_LEVEL=info
# Super admin Discord user IDs (comma separated). Default is the site owner.
SUPERADMIN_DISCORD_IDS=293719842274541579,1352267557213573160
# Super admin Discord user IDs (comma separated). Required for the admin site; keep the real IDs out of git.
SUPERADMIN_DISCORD_IDS=

View File

@@ -16,7 +16,7 @@ deploy/
| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | 디스코드 앱 |
| `SESSION_SECRET` | 짧은 수명 서명 쿠키(OAuth state)용 32바이트 |
| `DB_PATH` | 기본 `/data/app.db` |
| `SUPERADMIN_DISCORD_IDS` | 슈퍼어드민 디스코드 ID(쉼표 구분). 기본 `293719842274541579,1352267557213573160` |
| `SUPERADMIN_DISCORD_IDS` | 슈퍼어드민 디스코드 ID(쉼표 구분). 기본값 없음, 실제 값은 서버 `deploy/.env`에만(저장소에 넣지 않음) |
| `TRUST_PROXY` | `1`이면 Caddy가 붙인 X-Forwarded-For로 IP 판단 |
| `PORT` | 기본 3000 |
| `LOG_LEVEL` | info |

View File

@@ -5,7 +5,7 @@
## 0. 확정된 결정 (2026-10-04)
- 도메인: **game.tkrmagid.kr** 고정. 모든 기능을 완성한 뒤 연결한다.
- 상표명: 친구끼리 쓰는 사이트라 **원래 이름을 그대로 사용**한다.
- 관리자 사이트 추가. 슈퍼어드민은 디스코드 ID `293719842274541579`, 어드민은 디스코드 ID로 추가(`14-admin.md`).
- 관리자 사이트 추가. 슈퍼어드민은 `SUPERADMIN_DISCORD_IDS`의 디스코드 ID, 어드민은 디스코드 ID로 추가(`14-admin.md`).
- 화면은 **PC 버전 중심**으로 만든다(휴대폰은 접어서 지원).
- 게임 규칙 기본값은 관리자 사이트에서 모두 바꿀 수 있으므로, 3절의 기본안은 "처음 값"이다.

View File

@@ -8,7 +8,7 @@
## 2. 권한
| 등급 | 누가 | 할 수 있는 것 |
|---|---|---|
| 슈퍼어드민 | 디스코드 ID `293719842274541579`, `1352267557213573160` (설정 `SUPERADMIN_DISCORD_IDS`, 기본값이 이 두 ID) | 모든 것: 아래 어드민 권한 + 사용자 관리 + 어드민 추가/삭제 |
| 슈퍼어드민 | 서버 `deploy/.env`의 `SUPERADMIN_DISCORD_IDS`에 적은 디스코드 ID (기본값 없음, 개인정보라 저장소에 넣지 않음) | 모든 것: 아래 어드민 권한 + 사용자 관리 + 어드민 추가/삭제 |
| 어드민 | 슈퍼어드민이 디스코드 ID로 등록한 사람 | 관리자 사이트 보기, 게임 설정, 사이트 설정, 방 관리, 기록 보기 |
| 일반 | 그 외(게스트 포함) | 관리자 사이트 접근 불가(관리자 주소로 들어오면 "권한이 없어요") |

View File

@@ -13,9 +13,10 @@ const PORT = Number(process.env.E2E_PORT ?? 4518);
const ORIGIN = `http://localhost:${PORT}`;
const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results');
mkdirSync(SHOTS, { recursive: true });
const SUPER = '293719842274541579';
/** Made-up ID; the real superadmin IDs live only in deploy/.env. */
const SUPER = '900000000000000001';
const config = loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')), 'app.db'), PUBLIC_ORIGIN: ORIGIN });
const config = loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')), 'app.db'), PUBLIC_ORIGIN: ORIGIN, SUPERADMIN_DISCORD_IDS: SUPER });
config.discord = { clientId: 'e2e', clientSecret: 'e2e' };
const app = startServer({
config,