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

This commit is contained in:
Hide_D committed 2026-10-10 01:56:31 +09:00
1 parent 5771701007
commit 4e668b9fb6
31 files changed
+889 -238

No files matched your search

+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 parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
@@ -10,6 +10,7 @@ const parseReconcileInterval = (value: string | undefined): number => {
export interface GameApiConfig {
host: string;
corsAllowedOrigins: string[];
port: number;
trpcPath: string;
eventsPath: string;
@@ -48,6 +49,10 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
return {
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'),
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
+55 -70
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
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 { TournamentState } from '../../tournament/types.js';
@@ -18,6 +18,16 @@ import {
} from '../../services/redisClockFence.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 => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
return true;
@@ -197,6 +207,7 @@ const zMatch = z.object({
const zBetEntry = z.object({
generalId: z.number().int().positive(),
userId: z.string().min(1).nullable().optional(),
targetId: z.number().int().positive(),
amount: z.number().int().positive(),
});
@@ -458,7 +469,7 @@ export const tournamentRouter = router({
});
}),
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
const [state, entries, matches] = await Promise.all([
store.getState(),
@@ -488,7 +499,7 @@ export const tournamentRouter = router({
}
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + 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;
myAmount += entry.amount;
}
@@ -523,20 +534,15 @@ export const tournamentRouter = router({
const develCost = resolveCurrentDevelCost(worldState);
const feeResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
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') {
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,
});
}
assertTournamentAdjustment(feeResult);
const meta = asRecord(general.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
@@ -558,12 +564,17 @@ export const tournamentRouter = router({
try {
await store.setParticipants(next);
} catch (error) {
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }],
});
assertTournamentAdjustment(
await ctx.turnDaemon.requestCommand({
type: 'tournamentAdjustGeneral',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentJoinRollback',
userId: ctx.auth!.user.id,
generalId: general.id,
goldDelta: develCost,
minGoldAfter: 0,
})
);
throw error;
}
return { ok: true, count: next.length };
@@ -663,8 +674,13 @@ export const tournamentRouter = router({
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
.filter((entry) => entry.generalId === general.id)
.filter((entry) => entry.userId === ctx.auth!.user.id)
.reduce((sum, entry) => sum + entry.amount, 0);
if (previousBetAmount + input.amount > 1_000) {
throw new TRPCError({
@@ -674,68 +690,37 @@ export const tournamentRouter = router({
}
const adjustResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
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') {
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: '베팅 기록을 저장하지 못했습니다.' });
}
assertTournamentAdjustment(adjustResult);
try {
await store.appendBettingEntry({
generalId: general.id,
userId: ctx.auth!.user.id,
targetId: input.targetId,
amount: input.amount,
});
} catch (error) {
await Promise.all([
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
assertTournamentAdjustment(
await ctx.turnDaemon.requestCommand({
type: 'tournamentAdjustGeneral',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}),
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
reason: 'tournamentBetRollback',
adjustments: [
{
generalId: general.id,
metaDelta: { betgold: -input.amount },
},
],
}),
]);
userId: ctx.auth!.user.id,
generalId: general.id,
goldDelta: input.amount,
betGoldDelta: -input.amount,
minGoldAfter: 0,
})
);
throw error;
}
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 cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
@@ -76,13 +76,14 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
const resolveAuthFromToken = async (
token: string | null,
accessTokenStore: RedisAccessTokenStore,
flushStore: FlushStore
flushStore: FlushStore,
profileName: string
): Promise<GameSessionTokenPayload | null> => {
if (!token) {
return null;
}
const stored = await accessTokenStore.get(token);
if (!stored) {
if (!stored || stored.profile !== profileName) {
return null;
}
let flushedAt: Date | null;
@@ -273,8 +274,9 @@ export const createGameApiServer = async () => {
app.addHook('onClose', closeResources);
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
await app.register(cors, {
origin: true,
origin: config.corsAllowedOrigins,
credentials: true,
});
@@ -290,7 +292,7 @@ export const createGameApiServer = async () => {
...trpcJsonBodyHttpServerOptions,
createContext: async ({ req }: { req: FastifyRequest }) => {
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'])
? req.headers['idempotency-key'][0]
: req.headers['idempotency-key'];
@@ -334,7 +336,7 @@ export const createGameApiServer = async () => {
});
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 (
!auth ||
auth.profile !== config.profileName ||
+5 -1
View File
@@ -31,6 +31,7 @@ export class CorruptTournamentProjectionError extends Error {
const zTournamentBet = z
.object({
generalId: z.number().int(),
userId: z.string().min(1).nullable().optional(),
targetId: z.number().int(),
amount: z.number(),
})
@@ -286,7 +287,10 @@ export class TournamentStore {
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
const entries = await this.getBettingEntries();
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) {
existing.amount += entry.amount;
+2
View File
@@ -74,6 +74,8 @@ export interface TournamentMatchEntry {
export interface TournamentBetEntry {
generalId: number;
// 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다.
userId?: string | null;
targetId: number;
amount: number;
}
+18 -13
View File
@@ -760,7 +760,7 @@ const buildNpcBettingPlan = async (options: {
for (const npc of npcBetList) {
const targetId = rng.choice(candidateIds);
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;
@@ -789,19 +789,24 @@ export const seedNpcBets = async (options: {
}
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
await daemonTransport.sendCommand({
type: 'adjustGeneralResources',
requestId: `${requestPrefix}:resources`,
reason: 'tournamentNpcBet',
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })),
});
await daemonTransport.sendCommand({
type: 'adjustGeneralMeta',
requestId: `${requestPrefix}:meta`,
reason: 'tournamentNpcBet',
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
// Wait for one durable ENGINE result before publishing entries or opening stage 6.
// The engine skips NPCs whose owner changed after this deterministic plan was stored.
const result = await daemonTransport.requestCommand({
type: 'tournamentSeedNpcBets',
requestId: `${requestPrefix}:atomic`,
bets: plan.map((entry) => ({ generalId: entry.generalId, amount: 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 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 requireAuthMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.auth) {
if (!ctx.auth || ctx.auth.profile !== ctx.profile.name) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',