fix: 공개 API와 토너먼트의 사용자·장수 권한 경계를 강화한다
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 } }) => ({
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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의 한계는 유지됩니다.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user