feat: 정식 사용자의 오픈 요청과 동의 기반 예약을 영속화한다

This commit is contained in:
2026-10-23 00:18:45 +09:00
parent 856187b315
commit 236f1bc4ed
24 changed files with 2333 additions and 142 deletions
+2
View File
@@ -131,6 +131,8 @@ export const getAdminCapability = (permission: string): AdminCapabilityDefinitio
CAPABILITY_BY_PERMISSION.get(permission);
export const resolveAdminActionCapability = (path: string, rawInput?: unknown): string | undefined => {
if (path.endsWith('.openRequests.decide')) return 'admin.scenarios.reset';
if (path.endsWith('.openRequests.updatePolicy')) return 'admin.profiles.settings';
if (path.endsWith('.users.createLocal')) return 'admin.users.create';
if (path.includes('.users.')) return 'admin.users.manage';
if (path.includes('.system.')) return 'admin.notice.manage';
+48 -64
View File
@@ -7,6 +7,16 @@ import { gatewayProfileCapabilities } from '@sammo-ts/common';
import type { GatewayPrisma } from '@sammo-ts/infra';
import { procedure, router } from './trpc.js';
import { zOpenRequestPolicy } from './openRequests/policy.js';
import { actOnOpenRequest, updateOpenRequestPolicy } from './openRequests/service.js';
import {
isAllowedTurnTerm,
zInstallOptions,
zOperationInstallOptions,
zProfileResetDefaults,
buildResetOtherTextInfo,
SYSTEM_PROFILE_RESET_DEFAULTS,
} from './resetOptions.js';
import {
listScenarioPreviews,
resolveGitBranchCommitSha,
@@ -51,7 +61,6 @@ const zSpecialAccessProfile = z
.min(1)
.max(64)
.regex(/^[a-z0-9_-]+(?::[a-zA-Z0-9._-]+)?$/);
const zJoinMode = z.enum(['full', 'onlyRandom']);
const zServerAction = z.enum([
'RESUME',
'PAUSE',
@@ -441,15 +450,6 @@ const zLocalAccountInput = z.object({
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)),
});
// Ref aligns a 12-month game year inside a 24-hour wall-clock day. That makes
// every positive divisor of 120 valid, including 3 and 8 minutes.
const isAllowedTurnTerm = (value: number): boolean => Number.isInteger(value) && value > 0 && 120 % value === 0;
const zRuntimeSettings = z
.object({
turnTermMinutes: z
@@ -479,60 +479,6 @@ const isUniqueConstraintError = (error: unknown): boolean =>
const isProfileOperationConflictError = (error: unknown): boolean =>
error instanceof GatewayProfileOperationConflictError || isUniqueConstraintError(error);
const zInstallOptions = z.object({
scenarioId: z.number().int().min(0),
turnTermMinutes: z
.number()
.int()
.refine((value) => isAllowedTurnTerm(value), {
message: '턴 시간은 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 zProfileResetDefaults = zInstallOptions.omit({
scenarioId: true,
openAt: true,
preopenAt: true,
gitRef: true,
});
const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer<typeof zProfileResetDefaults> = {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
};
const buildResetOtherTextInfo = (install: z.infer<typeof zOperationInstallOptions>): string => {
const settings: string[] = [];
if (!install.sync) settings.push('시간동기화 없음');
if (!install.extend) settings.push('확장 NPC 미포함');
if (install.blockGeneralCreate === 1) settings.push('장수 생성 불가');
if (install.blockGeneralCreate === 2) settings.push('장수명 무작위');
if (install.joinMode === 'onlyRandom') settings.push('랜덤 임관');
if (install.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) {
settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][install.showImgLevel] ?? '이미지 표시');
}
if (!install.tournamentTrig) settings.push('토너먼트 수동 시작');
return settings.join(', ');
};
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
@@ -631,6 +577,44 @@ const applyMetaPatch = (
};
export const adminRouter = router({
openRequests: router({
permissions: adminProcedure
.input(z.object({ profileName: z.string().min(1).max(64) }))
.query(({ ctx, input }) => ({
canAccept: hasScopedPermission(requireAdminAuth(ctx), ROLE_ADMIN_SCENARIO_RESET, input.profileName),
canManagePolicy: hasScopedPermission(
requireAdminAuth(ctx),
ROLE_ADMIN_PROFILE_SETTINGS,
input.profileName
),
})),
decide: adminProcedure
.input(z.object({ id: z.string().uuid(), action: z.enum(['ACCEPT', 'REJECT']) }).strict())
.mutation(async ({ ctx, input }) => {
const request = await ctx.prisma.gatewayOpenRequest.findUnique({
where: { id: input.id },
select: { profileName: true },
});
if (!request) throw new TRPCError({ code: 'NOT_FOUND', message: '오픈 요청을 찾을 수 없습니다.' });
const auth = requireAdminAuth(ctx);
assertPermission(auth, ROLE_ADMIN_SCENARIO_RESET, request.profileName);
return actOnOpenRequest(ctx, auth.user, input.id, input.action);
}),
updatePolicy: adminProcedure
.input(
z
.object({
profileName: z.string().min(1).max(64),
policy: zOpenRequestPolicy,
reason: z.string().trim().min(3).max(200),
})
.strict()
)
.mutation(async ({ ctx, input }) => {
assertPermission(requireAdminAuth(ctx), ROLE_ADMIN_PROFILE_SETTINGS, input.profileName);
return updateOpenRequestPolicy(ctx, input.profileName, input.policy, requireAdminAuth(ctx).user.id);
}),
}),
capabilities: router({
list: adminProcedure.query(({ ctx }) => {
const adminAuth = requireAdminAuth(ctx);
@@ -0,0 +1,38 @@
import { z } from 'zod';
export const zOpenRequestPolicy = z
.object({
enabled: z.boolean().default(true),
lifetimeDays: z.number().int().min(1).max(365).default(7),
buildLeadMinutes: z.number().int().min(1).max(10080).default(10),
// null means opener approval only, including CHE by default.
endorsementThreshold: z.number().int().min(1).max(10000).nullable().default(null),
})
.strict();
export interface OpenRequestPolicy {
enabled: boolean;
lifetimeDays: number;
buildLeadMinutes: number;
endorsementThreshold: number | null;
}
export const readOpenRequestPolicy = (meta: Record<string, unknown>): OpenRequestPolicy =>
zOpenRequestPolicy.parse(meta.openRequestPolicy ?? {});
export const canRequestOpening = (status: string): boolean => ['COMPLETED', 'STOPPED', 'CANCELLED'].includes(status);
export const openingSeasonKey = (profile: { currentScenario: string | null; openAt?: string | Date | null }): string =>
JSON.stringify([profile.currentScenario, profile.openAt ? new Date(profile.openAt).toISOString() : null]);
export const canExecuteOpeningReservation = (
profile: { status: string; currentScenario: string | null; openAt?: string | Date | null },
seasonKey: unknown,
openAt: unknown,
now: Date
): boolean =>
canRequestOpening(profile.status) &&
openingSeasonKey(profile) === seasonKey &&
typeof openAt === 'string' &&
Number.isFinite(Date.parse(openAt)) &&
Date.parse(openAt) > now.getTime();
@@ -0,0 +1,48 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { procedure, router } from '../trpc.js';
import { isGatewaySessionCurrent } from '../auth/sessionValidity.js';
import type { GatewayApiContext } from '../context.js';
import { actOnOpenRequest, createOpenRequest, listOpenRequests, zOpeningInstall } from './service.js';
const memberProcedure = procedure.use(async ({ ctx, next }) => {
const header = ctx.requestHeaders['x-session-token'];
const token = Array.isArray(header) ? header[0] : header;
const session = token ? await ctx.sessions.getSession(token) : null;
const user = session ? await ctx.users.findById(session.userId) : null;
if (!session || !user || !isGatewaySessionCurrent(session, user)) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' });
}
return next({ ctx: { ...ctx, openingUser: user } });
});
const visibleProfile = async (ctx: GatewayApiContext, userId: string, profileName: string): Promise<void> => {
const profiles = await ctx.profileStatus.listLobbyProfiles({ userId });
if (!profiles.some((profile) => profile.profileName === profileName))
throw new TRPCError({ code: 'NOT_FOUND', message: '서버를 찾을 수 없습니다.' });
};
export const openRequestsRouter = router({
list: memberProcedure
.input(z.object({ profileName: z.string().min(1).max(64) }).strict())
.query(async ({ ctx, input }) => {
await visibleProfile(ctx, ctx.openingUser.id, input.profileName);
return listOpenRequests(ctx, ctx.openingUser, input.profileName);
}),
create: memberProcedure
.input(z.object({ profileName: z.string().min(1).max(64), install: zOpeningInstall }).strict())
.mutation(async ({ ctx, input }) => {
await visibleProfile(ctx, ctx.openingUser.id, input.profileName);
return createOpenRequest(ctx, ctx.openingUser, input.profileName, input.install);
}),
act: memberProcedure
.input(z.object({ id: z.string().uuid(), action: z.enum(['ENDORSE', 'WITHDRAW']) }).strict())
.mutation(async ({ ctx, input }) => {
const request = await ctx.prisma.gatewayOpenRequest.findUnique({
where: { id: input.id },
select: { profileName: true },
});
if (!request) throw new TRPCError({ code: 'NOT_FOUND', message: '요청을 찾을 수 없습니다.' });
await visibleProfile(ctx, ctx.openingUser.id, request.profileName);
return actOnOpenRequest(ctx, ctx.openingUser, input.id, input.action);
}),
});
+392
View File
@@ -0,0 +1,392 @@
import { isDeepStrictEqual } from 'node:util';
import { TRPCError } from '@trpc/server';
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
import type { GatewayApiContext } from '../context.js';
import type { UserRecord } from '../auth/userRepository.js';
import { resolveLocalAccountProfilePolicy } from '../auth/localAccountPolicy.js';
import { CONTROL_PLANE_OPERATION_CLAIM_LOCK } from '../orchestrator/profileRepository.js';
import { readProfileReleaseSource } from '../orchestrator/profileReleaseSource.js';
import { buildResetOtherTextInfo, zOperationInstallOptions } from '../resetOptions.js';
import {
listScenarioPreviews,
resolveGitBranchCommitSha,
resolveGitCommitSha,
supportsProfileReleaseBuild,
} from '../scenario/scenarioCatalog.js';
import { canRequestOpening, openingSeasonKey, readOpenRequestPolicy, type OpenRequestPolicy } from './policy.js';
import { z } from 'zod';
export const zOpeningInstall = zOperationInstallOptions.required({ preopenAt: true, openAt: true }).strict();
export type OpeningInstall = z.infer<typeof zOpeningInstall>;
export interface OpenRequestResult {
id: string;
status: string;
}
export interface OpenRequestList {
policy: OpenRequestPolicy;
canRequest: boolean;
requests: Array<{
id: string;
status: string;
requesterName: string;
isMine: boolean;
announcement: { scenarioTitle: string; defaultStatTotal: number };
install: OpeningInstall;
expiresAt: string;
createdAt: string;
endorsementCount: number;
endorsed: boolean;
canEndorse: boolean;
operationStatus: string | null;
}>;
}
type Profile = NonNullable<Awaited<ReturnType<GatewayPrismaClient['gatewayProfile']['findUnique']>>>;
type Request = NonNullable<Awaited<ReturnType<GatewayPrismaClient['gatewayOpenRequest']['findUnique']>>>;
const fail = (message: string, code: 'BAD_REQUEST' | 'CONFLICT' | 'FORBIDDEN' | 'NOT_FOUND' = 'BAD_REQUEST'): never => {
throw new TRPCError({ code, message });
};
// Authentication grace is deliberately not full membership. Special grants must apply to this profile.
export const isOpeningMember = async (
ctx: GatewayApiContext,
user: UserRecord,
profile: Profile,
now: Date
): Promise<boolean> => {
if (user.deleteAfter || isGameAccessBlocked(user.sanctions, [profile.profile, profile.profileName], now))
return false;
if (user.oauthType === 'KAKAO' && user.oauthId?.trim() && user.kakaoVerifiedAt) return true;
const policy = resolveLocalAccountProfilePolicy({
profile: profile.profile,
profileName: profile.profileName,
profileMeta: profile.meta as Record<string, unknown>,
defaultGraceDays: ctx.localAccountGraceDays,
user,
specialAccessGrants: await ctx.users.listSpecialAccessGrants(user.id),
now,
});
return policy.kakaoVerified || policy.specialAccess !== null;
};
const assertMember = async (ctx: GatewayApiContext, user: UserRecord, profile: Profile, now: Date): Promise<void> => {
const currentUser = await ctx.users.findById(user.id);
if (!currentUser || !(await isOpeningMember(ctx, currentUser, profile, now)))
fail('카카오 인증 또는 이 서버의 정식 접근 허용이 필요합니다.', 'FORBIDDEN');
};
const buildAt = (preopenAt: Date, lead: number): Date => new Date(preopenAt.getTime() - lead * 60_000);
const activeStatus = (request: Request, profile: Profile, now: Date): string => {
if (request.status !== 'PENDING') return request.status;
if (!canRequestOpening(profile.status) || request.seasonKey !== openingSeasonKey(profile)) return 'SUPERSEDED';
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
if (request.expiresAt <= now || buildAt(request.preopenAt, policy.buildLeadMinutes) <= now) return 'EXPIRED';
return request.status;
};
export const listOpenRequests = async (
ctx: GatewayApiContext,
user: UserRecord,
profileName: string
): Promise<OpenRequestList> => {
const profile = await ctx.prisma.gatewayProfile.findUnique({ where: { profileName } });
if (!profile) return fail('서버를 찾을 수 없습니다.', 'NOT_FOUND');
const now = new Date();
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
const member = await isOpeningMember(ctx, user, profile, now);
const requests = await ctx.prisma.gatewayOpenRequest.findMany({
where: {
profileName,
OR: [
{ status: 'PENDING', expiresAt: { gt: now } },
{ status: 'RESERVED', openAt: { gt: now } },
],
},
orderBy: [{ createdAt: 'desc' }, { id: 'asc' }],
take: 100,
include: {
requester: { select: { displayName: true } },
endorsements: { select: { userId: true } },
operation: { select: { status: true } },
},
});
const voters = await ctx.users.findByIds([
...new Set(requests.flatMap((request) => request.endorsements.map((vote) => vote.userId))),
]);
const qualifiedVoters = new Set<string>();
for (const voter of voters) {
if (await isOpeningMember(ctx, voter, profile, now)) qualifiedVoters.add(voter.id);
}
const activeOperation = await ctx.prisma.gatewayOperation.findFirst({
where: { profileName, status: { in: ['QUEUED', 'RUNNING'] } },
select: { id: true },
});
return {
policy,
canRequest: member && policy.enabled && canRequestOpening(profile.status) && !activeOperation,
requests: requests
.filter((request) => ['PENDING', 'RESERVED'].includes(activeStatus(request, profile, now)))
.map((request) => ({
id: request.id,
status: request.status,
requesterName: request.requester.displayName,
isMine: request.requestedBy === user.id,
announcement: z
.object({ scenarioTitle: z.string(), defaultStatTotal: z.number() })
.parse(request.announcement),
install: zOpeningInstall.parse(request.install),
expiresAt: request.expiresAt.toISOString(),
createdAt: request.createdAt.toISOString(),
endorsementCount: request.endorsements.filter((vote) => qualifiedVoters.has(vote.userId)).length,
endorsed: request.endorsements.some((vote) => vote.userId === user.id),
canEndorse:
member &&
policy.enabled &&
policy.endorsementThreshold !== null &&
request.requestedBy !== user.id &&
request.status === 'PENDING',
operationStatus: request.operation?.status ?? null,
})),
};
};
const inProfileTransaction = async <T>(
ctx: GatewayApiContext,
profileName: string,
run: (tx: GatewayPrisma.TransactionClient, profile: Profile, now: Date) => Promise<T>
): Promise<T> =>
ctx.prisma.$transaction(
async (tx) => {
// Same ordering as RESET admission/claim. This serializes consensus with operator operations.
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0))::text`;
await tx.$queryRaw`SELECT "profile_name" FROM "gateway_profile" WHERE "profile_name" = ${profileName} FOR UPDATE`;
const profile = await tx.gatewayProfile.findUnique({ where: { profileName } });
if (!profile) return fail('서버를 찾을 수 없습니다.', 'NOT_FOUND');
const [{ now }] = await tx.$queryRaw<Array<{ now: Date }>>`SELECT clock_timestamp() AS now`;
return run(tx, profile, now!);
},
{ timeout: 60_000 }
);
export const createOpenRequest = async (
ctx: GatewayApiContext,
user: UserRecord,
profileName: string,
install: OpeningInstall
): Promise<OpenRequestResult> =>
inProfileTransaction(ctx, profileName, async (tx, profile, now) => {
await assertMember(ctx, user, profile, now);
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
if (!policy.enabled || !canRequestOpening(profile.status))
return fail('통일되었거나 닫힌 서버에만 오픈 요청을 할 수 있습니다.', 'CONFLICT');
const preopenAt = new Date(install.preopenAt),
openAt = new Date(install.openAt);
const deadline = buildAt(preopenAt, policy.buildLeadMinutes);
if (deadline <= now || preopenAt >= openAt)
return fail('가오픈은 빌드 선행 시간 이후, 정식 오픈은 가오픈 이후여야 합니다.');
if (install.autorunUser && install.autorunUser.limitMinutes > 0 !== install.autorunUser.options.length > 0)
return fail('자율행동 시간과 항목을 함께 지정해 주세요.');
if (await tx.gatewayOperation.findFirst({ where: { profileName, status: { in: ['QUEUED', 'RUNNING'] } } }))
return fail('진행 중이거나 예약된 서버 작업이 있습니다.', 'CONFLICT');
const old = await tx.gatewayOpenRequest.findMany({ where: { profileName, status: 'PENDING' } });
for (const request of old) {
const status = activeStatus(request, profile, now);
if (status !== 'PENDING')
await tx.gatewayOpenRequest.update({ where: { id: request.id }, data: { status } });
else if (request.requestedBy === user.id) {
if (isDeepStrictEqual(request.install, install)) return { id: request.id, status: request.status };
return fail('기존 요청을 철회한 뒤 새 요청을 등록해 주세요.', 'CONFLICT');
}
}
if (old.filter((request) => activeStatus(request, profile, now) === 'PENDING').length >= 100)
return fail('이 서버의 대기 요청이 너무 많습니다.', 'CONFLICT');
if (!profile.buildCommitSha) return fail('서버의 시나리오 버전이 준비되지 않았습니다.');
const scenario = (await listScenarioPreviews({ gitRef: profile.buildCommitSha })).find(
(entry) => entry.id === install.scenarioId
);
if (!scenario) return fail('현재 서버에 없는 시나리오입니다.');
const expiresAt = new Date(Math.min(now.getTime() + policy.lifetimeDays * 86_400_000, deadline.getTime()));
const request = await tx.gatewayOpenRequest.create({
data: {
profileName,
requestedBy: user.id,
install,
preopenAt,
openAt,
expiresAt,
seasonKey: openingSeasonKey(profile),
announcement: { scenarioTitle: scenario.title, defaultStatTotal: scenario.defaultStatTotal },
},
});
return { id: request.id, status: request.status };
});
// Called only while holding the shared admission lock. All mutations, including the winning
// endorsement, sibling supersession, operation, and log either commit together or roll back.
const reserve = async (
ctx: GatewayApiContext,
tx: GatewayPrisma.TransactionClient,
profile: Profile,
request: Request,
actorId: string,
now: Date
) => {
const requester = await ctx.users.findById(request.requestedBy);
if (!requester || !(await isOpeningMember(ctx, requester, profile, now)))
return fail('요청자의 정식 참여 자격을 확인해 주세요.', 'CONFLICT');
if (
await tx.gatewayOperation.findFirst({
where: { profileName: profile.profileName, status: { in: ['QUEUED', 'RUNNING'] } },
})
)
return fail('진행 중이거나 예약된 서버 작업이 있습니다.', 'CONFLICT');
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
const install = zOpeningInstall.parse(request.install);
const source = readProfileReleaseSource({
meta: profile.meta as GatewayPrisma.JsonObject,
buildCommitSha: profile.buildCommitSha ?? undefined,
});
if (!source) return fail('서버 패치 정책이 준비되지 않았습니다.');
const commit =
source.mode === 'BRANCH' ? await resolveGitBranchCommitSha(source.ref) : await resolveGitCommitSha(source.ref);
const scenario = (await listScenarioPreviews({ gitRef: commit })).find((entry) => entry.id === install.scenarioId);
if (!scenario || !(await supportsProfileReleaseBuild(commit)))
return fail('패치 정책의 버전에서 시나리오 또는 배포 빌드를 사용할 수 없습니다.');
const scheduledAt = buildAt(request.preopenAt, policy.buildLeadMinutes);
const [{ now: admissionNow }] = await tx.$queryRaw<Array<{ now: Date }>>`SELECT clock_timestamp() AS now`;
if (scheduledAt <= admissionNow! || request.expiresAt <= admissionNow!)
return fail('빌드 시작 시각이 지나 요청을 수락할 수 없습니다.', 'CONFLICT');
const operation = await tx.gatewayOperation.create({
data: {
profileName: profile.profileName,
type: 'RESET',
sourceMode: source.mode,
sourceRef: source.mode === 'COMMIT' ? commit : source.ref,
requestedBy: actorId,
scheduledAt,
payload: {
install,
requestedSource: 'CURRENT',
releaseSource: { ...source },
openRequestId: request.id,
openRequestSeasonKey: request.seasonKey,
publicAnnouncement: {
enabled: true,
scenarioId: scenario.id,
scenarioTitle: scenario.title,
defaultStatTotal: scenario.defaultStatTotal,
scheduledAt: scheduledAt.toISOString(),
preopenAt: install.preopenAt,
openAt: install.openAt,
turnTermMinutes: install.turnTermMinutes,
fictionMode: install.fiction === 1 ? '가상' : '사실',
npcMode: install.npcMode,
otherTextInfo: buildResetOtherTextInfo(install),
autorunUser: install.autorunUser ?? null,
},
},
reason: '사용자 오픈 요청 수락',
},
});
await tx.gatewayOperationLog.create({
data: {
operationId: operation.id,
level: 'INFO',
phase: 'queue',
message: '오픈 요청이 서버 패치 정책을 따르는 초기화 예약으로 전환되었습니다.',
},
});
await tx.gatewayOpenRequest.update({
where: { id: request.id },
data: { status: 'RESERVED', operationId: operation.id, decidedBy: actorId, decidedAt: now },
});
await tx.gatewayOpenRequest.updateMany({
where: { profileName: profile.profileName, status: 'PENDING', id: { not: request.id } },
data: { status: 'SUPERSEDED', decidedAt: now },
});
return { id: request.id, status: 'RESERVED' };
};
export const actOnOpenRequest = async (
ctx: GatewayApiContext,
user: UserRecord,
id: string,
action: 'ENDORSE' | 'WITHDRAW' | 'ACCEPT' | 'REJECT'
): Promise<OpenRequestResult> => {
const initial = await ctx.prisma.gatewayOpenRequest.findUnique({ where: { id } });
if (!initial) return fail('오픈 요청을 찾을 수 없습니다.', 'NOT_FOUND');
return inProfileTransaction(ctx, initial.profileName, async (tx, profile, now) => {
const request = await tx.gatewayOpenRequest.findUniqueOrThrow({ where: { id } });
if (action === 'ENDORSE') await assertMember(ctx, user, profile, now);
if (action === 'WITHDRAW' && request.requestedBy !== user.id)
return fail('자신의 요청만 철회할 수 있습니다.', 'FORBIDDEN');
if (request.status === 'RESERVED' && ['ACCEPT', 'ENDORSE'].includes(action))
return { id, status: request.status };
if (action === 'WITHDRAW' && request.status === 'WITHDRAWN') return { id, status: request.status };
if (activeStatus(request, profile, now) !== 'PENDING')
return fail('만료되었거나 이미 처리된 요청입니다.', 'CONFLICT');
if (action === 'WITHDRAW' || action === 'REJECT') {
const status = action === 'WITHDRAW' ? 'WITHDRAWN' : 'REJECTED';
await tx.gatewayOpenRequest.update({ where: { id }, data: { status, decidedBy: user.id, decidedAt: now } });
return { id, status };
}
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
if (!policy.enabled) return fail('이 서버는 오픈 요청을 받지 않습니다.', 'CONFLICT');
if (action === 'ACCEPT') return reserve(ctx, tx, profile, request, user.id, now);
if (request.requestedBy === user.id) return fail('요청자는 자신의 요청에 동의할 수 없습니다.');
if (policy.endorsementThreshold === null) return fail('이 서버는 오프너 수락으로만 예약할 수 있습니다.');
await tx.gatewayOpenEndorsement.upsert({
where: { requestId_userId: { requestId: id, userId: user.id } },
create: { requestId: id, userId: user.id },
update: {},
});
const votes = await tx.gatewayOpenEndorsement.findMany({ where: { requestId: id } });
if (votes.length >= policy.endorsementThreshold) {
// Revoked, banned, deleted or unverified voters no longer contribute to automatic approval.
let valid = 0;
for (const voter of await ctx.users.findByIds(votes.map((vote) => vote.userId))) {
if (await isOpeningMember(ctx, voter, profile, now)) valid++;
}
if (valid >= policy.endorsementThreshold) return reserve(ctx, tx, profile, request, user.id, now);
}
return { id, status: 'PENDING' };
});
};
// Lowering the threshold also evaluates accumulated agreements immediately. Older eligible
// proposals win deterministically; the same admission lock prevents a parallel endorsement winner.
export const updateOpenRequestPolicy = async (
ctx: GatewayApiContext,
profileName: string,
policy: OpenRequestPolicy,
actorId: string
): Promise<OpenRequestPolicy> =>
inProfileTransaction(ctx, profileName, async (tx, profile, now) => {
const updated = await tx.gatewayProfile.update({
where: { profileName },
data: { meta: { ...(profile.meta as GatewayPrisma.JsonObject), openRequestPolicy: { ...policy } } },
});
if (policy.enabled && policy.endorsementThreshold !== null && canRequestOpening(updated.status)) {
const pending = await tx.gatewayOpenRequest.findMany({
where: { profileName, status: 'PENDING' },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
include: { endorsements: { select: { userId: true } } },
});
for (const request of pending) {
if (
activeStatus(request, updated, now) !== 'PENDING' ||
request.endorsements.length < policy.endorsementThreshold
)
continue;
let valid = 0;
for (const voter of await ctx.users.findByIds(request.endorsements.map((vote) => vote.userId))) {
if (await isOpeningMember(ctx, voter, updated, now)) valid++;
}
if (valid >= policy.endorsementThreshold) {
await reserve(ctx, tx, updated, request, actorId, now);
break;
}
}
}
return policy;
});
@@ -1,3 +1,4 @@
import { canExecuteOpeningReservation } from '../openRequests/policy.js';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
@@ -1854,6 +1855,22 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return;
}
if (operation.type === 'RESET' && typeof operation.payload.openRequestId === 'string') {
const install = isRecord(operation.payload.install) ? operation.payload.install : {};
if (
!canExecuteOpeningReservation(
profile,
operation.payload.openRequestSeasonKey,
install.openAt,
this.now()
)
) {
throw new Error(
'오픈 요청 이후 서버가 다시 열렸거나 기수가 변경되었거나 오픈 시각이 지나 예약을 실행할 수 없습니다.'
);
}
}
if (!operation.sourceMode || !operation.sourceRef) {
throw new Error('Operation source mode and ref are required.');
}
@@ -1958,7 +1975,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
commitSha,
assertLease,
operation.id,
readOperationReleaseSource(operation)
readOperationReleaseSource(operation),
typeof payload.openRequestId === 'string'
? { seasonKey: payload.openRequestSeasonKey, openAt: install.openAt }
: undefined
);
if (result.status === 'REQUESTED') {
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
@@ -2413,7 +2433,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
commitShaOverride?: string,
assertLease?: () => Promise<void>,
operationId?: string,
releaseSource?: ProfileReleaseSource
releaseSource?: ProfileReleaseSource,
communityReservation?: { seasonKey: unknown; openAt: unknown }
): Promise<GatewayAdminActionResult> {
const appendLog = async (
phase: string,
@@ -2525,6 +2546,24 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
);
return { status: 'FAILED', detail: 'selected workspace build failed' };
}
const assertCommunityReservation = async (): Promise<void> => {
if (!communityReservation) return;
const current = await this.repository.getProfile(profile.profileName);
if (
!current ||
!canExecuteOpeningReservation(
current,
communityReservation.seasonKey,
communityReservation.openAt,
this.now()
)
) {
throw new Error(
'오픈 요청 이후 서버 상태·기수가 변경되었거나 오픈 시각이 지나 초기화를 중단했습니다.'
);
}
};
await assertCommunityReservation();
await appendLog('seed', '선택 버전의 profile seed CLI를 확인합니다.');
await this.assertProfileSeedCli(workspace.root);
// A newly provisioned profile schema has no world_state row (or table) yet.
@@ -2568,6 +2607,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const firstGameIdx = resolveProfileFirstGameIdx(profileMeta);
const baseSeason = readMetaNumber(normalizeMeta(seedInfo.meta), 'season');
const season = nextSeasonIdx ?? baseSeason ?? 1;
await assertCommunityReservation();
await updateClaimedProfile({ status: 'STOPPED' }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
);
@@ -13,7 +13,9 @@ export interface ProfileReleaseSource {
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
export const readProfileReleaseSource = (profile: GatewayProfileRecord): ProfileReleaseSource | null => {
export const readProfileReleaseSource = (
profile: Pick<GatewayProfileRecord, 'meta' | 'buildCommitSha'>
): ProfileReleaseSource | null => {
const stored = isRecord(profile.meta) && isRecord(profile.meta.releaseSource) ? profile.meta.releaseSource : null;
const mode = stored?.mode;
const ref = typeof stored?.ref === 'string' ? stored.ref.trim() : '';
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const;
const zJoinMode = z.enum(['full', 'onlyRandom']);
const zInstallAutorun = z.object({
limitMinutes: z.number().int().min(0).max(43200),
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
});
// Ref aligns a 12-month game year inside a 24-hour wall-clock day. That makes
// every positive divisor of 120 valid, including 3 and 8 minutes.
export const isAllowedTurnTerm = (value: number): boolean => Number.isInteger(value) && value > 0 && 120 % value === 0;
export const zInstallOptions = z.object({
scenarioId: z.number().int().min(0),
turnTermMinutes: z
.number()
.int()
.refine((value) => isAllowedTurnTerm(value), {
message: '턴 시간은 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(),
});
export const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
export const zProfileResetDefaults = zInstallOptions.omit({
scenarioId: true,
openAt: true,
preopenAt: true,
gitRef: true,
});
export const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer<typeof zProfileResetDefaults> = {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
};
export const buildResetOtherTextInfo = (install: z.infer<typeof zOperationInstallOptions>): string => {
const settings: string[] = [];
if (!install.sync) settings.push('시간동기화 없음');
if (!install.extend) settings.push('확장 NPC 미포함');
if (install.blockGeneralCreate === 1) settings.push('장수 생성 불가');
if (install.blockGeneralCreate === 2) settings.push('장수명 무작위');
if (install.joinMode === 'onlyRandom') settings.push('랜덤 임관');
if (install.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) {
settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][install.showImgLevel] ?? '이미지 표시');
}
if (!install.tournamentTrig) settings.push('토너먼트 수동 시작');
return settings.join(', ');
};
+2
View File
@@ -11,6 +11,7 @@ import { procedure, router } from './trpc.js';
import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js';
import { openRequestsRouter } from './openRequests/router.js';
import { accountRouter } from './account/router.js';
import {
hasActiveSpecialAccountGrant,
@@ -130,6 +131,7 @@ const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verif
};
export const appRouter = router({
openRequests: openRequestsRouter,
navigation: router({
get: procedure.query(({ ctx }) => ctx.navigationConfig.get()),
}),
@@ -0,0 +1,569 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fastifyStatic from '@fastify/static';
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import fastify, { type FastifyRequest } from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
import { createGatewayApiContext } from '../src/context.js';
import { createGatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
import { appRouter } from '../src/router.js';
import { createOpenRequest, actOnOpenRequest, listOpenRequests } from '../src/openRequests/service.js';
import { readOpenRequestPolicy } from '../src/openRequests/policy.js';
import * as catalog from '../src/scenario/scenarioCatalog.js';
vi.mock('../src/scenario/scenarioCatalog.js', () => ({
listScenarioPreviews: vi.fn(async () => [
{
id: 2,
title: '기본 시나리오',
defaultStatTotal: 165,
year: 184,
fiction: 1,
npcCount: 100,
npcExCount: 0,
npcNeutralCount: 0,
nations: [],
},
]),
resolveGitCommitSha: vi.fn(async () => 'a'.repeat(40)),
resolveGitBranchCommitSha: vi.fn(async () => 'b'.repeat(40)),
supportsProfileReleaseBuild: vi.fn(async () => true),
}));
const repositoryRoot = fileURLToPath(new URL('../../../', import.meta.url));
const databaseUrl = process.env.OPEN_REQUEST_DATABASE_URL;
const suite = describe.runIf(Boolean(databaseUrl));
const profileName = 'opening:default';
const install = () => ({
scenarioId: 2,
turnTermMinutes: 3,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full' as const,
autorunUser: null,
preopenAt: new Date(Date.now() + 86_400_000).toISOString(),
openAt: new Date(Date.now() + 90_000_000).toISOString(),
});
suite('durable opening requests (isolated PostgreSQL and HTTP)', () => {
const connector = createGatewayPostgresConnector({ url: databaseUrl ?? '' });
const prisma = connector.prisma;
const users = createPostgresUserRepository(prisma);
const profiles = createGatewayProfileRepository(prisma);
const sessions = new InMemoryGatewaySessionService({ sessionTtlSeconds: 3600, gameSessionTtlSeconds: 600 });
const ids = [randomUUID(), randomUUID(), randomUUID(), randomUUID(), randomUUID()];
const passwordEnvelope = createPasswordEnvelopeService();
const profileStatus = new InMemoryProfileStatusService([
{
profileName,
profile: 'opening',
instanceKey: 'default',
currentScenario: null,
scenario: 'default',
status: 'STOPPED',
apiPort: 15991,
korName: '테스트',
color: '#fff',
runtime: {
apiRunning: false,
daemonRunning: false,
auctionRunning: false,
battleSimRunning: false,
tournamentRunning: false,
},
lifecycle: {
runtimeExpected: false,
userAccessible: false,
turnsRunning: false,
operatorResumable: true,
dataInitialized: false,
},
},
]);
const context = (headers: Record<string, string | string[] | undefined> = {}) =>
createGatewayApiContext({
prisma,
users,
profiles,
sessions,
passwordEnvelope,
profileStatus,
requestHeaders: headers,
flushPublisher: { publishUserFlush: async () => {} },
gameTokenSecret: 'fixture-only',
gameSessionTtlSeconds: 600,
publicBaseUrl: 'http://127.0.0.1',
localAccountGraceDays: 7,
adminLocalAccountEnabled: false,
localRegistrationEnabled: false,
kakaoClient: {} as never,
oauthSessions: {} as never,
orchestrator: {} as never,
});
const user = async (index: number) => (await users.findById(ids[index]!))!;
const caller = async (index: number) => {
const session = await sessions.createSession(await user(index));
return appRouter.createCaller(context({ 'x-session-token': session.sessionToken }));
};
const app = fastify();
beforeAll(async () => {
// This database is created exclusively for this suite by the verification runner.
if (
!databaseUrl?.includes('open_request') &&
new URL(databaseUrl!).searchParams.get('schema') !== process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA
)
throw new Error('Use an isolated open_request database/schema.');
await connector.connect();
await app.register(fastifyTRPCPlugin, {
prefix: '/gateway/api/trpc',
trpcOptions: {
router: appRouter,
...trpcJsonBodyHttpServerOptions,
createContext: ({ req }: { req: FastifyRequest }) => context(req.headers),
},
});
if (process.env.OPEN_REQUEST_BROWSER === '1') {
await app.register(fastifyStatic, {
root: path.join(repositoryRoot, 'app/gateway-frontend/dist'),
prefix: '/gateway/',
});
app.get('/gateway/api/navigation', async () =>
JSON.parse(await fs.readFile(path.join(repositoryRoot, 'resources/navigation.json'), 'utf8'))
);
app.setNotFoundHandler((request, reply) =>
request.url.startsWith('/gateway/') && !request.url.includes('/api/')
? reply.sendFile('index.html')
: reply.code(404).send()
);
}
await app.ready();
});
beforeEach(async () => {
await prisma.gatewayOpenRequest.deleteMany({ where: { profileName } });
await prisma.gatewayOperation.deleteMany({ where: { profileName } });
await prisma.specialAccountAccessGrant.deleteMany({ where: { userId: { in: ids } } });
await prisma.appUser.deleteMany({ where: { id: { in: ids } } });
for (let index = 0; index < ids.length; index++) {
await prisma.appUser.create({
data: {
id: ids[index],
loginId: `opening-${ids[0]}-${index}`,
displayName: `사용자${index}-${ids[0]!.slice(0, 6)}`,
passwordHash: 'fixture',
passwordSalt: 'fixture',
oauthType: index === 4 ? 'NONE' : 'KAKAO',
oauthId: index === 4 ? null : `opening-${ids[0]}-${index}`,
kakaoVerifiedAt: index === 4 ? null : new Date(),
createdAt: new Date(2020 + index, 0, 1),
roles: index === 3 ? [`admin.scenarios.reset:${profileName}`] : [],
},
});
}
await profiles.upsertProfile({
profile: 'opening',
instanceKey: 'default',
apiPort: 15991,
status: 'STOPPED',
buildCommitSha: 'a'.repeat(40),
meta: {},
});
await prisma.gatewayProfile.update({
where: { profileName },
data: { openAt: null, preopenAt: null, currentScenario: null, meta: {} },
});
vi.clearAllMocks();
vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(true);
});
afterAll(async () => {
await app.close();
await connector.disconnect();
});
const create = async (index = 1) => createOpenRequest(context(), await user(index), profileName, install());
const policy = async (threshold: number | null, extra: Record<string, number> = {}) =>
prisma.gatewayProfile.update({
where: { profileName },
data: { meta: { openRequestPolicy: { endorsementThreshold: threshold, ...extra } } },
});
it('persists immutable settings and expires before build; repeated submission is idempotent', async () => {
const input = install();
const first = await createOpenRequest(context(), await user(1), profileName, input);
const replay = await createOpenRequest(context(), await user(1), profileName, input);
expect(replay.id).toBe(first.id);
const row = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: first.id } });
expect(row.install).toEqual(input);
expect(row.expiresAt.getTime()).toBe(new Date(input.preopenAt).getTime() - 600_000);
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0);
expect((await listOpenRequests(context(), await user(2), profileName)).requests).toHaveLength(1);
expect(JSON.stringify(await listOpenRequests(context(), await user(2), profileName))).not.toContain('password');
});
it('uses the configured lifetime for distant proposals', async () => {
await policy(null, { lifetimeDays: 2 });
const input = install();
input.preopenAt = new Date(Date.now() + 10 * 86_400_000).toISOString();
input.openAt = new Date(Date.now() + 11 * 86_400_000).toISOString();
const r = await createOpenRequest(context(), await user(1), profileName, input);
const row = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } });
expect(row.expiresAt.getTime() - row.createdAt.getTime()).toBeCloseTo(2 * 86_400_000, -3);
});
it.each(['RUNNING', 'PREOPEN', 'PAUSED', 'RESERVED', 'DISABLED'] as const)(
'rejects requests in %s',
async (status) => {
await prisma.gatewayProfile.update({ where: { profileName }, data: { status } });
await expect(create()).rejects.toMatchObject({ code: 'CONFLICT' });
expect(await prisma.gatewayOpenRequest.count({ where: { profileName } })).toBe(0);
}
);
it.each(['COMPLETED', 'STOPPED', 'CANCELLED'] as const)('accepts a request in %s', async (status) => {
await prisma.gatewayProfile.update({ where: { profileName }, data: { status } });
await expect(create()).resolves.toMatchObject({ status: 'PENDING' });
});
it('rejects grace-only and sanctioned accounts, accepts a scoped active special grant', async () => {
await expect(create(4)).rejects.toMatchObject({ code: 'FORBIDDEN' });
await prisma.specialAccountAccessGrant.create({
data: {
userId: ids[4]!,
kind: 'TESTER',
profiles: ['different'],
allowsGeneralCreation: true,
reason: 'fixture',
grantedByUserId: ids[0]!,
},
});
await expect(create(4)).rejects.toMatchObject({ code: 'FORBIDDEN' });
await prisma.specialAccountAccessGrant.updateMany({
where: { userId: ids[4] },
data: { profiles: [profileName] },
});
await expect(create(4)).resolves.toMatchObject({ status: 'PENDING' });
await prisma.appUser.update({
where: { id: ids[1] },
data: { sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['game'] } } } },
});
await expect(create()).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('deduplicates concurrent endorsements and excludes self agreement', async () => {
await policy(3);
const r = await create();
await expect(actOnOpenRequest(context(), await user(1), r.id, 'ENDORSE')).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await Promise.all(
Array.from({ length: 5 }, async () => actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE'))
);
expect(await prisma.gatewayOpenEndorsement.count({ where: { requestId: r.id } })).toBe(1);
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0);
});
it('atomically reserves once at consensus, follows branch policy, supersedes other proposals', async () => {
await policy(2, { buildLeadMinutes: 25 });
await prisma.gatewayProfile.update({
where: { profileName },
data: {
meta: {
releaseSource: { mode: 'BRANCH', ref: 'main' },
openRequestPolicy: { endorsementThreshold: 2, buildLeadMinutes: 25 },
},
},
});
const r = await create();
const other = await create(2);
await Promise.all([
actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE'),
actOnOpenRequest(context(), await user(3), r.id, 'ENDORSE'),
]);
const row = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } });
const ops = await prisma.gatewayOperation.findMany({ where: { profileName } });
expect(row.status).toBe('RESERVED');
expect(ops).toHaveLength(1);
expect(ops[0]).toMatchObject({
sourceMode: 'BRANCH',
sourceRef: 'main',
resolvedCommitSha: null,
status: 'QUEUED',
});
expect(ops[0]!.scheduledAt!.getTime()).toBe(row.preopenAt.getTime() - 25 * 60_000);
expect((await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: other.id } })).status).toBe(
'SUPERSEDED'
);
expect(await profiles.claimNextOperation(new Date())).toBeNull();
});
it('keeps CHE-style manual approval and validates scoped opener capability', async () => {
expect(readOpenRequestPolicy({}).endorsementThreshold).toBeNull();
const r = await create();
await expect((await caller(2)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'FORBIDDEN' }
);
await expect(actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE')).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await Promise.all([
(await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' }),
(await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' }),
]);
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(1);
});
it('does not count a voter whose membership has been revoked', async () => {
await policy(2);
const r = await create();
await actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE');
await prisma.appUser.update({ where: { id: ids[2] }, data: { kakaoVerifiedAt: null } });
await actOnOpenRequest(context(), await user(3), r.id, 'ENDORSE');
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0);
});
it('rejects withdrawal by another user, permits own withdrawal, and rejects post-reservation withdrawal', async () => {
const r = await create();
await expect(actOnOpenRequest(context(), await user(2), r.id, 'WITHDRAW')).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await actOnOpenRequest(context(), await user(1), r.id, 'WITHDRAW');
expect((await listOpenRequests(context(), await user(1), profileName)).requests).toHaveLength(0);
const next = await create();
await (await caller(3)).admin.openRequests.decide({ id: next.id, action: 'ACCEPT' });
await expect(actOnOpenRequest(context(), await user(1), next.id, 'WITHDRAW')).rejects.toMatchObject({
code: 'CONFLICT',
});
});
it('hides expired requests and refuses late approval without operation side effects', async () => {
const r = await create();
// Keep DB temporal constraints valid while simulating an expired proposal.
await prisma.gatewayOpenRequest.update({
where: { id: r.id },
data: { createdAt: new Date(Date.now() - 2 * 86_400_000), expiresAt: new Date(Date.now() - 86_400_000) },
});
expect((await listOpenRequests(context(), await user(1), profileName)).requests).toHaveLength(0);
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'CONFLICT' }
);
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0);
});
it('refuses stale seasons and a game reopened before approval', async () => {
const r = await create();
await prisma.gatewayProfile.update({ where: { profileName }, data: { openAt: new Date() } });
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'CONFLICT' }
);
await prisma.gatewayProfile.update({ where: { profileName }, data: { openAt: null, status: 'RUNNING' } });
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'CONFLICT' }
);
});
it('serializes approval against existing operations and leaves proposal pending on build incompatibility', async () => {
const r = await create();
vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(false);
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'BAD_REQUEST' }
);
expect((await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('PENDING');
vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(true);
await profiles.createOperation({ profileName, type: 'START', requestedBy: ids[3]! });
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'CONFLICT' }
);
});
it('immediately applies a lowered consensus threshold to existing agreements', async () => {
await policy(3);
const r = await create();
await actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE');
await prisma.appUser.update({
where: { id: ids[3] },
data: { roles: [`admin.profiles.settings:${profileName}`] },
});
await (
await caller(3)
).admin.openRequests.updatePolicy({
profileName,
policy: { enabled: true, lifetimeDays: 7, buildLeadMinutes: 10, endorsementThreshold: 1 },
reason: '동의 기준 조정',
});
expect((await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('RESERVED');
});
it('rolls back a deciding endorsement if reservation fails and supports retry', async () => {
await policy(1);
const r = await create();
vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(false);
await expect(actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE')).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(await prisma.gatewayOpenEndorsement.count({ where: { requestId: r.id } })).toBe(0);
vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(true);
await expect(actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE')).resolves.toMatchObject({
status: 'RESERVED',
});
});
it('rejects equal, past and insufficient-lead dates and conflicting edits', async () => {
const input = install();
await expect(
createOpenRequest(context(), await user(1), profileName, { ...input, preopenAt: input.openAt })
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(
createOpenRequest(context(), await user(1), profileName, {
...input,
preopenAt: new Date(Date.now() + 60_000).toISOString(),
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await create();
await expect(
createOpenRequest(context(), await user(1), profileName, { ...input, turnTermMinutes: 8 })
).rejects.toMatchObject({ code: 'CONFLICT' });
});
it('rejects a different-profile opener and policy changes by an ordinary member', async () => {
const r = await create();
await prisma.appUser.update({
where: { id: ids[3] },
data: { roles: ['admin.scenarios.reset:other:default'] },
});
await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject(
{ code: 'FORBIDDEN' }
);
await expect(
(await caller(2)).admin.openRequests.updatePolicy({
profileName,
policy: { enabled: true, lifetimeDays: 7, buildLeadMinutes: 10, endorsementThreshold: 1 },
reason: '권한 거부 확인',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0);
});
it.runIf(process.env.OPEN_REQUEST_BROWSER === '1')(
'persists and approves through Chromium, actual HTTP and PostgreSQL',
async () => {
const { chromium } = await import('@playwright/test');
await policy(2);
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const browser = await chromium.launch({ headless: true });
const browserContext = await browser.newContext({
viewport: { width: 1200, height: 900 },
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
colorScheme: 'dark',
});
const session = await sessions.createSession(await user(1));
await browserContext.addInitScript((token) => {
if (!localStorage.getItem('sammo-session-token')) localStorage.setItem('sammo-session-token', token);
}, session.sessionToken);
const page = await browserContext.newPage();
const artifactDir = path.join(repositoryRoot, 'test-results/open-request-live');
await fs.mkdir(artifactDir, { recursive: true });
try {
await page.goto(`${address}/gateway/open-suggestion`);
await page.getByTestId('proposal-preopen').fill('2099-08-18T11:30');
await page.getByTestId('proposal-open').fill('2099-08-18T12:00');
await page.getByTestId('submit-open-request').click();
await page.locator('.request-card').waitFor();
const row = await prisma.gatewayOpenRequest.findFirstOrThrow({ where: { profileName } });
expect(row.preopenAt.toISOString()).toBe('2099-08-18T02:30:00.000Z');
expect(row.requestedBy).toBe(ids[1]);
await page.reload();
await page.locator('.request-card').waitFor();
const voterSession = await sessions.createSession(await user(2));
await page.evaluate(
(token) => localStorage.setItem('sammo-session-token', token),
voterSession.sessionToken
);
await page.reload();
const endorse = page.getByRole('button', { name: '동의', exact: true });
await endorse.waitFor();
await endorse.hover();
await endorse.focus();
await endorse.click();
await page.getByRole('button', { name: '동의 완료', exact: true }).waitFor();
expect(await prisma.gatewayOpenEndorsement.count({ where: { requestId: row.id } })).toBe(1);
await page.screenshot({ path: path.join(artifactDir, 'pending-endorsement.png'), fullPage: true });
const openerSession = await sessions.createSession(await user(3));
await page.evaluate(
(token) => localStorage.setItem('sammo-session-token', token),
openerSession.sessionToken
);
await page.reload();
const accept = page.getByRole('button', { name: '수락하여 오픈 예약' });
await accept.waitFor();
await accept.hover();
await accept.focus();
await accept.click();
await page.locator('.request-card').filter({ hasText: '준비 예약' }).waitFor();
const reserved = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: row.id } });
expect(reserved.status).toBe('RESERVED');
expect(await prisma.gatewayOperation.count({ where: { id: reserved.operationId! } })).toBe(1);
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await page.evaluate('document.fonts.ready');
await page.locator('.request-card summary').click();
const measurements = await page.locator('.request-list').evaluate((element) => {
const rect = element.getBoundingClientRect(),
style = element.ownerDocument.defaultView!.getComputedStyle(element);
return {
rect: rect.toJSON(),
font: style.font,
color: style.color,
documentWidth: element.ownerDocument.documentElement.scrollWidth,
viewportWidth: element.ownerDocument.defaultView!.innerWidth,
html: element.outerHTML,
};
});
expect(measurements.documentWidth).toBe(viewport.width);
await fs.writeFile(
path.join(artifactDir, `requests-${viewport.width}.json`),
JSON.stringify(measurements, null, 2)
);
await page.screenshot({
path: path.join(artifactDir, `requests-${viewport.width}.png`),
fullPage: true,
});
}
} catch (error) {
await fs.writeFile(path.join(artifactDir, 'failure.html'), await page.content());
await page.screenshot({ path: path.join(artifactDir, 'failure.png'), fullPage: true });
throw error;
} finally {
await browser.close();
}
},
60_000
);
it('enforces authentication and rejects user-supplied actor, source and build time over real HTTP', async () => {
const unauth = await app.inject({
method: 'POST',
url: '/gateway/api/trpc/openRequests.create',
payload: { profileName, install: install() },
});
expect(unauth.statusCode).toBe(401);
const session = await sessions.createSession(await user(1));
for (const extra of [
{ requestedBy: ids[3] },
{ scheduledAt: new Date().toISOString() },
{ sourceMode: 'BRANCH', sourceRef: 'evil' },
]) {
const response = await app.inject({
method: 'POST',
url: '/gateway/api/trpc/openRequests.create',
headers: { 'x-session-token': session.sessionToken },
payload: { profileName, install: install(), ...extra },
});
expect(response.statusCode).toBe(400);
}
const success = await app.inject({
method: 'POST',
url: '/gateway/api/trpc/openRequests.create',
headers: { 'x-session-token': session.sessionToken },
payload: { profileName, install: install() },
});
expect(success.statusCode).toBe(200);
expect(await prisma.gatewayOpenRequest.count({ where: { profileName } })).toBe(1);
});
});
@@ -669,6 +669,75 @@ describe('GatewayOrchestrator first-class operations', () => {
]);
});
it.each([
{ ...profile, status: 'RUNNING' as const },
{ ...profile, openAt: '2026-10-22T00:00:00.000Z' },
])('refuses a stale community reset before resolving or building code', async (changedProfile) => {
const reset: GatewayOperationRecord = {
...buildOperation('START'),
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: profile.buildCommitSha,
payload: {
openRequestId: 'request',
openRequestSeasonKey: JSON.stringify([profile.currentScenario, null]),
},
};
const harness = createHarness(reset, false, false, true, false, undefined, undefined, {
profile: changedProfile,
});
await harness.orchestrator.runOperationsNow();
expect(harness.completions).toEqual(['FAILED']);
expect(harness.started).toHaveLength(0);
expect(harness.stopped).toHaveLength(0);
expect(harness.buildStatuses).toHaveLength(0);
expect(harness.completionFields[0]?.error).toContain('서버가 다시 열렸거나');
});
it('allows an eligible community reservation to proceed into the existing build path', async () => {
const reset: GatewayOperationRecord = {
...buildOperation('START'),
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: profile.buildCommitSha,
payload: {
openRequestId: 'request',
openRequestSeasonKey: JSON.stringify([profile.currentScenario, null]),
install: { scenarioId: 2, openAt: '2099-01-01T00:00:00.000Z' },
},
};
const workspaceManager = {
resolveCommit: async () => profile.buildCommitSha!,
prepare: async () => {
throw new Error('fixture reached community build preparation');
},
remove: async () => {},
} as unknown as GitWorkspaceManager;
const harness = createHarness(reset, false, false, true, false, workspaceManager);
await harness.orchestrator.runOperationsNow();
expect(harness.completions).toEqual(['FAILED']);
expect(harness.completionFields[0]?.error).toBe('fixture reached community build preparation');
});
it('refuses a community reservation after its opening deadline', async () => {
const reset: GatewayOperationRecord = {
...buildOperation('START'),
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: profile.buildCommitSha,
payload: {
openRequestId: 'request',
openRequestSeasonKey: JSON.stringify([profile.currentScenario, null]),
install: { openAt: '2020-01-01T00:00:00.000Z' },
},
};
const harness = createHarness(reset);
await harness.orchestrator.runOperationsNow();
expect(harness.completions).toEqual(['FAILED']);
expect(harness.buildStatuses).toHaveLength(0);
expect(harness.completionFields[0]?.error).toContain('오픈 시각이 지나');
});
it('drains an in-flight operation before shutdown completes', async () => {
let releaseStart: (() => void) | undefined;
const startGate = new Promise<void>((resolve) => {
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
gatewaySchemaHead: '20261022000000_add_open_requests',
gameSchemaHead: '20260916080000_index_play_audit_decision_month',
});
});
@@ -8,14 +8,22 @@ const operationNames = (route: Route): string[] => {
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const installFixture = async (page: Page): Promise<string[]> => {
const installFixture = async (page: Page, opener = false): Promise<string[]> => {
const operations: string[] = [];
let savedRequest: Record<string, unknown> | null = null;
let policy = { enabled: true, lifetimeDays: 7, buildLeadMinutes: 10, endorsementThreshold: 2 as number | null };
await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'regular-user-session');
});
await page.route('**/gateway/api/trpc/**', async (route) => {
expect(route.request().headers()['x-session-token']).toBe('regular-user-session');
const results = operationNames(route).map((operation) => {
const url = new URL(route.request().url());
const raw =
route.request().method() === 'POST'
? route.request().postDataJSON()
: JSON.parse(url.searchParams.get('input') ?? '{}');
const results = operationNames(route).map((operation, index) => {
const input = url.searchParams.get('batch') === '1' ? raw[String(index)] : raw;
operations.push(operation);
if (operation === 'me') {
return response({
@@ -52,6 +60,7 @@ const installFixture = async (page: Page): Promise<string[]> => {
]);
}
if (operation === 'lobby.scenarios') {
expect(input).toEqual({ profileName: 'pya:default' });
return response([
{
id: 2701,
@@ -77,6 +86,48 @@ const installFixture = async (page: Page): Promise<string[]> => {
},
]);
}
if (operation === 'openRequests.list') {
expect(input).toEqual({ profileName: 'pya:default' });
return response({ policy, canRequest: true, requests: savedRequest ? [savedRequest] : [] });
}
if (operation === 'admin.openRequests.permissions') {
return response({ canAccept: opener, canManagePolicy: opener });
}
if (operation === 'openRequests.create') {
expect(Object.keys(input).sort()).toEqual(['install', 'profileName']);
expect(input.profileName).toBe('pya:default');
expect(input.install.preopenAt).toBe('2099-08-18T02:30:00.000Z');
expect(input.install.openAt).toBe('2099-08-18T03:00:00.000Z');
expect(input.install.scenarioId).toBe(2701);
expect(input.install).not.toHaveProperty('gitRef');
savedRequest = {
id: '11111111-1111-4111-8111-111111111111',
status: 'PENDING',
requesterName: '일반유저',
isMine: true,
announcement: { scenarioTitle: '아시아 명장전', defaultStatTotal: 310 },
install: input.install,
expiresAt: '2099-08-18T02:20:00.000Z',
createdAt: '2099-08-17T00:00:00.000Z',
endorsementCount: 0,
endorsed: false,
canEndorse: false,
operationStatus: null,
};
return response({ id: savedRequest.id, status: savedRequest.status });
}
if (operation === 'admin.openRequests.decide') {
expect(opener).toBe(true);
expect(input).toEqual({ id: savedRequest?.id, action: 'ACCEPT' });
savedRequest = { ...savedRequest, status: 'RESERVED', operationStatus: 'QUEUED' };
return response({ id: savedRequest.id, status: 'RESERVED' });
}
if (operation === 'admin.openRequests.updatePolicy') {
expect(opener).toBe(true);
expect(input.reason).toBe('테스트 서버 정책 변경');
policy = input.policy;
return response(policy);
}
throw new Error(`Unhandled tRPC operation: ${operation}`);
});
const batched = new URL(route.request().url()).searchParams.get('batch') === '1';
@@ -95,7 +146,7 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
const operations = await installFixture(page);
await page.goto('lobby');
const suggestionLink = page.getByRole('link', { name: '오픈 건의 양식 작성' });
const suggestionLink = page.getByRole('link', { name: '오픈 요청 · 목록' });
await expect(suggestionLink).toBeVisible();
await suggestionLink.hover();
await suggestionLink.focus();
@@ -103,8 +154,10 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
await suggestionLink.click();
await expect(page).toHaveURL(/\/gateway\/open-suggestion$/);
await expect(page.getByRole('heading', { name: '오픈 건의 양식' })).toBeVisible();
await expect(page.getByText('서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.')).toBeVisible();
await expect(page.getByRole('heading', { name: '오픈 요청', exact: true })).toBeVisible();
await expect(
page.getByText('복사는 문구만 복사하며, 오픈 요청 등록 버튼을 눌러야 요청이 저장됩니다.')
).toBeVisible();
await expect(page.getByTestId('scenario-summary')).toContainText('310');
await page.getByTestId('proposal-open').fill('2026-08-18T12:00');
@@ -166,7 +219,77 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
await page.screenshot({ path: testInfo.outputPath('open-suggestion-mobile.png'), fullPage: true });
expect(operations).toContain('lobby.scenarios');
expect(operations.every((operation) => ['me', 'lobby.notice', 'lobby.profiles', 'lobby.scenarios'].includes(operation))).toBe(
true
);
expect(
operations.every((operation) =>
[
'me',
'lobby.notice',
'lobby.profiles',
'lobby.scenarios',
'openRequests.list',
'admin.openRequests.permissions',
].includes(operation)
)
).toBe(true);
});
test('registers a durable proposal and lets the opener accept and configure it across desktop/mobile', async ({
page,
}, testInfo) => {
const operations = await installFixture(page, true);
await page.goto('open-suggestion');
await expect(page.getByRole('heading', { name: '오픈 요청 목록' })).toBeVisible();
await page.getByTestId('proposal-preopen').fill('2099-08-18T11:30');
await page.getByTestId('proposal-open').fill('2099-08-18T12:00');
const submit = page.getByTestId('submit-open-request');
await submit.hover();
await submit.focus();
await expect(submit).toBeEnabled();
await submit.click();
const card = page.locator('.request-card');
await expect(card).toContainText('일반유저');
await expect(card).toContainText('수락 대기');
await page.reload();
await expect(card).toContainText('아시아 명장전');
await card.getByText('요청 설정 전체').click();
await expect(card).toContainText('시간 동기화');
await card.getByRole('button', { name: '수락하여 오픈 예약' }).hover();
await card.getByRole('button', { name: '수락하여 오픈 예약' }).click();
await expect(card).toContainText('오픈 예약');
await expect(card).toContainText('준비 예약');
await expect(card.getByRole('button', { name: '요청 철회' })).toHaveCount(0);
await page.getByText('서버 오픈 요청 정책', { exact: true }).click();
const policy = page.locator('.request-policy');
await policy.getByLabel('가오픈 전 준비 시간(분)').fill('25');
await policy.getByLabel('동의로 자동 예약').uncheck();
await expect(policy.getByLabel('필요 동의 수(요청자 제외)')).toHaveCount(0);
await policy.getByLabel('변경 사유').fill('테스트 서버 정책 변경');
await policy.getByRole('button', { name: '정책 저장' }).click();
await expect(page.getByText('이 서버는 오프너가 수락하면 예약됩니다.')).toBeVisible();
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await page.evaluate(() => document.fonts.ready);
const measurements = await page.locator('.request-list').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: rect.toJSON(),
color: style.color,
font: style.font,
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
html: element.outerHTML,
};
});
expect(measurements.documentWidth).toBe(viewport.width);
expect(measurements.rect.right).toBeLessThanOrEqual(viewport.width);
await writeFile(testInfo.outputPath(`requests-${viewport.width}.json`), JSON.stringify(measurements, null, 2));
await page.screenshot({ path: testInfo.outputPath(`requests-${viewport.width}.png`), fullPage: true });
}
expect(operations).toContain('openRequests.create');
expect(operations).toContain('admin.openRequests.decide');
expect(operations).toContain('admin.openRequests.updatePolicy');
});
+2 -2
View File
@@ -802,13 +802,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</div>
<div class="p-6 text-center">
<p class="m-0 text-sm text-zinc-400">
시나리오와 빌드 옵션을 확인하고 운영자에게 전달할 오픈 건의 문구를 만들 수 있습니다.
다음 오픈을 요청하고, 다른 사용자의 요청에 동의하거나 진행 상황을 확인할 수 있습니다.
</p>
<RouterLink
to="/open-suggestion"
class="open-suggestion-link mt-4 inline-flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
>
오픈 건의 양식 작성
오픈 요청 · 목록
</RouterLink>
</div>
</div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common/time/ServerDateTime';
import type { AppRouter } from '@sammo-ts/gateway-api';
import type { inferRouterOutputs } from '@trpc/server';
import { computed, onMounted, reactive, ref, watch } from 'vue';
@@ -24,6 +25,112 @@ const scenarios = ref<Scenario[]>([]);
const catalogLoading = ref(false);
const catalogError = ref('');
const copiedMessage = ref('');
const requestState = ref<GatewayOutput['openRequests']['list'] | null>(null);
const requestMessage = ref('');
const requestError = ref('');
const requestBusy = ref(false);
const requestLoading = ref(false);
const canAccept = ref(false);
const canManagePolicy = ref(false);
const policyForm = reactive({
enabled: true,
lifetimeDays: 7,
buildLeadMinutes: 10,
endorsementThreshold: null as number | null,
});
const policyReason = ref('');
let requestListId = 0;
const loadRequests = async (): Promise<void> => {
const profileName = form.profileName;
const requestId = ++requestListId;
requestState.value = null;
canAccept.value = false;
canManagePolicy.value = false;
if (!profileName) return;
requestLoading.value = true;
try {
const result = await directTrpc.openRequests.list.query({ profileName });
const permissions = await directTrpc.admin.openRequests.permissions.query({ profileName }).catch(() => null);
if (requestId !== requestListId) return;
requestState.value = result;
Object.assign(policyForm, result.policy);
canAccept.value = permissions?.canAccept ?? false;
canManagePolicy.value = permissions?.canManagePolicy ?? false;
} catch (error) {
if (requestId === requestListId)
requestError.value = error instanceof Error ? error.message : '요청 목록을 불러오지 못했습니다.';
} finally {
if (requestId === requestListId) requestLoading.value = false;
}
};
const performRequest = async (work: () => Promise<unknown>, message: string): Promise<void> => {
if (requestBusy.value) return;
requestBusy.value = true;
requestError.value = '';
requestMessage.value = '';
try {
await work();
requestMessage.value = message;
await loadRequests();
} catch (error) {
requestError.value = error instanceof Error ? error.message : '요청을 처리하지 못했습니다.';
} finally {
requestBusy.value = false;
}
};
const submitRequest = (): Promise<void> =>
performRequest(async () => {
const preopenAt = serverDateTimeInputToIso(form.preopenAt);
const openAt = serverDateTimeInputToIso(form.openAt);
if (!preopenAt || !openAt || form.scenarioId === null)
throw new Error('가오픈·오픈 시각과 시나리오를 확인해 주세요.');
return directTrpc.openRequests.create.mutate({
profileName: form.profileName,
install: {
scenarioId: form.scenarioId,
preopenAt,
openAt,
turnTermMinutes: form.turnTermMinutes,
fiction: form.fiction,
npcMode: form.npcMode,
sync: form.sync,
extend: form.extend,
blockGeneralCreate: form.blockGeneralCreate,
showImgLevel: form.showImgLevel,
tournamentTrig: form.tournamentTrig,
joinMode: form.joinMode,
autorunUser: form.autorunEnabled
? { limitMinutes: form.autorunLimitMinutes, options: [...form.autorunOptions] }
: null,
},
});
}, '오픈 요청을 등록했습니다.');
const actOnRequest = (id: string, action: 'ENDORSE' | 'WITHDRAW' | 'ACCEPT' | 'REJECT'): Promise<void> =>
performRequest(
() =>
action === 'ACCEPT' || action === 'REJECT'
? directTrpc.admin.openRequests.decide.mutate({ id, action })
: directTrpc.openRequests.act.mutate({ id, action }),
action === 'ACCEPT'
? '오픈 예약으로 전환했습니다.'
: action === 'ENDORSE'
? '동의했습니다. 동의 수가 충족되면 자동 예약됩니다.'
: '요청을 처리했습니다.'
);
const savePolicy = (): Promise<void> =>
performRequest(
() =>
directTrpc.admin.openRequests.updatePolicy.mutate({
profileName: form.profileName,
policy: { ...policyForm },
reason: policyReason.value,
}),
'서버 오픈 요청 정책을 저장했습니다.'
);
const outputElement = ref<HTMLTextAreaElement | null>(null);
let catalogRequestId = 0;
@@ -48,9 +155,7 @@ const form = reactive({
const selectedProfile = computed(() => profiles.value.find((profile) => profile.profileName === form.profileName));
const selectedScenario = computed(() => scenarios.value.find((scenario) => scenario.id === form.scenarioId));
const dateOrderValid = computed(
() => !form.preopenAt || !form.openAt || new Date(form.preopenAt).getTime() <= new Date(form.openAt).getTime()
);
const dateOrderValid = computed(() => !form.preopenAt || !form.openAt || form.preopenAt < form.openAt);
const canCopy = computed(
() =>
Boolean(selectedProfile.value && selectedScenario.value && form.preopenAt && form.openAt) &&
@@ -64,6 +169,14 @@ const formatProposalDate = (value: string): string => {
return normalized.length === 16 ? `${normalized}:00` : normalized;
};
const operationStatusText = (status: string): string =>
({
QUEUED: '준비 예약',
RUNNING: '준비 중',
SUCCEEDED: '준비 완료',
FAILED: '준비 실패 · 오프너 확인 필요',
CANCELLED: '예약 취소',
})[status] ?? status;
const npcModeText = (value: number): string => ['불가', '가능', '선택 생성 가능'][value] ?? '불가';
const fictionText = (value: number): string => (value === 1 ? '가상' : '사실');
const autorunText = computed(() => {
@@ -138,7 +251,12 @@ const loadScenarios = async (): Promise<void> => {
watch(
() => form.profileName,
() => void loadScenarios()
() => {
requestError.value = '';
requestMessage.value = '';
void loadScenarios();
void loadRequests();
}
);
watch(selectedScenario, (scenario) => {
if (scenario?.fiction === 0 || scenario?.fiction === 1) form.fiction = scenario.fiction;
@@ -174,16 +292,185 @@ onMounted(async () => {
<header class="page-header">
<div>
<p class="eyebrow">GATEWAY COMMUNITY TOOL</p>
<h1>오픈 건의 양식</h1>
<p>현재 서버 빌드의 시나리오와 빌드 옵션을 살펴보고, 운영자에게 전달할 문구를 만듭니다.</p>
<h1>오픈 요청</h1>
<p>통일되었거나 닫힌 서버의 다음 오픈을 요청하고, 다른 사용자의 요청에 동의할 수 있습니다.</p>
</div>
<RouterLink class="back-link" to="/lobby">서버 목록으로</RouterLink>
</header>
<p class="read-only-notice" role="note">
이 화면은 조회와 문구 작성만 합니다. 서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.
이 화면은 조회와 문구 작성만 합니다. 복사는 문구만 복사하며, 오픈 요청 등록 버튼을 눌러야 요청이
저장됩니다.
</p>
<section class="panel request-list" aria-labelledby="requests-heading">
<div class="output-heading">
<h2 id="requests-heading">오픈 요청 목록</h2>
<button type="button" :disabled="requestLoading || requestBusy" @click="loadRequests">
새로고침
</button>
</div>
<p v-if="requestMessage" role="status">{{ requestMessage }}</p>
<p v-if="requestError" class="field-error" role="alert">{{ requestError }}</p>
<template v-if="requestState">
<p class="section-help">
요청은 {{ requestState.policy.lifetimeDays }}일 동안, 늦어도 빌드 시작 전까지 모집합니다. 가오픈
{{ requestState.policy.buildLeadMinutes }}분 전 서버 패치 정책에 따라 준비합니다.
<span v-if="requestState.policy.endorsementThreshold !== null"
>요청자 외 정식 사용자 {{ requestState.policy.endorsementThreshold }}명의 동의로 자동
예약됩니다.</span
>
<span v-else>이 서버는 오프너가 수락하면 예약됩니다.</span>
</p>
<p v-if="!requestState.canRequest" class="section-help">
현재 오픈 요청을 등록할 수 없습니다. 서버 상태와 정식 참여 자격을 확인해 주세요.
</p>
<p v-if="requestState.requests.length === 0">대기 중인 오픈 요청이 없습니다.</p>
<article
v-for="request in requestState.requests"
:key="request.id"
class="request-card"
:data-request-id="request.id"
>
<h3>{{ request.requesterName }} · {{ request.announcement.scenarioTitle }}</h3>
<p>
{{ request.status === 'RESERVED' ? '오픈 예약' : '수락 대기'
}}<span v-if="request.operationStatus">
· {{ operationStatusText(request.operationStatus) }}</span
>
</p>
<p>
가오픈 {{ formatServerDateTime(request.install.preopenAt) }} / 오픈
{{ formatServerDateTime(request.install.openAt) }} (서버 시간 UTC+9)
</p>
<p>
{{ request.install.turnTermMinutes }}분 턴 · 상성
{{ request.install.fiction === 1 ? '가상' : '사실' }} · 빙의
{{ npcModeText(request.install.npcMode) }} · 최대 스탯
{{ request.announcement.defaultStatTotal }}
</p>
<details>
<summary>요청 설정 전체</summary>
<dl class="request-settings">
<dt>시간 동기화</dt>
<dd>{{ request.install.sync ? '사용' : '사용 안 함' }}</dd>
<dt>확장 NPC</dt>
<dd>{{ request.install.extend ? '포함' : '미포함' }}</dd>
<dt>장수 생성</dt>
<dd>{{ ['가능', '불가', '장수명 무작위'][request.install.blockGeneralCreate] }}</dd>
<dt>임관</dt>
<dd>{{ request.install.joinMode === 'full' ? '자유 임관' : '랜덤 임관' }}</dd>
<dt>이미지 표시</dt>
<dd>{{ ['표시 안 함', '전콘', '전콘·병종', '전체'][request.install.showImgLevel] }}</dd>
<dt>토너먼트</dt>
<dd>{{ request.install.tournamentTrig ? '자동 시작' : '수동 시작' }}</dd>
<dt>자율행동</dt>
<dd>
{{
request.install.autorunUser
? `${request.install.autorunUser.limitMinutes}분`
: '사용 안 함'
}}
<template v-if="request.install.autorunUser">
·
{{
RESET_AUTORUN_LABELS.filter((entry) =>
request.install.autorunUser?.options.includes(entry.value)
)
.map((entry) => entry.label)
.join(', ')
}}</template
>
</dd>
</dl>
</details>
<p v-if="request.status === 'PENDING'">
모집 종료 {{ formatServerDateTime(request.expiresAt) }}
</p>
<p v-if="requestState.policy.endorsementThreshold !== null">
동의 {{ request.endorsementCount }}명 /
{{ requestState.policy.endorsementThreshold }}명<span v-if="request.endorsed">
· 동의 완료</span
>
</p>
<div class="request-actions" v-if="request.status === 'PENDING'">
<button
v-if="request.canEndorse"
type="button"
:disabled="requestBusy || request.endorsed"
@click="actOnRequest(request.id, 'ENDORSE')"
>
{{ request.endorsed ? '동의 완료' : '동의' }}
</button>
<button
v-if="request.isMine"
type="button"
:disabled="requestBusy"
@click="actOnRequest(request.id, 'WITHDRAW')"
>
요청 철회
</button>
<button
v-if="canAccept"
type="button"
:disabled="requestBusy"
@click="actOnRequest(request.id, 'ACCEPT')"
>
수락하여 오픈 예약
</button>
<button
v-if="canAccept"
type="button"
:disabled="requestBusy"
@click="actOnRequest(request.id, 'REJECT')"
>
요청 거절
</button>
</div>
</article>
<details v-if="canManagePolicy" class="request-policy">
<summary>서버 오픈 요청 정책</summary>
<div class="field-grid">
<label><span>요청 접수</span><input v-model="policyForm.enabled" type="checkbox" /></label>
<label
><span>모집 기간(일)</span
><input v-model.number="policyForm.lifetimeDays" type="number" min="1" max="365"
/></label>
<label
><span>가오픈 전 준비 시간(분)</span
><input v-model.number="policyForm.buildLeadMinutes" type="number" min="1" max="10080"
/></label>
<label
><span>동의로 자동 예약</span
><input
:checked="policyForm.endorsementThreshold !== null"
type="checkbox"
@change="
policyForm.endorsementThreshold =
policyForm.endorsementThreshold === null ? 5 : null
"
/></label>
<label v-if="policyForm.endorsementThreshold !== null"
><span>필요 동의 수(요청자 제외)</span
><input
v-model.number="policyForm.endorsementThreshold"
type="number"
min="1"
max="10000"
/></label>
<label><span>변경 사유</span><input v-model="policyReason" maxlength="200" /></label>
</div>
<button
type="button"
:disabled="requestBusy || policyReason.trim().length < 3"
@click="savePolicy"
>
정책 저장
</button>
</details>
</template>
</section>
<section class="panel" aria-labelledby="proposal-basic-heading">
<h2 id="proposal-basic-heading">기본 정보</h2>
<div class="field-grid">
@@ -208,34 +495,58 @@ onMounted(async () => {
</select>
</label>
<label>
<span>가오픈 일시</span>
<span>가오픈 일시 (서버 시간 UTC+9)</span>
<input v-model="form.preopenAt" type="datetime-local" step="1" data-testid="proposal-preopen" />
</label>
<label>
<span>오픈 일시</span>
<span>오픈 일시 (서버 시간 UTC+9)</span>
<input v-model="form.openAt" type="datetime-local" step="1" data-testid="proposal-open" />
</label>
</div>
<p v-if="catalogLoading" class="field-status" role="status">활성 빌드의 시나리오를 확인하고 있습니다.</p>
<p v-if="catalogLoading" class="field-status" role="status">
활성 빌드의 시나리오를 확인하고 있습니다.
</p>
<p v-else-if="catalogError" class="field-error" role="alert">
{{ catalogError }}
<button type="button" @click="loadScenarios">다시 확인</button>
</p>
<p v-if="!dateOrderValid" class="field-error" role="alert">가오픈 일시는 오픈 일시보다 늦을 수 없습니다.</p>
<p v-if="!dateOrderValid" class="field-error" role="alert">
가오픈 일시는 오픈 일시보다 늦을 수 없습니다.
</p>
<dl v-if="selectedScenario" class="scenario-summary" data-testid="scenario-summary">
<div><dt>시작 연도</dt><dd>{{ selectedScenario.year ?? '-' }}년</dd></div>
<div><dt>최대 스탯</dt><dd>{{ selectedScenario.defaultStatTotal }}</dd></div>
<div><dt>기본 NPC</dt><dd>{{ selectedScenario.npcCount }}명</dd></div>
<div><dt>확장 NPC</dt><dd>{{ selectedScenario.npcExCount }}명</dd></div>
<div><dt>중립 NPC</dt><dd>{{ selectedScenario.npcNeutralCount }}명</dd></div>
<div><dt>국가</dt><dd>{{ selectedScenario.nations.length }}개</dd></div>
<div>
<dt>시작 연도</dt>
<dd>{{ selectedScenario.year ?? '-' }}년</dd>
</div>
<div>
<dt>최대 스탯</dt>
<dd>{{ selectedScenario.defaultStatTotal }}</dd>
</div>
<div>
<dt>기본 NPC</dt>
<dd>{{ selectedScenario.npcCount }}명</dd>
</div>
<div>
<dt>확장 NPC</dt>
<dd>{{ selectedScenario.npcExCount }}명</dd>
</div>
<div>
<dt>중립 NPC</dt>
<dd>{{ selectedScenario.npcNeutralCount }}명</dd>
</div>
<div>
<dt>국가</dt>
<dd>{{ selectedScenario.nations.length }}개</dd>
</div>
</dl>
</section>
<section class="panel" aria-labelledby="proposal-options-heading">
<h2 id="proposal-options-heading">빌드 옵션</h2>
<p class="section-help">선택은 아래 미리보기에만 반영됩니다. 각 설명은 실제 초기화 옵션의 의미입니다.</p>
<p class="section-help">
선택한 설정은 오픈 요청에 저장됩니다. 각 설명은 실제 초기화 옵션의 의미입니다.
</p>
<div class="field-grid option-grid">
<label>
<span>{{ RESET_OPTION_COPY.turnTerm.label }}</span>
@@ -345,11 +656,35 @@ onMounted(async () => {
<p class="copy-status" role="status">{{ copiedMessage }}</p>
</section>
<section class="panel">
<p>
요청자는 빌드 시각이나 배포 버전을 지정하지 않습니다. 서버 정책에 따라 가오픈 전에 준비하며, 수락된
뒤에는 요청을 철회할 수 없습니다.
</p>
<button
type="button"
data-testid="submit-open-request"
:disabled="!canCopy || !requestState?.canRequest || requestBusy || requestLoading"
@click="submitRequest"
>
오픈 요청 등록
</button>
</section>
<details class="panel catalog-panel">
<summary>시나리오 목록 {{ scenarios.length }}개 보기</summary>
<div class="catalog-table-frame">
<table>
<thead><tr><th>ID</th><th>시나리오</th><th>시작</th><th>최대 스탯</th><th>NPC</th><th>국가</th></tr></thead>
<thead>
<tr>
<th>ID</th>
<th>시나리오</th>
<th>시작</th>
<th>최대 스탯</th>
<th>NPC</th>
<th>국가</th>
</tr>
</thead>
<tbody>
<tr v-for="scenario in scenarios" :key="scenario.id">
<td>{{ scenario.id }}</td>
@@ -368,6 +703,50 @@ onMounted(async () => {
</template>
<style scoped>
.request-card {
margin: 16px 0;
padding: 16px;
border: 1px solid #52525b;
border-radius: 6px;
overflow-wrap: anywhere;
}
.request-card h3 {
margin: 0 0 8px;
}
.request-settings {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
}
.request-card pre {
overflow: auto;
max-width: 100%;
white-space: pre-wrap;
}
.request-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.request-policy {
margin-top: 16px;
}
button {
min-height: 36px;
padding: 6px 12px;
border: 1px solid #71717a;
border-radius: 4px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:focus-visible {
outline: 2px solid #a5b4fc;
outline-offset: 2px;
}
.suggestion-page {
box-sizing: border-box;
width: min(100% - 32px, 960px);
@@ -390,10 +769,28 @@ onMounted(async () => {
color: #fff;
}
.page-header h1 { font-size: 28px; line-height: 1.25; }
.page-header p:not(.eyebrow), .section-help, .output-heading p { margin: 8px 0 0; color: #a1a1aa; }
.eyebrow { margin: 0 0 6px; color: #fb923c; font-size: 12px; font-weight: 700; letter-spacing: .12em; }
.back-link { flex: 0 0 auto; color: #fdba74; text-underline-offset: 3px; }
.page-header h1 {
font-size: 28px;
line-height: 1.25;
}
.page-header p:not(.eyebrow),
.section-help,
.output-heading p {
margin: 8px 0 0;
color: #a1a1aa;
}
.eyebrow {
margin: 0 0 6px;
color: #fb923c;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
}
.back-link {
flex: 0 0 auto;
color: #fdba74;
text-underline-offset: 3px;
}
.read-only-notice {
margin: 22px 0;
@@ -413,13 +810,37 @@ onMounted(async () => {
padding: 20px;
}
.panel h2 { font-size: 20px; }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 18px; }
.field-grid label, .autorun-limit { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
.field-grid label > span, .autorun-limit > span { color: #fafafa; font-size: 14px; font-weight: 700; }
.field-grid small { color: #a1a1aa; font-size: 12px; line-height: 1.45; }
.panel h2 {
font-size: 20px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 18px;
}
.field-grid label,
.autorun-limit {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.field-grid label > span,
.autorun-limit > span {
color: #fafafa;
font-size: 14px;
font-weight: 700;
}
.field-grid small {
color: #a1a1aa;
font-size: 12px;
line-height: 1.45;
}
select, input[type='datetime-local'], textarea {
select,
input[type='datetime-local'],
textarea {
box-sizing: border-box;
width: 100%;
border: 1px solid #52525b;
@@ -430,30 +851,94 @@ select, input[type='datetime-local'], textarea {
font: inherit;
}
select:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-visible, summary:focus-visible, .back-link:focus-visible {
select:focus-visible,
input:focus-visible,
textarea:focus-visible,
button:focus-visible,
summary:focus-visible,
.back-link:focus-visible {
outline: 2px solid #fb923c;
outline-offset: 2px;
}
.field-status { color: #a1a1aa; }
.field-error { color: #fca5a5; }
.field-error button { border: 0; background: transparent; color: #fdba74; text-decoration: underline; cursor: pointer; }
.field-status {
color: #a1a1aa;
}
.field-error {
color: #fca5a5;
}
.field-error button {
border: 0;
background: transparent;
color: #fdba74;
text-decoration: underline;
cursor: pointer;
}
.scenario-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 18px 0 0; border: 1px solid #3f3f46; }
.scenario-summary div { min-width: 0; border-right: 1px solid #3f3f46; padding: 10px; text-align: center; }
.scenario-summary div:last-child { border-right: 0; }
.scenario-summary dt { color: #a1a1aa; font-size: 12px; }
.scenario-summary dd { margin: 4px 0 0; color: #fff; font-weight: 700; }
.scenario-summary {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
margin: 18px 0 0;
border: 1px solid #3f3f46;
}
.scenario-summary div {
min-width: 0;
border-right: 1px solid #3f3f46;
padding: 10px;
text-align: center;
}
.scenario-summary div:last-child {
border-right: 0;
}
.scenario-summary dt {
color: #a1a1aa;
font-size: 12px;
}
.scenario-summary dd {
margin: 4px 0 0;
color: #fff;
font-weight: 700;
}
.toggle-grid, .checkbox-list { display: flex; flex-wrap: wrap; gap: 10px 18px; }
.toggle-grid { margin-top: 20px; border-top: 1px solid #3f3f46; padding-top: 16px; }
.toggle-grid label, .checkbox-list label { display: inline-flex; align-items: center; gap: 7px; }
.autorun-options { margin-top: 18px; border: 1px solid #3f3f46; border-radius: 4px; padding: 16px; }
.autorun-options legend { padding: 0 8px; color: #fff; font-weight: 700; }
.autorun-limit { width: min(100%, 260px); margin-top: 16px; }
.toggle-grid,
.checkbox-list {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
}
.toggle-grid {
margin-top: 20px;
border-top: 1px solid #3f3f46;
padding-top: 16px;
}
.toggle-grid label,
.checkbox-list label {
display: inline-flex;
align-items: center;
gap: 7px;
}
.autorun-options {
margin-top: 18px;
border: 1px solid #3f3f46;
border-radius: 4px;
padding: 16px;
}
.autorun-options legend {
padding: 0 8px;
color: #fff;
font-weight: 700;
}
.autorun-limit {
width: min(100%, 260px);
margin-top: 16px;
}
.output-heading { align-items: center; }
.output-heading h2 { margin: 0; }
.output-heading {
align-items: center;
}
.output-heading h2 {
margin: 0;
}
.output-heading button {
flex: 0 0 auto;
border: 1px solid #f97316;
@@ -464,25 +949,84 @@ select:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-
cursor: pointer;
font-weight: 700;
}
.output-heading button:disabled { border-color: #3f3f46; background: #27272a; color: #71717a; cursor: default; }
.suggestion-output { margin-top: 16px; resize: vertical; line-height: 1.6; white-space: pre-wrap; }
.copy-status { min-height: 20px; margin: 8px 0 0; color: #86efac; }
.output-heading button:disabled {
border-color: #3f3f46;
background: #27272a;
color: #71717a;
cursor: default;
}
.suggestion-output {
margin-top: 16px;
resize: vertical;
line-height: 1.6;
white-space: pre-wrap;
}
.copy-status {
min-height: 20px;
margin: 8px 0 0;
color: #86efac;
}
.catalog-panel summary { cursor: pointer; color: #fdba74; font-weight: 700; }
.catalog-table-frame { margin-top: 14px; overflow-x: auto; }
.catalog-table-frame table { width: 100%; min-width: 680px; border-collapse: collapse; }
.catalog-table-frame th, .catalog-table-frame td { border-bottom: 1px solid #3f3f46; padding: 9px; text-align: left; }
.catalog-table-frame th { color: #a1a1aa; font-size: 12px; }
.catalog-panel summary {
cursor: pointer;
color: #fdba74;
font-weight: 700;
}
.catalog-table-frame {
margin-top: 14px;
overflow-x: auto;
}
.catalog-table-frame table {
width: 100%;
min-width: 680px;
border-collapse: collapse;
}
.catalog-table-frame th,
.catalog-table-frame td {
border-bottom: 1px solid #3f3f46;
padding: 9px;
text-align: left;
}
.catalog-table-frame th {
color: #a1a1aa;
font-size: 12px;
}
@media (max-width: 700px) {
.suggestion-page { width: min(100% - 24px, 960px); padding-top: 96px; }
.page-header { flex-direction: column; gap: 10px; }
.field-grid, .scenario-summary { grid-template-columns: minmax(0, 1fr); }
.scenario-summary div { display: flex; justify-content: space-between; border-right: 0; border-bottom: 1px solid #3f3f46; text-align: left; }
.scenario-summary div:last-child { border-bottom: 0; }
.scenario-summary dd { margin: 0; }
.panel { padding: 16px; }
.output-heading { align-items: stretch; flex-direction: column; }
.output-heading button { width: 100%; }
.suggestion-page {
width: min(100% - 24px, 960px);
padding-top: 96px;
}
.page-header {
flex-direction: column;
gap: 10px;
}
.field-grid,
.scenario-summary {
grid-template-columns: minmax(0, 1fr);
}
.scenario-summary div {
display: flex;
justify-content: space-between;
border-right: 0;
border-bottom: 1px solid #3f3f46;
text-align: left;
}
.scenario-summary div:last-child {
border-bottom: 0;
}
.scenario-summary dd {
margin: 0;
}
.panel {
padding: 16px;
}
.output-heading {
align-items: stretch;
flex-direction: column;
}
.output-heading button {
width: 100%;
}
}
</style>
+131
View File
@@ -0,0 +1,131 @@
# Gateway 오픈 요청
`/gateway/open-suggestion`에서 정식 사용자가 다음 기수의 설정과 가오픈·정식 오픈
시각을 제출한다. 이전 문구 복사 도구를 확장한 Core 전용 기능이다. 게임 초기화,
서버 패치와 공개 예약 공지는 기존 RESET 경로를 사용한다.
## 참여와 운영 권한
- 요청·동의는 유효한 Gateway session의 actor로 처리한다. 카카오 연결 ID와 인증
완료 시각이 모두 있거나, 기존 계정 정책의 해당 profile에 유효한 특수 접근
허용이 있어야 한다. 단순 인증 유예만 있는 계정은 참여하지 못한다.
- 로그인·게임 제재, 해당 서버의 접근 제한, 예약 탈퇴 계정은 참여하지 못한다.
예약 전환 때 요청자와 동의자의 현재 자격을 다시 확인한다.
- 공개 목록은 로그인 사용자에게 제공한다. 요청자의 표시명, 요청 설정과 유효
동의 수를 표시하고, 계정 ID·배포 source·내부 오류·동의자 목록은 공개하지 않는다.
- 해당 profile의 `admin.scenarios.reset` 권한자가 오프너로서 수락·거절한다.
요청에서 파생한 예약에는 별도 deploy 권한이나 임의 시각 지정 권한을 부여하지
않는다. 정책 저장은 `admin.profiles.settings` 권한이 필요하다. 기존 superuser
판정과 profile scope 검사를 그대로 사용하며 운영 mutation은 감사 원장에 남긴다.
## 서버 정책
정책은 `GatewayProfile.meta.openRequestPolicy`에 저장한다. 요청 화면의 권한자용
`서버 오픈 요청 정책`에서 변경하며 변경 사유를 요구한다.
| key | 기본값 | 허용 범위 / 의미 |
| ---------------------- | ------ | ------------------------------------------------------------------- |
| `enabled` | `true` | 요청과 동의·수락 접수 여부 |
| `lifetimeDays` | `7` | 1~365일, 새 요청의 최대 모집 기간 |
| `buildLeadMinutes` | `10` | 1~10080분, 가오픈 이전 준비 시작 간격 |
| `endorsementThreshold` | `null` | `null`이면 오프너 수락만, 1~10000이면 요청자 외 필요한 정식 동의 수 |
CHE를 포함해 별도 설정이 없는 모든 서버는 자동 예약을 사용하지 않으며, 목록에
동의 목표 숫자와 동의 버튼도 표시하지 않는다. 필요한 서버에서만 숫자를 설정한다.
기준을 낮추거나 자동 예약을 켜면 이미 모인 유효 동의도 즉시 평가한다. 여러 요청이
기준을 충족하면 생성 시각·ID 순서상 가장 오래된 유효 요청 하나가 예약된다.
예약 전환에 실패하면 정책 변경 transaction도 취소되어 부분 적용을 숨기지 않는다.
요청 모집은 생성 시각 + 모집 기간과 가오픈 - 준비 간격 중 이른 시각에 끝난다.
미래 가오픈에는 현재 시점부터 준비 간격 이상의 여유가 있어야 하고 정식 오픈은
가오픈보다 뒤여야 한다. 준비 간격을 늘리면 기존 대기 요청도 새 준비 시각이 지난
경우 즉시 만료로 판정한다. 모집 기간 변경은 새 요청에 적용하고, 이미 수락된
예약의 실행 시각과 patch source는 정책 변경으로 소급 변경하지 않는다.
## 저장·예약·실행
`GatewayOpenRequest`는 원본 설치 설정, 시나리오 표시 snapshot, 요청자, 기수
식별자, 만료·가오픈·오픈 시각과 결정 정보를 저장한다. `GatewayOpenEndorsement`는
(request, user) PK로 한 사람의 중복 동의를 막는다. 요청자는 자기 요청에 동의할
수 없다. 한 사람은 한 서버에 유효 대기 요청 하나만 둘 수 있으며, 수정하려면
철회 후 다시 제출한다. 같은 설정의 제출 재시도는 기존 요청 ID를 반환한다.
```text
PENDING -> RESERVED 오프너 수락 또는 유효 동의 기준 충족
-> WITHDRAWN 요청자 철회
-> REJECTED 오프너 거절
-> SUPERSEDED 다른 요청 예약 / 서버 재오픈 / 기수 변경
-> EXPIRED 모집 또는 준비 시작 시각 도달
```
목록과 mutation은 현재 시각·서버·기수를 기준으로 실효 상태를 판정한다. 만료된
행은 목록에서 즉시 빠지며 DB에서는 삭제하지 않는다. 다음 요청 접수 때 오래된
PENDING 행의 저장 상태도 정리한다. 서버별 유효 대기 요청은 최대 100개다.
수락·동의, 요청 상태와 연결 RESET 생성, 등록 로그와 나머지 대기 요청의 종료는
하나의 PostgreSQL transaction이다. 기존 operation admission/claim과 같은 advisory
lock을 먼저 잡고 profile row를 잠근다. 다른 활성 서버 작업이나 예약이 있으면
새 예약을 만들지 않는다. 수락 재시도와 수락·동의 경합도 RESET 하나만 만든다.
예약의 `scheduledAt`은 서버가 **가오픈 - buildLeadMinutes**로 계산한다. 요청자는
build 시각, branch, commit, actor ID를 지정할 수 없다. 수락 시 현재 서버의
`releaseSource`를 읽고 시나리오와 release build 호환성을 검증한다. BRANCH는 기존
RESET과 같이 실행 시점 branch head를 해석하여 고정하고 COMMIT은 고정 SHA를
사용한다. 기존 active workspace를 재사용할 수 있으면 build를 생략하며 migration과
seed는 예정된 준비 시각부터 진행한다. 예약 공지는 기존 `publicAnnouncement`
projection으로 즉시 공개된다. 대기 중 profile을 `RESERVED`로 바꾸지 않으며 기존
RESERVED의 build·migration·seed 완료 후 가오픈 대기 의미를 보존한다.
worker는 실행 진입, build 이후와 초기화 전 서버 상태·기수·정식 오픈 deadline을
다시 확인한다. 서버가 다시 열렸거나 기수가 바뀌었거나 정식 오픈까지 지나면 오래된
요청으로 게임을 초기화하지 않고 실패로 남긴다. build가 가오픈을 넘기면 기존 예약
공지의 지연 판정을 사용한다. 준비 실패·취소 상태는 요청 목록과 관리자 operation
이력에서 확인하며 기존 취소·lease·재시도 경로로 처리한다. 새 기수 게시 이후 실패는
오래된 예약의 재실행을 차단하므로 현 상태를 확인한 후 기존 운영 복구 경로를 쓴다.
## 주요 경계 사례
| 경우 | 결과 |
| ------------------------------------------------ | ------------------------------------------------------ |
| COMPLETED / STOPPED / CANCELLED | 정식 사용자의 요청 허용 |
| RUNNING / PREOPEN / PAUSED / RESERVED / DISABLED | 새 요청과 예약 전환 거부 |
| 무인증·오래된 session·타 서버 오프너 | 거부, 요청·동의·operation mutation 없음 |
| 유예 계정·만료/타 서버 특수 허용·제재·탈퇴 예약 | 참여 거부 |
| 한 사람이 여러 session에서 동의 | 같은 동의 한 개, 추가 가산 없음 |
| 동의자 자격 회수 | 유효 숫자·자동 예약 집계에서 제외 |
| 요청자 자격 회수 | 수락·자동 예약 전환 거부 |
| 동시 임계 동의·동시 수락·서로 다른 요청 수락 | RESET 하나만 생성 |
| build source/시나리오 불일치·호환성 실패 | 예약 없음, 마지막 동의도 rollback; 수정 후 재시도 가능 |
| 이미 예약된 요청 철회 | 거부; 오프너가 기존 서버 작업 취소 경로 사용 |
| DB/API 재시작·worker 재시작 | DB 요청·고유 동의·기존 durable RESET/lease로 복구 |
| 오래된 예약과 새 게임 / 정식 오픈 deadline 경과 | worker가 초기화 전 거부 |
## Migration과 검증
추가 migration은 `20261022000000_add_open_requests`이다. Gateway migration을 먼저
적용하고 API·orchestrator·frontend를 함께 갱신한다. additive 변경이므로 기존 계정과
예약을 변환하거나 삭제하지 않는다. downgrade할 때는 기존 community 예약을 먼저
확인하고 취소·처리한다. 구형 worker에는 새 기수/deadline 보호가 없기 때문이다.
DB rollback은 신규 테이블 삭제가 아니라 사전 backup/restore 절차를 따른다.
격리된 개발 DB/Redis instance에서 다음을 실행한다. URL은 환경으로 전달하고 실제
credential을 CLI argument나 log로 출력하지 않는다.
```sh
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway
# OPEN_REQUEST_DATABASE_URL: 이 검증 전용 DB/schema
pnpm --filter @sammo-ts/infra verify:migration:open-requests
pnpm --filter @sammo-ts/gateway-api test openRequests.integration.test.ts
# 먼저 /gateway prefix의 production frontend를 build한 뒤 실제 HTTP+DB Chromium 검증
OPEN_REQUEST_BROWSER=1 pnpm --filter @sammo-ts/gateway-api test openRequests.integration.test.ts
PLAYWRIGHT_GATEWAY_FRONTEND_PORT=15272 pnpm --filter @sammo-ts/gateway-frontend exec playwright test open-suggestion.spec.ts --config e2e/playwright.config.mjs
```
Integration의 Git catalog/호환성 adapter는 fixture다. 실제 PostgreSQL, Fastify/tRPC,
session 검증, 요청·동의·수락과 operation persistence를 실행하고, 선택적으로 같은
서버에 production frontend와 실제 Chromium을 연결한다. screenshot·DOM·geometry와
computed style은 `test-results/open-request-live/`, UI fixture 결과는
`test-results/server-operations/`에 보존한다. fixture 성공은 운영 배포나 실제 외부
builder 실행의 증거가 아니다. 조건부 CI runner는 gateway_runtime 전용 schema에서
이 integration을 실행하며 browser는 별도 opt-in이다.
+3
View File
@@ -4,6 +4,9 @@
되돌리는 현재 운영 경계를 설명합니다. Profile은 Gateway orchestrator가,
Gateway 전체는 별도 release-controller가 처리합니다.
일반 사용자의 다음 기수 요청·동의·오프너 수락과 서버별 준비 정책은
[Gateway 오픈 요청](gateway-open-requests.md)을 참조합니다.
## 구성과 권한
| 대상 | 요청 경로 | 실행자 | 영속 상태 |
+1
View File
@@ -18,6 +18,7 @@
"prisma:migrate:deploy:game": "prisma migrate deploy --schema prisma/game.prisma",
"prisma:migrate:deploy:gateway": "PRISMA_SCHEMA=prisma/gateway.prisma prisma migrate deploy --schema prisma/gateway.prisma --config prisma.gateway.config.ts",
"prisma:migrate:status:game": "prisma migrate status --schema prisma/game.prisma",
"verify:migration:open-requests": "node scripts/verify-open-request-migration.mjs",
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh",
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
@@ -0,0 +1,28 @@
CREATE TABLE "gateway_open_request" (
"id" TEXT PRIMARY KEY,
"profile_name" TEXT NOT NULL REFERENCES "gateway_profile"("profile_name") ON DELETE CASCADE,
"requested_by" TEXT NOT NULL REFERENCES "app_user"("id") ON DELETE CASCADE,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"install" JSONB NOT NULL,
"announcement" JSONB NOT NULL,
"season_key" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"preopen_at" TIMESTAMP(3) NOT NULL,
"open_at" TIMESTAMP(3) NOT NULL,
"operation_id" TEXT UNIQUE REFERENCES "gateway_operation"("id") ON DELETE RESTRICT,
"decided_by" TEXT,
"decided_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "gateway_open_request_status_check" CHECK ("status" IN ('PENDING','RESERVED','EXPIRED','WITHDRAWN','REJECTED','SUPERSEDED')),
CONSTRAINT "gateway_open_request_dates_check" CHECK ("created_at" < "expires_at" AND "expires_at" <= "preopen_at" AND "preopen_at" < "open_at"),
CONSTRAINT "gateway_open_request_decision_check" CHECK (("status" = 'RESERVED') = ("operation_id" IS NOT NULL))
);
CREATE INDEX "gateway_open_request_profile_name_status_expires_at_idx" ON "gateway_open_request"("profile_name", "status", "expires_at");
CREATE UNIQUE INDEX "gateway_open_request_one_pending_per_user" ON "gateway_open_request"("profile_name", "requested_by") WHERE "status" = 'PENDING';
CREATE TABLE "gateway_open_endorsement" (
"request_id" TEXT NOT NULL REFERENCES "gateway_open_request"("id") ON DELETE CASCADE,
"user_id" TEXT NOT NULL REFERENCES "app_user"("id") ON DELETE CASCADE,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("request_id", "user_id")
);
+41
View File
@@ -115,6 +115,8 @@ model AppUser {
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
openRequests GatewayOpenRequest[]
openEndorsements GatewayOpenEndorsement[]
icons UserIcon[]
specialAccessGrants SpecialAccountAccessGrant[]
webPushSubscriptions WebPushSubscription[]
@@ -254,6 +256,7 @@ model GatewayProfile {
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
openRequests GatewayOpenRequest[]
operations GatewayOperation[]
runtimeActions GatewayRuntimeAction[]
@@ -407,6 +410,7 @@ model GatewayOperation {
bulkRelease GatewayBulkRelease? @relation(fields: [bulkReleaseId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
openRequest GatewayOpenRequest?
logs GatewayOperationLog[]
@@index([status, scheduledAt, createdAt])
@@ -513,3 +517,40 @@ model SystemSetting {
@@map("system")
}
// Immutable proposal options; approval and operation creation commit atomically.
model GatewayOpenRequest {
id String @id @default(uuid())
profileName String @map("profile_name")
profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade)
requestedBy String @map("requested_by")
requester AppUser @relation(fields: [requestedBy], references: [id], onDelete: Cascade)
status String @default("PENDING")
install Json
announcement Json
seasonKey String @map("season_key")
expiresAt DateTime @map("expires_at")
preopenAt DateTime @map("preopen_at")
openAt DateTime @map("open_at")
operationId String? @unique @map("operation_id")
operation GatewayOperation? @relation(fields: [operationId], references: [id], onDelete: Restrict)
decidedBy String? @map("decided_by")
decidedAt DateTime? @map("decided_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
endorsements GatewayOpenEndorsement[]
@@index([profileName, status, expiresAt])
@@map("gateway_open_request")
}
model GatewayOpenEndorsement {
requestId String @map("request_id")
request GatewayOpenRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
userId String @map("user_id")
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
@@id([requestId, userId])
@@map("gateway_open_endorsement")
}
@@ -0,0 +1,104 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';
import pg from 'pg';
const sourceUrl = process.env.OPEN_REQUEST_DATABASE_URL;
if (!sourceUrl) throw new Error('OPEN_REQUEST_DATABASE_URL must point to a disposable fixture instance.');
const target = '20261022000000_add_open_requests';
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const prismaRoot = path.join(packageRoot, 'prisma');
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'open-request-migration-'));
const schema = `open_request_upgrade_${Date.now()}`;
const url = new URL(sourceUrl);
url.searchParams.set('schema', schema);
const client = new pg.Client({ connectionString: sourceUrl });
const deploy = (config) => {
const result = spawnSync('pnpm', ['exec', 'prisma', 'migrate', 'deploy', '--config', config], {
cwd: packageRoot,
env: { ...process.env, GATEWAY_DATABASE_URL: url.href },
encoding: 'utf8',
});
if (result.status !== 0)
throw new Error(`Migration deploy failed (exit ${result.status}); datasource output suppressed.`);
};
try {
await fs.copyFile(path.join(prismaRoot, 'gateway.prisma'), path.join(temp, 'gateway.prisma'));
await fs.cp(path.join(prismaRoot, 'gateway-migrations'), path.join(temp, 'gateway-migrations'), {
recursive: true,
});
await fs.rm(path.join(temp, 'gateway-migrations', target), { recursive: true });
// Keep config inside this package so Prisma's config import resolves normally.
const config = path.join(packageRoot, `.open-request-migration-${process.pid}.config.mjs`);
const configText = `export default { schema: ${JSON.stringify(path.join(temp, 'gateway.prisma'))}, migrations: { path: ${JSON.stringify(path.join(temp, 'gateway-migrations'))} }, datasource: { url: process.env.GATEWAY_DATABASE_URL } };\n`;
await fs.writeFile(config, configText);
try {
deploy(config);
} finally {
await fs.rm(config);
}
await client.connect();
await client.query(`SET search_path TO "${schema}", public`);
const userId = randomUUID(),
profileName = 'opening-upgrade:default';
await client.query(
'INSERT INTO app_user(id,login_id,display_name,password_hash,password_salt,updated_at) VALUES($1,$2,$2,$3,$3,NOW())',
[userId, 'fixture-user', 'fixture-only']
);
await client.query(
'INSERT INTO gateway_profile(profile_name,profile,instance_key,scenario,api_port,status,updated_at) VALUES($1,$2,$3,$3,15995,$4,NOW())',
[profileName, 'opening-upgrade', 'default', 'STOPPED']
);
deploy(path.join(packageRoot, 'prisma.gateway.config.ts'));
const after = await client.query('SELECT COUNT(*)::int AS count FROM _prisma_migrations');
deploy(path.join(packageRoot, 'prisma.gateway.config.ts'));
assert.deepEqual((await client.query('SELECT COUNT(*)::int AS count FROM _prisma_migrations')).rows, after.rows);
assert.equal((await client.query('SELECT id FROM app_user WHERE id=$1', [userId])).rowCount, 1);
const requestId = randomUUID();
await client.query(
`INSERT INTO gateway_open_request(id,profile_name,requested_by,install,announcement,season_key,expires_at,preopen_at,open_at,updated_at)
VALUES($1,$2,$3,'{}','{}','fixture',NOW()+INTERVAL '1 day',NOW()+INTERVAL '2 days',NOW()+INTERVAL '3 days',NOW())`,
[requestId, profileName, userId]
);
await client.query('INSERT INTO gateway_open_endorsement(request_id,user_id) VALUES($1,$2)', [requestId, userId]);
await assert.rejects(
() =>
client.query('INSERT INTO gateway_open_endorsement(request_id,user_id) VALUES($1,$2)', [requestId, userId]),
{ code: '23505' }
);
await assert.rejects(
() =>
client.query(
`INSERT INTO gateway_open_request(id,profile_name,requested_by,install,announcement,season_key,expires_at,preopen_at,open_at,updated_at)
SELECT $1,profile_name,requested_by,install,announcement,season_key,expires_at,preopen_at,open_at,NOW() FROM gateway_open_request WHERE id=$2`,
[randomUUID(), requestId]
),
{ code: '23505' }
);
await assert.rejects(
() => client.query("UPDATE gateway_open_request SET status='RESERVED' WHERE id=$1", [requestId]),
{ code: '23514' }
);
await assert.rejects(
() => client.query('UPDATE gateway_open_request SET open_at=preopen_at WHERE id=$1', [requestId]),
{ code: '23514' }
);
await assert.rejects(
() =>
client.query('INSERT INTO gateway_open_endorsement(request_id,user_id) VALUES($1,$2)', [
randomUUID(),
userId,
]),
{ code: '23503' }
);
console.log(
`Opening migration: predecessor upgrade, preserved rows, repeated deploy no-op, unique/FK/CHECK constraints passed (${schema}).`
);
} finally {
await client.end();
await fs.rm(temp, { recursive: true, force: true });
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"formatVersion": 1,
"controllerProtocol": 2,
"gatewaySchemaHead": "20260825000000_add_bulk_release_batches",
"gatewaySchemaHead": "20261022000000_add_open_requests",
"gameSchemaHead": "20260916080000_index_play_audit_decision_month",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
}
@@ -5,6 +5,7 @@ CURRENT_SEASON_FIXTURE_DATABASE_URL external_fixture
GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime
GATEWAY_OPERATION_DATABASE_URL gateway_runtime
GATEWAY_RELEASE_DATABASE_URL gateway_runtime
OPEN_REQUEST_DATABASE_URL gateway_runtime
GENERAL_LIFECYCLE_DATABASE_URL core
IMMEDIATE_ACTION_DATABASE_URL immediate_action
INPUT_EVENT_DATABASE_URL core
1 # Environment variable Execution mode
5 GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime
6 GATEWAY_OPERATION_DATABASE_URL gateway_runtime
7 GATEWAY_RELEASE_DATABASE_URL gateway_runtime
8 OPEN_REQUEST_DATABASE_URL gateway_runtime
9 GENERAL_LIFECYCLE_DATABASE_URL core
10 IMMEDIATE_ACTION_DATABASE_URL immediate_action
11 INPUT_EVENT_DATABASE_URL core
+1
View File
@@ -712,6 +712,7 @@ gateway_runtime_database_url=$(build_database_url "$gateway_runtime_schema")
)
export GATEWAY_RUNTIME_ACTION_DATABASE_URL=$gateway_runtime_database_url
export GATEWAY_OPERATION_DATABASE_URL=$gateway_runtime_database_url
export OPEN_REQUEST_DATABASE_URL=$gateway_runtime_database_url
export GATEWAY_RELEASE_DATABASE_URL=$gateway_runtime_database_url
export GATEWAY_RUNTIME_INTEGRATION_SCHEMA=$gateway_runtime_schema
run_marked_tests app/gateway-api \