fix: 공개 API와 토너먼트의 사용자·장수 권한 경계를 강화한다
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { parseNumberWithFallback } from '@sammo-ts/common';
|
||||
import { parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||
|
||||
const parseReconcileInterval = (value: string | undefined): number => {
|
||||
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
|
||||
@@ -10,6 +10,7 @@ const parseReconcileInterval = (value: string | undefined): number => {
|
||||
|
||||
export interface GameApiConfig {
|
||||
host: string;
|
||||
corsAllowedOrigins: string[];
|
||||
port: number;
|
||||
trpcPath: string;
|
||||
eventsPath: string;
|
||||
@@ -48,6 +49,10 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
||||
|
||||
return {
|
||||
host: env.GAME_API_HOST ?? '0.0.0.0',
|
||||
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [
|
||||
env.GATEWAY_PUBLIC_URL,
|
||||
env.GAME_PUBLIC_URL,
|
||||
]),
|
||||
port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
|
||||
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
|
||||
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import type { TournamentType } from '@sammo-ts/logic';
|
||||
import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
@@ -18,6 +18,16 @@ import {
|
||||
} from '../../services/redisClockFence.js';
|
||||
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
||||
|
||||
const assertTournamentAdjustment = (result: TurnDaemonCommandResult | null): void => {
|
||||
if (result?.type === 'commandRejected') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
||||
}
|
||||
if (!result || result.type !== 'tournamentAdjustGeneral') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected tournament response' });
|
||||
}
|
||||
if (!result.ok) throw new TRPCError({ code: result.code, message: result.reason });
|
||||
};
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||
return true;
|
||||
@@ -197,6 +207,7 @@ const zMatch = z.object({
|
||||
|
||||
const zBetEntry = z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
targetId: z.number().int().positive(),
|
||||
amount: z.number().int().positive(),
|
||||
});
|
||||
@@ -458,7 +469,7 @@ export const tournamentRouter = router({
|
||||
});
|
||||
}),
|
||||
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const [state, entries, matches] = await Promise.all([
|
||||
store.getState(),
|
||||
@@ -488,7 +499,7 @@ export const tournamentRouter = router({
|
||||
}
|
||||
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
|
||||
totalAmount += entry.amount;
|
||||
if (entry.generalId === general.id) {
|
||||
if (entry.userId === ctx.auth!.user.id) {
|
||||
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
|
||||
myAmount += entry.amount;
|
||||
}
|
||||
@@ -523,20 +534,15 @@ export const tournamentRouter = router({
|
||||
|
||||
const develCost = resolveCurrentDevelCost(worldState);
|
||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: -develCost,
|
||||
minGoldAfter: 0,
|
||||
});
|
||||
if (!feeResult || feeResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!feeResult.ok || feeResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
|
||||
});
|
||||
}
|
||||
assertTournamentAdjustment(feeResult);
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
@@ -558,12 +564,17 @@ export const tournamentRouter = router({
|
||||
try {
|
||||
await store.setParticipants(next);
|
||||
} catch (error) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentJoinRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||
});
|
||||
assertTournamentAdjustment(
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentJoinRollback',
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: develCost,
|
||||
minGoldAfter: 0,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true, count: next.length };
|
||||
@@ -663,8 +674,13 @@ export const tournamentRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
||||
}
|
||||
|
||||
// 구형 row의 당시 actor는 현재 장수 소유자로 추정할 수 없다.
|
||||
// 운영 전환은 베팅 종료 뒤 하거나 검증한 actor 정보를 관리자가 복원해야 한다.
|
||||
if (entries.some((entry) => entry.userId === undefined)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이전 베팅의 사용자 확인이 필요합니다.' });
|
||||
}
|
||||
const previousBetAmount = entries
|
||||
.filter((entry) => entry.generalId === general.id)
|
||||
.filter((entry) => entry.userId === ctx.auth!.user.id)
|
||||
.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
if (previousBetAmount + input.amount > 1_000) {
|
||||
throw new TRPCError({
|
||||
@@ -674,68 +690,37 @@ export const tournamentRouter = router({
|
||||
}
|
||||
|
||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: -input.amount,
|
||||
betGoldDelta: input.amount,
|
||||
minGoldAfter: 500,
|
||||
});
|
||||
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!adjustResult.ok || adjustResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [
|
||||
{
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: input.amount },
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
});
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '베팅 기록을 저장하지 못했습니다.' });
|
||||
}
|
||||
assertTournamentAdjustment(adjustResult);
|
||||
|
||||
try {
|
||||
await store.appendBettingEntry({
|
||||
generalId: general.id,
|
||||
userId: ctx.auth!.user.id,
|
||||
targetId: input.targetId,
|
||||
amount: input.amount,
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
assertTournamentAdjustment(
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
}),
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [
|
||||
{
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: -input.amount },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: input.amount,
|
||||
betGoldDelta: -input.amount,
|
||||
minGoldAfter: 0,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
@@ -76,13 +76,14 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
|
||||
const resolveAuthFromToken = async (
|
||||
token: string | null,
|
||||
accessTokenStore: RedisAccessTokenStore,
|
||||
flushStore: FlushStore
|
||||
flushStore: FlushStore,
|
||||
profileName: string
|
||||
): Promise<GameSessionTokenPayload | null> => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const stored = await accessTokenStore.get(token);
|
||||
if (!stored) {
|
||||
if (!stored || stored.profile !== profileName) {
|
||||
return null;
|
||||
}
|
||||
let flushedAt: Date | null;
|
||||
@@ -273,8 +274,9 @@ export const createGameApiServer = async () => {
|
||||
|
||||
app.addHook('onClose', closeResources);
|
||||
|
||||
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
origin: config.corsAllowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
@@ -290,7 +292,7 @@ export const createGameApiServer = async () => {
|
||||
...trpcJsonBodyHttpServerOptions,
|
||||
createContext: async ({ req }: { req: FastifyRequest }) => {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
|
||||
? req.headers['idempotency-key'][0]
|
||||
: req.headers['idempotency-key'];
|
||||
@@ -334,7 +336,7 @@ export const createGameApiServer = async () => {
|
||||
});
|
||||
|
||||
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||
if (
|
||||
!auth ||
|
||||
auth.profile !== config.profileName ||
|
||||
|
||||
@@ -31,6 +31,7 @@ export class CorruptTournamentProjectionError extends Error {
|
||||
const zTournamentBet = z
|
||||
.object({
|
||||
generalId: z.number().int(),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
targetId: z.number().int(),
|
||||
amount: z.number(),
|
||||
})
|
||||
@@ -286,7 +287,10 @@ export class TournamentStore {
|
||||
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
|
||||
const entries = await this.getBettingEntries();
|
||||
const existing = entries.find(
|
||||
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId
|
||||
(candidate) =>
|
||||
candidate.generalId === entry.generalId &&
|
||||
candidate.targetId === entry.targetId &&
|
||||
candidate.userId === entry.userId
|
||||
);
|
||||
if (existing) {
|
||||
existing.amount += entry.amount;
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface TournamentMatchEntry {
|
||||
|
||||
export interface TournamentBetEntry {
|
||||
generalId: number;
|
||||
// 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다.
|
||||
userId?: string | null;
|
||||
targetId: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
@@ -760,7 +760,7 @@ const buildNpcBettingPlan = async (options: {
|
||||
for (const npc of npcBetList) {
|
||||
const targetId = rng.choice(candidateIds);
|
||||
if (!existingBettors.has(npc.id as number)) {
|
||||
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
|
||||
entries.push({ generalId: npc.id as number, userId: null, targetId, amount: betGold });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
@@ -789,19 +789,24 @@ export const seedNpcBets = async (options: {
|
||||
}
|
||||
|
||||
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
|
||||
await daemonTransport.sendCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: `${requestPrefix}:resources`,
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })),
|
||||
});
|
||||
await daemonTransport.sendCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: `${requestPrefix}:meta`,
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
|
||||
// Wait for one durable ENGINE result before publishing entries or opening stage 6.
|
||||
// The engine skips NPCs whose owner changed after this deterministic plan was stored.
|
||||
const result = await daemonTransport.requestCommand({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
requestId: `${requestPrefix}:atomic`,
|
||||
bets: plan.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })),
|
||||
});
|
||||
if (!result || result.type !== 'tournamentSeedNpcBets' || !result.ok) {
|
||||
throw new Error('NPC tournament betting did not commit.');
|
||||
}
|
||||
const processed = new Set(result.processedGeneralIds);
|
||||
const existing = await store.getBettingEntries();
|
||||
const existingBettors = new Set(existing.map((entry) => entry.generalId));
|
||||
await store.setBettingEntries(existing.concat(plan.filter((entry) => !existingBettors.has(entry.generalId))));
|
||||
await store.setBettingEntries(
|
||||
existing.concat(
|
||||
plan
|
||||
.filter((entry) => processed.has(entry.generalId) && !existingBettors.has(entry.generalId))
|
||||
.map((entry) => ({ ...entry, userId: null }))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ import { recordGeneralActivity } from './services/generalActivity.js';
|
||||
const t = initTRPC.context<GameApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
|
||||
|
||||
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.auth) {
|
||||
if (!ctx.auth || ctx.auth.profile !== ctx.profile.name) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('general access tracking', () => {
|
||||
db,
|
||||
generalAccessTracking: true,
|
||||
requestId: 'access-boundary-test',
|
||||
profile: { id: 'che:default', name: 'che' },
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
profileStatusSource,
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token =
|
||||
auth: token,
|
||||
db: { $executeRaw: executeRaw },
|
||||
generalAccessTracking: true,
|
||||
profile: { id: 'che:default', name: 'che', scenario: 'default' },
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
const activityRouter = router({
|
||||
|
||||
@@ -31,6 +31,7 @@ const buildContext = (
|
||||
) =>
|
||||
({
|
||||
auth: options.auth === undefined ? auth : options.auth,
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
|
||||
@@ -36,6 +36,7 @@ type LogQuery = {
|
||||
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
|
||||
({
|
||||
auth,
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
|
||||
|
||||
@@ -20,7 +20,7 @@ const profile: GameProfile = {
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che',
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'ranking-session',
|
||||
@@ -239,7 +239,7 @@ const buildContext = (options?: {
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile: selectedProfile,
|
||||
auth: options?.authenticated === false ? null : auth,
|
||||
auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name },
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
|
||||
@@ -70,6 +70,7 @@ const envKeys = [
|
||||
'GATEWAY_REDIS_PREFIX',
|
||||
'GATEWAY_INTERNAL_API_URL',
|
||||
'GAME_UPLOAD_DIR',
|
||||
'API_ALLOWED_ORIGINS',
|
||||
] as const;
|
||||
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
@@ -642,6 +643,7 @@ integration('game API security over HTTP transport', () => {
|
||||
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
|
||||
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
|
||||
process.env.GAME_UPLOAD_DIR = uploadDir;
|
||||
process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net';
|
||||
|
||||
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
|
||||
await connector.connect();
|
||||
@@ -3774,6 +3776,58 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('rejects an untrusted browser Origin before an authenticated logout mutation', async () => {
|
||||
const token = await createAccessToken('origin-mutation', {});
|
||||
const response = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
origin: 'https://untrusted.example',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull();
|
||||
expect(await accessTokenStore.get(token)).not.toBeNull();
|
||||
for (const origin of ['https://sam.hided.net', 'https://untrusted.example', 'null']) {
|
||||
const preflight = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin,
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'authorization,content-type',
|
||||
},
|
||||
});
|
||||
expect(preflight.status).toBe(origin === 'https://sam.hided.net' ? 204 : 403);
|
||||
expect(preflight.headers.get('access-control-allow-origin')).toBe(
|
||||
origin === 'https://sam.hided.net' ? origin : null
|
||||
);
|
||||
}
|
||||
await accessTokenStore.revoke(token);
|
||||
});
|
||||
|
||||
it('rejects a same-namespace foreign-profile token over tRPC before any DB mutation', async () => {
|
||||
const foreign = await accessTokenStore.create({
|
||||
...buildPayload('trpc-foreign', {}),
|
||||
profile: 'other:default',
|
||||
});
|
||||
if (!foreign) throw new Error('Foreign token fixture failed');
|
||||
const before = await readReservedMutationState();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/trpc/turns.reserved.getGeneral?input=${encodeURIComponent(JSON.stringify({ generalId }))}`,
|
||||
{
|
||||
headers: { authorization: `Bearer ${foreign.accessToken}` },
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await readReservedMutationState()).toEqual(before);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(foreign.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE accepts header auth and rejects query or cross-profile credentials', async () => {
|
||||
const token = await createAccessToken('sse-query', {});
|
||||
const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`);
|
||||
|
||||
@@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport {
|
||||
|
||||
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
|
||||
this.commands.push(command);
|
||||
if (command.type === 'adjustGeneralResources') {
|
||||
const adjustment = command.adjustments[0];
|
||||
if (!adjustment) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||
if (command.type === 'tournamentAdjustGeneral') {
|
||||
if (command.betGoldDelta !== undefined && this.failNextRankUpdate) {
|
||||
this.failNextRankUpdate = false;
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' };
|
||||
}
|
||||
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0);
|
||||
if (nextGold < (adjustment.minGoldAfter ?? 0)) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
|
||||
const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta;
|
||||
if (nextGold < command.minGoldAfter) {
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' };
|
||||
}
|
||||
this.gold.set(adjustment.generalId, nextGold);
|
||||
return {
|
||||
type: 'adjustGeneralResources',
|
||||
ok: true,
|
||||
processed: 1,
|
||||
missing: 0,
|
||||
totalGoldDelta: adjustment.goldDelta ?? 0,
|
||||
totalRiceDelta: 0,
|
||||
};
|
||||
this.gold.set(command.generalId, nextGold);
|
||||
return { type: command.type, ok: true, generalId: command.generalId };
|
||||
}
|
||||
if (command.type === 'setMySetting') {
|
||||
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
||||
}
|
||||
if (command.type === 'adjustGeneralMeta') {
|
||||
if (this.failNextRankUpdate) {
|
||||
this.failNextRankUpdate = false;
|
||||
return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' };
|
||||
}
|
||||
return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => {
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
|
||||
expect(transport.gold.get(general.id)).toBe(1_800);
|
||||
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
||||
expect(transport.commands.filter((command) => command.type === 'tournamentAdjustGeneral')).toHaveLength(1);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: 'http:tournament-join:tournamentJoin:resources',
|
||||
reason: 'tournamentJoin',
|
||||
userId: 'user-1',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
@@ -459,9 +445,12 @@ describe('tournament router permissions and mutations', () => {
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
expect(transport.gold.get(general.id)).toBe(1_936);
|
||||
expect(transport.commands).toContainEqual({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
userId: 'user-1',
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }],
|
||||
generalId: general.id,
|
||||
goldDelta: -64,
|
||||
minGoldAfter: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -534,21 +523,96 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
|
||||
reason: 'tournamentBet',
|
||||
userId: 'user-1',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
|
||||
reason: 'tournamentBet',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
userId: 'user-1',
|
||||
betGoldDelta: 600,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the per-user cap across possession and separates a previous owner's bets", async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-2', 3_000);
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:betting',
|
||||
JSON.stringify([
|
||||
{ generalId: 1, userId: 'user-1', targetId: 11, amount: 900 },
|
||||
{ generalId: 99, userId: 'user-2', targetId: 12, amount: 800 },
|
||||
{ generalId: 98, userId: null, targetId: 11, amount: 100 },
|
||||
])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||
);
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||
myAmount: 800,
|
||||
myTotals: { 12: 800 },
|
||||
totalAmount: 1_800,
|
||||
});
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 300 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 200 })).resolves.toEqual({ ok: true });
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||
myAmount: 1_000,
|
||||
myTotals: { 11: 200, 12: 800 },
|
||||
});
|
||||
expect(transport.gold.get(1)).toBe(2_800);
|
||||
});
|
||||
|
||||
it('does not infer a legacy bet actor from the current general owner', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-2', 3_000);
|
||||
transport.gold.set(1, 3_000);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:betting',
|
||||
JSON.stringify([{ generalId: 1, targetId: 11, amount: 900 }])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||
);
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({ myAmount: 0, totalAmount: 900 });
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 10 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
expect(transport.gold.get(1)).toBe(3_000);
|
||||
});
|
||||
|
||||
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
@@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect(snapshot.sourceRevision).toBe('41');
|
||||
});
|
||||
|
||||
it('refunds gold when the tournament bet rank update fails', async () => {
|
||||
it('does not debit gold when the atomic tournament adjustment is rejected', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1', 2_000);
|
||||
@@ -818,7 +882,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
);
|
||||
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.gold.get(general.id)).toBe(2_000);
|
||||
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
|
||||
|
||||
@@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
|
||||
|
||||
const createNoopDaemonTransport = (): TurnDaemonTransport => ({
|
||||
sendCommand: async () => 'ok',
|
||||
requestCommand: async () => null,
|
||||
requestCommand: async (command) =>
|
||||
command.type === 'tournamentSeedNpcBets'
|
||||
? {
|
||||
type: command.type,
|
||||
ok: true,
|
||||
processedGeneralIds: command.bets.map((bet) => bet.generalId),
|
||||
skippedGeneralIds: [],
|
||||
}
|
||||
: null,
|
||||
requestStatus: async () => null,
|
||||
});
|
||||
|
||||
@@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => {
|
||||
const commands: TurnDaemonCommand[] = [];
|
||||
const daemonTransport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
sendCommand: async (command) => {
|
||||
requestCommand: async (command) => {
|
||||
expect((await store.getState())?.stage).toBe(5);
|
||||
commands.push(command);
|
||||
return 'ok';
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
};
|
||||
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
|
||||
@@ -455,21 +463,14 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: 'tournament:123:npc-bet:resources',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })),
|
||||
},
|
||||
{
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'tournament:123:npc-bet:meta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
requestId: 'tournament:123:npc-bet:atomic',
|
||||
bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })),
|
||||
},
|
||||
]);
|
||||
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
|
||||
expect(await store.getBettingEntries()).toEqual(bets);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toHaveLength(1);
|
||||
// A fresh projection with the same seed and DB inputs has the same choices.
|
||||
await store.setBettingEntries([bets[0]!]);
|
||||
await seedNpcBets({
|
||||
@@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(await store.getBettingEntries()).toEqual(bets);
|
||||
});
|
||||
|
||||
it('publishes only NPC bets acknowledged after the current-owner check', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-owned-after-plan'));
|
||||
const state = createTournamentState({
|
||||
stage: 5,
|
||||
bettingId: 789,
|
||||
npcBettingPlan: [
|
||||
{ generalId: 3, userId: null, targetId: 11, amount: 10 },
|
||||
{ generalId: 4, userId: null, targetId: 12, amount: 10 },
|
||||
],
|
||||
});
|
||||
await store.setState(state);
|
||||
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
|
||||
const transport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
requestCommand: async (command) => {
|
||||
expect(command.type).toBe('tournamentSeedNpcBets');
|
||||
expect((await store.getState())?.stage).toBe(5);
|
||||
expect(await store.getBettingEntries()).toEqual([]);
|
||||
return { type: 'tournamentSeedNpcBets', ok: true, processedGeneralIds: [4], skippedGeneralIds: [3] };
|
||||
},
|
||||
};
|
||||
const opened = await applyPreBattleStage(store, createPrismaMock({}), state, 'seed', transport);
|
||||
expect(opened.stage).toBe(6);
|
||||
expect(await store.getBettingEntries()).toEqual([{ generalId: 4, userId: null, targetId: 12, amount: 10 }]);
|
||||
});
|
||||
|
||||
it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
|
||||
const state = createTournamentState({ stage: 5 });
|
||||
@@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reuses durable command identities after a partial enqueue failure', async () => {
|
||||
it('reuses durable command identities after an uncertain atomic command response', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
|
||||
const state = createTournamentState({ stage: 5, bettingId: 456 });
|
||||
await store.setState(state);
|
||||
@@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => {
|
||||
let fail = true;
|
||||
const transport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
sendCommand: async (command) => {
|
||||
requestCommand: async (command) => {
|
||||
commands.push(command);
|
||||
if (command.type === 'adjustGeneralMeta' && fail) {
|
||||
if (command.type === 'tournamentSeedNpcBets' && fail) {
|
||||
fail = false;
|
||||
// The accepted debit may already have committed before retry.
|
||||
prisma.general.findMany = async () => [];
|
||||
throw new Error('enqueue unavailable');
|
||||
}
|
||||
return 'ok';
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
};
|
||||
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
|
||||
@@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => {
|
||||
);
|
||||
expect(await store.getState()).toMatchObject({
|
||||
stage: 5,
|
||||
npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }],
|
||||
npcBettingPlan: [{ generalId: 3, userId: null, targetId: expect.any(Number), amount: 10 }],
|
||||
});
|
||||
expect(await store.getBettingEntries()).toEqual([]);
|
||||
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
|
||||
expect(commands.slice(2)).toEqual(commands.slice(0, 2));
|
||||
expect(commands.slice(1)).toEqual(commands.slice(0, 1));
|
||||
expect(commands.map((command) => command.requestId)).toEqual([
|
||||
'tournament:456:npc-bet:resources',
|
||||
'tournament:456:npc-bet:meta',
|
||||
'tournament:456:npc-bet:resources',
|
||||
'tournament:456:npc-bet:meta',
|
||||
'tournament:456:npc-bet:atomic',
|
||||
'tournament:456:npc-bet:atomic',
|
||||
]);
|
||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||
});
|
||||
@@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => {
|
||||
commands.push(command);
|
||||
return 'ok';
|
||||
},
|
||||
requestCommand: async () => null,
|
||||
requestCommand: async (command) => {
|
||||
commands.push(command);
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
requestStatus: async () => null,
|
||||
};
|
||||
|
||||
@@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
|
||||
expect(commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: expect.arrayContaining([
|
||||
expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }),
|
||||
]),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
const claimCoordinate = await readInputEventClockCoordinate(transaction);
|
||||
const world = await transaction.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true, meta: true },
|
||||
select: {
|
||||
clockPhase: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
clockTick: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
|
||||
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
|
||||
@@ -184,7 +190,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
|
||||
OR (
|
||||
${suspendedTournamentBetCommand}
|
||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta', 'tournamentAdjustGeneral')
|
||||
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
|
||||
)
|
||||
OR (
|
||||
|
||||
@@ -269,6 +269,21 @@ const zSetNpcPolicy = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
const zTournamentAdjustGeneral = z.object({
|
||||
type: z.literal('tournamentAdjustGeneral'),
|
||||
reason: z.enum(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback']),
|
||||
userId: z.string().min(1),
|
||||
generalId: zSafeInteger.positive(),
|
||||
goldDelta: zFiniteNumber,
|
||||
betGoldDelta: zFiniteNumber.optional(),
|
||||
minGoldAfter: zFiniteNumber.nonnegative(),
|
||||
});
|
||||
|
||||
const zTournamentSeedNpcBets = z.object({
|
||||
type: z.literal('tournamentSeedNpcBets'),
|
||||
bets: z.array(z.object({ generalId: zSafeInteger.positive(), amount: zSafeInteger.positive() })).min(1),
|
||||
});
|
||||
|
||||
const zAdjustGeneralResources = z.object({
|
||||
type: z.literal('adjustGeneralResources'),
|
||||
reason: z.string().optional(),
|
||||
@@ -737,6 +752,16 @@ const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeTournamentAdjustGeneral: CommandNormalizer<'tournamentAdjustGeneral'> = (envelope) => {
|
||||
const command = parseWith(zTournamentAdjustGeneral, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
|
||||
const normalizeTournamentSeedNpcBets: CommandNormalizer<'tournamentSeedNpcBets'> = (envelope) => {
|
||||
const command = parseWith(zTournamentSeedNpcBets, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
|
||||
const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => {
|
||||
const command = parseWith(zAdjustGeneralResources, envelope.command);
|
||||
if (!command) {
|
||||
@@ -905,6 +930,8 @@ const normalizers: CommandNormalizerMap = {
|
||||
voteReward: normalizeVoteReward,
|
||||
setNationSetting: normalizeSetNationSetting,
|
||||
setNpcPolicy: normalizeSetNpcPolicy,
|
||||
tournamentAdjustGeneral: normalizeTournamentAdjustGeneral,
|
||||
tournamentSeedNpcBets: normalizeTournamentSeedNpcBets,
|
||||
adjustGeneralResources: normalizeAdjustGeneralResources,
|
||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||
|
||||
@@ -175,6 +175,7 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
||||
};
|
||||
|
||||
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
|
||||
'tournamentAdjustGeneral',
|
||||
'troopCreate',
|
||||
'troopJoin',
|
||||
'troopExit',
|
||||
@@ -627,11 +628,84 @@ async function handleSetNpcPolicy(
|
||||
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
|
||||
}
|
||||
|
||||
async function handleTournamentAdjustGeneral(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<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(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
if (isLegacyTournamentAdjustment(command.reason)) {
|
||||
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
|
||||
}
|
||||
if (!command.adjustments || command.adjustments.length === 0) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||
}
|
||||
@@ -688,6 +762,9 @@ async function handleAdjustGeneralMeta(
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
if (isLegacyTournamentAdjustment(command.reason)) {
|
||||
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
|
||||
}
|
||||
if (!command.adjustments || command.adjustments.length === 0) {
|
||||
return {
|
||||
type: 'adjustGeneralMeta',
|
||||
@@ -3267,6 +3344,13 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
|
||||
setNpcPolicy: (command) =>
|
||||
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) =>
|
||||
handleAdjustGeneralResources(
|
||||
ctx,
|
||||
|
||||
@@ -7,6 +7,16 @@ import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
||||
{
|
||||
type: 'tournamentAdjustGeneral',
|
||||
reason: 'tournamentBet',
|
||||
requestId: 'tournamentAdjustGeneral',
|
||||
userId,
|
||||
generalId: 7,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
},
|
||||
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
|
||||
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
|
||||
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
|
||||
|
||||
@@ -274,69 +274,79 @@ integration('database command queue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => {
|
||||
const requestId = 'integration:engine:stale-owner-replay';
|
||||
const command: TurnDaemonCommand = {
|
||||
type: 'vacation',
|
||||
requestId,
|
||||
userId: 'old-owner',
|
||||
generalId: 7,
|
||||
};
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
it.each(['vacation', 'tournamentAdjustGeneral'] as const)(
|
||||
'stores a stale-owner %s rejection once and never redispatches it',
|
||||
async (type) => {
|
||||
const requestId = `integration:engine:stale-owner-replay:${type}`;
|
||||
const command: TurnDaemonCommand =
|
||||
type === 'vacation'
|
||||
? { type, requestId, userId: 'old-owner', generalId: 7 }
|
||||
: {
|
||||
type,
|
||||
reason: 'tournamentBet',
|
||||
requestId,
|
||||
userId: 'old-owner',
|
||||
generalId: 7,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
};
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
actorUserId: command.userId,
|
||||
payload: command as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
|
||||
updateGeneral: mutation,
|
||||
updateNation: mutation,
|
||||
createTroop: mutation,
|
||||
updateTroop: mutation,
|
||||
removeTroop: mutation,
|
||||
pushLog: mutation,
|
||||
queueMessage: mutation,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const handle = vi.spyOn(handler, 'handle');
|
||||
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
||||
|
||||
const claimed = await owner.drain();
|
||||
expect(claimed).toEqual([
|
||||
{
|
||||
...command,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
|
||||
expect(result).toMatchObject({
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
});
|
||||
await owner.publishCommandResult(requestId, result!);
|
||||
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: command.userId,
|
||||
payload: command as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
|
||||
updateGeneral: mutation,
|
||||
updateNation: mutation,
|
||||
createTroop: mutation,
|
||||
updateTroop: mutation,
|
||||
removeTroop: mutation,
|
||||
pushLog: mutation,
|
||||
queueMessage: mutation,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const handle = vi.spyOn(handler, 'handle');
|
||||
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
||||
|
||||
const claimed = await owner.drain();
|
||||
expect(claimed).toEqual([
|
||||
{
|
||||
...command,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
|
||||
expect(result).toMatchObject({
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
});
|
||||
await owner.publishCommandResult(requestId, result!);
|
||||
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: command.userId,
|
||||
eventType: command.type,
|
||||
payload: command,
|
||||
result,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
});
|
||||
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
eventType: command.type,
|
||||
payload: command,
|
||||
result,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
});
|
||||
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
|
||||
'handles pre-opening user commands in %s without treating them as scheduled turns',
|
||||
@@ -630,6 +640,8 @@ integration('database command queue', () => {
|
||||
const metaId = 'integration:engine:suspended-tournament-bet-meta';
|
||||
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
|
||||
const unrelatedId = 'integration:engine:suspended-resource-adjustment';
|
||||
const atomicBetId = 'integration:engine:suspended-atomic-bet';
|
||||
const atomicJoinId = 'integration:engine:suspended-atomic-join';
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
@@ -679,6 +691,41 @@ integration('database command queue', () => {
|
||||
],
|
||||
});
|
||||
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
requestId: atomicBetId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'tournamentAdjustGeneral',
|
||||
actorUserId: 'user-7',
|
||||
payload: {
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicBetId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
reason: 'tournamentBet',
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: atomicJoinId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'tournamentAdjustGeneral',
|
||||
actorUserId: 'user-7',
|
||||
payload: {
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicJoinId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
reason: 'tournamentJoin',
|
||||
goldDelta: -200,
|
||||
minGoldAfter: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
await expect(queue.drain()).resolves.toEqual([
|
||||
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
|
||||
@@ -688,7 +735,15 @@ integration('database command queue', () => {
|
||||
requestId: rollbackId,
|
||||
reason: 'tournamentBetRollback',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: atomicBetId,
|
||||
reason: 'tournamentBet',
|
||||
}),
|
||||
]);
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: atomicJoinId } })).resolves.toMatchObject({
|
||||
status: 'PENDING',
|
||||
});
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
processingGameTick: 321n,
|
||||
|
||||
@@ -79,6 +79,83 @@ const buildWorld = (
|
||||
return new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
};
|
||||
|
||||
describe('tournament mutation ownership', () => {
|
||||
it('checks the current owner and commits gold plus bet statistics together', async () => {
|
||||
const general = { ...buildGeneral(1), userId: 'owner' };
|
||||
const world = buildWorld([general]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const command = {
|
||||
type: 'tournamentAdjustGeneral' as const,
|
||||
reason: 'tournamentBet' as const,
|
||||
userId: 'owner',
|
||||
generalId: 1,
|
||||
goldDelta: -100,
|
||||
betGoldDelta: 100,
|
||||
minGoldAfter: 500,
|
||||
};
|
||||
await expect(handler.handle(command)).resolves.toEqual({ type: command.type, ok: true, generalId: 1 });
|
||||
expect(world.getGeneralById(1)).toMatchObject({ gold: 900, meta: { betgold: 100 } });
|
||||
const before = structuredClone(world.getGeneralById(1));
|
||||
await expect(handler.handle({ ...command, goldDelta: -500 })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(before);
|
||||
world.updateGeneral(1, { userId: 'new-owner' });
|
||||
const transferred = structuredClone(world.getGeneralById(1));
|
||||
for (const goldDelta of [-100, 100]) {
|
||||
await expect(handler.handle({ ...command, goldDelta })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(transferred);
|
||||
}
|
||||
});
|
||||
|
||||
it('skips possessed NPCs and commits only eligible NPC fees and statistics', async () => {
|
||||
const world = buildWorld([
|
||||
{ ...buildGeneral(1, {}, 1), userId: 'new-owner' },
|
||||
buildGeneral(2, {}, 2),
|
||||
{ ...buildGeneral(3, {}, 2), gold: 509 },
|
||||
]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const possessed = structuredClone(world.getGeneralById(1));
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: [1, 2, 3].map((generalId) => ({ generalId, amount: 10 })),
|
||||
})
|
||||
).resolves.toEqual({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
ok: true,
|
||||
processedGeneralIds: [2],
|
||||
skippedGeneralIds: [1, 3],
|
||||
});
|
||||
expect(world.getGeneralById(1)).toEqual(possessed);
|
||||
expect(world.getGeneralById(2)).toMatchObject({ gold: 990, meta: { betgold: 10 } });
|
||||
expect(world.getGeneralById(3)).toMatchObject({ gold: 509, meta: { killturn: 24 } });
|
||||
});
|
||||
|
||||
it.each(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'])(
|
||||
'rejects actorless legacy %s commands before mutation',
|
||||
async (reason) => {
|
||||
const world = buildWorld([{ ...buildGeneral(1), userId: 'new-owner' }]);
|
||||
const before = structuredClone(world.getGeneralById(1));
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
for (const command of [
|
||||
{ type: 'adjustGeneralResources' as const, reason, adjustments: [{ generalId: 1, goldDelta: -100 }] },
|
||||
{
|
||||
type: 'adjustGeneralMeta' as const,
|
||||
reason,
|
||||
adjustments: [{ generalId: 1, metaDelta: { betgold: 100 } }],
|
||||
},
|
||||
])
|
||||
await expect(handler.handle(command)).resolves.toMatchObject({ ok: false });
|
||||
expect(world.getGeneralById(1)).toEqual(before);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('tournament world commands', () => {
|
||||
it('uses the current world develcost for Ref-compatible tournament prizes', () => {
|
||||
const currentWorld = buildWorld([], {
|
||||
@@ -187,7 +264,7 @@ describe('tournament world commands', () => {
|
||||
});
|
||||
|
||||
it('records all four tournament types and NPC betting for at least ten generals', async () => {
|
||||
const generals = Array.from({ length: 12 }, (_, index) => buildGeneral(index + 1));
|
||||
const generals = Array.from({ length: 12 }, (_, index) => ({ ...buildGeneral(index + 1, {}, 2), gold: 2_000 }));
|
||||
const world = buildWorld(generals);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
@@ -204,13 +281,11 @@ describe('tournament world commands', () => {
|
||||
}
|
||||
}
|
||||
await handler.handle({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: generals.map((general) => ({
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: 1_000 },
|
||||
})),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: generals.map((general) => ({ generalId: general.id, amount: 1_000 })),
|
||||
});
|
||||
// Ref의 betwin 통계 대상은 npcState <= 1이다. 개방 시 NPC 비용 검증과 분리한다.
|
||||
for (const general of generals) world.updateGeneral(general.id, { npcState: 0 });
|
||||
await handler.handle({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common';
|
||||
import { parseBooleanWithFallback, parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||
import { resolveFrontendServeMode, type FrontendServeMode } from './orchestrator/frontendArtifactManager.js';
|
||||
|
||||
export interface GatewayApiConfig {
|
||||
@@ -19,6 +19,7 @@ export interface GatewayApiConfig {
|
||||
kakaoAdminKey?: string;
|
||||
kakaoRedirectUri: string;
|
||||
publicBaseUrl: string;
|
||||
corsAllowedOrigins: string[];
|
||||
userIconDir: string;
|
||||
userIconPublicUrl: string;
|
||||
imageUploadBaseUrl: string;
|
||||
@@ -129,6 +130,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
||||
kakaoRedirectUri,
|
||||
publicBaseUrl,
|
||||
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [publicBaseUrl]),
|
||||
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
|
||||
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? 'https://sam-image.hided.net/icons',
|
||||
imageUploadBaseUrl: env.GATEWAY_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
@@ -125,8 +125,9 @@ export const createGatewayApiServer = async () => {
|
||||
(error) => app.log.error({ err: error }, 'web push delivery failed')
|
||||
);
|
||||
|
||||
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
origin: config.corsAllowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true });
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
|
||||
import { trpcJsonBodyHttpServerOptions, createApiOriginGuard, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||
@@ -93,6 +94,9 @@ const createHarness = async (adminRoles = ['user', 'admin.users.manage', 'admin.
|
||||
const targetSession = await sessions.createSession(target);
|
||||
const flushes: Array<{ userId: string; reason?: string }> = [];
|
||||
const app = fastify({ logger: false });
|
||||
const origins = resolveApiAllowedOrigins(undefined, ['https://sam.hided.net/gateway/']);
|
||||
app.addHook('onRequest', createApiOriginGuard(origins));
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
@@ -176,6 +180,29 @@ const postTrpc = async (
|
||||
};
|
||||
|
||||
describe('admin security over HTTP transport', () => {
|
||||
it('rejects an untrusted browser origin before a valid session can log out', async () => {
|
||||
const harness = await createHarness();
|
||||
for (const origin of ['https://untrusted.example', 'null']) {
|
||||
const response = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-session-token': harness.adminSessionToken, origin },
|
||||
body: JSON.stringify({ sessionToken: harness.adminSessionToken }),
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull();
|
||||
expect((await postTrpc(harness.baseUrl, 'me', null, harness.adminSessionToken)).response.status).toBe(200);
|
||||
}
|
||||
const allowed = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: 'https://sam.hided.net',
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'x-session-token,content-type',
|
||||
},
|
||||
});
|
||||
expect(allowed.status).toBe(204);
|
||||
expect(allowed.headers.get('access-control-allow-origin')).toBe('https://sam.hided.net');
|
||||
});
|
||||
it('protects runtime diagnostics at the HTTP authentication and profile scope boundaries', async () => {
|
||||
const harness = await createHarness(['admin.profiles.runtime:che:default']);
|
||||
const input = { profileName: 'che:default' };
|
||||
|
||||
Reference in New Issue
Block a user