Files
core2026/app/gateway-api/src/adminRouter.ts
T

2064 lines
90 KiB
TypeScript

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, string | string[] | undefined>): 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<boolean> => {
const first = await ctx.prisma.appUser.findFirst({
orderBy: { createdAt: 'asc' },
select: { id: true },
});
return first?.id === userId;
};
const resolveAdminAuth = async (ctx: GatewayApiContext): Promise<AdminAuthContext> => {
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<string>,
nextRoles: ReadonlySet<string>
): 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<typeof zSanctionsPatch>;
// 제재 패치 입력을 현재 제재 상태에 병합한다.
const applySanctionsPatch = (current: UserSanctions, patch: SanctionsPatch): UserSanctions => {
const next: UserSanctions = { ...current };
const applyField = <K extends keyof UserSanctions>(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<string, unknown> => {
if (!value || typeof value !== 'object') {
return {};
}
return value as Record<string, unknown>;
};
const applyMetaPatch = (
meta: Record<string, unknown>,
patch: Record<string, unknown | null | undefined>
): Record<string, unknown> => {
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<void>((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<string, typeof runtimeActions>();
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<void>((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,
};
}),
}),
});