diff --git a/.env.example b/.env.example index 9806b56e..5e4917d4 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,11 @@ GATEWAY_INTERNAL_API_URL=http://127.0.0.1:13000 ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000 WEB_PUSH_OUTBOX_POLL_MS=1000 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. # 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. diff --git a/app/game-api/src/config.ts b/app/game-api/src/config.ts index c9125420..fdc77f05 100644 --- a/app/game-api/src/config.ts +++ b/app/game-api/src/config.ts @@ -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', diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 12216a3d..b91bace7 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -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 }; diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 81854b36..11505a11 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -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 => { 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 => { - const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore); + const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName); if ( !auth || auth.profile !== config.profileName || diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts index 277c0d64..14ea0f72 100644 --- a/app/game-api/src/tournament/store.ts +++ b/app/game-api/src/tournament/store.ts @@ -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 { 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; diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index f464b24b..db5e6237 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -74,6 +74,8 @@ export interface TournamentMatchEntry { export interface TournamentBetEntry { generalId: number; + // 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다. + userId?: string | null; targetId: number; amount: number; } diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index 6e1ff7ee..22b7800e 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -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 })) + ) + ); }; diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index d479cbea..e65482d1 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -24,7 +24,7 @@ import { recordGeneralActivity } from './services/generalActivity.js'; const t = initTRPC.context().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', diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index 2d8f4dd5..21ab048d 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -387,7 +387,7 @@ describe('general access tracking', () => { db, generalAccessTracking: true, requestId: 'access-boundary-test', - profile: { id: 'che:default', name: 'che' }, + profile: { id: 'che', name: 'che:default', scenario: 'default' }, profileStatusSource, } as unknown as GameApiContext; diff --git a/app/game-api/test/generalActivityTracking.test.ts b/app/game-api/test/generalActivityTracking.test.ts index 2cb5dfc8..b78218e6 100644 --- a/app/game-api/test/generalActivityTracking.test.ts +++ b/app/game-api/test/generalActivityTracking.test.ts @@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token = auth: token, db: { $executeRaw: executeRaw }, generalAccessTracking: true, - profile: { id: 'che:default', name: 'che', scenario: 'default' }, + profile: { id: 'che', name: 'che:default', scenario: 'default' }, }) as unknown as GameApiContext; const activityRouter = router({ diff --git a/app/game-api/test/mainFrontStatusRouter.test.ts b/app/game-api/test/mainFrontStatusRouter.test.ts index 4a722d20..85863c79 100644 --- a/app/game-api/test/mainFrontStatusRouter.test.ts +++ b/app/game-api/test/mainFrontStatusRouter.test.ts @@ -31,6 +31,7 @@ const buildContext = ( ) => ({ auth: options.auth === undefined ? auth : options.auth, + profile: { id: 'che', name: 'che:default', scenario: 'default' }, db: { general: { findFirst: vi.fn(async () => ({ diff --git a/app/game-api/test/mainRecordsRouter.test.ts b/app/game-api/test/mainRecordsRouter.test.ts index ad6f28c1..8e235ae0 100644 --- a/app/game-api/test/mainRecordsRouter.test.ts +++ b/app/game-api/test/mainRecordsRouter.test.ts @@ -36,6 +36,7 @@ type LogQuery = { const buildContext = (findMany: (query: LogQuery) => Promise>) => ({ auth, + profile: { id: 'che', name: 'che:default', scenario: 'default' }, db: { general: { findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({ diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts index 56727a58..fac43e6c 100644 --- a/app/game-api/test/rankingRouter.test.ts +++ b/app/game-api/test/rankingRouter.test.ts @@ -20,7 +20,7 @@ const profile: GameProfile = { const auth: GameSessionTokenPayload = { version: 1, - profile: 'che', + profile: 'che:default', issuedAt: '2026-07-26T00:00:00.000Z', expiresAt: '2026-07-27T00:00:00.000Z', sessionId: 'ranking-session', @@ -239,7 +239,7 @@ const buildContext = (options?: { turnDaemon: new InMemoryTurnDaemonTransport(), battleSim: new InMemoryBattleSimTransport(), profile: selectedProfile, - auth: options?.authenticated === false ? null : auth, + auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name }, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 5e117fee..e43ca296 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -70,6 +70,7 @@ const envKeys = [ 'GATEWAY_REDIS_PREFIX', 'GATEWAY_INTERNAL_API_URL', 'GAME_UPLOAD_DIR', + 'API_ALLOWED_ORIGINS', ] as const; 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_INTERNAL_API_URL = await listenGatewayStatusStub(); process.env.GAME_UPLOAD_DIR = uploadDir; + process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net'; const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl }); await connector.connect(); @@ -3774,6 +3776,58 @@ integration('game API security over HTTP transport', () => { } }, 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 () => { const token = await createAccessToken('sse-query', {}); const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`); diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index 75932a7a..40c89105 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport { async requestCommand(command: TurnDaemonCommand): Promise { this.commands.push(command); - if (command.type === 'adjustGeneralResources') { - const adjustment = command.adjustments[0]; - if (!adjustment) { - return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' }; + if (command.type === 'tournamentAdjustGeneral') { + if (command.betGoldDelta !== undefined && this.failNextRankUpdate) { + this.failNextRankUpdate = false; + return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' }; } - const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0); - if (nextGold < (adjustment.minGoldAfter ?? 0)) { - return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' }; + const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta; + if (nextGold < command.minGoldAfter) { + return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' }; } - this.gold.set(adjustment.generalId, nextGold); - return { - type: 'adjustGeneralResources', - ok: true, - processed: 1, - missing: 0, - totalGoldDelta: adjustment.goldDelta ?? 0, - totalRiceDelta: 0, - }; + this.gold.set(command.generalId, nextGold); + return { type: command.type, ok: true, generalId: command.generalId }; } if (command.type === 'setMySetting') { 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; } @@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => { await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 }); 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.objectContaining({ - type: 'adjustGeneralResources', + type: 'tournamentAdjustGeneral', requestId: 'http:tournament-join:tournamentJoin:resources', - reason: 'tournamentJoin', + userId: 'user-1', }) ); 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 }); expect(transport.gold.get(general.id)).toBe(1_936); expect(transport.commands).toContainEqual({ - type: 'adjustGeneralResources', + type: 'tournamentAdjustGeneral', + userId: 'user-1', 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(transport.commands).toContainEqual( expect.objectContaining({ - type: 'adjustGeneralResources', + type: 'tournamentAdjustGeneral', requestId: 'http:suspended-tournament-bet:tournamentBet:resources', - reason: 'tournamentBet', + userId: 'user-1', }) ); expect(outerApiTransaction).not.toHaveBeenCalled(); expect(transport.commands).toContainEqual( expect.objectContaining({ - type: 'adjustGeneralMeta', - requestId: 'http:suspended-tournament-bet:tournamentBet:rank', - reason: 'tournamentBet', + type: 'tournamentAdjustGeneral', + userId: 'user-1', + 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 () => { const redis = new MemoryRedis(); const transport = new TournamentTransport(); @@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => { 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 transport = new TournamentTransport(); 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({ - code: 'INTERNAL_SERVER_ERROR', + code: 'BAD_REQUEST', }); expect(transport.gold.get(general.id)).toBe(2_000); await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({ diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 9281c1e3..954809b7 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record => typeof va const createNoopDaemonTransport = (): TurnDaemonTransport => ({ 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, }); @@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => { const commands: TurnDaemonCommand[] = []; const daemonTransport: TurnDaemonTransport = { ...createNoopDaemonTransport(), - sendCommand: async (command) => { + requestCommand: async (command) => { expect((await store.getState())?.stage).toBe(5); commands.push(command); - return 'ok'; + return createNoopDaemonTransport().requestCommand(command); }, }; 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(commands).toEqual([ { - type: 'adjustGeneralResources', - requestId: 'tournament:123:npc-bet:resources', - reason: 'tournamentNpcBet', - 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 } })), + type: 'tournamentSeedNpcBets', + requestId: 'tournament:123:npc-bet:atomic', + bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })), }, ]); await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport }); 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. await store.setBettingEntries([bets[0]!]); await seedNpcBets({ @@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => { 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 () => { const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry')); const state = createTournamentState({ stage: 5 }); @@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => { 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 state = createTournamentState({ stage: 5, bettingId: 456 }); await store.setState(state); @@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => { let fail = true; const transport: TurnDaemonTransport = { ...createNoopDaemonTransport(), - sendCommand: async (command) => { + requestCommand: async (command) => { commands.push(command); - if (command.type === 'adjustGeneralMeta' && fail) { + if (command.type === 'tournamentSeedNpcBets' && fail) { fail = false; // The accepted debit may already have committed before retry. prisma.general.findMany = async () => []; throw new Error('enqueue unavailable'); } - return 'ok'; + return createNoopDaemonTransport().requestCommand(command); }, }; await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow( @@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => { ); expect(await store.getState()).toMatchObject({ 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([]); 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([ - 'tournament:456:npc-bet:resources', - 'tournament:456:npc-bet:meta', - 'tournament:456:npc-bet:resources', - 'tournament:456:npc-bet:meta', + 'tournament:456:npc-bet:atomic', + 'tournament:456:npc-bet:atomic', ]); expect(await store.getBettingEntries()).toHaveLength(1); }); @@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => { commands.push(command); return 'ok'; }, - requestCommand: async () => null, + requestCommand: async (command) => { + commands.push(command); + return createNoopDaemonTransport().requestCommand(command); + }, requestStatus: async () => null, }; @@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => { expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10); expect(commands).toContainEqual( expect.objectContaining({ - type: 'adjustGeneralMeta', - reason: 'tournamentNpcBet', - adjustments: expect.arrayContaining([ - expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }), - ]), + type: 'tournamentSeedNpcBets', + bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]), }) ); } diff --git a/app/game-engine/src/lifecycle/databaseCommandQueue.ts b/app/game-engine/src/lifecycle/databaseCommandQueue.ts index bdca37b3..1a71b603 100644 --- a/app/game-engine/src/lifecycle/databaseCommandQueue.ts +++ b/app/game-engine/src/lifecycle/databaseCommandQueue.ts @@ -120,7 +120,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T const claimCoordinate = await readInputEventClockCoordinate(transaction); const world = await transaction.worldState.findFirst({ 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이 별도로 지킨다. @@ -184,7 +190,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T OR (${postUnificationSurvey} AND "event_type" = 'voteReward') OR ( ${suspendedTournamentBetCommand} - AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta') + AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta', 'tournamentAdjustGeneral') AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback') ) OR ( diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 5a8ac8c0..1b286cd8 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -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({ type: z.literal('adjustGeneralResources'), reason: z.string().optional(), @@ -737,6 +752,16 @@ const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => { 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 command = parseWith(zAdjustGeneralResources, envelope.command); if (!command) { @@ -905,6 +930,8 @@ const normalizers: CommandNormalizerMap = { voteReward: normalizeVoteReward, setNationSetting: normalizeSetNationSetting, setNpcPolicy: normalizeSetNpcPolicy, + tournamentAdjustGeneral: normalizeTournamentAdjustGeneral, + tournamentSeedNpcBets: normalizeTournamentSeedNpcBets, adjustGeneralResources: normalizeAdjustGeneralResources, adjustGeneralMeta: normalizeAdjustGeneralMeta, tournamentMatchResult: normalizeTournamentMatchResult, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 9ccd880b..aaf64827 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -175,6 +175,7 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => { }; const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [ + 'tournamentAdjustGeneral', 'troopCreate', 'troopJoin', 'troopExit', @@ -627,11 +628,84 @@ async function handleSetNpcPolicy( return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt }); } +async function handleTournamentAdjustGeneral( + ctx: CommandHandlerContext, + command: Extract +): Promise { + 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 +): Promise { + 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( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; + if (isLegacyTournamentAdjustment(command.reason)) { + return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' }; + } if (!command.adjustments || command.adjustments.length === 0) { return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' }; } @@ -688,6 +762,9 @@ async function handleAdjustGeneralMeta( command: Extract ): Promise { const { world } = ctx; + if (isLegacyTournamentAdjustment(command.reason)) { + return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' }; + } if (!command.adjustments || command.adjustments.length === 0) { return { type: 'adjustGeneralMeta', @@ -3267,6 +3344,13 @@ export const createTurnDaemonCommandHandler = (options: { handleSetNationSetting(ctx, command as Extract), setNpcPolicy: (command) => handleSetNpcPolicy(ctx, command as Extract), + tournamentAdjustGeneral: (command) => + handleTournamentAdjustGeneral( + ctx, + command as Extract + ), + tournamentSeedNpcBets: (command) => + handleTournamentSeedNpcBets(ctx, command as Extract), adjustGeneralResources: (command) => handleAdjustGeneralResources( ctx, diff --git a/app/game-engine/test/authenticatedActorCommand.test.ts b/app/game-engine/test/authenticatedActorCommand.test.ts index e0b37da3..fdee7018 100644 --- a/app/game-engine/test/authenticatedActorCommand.test.ts +++ b/app/game-engine/test/authenticatedActorCommand.test.ts @@ -7,6 +7,16 @@ import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js'; import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; 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: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 }, { type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 }, diff --git a/app/game-engine/test/databaseCommandQueue.integration.test.ts b/app/game-engine/test/databaseCommandQueue.integration.test.ts index 95040467..0ab985ca 100644 --- a/app/game-engine/test/databaseCommandQueue.integration.test.ts +++ b/app/game-engine/test/databaseCommandQueue.integration.test.ts @@ -274,69 +274,79 @@ integration('database command queue', () => { }); }); - it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => { - const requestId = 'integration:engine:stale-owner-replay'; - const command: TurnDaemonCommand = { - type: 'vacation', - requestId, - userId: 'old-owner', - generalId: 7, - }; - await db.inputEvent.create({ - data: { - requestId, - target: 'ENGINE', - eventType: command.type, + it.each(['vacation', 'tournamentAdjustGeneral'] as const)( + 'stores a stale-owner %s rejection once and never redispatches it', + async (type) => { + const requestId = `integration:engine:stale-owner-replay:${type}`; + const command: TurnDaemonCommand = + type === 'vacation' + ? { type, requestId, userId: 'old-owner', generalId: 7 } + : { + type, + reason: 'tournamentBet', + requestId, + userId: 'old-owner', + 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, - 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, - 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(); - }); + 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'])( '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 rollbackId = 'integration:engine:suspended-tournament-bet-rollback'; 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({ 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); await expect(queue.drain()).resolves.toEqual([ expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }), @@ -688,7 +735,15 @@ integration('database command queue', () => { requestId: rollbackId, 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({ status: 'PROCESSING', processingGameTick: 321n, diff --git a/app/game-engine/test/tournamentCommands.test.ts b/app/game-engine/test/tournamentCommands.test.ts index 571cf129..057144e7 100644 --- a/app/game-engine/test/tournamentCommands.test.ts +++ b/app/game-engine/test/tournamentCommands.test.ts @@ -79,6 +79,83 @@ const buildWorld = ( 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', () => { it('uses the current world develcost for Ref-compatible tournament prizes', () => { 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 () => { - 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 handler = createTurnDaemonCommandHandler({ world }); @@ -204,13 +281,11 @@ describe('tournament world commands', () => { } } await handler.handle({ - type: 'adjustGeneralMeta', - reason: 'tournamentNpcBet', - adjustments: generals.map((general) => ({ - generalId: general.id, - metaDelta: { betgold: 1_000 }, - })), + type: 'tournamentSeedNpcBets', + bets: generals.map((general) => ({ generalId: general.id, amount: 1_000 })), }); + // Ref의 betwin 통계 대상은 npcState <= 1이다. 개방 시 NPC 비용 검증과 분리한다. + for (const general of generals) world.updateGeneral(general.id, { npcState: 0 }); await handler.handle({ type: 'tournamentBettingPayout', bettingId: 1, diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index 31f4356d..ff565812 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; 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'; export interface GatewayApiConfig { @@ -19,6 +19,7 @@ export interface GatewayApiConfig { kakaoAdminKey?: string; kakaoRedirectUri: string; publicBaseUrl: string; + corsAllowedOrigins: string[]; userIconDir: string; userIconPublicUrl: string; imageUploadBaseUrl: string; @@ -129,6 +130,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process. kakaoAdminKey: env.KAKAO_ADMIN_KEY, kakaoRedirectUri, publicBaseUrl, + corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [publicBaseUrl]), userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-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', diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 49b3dcd7..9fed5b50 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -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'; @@ -125,8 +125,9 @@ export const createGatewayApiServer = async () => { (error) => app.log.error({ err: error }, 'web push delivery failed') ); + app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins)); await app.register(cors, { - origin: true, + origin: config.corsAllowedOrigins, credentials: true, }); await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true }); diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts index 5ecd8c15..7bdcb9e6 100644 --- a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -1,10 +1,11 @@ import { z } from 'zod'; import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; import fastify, { type FastifyRequest } from 'fastify'; +import cors from '@fastify/cors'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; 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 { 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 flushes: Array<{ userId: string; reason?: string }> = []; 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, { prefix: '/trpc', trpcOptions: { @@ -176,6 +180,29 @@ const postTrpc = async ( }; 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 () => { const harness = await createHarness(['admin.profiles.runtime:che:default']); const input = { profileName: 'che:default' }; diff --git a/docs/e2e-caddy-routing.md b/docs/e2e-caddy-routing.md index 8d3b41f7..86e03291 100644 --- a/docs/e2e-caddy-routing.md +++ b/docs/e2e-caddy-routing.md @@ -4,17 +4,18 @@ | 환경 | 공개 주소·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입니다. | | 공개 개발 | `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 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 구현을 사용할 수 있지만 PostgreSQL, Redis, named volume, release queue와 active commit이 분리된 배포 환경입니다. 한 환경의 release/API/Chromium 결과를 다른 환경의 반영 근거로 사용하지 않습니다. 상위 `sam_rebuild` 작업공간에서는 `docs/docker-environment-routing.md`의 전체 결정 절차도 함께 따릅니다. +현재 기기가 운영 host `serv-2204`이면 SSH 없이 해당 stack 경로에서 작업합니다. 외부 Caddy는 E2E 호스트의 모든 경로를 `172.30.1.54:14999`로 전달하고 원래 `Host` header와 path prefix를 보존합니다. `handle_path`처럼 prefix를 제거하는 @@ -81,6 +82,23 @@ GAME_TRPC_PATH=/hwe/api/trpc 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 Gateway: diff --git a/docs/tournament-authorization.md b/docs/tournament-authorization.md new file mode 100644 index 00000000..1ba29ed7 --- /dev/null +++ b/docs/tournament-authorization.md @@ -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의 한계는 유지됩니다. diff --git a/packages/common/src/http/apiOrigin.ts b/packages/common/src/http/apiOrigin.ts new file mode 100644 index 00000000..5280dc62 --- /dev/null +++ b/packages/common/src/http/apiOrigin.ts @@ -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(); + 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 => { + 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(); + } + }; +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 4b7616c3..2dd93f49 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -33,6 +33,7 @@ export * from './game/accessPenalty.js'; export * from './http/trpcTransport.js'; export * from './http/safeLogger.js'; export * from './http/errorPrivacy.js'; +export * from './http/apiOrigin.js'; export * from './webPush/types.js'; export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js'; diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index 4f052cd1..aada6d63 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -307,6 +307,21 @@ export type TurnDaemonCommand = | { kind: 'nationPriority'; 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'; requestId?: string; @@ -701,6 +716,28 @@ export type TurnDaemonCommandResult = nationId?: number; 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'; ok: true; diff --git a/packages/common/test/apiOrigin.test.ts b/packages/common/test/apiOrigin.test.ts new file mode 100644 index 00000000..021da1a5 --- /dev/null +++ b/packages/common/test/apiOrigin.test.ts @@ -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 }); + }); +});