merge: 최신 main을 0.1.0 사전점검에 통합
This commit is contained in:
@@ -34,6 +34,8 @@ export interface GatewayApiConfig {
|
||||
orchestratorAdminIntervalMs: number;
|
||||
workspaceRootHint: string;
|
||||
worktreeRoot: string;
|
||||
navigationConfigFile: string | null;
|
||||
defaultNavigationConfigFile: string;
|
||||
}
|
||||
|
||||
export interface GatewayOrchestratorConfig {
|
||||
@@ -70,6 +72,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
const publicBaseUrl = env.GATEWAY_PUBLIC_URL ?? kakaoRedirectUri;
|
||||
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||
const port = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
|
||||
const workspaceRootHint = env.GATEWAY_WORKSPACE_ROOT ?? process.cwd();
|
||||
return {
|
||||
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
|
||||
port,
|
||||
@@ -129,9 +132,10 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
5000,
|
||||
'GATEWAY_ORCHESTRATOR_ADMIN_MS'
|
||||
),
|
||||
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
|
||||
worktreeRoot:
|
||||
env.GATEWAY_WORKTREE_ROOT ?? path.resolve(env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), '.worktrees'),
|
||||
workspaceRootHint,
|
||||
worktreeRoot: env.GATEWAY_WORKTREE_ROOT ?? path.resolve(workspaceRootHint, '.worktrees'),
|
||||
navigationConfigFile: env.CORE_NAVIGATION_CONFIG_FILE?.trim() || '/srv/data/navigation.json',
|
||||
defaultNavigationConfigFile: path.resolve(workspaceRootHint, 'resources/navigation.json'),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { AdminAuthContext } from './adminAuth.js';
|
||||
import type { PasswordEnvelopeService } from './auth/passwordEnvelope.js';
|
||||
import { createAdminAuditStore, type AdminAuditStore } from './adminAudit.js';
|
||||
import type { UserIconUploadStore } from './account/remoteUserIconStore.js';
|
||||
import path from 'node:path';
|
||||
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
|
||||
|
||||
export interface GatewayApiContext {
|
||||
users: UserRepository;
|
||||
@@ -41,6 +43,7 @@ export interface GatewayApiContext {
|
||||
prisma: GatewayPrismaClient;
|
||||
adminAudit: AdminAuditStore;
|
||||
adminAuth?: AdminAuthContext;
|
||||
navigationConfig: RuntimeNavigationConfigStore;
|
||||
}
|
||||
|
||||
export const createGatewayApiContext = (options: {
|
||||
@@ -67,6 +70,7 @@ export const createGatewayApiContext = (options: {
|
||||
requestHeaders?: Record<string, string | string[] | undefined>;
|
||||
prisma: GatewayPrismaClient;
|
||||
adminAudit?: AdminAuditStore;
|
||||
navigationConfig?: RuntimeNavigationConfigStore;
|
||||
}): GatewayApiContext => ({
|
||||
users: options.users,
|
||||
sessions: options.sessions,
|
||||
@@ -91,4 +95,7 @@ export const createGatewayApiContext = (options: {
|
||||
requestHeaders: options.requestHeaders ?? {},
|
||||
prisma: options.prisma,
|
||||
adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma),
|
||||
navigationConfig:
|
||||
options.navigationConfig ??
|
||||
new RuntimeNavigationConfigStore(null, path.resolve(process.cwd(), 'resources/navigation.json')),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zId = z.string().min(1).max(80).regex(/^[a-z0-9][a-z0-9-]*$/u);
|
||||
const zLabel = z.string().min(1).max(80);
|
||||
const zInternalPath = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.refine((value) => value.startsWith('/') && !value.startsWith('//'), '내부 경로는 /로 시작해야 합니다.');
|
||||
const zExternalHref = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.refine(
|
||||
(value) =>
|
||||
(value.startsWith('/') && !value.startsWith('///')) ||
|
||||
value.startsWith('https://') ||
|
||||
value.startsWith('http://'),
|
||||
'링크는 /, //, https:// 또는 http://로 시작해야 합니다.'
|
||||
);
|
||||
|
||||
const zNavigationLink = z
|
||||
.object({
|
||||
kind: z.literal('link'),
|
||||
id: zId,
|
||||
label: zLabel,
|
||||
to: zInternalPath.optional(),
|
||||
href: zExternalHref.optional(),
|
||||
action: z.literal('show-version').optional(),
|
||||
newTab: z.boolean().optional(),
|
||||
showWhen: z.enum(['always', 'npc-enabled']).optional(),
|
||||
highlightWhen: z.enum(['nation-betting', 'vote']).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
const destinations = [value.to, value.href, value.action].filter(Boolean);
|
||||
if (destinations.length !== 1) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: '메뉴 링크에는 to, href, action 중 하나만 필요합니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const zNavigationDivider = z.object({ kind: z.literal('divider'), id: zId }).strict();
|
||||
const zNavigationChild = z.union([zNavigationLink, zNavigationDivider]);
|
||||
const zNavigationEntry = z.union([
|
||||
zNavigationLink,
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('group'),
|
||||
id: zId,
|
||||
label: zLabel,
|
||||
items: z.array(zNavigationChild).min(1).max(30),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('split'),
|
||||
id: zId,
|
||||
main: zNavigationLink,
|
||||
items: z.array(zNavigationChild).min(1).max(30),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
export const zRuntimeNavigationConfig: z.ZodType<RuntimeNavigationConfig> = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
gateway: z
|
||||
.object({
|
||||
brand: z.object({ label: zLabel, to: zInternalPath }).strict(),
|
||||
items: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: zId,
|
||||
label: zLabel,
|
||||
href: zExternalHref,
|
||||
newTab: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(30),
|
||||
})
|
||||
.strict(),
|
||||
game: z.object({ items: z.array(zNavigationEntry).min(1).max(20) }).strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export class RuntimeNavigationConfigStore {
|
||||
constructor(
|
||||
private readonly overridePath: string | null,
|
||||
private readonly defaultPath: string
|
||||
) {}
|
||||
|
||||
async get(): Promise<RuntimeNavigationConfig> {
|
||||
const configPath = await this.resolveConfigPath();
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(await fs.readFile(configPath, 'utf8')) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`메뉴 설정 파일을 읽지 못했습니다: ${configPath}`, { cause: error });
|
||||
}
|
||||
const parsed = zRuntimeNavigationConfig.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`메뉴 설정 파일이 올바르지 않습니다: ${configPath}: ${parsed.error.message}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
private async resolveConfigPath(): Promise<string> {
|
||||
if (!this.overridePath) return this.defaultPath;
|
||||
try {
|
||||
await fs.access(this.overridePath);
|
||||
return this.overridePath;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && 'code' in error ? error.code : undefined;
|
||||
if (code === 'ENOENT') return this.defaultPath;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,9 @@ const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verif
|
||||
};
|
||||
|
||||
export const appRouter = router({
|
||||
navigation: router({
|
||||
get: procedure.query(({ ctx }) => ctx.navigationConfig.get()),
|
||||
}),
|
||||
health: router({
|
||||
ping: procedure.query(() => ({
|
||||
ok: true,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { registerProfileStatusInternalRoute } from './lobby/profileStatusInterna
|
||||
import { installGatewayShutdownController } from './lifecycle/shutdownController.js';
|
||||
import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
|
||||
import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
|
||||
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
|
||||
|
||||
export const createGatewayApiServer = async () => {
|
||||
const config = resolveGatewayApiConfigFromEnv();
|
||||
@@ -80,6 +81,10 @@ export const createGatewayApiServer = async () => {
|
||||
);
|
||||
const releases = createGatewayReleaseRepository(postgres.prisma as GatewayPrismaClient);
|
||||
const profileStatus = new RepositoryProfileStatusService(profiles, orchestrator);
|
||||
const navigationConfig = new RuntimeNavigationConfigStore(
|
||||
config.navigationConfigFile,
|
||||
config.defaultNavigationConfigFile
|
||||
);
|
||||
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
@@ -104,6 +109,10 @@ export const createGatewayApiServer = async () => {
|
||||
profiles,
|
||||
secret: config.gameTokenSecret,
|
||||
});
|
||||
app.get(config.trpcPath.replace(/\/trpc\/?$/u, '/navigation'), async (_request, reply) => {
|
||||
void reply.header('Cache-Control', 'no-store');
|
||||
return navigationConfig.get();
|
||||
});
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: config.trpcPath,
|
||||
@@ -134,6 +143,7 @@ export const createGatewayApiServer = async () => {
|
||||
profileStatus,
|
||||
requestHeaders: req.headers,
|
||||
prisma: postgres.prisma as GatewayPrismaClient,
|
||||
navigationConfig,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -789,6 +789,30 @@ describe('admin operation API', () => {
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('stores event season zero as the next season number', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
|
||||
);
|
||||
|
||||
await harness.caller.admin.profiles.updateMeta({
|
||||
profileName: 'che:2',
|
||||
patch: { nextSeasonIdx: 0 },
|
||||
reason: 'prepare event season',
|
||||
});
|
||||
expect(harness.updatedMetas.at(-1)).toMatchObject({ nextSeasonIdx: 0 });
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.updateMeta({
|
||||
profileName: 'che:2',
|
||||
patch: { nextSeasonIdx: -1 },
|
||||
reason: 'reject negative season',
|
||||
})
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('does not let a scenario-only operator combine a Git update with reset', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { RuntimeNavigationConfigStore } from '../src/navigation/runtimeNavigationConfig.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
const createTemporaryDirectory = async (): Promise<string> => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-navigation-'));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
|
||||
});
|
||||
|
||||
describe('RuntimeNavigationConfigStore', () => {
|
||||
it('운영 override가 없으면 저장소 기본 메뉴를 읽는다', async () => {
|
||||
const store = new RuntimeNavigationConfigStore(
|
||||
'/definitely-missing/navigation.json',
|
||||
path.resolve(import.meta.dirname, '../../../resources/navigation.json')
|
||||
);
|
||||
|
||||
const config = await store.get();
|
||||
|
||||
expect(config.gateway.items.map((item) => item.label)).toEqual([
|
||||
'공지사항',
|
||||
'커뮤니티',
|
||||
'건의/제안/개발',
|
||||
'신고/문의',
|
||||
'자주 묻는 질문',
|
||||
'패치 내역',
|
||||
'Git Repo.',
|
||||
'위키',
|
||||
'공식 오픈 톡',
|
||||
'잡담 오픈 톡',
|
||||
]);
|
||||
expect(config.game.items.map((item) => (item.kind === 'split' ? item.main.label : item.label))).toEqual([
|
||||
'천통국 베팅',
|
||||
'세력일람',
|
||||
'장수일람',
|
||||
'명장일람',
|
||||
'연감',
|
||||
'게임 정보',
|
||||
'커뮤니티',
|
||||
'설문조사',
|
||||
]);
|
||||
});
|
||||
|
||||
it('프로세스를 재시작하지 않아도 운영 JSON 수정이 다음 조회에 반영된다', async () => {
|
||||
const directory = await createTemporaryDirectory();
|
||||
const overridePath = path.join(directory, 'navigation.json');
|
||||
const defaultPath = path.resolve(import.meta.dirname, '../../../resources/navigation.json');
|
||||
const raw = JSON.parse(await fs.readFile(defaultPath, 'utf8')) as {
|
||||
gateway: { items: Array<{ label: string }> };
|
||||
};
|
||||
await fs.writeFile(overridePath, JSON.stringify(raw));
|
||||
const store = new RuntimeNavigationConfigStore(overridePath, defaultPath);
|
||||
|
||||
expect((await store.get()).gateway.items[0]?.label).toBe('공지사항');
|
||||
raw.gateway.items[0]!.label = '운영 공지';
|
||||
await fs.writeFile(overridePath, JSON.stringify(raw));
|
||||
expect((await store.get()).gateway.items[0]?.label).toBe('운영 공지');
|
||||
});
|
||||
|
||||
it('실행 가능한 스크립트 URL과 목적지가 없는 링크를 거부한다', async () => {
|
||||
const directory = await createTemporaryDirectory();
|
||||
const overridePath = path.join(directory, 'navigation.json');
|
||||
const invalid = {
|
||||
version: 1,
|
||||
gateway: {
|
||||
brand: { label: '삼국지 모의전투 HiDCHe', to: '/' },
|
||||
items: [{ id: 'unsafe', label: '위험', href: 'javascript:alert(1)' }],
|
||||
},
|
||||
game: { items: [{ kind: 'link', id: 'empty', label: '빈 링크' }] },
|
||||
};
|
||||
await fs.writeFile(overridePath, JSON.stringify(invalid));
|
||||
const store = new RuntimeNavigationConfigStore(overridePath, overridePath);
|
||||
|
||||
await expect(store.get()).rejects.toThrow('메뉴 설정 파일이 올바르지 않습니다');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user