feat: 일반 사용자 오픈 건의 양식을 추가한다

활성 profile 빌드의 시나리오 catalog를 세션 기반 읽기 전용 API로 제공한다. Gateway에서 초기화 옵션을 살펴보고 Ref 예약 공지 형식의 제안 문구를 복사하되 서버 mutation은 호출하지 않도록 한다.
This commit is contained in:
2026-08-22 09:07:29 +00:00
parent d74a8e48bb
commit 1648b6aa84
10 changed files with 780 additions and 0 deletions
+37
View File
@@ -21,6 +21,7 @@ import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername }
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
import type { GatewayApiContext } from './context.js';
import { listScenarioPreviews } from './scenario/scenarioCatalog.js';
import {
KakaoVerificationError,
mergeRequiredKakaoScopes,
@@ -193,6 +194,42 @@ export const appRouter = router({
})
);
}),
scenarios: procedure
.input(z.object({ profileName: z.string().min(1).max(64) }))
.query(async ({ ctx, input }) => {
const provided = ctx.requestHeaders['x-session-token'];
const sessionToken = Array.isArray(provided) ? provided[0] : provided;
if (!sessionToken) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session token is required.' });
}
const session = await ctx.sessions.getSession(sessionToken);
const user = session ? await ctx.users.findById(session.userId) : null;
if (!session || !user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session is not valid.' });
}
const visibleProfiles = await ctx.profileStatus.listLobbyProfiles({ userId: user.id });
if (!visibleProfiles.some((profile) => profile.profileName === input.profileName)) {
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.',
});
}
try {
return await listScenarioPreviews({ gitRef: activeBuildCommit });
} catch {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'The active build scenario catalog could not be read.',
});
}
}),
}),
admin: adminRouter,
account: accountRouter,
@@ -22,6 +22,8 @@ export interface ScenarioPreview {
id: number;
title: string;
year: number | null;
defaultStatTotal: number;
fiction: number | null;
npcCount: number;
npcExCount: number;
npcNeutralCount: number;
@@ -227,6 +229,8 @@ const buildScenarioPreview = async (scenarioId: number): Promise<ScenarioPreview
id: scenarioId,
title: scenario.title,
year: scenario.startYear ?? null,
defaultStatTotal: scenario.config.stat.total,
fiction: scenario.fiction,
npcCount: scenario.generals.length,
npcExCount: scenario.generalsEx.length,
npcNeutralCount: scenario.generalsNeutral.length,
@@ -272,6 +276,8 @@ const buildScenarioPreviewFromGit = async (commitSha: string, scenarioId: number
id: scenarioId,
title: scenario.title,
year: scenario.startYear ?? null,
defaultStatTotal: scenario.config.stat.total,
fiction: scenario.fiction,
npcCount: scenario.generals.length,
npcExCount: scenario.generalsEx.length,
npcNeutralCount: scenario.generalsNeutral.length,
+31
View File
@@ -98,6 +98,7 @@ const buildCaller = (
apiPort: 15003,
status: 'RUNNING' as const,
buildStatus: 'SUCCEEDED' as const,
buildCommitSha: 'HEAD',
meta: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -111,6 +112,7 @@ const buildCaller = (
apiPort: 15015,
status: 'RUNNING' as const,
buildStatus: 'SUCCEEDED' as const,
buildCommitSha: 'HEAD',
meta: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -268,6 +270,35 @@ const buildCaller = (
};
describe('gateway auth flow', () => {
it('allows a signed-in regular user to read only the active profile scenario catalog', async () => {
const { caller, sealPassword, setSessionHeader } = buildCaller();
await expect(caller.lobby.scenarios({ profileName: 'che:default' })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
const register = await caller.auth.registerLocal({
username: 'scenario-reader',
credential: sealPassword('scenario-reader-password'),
displayName: '시나리오조회자',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
});
setSessionHeader(register.sessionToken);
const scenarios = await caller.lobby.scenarios({ profileName: 'che:default' });
expect(scenarios.length).toBeGreaterThan(0);
expect(scenarios[0]).toMatchObject({
id: expect.any(Number),
title: expect.any(String),
defaultStatTotal: expect.any(Number),
});
await expect(caller.lobby.scenarios({ profileName: 'hidden:default' })).rejects.toMatchObject({
code: 'NOT_FOUND',
});
});
it('registers a local account first and accepts an encrypted password login', async () => {
const { caller, users, sealPassword } = buildCaller();
const register = await caller.auth.registerLocal({
@@ -14,6 +14,10 @@ describe('scenarioCatalog git ref support', () => {
const ids = previews.map((scenario) => scenario.id);
const sorted = [...ids].sort((a, b) => a - b);
expect(ids).toEqual(sorted);
expect(previews.every((scenario) => scenario.defaultStatTotal > 0)).toBe(true);
expect(previews.every((scenario) => scenario.fiction === null || Number.isInteger(scenario.fiction))).toBe(
true
);
});
it('rejects without crashing when git cannot be spawned', async () => {