fix: 공개 API와 토너먼트의 사용자·장수 권한 경계를 강화한다

This commit is contained in:
2026-10-10 01:56:31 +09:00
parent 5771701007
commit 4e668b9fb6
31 changed files with 889 additions and 238 deletions
+5
View File
@@ -38,6 +38,11 @@ GATEWAY_INTERNAL_API_URL=http://127.0.0.1:13000
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000 ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000
WEB_PUSH_OUTBOX_POLL_MS=1000 WEB_PUSH_OUTBOX_POLL_MS=1000
GATEWAY_PUBLIC_URL=http://localhost:13000 GATEWAY_PUBLIC_URL=http://localhost:13000
# Browser origins default to GATEWAY_PUBLIC_URL (Game API also accepts GAME_PUBLIC_URL).
# Add exact HTTP(S) origins for a separate frontend; wildcards/null/path entries are rejected.
# Requests with a different Origin fail with 403 before mutation. No-Origin clients still need auth.
# API_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
# GAME_PUBLIC_URL=http://localhost:14000
# Gateway authentication protection shares atomic 60s counters under this prefix. # Gateway authentication protection shares atomic 60s counters under this prefix.
# Defaults: 10 login attempts/account, 600 auth requests/socket peer, 1200 globally; # Defaults: 10 login attempts/account, 600 auth requests/socket peer, 1200 globally;
# expensive auth work is capped at 4/process. Forwarded headers are not trusted. # expensive auth work is capped at 4/process. Forwarded headers are not trusted.
+6 -1
View File
@@ -1,4 +1,4 @@
import { parseNumberWithFallback } from '@sammo-ts/common'; import { parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
const parseReconcileInterval = (value: string | undefined): number => { const parseReconcileInterval = (value: string | undefined): number => {
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS'); const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
@@ -10,6 +10,7 @@ const parseReconcileInterval = (value: string | undefined): number => {
export interface GameApiConfig { export interface GameApiConfig {
host: string; host: string;
corsAllowedOrigins: string[];
port: number; port: number;
trpcPath: string; trpcPath: string;
eventsPath: string; eventsPath: string;
@@ -48,6 +49,10 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
return { return {
host: env.GAME_API_HOST ?? '0.0.0.0', host: env.GAME_API_HOST ?? '0.0.0.0',
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [
env.GATEWAY_PUBLIC_URL,
env.GAME_PUBLIC_URL,
]),
port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'), port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc', trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events', eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
+55 -70
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
import type { TournamentType } from '@sammo-ts/logic'; import type { TournamentType } from '@sammo-ts/logic';
import type { TournamentState } from '../../tournament/types.js'; import type { TournamentState } from '../../tournament/types.js';
@@ -18,6 +18,16 @@ import {
} from '../../services/redisClockFence.js'; } from '../../services/redisClockFence.js';
import { loadClockAdminStatus } from '../../services/clockReadiness.js'; import { loadClockAdminStatus } from '../../services/clockReadiness.js';
const assertTournamentAdjustment = (result: TurnDaemonCommandResult | null): void => {
if (result?.type === 'commandRejected') {
throw new TRPCError({ code: 'FORBIDDEN', message: result.reason });
}
if (!result || result.type !== 'tournamentAdjustGeneral') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected tournament response' });
}
if (!result.ok) throw new TRPCError({ code: result.code, message: result.reason });
};
const hasAdminRole = (roles: string[], profileName: string): boolean => { const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
return true; return true;
@@ -197,6 +207,7 @@ const zMatch = z.object({
const zBetEntry = z.object({ const zBetEntry = z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
userId: z.string().min(1).nullable().optional(),
targetId: z.number().int().positive(), targetId: z.number().int().positive(),
amount: z.number().int().positive(), amount: z.number().int().positive(),
}); });
@@ -458,7 +469,7 @@ export const tournamentRouter = router({
}); });
}), }),
getBettingSummary: authedProcedure.query(async ({ ctx }) => { getBettingSummary: authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx); await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
const [state, entries, matches] = await Promise.all([ const [state, entries, matches] = await Promise.all([
store.getState(), store.getState(),
@@ -488,7 +499,7 @@ export const tournamentRouter = router({
} }
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount; totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
totalAmount += entry.amount; totalAmount += entry.amount;
if (entry.generalId === general.id) { if (entry.userId === ctx.auth!.user.id) {
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount; myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
myAmount += entry.amount; myAmount += entry.amount;
} }
@@ -523,20 +534,15 @@ export const tournamentRouter = router({
const develCost = resolveCurrentDevelCost(worldState); const develCost = resolveCurrentDevelCost(worldState);
const feeResult = await ctx.turnDaemon.requestCommand({ const feeResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'), requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentJoin', reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }], userId: ctx.auth!.user.id,
generalId: general.id,
goldDelta: -develCost,
minGoldAfter: 0,
}); });
if (!feeResult || feeResult.type !== 'adjustGeneralResources') { assertTournamentAdjustment(feeResult);
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!feeResult.ok || feeResult.processed !== 1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
});
}
const meta = asRecord(general.meta); const meta = asRecord(general.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0; const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
@@ -558,12 +564,17 @@ export const tournamentRouter = router({
try { try {
await store.setParticipants(next); await store.setParticipants(next);
} catch (error) { } catch (error) {
await ctx.turnDaemon.requestCommand({ assertTournamentAdjustment(
type: 'adjustGeneralResources', await ctx.turnDaemon.requestCommand({
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'), type: 'tournamentAdjustGeneral',
reason: 'tournamentJoinRollback', requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
adjustments: [{ generalId: general.id, goldDelta: develCost }], reason: 'tournamentJoinRollback',
}); userId: ctx.auth!.user.id,
generalId: general.id,
goldDelta: develCost,
minGoldAfter: 0,
})
);
throw error; throw error;
} }
return { ok: true, count: next.length }; return { ok: true, count: next.length };
@@ -663,8 +674,13 @@ export const tournamentRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
} }
// 구형 row의 당시 actor는 현재 장수 소유자로 추정할 수 없다.
// 운영 전환은 베팅 종료 뒤 하거나 검증한 actor 정보를 관리자가 복원해야 한다.
if (entries.some((entry) => entry.userId === undefined)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이전 베팅의 사용자 확인이 필요합니다.' });
}
const previousBetAmount = entries const previousBetAmount = entries
.filter((entry) => entry.generalId === general.id) .filter((entry) => entry.userId === ctx.auth!.user.id)
.reduce((sum, entry) => sum + entry.amount, 0); .reduce((sum, entry) => sum + entry.amount, 0);
if (previousBetAmount + input.amount > 1_000) { if (previousBetAmount + input.amount > 1_000) {
throw new TRPCError({ throw new TRPCError({
@@ -674,68 +690,37 @@ export const tournamentRouter = router({
} }
const adjustResult = await ctx.turnDaemon.requestCommand({ const adjustResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'), requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentBet', reason: 'tournamentBet',
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }], userId: ctx.auth!.user.id,
generalId: general.id,
goldDelta: -input.amount,
betGoldDelta: input.amount,
minGoldAfter: 500,
}); });
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') { assertTournamentAdjustment(adjustResult);
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!adjustResult.ok || adjustResult.processed !== 1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason,
});
}
const rankResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
reason: 'tournamentBet',
adjustments: [
{
generalId: general.id,
metaDelta: { betgold: input.amount },
},
],
});
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
});
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '베팅 기록을 저장하지 못했습니다.' });
}
try { try {
await store.appendBettingEntry({ await store.appendBettingEntry({
generalId: general.id, generalId: general.id,
userId: ctx.auth!.user.id,
targetId: input.targetId, targetId: input.targetId,
amount: input.amount, amount: input.amount,
}); });
} catch (error) { } catch (error) {
await Promise.all([ assertTournamentAdjustment(
ctx.turnDaemon.requestCommand({ await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'), requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }], userId: ctx.auth!.user.id,
}), generalId: general.id,
ctx.turnDaemon.requestCommand({ goldDelta: input.amount,
type: 'adjustGeneralMeta', betGoldDelta: -input.amount,
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'), minGoldAfter: 0,
reason: 'tournamentBetRollback', })
adjustments: [ );
{
generalId: general.id,
metaDelta: { betgold: -input.amount },
},
],
}),
]);
throw error; throw error;
} }
return { ok: true }; return { ok: true };
+8 -6
View File
@@ -1,4 +1,4 @@
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common'; import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
import fastify, { type FastifyRequest } from 'fastify'; import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
@@ -76,13 +76,14 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
const resolveAuthFromToken = async ( const resolveAuthFromToken = async (
token: string | null, token: string | null,
accessTokenStore: RedisAccessTokenStore, accessTokenStore: RedisAccessTokenStore,
flushStore: FlushStore flushStore: FlushStore,
profileName: string
): Promise<GameSessionTokenPayload | null> => { ): Promise<GameSessionTokenPayload | null> => {
if (!token) { if (!token) {
return null; return null;
} }
const stored = await accessTokenStore.get(token); const stored = await accessTokenStore.get(token);
if (!stored) { if (!stored || stored.profile !== profileName) {
return null; return null;
} }
let flushedAt: Date | null; let flushedAt: Date | null;
@@ -273,8 +274,9 @@ export const createGameApiServer = async () => {
app.addHook('onClose', closeResources); app.addHook('onClose', closeResources);
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
await app.register(cors, { await app.register(cors, {
origin: true, origin: config.corsAllowedOrigins,
credentials: true, credentials: true,
}); });
@@ -290,7 +292,7 @@ export const createGameApiServer = async () => {
...trpcJsonBodyHttpServerOptions, ...trpcJsonBodyHttpServerOptions,
createContext: async ({ req }: { req: FastifyRequest }) => { createContext: async ({ req }: { req: FastifyRequest }) => {
const token = extractBearerToken(req.headers.authorization); const token = extractBearerToken(req.headers.authorization);
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore); const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key']) const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
? req.headers['idempotency-key'][0] ? req.headers['idempotency-key'][0]
: req.headers['idempotency-key']; : req.headers['idempotency-key'];
@@ -334,7 +336,7 @@ export const createGameApiServer = async () => {
}); });
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => { const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore); const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
if ( if (
!auth || !auth ||
auth.profile !== config.profileName || auth.profile !== config.profileName ||
+5 -1
View File
@@ -31,6 +31,7 @@ export class CorruptTournamentProjectionError extends Error {
const zTournamentBet = z const zTournamentBet = z
.object({ .object({
generalId: z.number().int(), generalId: z.number().int(),
userId: z.string().min(1).nullable().optional(),
targetId: z.number().int(), targetId: z.number().int(),
amount: z.number(), amount: z.number(),
}) })
@@ -286,7 +287,10 @@ export class TournamentStore {
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> { async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
const entries = await this.getBettingEntries(); const entries = await this.getBettingEntries();
const existing = entries.find( const existing = entries.find(
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId (candidate) =>
candidate.generalId === entry.generalId &&
candidate.targetId === entry.targetId &&
candidate.userId === entry.userId
); );
if (existing) { if (existing) {
existing.amount += entry.amount; existing.amount += entry.amount;
+2
View File
@@ -74,6 +74,8 @@ export interface TournamentMatchEntry {
export interface TournamentBetEntry { export interface TournamentBetEntry {
generalId: number; generalId: number;
// 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다.
userId?: string | null;
targetId: number; targetId: number;
amount: number; amount: number;
} }
+18 -13
View File
@@ -760,7 +760,7 @@ const buildNpcBettingPlan = async (options: {
for (const npc of npcBetList) { for (const npc of npcBetList) {
const targetId = rng.choice(candidateIds); const targetId = rng.choice(candidateIds);
if (!existingBettors.has(npc.id as number)) { if (!existingBettors.has(npc.id as number)) {
entries.push({ generalId: npc.id as number, targetId, amount: betGold }); entries.push({ generalId: npc.id as number, userId: null, targetId, amount: betGold });
} }
} }
return entries; return entries;
@@ -789,19 +789,24 @@ export const seedNpcBets = async (options: {
} }
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`; const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
await daemonTransport.sendCommand({ // Wait for one durable ENGINE result before publishing entries or opening stage 6.
type: 'adjustGeneralResources', // The engine skips NPCs whose owner changed after this deterministic plan was stored.
requestId: `${requestPrefix}:resources`, const result = await daemonTransport.requestCommand({
reason: 'tournamentNpcBet', type: 'tournamentSeedNpcBets',
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })), requestId: `${requestPrefix}:atomic`,
}); bets: plan.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })),
await daemonTransport.sendCommand({
type: 'adjustGeneralMeta',
requestId: `${requestPrefix}:meta`,
reason: 'tournamentNpcBet',
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
}); });
if (!result || result.type !== 'tournamentSeedNpcBets' || !result.ok) {
throw new Error('NPC tournament betting did not commit.');
}
const processed = new Set(result.processedGeneralIds);
const existing = await store.getBettingEntries(); const existing = await store.getBettingEntries();
const existingBettors = new Set(existing.map((entry) => entry.generalId)); const existingBettors = new Set(existing.map((entry) => entry.generalId));
await store.setBettingEntries(existing.concat(plan.filter((entry) => !existingBettors.has(entry.generalId)))); await store.setBettingEntries(
existing.concat(
plan
.filter((entry) => processed.has(entry.generalId) && !existingBettors.has(entry.generalId))
.map((entry) => ({ ...entry, userId: null }))
)
);
}; };
+1 -1
View File
@@ -24,7 +24,7 @@ import { recordGeneralActivity } from './services/generalActivity.js';
const t = initTRPC.context<GameApiContext>().create({ errorFormatter: safeTrpcErrorFormatter }); const t = initTRPC.context<GameApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
const requireAuthMiddleware = t.middleware(({ ctx, next }) => { const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.auth) { if (!ctx.auth || ctx.auth.profile !== ctx.profile.name) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'Unauthorized', message: 'Unauthorized',
@@ -387,7 +387,7 @@ describe('general access tracking', () => {
db, db,
generalAccessTracking: true, generalAccessTracking: true,
requestId: 'access-boundary-test', requestId: 'access-boundary-test',
profile: { id: 'che:default', name: 'che' }, profile: { id: 'che', name: 'che:default', scenario: 'default' },
profileStatusSource, profileStatusSource,
} as unknown as GameApiContext; } as unknown as GameApiContext;
@@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token =
auth: token, auth: token,
db: { $executeRaw: executeRaw }, db: { $executeRaw: executeRaw },
generalAccessTracking: true, generalAccessTracking: true,
profile: { id: 'che:default', name: 'che', scenario: 'default' }, profile: { id: 'che', name: 'che:default', scenario: 'default' },
}) as unknown as GameApiContext; }) as unknown as GameApiContext;
const activityRouter = router({ const activityRouter = router({
@@ -31,6 +31,7 @@ const buildContext = (
) => ) =>
({ ({
auth: options.auth === undefined ? auth : options.auth, auth: options.auth === undefined ? auth : options.auth,
profile: { id: 'che', name: 'che:default', scenario: 'default' },
db: { db: {
general: { general: {
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
@@ -36,6 +36,7 @@ type LogQuery = {
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) => const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
({ ({
auth, auth,
profile: { id: 'che', name: 'che:default', scenario: 'default' },
db: { db: {
general: { general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({ findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
+2 -2
View File
@@ -20,7 +20,7 @@ const profile: GameProfile = {
const auth: GameSessionTokenPayload = { const auth: GameSessionTokenPayload = {
version: 1, version: 1,
profile: 'che', profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z', issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z', expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: 'ranking-session', sessionId: 'ranking-session',
@@ -239,7 +239,7 @@ const buildContext = (options?: {
turnDaemon: new InMemoryTurnDaemonTransport(), turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(), battleSim: new InMemoryBattleSimTransport(),
profile: selectedProfile, profile: selectedProfile,
auth: options?.authenticated === false ? null : auth, auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name },
uploadDir: 'uploads', uploadDir: 'uploads',
uploadPath: '/uploads', uploadPath: '/uploads',
uploadPublicUrl: null, uploadPublicUrl: null,
@@ -70,6 +70,7 @@ const envKeys = [
'GATEWAY_REDIS_PREFIX', 'GATEWAY_REDIS_PREFIX',
'GATEWAY_INTERNAL_API_URL', 'GATEWAY_INTERNAL_API_URL',
'GAME_UPLOAD_DIR', 'GAME_UPLOAD_DIR',
'API_ALLOWED_ORIGINS',
] as const; ] as const;
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]])); const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
@@ -642,6 +643,7 @@ integration('game API security over HTTP transport', () => {
process.env.GATEWAY_REDIS_PREFIX = redisPrefix; process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub(); process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
process.env.GAME_UPLOAD_DIR = uploadDir; process.env.GAME_UPLOAD_DIR = uploadDir;
process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net';
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl }); const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
await connector.connect(); await connector.connect();
@@ -3774,6 +3776,58 @@ integration('game API security over HTTP transport', () => {
} }
}, 15_000); }, 15_000);
it('rejects an untrusted browser Origin before an authenticated logout mutation', async () => {
const token = await createAccessToken('origin-mutation', {});
const response = await fetch(`${baseUrl}/trpc/auth.logout`, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
origin: 'https://untrusted.example',
'content-type': 'application/json',
},
body: JSON.stringify({}),
});
expect(response.status).toBe(403);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
expect(await accessTokenStore.get(token)).not.toBeNull();
for (const origin of ['https://sam.hided.net', 'https://untrusted.example', 'null']) {
const preflight = await fetch(`${baseUrl}/trpc/auth.logout`, {
method: 'OPTIONS',
headers: {
origin,
'access-control-request-method': 'POST',
'access-control-request-headers': 'authorization,content-type',
},
});
expect(preflight.status).toBe(origin === 'https://sam.hided.net' ? 204 : 403);
expect(preflight.headers.get('access-control-allow-origin')).toBe(
origin === 'https://sam.hided.net' ? origin : null
);
}
await accessTokenStore.revoke(token);
});
it('rejects a same-namespace foreign-profile token over tRPC before any DB mutation', async () => {
const foreign = await accessTokenStore.create({
...buildPayload('trpc-foreign', {}),
profile: 'other:default',
});
if (!foreign) throw new Error('Foreign token fixture failed');
const before = await readReservedMutationState();
try {
const response = await fetch(
`${baseUrl}/trpc/turns.reserved.getGeneral?input=${encodeURIComponent(JSON.stringify({ generalId }))}`,
{
headers: { authorization: `Bearer ${foreign.accessToken}` },
}
);
expect(response.status).toBe(401);
expect(await readReservedMutationState()).toEqual(before);
} finally {
await accessTokenStore.revoke(foreign.accessToken);
}
});
it('SSE accepts header auth and rejects query or cross-profile credentials', async () => { it('SSE accepts header auth and rejects query or cross-profile credentials', async () => {
const token = await createAccessToken('sse-query', {}); const token = await createAccessToken('sse-query', {});
const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`); const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`);
+99 -35
View File
@@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport {
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> { async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
this.commands.push(command); this.commands.push(command);
if (command.type === 'adjustGeneralResources') { if (command.type === 'tournamentAdjustGeneral') {
const adjustment = command.adjustments[0]; if (command.betGoldDelta !== undefined && this.failNextRankUpdate) {
if (!adjustment) { this.failNextRankUpdate = false;
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' }; return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' };
} }
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0); const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta;
if (nextGold < (adjustment.minGoldAfter ?? 0)) { if (nextGold < command.minGoldAfter) {
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' }; return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' };
} }
this.gold.set(adjustment.generalId, nextGold); this.gold.set(command.generalId, nextGold);
return { return { type: command.type, ok: true, generalId: command.generalId };
type: 'adjustGeneralResources',
ok: true,
processed: 1,
missing: 0,
totalGoldDelta: adjustment.goldDelta ?? 0,
totalRiceDelta: 0,
};
} }
if (command.type === 'setMySetting') { if (command.type === 'setMySetting') {
return { type: 'setMySetting', ok: true, generalId: command.generalId }; return { type: 'setMySetting', ok: true, generalId: command.generalId };
} }
if (command.type === 'adjustGeneralMeta') {
if (this.failNextRankUpdate) {
this.failNextRankUpdate = false;
return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' };
}
return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 };
}
return null; return null;
} }
@@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => {
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 }); await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_800); expect(transport.gold.get(general.id)).toBe(1_800);
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1); expect(transport.commands.filter((command) => command.type === 'tournamentAdjustGeneral')).toHaveLength(1);
expect(transport.commands).toContainEqual( expect(transport.commands).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
requestId: 'http:tournament-join:tournamentJoin:resources', requestId: 'http:tournament-join:tournamentJoin:resources',
reason: 'tournamentJoin', userId: 'user-1',
}) })
); );
expect(outerApiTransaction).not.toHaveBeenCalled(); expect(outerApiTransaction).not.toHaveBeenCalled();
@@ -459,9 +445,12 @@ describe('tournament router permissions and mutations', () => {
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 }); await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_936); expect(transport.gold.get(general.id)).toBe(1_936);
expect(transport.commands).toContainEqual({ expect(transport.commands).toContainEqual({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
userId: 'user-1',
reason: 'tournamentJoin', reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }], generalId: general.id,
goldDelta: -64,
minGoldAfter: 0,
}); });
}); });
@@ -534,21 +523,96 @@ describe('tournament router permissions and mutations', () => {
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600); expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
expect(transport.commands).toContainEqual( expect(transport.commands).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
requestId: 'http:suspended-tournament-bet:tournamentBet:resources', requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
reason: 'tournamentBet', userId: 'user-1',
}) })
); );
expect(outerApiTransaction).not.toHaveBeenCalled(); expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands).toContainEqual( expect(transport.commands).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'adjustGeneralMeta', type: 'tournamentAdjustGeneral',
requestId: 'http:suspended-tournament-bet:tournamentBet:rank', userId: 'user-1',
reason: 'tournamentBet', betGoldDelta: 600,
}) })
); );
}); });
it("keeps the per-user cap across possession and separates a previous owner's bets", async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-2', 3_000);
transport.gold.set(general.id, general.gold);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
await redis.set(
'sammo:che:default:tournament:betting',
JSON.stringify([
{ generalId: 1, userId: 'user-1', targetId: 11, amount: 900 },
{ generalId: 99, userId: 'user-2', targetId: 12, amount: 800 },
{ generalId: 98, userId: null, targetId: 11, amount: 100 },
])
);
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
);
expect(await caller.tournament.getBettingSummary()).toMatchObject({
myAmount: 800,
myTotals: { 12: 800 },
totalAmount: 1_800,
});
await expect(caller.tournament.placeBet({ targetId: 11, amount: 300 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(transport.commands).toHaveLength(0);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 200 })).resolves.toEqual({ ok: true });
expect(await caller.tournament.getBettingSummary()).toMatchObject({
myAmount: 1_000,
myTotals: { 11: 200, 12: 800 },
});
expect(transport.gold.get(1)).toBe(2_800);
});
it('does not infer a legacy bet actor from the current general owner', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-2', 3_000);
transport.gold.set(1, 3_000);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
await redis.set(
'sammo:che:default:tournament:betting',
JSON.stringify([{ generalId: 1, targetId: 11, amount: 900 }])
);
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
);
expect(await caller.tournament.getBettingSummary()).toMatchObject({ myAmount: 0, totalAmount: 900 });
await expect(caller.tournament.placeBet({ targetId: 11, amount: 10 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(transport.commands).toHaveLength(0);
expect(transport.gold.get(1)).toBe(3_000);
});
it('rejects a tournament bet during reconciliation without debiting gold', async () => { it('rejects a tournament bet during reconciliation without debiting gold', async () => {
const redis = new MemoryRedis(); const redis = new MemoryRedis();
const transport = new TournamentTransport(); const transport = new TournamentTransport();
@@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => {
expect(snapshot.sourceRevision).toBe('41'); expect(snapshot.sourceRevision).toBe('41');
}); });
it('refunds gold when the tournament bet rank update fails', async () => { it('does not debit gold when the atomic tournament adjustment is rejected', async () => {
const redis = new MemoryRedis(); const redis = new MemoryRedis();
const transport = new TournamentTransport(); const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1', 2_000); const general = buildGeneral(1, 'user-1', 2_000);
@@ -818,7 +882,7 @@ describe('tournament router permissions and mutations', () => {
); );
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({ await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR', code: 'BAD_REQUEST',
}); });
expect(transport.gold.get(general.id)).toBe(2_000); expect(transport.gold.get(general.id)).toBe(2_000);
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({ await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
+55 -30
View File
@@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
const createNoopDaemonTransport = (): TurnDaemonTransport => ({ const createNoopDaemonTransport = (): TurnDaemonTransport => ({
sendCommand: async () => 'ok', sendCommand: async () => 'ok',
requestCommand: async () => null, requestCommand: async (command) =>
command.type === 'tournamentSeedNpcBets'
? {
type: command.type,
ok: true,
processedGeneralIds: command.bets.map((bet) => bet.generalId),
skippedGeneralIds: [],
}
: null,
requestStatus: async () => null, requestStatus: async () => null,
}); });
@@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => {
const commands: TurnDaemonCommand[] = []; const commands: TurnDaemonCommand[] = [];
const daemonTransport: TurnDaemonTransport = { const daemonTransport: TurnDaemonTransport = {
...createNoopDaemonTransport(), ...createNoopDaemonTransport(),
sendCommand: async (command) => { requestCommand: async (command) => {
expect((await store.getState())?.stage).toBe(5); expect((await store.getState())?.stage).toBe(5);
commands.push(command); commands.push(command);
return 'ok'; return createNoopDaemonTransport().requestCommand(command);
}, },
}; };
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport); const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
@@ -455,21 +463,14 @@ describe('tournament worker (in-memory)', () => {
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1); expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
expect(commands).toEqual([ expect(commands).toEqual([
{ {
type: 'adjustGeneralResources', type: 'tournamentSeedNpcBets',
requestId: 'tournament:123:npc-bet:resources', requestId: 'tournament:123:npc-bet:atomic',
reason: 'tournamentNpcBet', bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })),
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })),
},
{
type: 'adjustGeneralMeta',
requestId: 'tournament:123:npc-bet:meta',
reason: 'tournamentNpcBet',
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })),
}, },
]); ]);
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport }); await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
expect(await store.getBettingEntries()).toEqual(bets); expect(await store.getBettingEntries()).toEqual(bets);
expect(commands).toHaveLength(2); expect(commands).toHaveLength(1);
// A fresh projection with the same seed and DB inputs has the same choices. // A fresh projection with the same seed and DB inputs has the same choices.
await store.setBettingEntries([bets[0]!]); await store.setBettingEntries([bets[0]!]);
await seedNpcBets({ await seedNpcBets({
@@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => {
expect(await store.getBettingEntries()).toEqual(bets); expect(await store.getBettingEntries()).toEqual(bets);
}); });
it('publishes only NPC bets acknowledged after the current-owner check', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-owned-after-plan'));
const state = createTournamentState({
stage: 5,
bettingId: 789,
npcBettingPlan: [
{ generalId: 3, userId: null, targetId: 11, amount: 10 },
{ generalId: 4, userId: null, targetId: 12, amount: 10 },
],
});
await store.setState(state);
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
const transport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
requestCommand: async (command) => {
expect(command.type).toBe('tournamentSeedNpcBets');
expect((await store.getState())?.stage).toBe(5);
expect(await store.getBettingEntries()).toEqual([]);
return { type: 'tournamentSeedNpcBets', ok: true, processedGeneralIds: [4], skippedGeneralIds: [3] };
},
};
const opened = await applyPreBattleStage(store, createPrismaMock({}), state, 'seed', transport);
expect(opened.stage).toBe(6);
expect(await store.getBettingEntries()).toEqual([{ generalId: 4, userId: null, targetId: 12, amount: 10 }]);
});
it('retries an opening query failure without advancing stage or changing the betting identity', async () => { it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry')); const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
const state = createTournamentState({ stage: 5 }); const state = createTournamentState({ stage: 5 });
@@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => {
expect(await store.getBettingEntries()).toHaveLength(1); expect(await store.getBettingEntries()).toHaveLength(1);
}); });
it('reuses durable command identities after a partial enqueue failure', async () => { it('reuses durable command identities after an uncertain atomic command response', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry')); const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
const state = createTournamentState({ stage: 5, bettingId: 456 }); const state = createTournamentState({ stage: 5, bettingId: 456 });
await store.setState(state); await store.setState(state);
@@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => {
let fail = true; let fail = true;
const transport: TurnDaemonTransport = { const transport: TurnDaemonTransport = {
...createNoopDaemonTransport(), ...createNoopDaemonTransport(),
sendCommand: async (command) => { requestCommand: async (command) => {
commands.push(command); commands.push(command);
if (command.type === 'adjustGeneralMeta' && fail) { if (command.type === 'tournamentSeedNpcBets' && fail) {
fail = false; fail = false;
// The accepted debit may already have committed before retry. // The accepted debit may already have committed before retry.
prisma.general.findMany = async () => []; prisma.general.findMany = async () => [];
throw new Error('enqueue unavailable'); throw new Error('enqueue unavailable');
} }
return 'ok'; return createNoopDaemonTransport().requestCommand(command);
}, },
}; };
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow( await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
@@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => {
); );
expect(await store.getState()).toMatchObject({ expect(await store.getState()).toMatchObject({
stage: 5, stage: 5,
npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }], npcBettingPlan: [{ generalId: 3, userId: null, targetId: expect.any(Number), amount: 10 }],
}); });
expect(await store.getBettingEntries()).toEqual([]); expect(await store.getBettingEntries()).toEqual([]);
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport); await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
expect(commands.slice(2)).toEqual(commands.slice(0, 2)); expect(commands.slice(1)).toEqual(commands.slice(0, 1));
expect(commands.map((command) => command.requestId)).toEqual([ expect(commands.map((command) => command.requestId)).toEqual([
'tournament:456:npc-bet:resources', 'tournament:456:npc-bet:atomic',
'tournament:456:npc-bet:meta', 'tournament:456:npc-bet:atomic',
'tournament:456:npc-bet:resources',
'tournament:456:npc-bet:meta',
]); ]);
expect(await store.getBettingEntries()).toHaveLength(1); expect(await store.getBettingEntries()).toHaveLength(1);
}); });
@@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => {
commands.push(command); commands.push(command);
return 'ok'; return 'ok';
}, },
requestCommand: async () => null, requestCommand: async (command) => {
commands.push(command);
return createNoopDaemonTransport().requestCommand(command);
},
requestStatus: async () => null, requestStatus: async () => null,
}; };
@@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => {
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10); expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
expect(commands).toContainEqual( expect(commands).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'adjustGeneralMeta', type: 'tournamentSeedNpcBets',
reason: 'tournamentNpcBet', bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]),
adjustments: expect.arrayContaining([
expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }),
]),
}) })
); );
} }
@@ -120,7 +120,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
const claimCoordinate = await readInputEventClockCoordinate(transaction); const claimCoordinate = await readInputEventClockCoordinate(transaction);
const world = await transaction.worldState.findFirst({ const world = await transaction.worldState.findFirst({
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true, meta: true }, select: {
clockPhase: true,
clockRevision: true,
deadlineGeneration: true,
clockTick: true,
meta: true,
},
}); });
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다. // 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다. // 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
@@ -184,7 +190,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
OR (${postUnificationSurvey} AND "event_type" = 'voteReward') OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
OR ( OR (
${suspendedTournamentBetCommand} ${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta') AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta', 'tournamentAdjustGeneral')
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback') AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
) )
OR ( OR (
@@ -269,6 +269,21 @@ const zSetNpcPolicy = z.object({
]), ]),
}); });
const zTournamentAdjustGeneral = z.object({
type: z.literal('tournamentAdjustGeneral'),
reason: z.enum(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback']),
userId: z.string().min(1),
generalId: zSafeInteger.positive(),
goldDelta: zFiniteNumber,
betGoldDelta: zFiniteNumber.optional(),
minGoldAfter: zFiniteNumber.nonnegative(),
});
const zTournamentSeedNpcBets = z.object({
type: z.literal('tournamentSeedNpcBets'),
bets: z.array(z.object({ generalId: zSafeInteger.positive(), amount: zSafeInteger.positive() })).min(1),
});
const zAdjustGeneralResources = z.object({ const zAdjustGeneralResources = z.object({
type: z.literal('adjustGeneralResources'), type: z.literal('adjustGeneralResources'),
reason: z.string().optional(), reason: z.string().optional(),
@@ -737,6 +752,16 @@ const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
return { ...command, requestId: envelope.requestId }; return { ...command, requestId: envelope.requestId };
}; };
const normalizeTournamentAdjustGeneral: CommandNormalizer<'tournamentAdjustGeneral'> = (envelope) => {
const command = parseWith(zTournamentAdjustGeneral, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeTournamentSeedNpcBets: CommandNormalizer<'tournamentSeedNpcBets'> = (envelope) => {
const command = parseWith(zTournamentSeedNpcBets, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => { const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => {
const command = parseWith(zAdjustGeneralResources, envelope.command); const command = parseWith(zAdjustGeneralResources, envelope.command);
if (!command) { if (!command) {
@@ -905,6 +930,8 @@ const normalizers: CommandNormalizerMap = {
voteReward: normalizeVoteReward, voteReward: normalizeVoteReward,
setNationSetting: normalizeSetNationSetting, setNationSetting: normalizeSetNationSetting,
setNpcPolicy: normalizeSetNpcPolicy, setNpcPolicy: normalizeSetNpcPolicy,
tournamentAdjustGeneral: normalizeTournamentAdjustGeneral,
tournamentSeedNpcBets: normalizeTournamentSeedNpcBets,
adjustGeneralResources: normalizeAdjustGeneralResources, adjustGeneralResources: normalizeAdjustGeneralResources,
adjustGeneralMeta: normalizeAdjustGeneralMeta, adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult, tournamentMatchResult: normalizeTournamentMatchResult,
@@ -175,6 +175,7 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
}; };
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'tournamentAdjustGeneral',
'troopCreate', 'troopCreate',
'troopJoin', 'troopJoin',
'troopExit', 'troopExit',
@@ -627,11 +628,84 @@ async function handleSetNpcPolicy(
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt }); return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
} }
async function handleTournamentAdjustGeneral(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
): Promise<TurnDaemonCommandResult> {
const general = ctx.world.getGeneralById(command.generalId);
// Ref의 참가/베팅 actor는 로그인 장수다. 큐 대기 중 빙의 소유자가 바뀐 경우도 막는다.
if (!general || general.userId !== command.userId) {
return { type: command.type, ok: false, code: 'FORBIDDEN', reason: '장수의 현재 소유자가 일치하지 않습니다.' };
}
const gold = general.gold + command.goldDelta;
if (!Number.isFinite(gold) || gold < command.minGoldAfter) {
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '금이 부족합니다.' };
}
const meta = { ...general.meta };
if (command.betGoldDelta !== undefined) {
const current = typeof meta.betgold === 'number' ? meta.betgold : 0;
const betgold = current + command.betGoldDelta;
if (!Number.isFinite(betgold) || betgold < 0) {
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '베팅 기록이 일치하지 않습니다.' };
}
meta.betgold = betgold;
}
// 금과 통계는 하나의 ENGINE event에서 함께 commit하거나 함께 거부한다.
ctx.world.updateGeneral(command.generalId, { gold, meta });
return { type: command.type, ok: true, generalId: command.generalId };
}
async function handleTournamentSeedNpcBets(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>
): Promise<TurnDaemonCommandResult> {
if (ctx.commandDb) {
const event = command.requestId
? await ctx.commandDb.inputEvent.findUnique({
where: { requestId: command.requestId },
select: { actorUserId: true, target: true, eventType: true },
})
: null;
if (!event || event.actorUserId !== null || event.target !== 'ENGINE' || event.eventType !== command.type) {
return { type: command.type, ok: false, reason: 'NPC 베팅 입력 이벤트가 일치하지 않습니다.' };
}
}
if (new Set(command.bets.map((bet) => bet.generalId)).size !== command.bets.length) {
return { type: command.type, ok: false, reason: 'NPC 베팅 대상이 중복됩니다.' };
}
const processedGeneralIds: number[] = [];
const skippedGeneralIds: number[] = [];
for (const bet of command.bets) {
const general = ctx.world.getGeneralById(bet.generalId);
// 계획 이후 유저가 빙의한 장수는 NPC 비용·통계를 변경하지 않는다.
if (!general || general.userId != null || general.npcState < 2 || general.gold - bet.amount < 500) {
skippedGeneralIds.push(bet.generalId);
continue;
}
const current = typeof general.meta.betgold === 'number' ? general.meta.betgold : 0;
ctx.world.updateGeneral(bet.generalId, {
gold: general.gold - bet.amount,
meta: { ...general.meta, betgold: current + bet.amount },
});
processedGeneralIds.push(bet.generalId);
}
return { type: command.type, ok: true, processedGeneralIds, skippedGeneralIds };
}
const isLegacyTournamentAdjustment = (reason: string | undefined): boolean =>
reason !== undefined &&
['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'].includes(
reason
);
async function handleAdjustGeneralResources( async function handleAdjustGeneralResources(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }> command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world } = ctx; const { world } = ctx;
if (isLegacyTournamentAdjustment(command.reason)) {
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
}
if (!command.adjustments || command.adjustments.length === 0) { if (!command.adjustments || command.adjustments.length === 0) {
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' }; return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
} }
@@ -688,6 +762,9 @@ async function handleAdjustGeneralMeta(
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }> command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world } = ctx; const { world } = ctx;
if (isLegacyTournamentAdjustment(command.reason)) {
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
}
if (!command.adjustments || command.adjustments.length === 0) { if (!command.adjustments || command.adjustments.length === 0) {
return { return {
type: 'adjustGeneralMeta', type: 'adjustGeneralMeta',
@@ -3267,6 +3344,13 @@ export const createTurnDaemonCommandHandler = (options: {
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>), handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
setNpcPolicy: (command) => setNpcPolicy: (command) =>
handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>), handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>),
tournamentAdjustGeneral: (command) =>
handleTournamentAdjustGeneral(
ctx,
command as Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
),
tournamentSeedNpcBets: (command) =>
handleTournamentSeedNpcBets(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>),
adjustGeneralResources: (command) => adjustGeneralResources: (command) =>
handleAdjustGeneralResources( handleAdjustGeneralResources(
ctx, ctx,
@@ -7,6 +7,16 @@ import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
{
type: 'tournamentAdjustGeneral',
reason: 'tournamentBet',
requestId: 'tournamentAdjustGeneral',
userId,
generalId: 7,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
},
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' }, { type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 }, { type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 }, { type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
@@ -274,69 +274,79 @@ integration('database command queue', () => {
}); });
}); });
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => { it.each(['vacation', 'tournamentAdjustGeneral'] as const)(
const requestId = 'integration:engine:stale-owner-replay'; 'stores a stale-owner %s rejection once and never redispatches it',
const command: TurnDaemonCommand = { async (type) => {
type: 'vacation', const requestId = `integration:engine:stale-owner-replay:${type}`;
requestId, const command: TurnDaemonCommand =
userId: 'old-owner', type === 'vacation'
generalId: 7, ? { type, requestId, userId: 'old-owner', generalId: 7 }
}; : {
await db.inputEvent.create({ type,
data: { reason: 'tournamentBet',
requestId, requestId,
target: 'ENGINE', userId: 'old-owner',
eventType: command.type, generalId: 7,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
};
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
actorUserId: command.userId,
payload: command as GamePrisma.InputJsonValue,
},
});
const mutation = vi.fn();
const world = {
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
updateGeneral: mutation,
updateNation: mutation,
createTroop: mutation,
updateTroop: mutation,
removeTroop: mutation,
pushLog: mutation,
queueMessage: mutation,
} as unknown as InMemoryTurnWorld;
const handler = createTurnDaemonCommandHandler({ world });
const handle = vi.spyOn(handler, 'handle');
const owner = new DatabaseTurnDaemonCommandQueue(db);
const claimed = await owner.drain();
expect(claimed).toEqual([
{
...command,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
expect(result).toMatchObject({
type: 'commandRejected',
ok: false,
commandType: command.type,
});
await owner.publishCommandResult(requestId, result!);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
actorUserId: command.userId, actorUserId: command.userId,
payload: command as GamePrisma.InputJsonValue, eventType: command.type,
}, payload: command,
}); result,
lockedBy: null,
const mutation = vi.fn(); leaseUntil: null,
const world = { });
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })), await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
updateGeneral: mutation, expect(handle).toHaveBeenCalledOnce();
updateNation: mutation, expect(mutation).not.toHaveBeenCalled();
createTroop: mutation, }
updateTroop: mutation, );
removeTroop: mutation,
pushLog: mutation,
queueMessage: mutation,
} as unknown as InMemoryTurnWorld;
const handler = createTurnDaemonCommandHandler({ world });
const handle = vi.spyOn(handler, 'handle');
const owner = new DatabaseTurnDaemonCommandQueue(db);
const claimed = await owner.drain();
expect(claimed).toEqual([
{
...command,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
expect(result).toMatchObject({
type: 'commandRejected',
ok: false,
commandType: command.type,
});
await owner.publishCommandResult(requestId, result!);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
actorUserId: command.userId,
eventType: command.type,
payload: command,
result,
lockedBy: null,
leaseUntil: null,
});
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
expect(handle).toHaveBeenCalledOnce();
expect(mutation).not.toHaveBeenCalled();
});
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])( it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
'handles pre-opening user commands in %s without treating them as scheduled turns', 'handles pre-opening user commands in %s without treating them as scheduled turns',
@@ -630,6 +640,8 @@ integration('database command queue', () => {
const metaId = 'integration:engine:suspended-tournament-bet-meta'; const metaId = 'integration:engine:suspended-tournament-bet-meta';
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback'; const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
const unrelatedId = 'integration:engine:suspended-resource-adjustment'; const unrelatedId = 'integration:engine:suspended-resource-adjustment';
const atomicBetId = 'integration:engine:suspended-atomic-bet';
const atomicJoinId = 'integration:engine:suspended-atomic-join';
await db.inputEvent.createMany({ await db.inputEvent.createMany({
data: [ data: [
{ {
@@ -679,6 +691,41 @@ integration('database command queue', () => {
], ],
}); });
await db.inputEvent.createMany({
data: [
{
requestId: atomicBetId,
target: 'ENGINE',
eventType: 'tournamentAdjustGeneral',
actorUserId: 'user-7',
payload: {
type: 'tournamentAdjustGeneral',
requestId: atomicBetId,
userId: 'user-7',
generalId: 7,
reason: 'tournamentBet',
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
},
},
{
requestId: atomicJoinId,
target: 'ENGINE',
eventType: 'tournamentAdjustGeneral',
actorUserId: 'user-7',
payload: {
type: 'tournamentAdjustGeneral',
requestId: atomicJoinId,
userId: 'user-7',
generalId: 7,
reason: 'tournamentJoin',
goldDelta: -200,
minGoldAfter: 0,
},
},
],
});
const queue = new DatabaseTurnDaemonCommandQueue(db); const queue = new DatabaseTurnDaemonCommandQueue(db);
await expect(queue.drain()).resolves.toEqual([ await expect(queue.drain()).resolves.toEqual([
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }), expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
@@ -688,7 +735,15 @@ integration('database command queue', () => {
requestId: rollbackId, requestId: rollbackId,
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
}), }),
expect.objectContaining({
type: 'tournamentAdjustGeneral',
requestId: atomicBetId,
reason: 'tournamentBet',
}),
]); ]);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: atomicJoinId } })).resolves.toMatchObject({
status: 'PENDING',
});
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({ await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
status: 'PROCESSING', status: 'PROCESSING',
processingGameTick: 321n, processingGameTick: 321n,
@@ -79,6 +79,83 @@ const buildWorld = (
return new InMemoryTurnWorld(state, snapshot, { schedule }); return new InMemoryTurnWorld(state, snapshot, { schedule });
}; };
describe('tournament mutation ownership', () => {
it('checks the current owner and commits gold plus bet statistics together', async () => {
const general = { ...buildGeneral(1), userId: 'owner' };
const world = buildWorld([general]);
const handler = createTurnDaemonCommandHandler({ world });
const command = {
type: 'tournamentAdjustGeneral' as const,
reason: 'tournamentBet' as const,
userId: 'owner',
generalId: 1,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
};
await expect(handler.handle(command)).resolves.toEqual({ type: command.type, ok: true, generalId: 1 });
expect(world.getGeneralById(1)).toMatchObject({ gold: 900, meta: { betgold: 100 } });
const before = structuredClone(world.getGeneralById(1));
await expect(handler.handle({ ...command, goldDelta: -500 })).resolves.toMatchObject({
ok: false,
code: 'BAD_REQUEST',
});
expect(world.getGeneralById(1)).toEqual(before);
world.updateGeneral(1, { userId: 'new-owner' });
const transferred = structuredClone(world.getGeneralById(1));
for (const goldDelta of [-100, 100]) {
await expect(handler.handle({ ...command, goldDelta })).resolves.toMatchObject({
ok: false,
code: 'FORBIDDEN',
});
expect(world.getGeneralById(1)).toEqual(transferred);
}
});
it('skips possessed NPCs and commits only eligible NPC fees and statistics', async () => {
const world = buildWorld([
{ ...buildGeneral(1, {}, 1), userId: 'new-owner' },
buildGeneral(2, {}, 2),
{ ...buildGeneral(3, {}, 2), gold: 509 },
]);
const handler = createTurnDaemonCommandHandler({ world });
const possessed = structuredClone(world.getGeneralById(1));
await expect(
handler.handle({
type: 'tournamentSeedNpcBets',
bets: [1, 2, 3].map((generalId) => ({ generalId, amount: 10 })),
})
).resolves.toEqual({
type: 'tournamentSeedNpcBets',
ok: true,
processedGeneralIds: [2],
skippedGeneralIds: [1, 3],
});
expect(world.getGeneralById(1)).toEqual(possessed);
expect(world.getGeneralById(2)).toMatchObject({ gold: 990, meta: { betgold: 10 } });
expect(world.getGeneralById(3)).toMatchObject({ gold: 509, meta: { killturn: 24 } });
});
it.each(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'])(
'rejects actorless legacy %s commands before mutation',
async (reason) => {
const world = buildWorld([{ ...buildGeneral(1), userId: 'new-owner' }]);
const before = structuredClone(world.getGeneralById(1));
const handler = createTurnDaemonCommandHandler({ world });
for (const command of [
{ type: 'adjustGeneralResources' as const, reason, adjustments: [{ generalId: 1, goldDelta: -100 }] },
{
type: 'adjustGeneralMeta' as const,
reason,
adjustments: [{ generalId: 1, metaDelta: { betgold: 100 } }],
},
])
await expect(handler.handle(command)).resolves.toMatchObject({ ok: false });
expect(world.getGeneralById(1)).toEqual(before);
}
);
});
describe('tournament world commands', () => { describe('tournament world commands', () => {
it('uses the current world develcost for Ref-compatible tournament prizes', () => { it('uses the current world develcost for Ref-compatible tournament prizes', () => {
const currentWorld = buildWorld([], { const currentWorld = buildWorld([], {
@@ -187,7 +264,7 @@ describe('tournament world commands', () => {
}); });
it('records all four tournament types and NPC betting for at least ten generals', async () => { it('records all four tournament types and NPC betting for at least ten generals', async () => {
const generals = Array.from({ length: 12 }, (_, index) => buildGeneral(index + 1)); const generals = Array.from({ length: 12 }, (_, index) => ({ ...buildGeneral(index + 1, {}, 2), gold: 2_000 }));
const world = buildWorld(generals); const world = buildWorld(generals);
const handler = createTurnDaemonCommandHandler({ world }); const handler = createTurnDaemonCommandHandler({ world });
@@ -204,13 +281,11 @@ describe('tournament world commands', () => {
} }
} }
await handler.handle({ await handler.handle({
type: 'adjustGeneralMeta', type: 'tournamentSeedNpcBets',
reason: 'tournamentNpcBet', bets: generals.map((general) => ({ generalId: general.id, amount: 1_000 })),
adjustments: generals.map((general) => ({
generalId: general.id,
metaDelta: { betgold: 1_000 },
})),
}); });
// Ref의 betwin 통계 대상은 npcState <= 1이다. 개방 시 NPC 비용 검증과 분리한다.
for (const general of generals) world.updateGeneral(general.id, { npcState: 0 });
await handler.handle({ await handler.handle({
type: 'tournamentBettingPayout', type: 'tournamentBettingPayout',
bettingId: 1, bettingId: 1,
+3 -1
View File
@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common'; import { parseBooleanWithFallback, parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
import { resolveFrontendServeMode, type FrontendServeMode } from './orchestrator/frontendArtifactManager.js'; import { resolveFrontendServeMode, type FrontendServeMode } from './orchestrator/frontendArtifactManager.js';
export interface GatewayApiConfig { export interface GatewayApiConfig {
@@ -19,6 +19,7 @@ export interface GatewayApiConfig {
kakaoAdminKey?: string; kakaoAdminKey?: string;
kakaoRedirectUri: string; kakaoRedirectUri: string;
publicBaseUrl: string; publicBaseUrl: string;
corsAllowedOrigins: string[];
userIconDir: string; userIconDir: string;
userIconPublicUrl: string; userIconPublicUrl: string;
imageUploadBaseUrl: string; imageUploadBaseUrl: string;
@@ -129,6 +130,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
kakaoAdminKey: env.KAKAO_ADMIN_KEY, kakaoAdminKey: env.KAKAO_ADMIN_KEY,
kakaoRedirectUri, kakaoRedirectUri,
publicBaseUrl, publicBaseUrl,
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [publicBaseUrl]),
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons', userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? 'https://sam-image.hided.net/icons', userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? 'https://sam-image.hided.net/icons',
imageUploadBaseUrl: env.GATEWAY_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net', imageUploadBaseUrl: env.GATEWAY_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net',
+3 -2
View File
@@ -1,4 +1,4 @@
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common'; import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
import fastify, { type FastifyRequest } from 'fastify'; import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
@@ -125,8 +125,9 @@ export const createGatewayApiServer = async () => {
(error) => app.log.error({ err: error }, 'web push delivery failed') (error) => app.log.error({ err: error }, 'web push delivery failed')
); );
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
await app.register(cors, { await app.register(cors, {
origin: true, origin: config.corsAllowedOrigins,
credentials: true, credentials: true,
}); });
await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true }); await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true });
@@ -1,10 +1,11 @@
import { z } from 'zod'; import { z } from 'zod';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
import fastify, { type FastifyRequest } from 'fastify'; import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common'; import { trpcJsonBodyHttpServerOptions, createApiOriginGuard, resolveApiAllowedOrigins } from '@sammo-ts/common';
import type { GatewayPrismaClient } from '@sammo-ts/infra'; import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
@@ -93,6 +94,9 @@ const createHarness = async (adminRoles = ['user', 'admin.users.manage', 'admin.
const targetSession = await sessions.createSession(target); const targetSession = await sessions.createSession(target);
const flushes: Array<{ userId: string; reason?: string }> = []; const flushes: Array<{ userId: string; reason?: string }> = [];
const app = fastify({ logger: false }); const app = fastify({ logger: false });
const origins = resolveApiAllowedOrigins(undefined, ['https://sam.hided.net/gateway/']);
app.addHook('onRequest', createApiOriginGuard(origins));
await app.register(cors, { origin: origins, credentials: true });
await app.register(fastifyTRPCPlugin, { await app.register(fastifyTRPCPlugin, {
prefix: '/trpc', prefix: '/trpc',
trpcOptions: { trpcOptions: {
@@ -176,6 +180,29 @@ const postTrpc = async (
}; };
describe('admin security over HTTP transport', () => { describe('admin security over HTTP transport', () => {
it('rejects an untrusted browser origin before a valid session can log out', async () => {
const harness = await createHarness();
for (const origin of ['https://untrusted.example', 'null']) {
const response = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-session-token': harness.adminSessionToken, origin },
body: JSON.stringify({ sessionToken: harness.adminSessionToken }),
});
expect(response.status).toBe(403);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
expect((await postTrpc(harness.baseUrl, 'me', null, harness.adminSessionToken)).response.status).toBe(200);
}
const allowed = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
method: 'OPTIONS',
headers: {
origin: 'https://sam.hided.net',
'access-control-request-method': 'POST',
'access-control-request-headers': 'x-session-token,content-type',
},
});
expect(allowed.status).toBe(204);
expect(allowed.headers.get('access-control-allow-origin')).toBe('https://sam.hided.net');
});
it('protects runtime diagnostics at the HTTP authentication and profile scope boundaries', async () => { it('protects runtime diagnostics at the HTTP authentication and profile scope boundaries', async () => {
const harness = await createHarness(['admin.profiles.runtime:che:default']); const harness = await createHarness(['admin.profiles.runtime:che:default']);
const input = { profileName: 'che:default' }; const input = { profileName: 'che:default' };
+20 -2
View File
@@ -4,17 +4,18 @@
| 환경 | 공개 주소·prefix | 접속·연결 계약 | | 환경 | 공개 주소·prefix | 접속·연결 계약 |
| ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- | | ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Ref | `dev-sam-ref.hided.net` | 개발 호스트 `172.30.1.54:3400`의 PHP 기준 구현입니다. | | Ref | `dev-sam-ref.hided.net` | 공개 서비스 종료(410). 작업공간의 loopback 비교 Ref와 별개입니다. |
| 로컬 E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → 개발 호스트 `172.30.1.54:14999` HTTP → Docker Caddy입니다. | | 로컬 E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → 개발 호스트 `172.30.1.54:14999` HTTP → Docker Caddy입니다. |
| 공개 개발 | `dev-sam2026.hided.net` | `ssh serv`의 `core2026-dev-sam2026`/`hidche_ng_my`입니다. 로컬 E2E `14999`와 다른 호스트입니다. | | 공개 개발 | `dev-sam2026.hided.net` | `ssh serv`의 `core2026-dev-sam2026`/`hidche_ng_my`입니다. 로컬 E2E `14999`와 다른 호스트입니다. |
| sam Core 운영 | `sam.hided.net/gateway/`와 일곱 profile prefix | `ssh serv`의 `core2026-sam-production`/`hidche_core2026_my`입니다. | | sam Core 운영 | `sam.hided.net/gateway/`와 일곱 profile prefix | `ssh serv`의 `core2026-sam-production`/`hidche_core2026_my`입니다. |
| sam PHP 운영 | `sam.hided.net/sam/`과 기존 PHP 경로 | `ssh serv`의 별도 `sam_hided_net` project입니다. | | sam PHP 게임 | `sam.hided.net/sam/` | 2026-10-10 종료(410). `sam_hided_net`의 게시판·위키·DB는 유지합니다. |
`dev-sam2026.hided.net`과 `sam.hided.net` Core prefix는 같은 Git 구현을 사용할 수 `dev-sam2026.hided.net`과 `sam.hided.net` Core prefix는 같은 Git 구현을 사용할 수
있지만 PostgreSQL, Redis, named volume, release queue와 active commit이 분리된 있지만 PostgreSQL, Redis, named volume, release queue와 active commit이 분리된
배포 환경입니다. 한 환경의 release/API/Chromium 결과를 다른 환경의 반영 근거로 배포 환경입니다. 한 환경의 release/API/Chromium 결과를 다른 환경의 반영 근거로
사용하지 않습니다. 상위 `sam_rebuild` 작업공간에서는 사용하지 않습니다. 상위 `sam_rebuild` 작업공간에서는
`docs/docker-environment-routing.md`의 전체 결정 절차도 함께 따릅니다. `docs/docker-environment-routing.md`의 전체 결정 절차도 함께 따릅니다.
현재 기기가 운영 host `serv-2204`이면 SSH 없이 해당 stack 경로에서 작업합니다.
외부 Caddy는 E2E 호스트의 모든 경로를 `172.30.1.54:14999`로 전달하고 원래 외부 Caddy는 E2E 호스트의 모든 경로를 `172.30.1.54:14999`로 전달하고 원래
`Host` header와 path prefix를 보존합니다. `handle_path`처럼 prefix를 제거하는 `Host` header와 path prefix를 보존합니다. `handle_path`처럼 prefix를 제거하는
@@ -81,6 +82,23 @@ GAME_TRPC_PATH=/hwe/api/trpc
GAME_API_EVENTS_PATH=/hwe/api/events GAME_API_EVENTS_PATH=/hwe/api/events
``` ```
## API의 browser Origin 경계
Gateway는 `GATEWAY_PUBLIC_URL`, game API는 `GATEWAY_PUBLIC_URL`과
`GAME_PUBLIC_URL`의 origin을 기본으로 허용합니다. 공개 URL의 `/gateway/`, `/che/`
같은 path는 origin에 포함하지 않습니다. 추가 frontend host가 필요하면 runtime의
`API_ALLOWED_ORIGINS`에 정확한 origin을 쉼표로 지정합니다. 예:
`http://localhost:14997,http://127.0.0.1:14997`. scheme·host·port 모두 일치해야 하며
wildcard·`null`·URL credential·path가 있는 추가 항목은 거부합니다.
허용하지 않은 `Origin`은 body parsing과 mutation 이전에 403으로 거부합니다.
허용 목록이 비어 있으면 Origin이 있는 요청은 모두 거부하므로 standalone game
개발 서버도 공개 URL 또는 명시 목록을 설정해야 합니다. Origin 없는 내부 client는
기존 session/Bearer/HMAC 인증을 통과해야 합니다. Origin 허용은 권한을 부여하지
않으며 비브라우저 client가 header를 조작하는 공격은 인증·resource ACL이 막습니다.
PM2 managed API는 runtime base 환경을 상속하므로 설정 변경 뒤 해당 API process의
환경까지 갱신합니다. 공개 운영·개발 목록을 서로 복사하지 않습니다.
## Frontend build ## Frontend build
Gateway: Gateway:
+48
View File
@@ -0,0 +1,48 @@
# 토너먼트의 사용자·장수 권한과 운영 전환
참가와 베팅의 actor는 인증된 사용자이며 장수 ID는 actor의 현재 소유 자원입니다.
API 조회와 ENGINE 실행 사이에 소유권이 바뀔 수 있으므로
`tournamentAdjustGeneral`은 `userId`와 `generalId`를 함께 전달하고 durable
`InputEvent.actorUserId` 및 실행 시점 world의 소유자를 다시 검사합니다. 다른
소유자의 금·통계는 차감과 보상 모두 변경하지 않습니다.
참가비는 현재 개발비, 베팅은 최소 10금·사용자별 합계 1000금·차감 후 500금
이상을 유지합니다. 금과 `betgold`는 한 ENGINE event에서 갱신합니다. 베팅은
기존처럼 SUSPENDED의 동결된 게임 시각에도 허용하고 참가 명령은 허용하지
않습니다. RECONCILING에서는 베팅 금융 명령을 새로 claim하지 않습니다.
Redis 베팅 row의 `userId`는 서버가 인증 session에서 기록합니다. 사용자별
한도와 개인 합계는 장수 교체 후에도 이 ID로 검사합니다. `generalId`·`targetId`가
같아도 사용자가 다르면 row를 합치지 않습니다. NPC row는 `userId: null`, 구형
row는 field 생략으로 구분합니다. 당첨금·취소 환급의 장수 ID 기준 자산 정산은
Ref의 기존 계약을 유지합니다. 당시 사용자의 개인 표시와 장수의 자산 귀속은
동일한 개념이 아닙니다.
NPC worker는 deterministic 계획을 먼저 저장하고 `tournamentSeedNpcBets`의
durable 결과를 기다립니다. ENGINE은 유저가 소유하게 된 NPC를 건너뛰고 남은
NPC의 금·통계를 함께 처리합니다. worker는 실제 처리된 ID만 베팅 projection에
기록한 뒤 stage 6을 공개합니다. 응답이 불확실하면 같은 request ID와 계획으로
재시도하며 NPC를 새로 선택하지 않습니다. 계획/RNG 생성 순서는 유지합니다.
## 구형 설치에서 전환
API·ENGINE·tournament worker를 같은 변경 세대로 전환해야 합니다. 새 API만
배포하면 구형 ENGINE은 새 명령을 처리할 수 없고, 구형 API만 남기면 새 ENGINE은
actor 없는 구형 토너먼트 `adjustGeneralResources/Meta` 명령을 거부합니다.
종료된 베팅과 정상 완료된 큐를 확인한 뒤 전환하는 것을 기본으로 합니다.
현재 owner로 구형 row의 당시 사용자를 추정하지 않습니다. 사용자 ID가 없는 row는
전체 풀·기존 정산에 유지하지만 개인 합계에 표시하지 않으며, 해당 베팅에 신규
베팅을 허용하지 않습니다. 활성 베팅을 유지해야 한다면 관리자가 당시 사용자
정보를 확인한 row만 `setBettingEntries`로 복원하고 NPC에는 null을 지정합니다.
근거가 없는 row를 현재 owner로 자동 backfill하거나 큐/베팅을 임의 삭제하지 않습니다.
이전 버전의 NPC 비용/통계 두 명령이 일부만 실행된 상태라면 동일 계획의 새
atomic 명령을 바로 재실행하지 않습니다. 기존 InputEvent 결과와 자원·통계를
대조하여 완료 여부를 먼저 복구해야 합니다. 이 변경은 자동 saga migration을
제공하지 않습니다.
ENGINE transaction과 Redis projection은 별도 저장소입니다. projection 쓰기 실패
뒤 compensation을 시도하지만 그 사이 소유자가 바뀌면 다른 owner의 자원을
수정하지 않도록 거부합니다. 전체 PostgreSQL/Redis crash recovery와 자동
reconciliation을 보장하지 않으며 기존 saga의 한계는 유지됩니다.
+56
View File
@@ -0,0 +1,56 @@
const MAX_ORIGINS = 32;
const MAX_ORIGIN_LENGTH = 2048;
const parsePublicUrl = (value: string): URL => {
if (value.length > MAX_ORIGIN_LENGTH) throw new Error('API origin configuration is too long.');
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('API origins require a public HTTP(S) URL without credentials.');
}
return url;
};
/** Public application URLs supply the default origins; extra origins must be explicit. */
export const resolveApiAllowedOrigins = (
extraOrigins: string | undefined,
publicUrls: readonly (string | undefined)[]
): string[] => {
const origins = new Set<string>();
for (const value of publicUrls) {
if (value?.trim()) origins.add(parsePublicUrl(value.trim()).origin);
}
if (extraOrigins?.trim()) {
const entries = extraOrigins.split(',');
if (entries.length > MAX_ORIGINS) throw new Error('Too many API allowed origins.');
for (const value of entries) {
const url = parsePublicUrl(value.trim());
if (url.pathname !== '/' || url.search || url.hash) {
throw new Error('API_ALLOWED_ORIGINS entries must be origins, without paths or queries.');
}
origins.add(url.origin);
}
}
if (origins.size > MAX_ORIGINS) throw new Error('Too many API allowed origins.');
return [...origins];
};
class ApiOriginForbiddenError extends Error {
readonly statusCode = 403;
constructor() {
super('Request origin is not allowed.');
}
}
/** Reject browser requests before parsing bodies or dispatching mutations, not just CORS responses. */
export const createApiOriginGuard = (allowedOrigins: readonly string[]) => {
const allowed = new Set(allowedOrigins);
return async (request: { headers: { origin?: string | string[] } }): Promise<void> => {
const origin = request.headers.origin;
// Internal machine clients have no Origin and still require their ordinary auth/HMAC.
if (origin === undefined) return;
if (typeof origin !== 'string' || origin.length > MAX_ORIGIN_LENGTH || !allowed.has(origin)) {
throw new ApiOriginForbiddenError();
}
};
};
+1
View File
@@ -33,6 +33,7 @@ export * from './game/accessPenalty.js';
export * from './http/trpcTransport.js'; export * from './http/trpcTransport.js';
export * from './http/safeLogger.js'; export * from './http/safeLogger.js';
export * from './http/errorPrivacy.js'; export * from './http/errorPrivacy.js';
export * from './http/apiOrigin.js';
export * from './webPush/types.js'; export * from './webPush/types.js';
export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js'; export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js';
+37
View File
@@ -307,6 +307,21 @@ export type TurnDaemonCommand =
| { kind: 'nationPriority'; priority: string[] } | { kind: 'nationPriority'; priority: string[] }
| { kind: 'generalPriority'; priority: string[] }; | { kind: 'generalPriority'; priority: string[] };
} }
| {
type: 'tournamentAdjustGeneral';
requestId?: string;
reason: 'tournamentJoin' | 'tournamentJoinRollback' | 'tournamentBet' | 'tournamentBetRollback';
userId: string;
generalId: number;
goldDelta: number;
betGoldDelta?: number;
minGoldAfter: number;
}
| {
type: 'tournamentSeedNpcBets';
requestId?: string;
bets: Array<{ generalId: number; amount: number }>;
}
| { | {
type: 'adjustGeneralResources'; type: 'adjustGeneralResources';
requestId?: string; requestId?: string;
@@ -701,6 +716,28 @@ export type TurnDaemonCommandResult =
nationId?: number; nationId?: number;
currentUpdatedAt?: string | null; currentUpdatedAt?: string | null;
} }
| {
type: 'tournamentAdjustGeneral';
ok: true;
generalId: number;
}
| {
type: 'tournamentAdjustGeneral';
ok: false;
code: 'FORBIDDEN' | 'BAD_REQUEST';
reason: string;
}
| {
type: 'tournamentSeedNpcBets';
ok: true;
processedGeneralIds: number[];
skippedGeneralIds: number[];
}
| {
type: 'tournamentSeedNpcBets';
ok: false;
reason: string;
}
| { | {
type: 'adjustGeneralResources'; type: 'adjustGeneralResources';
ok: true; ok: true;
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { createApiOriginGuard, resolveApiAllowedOrigins } from '../src/http/apiOrigin.js';
describe('public API origin policy', () => {
it('derives exact origins from application URLs and explicit extra origins', async () => {
const origins = resolveApiAllowedOrigins('http://localhost:5173,https://sam.hided.net', [
'https://sam.hided.net/gateway/',
'https://sam.hided.net/che/',
]);
expect(origins).toEqual(['https://sam.hided.net', 'http://localhost:5173']);
const guard = createApiOriginGuard(origins);
await expect(guard({ headers: {} })).resolves.toBeUndefined();
for (const origin of origins) await expect(guard({ headers: { origin } })).resolves.toBeUndefined();
for (const origin of [
'https://untrusted.example',
'https://sam.hided.net.untrusted.example',
'http://sam.hided.net',
'https://sam.hided.net:8443',
'null',
'',
'https://sam.hided.net,https://untrusted.example',
'x'.repeat(2049),
['https://sam.hided.net', 'https://untrusted.example'],
]) {
await expect(guard({ headers: { origin } })).rejects.toMatchObject({ statusCode: 403 });
}
});
it.each([
'*',
'null',
'https://user:password@example.com',
'file:///tmp/example',
'https://x.test/path',
'https://x.test?query',
'https://x.test#fragment',
'https://x.test,',
])('rejects unsafe configuration %s', (value) => {
expect(() => resolveApiAllowedOrigins(value, [])).toThrow();
});
it('bounds configuration and fails closed when no public origin is configured', async () => {
expect(() =>
resolveApiAllowedOrigins(Array.from({ length: 33 }, (_, i) => `https://${i}.test`).join(','), [])
).toThrow();
expect(() => resolveApiAllowedOrigins('https://' + 'a'.repeat(2048) + '.test', [])).toThrow();
await expect(
createApiOriginGuard([])({ headers: { origin: 'https://untrusted.example' } })
).rejects.toMatchObject({ statusCode: 403 });
});
});