import { randomBytes } from 'node:crypto'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { GatewayPrisma } from '@sammo-ts/infra'; import { procedure, router } from './trpc.js'; import { listScenarioPreviews, resolveGitBranchCommitSha, resolveGitCommitSha } from './scenario/scenarioCatalog.js'; import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js'; import { toPublicUser } from './auth/userRepository.js'; import type { AdminAuthContext } from './adminAuth.js'; import { buildAdminAuditTarget, newAdminAuditCorrelationId, sanitizeAdminAuditValue } from './adminAudit.js'; import { ADMIN_CAPABILITIES, getAdminCapability, isProfileCapabilityPermission, resolveAdminActionCapability, } from './adminCapabilities.js'; import type { GatewayApiContext } from './context.js'; import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js'; import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js'; import { orderGatewayProfiles } from './profileOrder.js'; import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES); const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES); const zUserRoleMode = z.enum(['set', 'grant', 'revoke']); const zSpecialAccountAccessKind = z.enum(['TESTER', 'RECOVERY', 'OTHER']); const zSpecialAccessProfile = z .string() .trim() .min(1) .max(64) .regex(/^[a-z0-9_-]+(?::[a-zA-Z0-9._-]+)?$/); const zJoinMode = z.enum(['full', 'onlyRandom']); const zServerAction = z.enum([ 'RESUME', 'PAUSE', 'STOP', 'ACCELERATE', 'DELAY', 'RESET_NOW', 'RESET_SCHEDULED', 'OPEN_SURVEY', 'SHUTDOWN', ]); const TURN_TERM_MINUTES = [1, 2, 5, 10, 20, 30, 60, 120] as const; const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const; const ADMIN_ROLE_PREFIX = 'admin.'; const ADMIN_ROLE_SUPERUSER = 'admin.superuser'; const ROLE_SUPERUSER = 'superuser'; const ROLE_ADMIN_USERS = 'admin.users.manage'; const ROLE_ADMIN_USERS_CREATE = 'admin.users.create'; const ROLE_ADMIN_PROFILES = 'admin.profiles.manage'; const ROLE_ADMIN_PROFILE_RUNTIME = 'admin.profiles.runtime'; const ROLE_ADMIN_PROFILE_SETTINGS = 'admin.profiles.settings'; const ROLE_ADMIN_PROFILE_DEPLOY = 'admin.profiles.deploy'; const ROLE_ADMIN_SCENARIO_RESET = 'admin.scenarios.reset'; const ROLE_ADMIN_RELEASES = 'admin.releases.manage'; const ROLE_ADMIN_NOTICE = 'admin.notice.manage'; const ROLE_ADMIN_AUDIT = 'admin.audit.read'; const ROLE_RESET_SCHEDULE = 'admin.reset.schedule'; const ROLE_RESUME_WHEN_STOPPED = 'admin.resume.when-stopped'; const ROLE_SURVEY_OPEN = 'admin.survey.open'; const readSessionToken = (headers: Record): string | null => { const provided = headers['x-session-token'] ?? headers['authorization'] ?? ''; const raw = Array.isArray(provided) ? (provided[0] ?? '') : (provided as string); const token = raw.startsWith('Bearer ') ? raw.slice(7) : raw; const trimmed = token.trim(); return trimmed ? trimmed : null; }; const isFirstUser = async (ctx: GatewayApiContext, userId: string): Promise => { const first = await ctx.prisma.appUser.findFirst({ orderBy: { createdAt: 'asc' }, select: { id: true }, }); return first?.id === userId; }; const resolveAdminAuth = async (ctx: GatewayApiContext): Promise => { const token = readSessionToken(ctx.requestHeaders); if (!token) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session token is required.', }); } const session = await ctx.sessions.getSession(token); if (!session) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session is not valid.', }); } const user = await ctx.users.findById(session.userId); if (!user) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not found.', }); } const roles = user.roles; const isSuperuser = roles.includes(ROLE_SUPERUSER) || roles.includes(ADMIN_ROLE_SUPERUSER) || (await isFirstUser(ctx, session.userId)); const hasAdminRole = isSuperuser || roles.some((role) => role === 'admin' || role.startsWith(ADMIN_ROLE_PREFIX)); if (!hasAdminRole) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.', }); } return { session, user, roles, isSuperuser, }; }; const requireAdminAuth = (ctx: { adminAuth?: AdminAuthContext }): AdminAuthContext => { if (!ctx.adminAuth) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Admin session is not available.', }); } return ctx.adminAuth; }; const roleMatchesScope = (role: string, permission: string, profileName?: string): boolean => { if (role === permission || role === `${permission}:*`) { return true; } if (profileName && role === `${permission}:${profileName}`) { return true; } return false; }; const hasScopedPermission = (adminAuth: AdminAuthContext, permission: string, profileName?: string): boolean => { if (adminAuth.isSuperuser) { return true; } return adminAuth.roles.some((role: string) => roleMatchesScope(role, permission, profileName)); }; const hasAnyScopedPermission = ( adminAuth: AdminAuthContext, permissions: readonly string[], profileName?: string ): boolean => permissions.some((permission) => hasScopedPermission(adminAuth, permission, profileName)); const assertAnyPermission = ( adminAuth: AdminAuthContext, permissions: readonly string[], profileName?: string ): void => { if (hasAnyScopedPermission(adminAuth, permissions, profileName)) return; throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' }); }; const splitRoleScope = (role: string): { permission: string; scope?: string } => { const separator = role.indexOf(':'); if (separator < 0) { return { permission: role }; } return { permission: role.slice(0, separator), scope: role.slice(separator + 1), }; }; const isRootAdminRole = (role: string): boolean => role === ROLE_SUPERUSER || role === 'admin' || role === ADMIN_ROLE_SUPERUSER; const canManageRole = (adminAuth: AdminAuthContext, role: string): boolean => { if (adminAuth.isSuperuser) { return true; } if (isRootAdminRole(role)) { return false; } const target = splitRoleScope(role); return adminAuth.roles.some((callerRole) => { const caller = splitRoleScope(callerRole); if (caller.permission !== target.permission) { return false; } return caller.scope === undefined || caller.scope === '*' || caller.scope === target.scope; }); }; const assertRoleChangesAllowed = ( adminAuth: AdminAuthContext, currentRoles: ReadonlySet, nextRoles: ReadonlySet ): void => { const changedRoles = new Set([...currentRoles, ...nextRoles]); for (const role of changedRoles) { if (currentRoles.has(role) === nextRoles.has(role)) { continue; } const parsed = splitRoleScope(role); if (parsed.permission.startsWith(ADMIN_ROLE_PREFIX) && parsed.permission !== ADMIN_ROLE_SUPERUSER) { const capability = getAdminCapability(parsed.permission); if (!capability) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Unknown administrator capability: ${role}` }); } if (capability.scope === 'GLOBAL' && parsed.scope !== undefined) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Capability does not accept a scope: ${role}` }); } if (capability.scope === 'PROFILE' && parsed.scope === '') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Profile scope is empty: ${role}` }); } } if (!canManageRole(adminAuth, role)) { throw new TRPCError({ code: 'FORBIDDEN', message: `Role change exceeds caller scope: ${role}`, }); } } }; const assertPermission = (adminAuth: AdminAuthContext, permission: string, profileName?: string): void => { if (hasScopedPermission(adminAuth, permission, profileName)) { return; } throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.', }); }; const assertTargetUserManageable = (adminAuth: AdminAuthContext, target: { id: string; roles: string[] }): void => { if (!adminAuth.isSuperuser && target.roles.some(isRootAdminRole)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only a superuser can change a root administrator account.', }); } }; const assertNotSelfDestructiveAction = (adminAuth: AdminAuthContext, targetUserId: string): void => { if (adminAuth.user.id === targetUserId) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Use account self-service instead of an administrator destructive action on yourself.', }); } }; const canCreateLocalUser = (adminAuth: AdminAuthContext): boolean => hasScopedPermission(adminAuth, ROLE_ADMIN_USERS_CREATE) || hasScopedPermission(adminAuth, ROLE_ADMIN_USERS); const canReadProfile = (adminAuth: AdminAuthContext, profileName: string): boolean => { if (adminAuth.isSuperuser) return true; return adminAuth.roles.some((role) => { const parsed = splitRoleScope(role); if (!isProfileCapabilityPermission(parsed.permission)) return false; return parsed.scope === undefined || parsed.scope === '*' || parsed.scope === profileName; }); }; // 로컬 계정 임의 생성은 환경 설정이 켜져 있을 때만 허용한다. const assertLocalAccountEnabled = (ctx: GatewayApiContext): void => { if (ctx.adminLocalAccountEnabled) { return; } throw new TRPCError({ code: 'FORBIDDEN', message: 'Local account provisioning is disabled.', }); }; const authenticatedAdminProcedure = procedure.use(async ({ ctx, next }) => { const adminAuth = await resolveAdminAuth(ctx as GatewayApiContext); return next({ ctx: { ...ctx, adminAuth, }, }); }); const adminProcedure = authenticatedAdminProcedure.use(async ({ ctx, type, path, getRawInput, next }) => { if (type !== 'mutation') { return next(); } const adminAuth = requireAdminAuth(ctx); const rawInput = await getRawInput().catch(() => undefined); const target = buildAdminAuditTarget(rawInput); const correlationId = newAdminAuditCorrelationId(); const action = path.startsWith('admin.') ? path : `admin.${path}`; const capability = resolveAdminActionCapability(action, rawInput); const baseEvent = { correlationId, actorUserId: adminAuth.user.id, actorUsername: adminAuth.user.username, ...(capability ? { capability } : {}), action, ...target, }; // STARTED 기록 실패 시 mutation을 시작하지 않는 fail-closed 경계입니다. await (ctx as GatewayApiContext).adminAudit.append({ ...baseEvent, outcome: 'STARTED' }); try { const result = await next(); if (!result.ok) { await (ctx as GatewayApiContext).adminAudit .append({ ...baseEvent, outcome: 'FAILED', errorCode: result.error.code, errorMessage: result.error.message.slice(0, 1000), }) .catch(() => undefined); return result; } // 업무 mutation은 이미 끝났으므로 terminal 기록 장애가 재시도/중복 mutation을 // 유발하지 않게 STARTED row를 남긴 채 원래 결과를 반환합니다. await (ctx as GatewayApiContext).adminAudit .append({ ...baseEvent, outcome: 'SUCCEEDED', summary: { request: target.summary, result: sanitizeAdminAuditValue(result.data), }, }) .catch(() => undefined); return result; } catch (error) { await (ctx as GatewayApiContext).adminAudit .append({ ...baseEvent, outcome: 'FAILED', errorCode: error instanceof TRPCError ? error.code : 'INTERNAL_SERVER_ERROR', errorMessage: error instanceof Error ? error.message.slice(0, 1000) : 'Unknown administrator error', }) .catch(() => undefined); throw error; } }); const noticeAdminProcedure = adminProcedure.use(({ ctx, next }) => { const adminAuth = requireAdminAuth(ctx); assertPermission(adminAuth, ROLE_ADMIN_NOTICE); return next(); }); const userAdminProcedure = adminProcedure.use(({ ctx, next }) => { const adminAuth = requireAdminAuth(ctx); assertPermission(adminAuth, ROLE_ADMIN_USERS); return next(); }); const userCreateProcedure = adminProcedure.use(({ ctx, next }) => { const adminAuth = requireAdminAuth(ctx); if (!canCreateLocalUser(adminAuth)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.', }); } return next(); }); const profileAdminProcedure = adminProcedure.use(({ ctx, next }) => { const adminAuth = requireAdminAuth(ctx); assertPermission(adminAuth, ROLE_ADMIN_PROFILES); return next(); }); const releaseAdminProcedure = adminProcedure.use(({ ctx, next }) => { const adminAuth = requireAdminAuth(ctx); assertPermission(adminAuth, ROLE_ADMIN_RELEASES); return next(); }); const auditAdminProcedure = adminProcedure.use(({ ctx, next }) => { const adminAuth = requireAdminAuth(ctx); assertPermission(adminAuth, ROLE_ADMIN_AUDIT); return next(); }); const zUserLookupInput = z .object({ id: z.string().min(1).optional(), username: z.string().min(1).optional(), email: z.string().min(3).optional(), }) .refine((value) => Boolean(value.id || value.username || value.email), { message: 'id, username, or email must be provided.', }); const zUserListInput = z .object({ query: z.string().trim().max(100).optional(), limit: z.number().int().min(1).max(100).default(30), cursor: z.string().uuid().optional(), }) .optional(); const zServerRestriction = z.object({ blockedFeatures: z.array(z.string().min(1)).optional(), until: z.string().datetime().nullable().optional(), reason: z.string().max(200).nullable().optional(), notes: z.string().max(2000).nullable().optional(), }); const zSanctionsPatch = z .object({ bannedUntil: z.string().datetime().nullable().optional(), mutedUntil: z.string().datetime().nullable().optional(), suspendedUntil: z.string().datetime().nullable().optional(), warningCount: z.number().int().min(0).nullable().optional(), flags: z.array(z.string().min(1)).nullable().optional(), notes: z.string().max(2000).nullable().optional(), serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(), }) .strict(); const zLocalAccountInput = z.object({ username: z.string().min(2).max(32), password: z.string().min(6).max(128), displayName: z.string().min(2).max(40).optional(), }); const zInstallAutorun = z.object({ limitMinutes: z.number().int().min(0).max(43200), options: z.array(z.enum(AUTORUN_USER_OPTIONS)), }); const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value); const isUniqueConstraintError = (error: unknown): boolean => Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'P2002'); const zInstallOptions = z.object({ scenarioId: z.number().int().min(0), turnTermMinutes: z .number() .int() .refine((value) => isAllowedTurnTerm(value), { message: 'turnTermMinutes must divide 120.', }), sync: z.boolean(), fiction: z.number().int().min(0).max(1), extend: z.boolean(), blockGeneralCreate: z.number().int().min(0).max(2), npcMode: z.number().int().min(0).max(2), showImgLevel: z.number().int().min(0).max(3), tournamentTrig: z.boolean(), joinMode: zJoinMode, autorunUser: zInstallAutorun.nullable().optional(), openAt: z.string().datetime().optional(), preopenAt: z.string().datetime().optional(), gitRef: z.string().min(1).max(128).optional(), }); const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true }); const zSourceMode = z.enum(['BRANCH', 'COMMIT']); const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']); type SanctionsPatch = z.infer; // 제재 패치 입력을 현재 제재 상태에 병합한다. const applySanctionsPatch = (current: UserSanctions, patch: SanctionsPatch): UserSanctions => { const next: UserSanctions = { ...current }; const applyField = (key: K, value: UserSanctions[K] | null | undefined): void => { if (value === undefined) { return; } if (value === null) { delete next[key]; return; } next[key] = value; }; applyField('bannedUntil', patch.bannedUntil); applyField('mutedUntil', patch.mutedUntil); applyField('suspendedUntil', patch.suspendedUntil); applyField('warningCount', patch.warningCount); applyField('flags', patch.flags); applyField('notes', patch.notes); if (patch.serverRestrictions !== undefined) { if (patch.serverRestrictions === null) { delete next.serverRestrictions; } else { const existing = { ...(next.serverRestrictions ?? {}) }; for (const [profile, restriction] of Object.entries(patch.serverRestrictions)) { if (!restriction) { delete existing[profile]; } else { const merged: UserServerRestriction = { ...(existing[profile] ?? {}), }; if (restriction.blockedFeatures !== undefined) { merged.blockedFeatures = restriction.blockedFeatures ?? undefined; } if (restriction.until !== undefined) { merged.until = restriction.until ?? undefined; } if (restriction.reason !== undefined) { merged.reason = restriction.reason ?? undefined; } if (restriction.notes !== undefined) { merged.notes = restriction.notes ?? undefined; } existing[profile] = merged; } } next.serverRestrictions = existing; } } return next; }; const buildAdminPassword = (): string => randomBytes(6).toString('hex'); // 프로필 메타를 안전하게 읽고, 패치를 병합한다. const readMetaObject = (value: unknown): Record => { if (!value || typeof value !== 'object') { return {}; } return value as Record; }; const applyMetaPatch = ( meta: Record, patch: Record ): Record => { const next = { ...meta }; for (const [key, value] of Object.entries(patch)) { if (value === undefined) { continue; } if (value === null) { delete next[key]; continue; } next[key] = value; } return next; }; export const adminRouter = router({ capabilities: router({ list: adminProcedure.query(({ ctx }) => { const adminAuth = requireAdminAuth(ctx); return ADMIN_CAPABILITIES.filter( (entry) => adminAuth.isSuperuser || adminAuth.roles.some((role) => { const parsed = splitRoleScope(role); return parsed.permission === entry.permission; }) ).map((entry) => { if (adminAuth.isSuperuser) return { ...entry, scopes: ['*'] }; const scopes = adminAuth.roles .map(splitRoleScope) .filter((role) => role.permission === entry.permission) .map((role) => role.scope ?? '*'); return { ...entry, scopes: Array.from(new Set(scopes)) }; }); }), }), audit: router({ list: auditAdminProcedure .input( z .object({ actorUserId: z.string().min(1).optional(), targetType: z.string().min(1).max(64).optional(), targetId: z.string().min(1).optional(), profileName: z.string().min(1).max(64).optional(), limit: z.number().int().min(1).max(200).optional(), }) .optional() ) .query(({ ctx, input }) => (ctx as GatewayApiContext).adminAudit.list(input)), }), system: router({ getNotice: adminProcedure.query(async ({ ctx }) => { const setting = await ctx.prisma.systemSetting.findUnique({ where: { id: 1 }, }); return { notice: purifyGatewayNoticeHtml(setting?.notice) }; }), setNotice: noticeAdminProcedure .input( z.object({ notice: z.string().max(4000), }) ) .mutation(async ({ ctx, input }) => { const notice = purifyGatewayNoticeHtml(input.notice); const setting = await ctx.prisma.systemSetting.upsert({ where: { id: 1 }, create: { id: 1, notice, }, update: { notice, }, }); return { notice: purifyGatewayNoticeHtml(setting.notice) }; }), }), users: router({ getLocalAccountStatus: adminProcedure.query(({ ctx }) => ({ enabled: (ctx as GatewayApiContext).adminLocalAccountEnabled, })), list: userAdminProcedure.input(zUserListInput).query(({ ctx, input }) => ctx.users.listForAdmin({ query: input?.query, limit: input?.limit ?? 30, cursor: input?.cursor, }) ), createLocal: userCreateProcedure.input(zLocalAccountInput).mutation(async ({ ctx, input }) => { const gatewayCtx = ctx as GatewayApiContext; assertLocalAccountEnabled(gatewayCtx); const existing = await gatewayCtx.users.findByUsername(input.username); if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'Username already exists.', }); } try { const created = await gatewayCtx.users.createUser({ username: input.username, password: input.password, displayName: input.displayName, }); return { user: toPublicUser(created) }; } catch (error) { throw new TRPCError({ code: 'CONFLICT', message: 'Username already exists.', cause: error, }); } }), lookup: userAdminProcedure.input(zUserLookupInput).query(async ({ ctx, input }) => { const user = input.id ? await ctx.users.findById(input.id) : input.username ? await ctx.users.findByUsername(input.username) : input.email ? await ctx.users.findByEmail(input.email) : null; if (!user) { return null; } return { id: user.id, username: user.username, displayName: user.displayName, roles: user.roles, sanctions: user.sanctions, oauthType: user.oauthType, oauthId: user.oauthId, email: user.email, kakaoVerifiedAt: user.kakaoVerifiedAt, kakaoGraceStartedAt: user.kakaoGraceStartedAt, kakaoGraceUntil: user.kakaoGraceUntil, profileIconResetAt: user.profileIconResetAt, deleteAfter: user.deleteAfter, createdAt: user.createdAt, }; }), getKakaoGracePolicies: userAdminProcedure .input(z.object({ userId: z.string().min(1) })) .query(async ({ ctx, input }) => { const user = await ctx.users.findById(input.userId); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); } const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles()); const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id); return { kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt), kakaoGraceStartedAt: user.kakaoGraceStartedAt, kakaoGraceUntil: user.kakaoGraceUntil ?? null, specialAccessGrants, profiles: profiles.map((profile) => ({ profileName: profile.profileName, ...resolveLocalAccountProfilePolicy({ profile: profile.profile, profileName: profile.profileName, profileMeta: readMetaObject(profile.meta), defaultGraceDays: (ctx as GatewayApiContext).localAccountGraceDays, user, specialAccessGrants, }), })), }; }), grantSpecialAccess: userAdminProcedure .input( z.object({ userId: z.string().min(1), kind: zSpecialAccountAccessKind, profiles: z.array(zSpecialAccessProfile).max(20).default([]), allowsGeneralCreation: z.boolean().default(true), expiresAt: z.string().datetime().nullable(), reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { const user = await ctx.users.findById(input.userId); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); } const adminAuth = requireAdminAuth(ctx); assertTargetUserManageable(adminAuth, user); const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null; const now = new Date(); if (expiresAt && expiresAt.getTime() <= now.getTime()) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Special access must end in the future.' }); } if (input.kind === 'RECOVERY') { if (!expiresAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access must expire.' }); } if (expiresAt.getTime() > now.getTime() + 90 * 24 * 60 * 60 * 1000) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access may last at most 90 days.', }); } } const profiles = [...new Set(input.profiles.map((profile) => profile.toLowerCase()))]; if (profiles.length > 0) { const knownProfiles = await ctx.profiles.listProfiles(); const knownNames = new Set( knownProfiles.flatMap((profile) => [ profile.profile.toLowerCase(), profile.profileName.toLowerCase(), ]) ); const unknown = profiles.find((profile) => !knownNames.has(profile)); if (unknown) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Unknown profile scope: ${unknown}` }); } } const grant = await ctx.users.createSpecialAccessGrant(input.userId, { kind: input.kind, profiles, allowsGeneralCreation: input.allowsGeneralCreation, expiresAt, reason: input.reason, grantedByUserId: adminAuth.user.id, }); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-special-access-granted'); return grant; }), revokeSpecialAccess: userAdminProcedure .input( z.object({ userId: z.string().min(1), grantId: z.string().uuid(), reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { const user = await ctx.users.findById(input.userId); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); } const adminAuth = requireAdminAuth(ctx); assertTargetUserManageable(adminAuth, user); const grant = await ctx.users.revokeSpecialAccessGrant(input.userId, input.grantId, { revokedAt: new Date(), revokedByUserId: adminAuth.user.id, reason: input.reason, }); if (!grant) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Active special access grant not found.' }); } await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-special-access-revoked'); return grant; }), updateKakaoGrace: userAdminProcedure .input( z.object({ userId: z.string().min(1), until: z.string().datetime().nullable(), reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { const user = await ctx.users.findById(input.userId); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); } const adminAuth = requireAdminAuth(ctx); assertTargetUserManageable(adminAuth, user); const until = input.until ? new Date(input.until) : null; if (until && user.oauthType === 'KAKAO' && user.kakaoVerifiedAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'A verified Kakao account does not need a grace override.', }); } if (until && until.getTime() <= Date.now()) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Grace extension must end in the future.' }); } await ctx.users.updateKakaoGraceUntil(input.userId, until); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-kakao-grace-updated'); return { kakaoGraceUntil: until?.toISOString() ?? null }; }), listHistory: userAdminProcedure .input(z.object({ userId: z.string().min(1), limit: z.number().int().min(1).max(200).optional() })) .query(({ ctx, input }) => (ctx as GatewayApiContext).adminAudit.list({ targetType: 'USER', targetId: input.userId, limit: input.limit, }) ), resetPassword: userAdminProcedure .input( z.object({ userId: z.string().min(1), newPassword: z.string().min(6).max(128).optional(), reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { const user = await ctx.users.findById(input.userId); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); } assertTargetUserManageable(requireAdminAuth(ctx), user); const password = input.newPassword ?? buildAdminPassword(); await ctx.users.updatePassword(input.userId, password); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-password-reset'); return { password }; }), updateRoles: userAdminProcedure .input( z.object({ userId: z.string().min(1), roles: z.array(z.string().trim().min(1).max(128)).min(1), mode: zUserRoleMode.optional(), reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { const user = await ctx.users.findById(input.userId); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.', }); } const mode = input.mode ?? 'set'; const currentRoles = new Set(user.roles); const roles = new Set(currentRoles); if (mode === 'set') { roles.clear(); for (const role of input.roles) { roles.add(role); } } else if (mode === 'grant') { for (const role of input.roles) { roles.add(role); } } else { for (const role of input.roles) { roles.delete(role); } } const adminAuth = requireAdminAuth(ctx); assertTargetUserManageable(adminAuth, user); assertRoleChangesAllowed(adminAuth, currentRoles, roles); const nextRoles = Array.from(roles); await ctx.users.updateRoles(input.userId, nextRoles); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-roles-updated'); return { roles: nextRoles }; }), updateSanctions: userAdminProcedure .input( z.object({ userId: z.string().min(1), patch: zSanctionsPatch, reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { const user = await ctx.users.findById(input.userId); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.', }); } const next = applySanctionsPatch(user.sanctions, input.patch); assertTargetUserManageable(requireAdminAuth(ctx), user); await ctx.users.updateSanctions(input.userId, next); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-sanctions-updated'); return { sanctions: next }; }), setServerRestriction: userAdminProcedure .input( z.object({ userId: z.string().min(1), profile: z.string().min(1).max(64), restriction: zServerRestriction.nullable(), reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { const user = await ctx.users.findById(input.userId); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.', }); } const patch: SanctionsPatch = { serverRestrictions: { [input.profile]: input.restriction ?? null, }, }; assertTargetUserManageable(requireAdminAuth(ctx), user); const next = applySanctionsPatch(user.sanctions, patch); await ctx.users.updateSanctions(input.userId, next); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-server-restriction'); return { sanctions: next }; }), resetProfileIcon: userAdminProcedure .input( z.object({ userId: z.string().min(1), reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { const user = await ctx.users.findById(input.userId); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.', }); } assertTargetUserManageable(requireAdminAuth(ctx), user); const profileIconResetAt = await ctx.users.resetProfileIcon(input.userId, new Date()); if (!profileIconResetAt) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.', }); } let flushPublished = true; try { await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-profile-icon-reset', { iconRevision: profileIconResetAt, }); } catch { flushPublished = false; } return { profileIconResetAt, flushPublished }; }), scheduleDeletion: userAdminProcedure .input( z.object({ userId: z.string().min(1), retentionDays: z.number().int().min(1).max(90).default(30), reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { const user = await ctx.users.findById(input.userId); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); } const adminAuth = requireAdminAuth(ctx); assertTargetUserManageable(adminAuth, user); assertNotSelfDestructiveAction(adminAuth, input.userId); const deleteAfter = new Date(Date.now() + input.retentionDays * 24 * 60 * 60 * 1000); await ctx.users.scheduleDeletion(input.userId, deleteAfter); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-scheduled-withdrawal'); return { ok: true, deleteAfter: deleteAfter.toISOString() }; }), forceDelete: userAdminProcedure .input( z.object({ userId: z.string().min(1), confirmUsername: z.string().min(1), reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); if (!adminAuth.isSuperuser) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Superuser permission is required.' }); } assertNotSelfDestructiveAction(adminAuth, input.userId); const user = await ctx.users.findById(input.userId); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); } if (input.confirmUsername !== user.username) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Username confirmation does not match.' }); } await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-force-withdraw'); await ctx.users.deleteUser(input.userId); return { ok: true }; }), }), operations: router({ list: adminProcedure .input( z .object({ profileName: z.string().min(1).optional(), limit: z.number().int().min(1).max(200).optional(), }) .optional() ) .query(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); if (input?.profileName) { if (!canReadProfile(adminAuth, input.profileName)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' }); } return ctx.profiles.listOperations({ profileName: input.profileName, limit: input.limit, }); } if (adminAuth.isSuperuser || adminAuth.roles.some((role) => role.endsWith(':*'))) { return ctx.profiles.listOperations({ limit: input?.limit }); } const profiles = await ctx.profiles.listProfiles(); const allowed = profiles.filter((profile) => canReadProfile(adminAuth, profile.profileName)); const operations = ( await Promise.all( allowed.map((profile) => ctx.profiles.listOperations({ profileName: profile.profileName, limit: input?.limit, }) ) ) ) .flat() .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); return operations.slice(0, input?.limit ?? 50); }), requestReset: adminProcedure .input( z.object({ profileName: z.string().min(1), sourceMode: zResetSourceMode, sourceRef: z.string().min(1).max(128).optional(), install: zOperationInstallOptions, scheduledAt: z.string().datetime().optional(), reason: z.string().max(200).optional(), }) ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], input.profileName); if (input.sourceMode !== 'CURRENT') { assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName); } if (input.scheduledAt) { assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_RESET_SCHEDULE], input.profileName); } const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); } if (input.scheduledAt && new Date(input.scheduledAt).getTime() <= Date.now()) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'scheduledAt must be in the future.', }); } const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null; const openAt = input.install.openAt ? new Date(input.install.openAt) : null; const preopenAt = input.install.preopenAt ? new Date(input.install.preopenAt) : null; if (preopenAt && !openAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'openAt is required when preopenAt is set.', }); } if (preopenAt && openAt && preopenAt.getTime() >= openAt.getTime()) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'preopenAt must be earlier than openAt.', }); } if (openAt && openAt.getTime() <= (scheduledAt?.getTime() ?? Date.now())) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'openAt must be later than the reset start.', }); } if (preopenAt && scheduledAt && preopenAt.getTime() < scheduledAt.getTime()) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'preopenAt cannot be earlier than scheduledAt.', }); } const autorunUser = input.install.autorunUser; if ( autorunUser && ((autorunUser.limitMinutes <= 0 && autorunUser.options.length > 0) || (autorunUser.limitMinutes > 0 && autorunUser.options.length === 0)) ) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'autorunUser minutes and options must be configured together.', }); } const sourceMode: 'BRANCH' | 'COMMIT' = input.sourceMode === 'CURRENT' ? 'COMMIT' : input.sourceMode; let sourceRef = input.sourceMode === 'CURRENT' ? profile.buildCommitSha?.trim() : input.sourceRef?.trim(); if (!sourceRef) { throw new TRPCError({ code: 'BAD_REQUEST', message: input.sourceMode === 'CURRENT' ? 'The profile has no active build commit to reset from.' : 'sourceRef is required.', }); } try { const resolved = sourceMode === 'BRANCH' ? await resolveGitBranchCommitSha(sourceRef) : await resolveGitCommitSha(sourceRef); if (sourceMode === 'COMMIT') { sourceRef = resolved; } const scenarios = await listScenarioPreviews({ gitRef: resolved }); if (!scenarios.some((scenario) => scenario.id === input.install.scenarioId)) { throw new Error('Scenario not found at source.'); } } catch (error) { throw new TRPCError({ code: 'BAD_REQUEST', message: sourceMode === 'BRANCH' ? 'Branch is invalid or does not contain the scenario.' : 'Commit is invalid or does not contain the scenario.', }); } try { const operation = await ctx.profiles.createOperation({ profileName: input.profileName, type: 'RESET', sourceMode, sourceRef, payload: { install: input.install, requestedSource: input.sourceMode, } as GatewayPrisma.JsonObject, reason: input.reason, requestedBy: adminAuth.user.id, scheduledAt: input.scheduledAt, }); return operation; } catch (error) { if (!isUniqueConstraintError(error)) { throw error; } throw new TRPCError({ code: 'CONFLICT', message: 'This profile already has a queued or running operation.', }); } }), requestDeploy: adminProcedure .input( z.object({ profileName: z.string().min(1), sourceMode: zSourceMode, sourceRef: z.string().min(1).max(128), reason: z.string().max(200).optional(), }) ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); } let sourceRef = input.sourceRef.trim(); try { const resolved = input.sourceMode === 'BRANCH' ? await resolveGitBranchCommitSha(sourceRef) : await resolveGitCommitSha(sourceRef); if (input.sourceMode === 'COMMIT') { sourceRef = resolved; } const scenarios = await listScenarioPreviews({ gitRef: resolved }); if (!scenarios.some((scenario) => String(scenario.id) === profile.scenario)) { throw new Error('Current scenario is not available at source.'); } } catch { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Source is invalid or does not contain the current scenario.', }); } try { return await ctx.profiles.createOperation({ profileName: input.profileName, type: 'DEPLOY', sourceMode: input.sourceMode, sourceRef, reason: input.reason, requestedBy: adminAuth.user.id, }); } catch (error) { if (!isUniqueConstraintError(error)) { throw error; } throw new TRPCError({ code: 'CONFLICT', message: 'This profile already has a queued or running operation.', }); } }), requestRuntime: adminProcedure .input( z.object({ profileName: z.string().min(1), action: z.enum(['START', 'STOP']), reason: z.string().max(200).optional(), }) ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME], input.profileName); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); } try { const operation = await ctx.profiles.createOperation({ profileName: input.profileName, type: input.action, reason: input.reason, requestedBy: adminAuth.user.id, }); return operation; } catch (error) { if (!isUniqueConstraintError(error)) { throw error; } throw new TRPCError({ code: 'CONFLICT', message: 'This profile already has a queued or running operation.', }); } }), cancel: adminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); const previous = await ctx.profiles.getOperation(input.id); if (!previous) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); } const permissions = previous.type === 'RESET' ? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET] : previous.type === 'DEPLOY' ? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY] : [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME]; assertAnyPermission(adminAuth, permissions, previous.profileName); const cancelled = await ctx.profiles.cancelOperation(input.id); if (!cancelled) { throw new TRPCError({ code: 'CONFLICT', message: 'Only queued operations can be cancelled.', }); } return { ok: true }; }), retry: adminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); const previous = await ctx.profiles.getOperation(input.id); if (!previous) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); } const permissions = previous.type === 'RESET' ? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET] : previous.type === 'DEPLOY' ? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY] : [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME]; assertAnyPermission(adminAuth, permissions, previous.profileName); if (previous.type === 'RESET') { const payload = readMetaObject(previous.payload); if (payload.requestedSource !== 'CURRENT') { assertAnyPermission( adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], previous.profileName ); } if (previous.scheduledAt) { assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_RESET_SCHEDULE], previous.profileName); } } try { const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id); if (!operation) { throw new TRPCError({ code: 'CONFLICT', message: 'Only failed or cancelled operations can be retried.', }); } return operation; } catch (error) { if (error instanceof TRPCError) { throw error; } if (!isUniqueConstraintError(error)) { throw error; } throw new TRPCError({ code: 'CONFLICT', message: 'This profile already has a queued or running operation.', }); } }), }), releases: router({ gatewayState: releaseAdminProcedure.query(({ ctx }) => ctx.releases.getState()), list: releaseAdminProcedure .input(z.object({ limit: z.number().int().min(1).max(200).optional() }).optional()) .query(({ ctx, input }) => ctx.releases.listOperations(input?.limit)), logs: releaseAdminProcedure .input( z.object({ id: z.string().uuid(), afterCursor: z.string().regex(/^\d+$/u).optional(), limit: z.number().int().min(1).max(500).default(200), timeoutMs: z.number().int().min(0).max(25_000).default(20_000), }) ) .query(async ({ ctx, input }) => { const deadline = Date.now() + input.timeoutMs; while (true) { const [operation, entries] = await Promise.all([ ctx.releases.getOperation(input.id), ctx.releases.listOperationLogs(input.id, input.afterCursor, input.limit), ]); if (!operation) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Gateway release operation not found.' }); } const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status); if (entries.length || terminal || Date.now() >= deadline) { return { operation, entries, nextCursor: entries.at(-1)?.cursor ?? input.afterCursor, }; } await new Promise((resolve) => setTimeout(resolve, 250)); } }), requestGatewayDeploy: releaseAdminProcedure .input( z.object({ sourceMode: zSourceMode, sourceRef: z.string().min(1).max(128), reason: z.string().max(200).optional(), }) ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); let sourceRef = input.sourceRef.trim(); try { const resolved = input.sourceMode === 'BRANCH' ? await resolveGitBranchCommitSha(sourceRef) : await resolveGitCommitSha(sourceRef); if (input.sourceMode === 'COMMIT') { sourceRef = resolved; } } catch { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Gateway release source is invalid.' }); } try { return await ctx.releases.createOperation({ type: 'DEPLOY', sourceMode: input.sourceMode, sourceRef, reason: input.reason, requestedBy: adminAuth.user.id, }); } catch (error) { if (!isUniqueConstraintError(error)) { throw error; } throw new TRPCError({ code: 'CONFLICT', message: 'A gateway release is already active.' }); } }), requestGatewayRollback: releaseAdminProcedure .input(z.object({ reason: z.string().max(200).optional() }).optional()) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); const state = await ctx.releases.getState(); if (!state.previousCommitSha || !state.previousWorkspace) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No previous gateway release is available.' }); } try { return await ctx.releases.createOperation({ type: 'ROLLBACK', sourceMode: 'COMMIT', sourceRef: state.previousCommitSha, payload: { expectedWorkspace: state.previousWorkspace, replacedCommitSha: state.activeCommitSha ?? null, }, reason: input?.reason, requestedBy: adminAuth.user.id, }); } catch (error) { if (!isUniqueConstraintError(error)) { throw error; } throw new TRPCError({ code: 'CONFLICT', message: 'A gateway release is already active.' }); } }), cancel: releaseAdminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => { if (!(await ctx.releases.cancelOperation(input.id))) { throw new TRPCError({ code: 'CONFLICT', message: 'Only queued releases can be cancelled.' }); } return { ok: true }; }), retry: releaseAdminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); try { const operation = await ctx.releases.retryOperation(input.id, adminAuth.user.id); if (!operation) { throw new TRPCError({ code: 'CONFLICT', message: 'Only failed or cancelled releases can be retried.', }); } return operation; } catch (error) { if (error instanceof TRPCError) { throw error; } if (!isUniqueConstraintError(error)) { throw error; } throw new TRPCError({ code: 'CONFLICT', message: 'A gateway release is already active.' }); } }), }), profiles: router({ list: adminProcedure.query(async ({ ctx }) => { const adminAuth = requireAdminAuth(ctx); const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles()).filter((profile) => canReadProfile(adminAuth, profile.profileName) ); const profileNames = profiles.map((profile) => profile.profileName); const [runtimeActions, activeOperations] = await Promise.all([ ctx.prisma.gatewayRuntimeAction.findMany({ where: { profileName: { in: profileNames } }, orderBy: { createdAt: 'desc' }, }), ctx.prisma.gatewayOperation.findMany({ where: { profileName: { in: profileNames }, status: { in: ['QUEUED', 'RUNNING'] }, }, select: { id: true, profileName: true, status: true }, }), ]); const activeOperationByProfile = new Map( activeOperations.map((operation) => [operation.profileName, operation]) ); const runtimeActionsByProfile = new Map(); for (const action of runtimeActions) { const bucket = runtimeActionsByProfile.get(action.profileName) ?? []; if (bucket.length < 10) { bucket.push(action); runtimeActionsByProfile.set(action.profileName, bucket); } } const runtimeStates = await ctx.orchestrator.listRuntimeStates( profiles.map((profile) => profile.profileName) ); const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state])); return profiles.map((profile) => ({ ...profile, runtimeActions: runtimeActionsByProfile.get(profile.profileName) ?? [], activeOperation: activeOperationByProfile.get(profile.profileName) ?? null, runtime: runtimeMap.get(profile.profileName) ?? { profileName: profile.profileName, frontendRunning: false, apiRunning: false, daemonRunning: false, auctionRunning: false, battleSimRunning: false, tournamentRunning: false, }, })); }), listScenarios: adminProcedure .input( z .object({ profileName: z.string().min(1).max(64).optional(), gitRef: z.string().min(1).max(128).optional(), sourceMode: zResetSourceMode.optional(), }) .optional() ) .query(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); const sourceMode = input?.sourceMode ?? 'CURRENT'; let gitRef = input?.gitRef?.trim(); if (sourceMode === 'CURRENT') { if (!input?.profileName) { if (!adminAuth.isSuperuser) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'profileName is required.' }); } } else { assertAnyPermission( adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], input.profileName ); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); gitRef = profile.buildCommitSha?.trim(); if (!gitRef) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'The profile has no active build commit.', }); } } } else if (input?.profileName) { assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName); } else { assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]); } if (!gitRef) { return listScenarioPreviews(); } const resolved = sourceMode === 'BRANCH' ? await resolveGitBranchCommitSha(gitRef) : await resolveGitCommitSha(gitRef); return listScenarioPreviews({ gitRef: resolved }); }), upsert: profileAdminProcedure .input( z.object({ profile: z.string().min(1).max(32), scenario: z.string().min(1).max(64), apiPort: z.number().int().min(1).max(65535), status: zProfileStatus.optional(), preopenAt: z.string().datetime().optional(), openAt: z.string().datetime().optional(), scheduledStartAt: z.string().datetime().optional(), buildCommitSha: z.string().min(7).max(64).optional(), }) ) .mutation(async ({ ctx, input }) => { const status = input.status ?? 'STOPPED'; return ctx.profiles.upsertProfile({ profile: input.profile, scenario: input.scenario, apiPort: input.apiPort, status, preopenAt: input.preopenAt, openAt: input.openAt, scheduledStartAt: input.scheduledStartAt, buildCommitSha: input.buildCommitSha, }); }), setStatus: profileAdminProcedure .input( z.object({ profileName: z.string().min(1), status: zProfileStatus, preopenAt: z.string().datetime().optional(), openAt: z.string().datetime().optional(), scheduledStartAt: z.string().datetime().optional(), buildCommitSha: z.string().min(7).max(64).optional(), }) ) .mutation(async ({ ctx, input }) => { if (input.status === 'RESERVED' && (!input.preopenAt || !input.openAt)) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'preopenAt and openAt are required for RESERVED status.', }); } const result = await ctx.profiles.updateStatus(input.profileName, input.status, { preopenAt: input.preopenAt, openAt: input.openAt, scheduledStartAt: input.status === 'RESERVED' ? input.scheduledStartAt : null, }); if (input.buildCommitSha) { await ctx.profiles.updateBuildStatus(input.profileName, 'IDLE', { commitSha: input.buildCommitSha, }); } await ctx.orchestrator.reconcileNow(); return result; }), updateMeta: adminProcedure .input( z.object({ profileName: z.string().min(1), patch: z.object({ korName: z.string().min(1).max(64).nullable().optional(), color: z.string().min(1).max(32).nullable().optional(), inGameNotice: z.string().max(4000).nullable().optional(), profileImageUrl: z.string().max(2048).nullable().optional(), nextSeasonIdx: z.number().int().min(0).nullable().optional(), localAccountAccessGraceDays: z.number().int().min(0).max(365).nullable().optional(), localAccountGeneralCreationGraceDays: z.number().int().min(0).max(365).nullable().optional(), }), reason: z.string().trim().min(3).max(200), }) ) .mutation(async ({ ctx, input }) => { assertAnyPermission( requireAdminAuth(ctx), [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_SETTINGS], input.profileName ); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.', }); } const meta = readMetaObject(profile.meta); const nextMeta = applyMetaPatch(meta, input.patch); return ctx.profiles.updateMeta(input.profileName, nextMeta); }), install: profileAdminProcedure .input( z.object({ profileName: z.string().min(1), install: zInstallOptions, reason: z.string().max(200).optional(), }) ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.', }); } const now = new Date(); const openAt = input.install.openAt ? new Date(input.install.openAt) : null; const preopenAt = input.install.preopenAt ? new Date(input.install.preopenAt) : null; if (openAt && Number.isNaN(openAt.getTime())) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'openAt is invalid.', }); } if (preopenAt && Number.isNaN(preopenAt.getTime())) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'preopenAt is invalid.', }); } if (openAt && openAt.getTime() < now.getTime()) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'openAt must be in the future.', }); } if (preopenAt && !openAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'openAt is required when preopenAt is set.', }); } if (preopenAt && openAt && preopenAt.getTime() >= openAt.getTime()) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'preopenAt must be earlier than openAt.', }); } const autorunUser = input.install.autorunUser ?? null; if (autorunUser) { if (autorunUser.limitMinutes <= 0 && autorunUser.options.length > 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'autorunUser limitMinutes must be positive when options are provided.', }); } if (autorunUser.limitMinutes > 0 && autorunUser.options.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'autorunUser options must be provided when limitMinutes is set.', }); } } const gitRef = input.install.gitRef?.trim(); const requestedRef = gitRef || profile.buildCommitSha || 'HEAD'; let resolvedCommitSha: string; try { resolvedCommitSha = await resolveGitCommitSha(requestedRef); const scenarios = await listScenarioPreviews({ gitRef: resolvedCommitSha }); if (!scenarios.some((scenario) => scenario.id === input.install.scenarioId)) { throw new Error('Scenario not found at source.'); } } catch { throw new TRPCError({ code: 'BAD_REQUEST', message: 'git ref is invalid or does not contain the scenario.', }); } const scheduledAt = openAt ? (preopenAt ?? openAt).toISOString() : null; const action = scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW'; const actionRecord = { action, requestedAt: now.toISOString(), scheduledAt, reason: input.reason ?? null, status: 'REQUESTED', install: { ...input.install, openAt: input.install.openAt ?? null, preopenAt: input.install.preopenAt ?? null, gitRef: resolvedCommitSha, autorunUser: autorunUser ? { limitMinutes: autorunUser.limitMinutes, options: autorunUser.options, } : null, adminUser: { id: adminAuth.user.id, username: adminAuth.user.username, displayName: adminAuth.user.displayName, }, }, }; try { const operation = await ctx.profiles.createOperation({ profileName: input.profileName, type: 'RESET', sourceMode: 'COMMIT', sourceRef: resolvedCommitSha, payload: { install: actionRecord.install } as GatewayPrisma.JsonObject, reason: input.reason, requestedBy: adminAuth.user.id, scheduledAt: scheduledAt ?? undefined, }); return { ok: true, operationId: operation.id, action: actionRecord }; } catch (error) { if (!isUniqueConstraintError(error)) { throw error; } throw new TRPCError({ code: 'CONFLICT', message: 'This profile already has a queued or running operation.', }); } }), installNow: profileAdminProcedure .input( z.object({ profileName: z.string().min(1), install: zInstallOptions, reason: z.string().max(200).optional(), }) ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.', }); } const requestedRef = input.install.gitRef?.trim() || profile.buildCommitSha || 'HEAD'; let resolvedCommitSha: string; try { resolvedCommitSha = await resolveGitCommitSha(requestedRef); const scenarios = await listScenarioPreviews({ gitRef: resolvedCommitSha }); if (!scenarios.some((scenario) => scenario.id === input.install.scenarioId)) { throw new Error('Scenario not found at source.'); } } catch { throw new TRPCError({ code: 'BAD_REQUEST', message: 'git ref is invalid or does not contain the scenario.', }); } try { const operation = await ctx.profiles.createOperation({ profileName: input.profileName, type: 'RESET', sourceMode: 'COMMIT', sourceRef: resolvedCommitSha, payload: { install: { ...input.install, gitRef: resolvedCommitSha, adminUser: { id: adminAuth.user.id, username: adminAuth.user.username, displayName: adminAuth.user.displayName, }, }, } as GatewayPrisma.JsonObject, reason: input.reason, requestedBy: adminAuth.user.id, }); const deadline = Date.now() + 10 * 60_000; while (Date.now() < deadline) { await ctx.orchestrator.runOperationsNow(); const current = await ctx.profiles.getOperation(operation.id); if (current?.status === 'SUCCEEDED') { return { ok: true, operationId: operation.id }; } if (current?.status === 'FAILED' || current?.status === 'CANCELLED') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: current.error ?? 'Profile install operation failed.', }); } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new TRPCError({ code: 'TIMEOUT', message: 'Profile install operation did not complete in time.', }); } catch (error) { if (!isUniqueConstraintError(error)) { throw error; } throw new TRPCError({ code: 'CONFLICT', message: 'This profile already has a queued or running operation.', }); } }), requestAction: adminProcedure .input( z.object({ profileName: z.string().min(1), action: zServerAction, durationMinutes: z.number().int().min(1).max(1440).optional(), scheduledAt: z.string().datetime().optional(), reason: z.string().max(200).optional(), }) ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); if (input.action === 'RESET_NOW' || input.action === 'RESET_SCHEDULED') { throw new TRPCError({ code: 'BAD_REQUEST', message: '시나리오 초기화는 operations.requestReset을 사용해 주세요.', }); } if ((input.action === 'ACCELERATE' || input.action === 'DELAY') && !input.durationMinutes) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'durationMinutes is required for acceleration or delay.', }); } if (input.scheduledAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'scheduledAt is supported only by operations.requestReset.', }); } const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.', }); } const canManageProfiles = hasAnyScopedPermission( adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME], profile.profileName ); const canResume = canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName); const canOpenSurvey = canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName); if (input.action === 'RESUME') { if (profile.status !== 'STOPPED' && profile.status !== 'PAUSED') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Resume is allowed only for STOPPED or PAUSED profiles.', }); } if (!canResume) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Resume permission is required.', }); } } else if (input.action === 'OPEN_SURVEY') { if (!canOpenSurvey) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Survey permission is required.', }); } } else if (!canManageProfiles) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Profile management permission is required.', }); } if (input.action === 'OPEN_SURVEY') { throw new TRPCError({ code: 'BAD_REQUEST', message: '설문은 게임 내 설문 관리 화면에서 생성해 주세요.', }); } if (input.action === 'ACCELERATE' || input.action === 'DELAY') { try { const runtimeAction = await ctx.prisma.gatewayRuntimeAction.create({ data: { profileName: input.profileName, action: input.action, durationMinutes: input.durationMinutes, reason: input.reason, requestedBy: adminAuth.user.id, }, }); return { ok: true, action: runtimeAction }; } catch (error) { if (!isUniqueConstraintError(error)) { throw error; } throw new TRPCError({ code: 'CONFLICT', message: '이 프로필의 이전 시간 조정 요청이 아직 처리 중입니다.', }); } } const statusMap = { RESUME: 'RUNNING', PAUSE: 'PAUSED', STOP: 'STOPPED', SHUTDOWN: 'DISABLED', } as const; const mappedStatus = statusMap[input.action as keyof typeof statusMap]; const meta = readMetaObject(profile.meta); const actionLog = Array.isArray(meta.adminActions) ? meta.adminActions.filter((entry) => entry && typeof entry === 'object') : []; const actionRecord = { action: input.action, requestedAt: new Date().toISOString(), durationMinutes: input.durationMinutes ?? null, scheduledAt: input.scheduledAt ?? null, reason: input.reason ?? null, status: 'REQUESTED', }; const nextMeta = { ...meta, adminActions: [...actionLog, actionRecord], }; await ctx.profiles.updateMeta(input.profileName, nextMeta); if (mappedStatus) { await ctx.profiles.updateStatus(input.profileName, mappedStatus); await ctx.orchestrator.reconcileNow(); const appliedActionRecord = { ...actionRecord, status: 'APPLIED', handledAt: new Date().toISOString(), handler: 'gateway-api', detail: `profile status reconciled as ${mappedStatus}`, }; await ctx.profiles.updateMeta(input.profileName, { ...nextMeta, adminActions: [...actionLog, appliedActionRecord], adminActionsUpdatedAt: appliedActionRecord.handledAt, }); return { ok: true, action: appliedActionRecord }; } return { ok: true, action: actionRecord }; }), requestBuild: profileAdminProcedure .input( z.object({ profileName: z.string().min(1), commitSha: z.string().min(7).max(64), }) ) .mutation(async ({ ctx, input }) => { const requestedAt = new Date().toISOString(); const result = await ctx.profiles.updateBuildStatus(input.profileName, 'QUEUED', { requestedAt, error: null, commitSha: input.commitSha, }); return result; }), setBuildStatus: profileAdminProcedure .input( z.object({ profileName: z.string().min(1), status: zBuildStatus, }) ) .mutation(async ({ ctx, input }) => ctx.profiles.updateBuildStatus(input.profileName, input.status)), reconcileNow: profileAdminProcedure.mutation(async ({ ctx }) => { await ctx.orchestrator.reconcileNow(); return { ok: true }; }), cleanupWorkspaces: profileAdminProcedure.mutation(async ({ ctx }) => { const result = await ctx.orchestrator.cleanupStaleWorkspaces(); return { removed: result.removed, skipped: result.skipped, }; }), }), });