feat: 오프너의 천하통일 서버 정리를 허용한다

시나리오 초기화 권한 범위에서 실제 game DB의 통일 상태를 재검증한 뒤 완료 서버만 STOPPED로 정리한다. 런타임 전체 권한은 확대하지 않고 DB와 완료 기록을 보존한다.
This commit is contained in:
2026-08-24 01:55:31 +00:00
parent 32bab36320
commit f8fb570dac
6 changed files with 203 additions and 12 deletions
+3 -1
View File
@@ -62,7 +62,8 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
{
permission: 'admin.scenarios.reset',
label: '시나리오 초기화',
description: '지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화합니다.',
description:
'지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화하고, 천하통일 서버를 닫아 정리합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
},
@@ -123,6 +124,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
if (path.endsWith('.profiles.requestAction') && rawInput && typeof rawInput === 'object') {
const action = (rawInput as { action?: unknown }).action;
if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule';
if (action === 'CLOSE_COMPLETED') return 'admin.scenarios.reset';
if (action === 'RESUME') return 'admin.resume.when-stopped';
if (action === 'UPDATE_RUNTIME_SETTINGS') return 'admin.profiles.runtime';
if (action === 'OPEN_SURVEY') return 'admin.survey.open';
+25
View File
@@ -45,6 +45,7 @@ const zServerAction = z.enum([
'RESUME',
'PAUSE',
'STOP',
'CLOSE_COMPLETED',
'ACCELERATE',
'DELAY',
'UPDATE_RUNTIME_SETTINGS',
@@ -2227,6 +2228,7 @@ export const adminRouter = router({
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName);
const canOpenSurvey =
canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
const canResetScenario = hasScopedPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, profile.profileName);
if (input.action === 'RESUME') {
if (profile.status !== 'STOPPED' && profile.status !== 'PAUSED') {
@@ -2257,6 +2259,28 @@ export const adminRouter = router({
code: 'BAD_REQUEST',
message: 'Stop is allowed only while the profile runtime is available.',
});
} else if (input.action === 'CLOSE_COMPLETED') {
if (!canResetScenario) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Scenario reset permission is required.',
});
}
if (!gatewayProfileCapabilities(profile.status).runtimeExpected) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Completed cleanup is allowed only while the profile runtime is available.',
});
}
const [runtimeSettings] =
(await ctx.orchestrator.listRuntimeSettings?.([profile.profileName])) ?? [];
const isUnited = profile.status === 'COMPLETED' || Number(runtimeSettings?.isUnited ?? 0) !== 0;
if (!isUnited) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Only a unified game can be closed by a scenario opener.',
});
}
} else if (input.action === 'OPEN_SURVEY') {
if (!canOpenSurvey) {
throw new TRPCError({
@@ -2323,6 +2347,7 @@ export const adminRouter = router({
RESUME: 'RUNNING',
PAUSE: 'PAUSED',
STOP: 'STOPPED',
CLOSE_COMPLETED: 'STOPPED',
SHUTDOWN: 'DISABLED',
} as const;
const mappedStatus = statusMap[input.action as keyof typeof statusMap];
@@ -111,6 +111,8 @@ export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
export interface ProfileRuntimeSettingsSnapshot {
profileName: string;
/** Ref game_env.isunited compatibility value. Any non-zero value means unification has begun. */
isUnited: number;
turnTermMinutes: number;
blockGeneralCreate: 0 | 1 | 2;
autorunUser: {
@@ -933,6 +935,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
);
return {
profileName,
isUnited: Number(meta.isunited ?? meta.isUnited ?? 0),
turnTermMinutes: Math.max(1, Math.round(row.tickSeconds / 60)),
blockGeneralCreate,
autorunUser:
@@ -31,6 +31,7 @@ const buildCaller = async (
initialProfileStatus?: GatewayProfileRecord['status'];
profileScenario?: string | null;
profileMeta?: GatewayProfileRecord['meta'];
gameIsUnited?: number;
releaseCommitSha?: string;
initialOperation?: GatewayOperationRecord;
profileLogVisibilityAfterPolls?: number;
@@ -291,6 +292,7 @@ const buildCaller = async (
listRuntimeSettings: async () => [
{
profileName: 'che:2',
isUnited: options.gameIsUnited ?? 0,
turnTermMinutes: 20,
blockGeneralCreate: 2,
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
@@ -405,6 +407,7 @@ describe('admin profile navigation API', () => {
expect(result[0]?.runtimeSettings).toEqual({
profileName: 'che:2',
isUnited: 0,
turnTermMinutes: 20,
blockGeneralCreate: 2,
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
@@ -1427,6 +1430,63 @@ describe('admin runtime clock action API', () => {
expect(harness.getReconcileCount()).toBe(1);
});
it('lets a scoped scenario opener close a unified game without runtime authority', async () => {
const harness = await buildCaller(unusedCreateOperation, {
adminRoles: ['user', 'admin.scenarios.reset:che:2'],
firstUserIsAdmin: false,
initialProfileStatus: 'RUNNING',
gameIsUnited: 2,
});
await expect(
harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'CLOSE_COMPLETED',
})
).resolves.toMatchObject({ ok: true });
expect(harness.updatedStatuses).toEqual(['STOPPED']);
expect(harness.getReconcileCount()).toBe(1);
expect(harness.auditEvents.at(-1)).toMatchObject({ capability: 'admin.scenarios.reset' });
});
it('does not let a scenario opener close a game before unification', async () => {
const harness = await buildCaller(unusedCreateOperation, {
adminRoles: ['user', 'admin.scenarios.reset:che:2'],
firstUserIsAdmin: false,
initialProfileStatus: 'RUNNING',
gameIsUnited: 0,
});
await expect(
harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'CLOSE_COMPLETED',
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'Only a unified game can be closed by a scenario opener.',
});
expect(harness.updatedStatuses).toEqual([]);
expect(harness.getReconcileCount()).toBe(0);
});
it('does not turn runtime authority into scenario-opener cleanup authority', async () => {
const harness = await buildCaller(unusedCreateOperation, {
adminRoles: ['user', 'admin.profiles.runtime:che:2'],
firstUserIsAdmin: false,
initialProfileStatus: 'RUNNING',
gameIsUnited: 2,
});
await expect(
harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'CLOSE_COMPLETED',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect(harness.updatedStatuses).toEqual([]);
});
it('creates a first-class clock action owned by the authenticated administrator', async () => {
const harness = await buildCaller(unusedCreateOperation);