import { randomBytes } from 'node:crypto'; import path from 'node:path'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { resolvePostgresConfigFromEnv } from '@sammo-ts/infra'; import { procedure, router } from './trpc.js'; import { listScenarioPreviews, 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 type { GatewayApiContext } from './context.js'; import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js'; import { seedProfileDatabase } from './orchestrator/seedProfileDatabase.js'; const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES); const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES); const zUserRoleMode = z.enum(['set', 'grant', 'revoke']); 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_NOTICE = 'admin.notice.manage'; 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 assertPermission = (adminAuth: AdminAuthContext, permission: string, profileName?: string): void => { if (hasScopedPermission(adminAuth, permission, profileName)) { return; } throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.', }); }; const canCreateLocalUser = (adminAuth: AdminAuthContext): boolean => hasScopedPermission(adminAuth, ROLE_ADMIN_USERS_CREATE) || hasScopedPermission(adminAuth, ROLE_ADMIN_USERS); // 로컬 계정 임의 생성은 환경 설정이 켜져 있을 때만 허용한다. const assertLocalAccountEnabled = (ctx: GatewayApiContext): void => { if (ctx.adminLocalAccountEnabled) { return; } throw new TRPCError({ code: 'FORBIDDEN', message: 'Local account provisioning is disabled.', }); }; const adminProcedure = procedure.use(async ({ ctx, next }) => { const adminAuth = await resolveAdminAuth(ctx as GatewayApiContext); return next({ ctx: { ...ctx, adminAuth, }, }); }); 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 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 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(), profileIconResetAt: z.string().datetime().nullable().optional(), serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(), }); 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 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(), }); 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); applyField('profileIconResetAt', patch.profileIconResetAt); 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({ system: router({ getNotice: adminProcedure.query(async ({ ctx }) => { const setting = await ctx.prisma.systemSetting.findUnique({ where: { id: 1 }, }); return { notice: setting?.notice ?? '' }; }), setNotice: noticeAdminProcedure .input( z.object({ notice: z.string().max(4000), }) ) .mutation(async ({ ctx, input }) => { const setting = await ctx.prisma.systemSetting.upsert({ where: { id: 1 }, create: { id: 1, notice: input.notice, }, update: { notice: input.notice, }, }); return { notice: setting.notice }; }), }), users: router({ getLocalAccountStatus: adminProcedure.query(({ ctx }) => ({ enabled: (ctx as GatewayApiContext).adminLocalAccountEnabled, })), 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, createdAt: user.createdAt, }; }), resetPassword: userAdminProcedure .input( z.object({ userId: z.string().min(1), newPassword: z.string().min(6).max(128).optional(), }) ) .mutation(async ({ ctx, input }) => { 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().min(1)).min(1), mode: zUserRoleMode.optional(), }) ) .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 roles = new Set(user.roles); 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 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, }) ) .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); 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(), }) ) .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, }, }; 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), }) ) .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, { profileIconResetAt: new Date().toISOString(), }); await ctx.users.updateSanctions(input.userId, next); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-profile-icon-reset'); return { profileIconResetAt: next.profileIconResetAt }; }), forceDelete: userAdminProcedure .input( z.object({ userId: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-force-withdraw'); await ctx.users.deleteUser(input.userId); return { ok: true }; }), }), profiles: router({ list: adminProcedure.query(async ({ ctx }) => { const profiles = await ctx.profiles.listProfiles(); 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, runtime: runtimeMap.get(profile.profileName) ?? { profileName: profile.profileName, apiRunning: false, daemonRunning: false, }, })); }), listScenarios: profileAdminProcedure .input( z .object({ gitRef: z.string().min(1).max(128).optional(), }) .optional() ) .query(async ({ input }) => { const gitRef = input?.gitRef?.trim(); return listScenarioPreviews({ gitRef: gitRef || null }); }), 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: profileAdminProcedure .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(), }), }) ) .mutation(async ({ ctx, input }) => { 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 scenarioValue = String(input.install.scenarioId); if (profile.scenario !== scenarioValue) { try { await ctx.profiles.updateScenario(profile.profileName, scenarioValue); } catch (error) { throw new TRPCError({ code: 'CONFLICT', message: 'Scenario update failed due to duplication.', }); } } let resolvedCommitSha: string | null = null; const gitRef = input.install.gitRef?.trim(); if (gitRef) { try { resolvedCommitSha = await resolveGitCommitSha(gitRef); } catch (error) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'git ref is invalid or unavailable.', }); } await ctx.profiles.updateBuildStatus(profile.profileName, profile.buildStatus, { commitSha: resolvedCommitSha, }); } const scheduledAt = openAt ? (preopenAt ?? openAt).toISOString() : null; const action = scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW'; const meta = readMetaObject(profile.meta); const actionLog = Array.isArray(meta.adminActions) ? meta.adminActions.filter((entry) => entry && typeof entry === 'object') : []; 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: gitRef ?? null, autorunUser: autorunUser ? { limitMinutes: autorunUser.limitMinutes, options: autorunUser.options, } : null, adminUser: { id: adminAuth.user.id, username: adminAuth.user.username, displayName: adminAuth.user.displayName, }, }, }; const nextMeta = { ...meta, adminActions: [...actionLog, actionRecord], install: actionRecord.install, installUpdatedAt: now.toISOString(), }; await ctx.profiles.updateMeta(input.profileName, nextMeta); if (openAt) { await ctx.profiles.updateStatus(profile.profileName, profile.status, { preopenAt: preopenAt ? preopenAt.toISOString() : openAt.toISOString(), openAt: openAt.toISOString(), scheduledStartAt: scheduledAt, }); } else { await ctx.profiles.updateStatus(profile.profileName, profile.status, { preopenAt: null, openAt: null, scheduledStartAt: null, }); } return { ok: true, action: actionRecord }; }), 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 scenarioValue = String(input.install.scenarioId); let updatedProfile = profile; if (profile.scenario !== scenarioValue) { const updated = await ctx.profiles.updateScenario(profile.profileName, scenarioValue); if (updated) { updatedProfile = updated; } } const databaseUrl = resolvePostgresConfigFromEnv({ env: process.env, schema: updatedProfile.profile, }).url; const resourcesRoot = path.resolve(process.cwd(), 'resources'); await seedProfileDatabase({ databaseUrl, scenarioId: input.install.scenarioId, tickSeconds: input.install.turnTermMinutes * 60, now: new Date(), installOptions: { turnTermMinutes: input.install.turnTermMinutes, sync: input.install.sync, fiction: input.install.fiction, extend: input.install.extend, blockGeneralCreate: input.install.blockGeneralCreate, npcMode: input.install.npcMode, showImgLevel: input.install.showImgLevel, tournamentTrig: input.install.tournamentTrig, joinMode: input.install.joinMode, autorunUser: input.install.autorunUser ? { limitMinutes: input.install.autorunUser.limitMinutes, options: Object.fromEntries( input.install.autorunUser.options.map((option) => [option, true]) ), } : null, }, scenarioOptions: { scenarioRoot: path.join(resourcesRoot, 'scenario') }, mapOptions: { mapRoot: path.join(resourcesRoot, 'map') }, unitSetOptions: { unitSetRoot: path.join(resourcesRoot, 'unitset') }, adminUser: { id: adminAuth.user.id, username: adminAuth.user.username, displayName: adminAuth.user.displayName, }, }); await ctx.profiles.updateStatus(updatedProfile.profileName, 'RUNNING', { preopenAt: null, openAt: null, scheduledStartAt: null, }); return { ok: true }; }), 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 === 'ACCELERATE' || input.action === 'DELAY') && !input.durationMinutes) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'durationMinutes is required for acceleration or delay.', }); } if (input.action === 'RESET_SCHEDULED' && !input.scheduledAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'scheduledAt is required for scheduled reset.', }); } const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.', }); } const canManageProfiles = hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName); const canResume = canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName); const canResetSchedule = canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESET_SCHEDULE, profile.profileName); const canOpenSurvey = canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName); if (input.action === 'RESUME') { if (profile.status !== 'STOPPED') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Resume is allowed only for STOPPED profiles.', }); } if (!canResume) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Resume permission is required.', }); } } else if (input.action === 'RESET_SCHEDULED') { if (profile.status !== 'COMPLETED') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Reset scheduling is allowed only for COMPLETED profiles.', }); } if (!canResetSchedule) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Reset scheduling 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.', }); } const statusMap = { RESUME: 'RUNNING', PAUSE: 'PAUSED', STOP: 'STOPPED', SHUTDOWN: 'DISABLED', } as const; const mappedStatus = statusMap[input.action as keyof typeof statusMap]; if (mappedStatus) { await ctx.profiles.updateStatus(input.profileName, mappedStatus); await ctx.orchestrator.reconcileNow(); } 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); 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, }; }), }), });