From b377250fb992ac693f648bda1420e77be9ab7d1a Mon Sep 17 00:00:00 2001 From: hide_d Date: Fri, 23 Oct 2026 00:42:11 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=B2=AB=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=84=20=EB=8B=AB=ED=9E=8C=20=EC=84=9C=EB=B2=84=EB=8F=84=20?= =?UTF-8?q?=ED=8C=A8=EC=B9=98=20=EC=A0=95=EC=B1=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=98=A4=ED=94=88=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EB=B0=9B?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/openRequests/catalogSource.ts | 26 +++++++++++++++++++ app/gateway-api/src/openRequests/service.ts | 8 ++++-- app/gateway-api/src/router.ts | 12 +++------ .../test/openRequests.integration.test.ts | 11 ++++++++ docs/gateway-open-requests.md | 4 +++ 5 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 app/gateway-api/src/openRequests/catalogSource.ts diff --git a/app/gateway-api/src/openRequests/catalogSource.ts b/app/gateway-api/src/openRequests/catalogSource.ts new file mode 100644 index 00000000..7cc865a8 --- /dev/null +++ b/app/gateway-api/src/openRequests/catalogSource.ts @@ -0,0 +1,26 @@ +import { TRPCError } from '@trpc/server'; +import type { GatewayProfileRecord } from '../orchestrator/profileRepository.js'; +import { readProfileReleaseSource } from '../orchestrator/profileReleaseSource.js'; +import { resolveGitBranchCommitSha, resolveGitCommitSha } from '../scenario/scenarioCatalog.js'; + +// 이미 배포된 서버는 그 catalog를 보존하고, 첫 오픈 전 서버만 운영자가 정한 source를 사용한다. +// 일반 사용자는 이 조회에도 branch/commit을 전달할 수 없다. +export const resolveOpeningCatalogCommit = async ( + profile: Pick +): Promise => { + if (profile.buildCommitSha?.trim()) return profile.buildCommitSha.trim(); + const source = readProfileReleaseSource(profile); + if (!source) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '서버의 배포 버전 또는 패치 정책이 필요합니다.' }); + } + try { + return source.mode === 'BRANCH' + ? await resolveGitBranchCommitSha(source.ref) + : await resolveGitCommitSha(source.ref); + } catch { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '서버 패치 정책의 시나리오 버전을 확인하지 못했습니다.', + }); + } +}; diff --git a/app/gateway-api/src/openRequests/service.ts b/app/gateway-api/src/openRequests/service.ts index 2ea4215a..7d5bc7ee 100644 --- a/app/gateway-api/src/openRequests/service.ts +++ b/app/gateway-api/src/openRequests/service.ts @@ -16,6 +16,7 @@ import { } from '../scenario/scenarioCatalog.js'; import { canRequestOpening, openingSeasonKey, readOpenRequestPolicy, type OpenRequestPolicy } from './policy.js'; import { z } from 'zod'; +import { resolveOpeningCatalogCommit } from './catalogSource.js'; export const zOpeningInstall = zOperationInstallOptions.required({ preopenAt: true, openAt: true }).strict(); export type OpeningInstall = z.infer; @@ -200,8 +201,11 @@ export const createOpenRequest = async ( } 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( + const catalogCommit = await resolveOpeningCatalogCommit({ + meta: profile.meta as GatewayPrisma.JsonObject, + buildCommitSha: profile.buildCommitSha ?? undefined, + }); + const scenario = (await listScenarioPreviews({ gitRef: catalogCommit })).find( (entry) => entry.id === install.scenarioId ); if (!scenario) return fail('현재 서버에 없는 시나리오입니다.'); diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 1525a194..6ec2f777 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -12,6 +12,7 @@ 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 { resolveOpeningCatalogCommit } from './openRequests/catalogSource.js'; import { accountRouter } from './account/router.js'; import { hasActiveSpecialAccountGrant, @@ -217,16 +218,11 @@ export const appRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); } const profile = await ctx.profiles.getProfile(input.profileName); - const activeBuildCommit = profile?.buildCommitSha?.trim(); - if (!profile || !activeBuildCommit) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'The profile has no active build commit.', - }); - } + if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); + const catalogCommit = await resolveOpeningCatalogCommit(profile); try { - return await listScenarioPreviews({ gitRef: activeBuildCommit }); + return await listScenarioPreviews({ gitRef: catalogCommit }); } catch { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', diff --git a/app/gateway-api/test/openRequests.integration.test.ts b/app/gateway-api/test/openRequests.integration.test.ts index ae6f974f..ae28a28f 100644 --- a/app/gateway-api/test/openRequests.integration.test.ts +++ b/app/gateway-api/test/openRequests.integration.test.ts @@ -209,6 +209,17 @@ suite('durable opening requests (isolated PostgreSQL and HTTP)', () => { 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('supports the first opening of a closed server using its configured source without an active build', async () => { + await prisma.gatewayProfile.update({ + where: { profileName }, + data: { buildCommitSha: null, meta: { releaseSource: { mode: 'BRANCH', ref: 'main' } } }, + }); + expect(await (await caller(1)).lobby.scenarios({ profileName })).toHaveLength(1); + expect(catalog.resolveGitBranchCommitSha).toHaveBeenCalledWith('main'); + const request = await create(); + await (await caller(3)).admin.openRequests.decide({ id: request.id, action: 'ACCEPT' }); + expect((await prisma.gatewayOperation.findFirstOrThrow({ where: { profileName } })).sourceMode).toBe('BRANCH'); + }); it('uses the configured lifetime for distant proposals', async () => { await policy(null, { lifetimeDays: 2 }); const input = install(); diff --git a/docs/gateway-open-requests.md b/docs/gateway-open-requests.md index 0ceef2f4..55e415fa 100644 --- a/docs/gateway-open-requests.md +++ b/docs/gateway-open-requests.md @@ -18,6 +18,10 @@ 않는다. 정책 저장은 `admin.profiles.settings` 권한이 필요하다. 기존 superuser 판정과 profile scope 검사를 그대로 사용하며 운영 mutation은 감사 원장에 남긴다. +배포 이력이 있는 서버의 시나리오는 active build에서 읽는다. 아직 첫 build가 없는 +닫힌 서버는 운영자가 설정한 releaseSource에서 시나리오를 읽어 첫 오픈도 요청할 수 +있다. active build와 patch 정책이 모두 없으면 운영 설정 준비를 요구한다. + ## 서버 정책 정책은 `GatewayProfile.meta.openRequestPolicy`에 저장한다. 요청 화면의 권한자용